From a64a1f1933f484a4b16ca1e9f6317d8afa5b0cd0 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Sat, 30 May 2026 00:57:38 +0200 Subject: [PATCH] refactor: everything --- .github/workflows/code-quality.yml | 8 + .github/workflows/test.yml | 2 +- .gitignore | 1 + MIGRATION.md | 1452 ++++++++++++ PORTING.md | 214 ++ REFACTOR.md | 115 +- REFACTOR_PROGRESS.md | 47 - REFACTOR_SLICES.json | 1707 -------------- apps/code/.storybook/main.ts | 7 +- apps/code/.storybook/preview.tsx | 2 +- apps/code/package.json | 78 +- apps/code/src/main/deep-links.ts | 2 +- apps/code/src/main/di/container.ts | 719 +++++- .../src/main/di/platform-identifiers.test.ts | 77 + apps/code/src/main/di/tokens.ts | 118 +- apps/code/src/main/index.ts | 186 +- apps/code/src/main/menu.ts | 24 +- .../platform-adapters/electron-app-meta.ts | 8 + .../main/platform-adapters/electron-crypto.ts | 14 + .../platform-adapters/electron-notifier.ts | 4 +- .../platform-adapters/electron-updater.ts | 1 + .../electron-usage-threshold-store.ts | 31 + .../electron-workspace-settings.ts | 62 + .../posthog-analytics.test.ts | 26 +- .../platform-adapters/posthog-analytics.ts | 102 + apps/code/src/main/protocols/mcp-sandbox.ts | 6 +- .../services/app-lifecycle/service.test.ts | 32 +- .../main/services/app-lifecycle/service.ts | 57 +- .../src/main/services/auth/port-adapters.ts | 157 ++ .../src/main/services/deep-link/service.ts | 23 +- .../main/services/encryption/service.test.ts | 49 + .../src/main/services/encryption/service.ts | 50 + .../src/main/services/file-watcher/bridge.ts | 36 - .../main/services/focus/desktop-adapters.ts | 45 + apps/code/src/main/services/focus/schemas.ts | 50 - apps/code/src/main/services/focus/service.ts | 198 -- apps/code/src/main/services/fs/schemas.ts | 71 - .../code/src/main/services/fs/service.test.ts | 64 - apps/code/src/main/services/fs/service.ts | 306 --- .../src/main/services/git/service.test.ts | 539 ----- apps/code/src/main/services/git/service.ts | 2048 ----------------- .../services/github-integration/schemas.ts | 8 - .../src/main/services/handoff/service.test.ts | 191 -- .../code/src/main/services/handoff/service.ts | 485 ---- apps/code/src/main/services/index.ts | 2 - .../services/linear-integration/schemas.ts | 8 - .../src/main/services/mcp-callback/service.ts | 294 --- .../src/main/services/posthog-analytics.ts | 102 - .../services/secure-store/service.test.ts | 67 + .../src/main/services/secure-store/service.ts | 70 + apps/code/src/main/services/settingsStore.ts | 8 + .../src/main/services/shell/service.test.ts | 525 ----- .../services/slack-integration/schemas.ts | 8 - .../src/main/services/usage-monitor/store.ts | 17 - .../main/services/workspace-server/service.ts | 2 +- apps/code/src/main/trpc/router.ts | 79 +- .../trpc/routers/additional-directories.ts | 54 - apps/code/src/main/trpc/routers/agent.ts | 215 -- apps/code/src/main/trpc/routers/analytics.ts | 38 - apps/code/src/main/trpc/routers/archive.ts | 44 - apps/code/src/main/trpc/routers/auth.ts | 67 - apps/code/src/main/trpc/routers/cloud-task.ts | 55 - .../src/main/trpc/routers/connectivity.ts | 38 - .../src/main/trpc/routers/context-menu.ts | 79 - apps/code/src/main/trpc/routers/deep-link.ts | 102 - apps/code/src/main/trpc/routers/encryption.ts | 51 +- .../code/src/main/trpc/routers/environment.ts | 51 - .../src/main/trpc/routers/external-apps.ts | 40 - .../src/main/trpc/routers/file-watcher.ts | 20 - apps/code/src/main/trpc/routers/focus.ts | 173 -- apps/code/src/main/trpc/routers/folders.ts | 60 - apps/code/src/main/trpc/routers/fs.ts | 81 - apps/code/src/main/trpc/routers/git.ts | 475 ---- .../main/trpc/routers/github-integration.ts | 61 - .../main/trpc/routers/linear-integration.ts | 20 - .../code/src/main/trpc/routers/llm-gateway.ts | 25 - apps/code/src/main/trpc/routers/logs.ts | 49 - .../src/main/trpc/routers/notification.ts | 25 - apps/code/src/main/trpc/routers/oauth.ts | 13 - apps/code/src/main/trpc/routers/os.ts | 401 ---- .../src/main/trpc/routers/process-tracking.ts | 52 - .../src/main/trpc/routers/provisioning.ts | 21 - .../src/main/trpc/routers/secure-store.ts | 62 - apps/code/src/main/trpc/routers/shell.ts | 82 - apps/code/src/main/trpc/routers/skills.ts | 46 - .../main/trpc/routers/slack-integration.ts | 62 - apps/code/src/main/trpc/routers/sleep.ts | 19 - apps/code/src/main/trpc/routers/suspension.ts | 49 - apps/code/src/main/trpc/routers/updates.ts | 51 - apps/code/src/main/trpc/routers/workspace.ts | 231 -- apps/code/src/main/trpc/trpc.ts | 18 +- apps/code/src/main/utils/async.ts | 26 +- apps/code/src/main/utils/logger.ts | 2 - .../src/main/utils/typed-event-emitter.ts | 38 - apps/code/src/main/utils/worktree-helpers.ts | 18 - apps/code/src/main/window.ts | 9 +- apps/code/src/renderer/App.tsx | 325 --- .../code/src/renderer/assets/fonts/Halfre.otf | Bin 20168 -> 0 bytes apps/code/src/renderer/assets/images/code.svg | 66 - .../assets/images/hedgehogs/clickthat-hog.png | Bin 131628 -> 0 bytes .../assets/images/hedgehogs/detective-hog.png | Bin 100794 -> 0 bytes .../images/hedgehogs/feature-flag-hog.png | Bin 36683 -> 0 bytes .../assets/images/hedgehogs/graphs-hog.png | Bin 261524 -> 0 bytes .../renderer/assets/images/wordmark-white.svg | 22 - .../src/renderer/assets/images/wordmark.svg | 22 - .../components/ActionSelector.stories.tsx | 100 - .../renderer/components/DraggableTitleBar.tsx | 17 - .../src/renderer/components/HedgehogMode.tsx | 87 - .../src/renderer/components/Providers.tsx | 21 +- .../components/ui/collapsible/collapsible.css | 29 - .../contributions/app-boot.contributions.ts | 37 + .../src/renderer/desktop-contributions.ts | 48 +- apps/code/src/renderer/desktop-services.ts | 221 ++ apps/code/src/renderer/di/container.ts | 327 ++- apps/code/src/renderer/di/tokens.ts | 5 +- .../features/auth/hooks/authClient.ts | 64 - .../features/auth/hooks/authMutations.ts | 92 - .../features/auth/hooks/authQueries.ts | 105 - .../features/auth/hooks/useOAuthFlow.ts | 68 - .../features/auth/stores/authStore.test.ts | 220 -- .../features/auth/stores/authStore.ts | 273 --- .../billing/hooks/useSpendAnalysis.ts | 47 - .../features/billing/hooks/useUsage.ts | 39 - .../features/billing/stores/seatStore.test.ts | 367 --- .../features/billing/stores/seatStore.ts | 259 --- .../code-editor/hooks/useFileEnrichment.ts | 46 - .../code-review/components/ReviewShell.tsx | 593 ----- .../hooks/useTaskDiffSummaryStats.ts | 63 - .../features/code-review/reviewHost.tsx | 13 + .../command-center/hooks/useAvailableTasks.ts | 23 - .../hooks/useCommandCenterData.ts | 116 - .../connectivity/connectivityToast.ts | 52 - .../external-apps/hooks/useExternalApps.ts | 71 - .../features/folders/hooks/useFolders.ts | 143 -- .../hooks/useGitInteraction.ts | 663 ------ .../git-interaction/hooks/usePrActions.ts | 42 - .../git-interaction/utils/deriveBranchName.ts | 15 - .../utils/getSuggestedBranchName.ts | 33 - .../git-interaction/utils/gitCacheKeys.ts | 44 - .../utils/partitionByStaged.ts | 14 - .../features/inbox/hooks/useCreatePrReport.ts | 173 -- .../features/inbox/hooks/useDiscussReport.ts | 178 -- .../inbox/hooks/useInboxBulkActions.ts | 404 ---- .../features/inbox/hooks/useReportTasks.ts | 63 - .../inbox/hooks/useSignalSourceManager.ts | 599 ----- .../inbox/stores/inboxCloudTaskStore.ts | 100 - .../inbox/utils/buildDiscussReportPrompt.ts | 19 - .../features/inbox/utils/inboxSort.ts | 43 - .../inbox/utils/resolveDefaultModel.ts | 34 - .../mcp-servers/components/parts/icons.tsx | 114 - .../components/parts/statusBadge.ts | 26 - .../suggestions/getSuggestions.ts | 175 -- .../onboarding/components/ProjectSelect.tsx | 137 -- .../onboarding/hooks/useOnboardingFlow.ts | 163 -- .../src/renderer/features/onboarding/types.ts | 16 - .../src/renderer/features/panels/index.ts | 22 - .../features/panels/store/panelLayoutStore.ts | 913 -------- .../features/panels/store/panelStore.ts | 270 --- .../features/panels/utils/panelLayoutUtils.ts | 20 - .../components/ProvisioningView.tsx | 82 - .../sessions/hooks/useSessionConnection.ts | 190 -- .../sessions/service/localHandoffService.ts | 123 - .../sessions/utils/cloudArtifacts.test.ts | 98 - .../features/sessions/utils/cloudArtifacts.ts | 409 ---- .../sessions/utils/parseSessionLogs.ts | 224 -- .../components/sections/ShortcutsSettings.tsx | 5 - .../features/setup/hooks/useSetupDiscovery.ts | 28 - .../setup/services/setupRunService.ts | 656 ------ .../features/setup/stores/setupStore.ts | 387 ---- .../features/sidebar/components/index.tsx | 2 - .../features/sidebar/hooks/usePinnedTasks.ts | 100 - .../features/sidebar/hooks/useSidebarData.ts | 365 --- .../features/sidebar/hooks/useTaskPrStatus.ts | 34 - .../features/sidebar/hooks/useTaskViewed.ts | 172 -- .../suspension/hooks/useSuspendTask.ts | 59 - .../suspension/hooks/useSuspensionSettings.ts | 26 - .../task-detail/components/RunModeSelect.tsx | 65 - .../task-detail/hooks/useCloudRunState.ts | 48 - .../task-detail/hooks/usePreviewConfig.ts | 272 --- .../features/task-detail/service/service.ts | 205 -- .../features/tasks/hooks/useArchiveTask.ts | 158 -- .../renderer/features/tasks/hooks/useTasks.ts | 393 ---- .../features/tasks/stores/taskStore.ts | 165 -- .../features/tour/stores/tourStore.ts | 139 -- .../features/tour/tours/tourRegistry.ts | 6 - .../features/workspace/hooks/index.ts | 1 - .../workspace/hooks/useLocalRepoPath.ts | 17 - .../features/workspace/hooks/useWorkspace.ts | 193 -- .../renderer/hooks/useAuthenticatedClient.ts | 5 - .../src/renderer/hooks/useConnectivity.ts | 9 - .../code/src/renderer/hooks/useFeatureFlag.ts | 23 - .../code/src/renderer/hooks/useFileWatcher.ts | 68 - .../src/renderer/hooks/useNewTaskDeepLink.ts | 194 -- .../renderer/hooks/useRepositoryDirectory.ts | 30 - apps/code/src/renderer/main.tsx | 19 +- .../platform-adapters/auth-side-effects.ts | 46 + .../platform-adapters/git-cache-keys.ts | 24 + .../platform-adapters/hedgehog-mode-host.ts | 35 + .../src/renderer/platform-adapters/setup.ts | 41 + .../platform-adapters/task-deletion.ts | 43 + .../src/renderer/platform-adapters/tour.ts | 10 + .../src/renderer/platform-adapters/updates.ts | 121 + apps/code/src/renderer/services/.gitkeep | 0 apps/code/src/renderer/stores/cloneStore.ts | 142 -- .../src/renderer/stores/connectivityStore.ts | 70 - apps/code/src/renderer/stores/focusStore.ts | 145 -- .../src/renderer/stores/settingsStore.test.ts | 56 - .../code/src/renderer/stores/settingsStore.ts | 42 - .../src/renderer/stores/updateStore.test.ts | 225 -- apps/code/src/renderer/stores/updateStore.ts | 197 -- apps/code/src/renderer/trpc/client.ts | 5 + apps/code/src/renderer/types/rehype.d.ts | 13 - apps/code/src/renderer/utils/clearStorage.ts | 23 - .../src/renderer/utils/electronStorage.ts | 18 +- apps/code/src/renderer/utils/generateTitle.ts | 146 -- apps/code/src/renderer/utils/getFilePath.ts | 13 - .../utils/handleExternalAppAction.tsx | 124 - apps/code/src/renderer/utils/logger.ts | 8 +- .../src/renderer/utils/notifications.test.ts | 175 -- apps/code/src/renderer/utils/notifications.ts | 117 - apps/code/src/renderer/utils/object.ts | 7 - .../src/renderer/utils/queryClient.test.ts | 2 +- apps/code/src/renderer/utils/queryClient.ts | 2 +- apps/code/src/shared/constants.ts | 7 - apps/code/src/shared/constants/environment.ts | 1 - apps/code/src/shared/deeplink.ts | 60 - apps/code/src/shared/mcp-sandbox-proxy.ts | 23 - apps/code/src/shared/test/loggerMock.ts | 14 - apps/code/src/shared/types/posthog.ts | 4 - apps/code/src/shared/types/suspension.ts | 30 - apps/code/src/shared/utils/id.ts | 11 - apps/code/vite.main.config.mts | 5 +- apps/code/vite.shared.mts | 16 + apps/code/vitest.config.ts | 19 +- apps/web/dev-server.mjs | 68 + apps/web/index.html | 12 + apps/web/package.json | 37 + apps/web/src/Providers.tsx | 25 + apps/web/src/main.tsx | 24 + apps/web/src/web-auth-side-effects.ts | 38 + apps/web/src/web-container.ts | 82 + apps/web/src/web-trpc.ts | 23 + apps/web/tsconfig.json | 23 + apps/web/vite.config.ts | 35 + biome.jsonc | 8 +- knip.json | 78 +- package.json | 1 + packages/agent/package.json | 4 + packages/agent/src/types.ts | 103 +- packages/agent/tsup.config.ts | 1 + packages/api-client/package.json | 6 +- .../api-client/src/posthog-client.test.ts | 2 +- .../api-client/src/posthog-client.ts | 197 +- .../api-client/src}/spend-analysis.ts | 0 packages/api-client/src/types.ts | 10 + packages/api-client/tsconfig.json | 3 + packages/core/package.json | 20 +- packages/core/src/archive/archive.module.ts | 11 + .../core/src/archive/archiveListView.test.ts | 108 + packages/core/src/archive/archiveListView.ts | 98 + .../src/archive/archiveOrchestration.test.ts | 113 + .../core/src/archive/archiveOrchestration.ts | 140 ++ .../archive/archivedTasksController.test.ts | 156 ++ .../src/archive/archivedTasksController.ts | 73 + packages/core/src/archive/identifiers.ts | 18 + .../core/src/archive/optimisticArchive.ts | 52 + .../src/archive/parseUnarchiveError.test.ts | 24 + .../core/src/archive/parseUnarchiveError.ts | 14 + .../core/src/archive/unarchiveService.test.ts | 121 + packages/core/src/archive/unarchiveService.ts | 77 + packages/core/src/auth/auth.module.ts | 9 + .../core/src/auth/auth.test.ts | 237 +- .../core/src/auth/auth.ts | 143 +- packages/core/src/auth/authErrors.ts | 27 + packages/core/src/auth/authIdentity.ts | 8 + packages/core/src/auth/identifiers.ts | 110 + .../core/src/auth/oauth.schemas.ts | 2 +- .../core/src}/auth/schemas.ts | 2 +- .../core/src/auth}/userInitials.test.ts | 0 .../core/src/auth}/userInitials.ts | 0 packages/core/src/billing/billing.module.ts | 8 + packages/core/src/billing/identifiers.ts | 27 + packages/core/src/billing/seatErrors.ts | 24 + packages/core/src/billing/seatService.test.ts | 280 +++ packages/core/src/billing/seatService.ts | 174 ++ packages/core/src/billing/seatView.test.ts | 55 + .../core/src/billing/seatView.ts | 29 +- .../core/src/billing}/spendAnalysisFormat.ts | 14 +- .../src/billing}/spendAnalysisPrompt.test.ts | 2 +- .../core/src/billing}/spendAnalysisPrompt.ts | 8 +- .../core/src/billing/spendAnalysisTypes.ts | 43 + packages/core/src/billing/spendSuggestions.ts | 44 + .../core/src/billing/usageDisplay.test.ts | 4 +- .../core/src/billing/usageDisplay.ts | 2 +- packages/core/src/clone/cloneProgress.test.ts | 35 + packages/core/src/clone/cloneProgress.ts | 20 + .../core/src/clone/cloneRemovalDelay.test.ts | 16 + packages/core/src/clone/cloneRemovalDelay.ts | 10 + .../core/src/clone/cloneSelectors.test.ts | 38 + packages/core/src/clone/cloneSelectors.ts | 19 + packages/core/src/clone/cloneTypes.ts | 22 + .../core/src/cloud-task/cloud-task-types.ts | 67 + .../core/src/cloud-task/cloud-task.module.ts | 7 + .../core/src/cloud-task/cloud-task.test.ts | 30 +- .../core/src/cloud-task/cloud-task.ts | 95 +- packages/core/src/cloud-task/identifiers.ts | 6 + .../core/src}/cloud-task/schemas.ts | 21 +- .../core/src}/cloud-task/sse-parser.test.ts | 0 .../core/src}/cloud-task/sse-parser.ts | 13 +- .../buildEnrichmentOccurrences.test.ts | 68 + .../code-editor/buildEnrichmentOccurrences.ts | 52 + .../code-editor/enrichmentEligibility.test.ts | 25 + .../src/code-editor/enrichmentEligibility.ts | 13 + .../src/code-editor/enrichmentPresenters.ts | 37 + .../core/src/code-editor/fileKind.ts | 0 .../core/src/code-editor/fileSource.test.ts | 91 + packages/core/src/code-editor/fileSource.ts | 93 + .../core/src/code-editor}/pathUtils.ts | 0 .../buildToolCallFallbacks.test.ts | 29 + .../src/code-review/buildToolCallFallbacks.ts | 18 + .../src/code-review/code-review.module.ts | 7 + .../core/src/code-review}/contentHash.ts | 0 .../src/code-review/diffAnnotations.test.ts | 82 + .../core/src/code-review}/diffAnnotations.ts | 3 +- .../code-review}/fileDiffExpansion.test.ts | 0 .../src/code-review}/fileDiffExpansion.ts | 0 packages/core/src/code-review/identifiers.ts | 4 + .../code-review/prCommentAnnotations.test.ts | 56 + .../src/code-review}/prCommentAnnotations.ts | 11 +- .../code-review}/resolveDiffSource.test.ts | 0 .../src/code-review}/resolveDiffSource.ts | 4 +- packages/core/src/code-review/revertHunk.ts | 15 + .../src/code-review/revertHunkService.test.ts | 183 ++ .../core/src/code-review/revertHunkService.ts | 86 + .../src/code-review/reviewItemKeys.test.ts | 32 + .../core/src/code-review/reviewItemKeys.ts | 15 + .../src/code-review/reviewPrompts.test.ts | 84 + .../core/src/code-review}/reviewPrompts.ts | 4 +- .../code-review/reviewShellGeometry.test.ts | 56 + .../src/code-review/reviewShellGeometry.ts | 47 + .../code-review/selectTaskDiffStats.test.ts | 86 + .../src/code-review/selectTaskDiffStats.ts | 50 + .../core/src}/code-review/types.ts | 44 +- .../core/src/command-center/autofill.test.ts | 151 ++ packages/core/src/command-center/autofill.ts | 37 + packages/core/src/command-center/cells.ts | 43 + .../src/command-center/eligibility.test.ts | 28 + .../core/src/command-center/eligibility.ts | 33 + packages/core/src/command-center/grid.test.ts | 58 + packages/core/src/command-center/grid.ts | 37 + .../core/src/command-center/status.test.ts | 90 + packages/core/src/command-center/status.ts | 59 + .../core/src/command-center/stopAll.test.ts | 25 + packages/core/src/command-center/stopAll.ts | 16 + .../src/connectivity/connectivityStore.ts | 13 + .../src/context-menu/context-menu.module.ts | 7 + .../src/context-menu/context-menu.test.ts | 188 ++ .../core/src/context-menu/context-menu.ts | 31 +- packages/core/src/context-menu/identifiers.ts | 18 + .../core/src}/context-menu/schemas.ts | 3 + .../core/src}/context-menu/types.ts | 0 .../core/src/deep-links/deep-links.module.ts | 7 + packages/core/src/deep-links/identifiers.ts | 66 + .../deep-links/newTaskLinkResolver.test.ts | 147 ++ .../src/deep-links/newTaskLinkResolver.ts | 148 ++ .../core/src/editor}/cloud-prompt.test.ts | 90 +- .../core/src/editor}/cloud-prompt.ts | 29 +- .../core/src/editor}/prompt-builder.ts | 2 +- .../src/external-apps/external-apps.module.ts | 7 + .../external-apps/externalAppService.test.ts | 154 ++ .../src/external-apps/externalAppService.ts | 148 ++ .../core/src/external-apps/identifiers.ts | 45 + packages/core/src/focus/focus-host.module.ts | 8 + packages/core/src/focus/focus-service.ts | 186 ++ packages/core/src/focus/host-focus.ts | 26 + packages/core/src/focus/identifiers.ts | 107 + packages/core/src/focus/service.test.ts | 339 +++ packages/core/src/focus/service.ts | 4 +- .../git-interaction}/branchCreation.test.ts | 58 +- .../src/git-interaction}/branchCreation.ts | 20 +- .../src/git-interaction/branchName.test.ts | 29 +- .../core/src/git-interaction/branchName.ts | 40 +- .../git-interaction}/deriveBranchName.test.ts | 2 +- .../core/src/git-interaction}/diffStats.ts | 15 +- .../core/src/git-interaction}/errorPrompts.ts | 2 +- .../git-interaction/git-interaction.module.ts | 7 + .../gitInteractionLogic.test.ts | 0 .../git-interaction}/gitInteractionLogic.ts | 8 +- .../gitInteractionService.test.ts | 321 +++ .../git-interaction/gitInteractionService.ts | 464 ++++ .../src/git-interaction}/gitStatusUtils.ts | 2 +- .../core/src/git-interaction/identifiers.ts | 7 + .../core/src/git-interaction/prStatus.ts | 35 +- .../src/git-interaction/stagingPlan.test.ts | 90 + .../core/src/git-interaction/stagingPlan.ts | 47 + .../core/src}/git-interaction/types.ts | 0 .../core/src/git-pr/create-pr-saga.test.ts | 67 + .../core/src/git-pr}/create-pr-saga.ts | 56 +- packages/core/src/git-pr/git-pr.module.ts | 7 + packages/core/src/git-pr/git-pr.test.ts | 207 ++ packages/core/src/git-pr/git-pr.ts | 305 +++ packages/core/src/git-pr/identifiers.ts | 104 + packages/core/src/git/git-host.module.ts | 8 + packages/core/src/git/git-host.ts | 276 +++ packages/core/src/git/host-git.ts | 61 + packages/core/src/git/identifiers.ts | 8 + .../core/src/git/router-schemas.ts | 142 +- .../core/src}/handoff/handoff-saga.test.ts | 121 +- .../core/src}/handoff/handoff-saga.ts | 66 +- .../handoff/handoff-to-cloud-saga.test.ts | 4 +- .../src}/handoff/handoff-to-cloud-saga.ts | 24 +- packages/core/src/handoff/handoff.module.ts | 7 + packages/core/src/handoff/handoff.test.ts | 125 + packages/core/src/handoff/handoff.ts | 269 +++ packages/core/src/handoff/identifiers.ts | 2 + .../core/src}/handoff/schemas.ts | 31 +- packages/core/src/handoff/types.ts | 37 + .../inbox}/buildCreatePrReportPrompt.test.ts | 2 +- .../inbox}/buildDiscussReportPrompt.test.ts | 2 +- .../core/src/inbox/bulkActionService.test.ts | 57 + packages/core/src/inbox/bulkActionService.ts | 57 + packages/core/src/inbox/bulkActions.ts | 177 ++ packages/core/src/inbox/dataSourceService.ts | 144 ++ packages/core/src/inbox/engagement.ts | 84 + packages/core/src/inbox/identifiers.ts | 29 + packages/core/src/inbox/inbox.module.ts | 25 + .../core/src/inbox/reportActionEvents.test.ts | 87 + packages/core/src/inbox/reportActionEvents.ts | 103 + packages/core/src/inbox/reportActionRules.ts | 27 + packages/core/src/inbox/reportArtefacts.ts | 55 + .../core/src/inbox/reportFilters.test.ts | 4 +- .../core/src/inbox/reportFilters.ts | 20 +- .../core/src/inbox/reportPrompts.ts | 23 +- packages/core/src/inbox/reportRepository.ts | 23 + packages/core/src/inbox/reportSignals.ts | 28 + packages/core/src/inbox/reportTaskCreation.ts | 65 + packages/core/src/inbox/reportTasks.ts | 44 + .../src/inbox/signalReportTaskService.test.ts | 112 + .../core/src/inbox/signalReportTaskService.ts | 116 + .../src/inbox/signalSourceService.test.ts | 147 ++ .../core/src/inbox/signalSourceService.ts | 417 ++++ packages/core/src/inbox/statusLabels.ts | 24 + .../core/src/inbox/suggestedReviewers.test.ts | 4 +- .../core/src/inbox/suggestedReviewers.ts | 2 +- .../core/src/integrations/branches.test.ts | 52 + packages/core/src/integrations/branches.ts | 37 + .../integrations/connectEligibility.test.ts | 58 + .../src/integrations/connectEligibility.ts | 25 + .../src/integrations/connectErrors.test.ts | 20 + .../core/src/integrations/connectErrors.ts | 41 + .../src/integrations/connectMachine.test.ts | 93 + .../core/src/integrations/connectMachine.ts | 84 + packages/core/src/integrations/github.test.ts | 215 ++ .../core/src/integrations/github.ts | 54 +- .../integrations/githubConnectService.test.ts | 108 + .../src/integrations/githubConnectService.ts | 58 + packages/core/src/integrations/identifiers.ts | 46 + .../src/integrations/integrations.module.ts | 21 + packages/core/src/integrations/linear.test.ts | 31 + .../core/src/integrations/linear.ts | 18 +- .../src/integrations/repositories.test.ts | 109 + .../core/src/integrations/repositories.ts | 157 ++ .../integrations/repositoriesService.test.ts | 102 + .../src/integrations/repositoriesService.ts | 51 + .../src/integrations/repositoryKeys.test.ts | 52 + .../core/src/integrations/repositoryKeys.ts | 78 + .../core/src/integrations/schemas.ts | 0 .../core/src/integrations/selectors.test.ts | 26 + .../core/src/integrations/selectors.ts | 21 +- packages/core/src/integrations/slack.test.ts | 201 ++ .../core/src/integrations/slack.ts | 67 +- packages/core/src/links/identifiers.ts | 12 + .../core/src/links/inbox-link.test.ts | 34 +- .../core/src/links/inbox-link.ts | 41 +- .../core/src/links/new-task-link.test.ts | 32 +- .../core/src/links/new-task-link.ts | 93 +- packages/core/src/links/task-link.test.ts | 169 ++ .../core/src/links/task-link.ts | 52 +- packages/core/src/llm-gateway/identifiers.ts | 23 + .../src/llm-gateway/llm-gateway.module.ts | 7 + .../core/src/llm-gateway/llm-gateway.test.ts | 211 ++ .../core/src/llm-gateway/llm-gateway.ts | 72 +- .../core/src}/llm-gateway/schemas.ts | 26 +- packages/core/src/mcp-apps/identifiers.ts | 1 + packages/core/src/mcp-apps/mcp-apps.module.ts | 7 + .../core/src/mcp-apps/mcp-apps.ts | 69 +- .../core/src/mcp-apps/schemas.ts | 0 .../src/mcp-servers/customServerForm.test.ts | 91 + .../core/src/mcp-servers/customServerForm.ts | 51 + .../core/src/mcp-servers/filters.test.ts | 4 +- .../core/src/mcp-servers/filters.ts | 2 +- .../core/src/mcp-servers/installFlow.test.ts | 122 + packages/core/src/mcp-servers/installFlow.ts | 109 + .../src/mcp-servers/resolveServerName.test.ts | 89 + .../core/src/mcp-servers/resolveServerName.ts | 59 + .../core/src/mcp-servers/status.test.ts | 4 +- packages/core/src/mcp-servers/status.ts | 11 + .../core/src/mcp-servers/toolBulk.test.ts | 4 +- .../core/src/mcp-servers/toolBulk.ts | 2 +- .../src/mcp-servers/toolDerivation.test.ts | 71 + .../core/src/mcp-servers/toolDerivation.ts | 45 + .../core/src/mcp-servers/toolRefresh.test.ts | 48 + packages/core/src/mcp-servers/toolRefresh.ts | 18 + packages/core/src/message-editor/commands.ts | 51 + .../core/src/message-editor}/content.test.ts | 0 .../core/src/message-editor}/content.ts | 2 +- .../message-editor}/githubIssueChip.test.ts | 0 .../src/message-editor}/githubIssueChip.ts | 16 +- .../message-editor}/githubIssueUrl.test.ts | 0 .../src/message-editor}/githubIssueUrl.ts | 2 +- packages/core/src/message-editor/paste.ts | 31 + .../src/message-editor}/persistFile.test.ts | 144 +- .../core/src/message-editor}/persistFile.ts | 76 +- .../message-editor}/suggestionLoader.test.ts | 0 .../src/message-editor}/suggestionLoader.ts | 0 .../core/src/message-editor/suggestions.ts | 134 ++ packages/core/src/notification/identifiers.ts | 3 + .../src/notification/notification.test.ts | 136 ++ .../core/src/notification/notification.ts | 49 +- packages/core/src/oauth/identifiers.ts | 17 + packages/core/src/oauth/oauth.module.ts | 7 + packages/core/src/oauth/oauth.test.ts | 183 ++ .../core/src/oauth/oauth.ts | 253 +- packages/core/src/oauth/schemas.ts | 1 + .../core/src/onboarding/analytics.test.ts | 62 + packages/core/src/onboarding/analytics.ts | 56 + .../src/onboarding/githubConnectPanel.test.ts | 196 ++ .../core/src/onboarding/githubConnectPanel.ts | 112 + .../onboarding/githubConnectService.test.ts | 117 + .../src/onboarding/githubConnectService.ts | 49 + packages/core/src/onboarding/identifiers.ts | 11 + .../core/src/onboarding/onboarding.module.ts | 7 + .../projectsWithIntegrations.test.ts | 39 + .../onboarding/projectsWithIntegrations.ts | 51 + .../core/src/onboarding/repoProvider.test.ts | 80 + packages/core/src/onboarding/repoProvider.ts | 43 + packages/core/src/onboarding/steps.test.ts | 46 + packages/core/src/onboarding/steps.ts | 76 + .../core/src/panels}/panelConstants.ts | 6 - .../src/panels/panelLayoutTransforms.test.ts | 91 + .../core/src/panels/panelLayoutTransforms.ts | 654 ++++++ .../core/src/panels/panelSizeMath.ts | 21 + .../core/src/panels}/panelStoreHelpers.ts | 49 +- .../core/src/panels}/panelTree.ts | 21 +- .../core/src/panels}/panelTypes.ts | 21 +- packages/core/src/panels/resolveTabPath.ts | 11 + .../src/panels/resolveWorkspaceForRepoPath.ts | 18 + packages/core/src/provisioning/identifiers.ts | 3 + packages/core/src/provisioning/output.test.ts | 38 + packages/core/src/provisioning/output.ts | 33 + .../src/provisioning/provisioning.test.ts | 28 + .../core/src/provisioning/provisioning.ts | 2 +- packages/core/src/secure-store/identifiers.ts | 24 + packages/core/src/secure-store/schemas.ts | 12 + .../core/src/sessions/acpNotifications.ts | 36 + packages/core/src/sessions/chatTitle.test.ts | 115 + packages/core/src/sessions/chatTitle.ts | 69 + .../src/sessions/cloudArtifactIdentifiers.ts | 49 + .../src/sessions/cloudArtifactService.test.ts | 96 + .../core/src/sessions/cloudArtifactService.ts | 250 ++ .../core/src/sessions/cloudLogGap.test.ts | 160 ++ packages/core/src/sessions/cloudLogGap.ts | 144 ++ .../sessions/cloudLogGapReconciler.test.ts | 205 ++ .../src/sessions/cloudLogGapReconciler.ts | 184 ++ .../core/src/sessions/cloudPrompt.test.ts | 46 + packages/core/src/sessions/cloudPrompt.ts | 174 ++ .../src/sessions/cloudRunIdleTracker.test.ts | 132 ++ .../core/src/sessions}/cloudRunIdleTracker.ts | 5 +- .../core/src/sessions/cloudRunOptions.test.ts | 88 + packages/core/src/sessions/cloudRunOptions.ts | 60 + .../src/sessions/cloudSessionConfig.test.ts | 76 + .../core/src/sessions/cloudSessionConfig.ts | 78 + .../core/src/sessions/connectRouting.test.ts | 97 + packages/core/src/sessions/connectRouting.ts | 52 + .../core/src/sessions/contextUsage.test.ts | 4 +- .../core/src/sessions/contextUsage.ts | 17 +- packages/core/src/sessions/executionModes.ts | 59 + .../src/sessions/localHandoffService.test.ts | 186 ++ .../core/src/sessions/localHandoffService.ts | 196 ++ .../src/sessions/permissionResponse.test.ts | 80 + .../core/src/sessions/permissionResponse.ts | 54 + .../core/src/sessions}/promptContent.test.ts | 0 .../core/src/sessions}/promptContent.ts | 2 +- .../core/src/sessions/sessionEvents.test.ts | 74 +- .../core/src/sessions/sessionEvents.ts | 65 +- .../core/src/sessions/sessionFactory.test.ts | 40 + packages/core/src/sessions/sessionFactory.ts | 24 + .../core/src/sessions/sessionLogs.test.ts | 91 + packages/core/src/sessions/sessionLogs.ts | 73 + .../core/src/sessions/sessionService.ts | 1857 ++++++++------- .../core/src/sessions/sessionViewState.ts | 37 +- packages/core/src/sessions/sessions.module.ts | 10 + .../src/sessions/titleGeneratorIdentifiers.ts | 17 + .../sessions/titleGeneratorService.test.ts | 143 +- .../src/sessions/titleGeneratorService.ts | 157 ++ .../src/settings/githubRepoSummary.test.ts | 47 + .../core/src/settings/githubRepoSummary.ts | 38 + packages/core/src/settings/posthogUrl.test.ts | 18 + packages/core/src/settings/posthogUrl.ts | 11 + .../settings/sandboxEnvironmentForm.test.ts | 115 + .../src/settings/sandboxEnvironmentForm.ts | 107 + .../settings/slackNotificationTarget.test.ts | 74 + .../src/settings/slackNotificationTarget.ts | 80 + .../core/src/settings/updateStatus.test.ts | 52 + packages/core/src/settings/updateStatus.ts | 41 + .../src/settings/worktreeGrouping.test.ts | 53 + .../core/src/settings/worktreeGrouping.ts | 60 + .../worktreeMaintenanceService.test.ts | 63 + .../settings/worktreeMaintenanceService.ts | 54 + .../src/setup}/buildDiscoveredTaskPrompt.ts | 6 +- packages/core/src/setup/identifiers.ts | 109 + .../core/src}/setup/prompts.ts | 2 +- packages/core/src/setup/sessionUpdate.ts | 112 + packages/core/src/setup/setup.module.ts | 6 + .../core/src/setup/setupRunService.test.ts | 206 ++ packages/core/src/setup/setupRunService.ts | 443 ++++ packages/core/src/setup/setupState.ts | 207 ++ packages/core/src/setup/suggestions.test.ts | 78 + packages/core/src/setup/suggestions.ts | 83 + .../core/src}/setup/types.ts | 0 packages/core/src/sidebar/buildSidebarData.ts | 213 ++ .../core/src/sidebar}/groupTasks.test.ts | 2 +- .../core/src/sidebar}/groupTasks.ts | 30 +- packages/core/src/sidebar/selection.test.ts | 164 ++ packages/core/src/sidebar/selection.ts | 103 + .../core/src/sidebar/sidebarData.types.ts | 44 + .../core/src/sidebar}/summaryIds.test.ts | 0 .../core/src/sidebar}/summaryIds.ts | 0 packages/core/src/sidebar/taskMeta.ts | 27 + .../core/src/skill-buttons/catalog.ts | 66 +- .../core/src}/skill-buttons/prompts.test.ts | 15 +- packages/core/src/skill-buttons/prompts.ts | 42 + packages/core/src/sleep/identifiers.ts | 1 + packages/core/src/sleep/sleep.test.ts | 116 + .../core/src/sleep/sleep.ts | 37 +- .../core/src/task-detail/cloudRunState.ts | 35 + .../src/task-detail}/cloudToolChanges.test.ts | 0 .../core/src/task-detail}/cloudToolChanges.ts | 40 +- .../core/src/task-detail}/configOptions.ts | 0 packages/core/src/task-detail/discardInfo.ts | 44 + packages/core/src/task-detail/identifiers.ts | 10 + .../core/src/task-detail/previewConfig.ts | 194 ++ .../src/task-detail/task-detail.module.ts | 9 + .../src/task-detail/taskCreationApiClient.ts | 37 + .../src/task-detail/taskCreationEffects.ts | 12 + .../core/src/task-detail/taskCreationHost.ts | 82 + .../src/task-detail/taskCreationSaga.test.ts | 170 +- .../core/src/task-detail/taskCreationSaga.ts | 179 +- packages/core/src/task-detail/taskInput.ts | 64 + packages/core/src/task-detail/taskService.ts | 173 ++ .../task-detail/workspaceSetupSaga.test.ts | 79 + .../src/task-detail/workspaceSetupSaga.ts | 46 + .../core/src/tasks/contextMenuActions.test.ts | 57 + packages/core/src/tasks/contextMenuActions.ts | 46 + packages/core/src/tasks/filters.test.ts | 105 + packages/core/src/tasks/filters.ts | 144 ++ packages/core/src/tasks/identifiers.ts | 38 + packages/core/src/tasks/taskDelete.test.ts | 91 + packages/core/src/tasks/taskDelete.ts | 45 + .../src/tasks/taskDeletionService.test.ts | 189 ++ .../core/src/tasks/taskDeletionService.ts | 98 + packages/core/src/tasks/taskRename.test.ts | 161 ++ packages/core/src/tasks/taskRename.ts | 99 + packages/core/src/tasks/tasks.module.ts | 7 + packages/core/src/terminal/identifiers.ts | 13 + .../resolveTerminalFontFamily.test.ts | 0 .../terminal}/resolveTerminalFontFamily.ts | 6 +- .../core/src/terminal/shellProcessPoller.ts | 80 + packages/core/src/terminal/terminal.module.ts | 8 + .../tour/calculateTooltipPlacement.test.ts | 79 + .../src/tour}/calculateTooltipPlacement.ts | 18 +- packages/core/src/tour/tourMachine.test.ts | 146 ++ packages/core/src/tour/tourMachine.ts | 160 ++ packages/core/src/tour/tourRegistry.ts | 15 + .../core/src}/tour/types.ts | 1 + packages/core/src/ui/identifiers.ts | 2 + packages/core/src/ui/ports.ts | 3 + .../core/src}/ui/schemas.ts | 0 packages/core/src/ui/ui.module.ts | 7 + packages/core/src/ui/ui.test.ts | 39 + .../service.ts => packages/core/src/ui/ui.ts | 12 +- packages/core/src/updates/identifiers.ts | 11 + .../core/src}/updates/schemas.ts | 0 packages/core/src/updates/updateStore.test.ts | 130 ++ packages/core/src/updates/updateStore.ts | 148 ++ packages/core/src/updates/updates.module.ts | 7 + .../core/src/updates/updates.test.ts | 104 +- .../core/src/updates/updates.ts | 125 +- packages/core/src/usage/identifiers.ts | 24 + .../core/src/usage/monitor-schemas.ts | 3 +- packages/core/src/usage/schemas.ts | 20 + .../core/src/usage/usage-monitor.module.ts | 7 + .../core/src/usage/usage-monitor.test.ts | 161 +- .../core/src/usage/usage-monitor.ts | 45 +- .../workspace/WorkspaceSetupService.test.ts | 86 + .../src/workspace/WorkspaceSetupService.ts | 57 + .../core/src/workspace/branchMismatch.test.ts | 34 + packages/core/src/workspace/branchMismatch.ts | 14 + .../workspace/branchMismatchDialog.test.ts | 89 + .../src/workspace/branchMismatchDialog.ts | 84 + .../src/workspace/ensureWorkspace.test.ts | 46 + .../core/src/workspace/ensureWorkspace.ts | 33 + .../core/src/workspace/focusWorkspace.test.ts | 69 + packages/core/src/workspace/focusWorkspace.ts | 35 + packages/core/src/workspace/identifiers.ts | 14 + .../core/src/workspace/localRepoPath.test.ts | 41 + packages/core/src/workspace/localRepoPath.ts | 13 + .../core/src/workspace/repoMismatch.test.ts | 36 + packages/core/src/workspace/repoMismatch.ts | 23 + .../core/src/workspace/workspace.module.ts | 7 + packages/core/tsconfig.json | 4 + packages/di/package.json | 34 + packages/di/src/container.ts | 40 + packages/di/src/contribution.test.ts | 49 + .../src/workbench => di/src}/contribution.ts | 4 +- packages/di/src/logger.ts | 12 + .../service-context.tsx => di/src/react.tsx} | 16 + packages/di/tsconfig.json | 4 + packages/enricher/package.json | 1 + packages/enricher/src/serialize.ts | 68 +- packages/enricher/src/types.ts | 9 +- packages/git/src/handoff.ts | 34 +- packages/git/src/worktree.test.ts | 93 +- packages/host-router/package.json | 38 + packages/host-router/src/client.ts | 6 + .../src/ports/connectivity-client.ts | 9 + .../src/ports/environment-client.ts | 7 + .../src/ports/file-watcher-control.ts | 8 + .../host-router/src/ports/git-pr-status.ts | 12 + packages/host-router/src/react.tsx | 8 + packages/host-router/src/router.ts | 84 + .../routers/additional-directories.router.ts | 62 + .../host-router/src/routers/agent.router.ts | 228 ++ .../src/routers/analytics.router.ts | 30 + .../host-router/src/routers/archive.router.ts | 52 + .../host-router/src/routers/auth.router.ts | 78 + .../src/routers/cloud-task.router.ts | 66 + .../src/routers/connectivity.router.ts | 51 + .../src/routers/context-menu.router.ts | 115 + .../src/routers/deep-link.router.ts | 80 + .../src/routers/enrichment.router.ts | 28 +- .../src/routers/environment.router.ts | 49 + .../src/routers/external-apps.router.ts | 54 + .../src/routers/file-watcher.router.ts | 26 + .../host-router/src/routers/focus.router.ts | 217 ++ .../host-router/src/routers/folders.router.ts | 64 + packages/host-router/src/routers/fs.router.ts | 92 + .../host-router/src/routers/git.router.ts | 628 +++++ .../src/routers/github-integration.router.ts | 56 + .../host-router/src/routers/handoff.router.ts | 30 +- .../src/routers/linear-integration.router.ts | 18 + .../src/routers/llm-gateway.router.ts | 25 + .../host-router/src/routers/logs.router.ts | 36 + .../src/routers/mcp-apps.router.ts | 54 +- .../src/routers/mcp-callback.router.ts | 27 +- .../src/routers/notification.router.ts | 29 + .../host-router/src/routers/oauth.router.ts | 12 + packages/host-router/src/routers/os.router.ts | 119 + .../src/routers/process-tracking.router.ts | 62 + .../src/routers/provisioning.router.ts | 18 + .../src/routers/secure-store.router.ts | 38 + .../host-router/src/routers/shell.router.ts | 99 + .../host-router/src/routers/skills.router.ts | 12 + .../src/routers/slack-integration.router.ts | 53 + .../host-router/src/routers/sleep.router.ts | 18 + .../src/routers/suspension.router.ts | 63 + .../host-router/src/routers/ui.router.ts | 16 +- .../src/routers/updates.router.test.ts | 44 +- .../host-router/src/routers/updates.router.ts | 47 + .../src/routers/usage-monitor.router.ts | 26 +- .../src/routers/workspace.router.ts | 221 ++ packages/host-router/tsconfig.json | 8 + packages/host-trpc/package.json | 28 + packages/host-trpc/src/context.ts | 9 + packages/host-trpc/src/trpc.ts | 11 + packages/host-trpc/tsconfig.json | 4 + packages/platform/package.json | 20 + packages/platform/src/analytics.ts | 18 + packages/platform/src/app-lifecycle.ts | 4 + packages/platform/src/app-meta.ts | 6 + packages/platform/src/bundled-resources.ts | 4 + packages/platform/src/clipboard.ts | 2 + packages/platform/src/context-menu.ts | 2 + packages/platform/src/crypto.ts | 13 + packages/platform/src/deep-link.ts | 12 + packages/platform/src/dialog.ts | 2 + packages/platform/src/file-icon.ts | 2 + packages/platform/src/image-processor.ts | 4 + packages/platform/src/main-window.ts | 2 + packages/platform/src/notifications.ts | 16 + packages/platform/src/notifier.ts | 2 + packages/platform/src/power-manager.ts | 4 + packages/platform/src/secure-storage.ts | 4 + packages/platform/src/storage-paths.ts | 4 + packages/platform/src/updater.ts | 2 + packages/platform/src/url-launcher.ts | 2 + packages/platform/src/workspace-settings.ts | 17 + packages/platform/tsup.config.ts | 5 + packages/shared/package.json | 16 +- .../shared/src/analytics-events.ts | 15 +- .../shared/src/archive-domain.ts | 4 + packages/shared/src/async.ts | 23 + packages/shared/src/backoff.test.ts | 53 + .../utils => packages/shared/src}/backoff.ts | 0 .../types => packages/shared/src}/cloud.ts | 0 .../shared/src/deep-links.test.ts | 74 +- packages/shared/src/deep-links.ts | 96 + .../shared/src/dismissal-reasons.ts | 0 .../shared/src/domain-types.ts | 63 +- packages/shared/src/enrichment.ts | 67 + packages/shared/src/errors.test.ts | 105 + .../shared => packages/shared/src}/errors.ts | 0 packages/shared/src/exec-types.ts | 8 + packages/shared/src/flags.ts | 5 + packages/shared/src/git-domain.ts | 69 + packages/shared/src/git-handoff.ts | 22 + packages/shared/src/git-naming.ts | 1 + packages/shared/src/git-types.ts | 6 + packages/shared/src/handoff-host.ts | 101 + packages/shared/src/inbox-types.ts | 6 + packages/shared/src/index.ts | 145 ++ .../utils => packages/shared/src}/links.ts | 0 .../shared/src/mcp-sandbox-proxy.test.ts | 2 +- packages/shared/src/mcp-sandbox-proxy.ts | 187 ++ .../shared/src}/oauth.test.ts | 0 .../shared/src}/oauth.ts | 2 +- .../shared/src}/path.test.ts | 0 .../utils => packages/shared/src}/path.ts | 0 packages/shared/src/regions.test.ts | 48 + .../types => packages/shared/src}/regions.ts | 0 .../utils => packages/shared/src}/repo.ts | 0 .../shared/src}/repository.ts | 0 .../types => packages/shared/src}/seat.ts | 0 .../shared/src}/session-events.ts | 0 packages/shared/src/sessions.ts | 162 ++ packages/shared/src/signal-types.ts | 16 + .../types => packages/shared/src}/skills.ts | 0 packages/shared/src/task-creation-domain.ts | 39 + packages/shared/src/task.ts | 87 + packages/shared/src/time.test.ts | 90 + .../utils => packages/shared/src}/time.ts | 0 .../shared/src/typed-event-emitter.test.ts | 175 ++ packages/shared/src/typed-event-emitter.ts | 255 ++ .../utils => packages/shared/src}/urls.ts | 2 +- packages/shared/src/workspace-domain.ts | 42 + packages/shared/src/workspace.ts | 1 + packages/shared/src/xml.test.ts | 34 + .../utils => packages/shared/src}/xml.ts | 0 packages/shared/tsup.config.ts | 7 +- packages/shared/vitest.config.ts | 10 + packages/ui/package.json | 89 +- packages/ui/src/assets.d.ts | 14 + .../src}/assets/file-icons/default_file.svg | 0 .../assets/file-icons/file_type_access.svg | 0 .../file-icons/file_type_actionscript.svg | 0 .../src}/assets/file-icons/file_type_ai.svg | 0 .../src}/assets/file-icons/file_type_ai2.svg | 0 .../src}/assets/file-icons/file_type_al.svg | 0 .../assets/file-icons/file_type_angular.svg | 0 .../assets/file-icons/file_type_ansible.svg | 0 .../assets/file-icons/file_type_antlr.svg | 0 .../assets/file-icons/file_type_anyscript.svg | 0 .../assets/file-icons/file_type_apache.svg | 0 .../src}/assets/file-icons/file_type_apex.svg | 0 .../src}/assets/file-icons/file_type_apib.svg | 0 .../assets/file-icons/file_type_apib2.svg | 0 .../file-icons/file_type_applescript.svg | 0 .../assets/file-icons/file_type_appveyor.svg | 0 .../assets/file-icons/file_type_arduino.svg | 0 .../src}/assets/file-icons/file_type_asp.svg | 0 .../src}/assets/file-icons/file_type_aspx.svg | 0 .../assets/file-icons/file_type_assembly.svg | 0 .../assets/file-icons/file_type_astro.svg | 0 .../assets/file-icons/file_type_audio.svg | 0 .../assets/file-icons/file_type_aurelia.svg | 0 .../file-icons/file_type_autohotkey.svg | 0 .../assets/file-icons/file_type_autoit.svg | 0 .../src}/assets/file-icons/file_type_avro.svg | 0 .../src}/assets/file-icons/file_type_aws.svg | 0 .../assets/file-icons/file_type_azure.svg | 0 .../assets/file-icons/file_type_babel.svg | 0 .../assets/file-icons/file_type_babel2.svg | 0 .../src}/assets/file-icons/file_type_bat.svg | 0 .../assets/file-icons/file_type_bazaar.svg | 0 .../assets/file-icons/file_type_bazel.svg | 0 .../assets/file-icons/file_type_binary.svg | 0 .../assets/file-icons/file_type_bithound.svg | 0 .../assets/file-icons/file_type_blade.svg | 0 .../src}/assets/file-icons/file_type_bolt.svg | 0 .../assets/file-icons/file_type_bower.svg | 0 .../assets/file-icons/file_type_bower2.svg | 0 .../assets/file-icons/file_type_buckbuild.svg | 0 .../src}/assets/file-icons/file_type_bun.svg | 0 .../assets/file-icons/file_type_bundler.svg | 0 .../ui/src}/assets/file-icons/file_type_c.svg | 0 .../src}/assets/file-icons/file_type_c2.svg | 0 .../src}/assets/file-icons/file_type_c_al.svg | 0 .../assets/file-icons/file_type_cabal.svg | 0 .../src}/assets/file-icons/file_type_cake.svg | 0 .../assets/file-icons/file_type_cakephp.svg | 0 .../assets/file-icons/file_type_cargo.svg | 0 .../src}/assets/file-icons/file_type_cert.svg | 0 .../src}/assets/file-icons/file_type_cf.svg | 0 .../src}/assets/file-icons/file_type_cf2.svg | 0 .../src}/assets/file-icons/file_type_cfc.svg | 0 .../src}/assets/file-icons/file_type_cfc2.svg | 0 .../src}/assets/file-icons/file_type_cfm.svg | 0 .../src}/assets/file-icons/file_type_cfm2.svg | 0 .../assets/file-icons/file_type_cheader.svg | 0 .../src}/assets/file-icons/file_type_chef.svg | 0 .../assets/file-icons/file_type_circleci.svg | 0 .../assets/file-icons/file_type_class.svg | 0 .../assets/file-icons/file_type_clojure.svg | 0 .../file-icons/file_type_cloudfoundry.svg | 0 .../assets/file-icons/file_type_cmake.svg | 0 .../assets/file-icons/file_type_cobol.svg | 0 .../file-icons/file_type_codeclimate.svg | 0 .../assets/file-icons/file_type_codecov.svg | 0 .../assets/file-icons/file_type_codekit.svg | 0 .../file-icons/file_type_codeowners.svg | 0 .../file-icons/file_type_coffeelint.svg | 0 .../file-icons/file_type_coffeescript.svg | 0 .../assets/file-icons/file_type_compass.svg | 0 .../assets/file-icons/file_type_composer.svg | 0 .../assets/file-icons/file_type_conan.svg | 0 .../assets/file-icons/file_type_config.svg | 0 .../assets/file-icons/file_type_coveralls.svg | 0 .../src}/assets/file-icons/file_type_cpp.svg | 0 .../src}/assets/file-icons/file_type_cpp2.svg | 0 .../assets/file-icons/file_type_cppheader.svg | 0 .../assets/file-icons/file_type_crowdin.svg | 0 .../assets/file-icons/file_type_crystal.svg | 0 .../assets/file-icons/file_type_csharp.svg | 0 .../assets/file-icons/file_type_csproj.svg | 0 .../src}/assets/file-icons/file_type_css.svg | 0 .../assets/file-icons/file_type_csslint.svg | 0 .../assets/file-icons/file_type_cssmap.svg | 0 .../assets/file-icons/file_type_cucumber.svg | 0 .../src}/assets/file-icons/file_type_cvs.svg | 0 .../assets/file-icons/file_type_cypress.svg | 0 .../src}/assets/file-icons/file_type_dal.svg | 0 .../assets/file-icons/file_type_darcs.svg | 0 .../assets/file-icons/file_type_dartlang.svg | 0 .../src}/assets/file-icons/file_type_db.svg | 0 .../assets/file-icons/file_type_delphi.svg | 0 .../src}/assets/file-icons/file_type_deno.svg | 0 .../file-icons/file_type_dependencies.svg | 0 .../src}/assets/file-icons/file_type_diff.svg | 0 .../assets/file-icons/file_type_django.svg | 0 .../assets/file-icons/file_type_dlang.svg | 0 .../assets/file-icons/file_type_docker.svg | 0 .../assets/file-icons/file_type_docker2.svg | 0 .../file-icons/file_type_dockertest.svg | 0 .../file-icons/file_type_dockertest2.svg | 0 .../assets/file-icons/file_type_docpad.svg | 0 .../assets/file-icons/file_type_dotenv.svg | 0 .../assets/file-icons/file_type_doxygen.svg | 0 .../assets/file-icons/file_type_drone.svg | 0 .../assets/file-icons/file_type_drools.svg | 0 .../assets/file-icons/file_type_dustjs.svg | 0 .../assets/file-icons/file_type_dylan.svg | 0 .../src}/assets/file-icons/file_type_edge.svg | 0 .../assets/file-icons/file_type_edge2.svg | 0 .../file-icons/file_type_editorconfig.svg | 0 .../src}/assets/file-icons/file_type_eex.svg | 0 .../src}/assets/file-icons/file_type_ejs.svg | 0 .../assets/file-icons/file_type_elastic.svg | 0 .../file-icons/file_type_elasticbeanstalk.svg | 0 .../assets/file-icons/file_type_elixir.svg | 0 .../src}/assets/file-icons/file_type_elm.svg | 0 .../src}/assets/file-icons/file_type_elm2.svg | 0 .../assets/file-icons/file_type_emacs.svg | 0 .../assets/file-icons/file_type_ember.svg | 0 .../assets/file-icons/file_type_ensime.svg | 0 .../src}/assets/file-icons/file_type_eps.svg | 0 .../src}/assets/file-icons/file_type_erb.svg | 0 .../assets/file-icons/file_type_erlang.svg | 0 .../assets/file-icons/file_type_erlang2.svg | 0 .../assets/file-icons/file_type_esbuild.svg | 0 .../assets/file-icons/file_type_eslint.svg | 0 .../assets/file-icons/file_type_eslint2.svg | 0 .../assets/file-icons/file_type_excel.svg | 0 .../assets/file-icons/file_type_favicon.svg | 0 .../src}/assets/file-icons/file_type_fbx.svg | 0 .../assets/file-icons/file_type_firebase.svg | 0 .../assets/file-icons/file_type_flash.svg | 0 .../assets/file-icons/file_type_floobits.svg | 0 .../src}/assets/file-icons/file_type_flow.svg | 0 .../src}/assets/file-icons/file_type_font.svg | 0 .../assets/file-icons/file_type_fortran.svg | 0 .../assets/file-icons/file_type_fossil.svg | 0 .../file-icons/file_type_freemarker.svg | 0 .../assets/file-icons/file_type_fsharp.svg | 0 .../assets/file-icons/file_type_fsharp2.svg | 0 .../assets/file-icons/file_type_fsproj.svg | 0 .../assets/file-icons/file_type_fusebox.svg | 0 .../assets/file-icons/file_type_galen.svg | 0 .../assets/file-icons/file_type_galen2.svg | 0 .../assets/file-icons/file_type_gamemaker.svg | 0 .../file-icons/file_type_gamemaker2.svg | 0 .../file-icons/file_type_gamemaker81.svg | 0 .../src}/assets/file-icons/file_type_git.svg | 0 .../src}/assets/file-icons/file_type_git2.svg | 0 .../assets/file-icons/file_type_gitlab.svg | 0 .../src}/assets/file-icons/file_type_glsl.svg | 0 .../src}/assets/file-icons/file_type_go.svg | 0 .../assets/file-icons/file_type_godot.svg | 0 .../assets/file-icons/file_type_gradle.svg | 0 .../assets/file-icons/file_type_graphql.svg | 0 .../assets/file-icons/file_type_graphviz.svg | 0 .../assets/file-icons/file_type_groovy.svg | 0 .../assets/file-icons/file_type_groovy2.svg | 0 .../assets/file-icons/file_type_grunt.svg | 0 .../src}/assets/file-icons/file_type_gulp.svg | 0 .../src}/assets/file-icons/file_type_haml.svg | 0 .../file-icons/file_type_handlebars.svg | 0 .../file-icons/file_type_handlebars2.svg | 0 .../assets/file-icons/file_type_harbour.svg | 0 .../assets/file-icons/file_type_hardhat.svg | 0 .../assets/file-icons/file_type_haskell.svg | 0 .../assets/file-icons/file_type_haskell2.svg | 0 .../src}/assets/file-icons/file_type_haxe.svg | 0 .../file-icons/file_type_haxecheckstyle.svg | 0 .../file-icons/file_type_haxedevelop.svg | 0 .../assets/file-icons/file_type_helix.svg | 0 .../src}/assets/file-icons/file_type_helm.svg | 0 .../src}/assets/file-icons/file_type_hlsl.svg | 0 .../src}/assets/file-icons/file_type_host.svg | 0 .../src}/assets/file-icons/file_type_html.svg | 0 .../assets/file-icons/file_type_htmlhint.svg | 0 .../src}/assets/file-icons/file_type_http.svg | 0 .../assets/file-icons/file_type_husky.svg | 0 .../assets/file-icons/file_type_idris.svg | 0 .../assets/file-icons/file_type_idrisbin.svg | 0 .../assets/file-icons/file_type_idrispkg.svg | 0 .../assets/file-icons/file_type_image.svg | 0 .../assets/file-icons/file_type_infopath.svg | 0 .../src}/assets/file-icons/file_type_ini.svg | 0 .../src}/assets/file-icons/file_type_io.svg | 0 .../assets/file-icons/file_type_iodine.svg | 0 .../assets/file-icons/file_type_ionic.svg | 0 .../src}/assets/file-icons/file_type_jar.svg | 0 .../src}/assets/file-icons/file_type_java.svg | 0 .../assets/file-icons/file_type_jbuilder.svg | 0 .../assets/file-icons/file_type_jekyll.svg | 0 .../assets/file-icons/file_type_jenkins.svg | 0 .../src}/assets/file-icons/file_type_jest.svg | 0 .../assets/file-icons/file_type_jinja.svg | 0 .../src}/assets/file-icons/file_type_jpm.svg | 0 .../src}/assets/file-icons/file_type_js.svg | 0 .../file-icons/file_type_js_official.svg | 0 .../file-icons/file_type_jsbeautify.svg | 0 .../assets/file-icons/file_type_jsconfig.svg | 0 .../assets/file-icons/file_type_jshint.svg | 0 .../assets/file-icons/file_type_jsmap.svg | 0 .../src}/assets/file-icons/file_type_json.svg | 0 .../assets/file-icons/file_type_json2.svg | 0 .../assets/file-icons/file_type_json5.svg | 0 .../file-icons/file_type_json_official.svg | 0 .../assets/file-icons/file_type_jsonld.svg | 0 .../src}/assets/file-icons/file_type_jsp.svg | 0 .../assets/file-icons/file_type_julia.svg | 0 .../assets/file-icons/file_type_julia2.svg | 0 .../assets/file-icons/file_type_jupyter.svg | 0 .../assets/file-icons/file_type_karma.svg | 0 .../src}/assets/file-icons/file_type_key.svg | 0 .../assets/file-icons/file_type_kitchenci.svg | 0 .../src}/assets/file-icons/file_type_kite.svg | 0 .../src}/assets/file-icons/file_type_kivy.svg | 0 .../src}/assets/file-icons/file_type_kos.svg | 0 .../assets/file-icons/file_type_kotlin.svg | 0 .../assets/file-icons/file_type_layout.svg | 0 .../assets/file-icons/file_type_lerna.svg | 0 .../src}/assets/file-icons/file_type_less.svg | 0 .../assets/file-icons/file_type_license.svg | 0 .../file-icons/file_type_light_babel.svg | 0 .../file-icons/file_type_light_babel2.svg | 0 .../file-icons/file_type_light_cabal.svg | 0 .../file-icons/file_type_light_circleci.svg | 0 .../file_type_light_cloudfoundry.svg | 0 .../file_type_light_codeclimate.svg | 0 .../file-icons/file_type_light_config.svg | 0 .../assets/file-icons/file_type_light_db.svg | 0 .../file-icons/file_type_light_docpad.svg | 0 .../file-icons/file_type_light_drone.svg | 0 .../file-icons/file_type_light_font.svg | 0 .../file-icons/file_type_light_gamemaker2.svg | 0 .../assets/file-icons/file_type_light_ini.svg | 0 .../assets/file-icons/file_type_light_io.svg | 0 .../assets/file-icons/file_type_light_js.svg | 0 .../file-icons/file_type_light_jsconfig.svg | 0 .../file-icons/file_type_light_jsmap.svg | 0 .../file-icons/file_type_light_json.svg | 0 .../file-icons/file_type_light_json5.svg | 0 .../file-icons/file_type_light_jsonld.svg | 0 .../file-icons/file_type_light_kite.svg | 0 .../file-icons/file_type_light_lerna.svg | 0 .../file-icons/file_type_light_mlang.svg | 0 .../file-icons/file_type_light_mustache.svg | 0 .../assets/file-icons/file_type_light_pcl.svg | 0 .../file-icons/file_type_light_prettier.svg | 0 .../file-icons/file_type_light_purescript.svg | 0 .../file-icons/file_type_light_rubocop.svg | 0 .../file-icons/file_type_light_shaderlab.svg | 0 .../file-icons/file_type_light_solidity.svg | 0 .../file-icons/file_type_light_stylelint.svg | 0 .../file-icons/file_type_light_stylus.svg | 0 .../file_type_light_systemverilog.svg | 0 .../file-icons/file_type_light_testjs.svg | 0 .../assets/file-icons/file_type_light_tex.svg | 0 .../file-icons/file_type_light_todo.svg | 0 .../file-icons/file_type_light_vash.svg | 0 .../file-icons/file_type_light_vsix.svg | 0 .../file-icons/file_type_light_yaml.svg | 0 .../src}/assets/file-icons/file_type_lime.svg | 0 .../assets/file-icons/file_type_liquid.svg | 0 .../src}/assets/file-icons/file_type_lisp.svg | 0 .../file-icons/file_type_livescript.svg | 0 .../assets/file-icons/file_type_locale.svg | 0 .../src}/assets/file-icons/file_type_log.svg | 0 .../assets/file-icons/file_type_lolcode.svg | 0 .../src}/assets/file-icons/file_type_lsl.svg | 0 .../src}/assets/file-icons/file_type_lua.svg | 0 .../src}/assets/file-icons/file_type_lync.svg | 0 .../assets/file-icons/file_type_manifest.svg | 0 .../file-icons/file_type_manifest_bak.svg | 0 .../file-icons/file_type_manifest_skip.svg | 0 .../src}/assets/file-icons/file_type_map.svg | 0 .../assets/file-icons/file_type_markdown.svg | 0 .../file-icons/file_type_markdownlint.svg | 0 .../assets/file-icons/file_type_marko.svg | 0 .../assets/file-icons/file_type_markojs.svg | 0 .../assets/file-icons/file_type_maxscript.svg | 0 .../src}/assets/file-icons/file_type_mdx.svg | 0 .../assets/file-icons/file_type_mediawiki.svg | 0 .../assets/file-icons/file_type_mercurial.svg | 0 .../assets/file-icons/file_type_meteor.svg | 0 .../src}/assets/file-icons/file_type_mjml.svg | 0 .../assets/file-icons/file_type_mlang.svg | 0 .../assets/file-icons/file_type_mocha.svg | 0 .../file-icons/file_type_mojolicious.svg | 0 .../assets/file-icons/file_type_mongo.svg | 0 .../assets/file-icons/file_type_monotone.svg | 0 .../src}/assets/file-icons/file_type_mson.svg | 0 .../assets/file-icons/file_type_mustache.svg | 0 .../assets/file-icons/file_type_netlify.svg | 0 .../src}/assets/file-icons/file_type_next.svg | 0 .../file-icons/file_type_ng_component_css.svg | 0 .../file_type_ng_component_html.svg | 0 .../file-icons/file_type_ng_component_js.svg | 0 .../file-icons/file_type_ng_component_js2.svg | 0 .../file_type_ng_component_less.svg | 0 .../file_type_ng_component_sass.svg | 0 .../file_type_ng_component_scss.svg | 0 .../file-icons/file_type_ng_component_ts.svg | 0 .../file-icons/file_type_ng_component_ts2.svg | 0 .../file-icons/file_type_ng_controller_js.svg | 0 .../file-icons/file_type_ng_controller_ts.svg | 0 .../file-icons/file_type_ng_directive_js.svg | 0 .../file-icons/file_type_ng_directive_js2.svg | 0 .../file-icons/file_type_ng_directive_ts.svg | 0 .../file-icons/file_type_ng_directive_ts2.svg | 0 .../file-icons/file_type_ng_guard_js.svg | 0 .../file-icons/file_type_ng_guard_ts.svg | 0 .../file_type_ng_interceptor_js.svg | 0 .../file_type_ng_interceptor_ts.svg | 0 .../file-icons/file_type_ng_module_js.svg | 0 .../file-icons/file_type_ng_module_js2.svg | 0 .../file-icons/file_type_ng_module_ts.svg | 0 .../file-icons/file_type_ng_module_ts2.svg | 0 .../file-icons/file_type_ng_pipe_js.svg | 0 .../file-icons/file_type_ng_pipe_js2.svg | 0 .../file-icons/file_type_ng_pipe_ts.svg | 0 .../file-icons/file_type_ng_pipe_ts2.svg | 0 .../file-icons/file_type_ng_routing_js.svg | 0 .../file-icons/file_type_ng_routing_js2.svg | 0 .../file-icons/file_type_ng_routing_ts.svg | 0 .../file-icons/file_type_ng_routing_ts2.svg | 0 .../file-icons/file_type_ng_service_js.svg | 0 .../file-icons/file_type_ng_service_js2.svg | 0 .../file-icons/file_type_ng_service_ts.svg | 0 .../file-icons/file_type_ng_service_ts2.svg | 0 .../file_type_ng_smart_component_js.svg | 0 .../file_type_ng_smart_component_js2.svg | 0 .../file_type_ng_smart_component_ts.svg | 0 .../file_type_ng_smart_component_ts2.svg | 0 .../assets/file-icons/file_type_nginx.svg | 0 .../src}/assets/file-icons/file_type_nim.svg | 0 .../assets/file-icons/file_type_njsproj.svg | 0 .../src}/assets/file-icons/file_type_node.svg | 0 .../assets/file-icons/file_type_node2.svg | 0 .../assets/file-icons/file_type_nodemon.svg | 0 .../src}/assets/file-icons/file_type_npm.svg | 0 .../src}/assets/file-icons/file_type_nsi.svg | 0 .../assets/file-icons/file_type_nuget.svg | 0 .../assets/file-icons/file_type_nunjucks.svg | 0 .../src}/assets/file-icons/file_type_nuxt.svg | 0 .../src}/assets/file-icons/file_type_nx.svg | 0 .../src}/assets/file-icons/file_type_nyc.svg | 0 .../file-icons/file_type_objectivec.svg | 0 .../file-icons/file_type_objectivecpp.svg | 0 .../assets/file-icons/file_type_ocaml.svg | 0 .../assets/file-icons/file_type_onenote.svg | 0 .../assets/file-icons/file_type_opencl.svg | 0 .../src}/assets/file-icons/file_type_org.svg | 0 .../assets/file-icons/file_type_outlook.svg | 0 .../assets/file-icons/file_type_package.svg | 0 .../assets/file-icons/file_type_paket.svg | 0 .../assets/file-icons/file_type_patch.svg | 0 .../src}/assets/file-icons/file_type_pcl.svg | 0 .../src}/assets/file-icons/file_type_pdf.svg | 0 .../src}/assets/file-icons/file_type_pdf2.svg | 0 .../src}/assets/file-icons/file_type_perl.svg | 0 .../assets/file-icons/file_type_perl2.svg | 0 .../assets/file-icons/file_type_perl6.svg | 0 .../assets/file-icons/file_type_photoshop.svg | 0 .../file-icons/file_type_photoshop2.svg | 0 .../src}/assets/file-icons/file_type_php.svg | 0 .../src}/assets/file-icons/file_type_php2.svg | 0 .../src}/assets/file-icons/file_type_php3.svg | 0 .../assets/file-icons/file_type_phpunit.svg | 0 .../assets/file-icons/file_type_phraseapp.svg | 0 .../src}/assets/file-icons/file_type_pip.svg | 0 .../assets/file-icons/file_type_plantuml.svg | 0 .../file-icons/file_type_playwright.svg | 0 .../assets/file-icons/file_type_plsql.svg | 0 .../file-icons/file_type_plsql_package.svg | 0 .../file_type_plsql_package_body.svg | 0 .../file_type_plsql_package_header.svg | 0 .../file_type_plsql_package_spec.svg | 0 .../src}/assets/file-icons/file_type_pnpm.svg | 0 .../assets/file-icons/file_type_poedit.svg | 0 .../assets/file-icons/file_type_polymer.svg | 0 .../assets/file-icons/file_type_postcss.svg | 0 .../file-icons/file_type_powerpoint.svg | 0 .../file-icons/file_type_powershell.svg | 0 .../assets/file-icons/file_type_prettier.svg | 0 .../assets/file-icons/file_type_prisma.svg | 0 .../file-icons/file_type_processinglang.svg | 0 .../assets/file-icons/file_type_procfile.svg | 0 .../assets/file-icons/file_type_progress.svg | 0 .../assets/file-icons/file_type_prolog.svg | 0 .../file-icons/file_type_prometheus.svg | 0 .../assets/file-icons/file_type_protobuf.svg | 0 .../file-icons/file_type_protractor.svg | 0 .../assets/file-icons/file_type_publisher.svg | 0 .../src}/assets/file-icons/file_type_pug.svg | 0 .../assets/file-icons/file_type_puppet.svg | 0 .../file-icons/file_type_purescript.svg | 0 .../assets/file-icons/file_type_python.svg | 0 .../ui/src}/assets/file-icons/file_type_q.svg | 0 .../assets/file-icons/file_type_qlikview.svg | 0 .../ui/src}/assets/file-icons/file_type_r.svg | 0 .../assets/file-icons/file_type_racket.svg | 0 .../assets/file-icons/file_type_rails.svg | 0 .../src}/assets/file-icons/file_type_rake.svg | 0 .../src}/assets/file-icons/file_type_raml.svg | 0 .../assets/file-icons/file_type_razor.svg | 0 .../assets/file-icons/file_type_reactjs.svg | 0 .../file-icons/file_type_reacttemplate.svg | 0 .../assets/file-icons/file_type_reactts.svg | 0 .../assets/file-icons/file_type_reason.svg | 0 .../assets/file-icons/file_type_registry.svg | 0 .../src}/assets/file-icons/file_type_rest.svg | 0 .../src}/assets/file-icons/file_type_riot.svg | 0 .../file-icons/file_type_robotframework.svg | 0 .../assets/file-icons/file_type_robots.svg | 0 .../assets/file-icons/file_type_rollup.svg | 0 .../assets/file-icons/file_type_rspec.svg | 0 .../assets/file-icons/file_type_rubocop.svg | 0 .../src}/assets/file-icons/file_type_ruby.svg | 0 .../src}/assets/file-icons/file_type_rust.svg | 0 .../assets/file-icons/file_type_saltstack.svg | 0 .../src}/assets/file-icons/file_type_sass.svg | 0 .../src}/assets/file-icons/file_type_sbt.svg | 0 .../assets/file-icons/file_type_scala.svg | 0 .../assets/file-icons/file_type_scilab.svg | 0 .../assets/file-icons/file_type_script.svg | 0 .../src}/assets/file-icons/file_type_scss.svg | 0 .../assets/file-icons/file_type_scss2.svg | 0 .../assets/file-icons/file_type_sdlang.svg | 0 .../assets/file-icons/file_type_sequelize.svg | 0 .../assets/file-icons/file_type_shaderlab.svg | 0 .../assets/file-icons/file_type_shell.svg | 0 .../file-icons/file_type_silverstripe.svg | 0 .../assets/file-icons/file_type_sketch.svg | 0 .../assets/file-icons/file_type_skipper.svg | 0 .../assets/file-icons/file_type_slice.svg | 0 .../src}/assets/file-icons/file_type_slim.svg | 0 .../src}/assets/file-icons/file_type_sln.svg | 0 .../assets/file-icons/file_type_smarty.svg | 0 .../assets/file-icons/file_type_snort.svg | 0 .../src}/assets/file-icons/file_type_snyk.svg | 0 .../file-icons/file_type_solidarity.svg | 0 .../assets/file-icons/file_type_solidity.svg | 0 .../assets/file-icons/file_type_source.svg | 0 .../src}/assets/file-icons/file_type_sqf.svg | 0 .../src}/assets/file-icons/file_type_sql.svg | 0 .../assets/file-icons/file_type_sqlite.svg | 0 .../assets/file-icons/file_type_squirrel.svg | 0 .../src}/assets/file-icons/file_type_sss.svg | 0 .../assets/file-icons/file_type_stata.svg | 0 .../file-icons/file_type_storyboard.svg | 0 .../assets/file-icons/file_type_storybook.svg | 0 .../assets/file-icons/file_type_stylable.svg | 0 .../assets/file-icons/file_type_style.svg | 0 .../assets/file-icons/file_type_stylelint.svg | 0 .../assets/file-icons/file_type_stylus.svg | 0 .../file-icons/file_type_subversion.svg | 0 .../assets/file-icons/file_type_svelte.svg | 0 .../src}/assets/file-icons/file_type_svg.svg | 0 .../assets/file-icons/file_type_swagger.svg | 0 .../assets/file-icons/file_type_swift.svg | 0 .../file-icons/file_type_systemverilog.svg | 0 .../assets/file-icons/file_type_tailwind.svg | 0 .../src}/assets/file-icons/file_type_tcl.svg | 0 .../assets/file-icons/file_type_terraform.svg | 0 .../src}/assets/file-icons/file_type_test.svg | 0 .../assets/file-icons/file_type_testjs.svg | 0 .../assets/file-icons/file_type_testts.svg | 0 .../src}/assets/file-icons/file_type_tex.svg | 0 .../src}/assets/file-icons/file_type_text.svg | 0 .../assets/file-icons/file_type_textile.svg | 0 .../src}/assets/file-icons/file_type_tfs.svg | 0 .../src}/assets/file-icons/file_type_todo.svg | 0 .../src}/assets/file-icons/file_type_toml.svg | 0 .../assets/file-icons/file_type_travis.svg | 0 .../assets/file-icons/file_type_tsconfig.svg | 0 .../assets/file-icons/file_type_tslint.svg | 0 .../assets/file-icons/file_type_turbo.svg | 0 .../src}/assets/file-icons/file_type_twig.svg | 0 .../file-icons/file_type_typescript.svg | 0 .../file_type_typescript_official.svg | 0 .../file-icons/file_type_typescriptdef.svg | 0 .../file_type_typescriptdef_official.svg | 0 .../assets/file-icons/file_type_vagrant.svg | 0 .../src}/assets/file-icons/file_type_vash.svg | 0 .../src}/assets/file-icons/file_type_vb.svg | 0 .../src}/assets/file-icons/file_type_vba.svg | 0 .../assets/file-icons/file_type_vbhtml.svg | 0 .../assets/file-icons/file_type_vbproj.svg | 0 .../assets/file-icons/file_type_vcxproj.svg | 0 .../assets/file-icons/file_type_velocity.svg | 0 .../assets/file-icons/file_type_vercel.svg | 0 .../assets/file-icons/file_type_verilog.svg | 0 .../src}/assets/file-icons/file_type_vhdl.svg | 0 .../assets/file-icons/file_type_video.svg | 0 .../src}/assets/file-icons/file_type_view.svg | 0 .../src}/assets/file-icons/file_type_vim.svg | 0 .../src}/assets/file-icons/file_type_vite.svg | 0 .../assets/file-icons/file_type_vitest.svg | 0 .../src}/assets/file-icons/file_type_volt.svg | 0 .../assets/file-icons/file_type_vscode.svg | 0 .../assets/file-icons/file_type_vscode2.svg | 0 .../src}/assets/file-icons/file_type_vsix.svg | 0 .../src}/assets/file-icons/file_type_vue.svg | 0 .../src}/assets/file-icons/file_type_wasm.svg | 0 .../file-icons/file_type_watchmanconfig.svg | 0 .../assets/file-icons/file_type_webpack.svg | 0 .../assets/file-icons/file_type_wercker.svg | 0 .../assets/file-icons/file_type_wolfram.svg | 0 .../src}/assets/file-icons/file_type_word.svg | 0 .../src}/assets/file-icons/file_type_wxml.svg | 0 .../src}/assets/file-icons/file_type_wxss.svg | 0 .../assets/file-icons/file_type_xcode.svg | 0 .../src}/assets/file-icons/file_type_xib.svg | 0 .../assets/file-icons/file_type_xliff.svg | 0 .../src}/assets/file-icons/file_type_xml.svg | 0 .../src}/assets/file-icons/file_type_xsl.svg | 0 .../src}/assets/file-icons/file_type_yaml.svg | 0 .../src}/assets/file-icons/file_type_yang.svg | 0 .../src}/assets/file-icons/file_type_yarn.svg | 0 .../assets/file-icons/file_type_yeoman.svg | 0 .../src}/assets/file-icons/file_type_zig.svg | 0 .../src}/assets/file-icons/file_type_zip.svg | 0 .../src}/assets/file-icons/file_type_zip2.svg | 0 .../JetBrainsMono/JetBrainsMono-Bold.woff2 | Bin .../JetBrainsMono-BoldItalic.woff2 | Bin .../JetBrainsMono-ExtraBold.woff2 | Bin .../JetBrainsMono-ExtraBoldItalic.woff2 | Bin .../JetBrainsMono-ExtraLight.woff2 | Bin .../JetBrainsMono-ExtraLightItalic.woff2 | Bin .../JetBrainsMono/JetBrainsMono-Italic.woff2 | Bin .../JetBrainsMono/JetBrainsMono-Light.woff2 | Bin .../JetBrainsMono-LightItalic.woff2 | Bin .../JetBrainsMono/JetBrainsMono-Medium.woff2 | Bin .../JetBrainsMono-MediumItalic.woff2 | Bin .../JetBrainsMono/JetBrainsMono-Regular.woff2 | Bin .../JetBrainsMono-SemiBold.woff2 | Bin .../JetBrainsMono-SemiBoldItalic.woff2 | Bin .../JetBrainsMono/JetBrainsMono-Thin.woff2 | Bin .../JetBrainsMono-ThinItalic.woff2 | Bin .../src}/assets/fonts/JetBrainsMono/OFL.txt | 0 .../fonts/OpenRunde/OpenRunde-Bold.woff | Bin .../fonts/OpenRunde/OpenRunde-Bold.woff2 | Bin .../fonts/OpenRunde/OpenRunde-Medium.woff | Bin .../fonts/OpenRunde/OpenRunde-Medium.woff2 | Bin .../fonts/OpenRunde/OpenRunde-Regular.woff | Bin .../fonts/OpenRunde/OpenRunde-Regular.woff2 | Bin .../fonts/OpenRunde/OpenRunde-Semibold.woff | Bin .../fonts/OpenRunde/OpenRunde-Semibold.woff2 | Bin packages/ui/src/assets/hedgehogs.ts | 3 + .../src/assets}/hedgehogs/builder-hog-03.png | Bin .../ui/src/assets}/hedgehogs/explorer-hog.png | Bin .../ui/src/assets}/hedgehogs/happy-hog.png | Bin .../ui/src}/assets/images/mail-hog.png | Bin .../ui/src}/assets/images/robo-zen.png | Bin .../ui/src}/assets/images/zen.png | Bin .../ui/src}/assets/services/airops.png | Bin .../ui/src}/assets/services/atlassian.svg | 0 .../ui/src}/assets/services/attio.png | Bin .../ui/src}/assets/services/box.svg | 0 .../ui/src}/assets/services/browserbase.svg | 0 .../ui/src}/assets/services/canva.svg | 0 .../ui/src}/assets/services/circle.png | Bin .../assets/services/cisco_thousandeyes.png | Bin .../ui/src}/assets/services/clerk.svg | 0 .../ui/src}/assets/services/clickhouse.svg | 0 .../ui/src}/assets/services/cloudflare.svg | 0 .../ui/src}/assets/services/context7.svg | 0 .../ui/src}/assets/services/datadog.svg | 0 .../ui/src}/assets/services/figma.svg | 0 .../ui/src}/assets/services/firetiger.svg | 0 .../ui/src}/assets/services/github.svg | 0 .../ui/src}/assets/services/gitlab.svg | 0 .../ui/src}/assets/services/hex.svg | 0 .../ui/src}/assets/services/hubspot.svg | 0 .../ui/src}/assets/services/launchdarkly.png | Bin .../ui/src}/assets/services/linear.svg | 0 .../ui/src}/assets/services/monday.svg | 0 .../ui/src}/assets/services/neon.svg | 0 .../ui/src}/assets/services/notion.svg | 0 .../ui/src}/assets/services/pagerduty.svg | 0 .../ui/src}/assets/services/planetscale.svg | 0 .../ui/src}/assets/services/postman.svg | 0 .../ui/src}/assets/services/prisma.svg | 0 .../ui/src}/assets/services/render.svg | 0 .../ui/src}/assets/services/sanity.svg | 0 .../ui/src}/assets/services/sentry.svg | 0 .../ui/src}/assets/services/slack.png | Bin .../ui/src}/assets/services/stripe.png | Bin .../ui/src}/assets/services/supabase.svg | 0 .../ui/src}/assets/services/svelte.png | Bin .../ui/src}/assets/services/wix.png | Bin .../ui/src}/assets/sounds/bubbles.mp3 | Bin .../ui/src}/assets/sounds/danilo.mp3 | Bin .../ui/src}/assets/sounds/drop.mp3 | Bin .../ui/src}/assets/sounds/guitar.mp3 | Bin .../ui/src}/assets/sounds/knock.mp3 | Bin .../ui/src}/assets/sounds/meep-smol.mp3 | Bin .../ui/src}/assets/sounds/meep.mp3 | Bin .../ui/src}/assets/sounds/revi.mp3 | Bin .../ui/src}/assets/sounds/ring.mp3 | Bin .../ui/src}/assets/sounds/shoot.mp3 | Bin .../ui/src}/assets/sounds/slide.mp3 | Bin .../ui/src}/assets/sounds/switch.mp3 | Bin .../ui/src}/assets/sounds/wilhelm.mp3 | Bin .../src/features/actions}/ActionTabIcon.tsx | 19 +- .../ui/src/features/actions}/actionStore.ts | 0 .../agent/agent-events.contribution.ts | 33 + .../ui/src/features/agent/agent.module.ts | 7 + .../ai-approval}/AiApprovalScreen.tsx | 43 +- .../archive}/ArchivedTasksView.stories.tsx | 10 +- .../features/archive}/ArchivedTasksView.tsx | 257 +-- .../ui/src/features/archive/useArchiveTask.ts | 181 ++ .../features/archive}/useArchivedTaskIds.ts | 6 +- .../src/features/archive/useUnarchiveTask.ts | 94 + .../ui/src/features/auth}/OAuthControls.tsx | 13 +- .../ui/src/features/auth}/RegionSelect.tsx | 8 +- .../ui/src/features/auth}/SignInCard.tsx | 11 +- .../features/auth/assets}/posthog-icon.svg | 0 .../ui/src/features/auth/auth.contribution.ts | 24 + packages/ui/src/features/auth/auth.module.ts | 7 + packages/ui/src/features/auth/authClient.ts | 70 + .../src/features/auth/authClientImperative.ts | 32 + packages/ui/src/features/auth/authQueries.ts | 48 + .../ui/src/features/auth}/authUiStateStore.ts | 2 +- .../features/auth/components/AuthScreen.tsx | 4 +- .../auth/components/InviteCodeScreen.tsx | 10 +- .../components/ScopeReauthPrompt.test.tsx | 6 +- .../auth}/components/ScopeReauthPrompt.tsx | 9 +- .../features/auth/components/SignInCard.tsx | 13 + packages/ui/src/features/auth/identifiers.ts | 17 + packages/ui/src/features/auth/store.ts | 38 + .../ui/src/features/auth/useAuthMutations.ts | 58 + .../ui/src/features/auth}/useAuthSession.ts | 50 +- .../ui/src/features/auth/useCurrentUser.ts | 39 + .../ui/src/features/auth}/useMeQuery.ts | 2 +- packages/ui/src/features/auth/useOAuthFlow.ts | 36 + .../ui/src/features/auth}/useOrgRole.ts | 4 +- packages/ui/src/features/auth/userInitials.ts | 1 + .../src/features/billing}/SidebarUsageBar.tsx | 17 +- .../billing}/TokenSpendAnalysisBanner.tsx | 91 +- .../src/features/billing}/UsageLimitModal.tsx | 16 +- .../features/billing/billing.contribution.ts | 44 +- .../ui/src/features/billing/billing.module.ts | 10 + .../ui/src/features/billing/seatClient.ts | 68 + .../ui/src/features/billing/seatStore.test.ts | 134 ++ packages/ui/src/features/billing/seatStore.ts | 103 + .../features/billing}/usageLimitStore.test.ts | 0 .../src/features/billing}/usageLimitStore.ts | 0 .../ui/src/features/billing}/useFreeUsage.ts | 4 +- packages/ui/src/features/billing/useSeat.ts | 23 + .../src/features/billing/useSpendAnalysis.ts | 50 + packages/ui/src/features/billing/useUsage.ts | 42 + .../src/features/clone/clone.contribution.ts | 54 + .../ui/src/features/clone/clone.module.ts | 7 + .../ui/src/features/clone/cloneActions.ts | 29 + .../ui/src/features/clone/cloneStore.test.ts | 58 + packages/ui/src/features/clone/cloneStore.ts | 64 + .../components/CodeEditorPanel.tsx | 113 +- .../components/CodeMirrorEditor.tsx | 4 +- .../components/EnrichmentPopover.tsx | 47 +- .../features/code-editor}/diffViewerStore.ts | 4 +- .../extensions/postHogEnrichment.ts | 61 +- .../code-editor/hooks/useCloudFileContent.ts | 4 +- .../code-editor/hooks/useCodeMirror.ts | 50 +- .../code-editor/hooks/useEditorExtensions.ts | 9 +- .../code-editor/hooks/useFileContent.ts | 45 + .../code-editor/hooks/useFileEnrichment.ts | 43 + .../code-editor}/pendingScrollStore.ts | 0 .../stores/enrichmentPopoverStore.ts | 6 +- .../features/code-editor/theme/editorTheme.ts | 0 .../features/code-editor/utils/languages.ts | 0 .../components/CloudReviewPage.tsx | 39 +- .../components/CommentAnnotation.tsx | 8 +- .../components/DiffSettingsMenu.tsx | 2 +- .../components/DiffSourceSelector.tsx | 4 +- .../components/DraftCommentAnnotation.tsx | 2 +- .../components/InteractiveFileDiff.tsx | 85 +- .../components/PatchedFileDiff.tsx | 6 +- .../components/PendingReviewBar.tsx | 6 +- .../components/PrCommentThread.tsx | 18 +- .../code-review/components/ReviewPage.tsx | 49 +- .../code-review/components/ReviewRows.tsx | 16 +- .../code-review/components/ReviewShell.tsx | 269 +++ .../code-review/components/ReviewToolbar.tsx | 14 +- .../components/reviewItemBuilders.tsx | 22 +- .../ui/src}/features/code-review/constants.ts | 0 .../code-review/hooks/useCommentState.ts | 2 +- .../code-review/hooks/useDiffStatsToggle.ts | 6 +- .../hooks/useEffectiveDiffSource.ts | 30 +- .../hooks/useExpandableFileDiff.ts | 12 +- .../code-review/hooks/usePrCommentActions.ts | 26 +- .../hooks/useReadRepoFileBounded.ts | 4 +- .../code-review/hooks/useRevertHunk.ts | 44 + .../code-review/hooks/useReviewDiffs.ts | 24 +- .../hooks/useTaskDiffSummaryStats.ts | 61 + .../code-review/prCommentAnnotations.ts | 2 + .../code-review}/reviewDraftsStore.test.ts | 0 .../code-review}/reviewDraftsStore.ts | 13 +- .../ui/src/features/code-review/reviewHost.ts | 9 + .../code-review}/reviewNavigationStore.ts | 0 .../code-review/reviewShellParts.test.tsx | 28 +- .../features/code-review/reviewShellParts.tsx | 294 +++ packages/ui/src/features/code-review/types.ts | 31 + .../commandCenterStore.test.ts | 0 .../command-center}/commandCenterStore.ts | 51 +- .../components/CommandCenterGrid.tsx | 6 +- .../components/CommandCenterPRButton.tsx | 8 +- .../components/CommandCenterPanel.tsx | 21 +- .../components/CommandCenterSessionView.tsx | 12 +- .../components/CommandCenterToolbar.tsx | 26 +- .../components/CommandCenterView.tsx | 6 +- .../components/TaskSelector.tsx | 6 +- .../hooks/useAutofillCommandCenter.test.ts | 14 +- .../hooks/useAutofillCommandCenter.ts | 43 +- .../command-center/hooks/useAvailableTasks.ts | 26 + .../hooks/useCommandCenterData.ts | 61 + .../src/features/command}/CommandKeyHints.tsx | 0 .../ui/src/features/command}/CommandMenu.tsx | 32 +- .../ui/src/features/command}/FilePicker.tsx | 18 +- .../command/KeyboardShortcutsSheet.tsx | 201 ++ .../features/command}/keyboard-shortcuts.ts | 2 +- .../connectivity-events.contribution.ts | 37 + .../connectivity/connectivity.module.ts | 9 + .../connectivity/connectivityClient.ts | 17 + .../connectivity/connectivityToast.ts | 50 + .../features/deep-links/useNewTaskDeepLink.ts | 102 + .../deep-links/useTaskDeepLink.test.tsx | 78 + .../features/deep-links}/useTaskDeepLink.ts | 57 +- .../editor/components/GithubRefChip.tsx | 0 .../editor/components/MarkdownRenderer.tsx | 18 +- .../environments}/EnvironmentSelector.tsx | 18 +- .../features/environments/useEnvironments.ts | 10 + .../external-apps/focusCoordinator.ts | 20 + .../external-apps/useExternalAppAction.ts | 60 + .../external-apps/useExternalApps.test.tsx | 70 + .../features/external-apps/useExternalApps.ts | 57 + .../src/features/feature-flags/identifiers.ts | 11 + .../features/feature-flags/useFeatureFlag.ts | 20 + .../file-watcher/file-watcher.contribution.ts | 15 + .../file-watcher/file-watcher.module.ts | 7 + .../src/features/file-watcher/identifiers.ts | 6 + .../file-watcher/useRepoFileWatcher.ts | 80 + .../focus/focus-events.contribution.ts | 57 + .../ui/src/features/focus/focus.module.ts | 7 + .../ui/src/features/focus/focusAdapter.ts | 65 + packages/ui/src/features/focus/focusClient.ts | 7 + packages/ui/src/features/focus/focusStore.ts | 90 + .../ui/src/features/focus}/focusToast.tsx | 4 +- .../folder-picker}/AddDirectoryDialog.tsx | 11 +- .../features/folder-picker}/FolderPicker.tsx | 13 +- .../folder-picker}/GitHubRepoPicker.tsx | 2 +- .../folder-picker}/addDirectoryDialogStore.ts | 0 packages/ui/src/features/folders/types.ts | 9 + .../ui/src/features/folders/useFolders.ts | 92 + .../git-interaction/cloudPrUrl.test.ts | 17 +- .../features/git-interaction/cloudPrUrl.ts | 14 +- .../components/BranchSelector.test.tsx | 28 +- .../components/BranchSelector.tsx | 89 +- .../components/CloudGitInteractionHeader.tsx | 70 +- .../components/CreatePrDialog.stories.tsx | 6 +- .../components/CreatePrDialog.tsx | 24 +- .../GitInteractionDialogs.stories.tsx | 5 +- .../components/GitInteractionDialogs.tsx | 10 +- .../components/PRBadgeLink.tsx | 8 +- .../components/TaskActionsMenu.tsx | 42 +- .../features/git-interaction/gitCacheKeys.ts | 75 + .../git-interaction/gitCacheProvider.ts | 21 + .../git-interaction/gitInteractionAdapter.ts | 96 + .../src/features/git-interaction/prIcon.tsx | 32 + .../state/gitInteractionStore.test.ts | 2 +- .../state/gitInteractionStore.ts | 8 +- .../ui/src/features/git-interaction/types.ts | 28 + .../features/git-interaction/useCloudPrUrl.ts | 13 + .../git-interaction}/useFixWithAgent.ts | 8 +- .../git-interaction/useGitInteraction.ts | 477 ++++ .../git-interaction}/useGitQueries.ts | 141 +- .../git-interaction}/useLinkedBranchPrUrl.ts | 28 +- .../features/git-interaction/usePrActions.ts | 41 + .../features/git-interaction}/usePrDetails.ts | 44 +- .../features/git-interaction}/useTaskPrUrl.ts | 28 +- .../git-interaction/utils/branchCreation.ts | 31 + .../git-interaction/utils/diffStats.ts | 6 + .../features/git-interaction/utils/fileKey.ts | 0 .../utils/getSuggestedBranchName.ts | 35 + .../git-interaction/utils/gitStatusUtils.ts | 5 + .../utils/partitionByStaged.ts | 1 + .../git-interaction/utils/updateGitCache.ts | 21 +- .../inbox/components/DataSourceSetup.tsx | 191 +- .../inbox/components/DismissReportDialog.tsx | 20 +- .../inbox/components/InboxEmptyStates.tsx | 13 +- .../inbox/components/InboxSetupPane.tsx | 2 +- .../inbox/components/InboxSignalsTab.tsx | 103 +- .../inbox/components/InboxSourcesDialog.tsx | 2 +- .../features/inbox/components/InboxView.tsx | 14 +- .../inbox/components/SignalSourceToggles.tsx | 17 +- .../components/detail/MultiSelectStack.tsx | 4 +- .../components/detail/ReportDetailPane.tsx | 227 +- .../components/detail/ReportTaskLogs.tsx | 13 +- .../inbox/components/detail/SignalCard.tsx | 23 +- .../detail/signalInteractionContext.ts | 2 +- .../inbox/components/list/FilterSortMenu.tsx | 20 +- .../list/GitHubConnectionBanner.tsx | 20 +- .../inbox/components/list/ReportListPane.tsx | 4 +- .../inbox/components/list/ReportListRow.tsx | 6 +- .../inbox/components/list/SignalsToolbar.tsx | 95 +- .../list/SuggestedReviewerFilterMenu.tsx | 12 +- .../components/utils/AnimatedEllipsis.tsx | 0 .../utils/ExplainedDismissOptionLabels.tsx | 2 +- .../inbox/components/utils/PgAnalyzeIcon.tsx | 0 .../components/utils/ReportCardContent.tsx | 14 +- .../utils/ReportImplementationPrLink.tsx | 2 +- .../utils/SignalReportActionabilityBadge.tsx | 4 +- .../utils/SignalReportPriorityBadge.tsx | 4 +- .../utils/SignalReportStatusBadge.tsx | 6 +- .../utils/SignalReportSummaryMarkdown.tsx | 2 +- .../components/utils/source-product-icons.tsx | 0 .../inbox/devtools/inboxDemoConsole.ts | 16 +- .../features/inbox/hooks/useCreatePrReport.ts | 145 ++ .../inbox/hooks/useDiscussReport.test.tsx | 83 + .../features/inbox/hooks/useDiscussReport.ts | 153 ++ .../features/inbox/hooks/useEvaluations.ts | 8 +- .../inbox/hooks/useExternalDataSources.ts | 6 +- .../inbox/hooks/useInboxBulkActions.ts | 203 ++ .../features/inbox/hooks/useInboxDeepLink.ts | 38 +- .../inbox/hooks/useInboxDeepLinkListSync.ts | 8 +- .../inbox/hooks/useInboxEngagementTracker.ts | 73 +- .../features/inbox/hooks/useInboxReports.ts | 13 +- .../features/inbox/hooks/useReportTasks.ts | 43 + .../useSeedSuggestedReviewerFilter.test.ts | 2 +- .../hooks/useSeedSuggestedReviewerFilter.ts | 2 +- .../inbox/hooks/useSignalSourceConfigs.ts | 6 +- .../inbox/hooks/useSignalSourceManager.ts | 374 +++ .../inbox/hooks/useSignalTeamConfig.ts | 4 +- .../hooks/useSignalUserAutonomyConfig.ts | 4 +- .../features/inbox/hooks/useSlackChannels.ts | 4 +- .../inboxAvailableSuggestedReviewersStore.ts | 2 +- .../inbox}/inboxReportSelectionStore.test.ts | 0 .../inbox}/inboxReportSelectionStore.ts | 0 .../inbox}/inboxSignalsFilterStore.test.ts | 0 .../inbox}/inboxSignalsFilterStore.ts | 2 +- .../inbox}/inboxSourcesDialogStore.ts | 0 .../inbox/stores/inboxCloudTaskStore.ts | 32 + .../inbox/stores/inboxSignalsSidebarStore.ts | 2 +- .../features/inbox/utils/inboxConstants.ts | 0 .../ui/src/features/inbox/utils/inboxSort.ts | 20 + .../inbox/utils/pendingInboxOpenMethod.ts | 2 +- .../integrations/integrationsClientImpl.ts | 53 + .../ui/src/features/integrations/store.ts | 26 + .../useGitHubIntegrationCallback.ts | 62 +- .../integrations/useGithubDisconnect.ts | 57 + .../integrations}/useGithubUserConnect.ts | 210 +- .../features/integrations}/useIntegrations.ts | 351 +-- .../features/integrations}/useSlackConnect.ts | 80 +- .../useSlackIntegrationCallback.ts | 62 +- .../mcp-apps/components/McpAppHost.tsx | 44 +- .../mcp-apps/components}/McpToolBlock.tsx | 31 +- .../mcp-apps/components/McpToolView.tsx | 10 +- .../features/mcp-apps/hooks/useAppBridge.ts | 10 +- .../ui/src/features/mcp-apps/identifiers.ts | 23 + .../mcp-apps/utils/mcp-app-csp.test.ts | 0 .../features/mcp-apps/utils/mcp-app-csp.ts | 0 .../mcp-apps/utils/mcp-app-host-utils.test.ts | 0 .../mcp-apps/utils/mcp-app-host-utils.ts | 0 .../mcp-apps/utils/mcp-app-theme.test.ts | 0 .../features/mcp-apps/utils/mcp-app-theme.ts | 0 .../mcp-servers/components/McpServersView.tsx | 18 +- .../components/parts/AddCustomServerForm.tsx | 33 +- .../components/parts/MarketplaceView.tsx | 16 +- .../components/parts/McpInstalledRail.tsx | 52 +- .../components/parts/ServerCard.tsx | 4 +- .../components/parts/ServerDetailView.tsx | 78 +- .../components/parts/ToolPolicyToggle.tsx | 2 +- .../mcp-servers/components/parts/ToolRow.tsx | 4 +- .../mcp-servers/components/parts/icons.tsx | 114 + .../components/parts/statusBadge.ts | 22 + .../hooks/useMcpInstallationTools.ts | 43 +- .../mcp-servers/hooks/useMcpServers.ts | 98 +- .../src}/features/message-editor/analytics.ts | 0 .../src}/features/message-editor/commands.ts | 53 +- .../components/AdapterIndicator.tsx | 0 .../components/AttachmentMenu.test.tsx | 31 +- .../components/AttachmentMenu.tsx | 29 +- .../components/AttachmentsBar.tsx | 18 +- .../message-editor/components/IssuePicker.tsx | 34 +- .../message-editor/components/IssueRow.tsx | 2 +- .../components/ModeSelector.tsx | 2 +- .../components/PromptHistoryDialog.tsx | 10 +- .../components/PromptInput.stories.tsx | 85 +- .../message-editor/components/PromptInput.tsx | 8 +- .../components/SuggestionStatus.tsx | 0 .../components/message-editor.css | 0 .../ui/src/features/message-editor/content.ts | 14 + .../features/message-editor}/draftStore.ts | 4 +- .../features/message-editor/githubIssueUrl.ts | 5 + .../ui/src/features/message-editor/hostApi.ts | 109 + .../features/message-editor/identifiers.ts | 12 + .../message-editor}/promptHistoryStore.ts | 0 .../suggestions/getSuggestions.ts | 93 + .../message-editor}/taskInputHistoryStore.ts | 0 .../message-editor/tiptap/CommandGhostText.ts | 0 .../message-editor/tiptap/CommandMention.ts | 2 +- .../message-editor/tiptap/FileMention.ts | 0 .../message-editor/tiptap/IssueMention.tsx | 0 .../message-editor/tiptap/MentionChipNode.ts | 0 .../message-editor/tiptap/MentionChipView.tsx | 8 +- .../message-editor/tiptap/SuggestionList.tsx | 2 +- .../tiptap/createSuggestionMention.ts | 4 +- .../message-editor/tiptap/extensions.ts | 0 .../tiptap/useDraftSync.test.tsx | 4 +- .../message-editor/tiptap/useDraftSync.ts | 8 +- .../message-editor/tiptap/useTiptapEditor.ts | 89 +- .../ui/src}/features/message-editor/types.ts | 4 +- .../message-editor}/useAutoFocusOnTyping.ts | 2 +- .../message-editor/utils/persistFile.test.ts | 95 + .../message-editor/utils/persistFile.ts | 65 + .../ui/src/features/navigation/store.test.ts | 45 +- .../ui/src/features/navigation/store.ts | 85 +- .../ui/src/features/navigation/taskBinder.ts | 15 + .../src/features/navigation/taskBinderImpl.ts | 98 + .../src/features/notifications/identifiers.ts | 26 + .../notifications/notifications.module.ts | 6 + .../notifications/notifications.test.ts | 169 ++ .../features/notifications/notifications.ts | 76 + .../onboarding/components/CliCheckPanel.tsx | 2 +- .../components/ConnectGitHubStep.tsx | 12 +- .../components/FeatureBentoCard.css | 0 .../components/FeatureBentoCard.tsx | 0 .../components/GitHubConnectPanel.tsx | 217 +- .../onboarding/components/InstallCliStep.tsx | 40 +- .../onboarding/components/InviteCodeStep.tsx | 12 +- .../onboarding/components/OnboardingFlow.tsx | 114 +- .../onboarding/components/OptionalBadge.tsx | 0 .../components/ProjectSelectStep.tsx | 50 +- .../onboarding/components/SelectRepoStep.tsx | 21 +- .../onboarding/components/StepActions.tsx | 0 .../onboarding/components/StepIndicator.tsx | 0 .../onboarding/components/WelcomeScreen.tsx | 10 +- .../onboarding/components/onboardingStyles.ts | 0 .../onboarding/githubConnectClientImpl.ts | 18 + .../onboarding/hooks/useOnboardingFlow.ts | 133 ++ .../hooks/useProjectsWithIntegrations.ts | 39 +- .../features/onboarding}/onboardingStore.ts | 4 +- packages/ui/src/features/onboarding/types.ts | 5 + .../panels/components/DraggableTab.tsx | 60 +- .../panels/components/GroupNodeRenderer.tsx | 6 +- .../panels/components/LeafNodeRenderer.tsx | 8 +- .../src}/features/panels/components/Panel.tsx | 0 .../panels/components/PanelDropZones.tsx | 2 +- .../features/panels/components/PanelGroup.tsx | 0 .../panels/components/PanelLayout.tsx | 8 +- .../panels/components/PanelResizeHandle.tsx | 0 .../features/panels/components/PanelTab.tsx | 2 +- .../features/panels/components/PanelTree.tsx | 0 .../panels/components/TabbedPanel.tsx | 19 +- .../panels/hooks/useDragDropHandlers.ts | 7 +- .../panels/hooks/usePanelKeyboardShortcuts.ts | 6 +- .../panels/hooks/usePanelLayoutHooks.tsx | 31 +- .../ui/src/features/panels/panelConstants.ts | 11 + .../features/panels}/panelLayoutStore.test.ts | 21 +- .../src/features/panels/panelLayoutStore.ts | 377 +++ .../src/features/panels/panelLayoutUtils.ts | 4 + .../src/features/panels/panelStoreHelpers.ts | 89 + .../src/features/panels}/panelTestHelpers.ts | 4 +- packages/ui/src/features/panels/panelTree.ts | 50 + packages/ui/src/features/panels/panelTypes.ts | 50 + packages/ui/src/features/panels/panelUtils.ts | 5 + .../permissions/DefaultPermission.tsx | 2 +- .../permissions/DeletePermission.tsx | 4 +- .../features}/permissions/EditPermission.tsx | 4 +- .../permissions/ExecutePermission.tsx | 4 +- .../features}/permissions/FetchPermission.tsx | 2 +- .../features}/permissions/McpPermission.tsx | 8 +- .../features}/permissions/MovePermission.tsx | 2 +- .../PermissionSelector.stories.tsx | 2 +- .../permissions/PermissionSelector.tsx | 0 .../src/features}/permissions/PlanContent.tsx | 0 .../permissions/QuestionPermission.tsx | 12 +- .../features}/permissions/ReadPermission.tsx | 2 +- .../permissions/SearchPermission.tsx | 2 +- .../permissions/SwitchModePermission.tsx | 2 +- .../features}/permissions/ThinkPermission.tsx | 2 +- .../ui/src/features}/permissions/types.ts | 6 +- .../utils/posthog-exec-display.test.ts | 0 .../posthog-mcp/utils/posthog-exec-display.ts | 0 .../src/features/projects}/useProjectQuery.ts | 4 +- .../ui/src/features/projects}/useProjects.tsx | 17 +- .../provisioning/ProvisioningView.tsx | 42 + .../provisioning/provisioning.contribution.ts | 23 + .../provisioning/provisioning.module.ts | 7 + .../ui/src/features/provisioning/store.ts | 15 +- .../repo-files}/useDetectedCloudRepository.ts | 18 +- .../src/features/repo-files}/useRepoFiles.ts | 66 +- .../features/right-sidebar}/fileTreeStore.ts | 0 .../features/sessions/agentPromptSender.ts | 8 + .../components/CloudInitializingView.tsx | 4 +- .../ContextBreakdownPopover.test.tsx | 2 +- .../components/ContextBreakdownPopover.tsx | 4 +- .../components/ContextUsageIndicator.tsx | 4 +- .../components/ConversationSearchBar.tsx | 0 .../components/ConversationView.stories.tsx | 4 +- .../sessions/components/ConversationView.tsx | 85 +- .../sessions/components/DiffStatsChip.tsx | 10 +- .../sessions/components/DirtyTreeDialog.tsx | 10 +- .../sessions/components/DropZoneOverlay.tsx | 0 .../components/GeneratingIndicator.test.ts | 3 +- .../components/GeneratingIndicator.tsx | 0 .../sessions/components/GitActionMessage.tsx | 0 .../sessions/components/GitActionResult.tsx | 19 +- .../components/HandoffConfirmDialog.tsx | 2 +- .../sessions/components/ModelSelector.tsx | 11 +- .../sessions/components/PendingChatView.tsx | 8 +- .../components/PendingInputPlaceholder.tsx | 0 .../components/PlanStatusBar.stories.tsx | 4 +- .../sessions/components/PlanStatusBar.tsx | 10 +- .../components/ReasoningLevelSelector.tsx | 2 +- .../sessions/components/SessionFooter.tsx | 12 +- .../sessions/components/SessionView.tsx | 231 +- .../components/UnifiedModelSelector.tsx | 4 +- .../sessions/components/VirtualizedList.tsx | 0 .../components/buildConversationItems.test.ts | 4 +- .../components/buildConversationItems.ts | 33 +- .../components/mergeConversationItems.test.ts | 2 +- .../components/mergeConversationItems.ts | 0 .../components/raw-logs/RawLogEntry.tsx | 3 +- .../components/raw-logs/RawLogsHeader.tsx | 0 .../components/raw-logs/RawLogsView.tsx | 16 +- .../session-update/AgentMessage.tsx | 18 +- .../components/session-update/CodePreview.tsx | 7 +- .../session-update/CompactBoundaryView.tsx | 0 .../session-update/ConsoleMessage.tsx | 0 .../session-update/DeleteToolView.tsx | 0 .../session-update/EditToolView.tsx | 0 .../session-update/ErrorNotificationView.tsx | 0 .../session-update/ExecuteToolView.tsx | 2 +- .../session-update/FetchToolView.tsx | 0 .../session-update/FileMentionChip.tsx | 37 +- .../session-update/MoveToolView.tsx | 0 .../session-update/PlanApprovalView.test.tsx | 2 +- .../session-update/PlanApprovalView.tsx | 2 +- .../session-update/ProgressGroupView.tsx | 2 +- .../session-update/QuestionToolView.tsx | 0 .../session-update/QueuedMessageView.tsx | 4 +- .../session-update/ReadToolView.tsx | 2 +- .../session-update/SearchToolView.tsx | 0 .../session-update/SessionUpdateView.tsx | 26 +- .../session-update/StatusNotificationView.tsx | 0 .../session-update/SubagentToolView.tsx | 13 +- .../session-update/TaskNotificationView.tsx | 0 .../session-update/ThinkToolView.tsx | 0 .../components/session-update/ThoughtView.tsx | 0 .../session-update/ToolCallBlock.stories.tsx | 7 +- .../session-update/ToolCallBlock.tsx | 46 +- .../session-update/ToolCallView.tsx | 4 +- .../components/session-update/ToolRow.tsx | 0 .../session-update/UserMessage.test.tsx | 8 +- .../components/session-update/UserMessage.tsx | 10 +- .../session-update/UserShellExecuteView.tsx | 2 +- .../components/session-update/identifiers.ts | 10 + .../session-update/parseFileMentions.tsx | 12 +- .../session-update/toolCallUtils.tsx | 4 +- .../useCodePreviewExtensions.ts | 6 +- .../sessions/components/useFileContextMenu.ts | 44 + .../ui/src}/features/sessions/constants.ts | 0 .../src/features/sessions}/contextColors.ts | 2 +- .../features/sessions}/handoffDialogStore.ts | 2 +- .../sessions/hooks/useAgentVersion.ts | 4 +- .../hooks/useChatTitleGenerator.test.ts | 106 +- .../sessions/hooks/useChatTitleGenerator.ts | 118 +- .../sessions/hooks/useContextUsage.ts | 13 + .../sessions/hooks/useConversationSearch.ts | 9 +- .../sessions/hooks/useSessionCallbacks.ts | 76 +- .../sessions/hooks/useSessionConnection.ts | 75 + .../sessions/hooks/useSessionViewState.ts | 22 + .../ui/src/features/sessions/identifiers.ts | 2 + .../features/sessions/localHandoffService.ts | 56 + .../features/sessions}/sendPromptToAgent.ts | 16 +- .../features/sessions}/sessionAdapterStore.ts | 2 +- .../features/sessions}/sessionConfigStore.ts | 2 +- .../src/features/sessions/sessionLogTypes.ts | 1 + ...onServiceHost.recovery.integration.test.ts | 97 +- .../sessions/sessionServiceHost.test.ts | 113 +- .../features/sessions/sessionServiceHost.ts | 170 ++ .../features/sessions}/sessionStore.test.ts | 0 .../ui/src/features/sessions}/sessionStore.ts | 242 +- .../features/sessions}/sessionViewStore.ts | 0 .../ui/src}/features/sessions/types.ts | 0 .../ui/src/features/sessions}/useSession.ts | 6 +- .../features/sessions}/useSessionTaskId.tsx | 0 .../src/features/sessions/userMessageTypes.ts | 4 + .../utils/extractSearchableText.test.ts | 4 +- .../sessions/utils/extractSearchableText.ts | 4 +- .../features/settings}/FolderSettingsView.tsx | 8 +- .../settings}/ModalInlineComboboxContent.tsx | 0 .../ui/src/features/settings}/SettingRow.tsx | 0 .../src/features/settings}/SettingsDialog.tsx | 28 +- .../settings}/SettingsOptionSelect.tsx | 0 .../settings}/sections/AccountSettings.tsx | 16 +- .../settings}/sections/AdvancedSettings.tsx | 16 +- .../settings}/sections/ClaudeCodeSettings.tsx | 12 +- .../settings}/sections/GeneralSettings.tsx | 32 +- .../sections/GitHubIntegrationSection.tsx | 26 +- .../settings}/sections/GitHubSettings.tsx | 45 +- .../sections/PermissionsSettings.tsx | 7 +- .../sections/PersonalizationSettings.tsx | 8 +- .../settings}/sections/PlanUsageSettings.tsx | 46 +- .../settings/sections/ShortcutsSettings.tsx | 5 + .../SignalSlackNotificationsSettings.tsx | 103 +- .../sections/SignalSourcesSettings.tsx | 16 +- .../settings}/sections/SlackSettings.tsx | 14 +- .../settings}/sections/TerminalSettings.tsx | 10 +- .../settings}/sections/UpdatesSettings.tsx | 39 +- .../settings}/sections/WorkspacesSettings.tsx | 72 +- .../CloudEnvironmentsSettings.tsx | 124 +- .../sections/environments/EnvironmentForm.tsx | 30 +- .../sections/environments/EnvironmentRow.tsx | 2 +- .../environments/EnvironmentsSettings.tsx | 2 +- .../LocalEnvironmentsSettings.tsx | 12 +- .../environments/ProjectEnvironmentCard.tsx | 4 +- .../environments}/useSandboxEnvironments.ts | 8 +- .../worktrees/WorktreeGroupSection.tsx | 2 +- .../sections/worktrees/WorktreeRow.tsx | 8 +- .../sections/worktrees/WorktreeSize.tsx | 4 +- .../sections/worktrees/WorktreesSettings.tsx | 153 +- .../settings}/settingsDialogStore.test.ts | 0 .../features/settings}/settingsDialogStore.ts | 0 .../features/settings}/settingsStore.test.ts | 14 +- .../src/features/settings}/settingsStore.ts | 5 +- .../setup}/DiscoveredTaskDetailDialog.tsx | 32 +- .../ui/src/features/setup}/SetupScanFeed.tsx | 4 +- .../ui/src/features/setup}/categoryConfig.ts | 2 +- .../ui/src/features/setup/setup.module.ts | 7 + .../src/features/setup/setupRunServiceImpl.ts | 206 ++ packages/ui/src/features/setup/setupStore.ts | 194 ++ .../src/features/setup/useSetupDiscovery.ts | 14 + .../sidebar/components/DraggableFolder.tsx | 0 .../sidebar/components/MainSidebar.tsx | 11 +- .../sidebar/components/ProjectSwitcher.tsx | 39 +- .../features/sidebar/components/Sidebar.tsx | 4 +- .../sidebar/components/SidebarContent.tsx | 12 +- .../sidebar/components/SidebarItem.tsx | 2 +- .../sidebar/components/SidebarMenu.tsx | 155 +- .../sidebar/components/SidebarSection.tsx | 2 +- .../sidebar/components/SidebarTrigger.tsx | 8 +- .../sidebar/components/TaskListView.tsx | 48 +- .../sidebar/components/UpdateBanner.tsx | 11 +- .../components/items/CommandCenterItem.tsx | 0 .../sidebar/components/items/HomeItem.tsx | 8 +- .../components/items/McpServersItem.tsx | 0 .../sidebar/components/items/SearchItem.tsx | 2 +- .../components/items/SidebarKbdHint.tsx | 2 +- .../sidebar/components/items/SkillsItem.tsx | 0 .../sidebar/components/items/TaskIcon.tsx | 17 +- .../sidebar/components/items/TaskItem.tsx | 10 +- .../ui/src}/features/sidebar/constants.ts | 0 .../ui/src/features/sidebar}/sidebarStore.ts | 2 +- .../ui/src/features/sidebar/taskMetaApi.ts | 53 + .../sidebar}/taskSelectionStore.test.ts | 0 .../features/sidebar}/taskSelectionStore.ts | 43 +- .../ui/src}/features/sidebar/types.ts | 0 .../ui/src/features/sidebar}/useCwd.ts | 4 +- .../ui/src/features/sidebar/usePinnedTasks.ts | 78 + .../ui/src/features/sidebar/useSidebarData.ts | 243 ++ .../features/sidebar}/useTaskPrStatus.test.ts | 13 +- .../src/features/sidebar/useTaskPrStatus.ts | 35 + .../ui/src/features/sidebar/useTaskViewed.ts | 136 ++ .../features/sidebar}/useVisualTaskOrder.ts | 7 +- .../SkillButtonActionMessage.stories.tsx | 2 +- .../components/SkillButtonActionMessage.tsx | 5 +- .../components/SkillButtonsMenu.stories.tsx | 2 +- .../components/SkillButtonsMenu.tsx | 22 +- .../ui/src/features/skill-buttons/prompts.ts | 47 + .../skill-buttons}/skillButtonsStore.ts | 14 +- .../ui/src/features/skills}/SkillCard.tsx | 2 +- .../src/features/skills}/SkillDetailPanel.tsx | 18 +- .../ui/src/features/skills}/SkillsView.tsx | 16 +- .../features/skills}/skillsSidebarStore.ts | 2 +- .../ui/src/features/skills/useSkills.test.tsx | 50 + packages/ui/src/features/skills/useSkills.ts | 9 + .../features/suspension}/useRestoreTask.ts | 28 +- .../suspension/useSuspendTask.test.tsx | 90 + .../src/features/suspension/useSuspendTask.ts | 64 + .../suspension}/useSuspendedTaskIds.ts | 8 +- .../suspension/useSuspensionSettings.ts | 34 + .../task-detail}/BranchMismatchDialog.tsx | 0 .../task-detail}/HeaderTitleEditor.tsx | 0 .../task-detail/components/ActionPanel.tsx | 2 +- .../task-detail/components/ChangesPanel.tsx | 183 +- .../components/ChangesTreeView.tsx | 4 +- .../components/CloudGithubMissingNotice.tsx | 8 +- .../components/ExternalAppsOpener.tsx | 23 +- .../task-detail/components/FileTreePanel.tsx | 63 +- .../components/SuggestedTaskCard.tsx | 6 +- .../components/SuggestedTasksPanel.tsx | 20 +- .../components/TabContentRenderer.tsx | 22 +- .../task-detail/components/TaskDetail.tsx | 46 +- .../task-detail/components/TaskInput.tsx | 123 +- .../task-detail/components/TaskLogsPanel.tsx | 40 +- .../components/TaskPendingView.tsx | 4 +- .../task-detail/components/TaskShellPanel.tsx | 20 +- .../components/WorkspaceModeSelect.tsx | 8 +- .../components/WorkspaceSetupPrompt.tsx | 82 +- .../task-detail/hooks/useCloudChangedFiles.ts | 8 +- .../task-detail/hooks/useCloudEventSummary.ts | 4 +- .../task-detail/hooks/useCloudRunState.ts | 40 + .../task-detail/hooks/useDiscardFile.ts | 44 + .../useInitialDirectoryFromFolderId.test.ts | 2 +- .../hooks/useInitialDirectoryFromFolderId.ts | 2 +- .../task-detail/hooks/usePreviewConfig.ts | 137 ++ .../task-detail/hooks/useStageToggle.ts | 34 + .../task-detail/hooks/useTaskCreation.ts | 137 +- .../features/task-detail/hooks/useTaskData.ts | 38 +- .../task-detail/taskCreationEffectsImpl.ts | 54 + .../task-detail/taskCreationHostImpl.ts | 152 ++ .../ui/src/features/tasks}/taskKeys.ts | 0 packages/ui/src/features/tasks/taskStore.ts | 102 + .../ui/src/features/tasks}/taskStore.types.ts | 61 +- .../src/features/tasks}/useTaskContextMenu.ts | 70 +- .../tasks/useTaskCrudMutations.test.tsx | 73 + .../features/tasks/useTaskCrudMutations.ts | 116 + .../features/tasks/useTaskMutations.test.tsx | 25 +- .../ui/src/features/tasks/useTaskMutations.ts | 143 ++ packages/ui/src/features/tasks/useTasks.ts | 73 + .../src/features/terminal}/ActionTerminal.tsx | 2 +- .../src/features/terminal}/ShellTerminal.tsx | 4 +- .../ui/src/features/terminal}/Terminal.tsx | 56 +- .../src/features/terminal}/TerminalManager.ts | 44 +- .../ui/src/features/terminal/shellClient.ts | 43 + .../src/features/terminal}/terminalStore.ts | 46 +- .../terminal/useShellProcessPoller.ts | 28 + .../features/tour/components/TourOverlay.tsx | 10 +- .../features/tour/components/TourTooltip.tsx | 8 +- .../features/tour/hooks/useElementRect.ts | 0 packages/ui/src/features/tour/tourStore.ts | 100 + .../tour/tours/createFirstTaskTour.ts | 11 +- .../ui/src/features/updates/updateStore.ts | 51 + .../ui/src/features/updates/updatesAdapter.ts | 28 + .../ui/src/features/updates/updatesClient.ts | 23 + .../ui/src/features/workspace/identifiers.ts | 6 + .../workspace}/useBranchMismatch.test.ts | 0 .../features/workspace}/useBranchMismatch.ts | 13 +- .../useBranchMismatchDialog.test.ts | 41 +- .../workspace}/useBranchMismatchDialog.ts | 99 +- .../features/workspace}/useFocusWorkspace.tsx | 36 +- .../src/features/workspace}/useIsCloudTask.ts | 0 .../features/workspace/useLocalRepoPath.ts | 11 + .../ui/src/features/workspace/useWorkspace.ts | 39 + .../features/workspace}/useWorkspaceEvents.ts | 9 +- .../workspace/useWorkspaceMutations.test.tsx | 91 + .../workspace/useWorkspaceMutations.ts | 121 + .../workspace-events.contribution.test.ts | 89 + .../workspace-events.contribution.ts | 60 + .../features/workspace/workspace.module.ts | 9 + packages/ui/src/hooks/createSelectors.ts | 20 + .../ui/src/hooks/useAuthenticatedClient.ts | 5 + .../hooks/useAuthenticatedInfiniteQuery.ts | 6 +- .../ui/src}/hooks/useAuthenticatedMutation.ts | 4 +- .../ui/src}/hooks/useAuthenticatedQuery.ts | 6 +- .../ui/src}/hooks/useBlurOnEscape.ts | 4 +- packages/ui/src/hooks/useConnectivity.ts | 8 + .../ui/src}/hooks/useSetHeaderContent.ts | 2 +- .../ui/src/primitives}/ActionSelector.tsx | 0 .../ui/src/primitives}/BackgroundWrapper.tsx | 0 .../ui/src/primitives}/Badge.tsx | 0 .../ui/src/primitives}/Button.tsx | 2 +- .../ui/src/primitives}/CodeBlock.test.tsx | 8 +- .../ui/src/primitives}/CodeBlock.tsx | 0 .../ui/src/primitives}/Divider.tsx | 0 .../src/primitives}/DotPatternBackground.tsx | 0 .../ui/src/primitives}/DotsCircleSpinner.tsx | 0 .../ui/src/primitives/DraggableTitleBar.tsx | 16 + .../ui/src/primitives}/ErrorBoundary.tsx | 46 +- .../ui/src/primitives}/FileIcon.tsx | 10 +- .../ui/src/primitives}/FullScreenLayout.tsx | 21 +- .../ui/src/primitives}/HighlightedCode.tsx | 4 +- .../ui/src/primitives}/KeyHint.tsx | 0 .../primitives}/KeyboardShortcutsSheet.tsx | 6 +- .../ui/src/primitives}/List.tsx | 0 .../ui/src/primitives}/LoginTransition.tsx | 0 .../ui/src/primitives/Logo.tsx | 2 +- .../ui/src/primitives}/OnboardingHogTip.tsx | 0 .../ui/src/primitives}/PanelMessage.tsx | 0 .../ui/src/primitives}/RelativeTimestamp.tsx | 4 +- .../ui/src/primitives}/ResizableSidebar.tsx | 2 +- .../ui/src/primitives}/SafeImagePreview.tsx | 2 +- .../ui/src/primitives}/StepList.tsx | 0 .../ui/src/primitives}/ThemeWrapper.tsx | 2 +- .../ui/src/primitives}/Tooltip.tsx | 0 .../ui/src/primitives}/TreeDirectoryRow.tsx | 2 +- .../ui/src/primitives}/ZenHedgehog.tsx | 4 +- .../action-selector/ActionSelector.tsx | 2 +- .../action-selector/InlineEditableText.tsx | 0 .../primitives}/action-selector/OptionRow.tsx | 2 +- .../primitives}/action-selector/StepTabs.tsx | 0 .../primitives}/action-selector/constants.ts | 0 .../src/primitives}/action-selector/types.ts | 0 .../action-selector/useActionSelectorState.ts | 0 .../ui/src/primitives}/combobox/Combobox.css | 0 .../primitives}/combobox/Combobox.stories.tsx | 2 +- .../ui/src/primitives}/combobox/Combobox.tsx | 0 .../combobox/useComboboxFilter.test.ts | 2 +- .../primitives}/combobox/useComboboxFilter.ts | 2 +- .../ui/src/primitives}/confetti.ts | 0 .../src/primitives}/hooks/useDebounce.test.ts | 2 +- .../ui/src/primitives}/hooks/useDebounce.ts | 0 .../primitives}/hooks/useDebouncedValue.ts | 0 .../hooks/useImagePanAndZoom.test.tsx | 2 +- .../primitives}/hooks/useImagePanAndZoom.ts | 0 .../ui/src/primitives}/hooks/useInView.ts | 0 .../ui/src/primitives}/toast.tsx | 0 .../ui/src}/styles/fieldTrigger.ts | 0 .../ui/src}/styles/globals.css | 71 +- packages/ui/src/test/setup.ts | 56 + .../ui/src}/utils/agentVersion.test.ts | 0 .../ui/src}/utils/agentVersion.ts | 0 .../ui/src}/utils/browser.ts | 4 +- packages/ui/src/utils/clearStorage.ts | 27 + .../ui/src}/utils/dialog.ts | 16 +- packages/ui/src/utils/getFilePath.ts | 14 + .../ui/src}/utils/overlay.test.ts | 0 .../ui/src}/utils/overlay.ts | 0 .../ui/src}/utils/platform.ts | 0 .../ui/src}/utils/posthogLinks.ts | 8 +- packages/ui/src/utils/promptContent.test.ts | 68 + packages/ui/src/utils/promptContent.ts | 125 + .../ui/src}/utils/random.ts | 0 .../ui/src}/utils/sendMessageKey.test.ts | 16 +- .../ui/src}/utils/sendMessageKey.ts | 2 +- .../ui/src}/utils/sounds.ts | 28 +- .../ui/src}/utils/syntax-highlight.ts | 0 .../ui/src}/utils/urls.test.ts | 9 +- .../ui/src}/utils/urls.ts | 8 +- packages/ui/src/workbench/App.tsx | 206 ++ .../ui/src/workbench}/ErrorBoundary.test.tsx | 11 +- packages/ui/src/workbench/ErrorBoundary.tsx | 43 + .../ui/src/workbench/FullScreenLayout.tsx | 21 + .../ui/src/workbench}/GlobalEventHandlers.tsx | 49 +- .../ui/src/workbench}/HeaderRow.tsx | 44 +- packages/ui/src/workbench/HedgehogMode.tsx | 75 + .../ui/src/workbench}/MainLayout.tsx | 113 +- .../ui/src/workbench}/SpaceSwitcher.tsx | 6 +- .../ui/src/workbench}/activeRepoStore.ts | 0 packages/ui/src/workbench/analytics.ts | 76 + .../ui/src/workbench}/commandMenuStore.ts | 0 .../ui/src/workbench}/createSidebarStore.ts | 0 packages/ui/src/workbench/diffWorkerHost.ts | 3 + .../ui/src/workbench}/headerStore.ts | 0 packages/ui/src/workbench/hedgehogModeHost.ts | 25 + packages/ui/src/workbench/logger.ts | 51 + packages/ui/src/workbench/openExternal.ts | 11 + .../src/workbench}/pendingTaskPromptStore.ts | 2 +- .../workbench/posthogAnalyticsImpl.test.ts | 2 +- .../ui/src/workbench/posthogAnalyticsImpl.ts | 41 +- packages/ui/src/workbench/queryClient.ts | 7 + packages/ui/src/workbench/rendererStorage.ts | 35 + .../workbench}/rendererWindowFocusStore.ts | 0 .../ui/src/workbench}/shortcutsSheetStore.ts | 0 .../ui/src/workbench}/themeStore.ts | 0 {apps/code => packages/ui}/tailwind.config.js | 8 +- packages/ui/tsconfig.json | 7 +- packages/ui/vitest.config.ts | 28 + packages/workspace-client/src/environment.ts | 7 + .../workspace-server}/drizzle.config.ts | 4 +- packages/workspace-server/package.json | 25 +- packages/workspace-server/src/db/db.module.ts | 7 + .../workspace-server/src/db/identifiers.ts | 26 + .../src}/db/migrations/0000_red_jigsaw.sql | 0 .../src}/db/migrations/0001_tan_lifeguard.sql | 0 .../db/migrations/0002_massive_bishop.sql | 0 .../src}/db/migrations/0003_fair_whiplash.sql | 0 .../db/migrations/0004_auth_preferences.sql | 0 .../0005_youthful_scarlet_spider.sql | 0 .../db/migrations/0006_youthful_warstar.sql | 0 .../db/migrations/meta/0000_snapshot.json | 0 .../db/migrations/meta/0001_snapshot.json | 0 .../db/migrations/meta/0002_snapshot.json | 0 .../db/migrations/meta/0003_snapshot.json | 0 .../db/migrations/meta/0004_snapshot.json | 0 .../db/migrations/meta/0005_snapshot.json | 0 .../db/migrations/meta/0006_snapshot.json | 0 .../src}/db/migrations/meta/_journal.json | 0 .../src/db}/normalize-path.ts | 0 .../src/db/repositories.module.ts | 34 + .../repositories/archive-repository.mock.ts | 0 .../db/repositories/archive-repository.ts | 4 +- .../auth-preference-repository.mock.ts | 0 .../auth-preference-repository.ts | 4 +- .../auth-session-repository.mock.ts | 0 .../repositories/auth-session-repository.ts | 7 +- ...lt-additional-directory-repository.mock.ts | 0 ...default-additional-directory-repository.ts | 6 +- .../src/db/repositories/repositories.test.ts | 82 + .../repository-repository.mock.ts | 0 .../db/repositories/repository-repository.ts | 4 +- .../suspension-repository.mock.ts | 0 .../db/repositories/suspension-repository.ts | 8 +- .../repositories/workspace-repository.mock.ts | 0 .../db/repositories/workspace-repository.ts | 9 +- .../repositories/worktree-repository.mock.ts | 0 .../db/repositories/worktree-repository.ts | 4 +- .../workspace-server/src}/db/schema.ts | 0 .../workspace-server/src}/db/service.ts | 32 +- .../workspace-server/src}/db/test-helpers.ts | 2 +- packages/workspace-server/src/di/container.ts | 14 + packages/workspace-server/src/di/tokens.ts | 13 +- .../additional-directories.module.ts | 9 + .../additional-directories.test.ts | 73 + .../additional-directories.ts | 48 + .../additional-directories/identifiers.ts | 3 + .../src/services/agent/agent.module.ts | 9 + .../src/services/agent/agent.test.ts | 35 +- .../src/services/agent/agent.ts | 249 +- .../src}/services/agent/auth-adapter.test.ts | 20 +- .../src}/services/agent/auth-adapter.ts | 35 +- .../services/agent/discover-plugins.test.ts | 11 - .../src}/services/agent/discover-plugins.ts | 119 +- .../src/services/agent/identifiers.ts | 11 + .../src/services/agent/ports.ts | 64 + .../src}/services/agent/schemas.ts | 4 +- .../archive/archive.integration.test.ts | 36 +- .../src/services/archive/archive.module.ts | 7 + .../src/services/archive/archive.ts | 193 +- .../src/services/archive/identifiers.ts | 7 + .../src/services/archive/ports.ts | 7 + .../src}/services/archive/schemas.ts | 16 +- .../services/auth-proxy/auth-proxy.module.ts | 7 + .../src/services/auth-proxy/auth-proxy.ts | 37 +- .../src/services/auth-proxy/identifiers.ts | 4 + .../src/services/auth-proxy/ports.ts | 3 + .../src}/services/connectivity/schemas.ts | 0 .../services/connectivity/service.test.ts | 54 +- .../src}/services/connectivity/service.ts | 68 +- .../detectPosthogInstallState.test.ts | 48 +- .../services/enrichment/enrichment.module.ts | 7 + .../src/services/enrichment/enrichment.ts | 74 +- .../findStaleFlagSuggestions.test.ts | 43 +- .../src/services/enrichment/identifiers.ts | 5 + .../src/services/enrichment/ports.ts | 21 + .../src}/services/environment/schemas.ts | 0 .../src}/services/environment/service.test.ts | 0 .../src}/services/environment/service.ts | 0 .../external-apps/external-apps.module.ts | 7 + .../services/external-apps/external-apps.ts | 45 +- .../src/services/external-apps/identifiers.ts | 6 + .../src/services/external-apps/ports.ts | 6 + .../src}/services/external-apps/schemas.ts | 3 +- .../src}/services/external-apps/types.ts | 2 +- .../src/services/focus/service.ts | 20 +- .../src/services/folders/folders.module.ts | 7 + .../src/services/folders/folders.test.ts | 72 +- .../src/services/folders/folders.ts | 69 +- .../src/services/folders/identifiers.ts | 1 + .../src}/services/folders/schemas.ts | 0 .../src/services/fs/identifiers.ts | 33 + .../src/services/fs/schemas.ts | 70 + .../src/services/fs/service.test.ts | 100 + .../src/services/fs/service.ts | 248 +- .../src/services/git/git.integration.test.ts | 304 +++ .../src/services/git/schemas.ts | 529 +++++ .../src/services/git/service.ts | 1601 ++++++++++++- .../src/services/handoff/identifiers.ts | 6 + .../src/services/handoff/ports.ts | 29 + .../src/services/handoff/service.test.ts | 161 ++ .../src/services/handoff/service.ts | 278 +++ .../src/services/local-logs/identifiers.ts | 7 + .../src/services/local-logs/schemas.ts | 26 + .../src}/services/local-logs/service.test.ts | 72 +- .../src}/services/local-logs/service.ts | 56 +- .../src/services/mcp-callback/identifiers.ts | 6 + .../mcp-callback/mcp-callback-server.ts | 136 ++ .../mcp-callback/mcp-callback.module.ts | 9 + .../src/services/mcp-callback/mcp-callback.ts | 212 ++ .../src}/services/mcp-callback/schemas.ts | 0 .../src/services/mcp-proxy/identifiers.ts | 4 + .../services/mcp-proxy/mcp-proxy.module.ts | 7 + .../src/services/mcp-proxy/mcp-proxy.test.ts | 44 +- .../src/services/mcp-proxy/mcp-proxy.ts | 54 +- .../src/services/mcp-proxy/ports.ts | 4 + .../services/oauth-callback/identifiers.ts | 3 + .../oauth-callback/oauth-callback.module.ts | 7 + .../services/oauth-callback/oauth-callback.ts | 151 ++ .../src/services/os/identifiers.ts | 1 + .../src/services/os/os.module.ts | 7 + .../src/services/os/os.test.ts | 191 ++ .../workspace-server/src/services/os/os.ts | 315 +++ .../src/services/os/schemas.ts | 85 + .../src}/services/posthog-plugin/README.md | 0 .../services/posthog-plugin}/extract-zip.ts | 0 .../services/posthog-plugin/identifiers.ts | 3 + .../posthog-plugin/posthog-plugin.module.ts | 7 + .../posthog-plugin/posthog-plugin.test.ts | 60 +- .../services/posthog-plugin/posthog-plugin.ts | 69 +- .../posthog-plugin/update-skills-saga.ts | 2 +- .../services/process-tracking/identifiers.ts | 3 + .../process-tracking.module.ts | 7 + .../process-tracking/process-tracking.test.ts | 15 +- .../process-tracking/process-tracking.ts | 57 +- .../process-tracking}/process-utils.ts | 7 +- .../src/services/process-tracking/schemas.ts | 46 + .../repo-fs-query/repo-fs-query.test.ts | 66 + .../services/repo-fs-query/repo-fs-query.ts | 41 + .../src/services/secure-store/identifiers.ts | 10 + .../src/services/secure-store/schemas.ts | 12 + .../src}/services/session-env/loader.test.ts | 0 .../src}/services/session-env/loader.ts | 21 +- .../src/services/shell/identifiers.ts | 1 + .../src}/services/shell/schemas.ts | 0 .../src/services/shell/shell.module.ts | 7 + .../src/services/shell/shell.ts | 55 +- .../src/services/skills/identifiers.ts | 1 + .../skills}/parse-skill-frontmatter.ts | 0 .../src/services/skills/schemas.ts | 5 +- .../services/skills/skill-discovery.test.ts | 85 + .../src/services/skills/skill-discovery.ts | 101 + .../src/services/skills/skills.module.ts | 7 + .../src/services/skills/skills.ts | 48 + .../src/services/suspension/identifiers.ts | 9 + .../src/services/suspension/ports.ts | 7 + .../src}/services/suspension/schemas.ts | 37 +- .../services/suspension/suspension.module.ts | 7 + .../services/suspension/suspension.test.ts | 85 +- .../src/services/suspension/suspension.ts | 208 +- .../services/watcher-registry/identifiers.ts | 3 + .../watcher-registry.module.ts | 7 + .../watcher-registry/watcher-registry.ts | 48 +- .../workspace-metadata/identifiers.ts | 3 + .../workspace-metadata.module.ts | 9 + .../workspace-metadata.test.ts | 158 ++ .../workspace-metadata/workspace-metadata.ts | 74 + .../src/services/workspace/identifiers.ts | 11 + .../src/services/workspace/ports.ts | 33 + .../src}/services/workspace/schemas.ts | 56 +- .../services/workspace/workspace.module.ts | 7 + .../src/services/workspace/workspace.test.ts | 217 ++ .../src/services/workspace/workspace.ts | 451 ++-- .../worktree-checkpoint.test.ts | 152 ++ .../worktree-checkpoint.ts | 84 + .../worktree-path/worktree-path.test.ts | 81 + .../services/worktree-path/worktree-path.ts | 50 + .../worktree-query/worktree-query.test.ts | 109 + .../services/worktree-query/worktree-query.ts | 115 + packages/workspace-server/src/trpc.ts | 689 +++++- .../workspace-server/src/workspace-env.ts | 2 +- packages/workspace-server/vitest.config.ts | 10 + ...6-05-27-workspace-server-vertical-slice.md | 179 -- pnpm-lock.yaml | 1048 ++++++--- pnpm-workspace.yaml | 36 +- scripts/check-host-boundaries.mjs | 152 ++ scripts/host-boundary-allowlist.json | 30 + scripts/refactor-init.sh | 11 +- 2402 files changed, 63858 insertions(+), 35865 deletions(-) create mode 100644 PORTING.md delete mode 100644 REFACTOR_PROGRESS.md delete mode 100644 REFACTOR_SLICES.json create mode 100644 apps/code/src/main/di/platform-identifiers.test.ts create mode 100644 apps/code/src/main/platform-adapters/electron-crypto.ts create mode 100644 apps/code/src/main/platform-adapters/electron-usage-threshold-store.ts create mode 100644 apps/code/src/main/platform-adapters/electron-workspace-settings.ts rename apps/code/src/main/{services => platform-adapters}/posthog-analytics.test.ts (81%) create mode 100644 apps/code/src/main/platform-adapters/posthog-analytics.ts create mode 100644 apps/code/src/main/services/auth/port-adapters.ts create mode 100644 apps/code/src/main/services/encryption/service.test.ts create mode 100644 apps/code/src/main/services/encryption/service.ts delete mode 100644 apps/code/src/main/services/file-watcher/bridge.ts create mode 100644 apps/code/src/main/services/focus/desktop-adapters.ts delete mode 100644 apps/code/src/main/services/focus/schemas.ts delete mode 100644 apps/code/src/main/services/focus/service.ts delete mode 100644 apps/code/src/main/services/fs/schemas.ts delete mode 100644 apps/code/src/main/services/fs/service.test.ts delete mode 100644 apps/code/src/main/services/fs/service.ts delete mode 100644 apps/code/src/main/services/git/service.test.ts delete mode 100644 apps/code/src/main/services/git/service.ts delete mode 100644 apps/code/src/main/services/github-integration/schemas.ts delete mode 100644 apps/code/src/main/services/handoff/service.test.ts delete mode 100644 apps/code/src/main/services/handoff/service.ts delete mode 100644 apps/code/src/main/services/linear-integration/schemas.ts delete mode 100644 apps/code/src/main/services/mcp-callback/service.ts delete mode 100644 apps/code/src/main/services/posthog-analytics.ts create mode 100644 apps/code/src/main/services/secure-store/service.test.ts create mode 100644 apps/code/src/main/services/secure-store/service.ts delete mode 100644 apps/code/src/main/services/shell/service.test.ts delete mode 100644 apps/code/src/main/services/slack-integration/schemas.ts delete mode 100644 apps/code/src/main/services/usage-monitor/store.ts delete mode 100644 apps/code/src/main/trpc/routers/additional-directories.ts delete mode 100644 apps/code/src/main/trpc/routers/agent.ts delete mode 100644 apps/code/src/main/trpc/routers/analytics.ts delete mode 100644 apps/code/src/main/trpc/routers/archive.ts delete mode 100644 apps/code/src/main/trpc/routers/auth.ts delete mode 100644 apps/code/src/main/trpc/routers/cloud-task.ts delete mode 100644 apps/code/src/main/trpc/routers/connectivity.ts delete mode 100644 apps/code/src/main/trpc/routers/context-menu.ts delete mode 100644 apps/code/src/main/trpc/routers/deep-link.ts delete mode 100644 apps/code/src/main/trpc/routers/environment.ts delete mode 100644 apps/code/src/main/trpc/routers/external-apps.ts delete mode 100644 apps/code/src/main/trpc/routers/file-watcher.ts delete mode 100644 apps/code/src/main/trpc/routers/focus.ts delete mode 100644 apps/code/src/main/trpc/routers/folders.ts delete mode 100644 apps/code/src/main/trpc/routers/fs.ts delete mode 100644 apps/code/src/main/trpc/routers/git.ts delete mode 100644 apps/code/src/main/trpc/routers/github-integration.ts delete mode 100644 apps/code/src/main/trpc/routers/linear-integration.ts delete mode 100644 apps/code/src/main/trpc/routers/llm-gateway.ts delete mode 100644 apps/code/src/main/trpc/routers/logs.ts delete mode 100644 apps/code/src/main/trpc/routers/notification.ts delete mode 100644 apps/code/src/main/trpc/routers/oauth.ts delete mode 100644 apps/code/src/main/trpc/routers/os.ts delete mode 100644 apps/code/src/main/trpc/routers/process-tracking.ts delete mode 100644 apps/code/src/main/trpc/routers/provisioning.ts delete mode 100644 apps/code/src/main/trpc/routers/secure-store.ts delete mode 100644 apps/code/src/main/trpc/routers/shell.ts delete mode 100644 apps/code/src/main/trpc/routers/skills.ts delete mode 100644 apps/code/src/main/trpc/routers/slack-integration.ts delete mode 100644 apps/code/src/main/trpc/routers/sleep.ts delete mode 100644 apps/code/src/main/trpc/routers/suspension.ts delete mode 100644 apps/code/src/main/trpc/routers/updates.ts delete mode 100644 apps/code/src/main/trpc/routers/workspace.ts delete mode 100644 apps/code/src/main/utils/typed-event-emitter.ts delete mode 100644 apps/code/src/main/utils/worktree-helpers.ts delete mode 100644 apps/code/src/renderer/App.tsx delete mode 100644 apps/code/src/renderer/assets/fonts/Halfre.otf delete mode 100644 apps/code/src/renderer/assets/images/code.svg delete mode 100644 apps/code/src/renderer/assets/images/hedgehogs/clickthat-hog.png delete mode 100644 apps/code/src/renderer/assets/images/hedgehogs/detective-hog.png delete mode 100644 apps/code/src/renderer/assets/images/hedgehogs/feature-flag-hog.png delete mode 100644 apps/code/src/renderer/assets/images/hedgehogs/graphs-hog.png delete mode 100644 apps/code/src/renderer/assets/images/wordmark-white.svg delete mode 100644 apps/code/src/renderer/assets/images/wordmark.svg delete mode 100644 apps/code/src/renderer/components/ActionSelector.stories.tsx delete mode 100644 apps/code/src/renderer/components/DraggableTitleBar.tsx delete mode 100644 apps/code/src/renderer/components/HedgehogMode.tsx delete mode 100644 apps/code/src/renderer/components/ui/collapsible/collapsible.css create mode 100644 apps/code/src/renderer/contributions/app-boot.contributions.ts delete mode 100644 apps/code/src/renderer/features/auth/hooks/authClient.ts delete mode 100644 apps/code/src/renderer/features/auth/hooks/authMutations.ts delete mode 100644 apps/code/src/renderer/features/auth/hooks/authQueries.ts delete mode 100644 apps/code/src/renderer/features/auth/hooks/useOAuthFlow.ts delete mode 100644 apps/code/src/renderer/features/auth/stores/authStore.test.ts delete mode 100644 apps/code/src/renderer/features/auth/stores/authStore.ts delete mode 100644 apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts delete mode 100644 apps/code/src/renderer/features/billing/hooks/useUsage.ts delete mode 100644 apps/code/src/renderer/features/billing/stores/seatStore.test.ts delete mode 100644 apps/code/src/renderer/features/billing/stores/seatStore.ts delete mode 100644 apps/code/src/renderer/features/code-editor/hooks/useFileEnrichment.ts delete mode 100644 apps/code/src/renderer/features/code-review/components/ReviewShell.tsx delete mode 100644 apps/code/src/renderer/features/code-review/hooks/useTaskDiffSummaryStats.ts create mode 100644 apps/code/src/renderer/features/code-review/reviewHost.tsx delete mode 100644 apps/code/src/renderer/features/command-center/hooks/useAvailableTasks.ts delete mode 100644 apps/code/src/renderer/features/command-center/hooks/useCommandCenterData.ts delete mode 100644 apps/code/src/renderer/features/connectivity/connectivityToast.ts delete mode 100644 apps/code/src/renderer/features/external-apps/hooks/useExternalApps.ts delete mode 100644 apps/code/src/renderer/features/folders/hooks/useFolders.ts delete mode 100644 apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts delete mode 100644 apps/code/src/renderer/features/git-interaction/hooks/usePrActions.ts delete mode 100644 apps/code/src/renderer/features/git-interaction/utils/deriveBranchName.ts delete mode 100644 apps/code/src/renderer/features/git-interaction/utils/getSuggestedBranchName.ts delete mode 100644 apps/code/src/renderer/features/git-interaction/utils/gitCacheKeys.ts delete mode 100644 apps/code/src/renderer/features/git-interaction/utils/partitionByStaged.ts delete mode 100644 apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts delete mode 100644 apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts delete mode 100644 apps/code/src/renderer/features/inbox/hooks/useInboxBulkActions.ts delete mode 100644 apps/code/src/renderer/features/inbox/hooks/useReportTasks.ts delete mode 100644 apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts delete mode 100644 apps/code/src/renderer/features/inbox/stores/inboxCloudTaskStore.ts delete mode 100644 apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts delete mode 100644 apps/code/src/renderer/features/inbox/utils/inboxSort.ts delete mode 100644 apps/code/src/renderer/features/inbox/utils/resolveDefaultModel.ts delete mode 100644 apps/code/src/renderer/features/mcp-servers/components/parts/icons.tsx delete mode 100644 apps/code/src/renderer/features/mcp-servers/components/parts/statusBadge.ts delete mode 100644 apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts delete mode 100644 apps/code/src/renderer/features/onboarding/components/ProjectSelect.tsx delete mode 100644 apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts delete mode 100644 apps/code/src/renderer/features/onboarding/types.ts delete mode 100644 apps/code/src/renderer/features/panels/index.ts delete mode 100644 apps/code/src/renderer/features/panels/store/panelLayoutStore.ts delete mode 100644 apps/code/src/renderer/features/panels/store/panelStore.ts delete mode 100644 apps/code/src/renderer/features/panels/utils/panelLayoutUtils.ts delete mode 100644 apps/code/src/renderer/features/provisioning/components/ProvisioningView.tsx delete mode 100644 apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts delete mode 100644 apps/code/src/renderer/features/sessions/service/localHandoffService.ts delete mode 100644 apps/code/src/renderer/features/sessions/utils/cloudArtifacts.test.ts delete mode 100644 apps/code/src/renderer/features/sessions/utils/cloudArtifacts.ts delete mode 100644 apps/code/src/renderer/features/sessions/utils/parseSessionLogs.ts delete mode 100644 apps/code/src/renderer/features/settings/components/sections/ShortcutsSettings.tsx delete mode 100644 apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts delete mode 100644 apps/code/src/renderer/features/setup/services/setupRunService.ts delete mode 100644 apps/code/src/renderer/features/setup/stores/setupStore.ts delete mode 100644 apps/code/src/renderer/features/sidebar/components/index.tsx delete mode 100644 apps/code/src/renderer/features/sidebar/hooks/usePinnedTasks.ts delete mode 100644 apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts delete mode 100644 apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts delete mode 100644 apps/code/src/renderer/features/sidebar/hooks/useTaskViewed.ts delete mode 100644 apps/code/src/renderer/features/suspension/hooks/useSuspendTask.ts delete mode 100644 apps/code/src/renderer/features/suspension/hooks/useSuspensionSettings.ts delete mode 100644 apps/code/src/renderer/features/task-detail/components/RunModeSelect.tsx delete mode 100644 apps/code/src/renderer/features/task-detail/hooks/useCloudRunState.ts delete mode 100644 apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts delete mode 100644 apps/code/src/renderer/features/task-detail/service/service.ts delete mode 100644 apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts delete mode 100644 apps/code/src/renderer/features/tasks/hooks/useTasks.ts delete mode 100644 apps/code/src/renderer/features/tasks/stores/taskStore.ts delete mode 100644 apps/code/src/renderer/features/tour/stores/tourStore.ts delete mode 100644 apps/code/src/renderer/features/tour/tours/tourRegistry.ts delete mode 100644 apps/code/src/renderer/features/workspace/hooks/index.ts delete mode 100644 apps/code/src/renderer/features/workspace/hooks/useLocalRepoPath.ts delete mode 100644 apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts delete mode 100644 apps/code/src/renderer/hooks/useAuthenticatedClient.ts delete mode 100644 apps/code/src/renderer/hooks/useConnectivity.ts delete mode 100644 apps/code/src/renderer/hooks/useFeatureFlag.ts delete mode 100644 apps/code/src/renderer/hooks/useFileWatcher.ts delete mode 100644 apps/code/src/renderer/hooks/useNewTaskDeepLink.ts delete mode 100644 apps/code/src/renderer/hooks/useRepositoryDirectory.ts create mode 100644 apps/code/src/renderer/platform-adapters/auth-side-effects.ts create mode 100644 apps/code/src/renderer/platform-adapters/git-cache-keys.ts create mode 100644 apps/code/src/renderer/platform-adapters/hedgehog-mode-host.ts create mode 100644 apps/code/src/renderer/platform-adapters/setup.ts create mode 100644 apps/code/src/renderer/platform-adapters/task-deletion.ts create mode 100644 apps/code/src/renderer/platform-adapters/tour.ts create mode 100644 apps/code/src/renderer/platform-adapters/updates.ts delete mode 100644 apps/code/src/renderer/services/.gitkeep delete mode 100644 apps/code/src/renderer/stores/cloneStore.ts delete mode 100644 apps/code/src/renderer/stores/connectivityStore.ts delete mode 100644 apps/code/src/renderer/stores/focusStore.ts delete mode 100644 apps/code/src/renderer/stores/settingsStore.test.ts delete mode 100644 apps/code/src/renderer/stores/settingsStore.ts delete mode 100644 apps/code/src/renderer/stores/updateStore.test.ts delete mode 100644 apps/code/src/renderer/stores/updateStore.ts delete mode 100644 apps/code/src/renderer/types/rehype.d.ts delete mode 100644 apps/code/src/renderer/utils/clearStorage.ts delete mode 100644 apps/code/src/renderer/utils/generateTitle.ts delete mode 100644 apps/code/src/renderer/utils/getFilePath.ts delete mode 100644 apps/code/src/renderer/utils/handleExternalAppAction.tsx delete mode 100644 apps/code/src/renderer/utils/notifications.test.ts delete mode 100644 apps/code/src/renderer/utils/notifications.ts delete mode 100644 apps/code/src/renderer/utils/object.ts delete mode 100644 apps/code/src/shared/constants/environment.ts delete mode 100644 apps/code/src/shared/deeplink.ts delete mode 100644 apps/code/src/shared/mcp-sandbox-proxy.ts delete mode 100644 apps/code/src/shared/test/loggerMock.ts delete mode 100644 apps/code/src/shared/types/posthog.ts delete mode 100644 apps/code/src/shared/types/suspension.ts delete mode 100644 apps/code/src/shared/utils/id.ts create mode 100644 apps/web/dev-server.mjs create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/Providers.tsx create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/web-auth-side-effects.ts create mode 100644 apps/web/src/web-container.ts create mode 100644 apps/web/src/web-trpc.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts rename apps/code/src/renderer/api/posthogClient.test.ts => packages/api-client/src/posthog-client.test.ts (99%) rename apps/code/src/renderer/api/posthogClient.ts => packages/api-client/src/posthog-client.ts (94%) rename {apps/code/src/renderer/features/billing/types => packages/api-client/src}/spend-analysis.ts (100%) create mode 100644 packages/api-client/src/types.ts create mode 100644 packages/core/src/archive/archive.module.ts create mode 100644 packages/core/src/archive/archiveListView.test.ts create mode 100644 packages/core/src/archive/archiveListView.ts create mode 100644 packages/core/src/archive/archiveOrchestration.test.ts create mode 100644 packages/core/src/archive/archiveOrchestration.ts create mode 100644 packages/core/src/archive/archivedTasksController.test.ts create mode 100644 packages/core/src/archive/archivedTasksController.ts create mode 100644 packages/core/src/archive/identifiers.ts create mode 100644 packages/core/src/archive/optimisticArchive.ts create mode 100644 packages/core/src/archive/parseUnarchiveError.test.ts create mode 100644 packages/core/src/archive/parseUnarchiveError.ts create mode 100644 packages/core/src/archive/unarchiveService.test.ts create mode 100644 packages/core/src/archive/unarchiveService.ts create mode 100644 packages/core/src/auth/auth.module.ts rename apps/code/src/main/services/auth/service.test.ts => packages/core/src/auth/auth.test.ts (72%) rename apps/code/src/main/services/auth/service.ts => packages/core/src/auth/auth.ts (81%) create mode 100644 packages/core/src/auth/authErrors.ts create mode 100644 packages/core/src/auth/authIdentity.ts create mode 100644 packages/core/src/auth/identifiers.ts rename apps/code/src/main/services/oauth/schemas.ts => packages/core/src/auth/oauth.schemas.ts (98%) rename {apps/code/src/main/services => packages/core/src}/auth/schemas.ts (94%) rename {apps/code/src/renderer/features/auth/utils => packages/core/src/auth}/userInitials.test.ts (100%) rename {apps/code/src/renderer/features/auth/utils => packages/core/src/auth}/userInitials.ts (100%) create mode 100644 packages/core/src/billing/billing.module.ts create mode 100644 packages/core/src/billing/identifiers.ts create mode 100644 packages/core/src/billing/seatErrors.ts create mode 100644 packages/core/src/billing/seatService.test.ts create mode 100644 packages/core/src/billing/seatService.ts create mode 100644 packages/core/src/billing/seatView.test.ts rename apps/code/src/renderer/hooks/useSeat.ts => packages/core/src/billing/seatView.ts (53%) rename {apps/code/src/renderer/features/billing/utils => packages/core/src/billing}/spendAnalysisFormat.ts (64%) rename {apps/code/src/renderer/features/billing/utils => packages/core/src/billing}/spendAnalysisPrompt.test.ts (98%) rename {apps/code/src/renderer/features/billing/utils => packages/core/src/billing}/spendAnalysisPrompt.ts (95%) create mode 100644 packages/core/src/billing/spendAnalysisTypes.ts create mode 100644 packages/core/src/billing/spendSuggestions.ts rename apps/code/src/renderer/features/billing/utils.test.ts => packages/core/src/billing/usageDisplay.test.ts (95%) rename apps/code/src/renderer/features/billing/utils.ts => packages/core/src/billing/usageDisplay.ts (93%) create mode 100644 packages/core/src/clone/cloneProgress.test.ts create mode 100644 packages/core/src/clone/cloneProgress.ts create mode 100644 packages/core/src/clone/cloneRemovalDelay.test.ts create mode 100644 packages/core/src/clone/cloneRemovalDelay.ts create mode 100644 packages/core/src/clone/cloneSelectors.test.ts create mode 100644 packages/core/src/clone/cloneSelectors.ts create mode 100644 packages/core/src/clone/cloneTypes.ts create mode 100644 packages/core/src/cloud-task/cloud-task-types.ts create mode 100644 packages/core/src/cloud-task/cloud-task.module.ts rename apps/code/src/main/services/cloud-task/service.test.ts => packages/core/src/cloud-task/cloud-task.test.ts (99%) rename apps/code/src/main/services/cloud-task/service.ts => packages/core/src/cloud-task/cloud-task.ts (94%) create mode 100644 packages/core/src/cloud-task/identifiers.ts rename {apps/code/src/main/services => packages/core/src}/cloud-task/schemas.ts (74%) rename {apps/code/src/main/services => packages/core/src}/cloud-task/sse-parser.test.ts (100%) rename {apps/code/src/main/services => packages/core/src}/cloud-task/sse-parser.ts (90%) create mode 100644 packages/core/src/code-editor/buildEnrichmentOccurrences.test.ts create mode 100644 packages/core/src/code-editor/buildEnrichmentOccurrences.ts create mode 100644 packages/core/src/code-editor/enrichmentEligibility.test.ts create mode 100644 packages/core/src/code-editor/enrichmentEligibility.ts create mode 100644 packages/core/src/code-editor/enrichmentPresenters.ts rename apps/code/src/renderer/features/code-editor/utils/markdownUtils.ts => packages/core/src/code-editor/fileKind.ts (100%) create mode 100644 packages/core/src/code-editor/fileSource.test.ts create mode 100644 packages/core/src/code-editor/fileSource.ts rename {apps/code/src/renderer/features/code-editor/utils => packages/core/src/code-editor}/pathUtils.ts (100%) create mode 100644 packages/core/src/code-review/buildToolCallFallbacks.test.ts create mode 100644 packages/core/src/code-review/buildToolCallFallbacks.ts create mode 100644 packages/core/src/code-review/code-review.module.ts rename {apps/code/src/renderer/features/code-review/utils => packages/core/src/code-review}/contentHash.ts (100%) create mode 100644 packages/core/src/code-review/diffAnnotations.test.ts rename {apps/code/src/renderer/features/code-review/utils => packages/core/src/code-review}/diffAnnotations.ts (93%) rename {apps/code/src/renderer/features/code-review/utils => packages/core/src/code-review}/fileDiffExpansion.test.ts (100%) rename {apps/code/src/renderer/features/code-review/utils => packages/core/src/code-review}/fileDiffExpansion.ts (100%) create mode 100644 packages/core/src/code-review/identifiers.ts create mode 100644 packages/core/src/code-review/prCommentAnnotations.test.ts rename {apps/code/src/renderer/features/code-review/utils => packages/core/src/code-review}/prCommentAnnotations.ts (83%) rename {apps/code/src/renderer/features/code-review/utils => packages/core/src/code-review}/resolveDiffSource.test.ts (100%) rename {apps/code/src/renderer/features/code-review/utils => packages/core/src/code-review}/resolveDiffSource.ts (87%) create mode 100644 packages/core/src/code-review/revertHunk.ts create mode 100644 packages/core/src/code-review/revertHunkService.test.ts create mode 100644 packages/core/src/code-review/revertHunkService.ts create mode 100644 packages/core/src/code-review/reviewItemKeys.test.ts create mode 100644 packages/core/src/code-review/reviewItemKeys.ts create mode 100644 packages/core/src/code-review/reviewPrompts.test.ts rename {apps/code/src/renderer/features/code-review/utils => packages/core/src/code-review}/reviewPrompts.ts (95%) create mode 100644 packages/core/src/code-review/reviewShellGeometry.test.ts create mode 100644 packages/core/src/code-review/reviewShellGeometry.ts create mode 100644 packages/core/src/code-review/selectTaskDiffStats.test.ts create mode 100644 packages/core/src/code-review/selectTaskDiffStats.ts rename {apps/code/src/renderer/features => packages/core/src}/code-review/types.ts (58%) create mode 100644 packages/core/src/command-center/autofill.test.ts create mode 100644 packages/core/src/command-center/autofill.ts create mode 100644 packages/core/src/command-center/cells.ts create mode 100644 packages/core/src/command-center/eligibility.test.ts create mode 100644 packages/core/src/command-center/eligibility.ts create mode 100644 packages/core/src/command-center/grid.test.ts create mode 100644 packages/core/src/command-center/grid.ts create mode 100644 packages/core/src/command-center/status.test.ts create mode 100644 packages/core/src/command-center/status.ts create mode 100644 packages/core/src/command-center/stopAll.test.ts create mode 100644 packages/core/src/command-center/stopAll.ts create mode 100644 packages/core/src/connectivity/connectivityStore.ts create mode 100644 packages/core/src/context-menu/context-menu.module.ts create mode 100644 packages/core/src/context-menu/context-menu.test.ts rename apps/code/src/main/services/context-menu/service.ts => packages/core/src/context-menu/context-menu.ts (94%) create mode 100644 packages/core/src/context-menu/identifiers.ts rename {apps/code/src/main/services => packages/core/src}/context-menu/schemas.ts (98%) rename {apps/code/src/main/services => packages/core/src}/context-menu/types.ts (100%) create mode 100644 packages/core/src/deep-links/deep-links.module.ts create mode 100644 packages/core/src/deep-links/identifiers.ts create mode 100644 packages/core/src/deep-links/newTaskLinkResolver.test.ts create mode 100644 packages/core/src/deep-links/newTaskLinkResolver.ts rename {apps/code/src/renderer/features/editor/utils => packages/core/src/editor}/cloud-prompt.test.ts (78%) rename {apps/code/src/renderer/features/editor/utils => packages/core/src/editor}/cloud-prompt.ts (90%) rename {apps/code/src/renderer/features/editor/utils => packages/core/src/editor}/prompt-builder.ts (90%) create mode 100644 packages/core/src/external-apps/external-apps.module.ts create mode 100644 packages/core/src/external-apps/externalAppService.test.ts create mode 100644 packages/core/src/external-apps/externalAppService.ts create mode 100644 packages/core/src/external-apps/identifiers.ts create mode 100644 packages/core/src/focus/focus-host.module.ts create mode 100644 packages/core/src/focus/focus-service.ts create mode 100644 packages/core/src/focus/host-focus.ts create mode 100644 packages/core/src/focus/identifiers.ts create mode 100644 packages/core/src/focus/service.test.ts rename {apps/code/src/renderer/features/git-interaction/utils => packages/core/src/git-interaction}/branchCreation.test.ts (65%) rename {apps/code/src/renderer/features/git-interaction/utils => packages/core/src/git-interaction}/branchCreation.ts (80%) rename apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.test.ts => packages/core/src/git-interaction/branchName.test.ts (80%) rename apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.ts => packages/core/src/git-interaction/branchName.ts (65%) rename {apps/code/src/renderer/features/git-interaction/utils => packages/core/src/git-interaction}/deriveBranchName.test.ts (96%) rename {apps/code/src/renderer/features/git-interaction/utils => packages/core/src/git-interaction}/diffStats.ts (65%) rename {apps/code/src/renderer/features/git-interaction/utils => packages/core/src/git-interaction}/errorPrompts.ts (97%) create mode 100644 packages/core/src/git-interaction/git-interaction.module.ts rename {apps/code/src/renderer/features/git-interaction/state => packages/core/src/git-interaction}/gitInteractionLogic.test.ts (100%) rename {apps/code/src/renderer/features/git-interaction/state => packages/core/src/git-interaction}/gitInteractionLogic.ts (95%) create mode 100644 packages/core/src/git-interaction/gitInteractionService.test.ts create mode 100644 packages/core/src/git-interaction/gitInteractionService.ts rename {apps/code/src/renderer/features/git-interaction/utils => packages/core/src/git-interaction}/gitStatusUtils.ts (91%) create mode 100644 packages/core/src/git-interaction/identifiers.ts rename apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx => packages/core/src/git-interaction/prStatus.ts (70%) create mode 100644 packages/core/src/git-interaction/stagingPlan.test.ts create mode 100644 packages/core/src/git-interaction/stagingPlan.ts rename {apps/code/src/renderer/features => packages/core/src}/git-interaction/types.ts (100%) create mode 100644 packages/core/src/git-pr/create-pr-saga.test.ts rename {apps/code/src/main/services/git => packages/core/src/git-pr}/create-pr-saga.ts (83%) create mode 100644 packages/core/src/git-pr/git-pr.module.ts create mode 100644 packages/core/src/git-pr/git-pr.test.ts create mode 100644 packages/core/src/git-pr/git-pr.ts create mode 100644 packages/core/src/git-pr/identifiers.ts create mode 100644 packages/core/src/git/git-host.module.ts create mode 100644 packages/core/src/git/git-host.ts create mode 100644 packages/core/src/git/host-git.ts create mode 100644 packages/core/src/git/identifiers.ts rename apps/code/src/main/services/git/schemas.ts => packages/core/src/git/router-schemas.ts (81%) rename {apps/code/src/main/services => packages/core/src}/handoff/handoff-saga.test.ts (79%) rename {apps/code/src/main/services => packages/core/src}/handoff/handoff-saga.ts (78%) rename {apps/code/src/main/services => packages/core/src}/handoff/handoff-to-cloud-saga.test.ts (95%) rename {apps/code/src/main/services => packages/core/src}/handoff/handoff-to-cloud-saga.ts (77%) create mode 100644 packages/core/src/handoff/handoff.module.ts create mode 100644 packages/core/src/handoff/handoff.test.ts create mode 100644 packages/core/src/handoff/handoff.ts create mode 100644 packages/core/src/handoff/identifiers.ts rename {apps/code/src/main/services => packages/core/src}/handoff/schemas.ts (80%) create mode 100644 packages/core/src/handoff/types.ts rename {apps/code/src/renderer/features/inbox/utils => packages/core/src/inbox}/buildCreatePrReportPrompt.test.ts (94%) rename {apps/code/src/renderer/features/inbox/utils => packages/core/src/inbox}/buildDiscussReportPrompt.test.ts (97%) create mode 100644 packages/core/src/inbox/bulkActionService.test.ts create mode 100644 packages/core/src/inbox/bulkActionService.ts create mode 100644 packages/core/src/inbox/bulkActions.ts create mode 100644 packages/core/src/inbox/dataSourceService.ts create mode 100644 packages/core/src/inbox/engagement.ts create mode 100644 packages/core/src/inbox/identifiers.ts create mode 100644 packages/core/src/inbox/inbox.module.ts create mode 100644 packages/core/src/inbox/reportActionEvents.test.ts create mode 100644 packages/core/src/inbox/reportActionEvents.ts create mode 100644 packages/core/src/inbox/reportActionRules.ts create mode 100644 packages/core/src/inbox/reportArtefacts.ts rename apps/code/src/renderer/features/inbox/utils/filterReports.test.ts => packages/core/src/inbox/reportFilters.test.ts (97%) rename apps/code/src/renderer/features/inbox/utils/filterReports.ts => packages/core/src/inbox/reportFilters.ts (77%) rename apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.ts => packages/core/src/inbox/reportPrompts.ts (51%) create mode 100644 packages/core/src/inbox/reportRepository.ts create mode 100644 packages/core/src/inbox/reportSignals.ts create mode 100644 packages/core/src/inbox/reportTaskCreation.ts create mode 100644 packages/core/src/inbox/reportTasks.ts create mode 100644 packages/core/src/inbox/signalReportTaskService.test.ts create mode 100644 packages/core/src/inbox/signalReportTaskService.ts create mode 100644 packages/core/src/inbox/signalSourceService.test.ts create mode 100644 packages/core/src/inbox/signalSourceService.ts create mode 100644 packages/core/src/inbox/statusLabels.ts rename apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.test.ts => packages/core/src/inbox/suggestedReviewers.test.ts (97%) rename apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.ts => packages/core/src/inbox/suggestedReviewers.ts (97%) create mode 100644 packages/core/src/integrations/branches.test.ts create mode 100644 packages/core/src/integrations/branches.ts create mode 100644 packages/core/src/integrations/connectEligibility.test.ts create mode 100644 packages/core/src/integrations/connectEligibility.ts create mode 100644 packages/core/src/integrations/connectErrors.test.ts create mode 100644 packages/core/src/integrations/connectErrors.ts create mode 100644 packages/core/src/integrations/connectMachine.test.ts create mode 100644 packages/core/src/integrations/connectMachine.ts create mode 100644 packages/core/src/integrations/github.test.ts rename apps/code/src/main/services/github-integration/service.ts => packages/core/src/integrations/github.ts (76%) create mode 100644 packages/core/src/integrations/githubConnectService.test.ts create mode 100644 packages/core/src/integrations/githubConnectService.ts create mode 100644 packages/core/src/integrations/identifiers.ts create mode 100644 packages/core/src/integrations/integrations.module.ts create mode 100644 packages/core/src/integrations/linear.test.ts rename apps/code/src/main/services/linear-integration/service.ts => packages/core/src/integrations/linear.ts (60%) create mode 100644 packages/core/src/integrations/repositories.test.ts create mode 100644 packages/core/src/integrations/repositories.ts create mode 100644 packages/core/src/integrations/repositoriesService.test.ts create mode 100644 packages/core/src/integrations/repositoriesService.ts create mode 100644 packages/core/src/integrations/repositoryKeys.test.ts create mode 100644 packages/core/src/integrations/repositoryKeys.ts rename apps/code/src/main/services/integration-flow-schemas.ts => packages/core/src/integrations/schemas.ts (100%) create mode 100644 packages/core/src/integrations/selectors.test.ts rename apps/code/src/renderer/features/integrations/stores/integrationStore.ts => packages/core/src/integrations/selectors.ts (61%) create mode 100644 packages/core/src/integrations/slack.test.ts rename apps/code/src/main/services/slack-integration/service.ts => packages/core/src/integrations/slack.ts (68%) create mode 100644 packages/core/src/links/identifiers.ts rename apps/code/src/main/services/inbox-link/service.test.ts => packages/core/src/links/inbox-link.test.ts (88%) rename apps/code/src/main/services/inbox-link/service.ts => packages/core/src/links/inbox-link.ts (59%) rename apps/code/src/main/services/new-task-link/service.test.ts => packages/core/src/links/new-task-link.test.ts (96%) rename apps/code/src/main/services/new-task-link/service.ts => packages/core/src/links/new-task-link.ts (59%) create mode 100644 packages/core/src/links/task-link.test.ts rename apps/code/src/main/services/task-link/service.ts => packages/core/src/links/task-link.ts (59%) create mode 100644 packages/core/src/llm-gateway/identifiers.ts create mode 100644 packages/core/src/llm-gateway/llm-gateway.module.ts create mode 100644 packages/core/src/llm-gateway/llm-gateway.test.ts rename apps/code/src/main/services/llm-gateway/service.ts => packages/core/src/llm-gateway/llm-gateway.ts (72%) rename {apps/code/src/main/services => packages/core/src}/llm-gateway/schemas.ts (65%) create mode 100644 packages/core/src/mcp-apps/identifiers.ts create mode 100644 packages/core/src/mcp-apps/mcp-apps.module.ts rename apps/code/src/main/services/mcp-apps/service.ts => packages/core/src/mcp-apps/mcp-apps.ts (88%) rename apps/code/src/shared/types/mcp-apps.ts => packages/core/src/mcp-apps/schemas.ts (100%) create mode 100644 packages/core/src/mcp-servers/customServerForm.test.ts create mode 100644 packages/core/src/mcp-servers/customServerForm.ts rename apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.test.ts => packages/core/src/mcp-servers/filters.test.ts (96%) rename apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.ts => packages/core/src/mcp-servers/filters.ts (96%) create mode 100644 packages/core/src/mcp-servers/installFlow.test.ts create mode 100644 packages/core/src/mcp-servers/installFlow.ts create mode 100644 packages/core/src/mcp-servers/resolveServerName.test.ts create mode 100644 packages/core/src/mcp-servers/resolveServerName.ts rename apps/code/src/renderer/features/mcp-servers/components/parts/statusBadge.test.ts => packages/core/src/mcp-servers/status.test.ts (90%) create mode 100644 packages/core/src/mcp-servers/status.ts rename apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.test.ts => packages/core/src/mcp-servers/toolBulk.test.ts (95%) rename apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.ts => packages/core/src/mcp-servers/toolBulk.ts (95%) create mode 100644 packages/core/src/mcp-servers/toolDerivation.test.ts create mode 100644 packages/core/src/mcp-servers/toolDerivation.ts create mode 100644 packages/core/src/mcp-servers/toolRefresh.test.ts create mode 100644 packages/core/src/mcp-servers/toolRefresh.ts create mode 100644 packages/core/src/message-editor/commands.ts rename {apps/code/src/renderer/features/message-editor/utils => packages/core/src/message-editor}/content.test.ts (100%) rename {apps/code/src/renderer/features/message-editor/utils => packages/core/src/message-editor}/content.ts (98%) rename {apps/code/src/renderer/features/message-editor/utils => packages/core/src/message-editor}/githubIssueChip.test.ts (100%) rename {apps/code/src/renderer/features/message-editor/utils => packages/core/src/message-editor}/githubIssueChip.ts (64%) rename {apps/code/src/renderer/features/message-editor/utils => packages/core/src/message-editor}/githubIssueUrl.test.ts (100%) rename {apps/code/src/renderer/features/message-editor/utils => packages/core/src/message-editor}/githubIssueUrl.ts (94%) create mode 100644 packages/core/src/message-editor/paste.ts rename {apps/code/src/renderer/features/message-editor/utils => packages/core/src/message-editor}/persistFile.test.ts (59%) rename {apps/code/src/renderer/features/message-editor/utils => packages/core/src/message-editor}/persistFile.ts (55%) rename {apps/code/src/renderer/features/message-editor/tiptap => packages/core/src/message-editor}/suggestionLoader.test.ts (100%) rename {apps/code/src/renderer/features/message-editor/tiptap => packages/core/src/message-editor}/suggestionLoader.ts (100%) create mode 100644 packages/core/src/message-editor/suggestions.ts create mode 100644 packages/core/src/notification/identifiers.ts create mode 100644 packages/core/src/notification/notification.test.ts rename apps/code/src/main/services/notification/service.ts => packages/core/src/notification/notification.ts (52%) create mode 100644 packages/core/src/oauth/identifiers.ts create mode 100644 packages/core/src/oauth/oauth.module.ts create mode 100644 packages/core/src/oauth/oauth.test.ts rename apps/code/src/main/services/oauth/service.ts => packages/core/src/oauth/oauth.ts (63%) create mode 100644 packages/core/src/oauth/schemas.ts create mode 100644 packages/core/src/onboarding/analytics.test.ts create mode 100644 packages/core/src/onboarding/analytics.ts create mode 100644 packages/core/src/onboarding/githubConnectPanel.test.ts create mode 100644 packages/core/src/onboarding/githubConnectPanel.ts create mode 100644 packages/core/src/onboarding/githubConnectService.test.ts create mode 100644 packages/core/src/onboarding/githubConnectService.ts create mode 100644 packages/core/src/onboarding/identifiers.ts create mode 100644 packages/core/src/onboarding/onboarding.module.ts create mode 100644 packages/core/src/onboarding/projectsWithIntegrations.test.ts create mode 100644 packages/core/src/onboarding/projectsWithIntegrations.ts create mode 100644 packages/core/src/onboarding/repoProvider.test.ts create mode 100644 packages/core/src/onboarding/repoProvider.ts create mode 100644 packages/core/src/onboarding/steps.test.ts create mode 100644 packages/core/src/onboarding/steps.ts rename {apps/code/src/renderer/features/panels/constants => packages/core/src/panels}/panelConstants.ts (80%) create mode 100644 packages/core/src/panels/panelLayoutTransforms.test.ts create mode 100644 packages/core/src/panels/panelLayoutTransforms.ts rename apps/code/src/renderer/features/panels/store/panelUtils.ts => packages/core/src/panels/panelSizeMath.ts (75%) rename {apps/code/src/renderer/features/panels/store => packages/core/src/panels}/panelStoreHelpers.ts (80%) rename {apps/code/src/renderer/features/panels/store => packages/core/src/panels}/panelTree.ts (83%) rename {apps/code/src/renderer/features/panels/store => packages/core/src/panels}/panelTypes.ts (80%) create mode 100644 packages/core/src/panels/resolveTabPath.ts create mode 100644 packages/core/src/panels/resolveWorkspaceForRepoPath.ts create mode 100644 packages/core/src/provisioning/identifiers.ts create mode 100644 packages/core/src/provisioning/output.test.ts create mode 100644 packages/core/src/provisioning/output.ts create mode 100644 packages/core/src/provisioning/provisioning.test.ts rename apps/code/src/main/services/provisioning/service.ts => packages/core/src/provisioning/provisioning.ts (88%) create mode 100644 packages/core/src/secure-store/identifiers.ts create mode 100644 packages/core/src/secure-store/schemas.ts create mode 100644 packages/core/src/sessions/acpNotifications.ts create mode 100644 packages/core/src/sessions/chatTitle.test.ts create mode 100644 packages/core/src/sessions/chatTitle.ts create mode 100644 packages/core/src/sessions/cloudArtifactIdentifiers.ts create mode 100644 packages/core/src/sessions/cloudArtifactService.test.ts create mode 100644 packages/core/src/sessions/cloudArtifactService.ts create mode 100644 packages/core/src/sessions/cloudLogGap.test.ts create mode 100644 packages/core/src/sessions/cloudLogGap.ts create mode 100644 packages/core/src/sessions/cloudLogGapReconciler.test.ts create mode 100644 packages/core/src/sessions/cloudLogGapReconciler.ts create mode 100644 packages/core/src/sessions/cloudPrompt.test.ts create mode 100644 packages/core/src/sessions/cloudPrompt.ts create mode 100644 packages/core/src/sessions/cloudRunIdleTracker.test.ts rename {apps/code/src/renderer/features/sessions/service => packages/core/src/sessions}/cloudRunIdleTracker.ts (95%) create mode 100644 packages/core/src/sessions/cloudRunOptions.test.ts create mode 100644 packages/core/src/sessions/cloudRunOptions.ts create mode 100644 packages/core/src/sessions/cloudSessionConfig.test.ts create mode 100644 packages/core/src/sessions/cloudSessionConfig.ts create mode 100644 packages/core/src/sessions/connectRouting.test.ts create mode 100644 packages/core/src/sessions/connectRouting.ts rename apps/code/src/renderer/features/sessions/hooks/useContextUsage.test.ts => packages/core/src/sessions/contextUsage.test.ts (94%) rename apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts => packages/core/src/sessions/contextUsage.ts (74%) create mode 100644 packages/core/src/sessions/executionModes.ts create mode 100644 packages/core/src/sessions/localHandoffService.test.ts create mode 100644 packages/core/src/sessions/localHandoffService.ts create mode 100644 packages/core/src/sessions/permissionResponse.test.ts create mode 100644 packages/core/src/sessions/permissionResponse.ts rename {apps/code/src/renderer/utils => packages/core/src/sessions}/promptContent.test.ts (100%) rename {apps/code/src/renderer/utils => packages/core/src/sessions}/promptContent.ts (98%) rename apps/code/src/renderer/utils/session.test.ts => packages/core/src/sessions/sessionEvents.test.ts (69%) rename apps/code/src/renderer/utils/session.ts => packages/core/src/sessions/sessionEvents.ts (77%) create mode 100644 packages/core/src/sessions/sessionFactory.test.ts create mode 100644 packages/core/src/sessions/sessionFactory.ts create mode 100644 packages/core/src/sessions/sessionLogs.test.ts create mode 100644 packages/core/src/sessions/sessionLogs.ts rename apps/code/src/renderer/features/sessions/service/service.ts => packages/core/src/sessions/sessionService.ts (70%) rename apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts => packages/core/src/sessions/sessionViewState.ts (67%) create mode 100644 packages/core/src/sessions/sessions.module.ts create mode 100644 packages/core/src/sessions/titleGeneratorIdentifiers.ts rename apps/code/src/renderer/utils/generateTitle.test.ts => packages/core/src/sessions/titleGeneratorService.test.ts (51%) create mode 100644 packages/core/src/sessions/titleGeneratorService.ts create mode 100644 packages/core/src/settings/githubRepoSummary.test.ts create mode 100644 packages/core/src/settings/githubRepoSummary.ts create mode 100644 packages/core/src/settings/posthogUrl.test.ts create mode 100644 packages/core/src/settings/posthogUrl.ts create mode 100644 packages/core/src/settings/sandboxEnvironmentForm.test.ts create mode 100644 packages/core/src/settings/sandboxEnvironmentForm.ts create mode 100644 packages/core/src/settings/slackNotificationTarget.test.ts create mode 100644 packages/core/src/settings/slackNotificationTarget.ts create mode 100644 packages/core/src/settings/updateStatus.test.ts create mode 100644 packages/core/src/settings/updateStatus.ts create mode 100644 packages/core/src/settings/worktreeGrouping.test.ts create mode 100644 packages/core/src/settings/worktreeGrouping.ts create mode 100644 packages/core/src/settings/worktreeMaintenanceService.test.ts create mode 100644 packages/core/src/settings/worktreeMaintenanceService.ts rename {apps/code/src/renderer/features/setup/utils => packages/core/src/setup}/buildDiscoveredTaskPrompt.ts (87%) create mode 100644 packages/core/src/setup/identifiers.ts rename {apps/code/src/renderer/features => packages/core/src}/setup/prompts.ts (98%) create mode 100644 packages/core/src/setup/sessionUpdate.ts create mode 100644 packages/core/src/setup/setup.module.ts create mode 100644 packages/core/src/setup/setupRunService.test.ts create mode 100644 packages/core/src/setup/setupRunService.ts create mode 100644 packages/core/src/setup/setupState.ts create mode 100644 packages/core/src/setup/suggestions.test.ts create mode 100644 packages/core/src/setup/suggestions.ts rename {apps/code/src/renderer/features => packages/core/src}/setup/types.ts (100%) create mode 100644 packages/core/src/sidebar/buildSidebarData.ts rename {apps/code/src/renderer/features/sidebar/utils => packages/core/src/sidebar}/groupTasks.test.ts (99%) rename {apps/code/src/renderer/features/sidebar/utils => packages/core/src/sidebar}/groupTasks.ts (79%) create mode 100644 packages/core/src/sidebar/selection.test.ts create mode 100644 packages/core/src/sidebar/selection.ts create mode 100644 packages/core/src/sidebar/sidebarData.types.ts rename {apps/code/src/renderer/features/sidebar/utils => packages/core/src/sidebar}/summaryIds.test.ts (100%) rename {apps/code/src/renderer/features/sidebar/utils => packages/core/src/sidebar}/summaryIds.ts (100%) create mode 100644 packages/core/src/sidebar/taskMeta.ts rename apps/code/src/renderer/features/skill-buttons/prompts.ts => packages/core/src/skill-buttons/catalog.ts (69%) rename {apps/code/src/renderer/features => packages/core/src}/skill-buttons/prompts.test.ts (81%) create mode 100644 packages/core/src/skill-buttons/prompts.ts create mode 100644 packages/core/src/sleep/identifiers.ts create mode 100644 packages/core/src/sleep/sleep.test.ts rename apps/code/src/main/services/sleep/service.ts => packages/core/src/sleep/sleep.ts (58%) create mode 100644 packages/core/src/task-detail/cloudRunState.ts rename {apps/code/src/renderer/features/task-detail/utils => packages/core/src/task-detail}/cloudToolChanges.test.ts (100%) rename {apps/code/src/renderer/features/task-detail/utils => packages/core/src/task-detail}/cloudToolChanges.ts (90%) rename {apps/code/src/renderer/features/task-detail/utils => packages/core/src/task-detail}/configOptions.ts (100%) create mode 100644 packages/core/src/task-detail/discardInfo.ts create mode 100644 packages/core/src/task-detail/identifiers.ts create mode 100644 packages/core/src/task-detail/previewConfig.ts create mode 100644 packages/core/src/task-detail/task-detail.module.ts create mode 100644 packages/core/src/task-detail/taskCreationApiClient.ts create mode 100644 packages/core/src/task-detail/taskCreationEffects.ts create mode 100644 packages/core/src/task-detail/taskCreationHost.ts rename apps/code/src/renderer/sagas/task/task-creation.test.ts => packages/core/src/task-detail/taskCreationSaga.test.ts (80%) rename apps/code/src/renderer/sagas/task/task-creation.ts => packages/core/src/task-detail/taskCreationSaga.ts (65%) create mode 100644 packages/core/src/task-detail/taskInput.ts create mode 100644 packages/core/src/task-detail/taskService.ts create mode 100644 packages/core/src/task-detail/workspaceSetupSaga.test.ts create mode 100644 packages/core/src/task-detail/workspaceSetupSaga.ts create mode 100644 packages/core/src/tasks/contextMenuActions.test.ts create mode 100644 packages/core/src/tasks/contextMenuActions.ts create mode 100644 packages/core/src/tasks/filters.test.ts create mode 100644 packages/core/src/tasks/filters.ts create mode 100644 packages/core/src/tasks/identifiers.ts create mode 100644 packages/core/src/tasks/taskDelete.test.ts create mode 100644 packages/core/src/tasks/taskDelete.ts create mode 100644 packages/core/src/tasks/taskDeletionService.test.ts create mode 100644 packages/core/src/tasks/taskDeletionService.ts create mode 100644 packages/core/src/tasks/taskRename.test.ts create mode 100644 packages/core/src/tasks/taskRename.ts create mode 100644 packages/core/src/tasks/tasks.module.ts create mode 100644 packages/core/src/terminal/identifiers.ts rename {apps/code/src/renderer/features/terminal/utils => packages/core/src/terminal}/resolveTerminalFontFamily.test.ts (100%) rename {apps/code/src/renderer/features/terminal/utils => packages/core/src/terminal}/resolveTerminalFontFamily.ts (87%) create mode 100644 packages/core/src/terminal/shellProcessPoller.ts create mode 100644 packages/core/src/terminal/terminal.module.ts create mode 100644 packages/core/src/tour/calculateTooltipPlacement.test.ts rename {apps/code/src/renderer/features/tour/utils => packages/core/src/tour}/calculateTooltipPlacement.ts (92%) create mode 100644 packages/core/src/tour/tourMachine.test.ts create mode 100644 packages/core/src/tour/tourMachine.ts create mode 100644 packages/core/src/tour/tourRegistry.ts rename {apps/code/src/renderer/features => packages/core/src}/tour/types.ts (90%) create mode 100644 packages/core/src/ui/identifiers.ts create mode 100644 packages/core/src/ui/ports.ts rename {apps/code/src/main/services => packages/core/src}/ui/schemas.ts (100%) create mode 100644 packages/core/src/ui/ui.module.ts create mode 100644 packages/core/src/ui/ui.test.ts rename apps/code/src/main/services/ui/service.ts => packages/core/src/ui/ui.ts (67%) create mode 100644 packages/core/src/updates/identifiers.ts rename {apps/code/src/main/services => packages/core/src}/updates/schemas.ts (100%) create mode 100644 packages/core/src/updates/updateStore.test.ts create mode 100644 packages/core/src/updates/updateStore.ts create mode 100644 packages/core/src/updates/updates.module.ts rename apps/code/src/main/services/updates/service.test.ts => packages/core/src/updates/updates.test.ts (91%) rename apps/code/src/main/services/updates/service.ts => packages/core/src/updates/updates.ts (78%) create mode 100644 packages/core/src/usage/identifiers.ts rename apps/code/src/main/services/usage-monitor/schemas.ts => packages/core/src/usage/monitor-schemas.ts (87%) create mode 100644 packages/core/src/usage/schemas.ts create mode 100644 packages/core/src/usage/usage-monitor.module.ts rename apps/code/src/main/services/usage-monitor/service.test.ts => packages/core/src/usage/usage-monitor.test.ts (70%) rename apps/code/src/main/services/usage-monitor/service.ts => packages/core/src/usage/usage-monitor.ts (83%) create mode 100644 packages/core/src/workspace/WorkspaceSetupService.test.ts create mode 100644 packages/core/src/workspace/WorkspaceSetupService.ts create mode 100644 packages/core/src/workspace/branchMismatch.test.ts create mode 100644 packages/core/src/workspace/branchMismatch.ts create mode 100644 packages/core/src/workspace/branchMismatchDialog.test.ts create mode 100644 packages/core/src/workspace/branchMismatchDialog.ts create mode 100644 packages/core/src/workspace/ensureWorkspace.test.ts create mode 100644 packages/core/src/workspace/ensureWorkspace.ts create mode 100644 packages/core/src/workspace/focusWorkspace.test.ts create mode 100644 packages/core/src/workspace/focusWorkspace.ts create mode 100644 packages/core/src/workspace/identifiers.ts create mode 100644 packages/core/src/workspace/localRepoPath.test.ts create mode 100644 packages/core/src/workspace/localRepoPath.ts create mode 100644 packages/core/src/workspace/repoMismatch.test.ts create mode 100644 packages/core/src/workspace/repoMismatch.ts create mode 100644 packages/core/src/workspace/workspace.module.ts create mode 100644 packages/di/package.json create mode 100644 packages/di/src/container.ts create mode 100644 packages/di/src/contribution.test.ts rename packages/{ui/src/workbench => di/src}/contribution.ts (83%) create mode 100644 packages/di/src/logger.ts rename packages/{ui/src/workbench/service-context.tsx => di/src/react.tsx} (66%) create mode 100644 packages/di/tsconfig.json create mode 100644 packages/host-router/package.json create mode 100644 packages/host-router/src/client.ts create mode 100644 packages/host-router/src/ports/connectivity-client.ts create mode 100644 packages/host-router/src/ports/environment-client.ts create mode 100644 packages/host-router/src/ports/file-watcher-control.ts create mode 100644 packages/host-router/src/ports/git-pr-status.ts create mode 100644 packages/host-router/src/react.tsx create mode 100644 packages/host-router/src/router.ts create mode 100644 packages/host-router/src/routers/additional-directories.router.ts create mode 100644 packages/host-router/src/routers/agent.router.ts create mode 100644 packages/host-router/src/routers/analytics.router.ts create mode 100644 packages/host-router/src/routers/archive.router.ts create mode 100644 packages/host-router/src/routers/auth.router.ts create mode 100644 packages/host-router/src/routers/cloud-task.router.ts create mode 100644 packages/host-router/src/routers/connectivity.router.ts create mode 100644 packages/host-router/src/routers/context-menu.router.ts create mode 100644 packages/host-router/src/routers/deep-link.router.ts rename apps/code/src/main/trpc/routers/enrichment.ts => packages/host-router/src/routers/enrichment.router.ts (60%) create mode 100644 packages/host-router/src/routers/environment.router.ts create mode 100644 packages/host-router/src/routers/external-apps.router.ts create mode 100644 packages/host-router/src/routers/file-watcher.router.ts create mode 100644 packages/host-router/src/routers/focus.router.ts create mode 100644 packages/host-router/src/routers/folders.router.ts create mode 100644 packages/host-router/src/routers/fs.router.ts create mode 100644 packages/host-router/src/routers/git.router.ts create mode 100644 packages/host-router/src/routers/github-integration.router.ts rename apps/code/src/main/trpc/routers/handoff.ts => packages/host-router/src/routers/handoff.router.ts (57%) create mode 100644 packages/host-router/src/routers/linear-integration.router.ts create mode 100644 packages/host-router/src/routers/llm-gateway.router.ts create mode 100644 packages/host-router/src/routers/logs.router.ts rename apps/code/src/main/trpc/routers/mcp-apps.ts => packages/host-router/src/routers/mcp-apps.router.ts (59%) rename apps/code/src/main/trpc/routers/mcp-callback.ts => packages/host-router/src/routers/mcp-callback.router.ts (59%) create mode 100644 packages/host-router/src/routers/notification.router.ts create mode 100644 packages/host-router/src/routers/oauth.router.ts create mode 100644 packages/host-router/src/routers/os.router.ts create mode 100644 packages/host-router/src/routers/process-tracking.router.ts create mode 100644 packages/host-router/src/routers/provisioning.router.ts create mode 100644 packages/host-router/src/routers/secure-store.router.ts create mode 100644 packages/host-router/src/routers/shell.router.ts create mode 100644 packages/host-router/src/routers/skills.router.ts create mode 100644 packages/host-router/src/routers/slack-integration.router.ts create mode 100644 packages/host-router/src/routers/sleep.router.ts create mode 100644 packages/host-router/src/routers/suspension.router.ts rename apps/code/src/main/trpc/routers/ui.ts => packages/host-router/src/routers/ui.router.ts (61%) rename apps/code/src/main/trpc/routers/updates.test.ts => packages/host-router/src/routers/updates.router.test.ts (56%) create mode 100644 packages/host-router/src/routers/updates.router.ts rename apps/code/src/main/trpc/routers/usage-monitor.ts => packages/host-router/src/routers/usage-monitor.router.ts (51%) create mode 100644 packages/host-router/src/routers/workspace.router.ts create mode 100644 packages/host-router/tsconfig.json create mode 100644 packages/host-trpc/package.json create mode 100644 packages/host-trpc/src/context.ts create mode 100644 packages/host-trpc/src/trpc.ts create mode 100644 packages/host-trpc/tsconfig.json create mode 100644 packages/platform/src/analytics.ts create mode 100644 packages/platform/src/crypto.ts create mode 100644 packages/platform/src/deep-link.ts create mode 100644 packages/platform/src/notifications.ts create mode 100644 packages/platform/src/workspace-settings.ts rename apps/code/src/shared/types/analytics.ts => packages/shared/src/analytics-events.ts (98%) rename apps/code/src/shared/types/archive.ts => packages/shared/src/archive-domain.ts (56%) create mode 100644 packages/shared/src/async.ts create mode 100644 packages/shared/src/backoff.test.ts rename {apps/code/src/shared/utils => packages/shared/src}/backoff.ts (100%) rename {apps/code/src/shared/types => packages/shared/src}/cloud.ts (100%) rename apps/code/src/shared/deeplink.test.ts => packages/shared/src/deep-links.test.ts (50%) create mode 100644 packages/shared/src/deep-links.ts rename apps/code/src/shared/dismissalReasons.ts => packages/shared/src/dismissal-reasons.ts (100%) rename apps/code/src/shared/types.ts => packages/shared/src/domain-types.ts (92%) create mode 100644 packages/shared/src/enrichment.ts create mode 100644 packages/shared/src/errors.test.ts rename {apps/code/src/shared => packages/shared/src}/errors.ts (100%) create mode 100644 packages/shared/src/exec-types.ts create mode 100644 packages/shared/src/flags.ts create mode 100644 packages/shared/src/git-domain.ts create mode 100644 packages/shared/src/git-handoff.ts create mode 100644 packages/shared/src/git-naming.ts create mode 100644 packages/shared/src/git-types.ts create mode 100644 packages/shared/src/handoff-host.ts create mode 100644 packages/shared/src/inbox-types.ts rename {apps/code/src/renderer/utils => packages/shared/src}/links.ts (100%) rename apps/code/src/renderer/features/mcp-apps/utils/mcp-app-sandbox-proxy.test.ts => packages/shared/src/mcp-sandbox-proxy.test.ts (97%) create mode 100644 packages/shared/src/mcp-sandbox-proxy.ts rename {apps/code/src/shared/constants => packages/shared/src}/oauth.test.ts (100%) rename {apps/code/src/shared/constants => packages/shared/src}/oauth.ts (93%) rename {apps/code/src/renderer/utils => packages/shared/src}/path.test.ts (100%) rename {apps/code/src/renderer/utils => packages/shared/src}/path.ts (100%) create mode 100644 packages/shared/src/regions.test.ts rename {apps/code/src/shared/types => packages/shared/src}/regions.ts (100%) rename {apps/code/src/shared/utils => packages/shared/src}/repo.ts (100%) rename {apps/code/src/renderer/utils => packages/shared/src}/repository.ts (100%) rename {apps/code/src/shared/types => packages/shared/src}/seat.ts (100%) rename {apps/code/src/shared/types => packages/shared/src}/session-events.ts (100%) create mode 100644 packages/shared/src/sessions.ts create mode 100644 packages/shared/src/signal-types.ts rename {apps/code/src/shared/types => packages/shared/src}/skills.ts (100%) create mode 100644 packages/shared/src/task-creation-domain.ts create mode 100644 packages/shared/src/task.ts create mode 100644 packages/shared/src/time.test.ts rename {apps/code/src/renderer/utils => packages/shared/src}/time.ts (100%) create mode 100644 packages/shared/src/typed-event-emitter.test.ts create mode 100644 packages/shared/src/typed-event-emitter.ts rename {apps/code/src/shared/utils => packages/shared/src}/urls.ts (81%) create mode 100644 packages/shared/src/workspace-domain.ts create mode 100644 packages/shared/src/workspace.ts create mode 100644 packages/shared/src/xml.test.ts rename {apps/code/src/renderer/utils => packages/shared/src}/xml.ts (100%) create mode 100644 packages/shared/vitest.config.ts create mode 100644 packages/ui/src/assets.d.ts rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/default_file.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_access.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_actionscript.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ai.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ai2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_al.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_angular.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ansible.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_antlr.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_anyscript.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_apache.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_apex.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_apib.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_apib2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_applescript.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_appveyor.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_arduino.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_asp.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_aspx.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_assembly.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_astro.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_audio.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_aurelia.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_autohotkey.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_autoit.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_avro.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_aws.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_azure.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_babel.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_babel2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_bat.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_bazaar.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_bazel.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_binary.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_bithound.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_blade.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_bolt.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_bower.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_bower2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_buckbuild.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_bun.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_bundler.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_c.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_c2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_c_al.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cabal.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cake.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cakephp.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cargo.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cert.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cf.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cf2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cfc.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cfc2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cfm.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cfm2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cheader.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_chef.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_circleci.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_class.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_clojure.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cloudfoundry.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cmake.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cobol.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_codeclimate.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_codecov.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_codekit.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_codeowners.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_coffeelint.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_coffeescript.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_compass.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_composer.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_conan.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_config.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_coveralls.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cpp.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cpp2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cppheader.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_crowdin.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_crystal.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_csharp.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_csproj.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_css.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_csslint.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cssmap.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cucumber.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cvs.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_cypress.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_dal.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_darcs.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_dartlang.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_db.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_delphi.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_deno.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_dependencies.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_diff.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_django.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_dlang.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_docker.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_docker2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_dockertest.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_dockertest2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_docpad.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_dotenv.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_doxygen.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_drone.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_drools.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_dustjs.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_dylan.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_edge.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_edge2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_editorconfig.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_eex.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ejs.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_elastic.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_elasticbeanstalk.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_elixir.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_elm.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_elm2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_emacs.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ember.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ensime.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_eps.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_erb.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_erlang.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_erlang2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_esbuild.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_eslint.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_eslint2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_excel.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_favicon.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_fbx.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_firebase.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_flash.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_floobits.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_flow.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_font.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_fortran.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_fossil.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_freemarker.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_fsharp.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_fsharp2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_fsproj.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_fusebox.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_galen.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_galen2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_gamemaker.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_gamemaker2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_gamemaker81.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_git.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_git2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_gitlab.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_glsl.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_go.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_godot.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_gradle.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_graphql.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_graphviz.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_groovy.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_groovy2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_grunt.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_gulp.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_haml.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_handlebars.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_handlebars2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_harbour.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_hardhat.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_haskell.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_haskell2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_haxe.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_haxecheckstyle.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_haxedevelop.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_helix.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_helm.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_hlsl.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_host.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_html.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_htmlhint.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_http.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_husky.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_idris.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_idrisbin.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_idrispkg.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_image.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_infopath.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ini.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_io.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_iodine.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ionic.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jar.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_java.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jbuilder.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jekyll.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jenkins.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jest.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jinja.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jpm.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_js_official.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jsbeautify.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jsconfig.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jshint.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jsmap.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_json.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_json2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_json5.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_json_official.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jsonld.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jsp.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_julia.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_julia2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_jupyter.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_karma.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_key.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_kitchenci.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_kite.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_kivy.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_kos.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_kotlin.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_layout.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_lerna.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_less.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_license.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_babel.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_babel2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_cabal.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_circleci.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_cloudfoundry.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_codeclimate.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_config.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_db.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_docpad.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_drone.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_font.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_gamemaker2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_ini.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_io.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_jsconfig.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_jsmap.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_json.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_json5.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_jsonld.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_kite.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_lerna.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_mlang.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_mustache.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_pcl.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_prettier.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_purescript.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_rubocop.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_shaderlab.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_solidity.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_stylelint.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_stylus.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_systemverilog.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_testjs.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_tex.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_todo.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_vash.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_vsix.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_light_yaml.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_lime.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_liquid.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_lisp.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_livescript.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_locale.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_log.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_lolcode.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_lsl.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_lua.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_lync.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_manifest.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_manifest_bak.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_manifest_skip.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_map.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_markdown.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_markdownlint.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_marko.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_markojs.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_maxscript.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_mdx.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_mediawiki.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_mercurial.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_meteor.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_mjml.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_mlang.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_mocha.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_mojolicious.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_mongo.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_monotone.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_mson.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_mustache.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_netlify.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_next.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_component_css.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_component_html.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_component_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_component_js2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_component_less.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_component_sass.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_component_scss.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_component_ts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_component_ts2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_controller_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_controller_ts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_directive_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_directive_js2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_directive_ts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_directive_ts2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_guard_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_guard_ts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_interceptor_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_interceptor_ts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_module_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_module_js2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_module_ts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_module_ts2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_pipe_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_pipe_js2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_pipe_ts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_pipe_ts2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_routing_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_routing_js2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_routing_ts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_routing_ts2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_service_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_service_js2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_service_ts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_service_ts2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_smart_component_js.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_smart_component_js2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_smart_component_ts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ng_smart_component_ts2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_nginx.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_nim.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_njsproj.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_node.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_node2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_nodemon.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_npm.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_nsi.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_nuget.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_nunjucks.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_nuxt.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_nx.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_nyc.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_objectivec.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_objectivecpp.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ocaml.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_onenote.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_opencl.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_org.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_outlook.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_package.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_paket.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_patch.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_pcl.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_pdf.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_pdf2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_perl.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_perl2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_perl6.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_photoshop.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_photoshop2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_php.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_php2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_php3.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_phpunit.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_phraseapp.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_pip.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_plantuml.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_playwright.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_plsql.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_plsql_package.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_plsql_package_body.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_plsql_package_header.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_plsql_package_spec.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_pnpm.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_poedit.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_polymer.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_postcss.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_powerpoint.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_powershell.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_prettier.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_prisma.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_processinglang.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_procfile.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_progress.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_prolog.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_prometheus.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_protobuf.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_protractor.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_publisher.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_pug.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_puppet.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_purescript.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_python.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_q.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_qlikview.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_r.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_racket.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_rails.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_rake.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_raml.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_razor.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_reactjs.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_reacttemplate.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_reactts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_reason.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_registry.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_rest.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_riot.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_robotframework.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_robots.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_rollup.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_rspec.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_rubocop.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_ruby.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_rust.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_saltstack.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_sass.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_sbt.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_scala.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_scilab.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_script.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_scss.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_scss2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_sdlang.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_sequelize.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_shaderlab.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_shell.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_silverstripe.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_sketch.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_skipper.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_slice.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_slim.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_sln.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_smarty.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_snort.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_snyk.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_solidarity.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_solidity.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_source.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_sqf.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_sql.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_sqlite.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_squirrel.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_sss.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_stata.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_storyboard.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_storybook.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_stylable.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_style.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_stylelint.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_stylus.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_subversion.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_svelte.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_svg.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_swagger.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_swift.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_systemverilog.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_tailwind.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_tcl.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_terraform.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_test.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_testjs.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_testts.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_tex.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_text.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_textile.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_tfs.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_todo.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_toml.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_travis.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_tsconfig.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_tslint.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_turbo.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_twig.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_typescript.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_typescript_official.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_typescriptdef.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_typescriptdef_official.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vagrant.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vash.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vb.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vba.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vbhtml.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vbproj.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vcxproj.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_velocity.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vercel.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_verilog.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vhdl.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_video.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_view.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vim.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vite.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vitest.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_volt.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vscode.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vscode2.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vsix.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_vue.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_wasm.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_watchmanconfig.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_webpack.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_wercker.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_wolfram.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_word.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_wxml.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_wxss.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_xcode.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_xib.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_xliff.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_xml.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_xsl.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_yaml.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_yang.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_yarn.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_yeoman.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_zig.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_zip.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/file-icons/file_type_zip2.svg (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-Bold.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-BoldItalic.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraBold.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraBoldItalic.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraLight.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraLightItalic.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-Italic.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-Light.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-LightItalic.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-Medium.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-MediumItalic.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-Regular.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBold.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBoldItalic.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-Thin.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/JetBrainsMono-ThinItalic.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/JetBrainsMono/OFL.txt (100%) rename {apps/code => packages/ui/src}/assets/fonts/OpenRunde/OpenRunde-Bold.woff (100%) rename {apps/code => packages/ui/src}/assets/fonts/OpenRunde/OpenRunde-Bold.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/OpenRunde/OpenRunde-Medium.woff (100%) rename {apps/code => packages/ui/src}/assets/fonts/OpenRunde/OpenRunde-Medium.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/OpenRunde/OpenRunde-Regular.woff (100%) rename {apps/code => packages/ui/src}/assets/fonts/OpenRunde/OpenRunde-Regular.woff2 (100%) rename {apps/code => packages/ui/src}/assets/fonts/OpenRunde/OpenRunde-Semibold.woff (100%) rename {apps/code => packages/ui/src}/assets/fonts/OpenRunde/OpenRunde-Semibold.woff2 (100%) create mode 100644 packages/ui/src/assets/hedgehogs.ts rename {apps/code/src/renderer/assets/images => packages/ui/src/assets}/hedgehogs/builder-hog-03.png (100%) rename {apps/code/src/renderer/assets/images => packages/ui/src/assets}/hedgehogs/explorer-hog.png (100%) rename {apps/code/src/renderer/assets/images => packages/ui/src/assets}/hedgehogs/happy-hog.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/images/mail-hog.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/images/robo-zen.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/images/zen.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/airops.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/atlassian.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/attio.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/box.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/browserbase.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/canva.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/circle.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/cisco_thousandeyes.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/clerk.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/clickhouse.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/cloudflare.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/context7.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/datadog.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/figma.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/firetiger.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/github.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/gitlab.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/hex.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/hubspot.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/launchdarkly.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/linear.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/monday.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/neon.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/notion.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/pagerduty.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/planetscale.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/postman.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/prisma.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/render.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/sanity.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/sentry.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/slack.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/stripe.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/supabase.svg (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/svelte.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/services/wix.png (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/bubbles.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/danilo.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/drop.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/guitar.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/knock.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/meep-smol.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/meep.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/revi.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/ring.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/shoot.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/slide.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/switch.mp3 (100%) rename {apps/code/src/renderer => packages/ui/src}/assets/sounds/wilhelm.mp3 (100%) rename {apps/code/src/renderer/features/actions/components => packages/ui/src/features/actions}/ActionTabIcon.tsx (79%) rename {apps/code/src/renderer/features/actions/stores => packages/ui/src/features/actions}/actionStore.ts (100%) create mode 100644 packages/ui/src/features/agent/agent-events.contribution.ts create mode 100644 packages/ui/src/features/agent/agent.module.ts rename {apps/code/src/renderer/features/ai-approval/components => packages/ui/src/features/ai-approval}/AiApprovalScreen.tsx (81%) rename {apps/code/src/renderer/features/archive/components => packages/ui/src/features/archive}/ArchivedTasksView.stories.tsx (95%) rename {apps/code/src/renderer/features/archive/components => packages/ui/src/features/archive}/ArchivedTasksView.tsx (70%) create mode 100644 packages/ui/src/features/archive/useArchiveTask.ts rename {apps/code/src/renderer/features/archive/hooks => packages/ui/src/features/archive}/useArchivedTaskIds.ts (54%) create mode 100644 packages/ui/src/features/archive/useUnarchiveTask.ts rename {apps/code/src/renderer/features/auth/components => packages/ui/src/features/auth}/OAuthControls.tsx (85%) rename {apps/code/src/renderer/features/auth/components => packages/ui/src/features/auth}/RegionSelect.tsx (90%) rename {apps/code/src/renderer/features/auth/components => packages/ui/src/features/auth}/SignInCard.tsx (70%) rename {apps/code/src/renderer/assets/images => packages/ui/src/features/auth/assets}/posthog-icon.svg (100%) create mode 100644 packages/ui/src/features/auth/auth.contribution.ts create mode 100644 packages/ui/src/features/auth/auth.module.ts create mode 100644 packages/ui/src/features/auth/authClient.ts create mode 100644 packages/ui/src/features/auth/authClientImperative.ts create mode 100644 packages/ui/src/features/auth/authQueries.ts rename {apps/code/src/renderer/features/auth/stores => packages/ui/src/features/auth}/authUiStateStore.ts (94%) rename {apps/code/src/renderer => packages/ui/src}/features/auth/components/AuthScreen.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/auth/components/InviteCodeScreen.tsx (93%) rename {apps/code/src/renderer => packages/ui/src/features/auth}/components/ScopeReauthPrompt.test.tsx (95%) rename {apps/code/src/renderer => packages/ui/src/features/auth}/components/ScopeReauthPrompt.tsx (91%) create mode 100644 packages/ui/src/features/auth/components/SignInCard.tsx create mode 100644 packages/ui/src/features/auth/identifiers.ts create mode 100644 packages/ui/src/features/auth/store.ts create mode 100644 packages/ui/src/features/auth/useAuthMutations.ts rename {apps/code/src/renderer/features/auth/hooks => packages/ui/src/features/auth}/useAuthSession.ts (69%) create mode 100644 packages/ui/src/features/auth/useCurrentUser.ts rename {apps/code/src/renderer/hooks => packages/ui/src/features/auth}/useMeQuery.ts (72%) create mode 100644 packages/ui/src/features/auth/useOAuthFlow.ts rename {apps/code/src/renderer/features/auth/hooks => packages/ui/src/features/auth}/useOrgRole.ts (71%) create mode 100644 packages/ui/src/features/auth/userInitials.ts rename {apps/code/src/renderer/features/billing/components => packages/ui/src/features/billing}/SidebarUsageBar.tsx (83%) rename {apps/code/src/renderer/features/billing/components => packages/ui/src/features/billing}/TokenSpendAnalysisBanner.tsx (80%) rename {apps/code/src/renderer/features/billing/components => packages/ui/src/features/billing}/UsageLimitModal.tsx (87%) rename apps/code/src/renderer/features/billing/subscriptions.ts => packages/ui/src/features/billing/billing.contribution.ts (53%) create mode 100644 packages/ui/src/features/billing/billing.module.ts create mode 100644 packages/ui/src/features/billing/seatClient.ts create mode 100644 packages/ui/src/features/billing/seatStore.test.ts create mode 100644 packages/ui/src/features/billing/seatStore.ts rename {apps/code/src/renderer/features/billing/stores => packages/ui/src/features/billing}/usageLimitStore.test.ts (100%) rename {apps/code/src/renderer/features/billing/stores => packages/ui/src/features/billing}/usageLimitStore.ts (100%) rename {apps/code/src/renderer/features/billing/hooks => packages/ui/src/features/billing}/useFreeUsage.ts (85%) create mode 100644 packages/ui/src/features/billing/useSeat.ts create mode 100644 packages/ui/src/features/billing/useSpendAnalysis.ts create mode 100644 packages/ui/src/features/billing/useUsage.ts create mode 100644 packages/ui/src/features/clone/clone.contribution.ts create mode 100644 packages/ui/src/features/clone/clone.module.ts create mode 100644 packages/ui/src/features/clone/cloneActions.ts create mode 100644 packages/ui/src/features/clone/cloneStore.test.ts create mode 100644 packages/ui/src/features/clone/cloneStore.ts rename {apps/code/src/renderer => packages/ui/src}/features/code-editor/components/CodeEditorPanel.tsx (71%) rename {apps/code/src/renderer => packages/ui/src}/features/code-editor/components/CodeMirrorEditor.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/code-editor/components/EnrichmentPopover.tsx (84%) rename {apps/code/src/renderer/features/code-editor/stores => packages/ui/src/features/code-editor}/diffViewerStore.ts (95%) rename {apps/code/src/renderer => packages/ui/src}/features/code-editor/extensions/postHogEnrichment.ts (72%) rename {apps/code/src/renderer => packages/ui/src}/features/code-editor/hooks/useCloudFileContent.ts (82%) rename {apps/code/src/renderer => packages/ui/src}/features/code-editor/hooks/useCodeMirror.ts (57%) rename {apps/code/src/renderer => packages/ui/src}/features/code-editor/hooks/useEditorExtensions.ts (82%) create mode 100644 packages/ui/src/features/code-editor/hooks/useFileContent.ts create mode 100644 packages/ui/src/features/code-editor/hooks/useFileEnrichment.ts rename {apps/code/src/renderer/features/code-editor/stores => packages/ui/src/features/code-editor}/pendingScrollStore.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/code-editor/stores/enrichmentPopoverStore.ts (76%) rename {apps/code/src/renderer => packages/ui/src}/features/code-editor/theme/editorTheme.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/code-editor/utils/languages.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/CloudReviewPage.tsx (74%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/CommentAnnotation.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/DiffSettingsMenu.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/DiffSourceSelector.tsx (92%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/DraftCommentAnnotation.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/InteractiveFileDiff.tsx (84%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/PatchedFileDiff.tsx (89%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/PendingReviewBar.tsx (85%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/PrCommentThread.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/ReviewPage.tsx (90%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/ReviewRows.tsx (94%) create mode 100644 packages/ui/src/features/code-review/components/ReviewShell.tsx rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/ReviewToolbar.tsx (89%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/components/reviewItemBuilders.tsx (85%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/constants.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/hooks/useCommentState.ts (96%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/hooks/useDiffStatsToggle.ts (82%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/hooks/useEffectiveDiffSource.ts (74%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/hooks/useExpandableFileDiff.ts (93%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/hooks/usePrCommentActions.ts (67%) rename {apps/code/src/renderer => packages/ui/src}/features/code-review/hooks/useReadRepoFileBounded.ts (84%) create mode 100644 packages/ui/src/features/code-review/hooks/useRevertHunk.ts rename {apps/code/src/renderer => packages/ui/src}/features/code-review/hooks/useReviewDiffs.ts (80%) create mode 100644 packages/ui/src/features/code-review/hooks/useTaskDiffSummaryStats.ts create mode 100644 packages/ui/src/features/code-review/prCommentAnnotations.ts rename {apps/code/src/renderer/features/code-review/stores => packages/ui/src/features/code-review}/reviewDraftsStore.test.ts (100%) rename {apps/code/src/renderer/features/code-review/stores => packages/ui/src/features/code-review}/reviewDraftsStore.ts (92%) create mode 100644 packages/ui/src/features/code-review/reviewHost.ts rename {apps/code/src/renderer/features/code-review/stores => packages/ui/src/features/code-review}/reviewNavigationStore.ts (100%) rename apps/code/src/renderer/features/code-review/components/ReviewShell.test.tsx => packages/ui/src/features/code-review/reviewShellParts.test.tsx (76%) create mode 100644 packages/ui/src/features/code-review/reviewShellParts.tsx create mode 100644 packages/ui/src/features/code-review/types.ts rename {apps/code/src/renderer/features/command-center/stores => packages/ui/src/features/command-center}/commandCenterStore.test.ts (100%) rename {apps/code/src/renderer/features/command-center/stores => packages/ui/src/features/command-center}/commandCenterStore.ts (82%) rename {apps/code/src/renderer => packages/ui/src}/features/command-center/components/CommandCenterGrid.tsx (98%) rename {apps/code/src/renderer => packages/ui/src}/features/command-center/components/CommandCenterPRButton.tsx (70%) rename {apps/code/src/renderer => packages/ui/src}/features/command-center/components/CommandCenterPanel.tsx (92%) rename {apps/code/src/renderer => packages/ui/src}/features/command-center/components/CommandCenterSessionView.tsx (80%) rename {apps/code/src/renderer => packages/ui/src}/features/command-center/components/CommandCenterToolbar.tsx (92%) rename {apps/code/src/renderer => packages/ui/src}/features/command-center/components/CommandCenterView.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/command-center/components/TaskSelector.tsx (92%) rename {apps/code/src/renderer => packages/ui/src}/features/command-center/hooks/useAutofillCommandCenter.test.ts (95%) rename {apps/code/src/renderer => packages/ui/src}/features/command-center/hooks/useAutofillCommandCenter.ts (51%) create mode 100644 packages/ui/src/features/command-center/hooks/useAvailableTasks.ts create mode 100644 packages/ui/src/features/command-center/hooks/useCommandCenterData.ts rename {apps/code/src/renderer/features/command/components => packages/ui/src/features/command}/CommandKeyHints.tsx (100%) rename {apps/code/src/renderer/features/command/components => packages/ui/src/features/command}/CommandMenu.tsx (91%) rename {apps/code/src/renderer/features/command/components => packages/ui/src/features/command}/FilePicker.tsx (94%) create mode 100644 packages/ui/src/features/command/KeyboardShortcutsSheet.tsx rename {apps/code/src/renderer/constants => packages/ui/src/features/command}/keyboard-shortcuts.ts (99%) create mode 100644 packages/ui/src/features/connectivity/connectivity-events.contribution.ts create mode 100644 packages/ui/src/features/connectivity/connectivity.module.ts create mode 100644 packages/ui/src/features/connectivity/connectivityClient.ts create mode 100644 packages/ui/src/features/connectivity/connectivityToast.ts create mode 100644 packages/ui/src/features/deep-links/useNewTaskDeepLink.ts create mode 100644 packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx rename {apps/code/src/renderer/hooks => packages/ui/src/features/deep-links}/useTaskDeepLink.ts (66%) rename {apps/code/src/renderer => packages/ui/src}/features/editor/components/GithubRefChip.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/editor/components/MarkdownRenderer.tsx (91%) rename {apps/code/src/renderer/features/environments/components => packages/ui/src/features/environments}/EnvironmentSelector.tsx (89%) create mode 100644 packages/ui/src/features/environments/useEnvironments.ts create mode 100644 packages/ui/src/features/external-apps/focusCoordinator.ts create mode 100644 packages/ui/src/features/external-apps/useExternalAppAction.ts create mode 100644 packages/ui/src/features/external-apps/useExternalApps.test.tsx create mode 100644 packages/ui/src/features/external-apps/useExternalApps.ts create mode 100644 packages/ui/src/features/feature-flags/identifiers.ts create mode 100644 packages/ui/src/features/feature-flags/useFeatureFlag.ts create mode 100644 packages/ui/src/features/file-watcher/file-watcher.contribution.ts create mode 100644 packages/ui/src/features/file-watcher/file-watcher.module.ts create mode 100644 packages/ui/src/features/file-watcher/identifiers.ts create mode 100644 packages/ui/src/features/file-watcher/useRepoFileWatcher.ts create mode 100644 packages/ui/src/features/focus/focus-events.contribution.ts create mode 100644 packages/ui/src/features/focus/focus.module.ts create mode 100644 packages/ui/src/features/focus/focusAdapter.ts create mode 100644 packages/ui/src/features/focus/focusClient.ts create mode 100644 packages/ui/src/features/focus/focusStore.ts rename {apps/code/src/renderer/utils => packages/ui/src/features/focus}/focusToast.tsx (82%) rename {apps/code/src/renderer/features/folder-picker/components => packages/ui/src/features/folder-picker}/AddDirectoryDialog.tsx (89%) rename {apps/code/src/renderer/features/folder-picker/components => packages/ui/src/features/folder-picker}/FolderPicker.tsx (90%) rename {apps/code/src/renderer/features/folder-picker/components => packages/ui/src/features/folder-picker}/GitHubRepoPicker.tsx (99%) rename {apps/code/src/renderer/features/folder-picker/stores => packages/ui/src/features/folder-picker}/addDirectoryDialogStore.ts (100%) create mode 100644 packages/ui/src/features/folders/types.ts create mode 100644 packages/ui/src/features/folders/useFolders.ts rename apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.test.ts => packages/ui/src/features/git-interaction/cloudPrUrl.test.ts (81%) rename apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.ts => packages/ui/src/features/git-interaction/cloudPrUrl.ts (51%) rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/components/BranchSelector.test.tsx (87%) rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/components/BranchSelector.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/components/CloudGitInteractionHeader.tsx (72%) rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/components/CreatePrDialog.stories.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/components/CreatePrDialog.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/components/GitInteractionDialogs.stories.tsx (98%) rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/components/GitInteractionDialogs.tsx (99%) rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/components/PRBadgeLink.tsx (91%) rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/components/TaskActionsMenu.tsx (93%) create mode 100644 packages/ui/src/features/git-interaction/gitCacheKeys.ts create mode 100644 packages/ui/src/features/git-interaction/gitCacheProvider.ts create mode 100644 packages/ui/src/features/git-interaction/gitInteractionAdapter.ts create mode 100644 packages/ui/src/features/git-interaction/prIcon.tsx rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/state/gitInteractionStore.test.ts (99%) rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/state/gitInteractionStore.ts (98%) create mode 100644 packages/ui/src/features/git-interaction/types.ts create mode 100644 packages/ui/src/features/git-interaction/useCloudPrUrl.ts rename {apps/code/src/renderer/features/git-interaction/hooks => packages/ui/src/features/git-interaction}/useFixWithAgent.ts (79%) create mode 100644 packages/ui/src/features/git-interaction/useGitInteraction.ts rename {apps/code/src/renderer/features/git-interaction/hooks => packages/ui/src/features/git-interaction}/useGitQueries.ts (58%) rename {apps/code/src/renderer/features/git-interaction/hooks => packages/ui/src/features/git-interaction}/useLinkedBranchPrUrl.ts (55%) create mode 100644 packages/ui/src/features/git-interaction/usePrActions.ts rename {apps/code/src/renderer/features/git-interaction/hooks => packages/ui/src/features/git-interaction}/usePrDetails.ts (58%) rename {apps/code/src/renderer/features/git-interaction/hooks => packages/ui/src/features/git-interaction}/useTaskPrUrl.ts (58%) create mode 100644 packages/ui/src/features/git-interaction/utils/branchCreation.ts create mode 100644 packages/ui/src/features/git-interaction/utils/diffStats.ts rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/utils/fileKey.ts (100%) create mode 100644 packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts create mode 100644 packages/ui/src/features/git-interaction/utils/gitStatusUtils.ts create mode 100644 packages/ui/src/features/git-interaction/utils/partitionByStaged.ts rename {apps/code/src/renderer => packages/ui/src}/features/git-interaction/utils/updateGitCache.ts (60%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/DataSourceSetup.tsx (77%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/DismissReportDialog.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/InboxEmptyStates.tsx (91%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/InboxSetupPane.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/InboxSignalsTab.tsx (91%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/InboxSourcesDialog.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/InboxView.tsx (83%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/SignalSourceToggles.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/detail/MultiSelectStack.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/detail/ReportDetailPane.tsx (84%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/detail/ReportTaskLogs.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/detail/SignalCard.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/detail/signalInteractionContext.ts (91%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/list/FilterSortMenu.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/list/GitHubConnectionBanner.tsx (91%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/list/ReportListPane.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/list/ReportListRow.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/list/SignalsToolbar.tsx (89%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/list/SuggestedReviewerFilterMenu.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/utils/AnimatedEllipsis.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/utils/ExplainedDismissOptionLabels.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/utils/PgAnalyzeIcon.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/utils/ReportCardContent.tsx (82%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/utils/ReportImplementationPrLink.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/utils/SignalReportActionabilityBadge.tsx (85%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/utils/SignalReportPriorityBadge.tsx (81%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/utils/SignalReportStatusBadge.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/utils/SignalReportSummaryMarkdown.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/components/utils/source-product-icons.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/devtools/inboxDemoConsole.ts (93%) create mode 100644 packages/ui/src/features/inbox/hooks/useCreatePrReport.ts create mode 100644 packages/ui/src/features/inbox/hooks/useDiscussReport.test.tsx create mode 100644 packages/ui/src/features/inbox/hooks/useDiscussReport.ts rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useEvaluations.ts (58%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useExternalDataSources.ts (64%) create mode 100644 packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useInboxDeepLink.ts (75%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useInboxDeepLinkListSync.ts (90%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useInboxEngagementTracker.ts (74%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useInboxReports.ts (93%) create mode 100644 packages/ui/src/features/inbox/hooks/useReportTasks.ts rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useSeedSuggestedReviewerFilter.test.ts (95%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useSeedSuggestedReviewerFilter.ts (89%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useSignalSourceConfigs.ts (64%) create mode 100644 packages/ui/src/features/inbox/hooks/useSignalSourceManager.ts rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useSignalTeamConfig.ts (76%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useSignalUserAutonomyConfig.ts (77%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/hooks/useSlackChannels.ts (91%) rename {apps/code/src/renderer/features/inbox/stores => packages/ui/src/features/inbox}/inboxAvailableSuggestedReviewersStore.ts (96%) rename {apps/code/src/renderer/features/inbox/stores => packages/ui/src/features/inbox}/inboxReportSelectionStore.test.ts (100%) rename {apps/code/src/renderer/features/inbox/stores => packages/ui/src/features/inbox}/inboxReportSelectionStore.ts (100%) rename {apps/code/src/renderer/features/inbox/stores => packages/ui/src/features/inbox}/inboxSignalsFilterStore.test.ts (100%) rename {apps/code/src/renderer/features/inbox/stores => packages/ui/src/features/inbox}/inboxSignalsFilterStore.ts (99%) rename {apps/code/src/renderer/features/inbox/stores => packages/ui/src/features/inbox}/inboxSourcesDialogStore.ts (100%) create mode 100644 packages/ui/src/features/inbox/stores/inboxCloudTaskStore.ts rename {apps/code/src/renderer => packages/ui/src}/features/inbox/stores/inboxSignalsSidebarStore.ts (65%) rename {apps/code/src/renderer => packages/ui/src}/features/inbox/utils/inboxConstants.ts (100%) create mode 100644 packages/ui/src/features/inbox/utils/inboxSort.ts rename {apps/code/src/renderer => packages/ui/src}/features/inbox/utils/pendingInboxOpenMethod.ts (92%) create mode 100644 packages/ui/src/features/integrations/integrationsClientImpl.ts create mode 100644 packages/ui/src/features/integrations/store.ts rename {apps/code/src/renderer/features/integrations/hooks => packages/ui/src/features/integrations}/useGitHubIntegrationCallback.ts (57%) create mode 100644 packages/ui/src/features/integrations/useGithubDisconnect.ts rename {apps/code/src/renderer/features/integrations/hooks => packages/ui/src/features/integrations}/useGithubUserConnect.ts (51%) rename {apps/code/src/renderer/hooks => packages/ui/src/features/integrations}/useIntegrations.ts (56%) rename {apps/code/src/renderer/features/integrations/hooks => packages/ui/src/features/integrations}/useSlackConnect.ts (62%) rename {apps/code/src/renderer/features/integrations/hooks => packages/ui/src/features/integrations}/useSlackIntegrationCallback.ts (58%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-apps/components/McpAppHost.tsx (84%) rename {apps/code/src/renderer/features/sessions/components/session-update => packages/ui/src/features/mcp-apps/components}/McpToolBlock.tsx (55%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-apps/components/McpToolView.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-apps/hooks/useAppBridge.ts (97%) create mode 100644 packages/ui/src/features/mcp-apps/identifiers.ts rename {apps/code/src/renderer => packages/ui/src}/features/mcp-apps/utils/mcp-app-csp.test.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-apps/utils/mcp-app-csp.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-apps/utils/mcp-app-host-utils.test.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-apps/utils/mcp-app-host-utils.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-apps/utils/mcp-app-theme.test.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-apps/utils/mcp-app-theme.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-servers/components/McpServersView.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-servers/components/parts/AddCustomServerForm.tsx (91%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-servers/components/parts/MarketplaceView.tsx (98%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-servers/components/parts/McpInstalledRail.tsx (81%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-servers/components/parts/ServerCard.tsx (98%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-servers/components/parts/ServerDetailView.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-servers/components/parts/ToolPolicyToggle.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-servers/components/parts/ToolRow.tsx (98%) create mode 100644 packages/ui/src/features/mcp-servers/components/parts/icons.tsx create mode 100644 packages/ui/src/features/mcp-servers/components/parts/statusBadge.ts rename {apps/code/src/renderer => packages/ui/src}/features/mcp-servers/hooks/useMcpInstallationTools.ts (74%) rename {apps/code/src/renderer => packages/ui/src}/features/mcp-servers/hooks/useMcpServers.ts (68%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/analytics.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/commands.ts (71%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/AdapterIndicator.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/AttachmentMenu.test.tsx (89%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/AttachmentMenu.tsx (93%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/AttachmentsBar.tsx (92%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/IssuePicker.tsx (80%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/IssueRow.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/ModeSelector.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/PromptHistoryDialog.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/PromptInput.stories.tsx (70%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/PromptInput.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/SuggestionStatus.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/components/message-editor.css (100%) create mode 100644 packages/ui/src/features/message-editor/content.ts rename {apps/code/src/renderer/features/message-editor/stores => packages/ui/src/features/message-editor}/draftStore.ts (96%) create mode 100644 packages/ui/src/features/message-editor/githubIssueUrl.ts create mode 100644 packages/ui/src/features/message-editor/hostApi.ts create mode 100644 packages/ui/src/features/message-editor/identifiers.ts rename {apps/code/src/renderer/features/message-editor/stores => packages/ui/src/features/message-editor}/promptHistoryStore.ts (100%) create mode 100644 packages/ui/src/features/message-editor/suggestions/getSuggestions.ts rename {apps/code/src/renderer/features/message-editor/stores => packages/ui/src/features/message-editor}/taskInputHistoryStore.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/CommandGhostText.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/CommandMention.ts (92%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/FileMention.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/IssueMention.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/MentionChipNode.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/MentionChipView.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/SuggestionList.tsx (98%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/createSuggestionMention.ts (97%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/extensions.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/useDraftSync.test.tsx (92%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/useDraftSync.ts (98%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/tiptap/useTiptapEditor.ts (90%) rename {apps/code/src/renderer => packages/ui/src}/features/message-editor/types.ts (92%) rename {apps/code/src/renderer/hooks => packages/ui/src/features/message-editor}/useAutoFocusOnTyping.ts (92%) create mode 100644 packages/ui/src/features/message-editor/utils/persistFile.test.ts create mode 100644 packages/ui/src/features/message-editor/utils/persistFile.ts rename apps/code/src/renderer/stores/navigationStore.test.ts => packages/ui/src/features/navigation/store.test.ts (87%) rename apps/code/src/renderer/stores/navigationStore.ts => packages/ui/src/features/navigation/store.ts (79%) create mode 100644 packages/ui/src/features/navigation/taskBinder.ts create mode 100644 packages/ui/src/features/navigation/taskBinderImpl.ts create mode 100644 packages/ui/src/features/notifications/identifiers.ts create mode 100644 packages/ui/src/features/notifications/notifications.module.ts create mode 100644 packages/ui/src/features/notifications/notifications.test.ts create mode 100644 packages/ui/src/features/notifications/notifications.ts rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/CliCheckPanel.tsx (93%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/ConnectGitHubStep.tsx (89%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/FeatureBentoCard.css (100%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/FeatureBentoCard.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/GitHubConnectPanel.tsx (75%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/InstallCliStep.tsx (90%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/InviteCodeStep.tsx (92%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/OnboardingFlow.tsx (75%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/OptionalBadge.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/ProjectSelectStep.tsx (93%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/SelectRepoStep.tsx (93%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/StepActions.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/StepIndicator.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/WelcomeScreen.tsx (92%) rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/components/onboardingStyles.ts (100%) create mode 100644 packages/ui/src/features/onboarding/githubConnectClientImpl.ts create mode 100644 packages/ui/src/features/onboarding/hooks/useOnboardingFlow.ts rename {apps/code/src/renderer => packages/ui/src}/features/onboarding/hooks/useProjectsWithIntegrations.ts (50%) rename {apps/code/src/renderer/features/onboarding/stores => packages/ui/src/features/onboarding}/onboardingStore.ts (92%) create mode 100644 packages/ui/src/features/onboarding/types.ts rename {apps/code/src/renderer => packages/ui/src}/features/panels/components/DraggableTab.tsx (76%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/components/GroupNodeRenderer.tsx (86%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/components/LeafNodeRenderer.tsx (91%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/components/Panel.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/components/PanelDropZones.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/components/PanelGroup.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/components/PanelLayout.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/components/PanelResizeHandle.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/components/PanelTab.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/components/PanelTree.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/components/TabbedPanel.tsx (93%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/hooks/useDragDropHandlers.ts (96%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/hooks/usePanelKeyboardShortcuts.ts (91%) rename {apps/code/src/renderer => packages/ui/src}/features/panels/hooks/usePanelLayoutHooks.tsx (84%) create mode 100644 packages/ui/src/features/panels/panelConstants.ts rename {apps/code/src/renderer/features/panels/store => packages/ui/src/features/panels}/panelLayoutStore.test.ts (99%) create mode 100644 packages/ui/src/features/panels/panelLayoutStore.ts create mode 100644 packages/ui/src/features/panels/panelLayoutUtils.ts create mode 100644 packages/ui/src/features/panels/panelStoreHelpers.ts rename {apps/code/src/shared/test => packages/ui/src/features/panels}/panelTestHelpers.ts (95%) create mode 100644 packages/ui/src/features/panels/panelTree.ts create mode 100644 packages/ui/src/features/panels/panelTypes.ts create mode 100644 packages/ui/src/features/panels/panelUtils.ts rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/DefaultPermission.tsx (85%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/DeletePermission.tsx (87%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/EditPermission.tsx (84%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/ExecutePermission.tsx (87%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/FetchPermission.tsx (95%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/McpPermission.tsx (84%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/MovePermission.tsx (84%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/PermissionSelector.stories.tsx (99%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/PermissionSelector.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/PlanContent.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/QuestionPermission.tsx (99%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/ReadPermission.tsx (85%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/SearchPermission.tsx (84%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/SwitchModePermission.tsx (84%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/ThinkPermission.tsx (84%) rename {apps/code/src/renderer/components => packages/ui/src/features}/permissions/types.ts (87%) rename {apps/code/src/renderer => packages/ui/src}/features/posthog-mcp/utils/posthog-exec-display.test.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/posthog-mcp/utils/posthog-exec-display.ts (100%) rename {apps/code/src/renderer/hooks => packages/ui/src/features/projects}/useProjectQuery.ts (74%) rename {apps/code/src/renderer/features/projects/hooks => packages/ui/src/features/projects}/useProjects.tsx (87%) create mode 100644 packages/ui/src/features/provisioning/ProvisioningView.tsx create mode 100644 packages/ui/src/features/provisioning/provisioning.contribution.ts create mode 100644 packages/ui/src/features/provisioning/provisioning.module.ts rename apps/code/src/renderer/features/provisioning/stores/provisioningStore.ts => packages/ui/src/features/provisioning/store.ts (62%) rename {apps/code/src/renderer/hooks => packages/ui/src/features/repo-files}/useDetectedCloudRepository.ts (50%) rename {apps/code/src/renderer/hooks => packages/ui/src/features/repo-files}/useRepoFiles.ts (64%) rename {apps/code/src/renderer/features/right-sidebar/stores => packages/ui/src/features/right-sidebar}/fileTreeStore.ts (100%) create mode 100644 packages/ui/src/features/sessions/agentPromptSender.ts rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/CloudInitializingView.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/ContextBreakdownPopover.test.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/ContextBreakdownPopover.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/ContextUsageIndicator.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/ConversationSearchBar.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/ConversationView.stories.tsx (99%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/ConversationView.tsx (82%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/DiffStatsChip.tsx (81%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/DirtyTreeDialog.tsx (89%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/DropZoneOverlay.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/GeneratingIndicator.test.ts (81%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/GeneratingIndicator.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/GitActionMessage.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/GitActionResult.tsx (87%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/HandoffConfirmDialog.tsx (93%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/ModelSelector.tsx (89%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/PendingChatView.tsx (74%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/PendingInputPlaceholder.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/PlanStatusBar.stories.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/PlanStatusBar.tsx (90%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/ReasoningLevelSelector.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/SessionFooter.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/SessionView.tsx (73%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/UnifiedModelSelector.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/VirtualizedList.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/buildConversationItems.test.ts (98%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/buildConversationItems.ts (95%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/mergeConversationItems.test.ts (98%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/mergeConversationItems.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/raw-logs/RawLogEntry.tsx (93%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/raw-logs/RawLogsHeader.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/raw-logs/RawLogsView.tsx (84%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/AgentMessage.tsx (89%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/CodePreview.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/CompactBoundaryView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/ConsoleMessage.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/DeleteToolView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/EditToolView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/ErrorNotificationView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/ExecuteToolView.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/FetchToolView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/FileMentionChip.tsx (73%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/MoveToolView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/PlanApprovalView.test.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/PlanApprovalView.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/ProgressGroupView.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/QuestionToolView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/QueuedMessageView.tsx (89%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/ReadToolView.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/SearchToolView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/SessionUpdateView.tsx (74%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/StatusNotificationView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/SubagentToolView.tsx (94%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/TaskNotificationView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/ThinkToolView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/ThoughtView.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/ToolCallBlock.stories.tsx (98%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/ToolCallBlock.tsx (58%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/ToolCallView.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/ToolRow.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/UserMessage.test.tsx (81%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/UserMessage.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/UserShellExecuteView.tsx (93%) create mode 100644 packages/ui/src/features/sessions/components/session-update/identifiers.ts rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/parseFileMentions.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/toolCallUtils.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/components/session-update/useCodePreviewExtensions.ts (86%) create mode 100644 packages/ui/src/features/sessions/components/useFileContextMenu.ts rename {apps/code/src/renderer => packages/ui/src}/features/sessions/constants.ts (100%) rename {apps/code/src/renderer/features/sessions/utils => packages/ui/src/features/sessions}/contextColors.ts (92%) rename {apps/code/src/renderer/features/sessions/stores => packages/ui/src/features/sessions}/handoffDialogStore.ts (97%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/hooks/useAgentVersion.ts (86%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/hooks/useChatTitleGenerator.test.ts (79%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/hooks/useChatTitleGenerator.ts (57%) create mode 100644 packages/ui/src/features/sessions/hooks/useContextUsage.ts rename {apps/code/src/renderer => packages/ui/src}/features/sessions/hooks/useConversationSearch.ts (93%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/hooks/useSessionCallbacks.ts (68%) create mode 100644 packages/ui/src/features/sessions/hooks/useSessionConnection.ts create mode 100644 packages/ui/src/features/sessions/hooks/useSessionViewState.ts create mode 100644 packages/ui/src/features/sessions/identifiers.ts create mode 100644 packages/ui/src/features/sessions/localHandoffService.ts rename {apps/code/src/renderer/features/sessions/utils => packages/ui/src/features/sessions}/sendPromptToAgent.ts (62%) rename {apps/code/src/renderer/features/sessions/stores => packages/ui/src/features/sessions}/sessionAdapterStore.ts (93%) rename {apps/code/src/renderer/features/sessions/stores => packages/ui/src/features/sessions}/sessionConfigStore.ts (97%) create mode 100644 packages/ui/src/features/sessions/sessionLogTypes.ts rename apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts => packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts (93%) rename apps/code/src/renderer/features/sessions/service/service.test.ts => packages/ui/src/features/sessions/sessionServiceHost.test.ts (98%) create mode 100644 packages/ui/src/features/sessions/sessionServiceHost.ts rename {apps/code/src/renderer/features/sessions/stores => packages/ui/src/features/sessions}/sessionStore.test.ts (100%) rename {apps/code/src/renderer/features/sessions/stores => packages/ui/src/features/sessions}/sessionStore.ts (55%) rename {apps/code/src/renderer/features/sessions/stores => packages/ui/src/features/sessions}/sessionViewStore.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/types.ts (100%) rename {apps/code/src/renderer/features/sessions/hooks => packages/ui/src/features/sessions}/useSession.ts (96%) rename {apps/code/src/renderer/features/sessions/hooks => packages/ui/src/features/sessions}/useSessionTaskId.tsx (100%) create mode 100644 packages/ui/src/features/sessions/userMessageTypes.ts rename {apps/code/src/renderer => packages/ui/src}/features/sessions/utils/extractSearchableText.test.ts (96%) rename {apps/code/src/renderer => packages/ui/src}/features/sessions/utils/extractSearchableText.ts (86%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/FolderSettingsView.tsx (96%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/ModalInlineComboboxContent.tsx (100%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/SettingRow.tsx (100%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/SettingsDialog.tsx (93%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/SettingsOptionSelect.tsx (100%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/AccountSettings.tsx (82%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/AdvancedSettings.tsx (74%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/ClaudeCodeSettings.tsx (94%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/GeneralSettings.tsx (95%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/GitHubIntegrationSection.tsx (88%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/GitHubSettings.tsx (91%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/PermissionsSettings.tsx (92%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/PersonalizationSettings.tsx (89%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/PlanUsageSettings.tsx (91%) create mode 100644 packages/ui/src/features/settings/sections/ShortcutsSettings.tsx rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/SignalSlackNotificationsSettings.tsx (84%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/SignalSourcesSettings.tsx (85%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/SlackSettings.tsx (90%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/TerminalSettings.tsx (91%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/UpdatesSettings.tsx (77%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/WorkspacesSettings.tsx (67%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/environments/CloudEnvironmentsSettings.tsx (84%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/environments/EnvironmentForm.tsx (87%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/environments/EnvironmentRow.tsx (95%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/environments/EnvironmentsSettings.tsx (96%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/environments/LocalEnvironmentsSettings.tsx (90%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/environments/ProjectEnvironmentCard.tsx (94%) rename {apps/code/src/renderer/features/settings/hooks => packages/ui/src/features/settings/sections/environments}/useSandboxEnvironments.ts (85%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/worktrees/WorktreeGroupSection.tsx (96%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/worktrees/WorktreeRow.tsx (90%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/worktrees/WorktreeSize.tsx (91%) rename {apps/code/src/renderer/features/settings/components => packages/ui/src/features/settings}/sections/worktrees/WorktreesSettings.tsx (61%) rename {apps/code/src/renderer/features/settings/stores => packages/ui/src/features/settings}/settingsDialogStore.test.ts (100%) rename {apps/code/src/renderer/features/settings/stores => packages/ui/src/features/settings}/settingsDialogStore.ts (100%) rename {apps/code/src/renderer/features/settings/stores => packages/ui/src/features/settings}/settingsStore.test.ts (91%) rename {apps/code/src/renderer/features/settings/stores => packages/ui/src/features/settings}/settingsStore.ts (98%) rename {apps/code/src/renderer/features/setup/components => packages/ui/src/features/setup}/DiscoveredTaskDetailDialog.tsx (86%) rename {apps/code/src/renderer/features/setup/components => packages/ui/src/features/setup}/SetupScanFeed.tsx (98%) rename {apps/code/src/renderer/features/setup/utils => packages/ui/src/features/setup}/categoryConfig.ts (96%) create mode 100644 packages/ui/src/features/setup/setup.module.ts create mode 100644 packages/ui/src/features/setup/setupRunServiceImpl.ts create mode 100644 packages/ui/src/features/setup/setupStore.ts create mode 100644 packages/ui/src/features/setup/useSetupDiscovery.ts rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/DraggableFolder.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/MainSidebar.tsx (74%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/ProjectSwitcher.tsx (92%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/Sidebar.tsx (81%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/SidebarContent.tsx (72%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/SidebarItem.tsx (97%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/SidebarMenu.tsx (74%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/SidebarSection.tsx (98%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/SidebarTrigger.tsx (76%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/TaskListView.tsx (92%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/UpdateBanner.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/items/CommandCenterItem.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/items/HomeItem.tsx (89%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/items/McpServersItem.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/items/SearchItem.tsx (86%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/items/SidebarKbdHint.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/items/SkillsItem.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/items/TaskIcon.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/components/items/TaskItem.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/constants.ts (100%) rename {apps/code/src/renderer/features/sidebar/stores => packages/ui/src/features/sidebar}/sidebarStore.ts (98%) create mode 100644 packages/ui/src/features/sidebar/taskMetaApi.ts rename {apps/code/src/renderer/features/sidebar/stores => packages/ui/src/features/sidebar}/taskSelectionStore.test.ts (100%) rename {apps/code/src/renderer/features/sidebar/stores => packages/ui/src/features/sidebar}/taskSelectionStore.ts (67%) rename {apps/code/src/renderer => packages/ui/src}/features/sidebar/types.ts (100%) rename {apps/code/src/renderer/features/sidebar/hooks => packages/ui/src/features/sidebar}/useCwd.ts (64%) create mode 100644 packages/ui/src/features/sidebar/usePinnedTasks.ts create mode 100644 packages/ui/src/features/sidebar/useSidebarData.ts rename {apps/code/src/renderer/features/sidebar/hooks => packages/ui/src/features/sidebar}/useTaskPrStatus.test.ts (90%) create mode 100644 packages/ui/src/features/sidebar/useTaskPrStatus.ts create mode 100644 packages/ui/src/features/sidebar/useTaskViewed.ts rename {apps/code/src/renderer/features/sidebar/hooks => packages/ui/src/features/sidebar}/useVisualTaskOrder.ts (85%) rename {apps/code/src/renderer => packages/ui/src}/features/skill-buttons/components/SkillButtonActionMessage.stories.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/skill-buttons/components/SkillButtonActionMessage.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/skill-buttons/components/SkillButtonsMenu.stories.tsx (79%) rename {apps/code/src/renderer => packages/ui/src}/features/skill-buttons/components/SkillButtonsMenu.tsx (90%) create mode 100644 packages/ui/src/features/skill-buttons/prompts.ts rename {apps/code/src/renderer/features/skill-buttons/stores => packages/ui/src/features/skill-buttons}/skillButtonsStore.ts (72%) rename {apps/code/src/renderer/features/skills/components => packages/ui/src/features/skills}/SkillCard.tsx (97%) rename {apps/code/src/renderer/features/skills/components => packages/ui/src/features/skills}/SkillDetailPanel.tsx (83%) rename {apps/code/src/renderer/features/skills/components => packages/ui/src/features/skills}/SkillsView.tsx (89%) rename {apps/code/src/renderer/features/skills/stores => packages/ui/src/features/skills}/skillsSidebarStore.ts (58%) create mode 100644 packages/ui/src/features/skills/useSkills.test.tsx create mode 100644 packages/ui/src/features/skills/useSkills.ts rename {apps/code/src/renderer/features/suspension/hooks => packages/ui/src/features/suspension}/useRestoreTask.ts (58%) create mode 100644 packages/ui/src/features/suspension/useSuspendTask.test.tsx create mode 100644 packages/ui/src/features/suspension/useSuspendTask.ts rename {apps/code/src/renderer/features/suspension/hooks => packages/ui/src/features/suspension}/useSuspendedTaskIds.ts (53%) create mode 100644 packages/ui/src/features/suspension/useSuspensionSettings.ts rename {apps/code/src/renderer/features/task-detail/components => packages/ui/src/features/task-detail}/BranchMismatchDialog.tsx (100%) rename {apps/code/src/renderer/features/task-detail/components => packages/ui/src/features/task-detail}/HeaderTitleEditor.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/ActionPanel.tsx (84%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/ChangesPanel.tsx (75%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/ChangesTreeView.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/CloudGithubMissingNotice.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/ExternalAppsOpener.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/FileTreePanel.tsx (80%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/SuggestedTaskCard.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/SuggestedTasksPanel.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/TabContentRenderer.tsx (61%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/TaskDetail.tsx (82%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/TaskInput.tsx (88%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/TaskLogsPanel.tsx (73%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/TaskPendingView.tsx (73%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/TaskShellPanel.tsx (60%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/WorkspaceModeSelect.tsx (96%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/components/WorkspaceSetupPrompt.tsx (65%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/hooks/useCloudChangedFiles.ts (86%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/hooks/useCloudEventSummary.ts (78%) create mode 100644 packages/ui/src/features/task-detail/hooks/useCloudRunState.ts create mode 100644 packages/ui/src/features/task-detail/hooks/useDiscardFile.ts rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts (97%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts (93%) create mode 100644 packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts create mode 100644 packages/ui/src/features/task-detail/hooks/useStageToggle.ts rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/hooks/useTaskCreation.ts (65%) rename {apps/code/src/renderer => packages/ui/src}/features/task-detail/hooks/useTaskData.ts (54%) create mode 100644 packages/ui/src/features/task-detail/taskCreationEffectsImpl.ts create mode 100644 packages/ui/src/features/task-detail/taskCreationHostImpl.ts rename {apps/code/src/renderer/features/tasks/hooks => packages/ui/src/features/tasks}/taskKeys.ts (100%) create mode 100644 packages/ui/src/features/tasks/taskStore.ts rename {apps/code/src/renderer/features/tasks/stores => packages/ui/src/features/tasks}/taskStore.types.ts (68%) rename {apps/code/src/renderer/hooks => packages/ui/src/features/tasks}/useTaskContextMenu.ts (58%) create mode 100644 packages/ui/src/features/tasks/useTaskCrudMutations.test.tsx create mode 100644 packages/ui/src/features/tasks/useTaskCrudMutations.ts rename apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx => packages/ui/src/features/tasks/useTaskMutations.test.tsx (94%) create mode 100644 packages/ui/src/features/tasks/useTaskMutations.ts create mode 100644 packages/ui/src/features/tasks/useTasks.ts rename {apps/code/src/renderer/features/terminal/components => packages/ui/src/features/terminal}/ActionTerminal.tsx (96%) rename {apps/code/src/renderer/features/terminal/components => packages/ui/src/features/terminal}/ShellTerminal.tsx (86%) rename {apps/code/src/renderer/features/terminal/components => packages/ui/src/features/terminal}/Terminal.tsx (73%) rename {apps/code/src/renderer/features/terminal/services => packages/ui/src/features/terminal}/TerminalManager.ts (92%) create mode 100644 packages/ui/src/features/terminal/shellClient.ts rename {apps/code/src/renderer/features/terminal/stores => packages/ui/src/features/terminal}/terminalStore.ts (67%) create mode 100644 packages/ui/src/features/terminal/useShellProcessPoller.ts rename {apps/code/src/renderer => packages/ui/src}/features/tour/components/TourOverlay.tsx (93%) rename {apps/code/src/renderer => packages/ui/src}/features/tour/components/TourTooltip.tsx (95%) rename {apps/code/src/renderer => packages/ui/src}/features/tour/hooks/useElementRect.ts (100%) create mode 100644 packages/ui/src/features/tour/tourStore.ts rename {apps/code/src/renderer => packages/ui/src}/features/tour/tours/createFirstTaskTour.ts (73%) create mode 100644 packages/ui/src/features/updates/updateStore.ts create mode 100644 packages/ui/src/features/updates/updatesAdapter.ts create mode 100644 packages/ui/src/features/updates/updatesClient.ts create mode 100644 packages/ui/src/features/workspace/identifiers.ts rename {apps/code/src/renderer/features/workspace/hooks => packages/ui/src/features/workspace}/useBranchMismatch.test.ts (100%) rename {apps/code/src/renderer/features/workspace/hooks => packages/ui/src/features/workspace}/useBranchMismatch.ts (84%) rename {apps/code/src/renderer/features/workspace/hooks => packages/ui/src/features/workspace}/useBranchMismatchDialog.test.ts (91%) rename {apps/code/src/renderer/features/workspace/hooks => packages/ui/src/features/workspace}/useBranchMismatchDialog.ts (59%) rename {apps/code/src/renderer/features/workspace/hooks => packages/ui/src/features/workspace}/useFocusWorkspace.tsx (79%) rename {apps/code/src/renderer/features/workspace/hooks => packages/ui/src/features/workspace}/useIsCloudTask.ts (100%) create mode 100644 packages/ui/src/features/workspace/useLocalRepoPath.ts create mode 100644 packages/ui/src/features/workspace/useWorkspace.ts rename {apps/code/src/renderer/features/workspace/hooks => packages/ui/src/features/workspace}/useWorkspaceEvents.ts (63%) create mode 100644 packages/ui/src/features/workspace/useWorkspaceMutations.test.tsx create mode 100644 packages/ui/src/features/workspace/useWorkspaceMutations.ts create mode 100644 packages/ui/src/features/workspace/workspace-events.contribution.test.ts create mode 100644 packages/ui/src/features/workspace/workspace-events.contribution.ts create mode 100644 packages/ui/src/features/workspace/workspace.module.ts create mode 100644 packages/ui/src/hooks/createSelectors.ts create mode 100644 packages/ui/src/hooks/useAuthenticatedClient.ts rename {apps/code/src/renderer => packages/ui/src}/hooks/useAuthenticatedInfiniteQuery.ts (86%) rename {apps/code/src/renderer => packages/ui/src}/hooks/useAuthenticatedMutation.ts (83%) rename {apps/code/src/renderer => packages/ui/src}/hooks/useAuthenticatedQuery.ts (80%) rename {apps/code/src/renderer => packages/ui/src}/hooks/useBlurOnEscape.ts (81%) create mode 100644 packages/ui/src/hooks/useConnectivity.ts rename {apps/code/src/renderer => packages/ui/src}/hooks/useSetHeaderContent.ts (82%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/ActionSelector.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/BackgroundWrapper.tsx (100%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/Badge.tsx (100%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/Button.tsx (97%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/CodeBlock.test.tsx (84%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/CodeBlock.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/Divider.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/DotPatternBackground.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/DotsCircleSpinner.tsx (100%) create mode 100644 packages/ui/src/primitives/DraggableTitleBar.tsx rename {apps/code/src/renderer/components => packages/ui/src/primitives}/ErrorBoundary.tsx (69%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/FileIcon.tsx (84%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/FullScreenLayout.tsx (77%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/HighlightedCode.tsx (86%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/KeyHint.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/KeyboardShortcutsSheet.tsx (99%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/List.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/LoginTransition.tsx (100%) rename apps/code/src/renderer/assets/logo.tsx => packages/ui/src/primitives/Logo.tsx (99%) rename {apps/code/src/renderer/features/onboarding/components => packages/ui/src/primitives}/OnboardingHogTip.tsx (100%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/PanelMessage.tsx (100%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/RelativeTimestamp.tsx (86%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/ResizableSidebar.tsx (97%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/SafeImagePreview.tsx (97%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/StepList.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/ThemeWrapper.tsx (93%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/Tooltip.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/TreeDirectoryRow.tsx (98%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/ZenHedgehog.tsx (95%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/action-selector/ActionSelector.tsx (99%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/action-selector/InlineEditableText.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/action-selector/OptionRow.tsx (99%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/action-selector/StepTabs.tsx (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/action-selector/constants.ts (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/action-selector/types.ts (100%) rename {apps/code/src/renderer/components => packages/ui/src/primitives}/action-selector/useActionSelectorState.ts (100%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/combobox/Combobox.css (100%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/combobox/Combobox.stories.tsx (99%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/combobox/Combobox.tsx (100%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/combobox/useComboboxFilter.test.ts (96%) rename {apps/code/src/renderer/components/ui => packages/ui/src/primitives}/combobox/useComboboxFilter.ts (98%) rename {apps/code/src/renderer/utils => packages/ui/src/primitives}/confetti.ts (100%) rename {apps/code/src/renderer => packages/ui/src/primitives}/hooks/useDebounce.test.ts (96%) rename {apps/code/src/renderer => packages/ui/src/primitives}/hooks/useDebounce.ts (100%) rename {apps/code/src/renderer => packages/ui/src/primitives}/hooks/useDebouncedValue.ts (100%) rename {apps/code/src/renderer => packages/ui/src/primitives}/hooks/useImagePanAndZoom.test.tsx (98%) rename {apps/code/src/renderer => packages/ui/src/primitives}/hooks/useImagePanAndZoom.ts (100%) rename {apps/code/src/renderer => packages/ui/src/primitives}/hooks/useInView.ts (100%) rename {apps/code/src/renderer/utils => packages/ui/src/primitives}/toast.tsx (100%) rename {apps/code/src/renderer => packages/ui/src}/styles/fieldTrigger.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/styles/globals.css (92%) create mode 100644 packages/ui/src/test/setup.ts rename {apps/code/src/renderer => packages/ui/src}/utils/agentVersion.test.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/utils/agentVersion.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/utils/browser.ts (58%) create mode 100644 packages/ui/src/utils/clearStorage.ts rename {apps/code/src/renderer => packages/ui/src}/utils/dialog.ts (57%) create mode 100644 packages/ui/src/utils/getFilePath.ts rename {apps/code/src/renderer => packages/ui/src}/utils/overlay.test.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/utils/overlay.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/utils/platform.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/utils/posthogLinks.ts (88%) create mode 100644 packages/ui/src/utils/promptContent.test.ts create mode 100644 packages/ui/src/utils/promptContent.ts rename {apps/code/src/renderer => packages/ui/src}/utils/random.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/utils/sendMessageKey.test.ts (79%) rename {apps/code/src/renderer => packages/ui/src}/utils/sendMessageKey.ts (83%) rename {apps/code/src/renderer => packages/ui/src}/utils/sounds.ts (53%) rename {apps/code/src/renderer => packages/ui/src}/utils/syntax-highlight.ts (100%) rename {apps/code/src/renderer => packages/ui/src}/utils/urls.test.ts (86%) rename {apps/code/src/renderer => packages/ui/src}/utils/urls.ts (66%) create mode 100644 packages/ui/src/workbench/App.tsx rename {apps/code/src/renderer/components => packages/ui/src/workbench}/ErrorBoundary.test.tsx (94%) create mode 100644 packages/ui/src/workbench/ErrorBoundary.tsx create mode 100644 packages/ui/src/workbench/FullScreenLayout.tsx rename {apps/code/src/renderer/components => packages/ui/src/workbench}/GlobalEventHandlers.tsx (84%) rename {apps/code/src/renderer/components => packages/ui/src/workbench}/HeaderRow.tsx (80%) create mode 100644 packages/ui/src/workbench/HedgehogMode.tsx rename {apps/code/src/renderer/components => packages/ui/src/workbench}/MainLayout.tsx (58%) rename {apps/code/src/renderer/components => packages/ui/src/workbench}/SpaceSwitcher.tsx (92%) rename {apps/code/src/renderer/stores => packages/ui/src/workbench}/activeRepoStore.ts (100%) create mode 100644 packages/ui/src/workbench/analytics.ts rename {apps/code/src/renderer/stores => packages/ui/src/workbench}/commandMenuStore.ts (100%) rename {apps/code/src/renderer/stores => packages/ui/src/workbench}/createSidebarStore.ts (100%) create mode 100644 packages/ui/src/workbench/diffWorkerHost.ts rename {apps/code/src/renderer/stores => packages/ui/src/workbench}/headerStore.ts (100%) create mode 100644 packages/ui/src/workbench/hedgehogModeHost.ts create mode 100644 packages/ui/src/workbench/logger.ts create mode 100644 packages/ui/src/workbench/openExternal.ts rename {apps/code/src/renderer/stores => packages/ui/src/workbench}/pendingTaskPromptStore.ts (94%) rename apps/code/src/renderer/utils/analytics.test.ts => packages/ui/src/workbench/posthogAnalyticsImpl.test.ts (98%) rename apps/code/src/renderer/utils/analytics.ts => packages/ui/src/workbench/posthogAnalyticsImpl.ts (88%) create mode 100644 packages/ui/src/workbench/queryClient.ts create mode 100644 packages/ui/src/workbench/rendererStorage.ts rename {apps/code/src/renderer/stores => packages/ui/src/workbench}/rendererWindowFocusStore.ts (100%) rename {apps/code/src/renderer/stores => packages/ui/src/workbench}/shortcutsSheetStore.ts (100%) rename {apps/code/src/renderer/stores => packages/ui/src/workbench}/themeStore.ts (100%) rename {apps/code => packages/ui}/tailwind.config.js (88%) create mode 100644 packages/ui/vitest.config.ts create mode 100644 packages/workspace-client/src/environment.ts rename {apps/code => packages/workspace-server}/drizzle.config.ts (86%) create mode 100644 packages/workspace-server/src/db/db.module.ts create mode 100644 packages/workspace-server/src/db/identifiers.ts rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/0000_red_jigsaw.sql (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/0001_tan_lifeguard.sql (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/0002_massive_bishop.sql (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/0003_fair_whiplash.sql (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/0004_auth_preferences.sql (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/0005_youthful_scarlet_spider.sql (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/0006_youthful_warstar.sql (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/meta/0000_snapshot.json (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/meta/0001_snapshot.json (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/meta/0002_snapshot.json (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/meta/0003_snapshot.json (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/meta/0004_snapshot.json (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/meta/0005_snapshot.json (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/meta/0006_snapshot.json (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/migrations/meta/_journal.json (100%) rename {apps/code/src/main/utils => packages/workspace-server/src/db}/normalize-path.ts (100%) create mode 100644 packages/workspace-server/src/db/repositories.module.ts rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/archive-repository.mock.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/archive-repository.ts (96%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/auth-preference-repository.mock.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/auth-preference-repository.ts (96%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/auth-session-repository.mock.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/auth-session-repository.ts (93%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/default-additional-directory-repository.mock.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/default-additional-directory-repository.ts (88%) create mode 100644 packages/workspace-server/src/db/repositories/repositories.test.ts rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/repository-repository.mock.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/repository-repository.ts (97%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/suspension-repository.mock.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/suspension-repository.ts (90%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/workspace-repository.mock.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/workspace-repository.ts (96%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/worktree-repository.mock.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/repositories/worktree-repository.ts (96%) rename {apps/code/src/main => packages/workspace-server/src}/db/schema.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/db/service.ts (58%) rename {apps/code/src/main => packages/workspace-server/src}/db/test-helpers.ts (91%) create mode 100644 packages/workspace-server/src/services/additional-directories/additional-directories.module.ts create mode 100644 packages/workspace-server/src/services/additional-directories/additional-directories.test.ts create mode 100644 packages/workspace-server/src/services/additional-directories/additional-directories.ts create mode 100644 packages/workspace-server/src/services/additional-directories/identifiers.ts create mode 100644 packages/workspace-server/src/services/agent/agent.module.ts rename apps/code/src/main/services/agent/service.test.ts => packages/workspace-server/src/services/agent/agent.test.ts (96%) rename apps/code/src/main/services/agent/service.ts => packages/workspace-server/src/services/agent/agent.ts (90%) rename {apps/code/src/main => packages/workspace-server/src}/services/agent/auth-adapter.test.ts (97%) rename {apps/code/src/main => packages/workspace-server/src}/services/agent/auth-adapter.ts (91%) rename {apps/code/src/main => packages/workspace-server/src}/services/agent/discover-plugins.test.ts (98%) rename {apps/code/src/main => packages/workspace-server/src}/services/agent/discover-plugins.ts (54%) create mode 100644 packages/workspace-server/src/services/agent/identifiers.ts create mode 100644 packages/workspace-server/src/services/agent/ports.ts rename {apps/code/src/main => packages/workspace-server/src}/services/agent/schemas.ts (98%) rename apps/code/src/main/services/archive/service.integration.test.ts => packages/workspace-server/src/services/archive/archive.integration.test.ts (94%) create mode 100644 packages/workspace-server/src/services/archive/archive.module.ts rename apps/code/src/main/services/archive/service.ts => packages/workspace-server/src/services/archive/archive.ts (77%) create mode 100644 packages/workspace-server/src/services/archive/identifiers.ts create mode 100644 packages/workspace-server/src/services/archive/ports.ts rename {apps/code/src/main => packages/workspace-server/src}/services/archive/schemas.ts (68%) create mode 100644 packages/workspace-server/src/services/auth-proxy/auth-proxy.module.ts rename apps/code/src/main/services/auth-proxy/service.ts => packages/workspace-server/src/services/auth-proxy/auth-proxy.ts (86%) create mode 100644 packages/workspace-server/src/services/auth-proxy/identifiers.ts create mode 100644 packages/workspace-server/src/services/auth-proxy/ports.ts rename {apps/code/src/main => packages/workspace-server/src}/services/connectivity/schemas.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/services/connectivity/service.test.ts (84%) rename {apps/code/src/main => packages/workspace-server/src}/services/connectivity/service.ts (63%) rename {apps/code/src/main => packages/workspace-server/src}/services/enrichment/detectPosthogInstallState.test.ts (85%) create mode 100644 packages/workspace-server/src/services/enrichment/enrichment.module.ts rename apps/code/src/main/services/enrichment/service.ts => packages/workspace-server/src/services/enrichment/enrichment.ts (84%) rename {apps/code/src/main => packages/workspace-server/src}/services/enrichment/findStaleFlagSuggestions.test.ts (84%) create mode 100644 packages/workspace-server/src/services/enrichment/identifiers.ts create mode 100644 packages/workspace-server/src/services/enrichment/ports.ts rename {apps/code/src/main => packages/workspace-server/src}/services/environment/schemas.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/services/environment/service.test.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/services/environment/service.ts (100%) create mode 100644 packages/workspace-server/src/services/external-apps/external-apps.module.ts rename apps/code/src/main/services/external-apps/service.ts => packages/workspace-server/src/services/external-apps/external-apps.ts (93%) create mode 100644 packages/workspace-server/src/services/external-apps/identifiers.ts create mode 100644 packages/workspace-server/src/services/external-apps/ports.ts rename {apps/code/src/main => packages/workspace-server/src}/services/external-apps/schemas.ts (91%) rename {apps/code/src/main => packages/workspace-server/src}/services/external-apps/types.ts (84%) create mode 100644 packages/workspace-server/src/services/folders/folders.module.ts rename apps/code/src/main/services/folders/service.test.ts => packages/workspace-server/src/services/folders/folders.test.ts (93%) rename apps/code/src/main/services/folders/service.ts => packages/workspace-server/src/services/folders/folders.ts (82%) create mode 100644 packages/workspace-server/src/services/folders/identifiers.ts rename {apps/code/src/main => packages/workspace-server/src}/services/folders/schemas.ts (100%) create mode 100644 packages/workspace-server/src/services/fs/identifiers.ts create mode 100644 packages/workspace-server/src/services/fs/service.test.ts create mode 100644 packages/workspace-server/src/services/git/git.integration.test.ts create mode 100644 packages/workspace-server/src/services/handoff/identifiers.ts create mode 100644 packages/workspace-server/src/services/handoff/ports.ts create mode 100644 packages/workspace-server/src/services/handoff/service.test.ts create mode 100644 packages/workspace-server/src/services/handoff/service.ts create mode 100644 packages/workspace-server/src/services/local-logs/identifiers.ts create mode 100644 packages/workspace-server/src/services/local-logs/schemas.ts rename {apps/code/src/main => packages/workspace-server/src}/services/local-logs/service.test.ts (75%) rename {apps/code/src/main => packages/workspace-server/src}/services/local-logs/service.ts (63%) create mode 100644 packages/workspace-server/src/services/mcp-callback/identifiers.ts create mode 100644 packages/workspace-server/src/services/mcp-callback/mcp-callback-server.ts create mode 100644 packages/workspace-server/src/services/mcp-callback/mcp-callback.module.ts create mode 100644 packages/workspace-server/src/services/mcp-callback/mcp-callback.ts rename {apps/code/src/main => packages/workspace-server/src}/services/mcp-callback/schemas.ts (100%) create mode 100644 packages/workspace-server/src/services/mcp-proxy/identifiers.ts create mode 100644 packages/workspace-server/src/services/mcp-proxy/mcp-proxy.module.ts rename apps/code/src/main/services/mcp-proxy/service.test.ts => packages/workspace-server/src/services/mcp-proxy/mcp-proxy.test.ts (91%) rename apps/code/src/main/services/mcp-proxy/service.ts => packages/workspace-server/src/services/mcp-proxy/mcp-proxy.ts (88%) create mode 100644 packages/workspace-server/src/services/mcp-proxy/ports.ts create mode 100644 packages/workspace-server/src/services/oauth-callback/identifiers.ts create mode 100644 packages/workspace-server/src/services/oauth-callback/oauth-callback.module.ts create mode 100644 packages/workspace-server/src/services/oauth-callback/oauth-callback.ts create mode 100644 packages/workspace-server/src/services/os/identifiers.ts create mode 100644 packages/workspace-server/src/services/os/os.module.ts create mode 100644 packages/workspace-server/src/services/os/os.test.ts create mode 100644 packages/workspace-server/src/services/os/os.ts create mode 100644 packages/workspace-server/src/services/os/schemas.ts rename {apps/code/src/main => packages/workspace-server/src}/services/posthog-plugin/README.md (100%) rename {apps/code/src/main/utils => packages/workspace-server/src/services/posthog-plugin}/extract-zip.ts (100%) create mode 100644 packages/workspace-server/src/services/posthog-plugin/identifiers.ts create mode 100644 packages/workspace-server/src/services/posthog-plugin/posthog-plugin.module.ts rename apps/code/src/main/services/posthog-plugin/service.test.ts => packages/workspace-server/src/services/posthog-plugin/posthog-plugin.test.ts (92%) rename apps/code/src/main/services/posthog-plugin/service.ts => packages/workspace-server/src/services/posthog-plugin/posthog-plugin.ts (77%) rename {apps/code/src/main => packages/workspace-server/src}/services/posthog-plugin/update-skills-saga.ts (99%) create mode 100644 packages/workspace-server/src/services/process-tracking/identifiers.ts create mode 100644 packages/workspace-server/src/services/process-tracking/process-tracking.module.ts rename apps/code/src/main/services/process-tracking/service.test.ts => packages/workspace-server/src/services/process-tracking/process-tracking.test.ts (97%) rename apps/code/src/main/services/process-tracking/service.ts => packages/workspace-server/src/services/process-tracking/process-tracking.ts (79%) rename {apps/code/src/main/utils => packages/workspace-server/src/services/process-tracking}/process-utils.ts (90%) create mode 100644 packages/workspace-server/src/services/process-tracking/schemas.ts create mode 100644 packages/workspace-server/src/services/repo-fs-query/repo-fs-query.test.ts create mode 100644 packages/workspace-server/src/services/repo-fs-query/repo-fs-query.ts create mode 100644 packages/workspace-server/src/services/secure-store/identifiers.ts create mode 100644 packages/workspace-server/src/services/secure-store/schemas.ts rename {apps/code/src/main => packages/workspace-server/src}/services/session-env/loader.test.ts (100%) rename {apps/code/src/main => packages/workspace-server/src}/services/session-env/loader.ts (88%) create mode 100644 packages/workspace-server/src/services/shell/identifiers.ts rename {apps/code/src/main => packages/workspace-server/src}/services/shell/schemas.ts (100%) create mode 100644 packages/workspace-server/src/services/shell/shell.module.ts rename apps/code/src/main/services/shell/service.ts => packages/workspace-server/src/services/shell/shell.ts (88%) create mode 100644 packages/workspace-server/src/services/skills/identifiers.ts rename {apps/code/src/main/services/agent => packages/workspace-server/src/services/skills}/parse-skill-frontmatter.ts (100%) rename apps/code/src/main/services/agent/skill-schemas.ts => packages/workspace-server/src/services/skills/schemas.ts (75%) create mode 100644 packages/workspace-server/src/services/skills/skill-discovery.test.ts create mode 100644 packages/workspace-server/src/services/skills/skill-discovery.ts create mode 100644 packages/workspace-server/src/services/skills/skills.module.ts create mode 100644 packages/workspace-server/src/services/skills/skills.ts create mode 100644 packages/workspace-server/src/services/suspension/identifiers.ts create mode 100644 packages/workspace-server/src/services/suspension/ports.ts rename {apps/code/src/main => packages/workspace-server/src}/services/suspension/schemas.ts (51%) create mode 100644 packages/workspace-server/src/services/suspension/suspension.module.ts rename apps/code/src/main/services/suspension/service.test.ts => packages/workspace-server/src/services/suspension/suspension.test.ts (82%) rename apps/code/src/main/services/suspension/service.ts => packages/workspace-server/src/services/suspension/suspension.ts (74%) create mode 100644 packages/workspace-server/src/services/watcher-registry/identifiers.ts create mode 100644 packages/workspace-server/src/services/watcher-registry/watcher-registry.module.ts rename apps/code/src/main/services/watcher-registry/service.ts => packages/workspace-server/src/services/watcher-registry/watcher-registry.ts (67%) create mode 100644 packages/workspace-server/src/services/workspace-metadata/identifiers.ts create mode 100644 packages/workspace-server/src/services/workspace-metadata/workspace-metadata.module.ts create mode 100644 packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts create mode 100644 packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts create mode 100644 packages/workspace-server/src/services/workspace/identifiers.ts create mode 100644 packages/workspace-server/src/services/workspace/ports.ts rename {apps/code/src/main => packages/workspace-server/src}/services/workspace/schemas.ts (84%) create mode 100644 packages/workspace-server/src/services/workspace/workspace.module.ts create mode 100644 packages/workspace-server/src/services/workspace/workspace.test.ts rename apps/code/src/main/services/workspace/service.ts => packages/workspace-server/src/services/workspace/workspace.ts (74%) create mode 100644 packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.test.ts create mode 100644 packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.ts create mode 100644 packages/workspace-server/src/services/worktree-path/worktree-path.test.ts create mode 100644 packages/workspace-server/src/services/worktree-path/worktree-path.ts create mode 100644 packages/workspace-server/src/services/worktree-query/worktree-query.test.ts create mode 100644 packages/workspace-server/src/services/worktree-query/worktree-query.ts rename apps/code/src/main/services/workspace/workspaceEnv.ts => packages/workspace-server/src/workspace-env.ts (97%) create mode 100644 packages/workspace-server/vitest.config.ts delete mode 100644 plans/2026-05-27-workspace-server-vertical-slice.md create mode 100644 scripts/check-host-boundaries.mjs create mode 100644 scripts/host-boundary-allowlist.json diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 355d9a3723..de28c763ba 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -23,3 +23,11 @@ jobs: - name: Run Biome run: biome ci . + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + + - name: Check host boundaries (apps/code must stay a thin Electron host) + run: node scripts/check-host-boundaries.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 81059bea70..c474ab7d1d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -91,7 +91,7 @@ jobs: - name: Package Electron app run: pnpm --filter code run package env: - NODE_OPTIONS: "--max-old-space-size=4096" + NODE_OPTIONS: "--max-old-space-size=6144" - name: Install Playwright if: steps.playwright-cache.outputs.cache-hit != 'true' diff --git a/.gitignore b/.gitignore index 90dbc33161..623b8abfcf 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ playwright-report/ test-results/ *storybook.log .session-store.json +.playwright-mcp # Downloaded binaries apps/code/resources/codex-acp/ diff --git a/MIGRATION.md b/MIGRATION.md index 7bfba11a46..822bd4dae4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -4,8 +4,302 @@ Running log of what moved and where. Ten lines per entry max. For the procedure to follow when porting a new feature, see [REFACTOR.md](./REFACTOR.md). +## 2026-06-02 — task-creation orchestration → @posthog/ui (ui-task-detail COMPLETE) + +- Moved: `TaskService` + `TaskCreationSaga` (the canonical renderer-service-fetching-domain-data + multi-step-orchestration forbidden pattern, ~610L) → `@posthog/ui/features/task-detail/{taskService,taskCreationSaga}`. apps task-detail feature is now fully ported. +- Registered: NEW `TASK_CREATION_PORT` (taskCreationPort.ts) aggregating workspace/folders/environment/git host I/O + getAuthenticatedClient + getTaskDirectory + getWorkspace. apps `TrpcTaskCreationPort` adapter bound in `di/container.ts`. Added `disconnectFromTask` to `sessionServiceBridge`. +- Data: orchestration (saga steps + rollback) is host-agnostic in ui; the port is dumb transport; TaskService stays a thin injectable wrapper updating ui stores. +- Cleaned: deleted apps `task-detail/service/service.ts` + `sagas/task/task-creation.ts`; repointed di/container + task-service-bridge to the ui TaskService; migrated the 490L saga test → ui (port mock replaces trpc/getSessionService vi.mocks). +- Validation: apps tsc 0; ui my-files 0; biome 0 noRestrictedImports; ui task-detail+bridge vitest 29/29 (saga 7/7); renderer `vite build` ✓ (whole app bundles). ui-task-detail → needs_validation (live create-task GUI smoke remains). + +## 2026-06-02 — sidebar imperative host-I/O retired → @posthog/ui (ui-sidebar) + +- Moved: `taskViewedApi` + `pinnedTasksApi` (the last imperative host helpers in apps sidebar) → `@posthog/ui/features/sidebar/taskMetaApi.ts` via module-setter `setTaskMetaApi` (parse/unpin/isPinned logic in ui; raw `trpc.workspace.*` host calls injected, wired in `desktop-services.ts`). +- Repointed: sessions/service/service.ts, archive-task-bridge, task-mutation-bridge, + 2 sessions test mocks → ui taskMetaApi. `git rm` apps useTaskViewed.ts + usePinnedTasks.ts (0 consumers). +- Data: per-task pins/timestamps truth stays host (trpc.workspace); taskMetaApi is dumb transport for non-React callers (React reads go through SIDEBAR_TASK_META_CLIENT hooks). +- Bridge: useSidebarData.ts / useTaskPrStatus.ts pure re-export shims remain (cosmetic; one is mid concurrent-delete). panels/index.ts dead barrel (0 consumers). +- Validation: apps tsc 0; ui taskMetaApi clean; biome 0 noRestrictedImports; ui sidebar vitest 41/41. GUI smoke blocked by exogenous ui-inbox InboxView build breakage. ui-sidebar → needs_validation. + +## 2026-06-02 — settings feature COMPLETE → @posthog/ui (ui-settings) + +- Moved: `SettingsDialog` (container), `settings/sections/SignalSourcesSettings`, `inbox/components/DataSourceSetup` (576L) → `@posthog/ui`. apps settings feature is now 100% re-export shims. +- Registered: NEW `LINEAR_INTEGRATION_CLIENT` port + `LinearIntegrationClient` iface (integrations/ports.ts); `TrpcLinearIntegrationClient` adapter bound in `desktop-services.ts` (DataSourceSetup's lone trpc call `linearIntegration.startFlow`). GitHubRepoPicker + useAuthenticatedClient were already in ui (false blockers). +- Data: settings persistence stays host (SETTINGS_*_PORT + settingsStore); SettingsDialog is pure UI reading the ported sections. +- Bridge: apps re-export shims at `@features/settings/components/SettingsDialog`, `.../sections/SignalSourcesSettings`, `@features/inbox/components/DataSourceSetup` (consumers App.tsx/MainLayout + inbox unchanged). +- Validation: apps tsc 0; ui my-files 0; biome 0 noRestrictedImports; ui inbox+settings+integrations vitest 89/89; renderer `vite build -c vite.renderer.config.mts` ✓. ui-settings → needs_validation (only live GUI smoke remains). + +## 2026-06-02 — Slack settings cluster → @posthog/ui (ui-settings) + +- Moved: `settings/sections/{SlackSettings,SignalSlackNotificationsSettings}` → `@posthog/ui/features/settings/sections/`. Last real settings sections except the inbox-gated SignalSources. +- Registered: NEW `SLACK_INTEGRATION_CLIENT` port + `SlackIntegrationClient` iface in `@posthog/ui/features/integrations/ports.ts` (mirrors GITHUB_INTEGRATION_CLIENT: startFlow/consumePendingCallback/onCallback/onFlowTimedOut). Ported `useSlackConnect` + `useSlackIntegrationCallback` → `@posthog/ui/features/integrations/` (off `@renderer/trpc` → useService + ui auth store). Desktop adapter `TrpcSlackIntegrationClient` bound to SLACK_INTEGRATION_CLIENT in `desktop-services.ts`. +- Data: Slack integration list/connection truth stays in the host slackIntegration router (react-query cache projection); the port is dumb transport. +- Cleaned: deleted dead apps `integrations/hooks/{useSlackConnect,useSlackIntegrationCallback}.ts` (0 consumers after the move). Inbox hooks (useSignalSourceManager/useSlackChannels) were already in ui = false blockers. +- Bridge: apps `settings/.../sections/{SlackSettings,SignalSlackNotificationsSettings}.tsx` re-export shims (consumers SettingsDialog + SignalSourcesSettings unchanged). +- Validation: ui typecheck (my files 0; exogenous task-detail/sessions red), apps typecheck 0, biome 0 noRestrictedImports, ui integrations+settings vitest 11/11, renderer `vite build -c vite.renderer.config.mts` ✓. +- Remaining ui-settings: SignalSourcesSettings + SettingsDialog gated ONLY on inbox's DataSourceSetup → ui (ui-inbox in_progress owns it). + +## 2026-06-01 — editor/setup/tasks/connectivity/skill-buttons leaves → @posthog/ui + +- Moved: `editor/prompt-builder` (→`@posthog/shared` for path), `setup/{buildDiscoveredTaskPrompt,categoryConfig,SetupScanFeed}`, `connectivity/connectivityToast`, `tasks/taskKeys` → `@posthog/ui`. All pure / ui-only deps. +- Dedup: `skill-buttons/prompts` apps copy (divergent near-dup, 4 live consumers) → shim re-exporting the canonical ui twin (single source of truth). Deleted dead `integrations/integrationStore` (0 refs). +- Bridge: apps re-export shims at old paths where consumers are hot (App/SessionView/SuggestedTasksPanel/SuggestedTaskCard/sessions/task-creation); cold single-consumers repointed directly. +- Validation: full typecheck 19/19; ui mcp-apps 39/39 + billing spendAnalysis 21/21; biome clean. + +## 2026-06-01 — mcp-apps pure utils → @posthog/ui + +- Moved: `mcp-app-theme.ts` (pure) + `mcp-app-csp.ts` (ext-apps type only) + tests → `@posthog/ui/features/mcp-apps/utils/` (alongside the already-ported host-utils). +- Bridge: none — single consumer `useAppBridge` repointed to the ui path. +- Validation: ui typecheck 0; mcp-apps/utils tests 39/39; biome clean. + +## 2026-06-01 — billing spend-analysis pure layer → @posthog/ui + +- Moved: `spendAnalysisFormat.ts` + `spendAnalysisPrompt.ts` (+test, 21) → `@posthog/ui/features/billing/`. Pure display/markdown helpers, no trpc/store/host coupling. +- Cleaned: spendAnalysisPrompt's type import now reads `@posthog/api-client/spend-analysis` directly (the apps `types/spend-analysis.ts` was only re-exporting that). +- Data: SpendAnalysisResponse owned by `@posthog/api-client`; these are pure projections of it. +- Bridge: none — single cold consumer `TokenSpendAnalysisBanner` repointed directly. Deferred `billing/utils.ts` (blocked on `@main` llm-gateway `UsageOutput` type). +- Validation: full typecheck 19/19; spendAnalysisPrompt 21/21; biome clean. + +## 2026-06-01 — handleExternalAppAction + focusToast → @posthog/ui (external-app-action-port) + +- Moved: `handleExternalAppAction` → `@posthog/ui/features/external-apps/handleExternalAppAction.ts`; `focusToast.tsx` → `@posthog/ui/features/focus/`. The recurring "hot host util" that blocked code-editor/panels/task-detail/sessions from importing it. +- Registered: `EXTERNAL_APPS_CLIENT` port extended with `openInApp`/`copyPath`; new module-level `setExternalAppsClient` (cloudFileReader pattern) for the non-React caller, wired at boot in `desktop-services` from the DI singleton. +- Data: source of truth is the desktop adapter behind `EXTERNAL_APPS_CLIENT`; toasts/auto-focus are derived effects. +- Bridge: apps `@utils/handleExternalAppAction.tsx` re-export shim (8 consumers unchanged). Retire when code-editor/panels/task-detail import the package path directly. +- Validation: full typecheck 19/19; ui external-apps 6/6 (3 new); biome clean. GUI smoke pending. + +## 2026-06-01 — code-review presentational batch → @posthog/ui (ui-code-review) + +- Moved: `DiffSettingsMenu`, `DiffSourceSelector`, `DraftCommentAnnotation`, `ReviewToolbar`, `constants.ts`, `hooks/useCommentState.ts` → `@posthog/ui/features/code-review` (consume only ui stores/primitives + `@pierre/diffs` + lucide). +- Registered: added `lucide-react ^1.7.0` to `@posthog/ui` deps (ReviewToolbar icons; forward-compat for remaining code-review components). +- Bridge: app re-export shims at all 6 old paths; coupled siblings (ReviewShell/ReviewPage/ReviewRows) import them via the shims unchanged. +- Note: the bulk of code-review (diff rendering + comment hooks) is blocked — it needs `trpc.git` diffs (the git-interaction cache-coherence unit) + the unported `task-detail` hub. +- Validation: ui typecheck 0 + code-review 27/27; apps web/main 0 non-exogenous; apps ReviewShell.test 4/4; biome clean. + +## 2026-06-01 — resolveCloudPrUrl → @posthog/ui (ui-git-interaction) + +- Moved: pure `resolveCloudPrUrl` (PR-url derivation, zero trpc) + test → `@posthog/ui/features/git-interaction/cloudPrUrl.ts` (Task ← `@posthog/shared/domain-types`, AgentSession ← ui sessionStore). +- Bridge: apps `useCloudPrUrl.ts` re-exports it; the hook stays in apps (depends on unported `useTasks`). Consumers (useCloudRunState/useTaskPrUrl) unchanged. +- Note: the rest of the git-interaction data layer is ONE coherent tRPC-react cache unit (usePrActions optimistic writes share read hooks' keys; gitCacheKeys/updateGitCache keys are shared with ChangesPanel et al.) — must move together behind GIT_INTERACTION_CLIENT, not piecemeal. See slice notes. +- Validation: ui typecheck 0; ui git-interaction 63/63; apps web touched-files clean (3 exogenous message-editor errors); biome clean. + +## 2026-06-01 — PrActionType → @posthog/shared + prStatus → @posthog/ui (ui-git-interaction) + +- Moved: `prActionType` enum/`PrActionType` → `@posthog/shared/git-domain` (zod-backed, barrel-exported); `git-interaction/utils/prStatus.tsx` → `@posthog/ui/features/git-interaction/utils/` (pure PR-status presentation). +- Cleaned: removes the `@main/services/git/schemas` import that previously blocked porting `prStatus`. main schemas re-export the shared type (drop-in); ws-server keeps its own enum (zod v4-vs-v3 isolation). +- Bridge: app re-export shims at `@features/git-interaction/utils/prStatus`; consumers (TaskActionsMenu/PRBadgeLink/usePrActions) unchanged. +- Validation: shared+ui+apps(main+web)+ws-server typecheck 0; ui git-interaction 56/56; biome clean. + +## 2026-06-01 — agentVersion + getFilePath → @posthog/ui/utils (renderer-shared-utils) + +- Moved: `agentVersion.ts`(+test) → `@posthog/ui/utils/agentVersion` (pure semver gate; added `semver`/`@types/semver` to ui); `getFilePath.ts` → `@posthog/ui/utils/getFilePath` behind `setFilePathResolver`. +- Registered: `setFilePathResolver` wired in `desktop-services` to Electron `window.electronUtils.getPathForFile` (the only host-specific bit; stays in apps). +- Bridge: app re-export shims at both `@utils/*` paths — consumers (useAgentVersion, message-editor/persistFile) unchanged. +- Validation: ui typecheck 0; apps/code web tsc 0; agentVersion 11/11; persistFile 12/12; biome clean. + +## 2026-06-01 — createPr orchestration → @posthog/core/git-pr (git-pr-coupled) + +- Moved: the create-PR saga orchestration `apps/code/.../git/service.ts createPr` → `GitPrService.createPr(input, host, onProgress)`. The already-ported `CreatePrSaga` is now constructed+run inside core; apps no longer imports it. +- Registered: new `CreatePrHost`/`CreatePrInput`/`CreatePrResult` in `packages/core/src/git-pr/ports.ts`; `GitPrLogger` now extends `SagaLogger`. Host ops passed per-call (no DI cycle). +- Data: source of truth is core `GitPrService`; apps `GitService.createPr` is a thin transport bridge (builds the host adapter, emits `GitServiceEvent.CreatePrProgress`). +- Bridge: apps `GitService.createPr` + git router forward unchanged; `createPrViaGh` (gh CLI = host syscall) stays host-side behind the port. Retire when renderer consumes workspace-client. +- Validation: core typecheck 0 + purity gate 0; core git-pr.test 7/7 (3 new createPr); apps main tsc 0; apps git service.test 27/27. GUI PR-creation smoke not run. + +## 2026-06-01 — host-coupled utils (sounds/browser/dialog/clearStorage) → @posthog/ui + +- Moved: `sounds` (+13 .mp3 assets), `browser`, `dialog`, `clearStorage` → `@posthog/ui/utils` via the module-setter pattern (`setMessageBoxHost`, `setStorageDataCleaner`, existing `openExternalUrl`/`setCloudFileReader`). `sounds` eliminated the redundant `COMPLETION_SOUND_PORT`. +- Registered: desktop-services wires the setters to trpc (`os.showMessageBox`, `folders.clearAllData`, `os.openExternal`, `fs.readFileAsBase64`). +- Bridge: app re-export shims at all `@utils/*` paths — consumers unchanged. +- Validation: ui + apps typecheck clean; notifications 12/12; biome clean. + +## 2026-06-01 — renderer-shared-utils keystone batch → @posthog/ui + +- Moved: `overlay`(+test), `promptContent`(+test), `urls`(+test), `posthogLinks` → `@posthog/ui/utils`; `useBlurOnEscape` → `@posthog/ui/hooks`; deleted dead `object.ts`. +- Cleaned: `urls`/`posthogLinks` read region/projectId from the ui auth store (`useAuthStore.getState()`) instead of app `getCachedAuthState` — no port needed. `overlay` (DOM) unblocked `useBlurOnEscape`. +- Bridge: app re-export shims at all old `@utils/*` / `@hooks/*` paths — consumers unchanged. +- Validation: ui + apps typecheck clean; overlay/promptContent/urls tests 23/23; biome clean. + +## 2026-06-01 — cloud-artifacts + cloud-prompt → packages/ui (sessions, ~640 LOC) + +- Moved: `features/sessions/utils/cloudArtifacts.ts` (409L) + `features/editor/utils/cloud-prompt.ts` (230L) → `packages/ui` (sessions/editor). Deps → `@posthog/shared`/`@posthog/api-client`/`@posthog/ui`. +- Registered: new `cloudFileReader.ts` module-level host setter (`setCloudFileReader`) wired at boot in `desktop-services.ts`; replaces the per-file `trpcClient.fs.readFileAsBase64` call. +- Bridge: app re-export shims at both old paths (sessions service / task-creation saga / useTaskCreation unchanged). cloud-prompt.test (16) moved to ui, mock repointed, node:url removed. +- Validation: ui + apps typecheck clean; cloud-prompt.test 16/16; biome clean. + +## 2026-06-01 — GeneralSettings → packages/ui via SETTINGS_GENERAL_PORT (ui-settings) + +- Moved: `sections/GeneralSettings` (largest settings section, 559 LOC) → `packages/ui`; sleep pref behind new `SETTINGS_GENERAL_PORT`, sound via `COMPLETION_SOUND_PORT`, `getPostHogUrl` inlined via `@posthog/shared`. +- Registered: `RendererSettingsGeneralClient` (sleep.getEnabled/setEnabled) bound in `desktop-services.ts`; app shim left. +- Validation: ui + apps/code typecheck clean for settings; biome clean; settings tests 11/11. + +## 2026-06-01 — UpdatesSettings → packages/ui via SETTINGS_UPDATES_CLIENT port (ui-settings) + +- Moved: `sections/UpdatesSettings` → `packages/ui/src/features/settings/sections/` behind a new `SETTINGS_UPDATES_CLIENT` port (`ports.ts`); rewrote off `@renderer/trpc` to `useService` + per-feature client. +- Registered: desktop adapter `RendererSettingsUpdatesClient` (wraps `os.getAppVersion`/`updates.check`/`updates.onStatus`) bound in `desktop-services.ts`. +- Note: confirms the per-feature client-port pattern (no generic main-trpc-react client possible — app router type can't cross into ui). Template for the remaining trpc-coupled sections. +- Validation: ui + apps/code typecheck clean for settings; biome clean. + +## 2026-06-01 — settings components batch 1 → packages/ui (ui-settings) + +- Moved: `SettingRow`, `SettingsOptionSelect`, `ModalInlineComboboxContent` (pure) + `sections/TerminalSettings`, `sections/PersonalizationSettings` → `packages/ui/src/features/settings/`. Imports repointed (analytics → `@posthog/ui/workbench/analytics`, `ANALYTICS_EVENTS` → `@posthog/shared`, useDebounce → ui). +- Bridge: app re-export shims at `@features/settings/components/*` keep all consumers (7 SettingRow sections, SettingsDialog, Signal* sections) unchanged. +- Cleaned: shrinks the settings feature in apps/code; SettingRow now a shared ui presentational primitive. +- Deferred: sections using `@renderer/trpc` (Updates/Permissions/Workspaces/ClaudeCode), auth/seat (Account), integrations (GitHub/Slack), host utils (General/Advanced) — all gated on a packages/ui main-trpc-react port. +- Validation: ui + apps/code typecheck clean for settings; biome clean. + +## 2026-06-01 — SetupRunService orchestration → packages/ui (setup-orchestration) + +- Moved: `apps/code/.../setup/services/setupRunService.ts` (656 LOC forbidden renderer orchestration) → `packages/ui/src/features/setup/setupRunService.ts` as an `@injectable()` Inversify UI service. `prompts.ts` → `packages/ui/src/features/setup/prompts.ts`. +- Registered: `SETUP_RUN_PORT` (packages/ui/.../setup/ports.ts) — host capability port (auth/task-API/agent/enrichment/env/analytics, intent-based). Service injects it + `WORKBENCH_LOGGER`; writes to the ported setupStore. +- Bridge: `apps/code/.../platform-adapters/setup-run-port.ts` (RendererSetupRunPort) wraps trpcClient + authed PostHog client + analytics + dev flag; bound in desktop-services.ts. `RENDERER_TOKENS.SetupRunService` now binds the package class. +- Data: SetupRunService owns the flow; SETUP_RUN_PORT owns host I/O; setupStore holds UI state. +- Cleaned: removes the canonical "Renderer Service Fetching Domain Data" forbidden pattern (no trpc/Electron/analytics/import.meta.env in the package). +- Validation: setupRunService.test 6 + suggestions.test 8 = 14/14; ui + apps/code typecheck clean for setup (other red exogenous); biome clean. Live discovery smoke not run. + +## 2026-06-01 — ErrorBoundary → packages/ui/primitives (ui-shell leaf) + +- Moved: `apps/code/.../components/ErrorBoundary.tsx` → `packages/ui/src/primitives/ErrorBoundary.tsx`, made host-agnostic (dropped `@utils/analytics`+`@utils/logger`; added `onError(error,{componentStack,suppressed})` prop). +- Bridge: `apps/code/.../components/ErrorBoundary.tsx` is now a thin wrapper supplying `onError` → `captureException` + `logger.scope`; re-exports `ErrorBoundaryProps`. Consumers (App.tsx, task-detail/TaskLogsPanel) unchanged. +- Data: telemetry/logging decision stays in the host wrapper; the primitive only signals via callback. +- Cleaned: removes apps/code analytics/logger coupling from a shared primitive. +- Validation: ui + apps/code typecheck clean for ErrorBoundary; ErrorBoundary.test 10/10 (kept in apps/code as wrapper+primitive integration test — packages/ui lacks @testing-library); biome clean. + +## 2026-06-01 — setup domain logic dedup (sub-slice of ui-onboarding) + +- Moved: pure enricher suggestion builders (buildStaleFlagSuggestion/buildSdkHealthSuggestion/buildPosthogSetupSuggestion + StaleFlagPayload) `apps/code/.../setup/services/setupRunService.ts` → `packages/ui/src/features/setup/suggestions.ts` (+ suggestions.test.ts, 8 tests). +- Cleaned: deleted byte-duplicate stale `apps/code/.../setup/types.ts` + `apps/code/.../setup/stores/setupStore.ts` (canonical lives in `@posthog/ui/features/setup/{types,setupStore}`; app copies had zero external consumers) — removes a duplicated-truth violation. +- Data: source of truth is `@posthog/ui/features/setup/types.ts` (DiscoveredTask + buildTaskDiscoverySchema). +- Bridge: none. Behavior-preserving; SetupRunService imports builders from the package. +- Remaining (ui-onboarding parent): SetupRunService orchestration (runDiscovery/runEnricher) still in renderer → move to core/main behind agent/enrichment/task-run/auth ports; delete onboarding stale dups. +- Validation: @posthog/ui + apps/code typecheck clean for setup (other red exogenous); suggestions.test 8/8; biome clean. + +## 2026-06-01 — skills backing service + host ops → workspace-server (ui-skills #1) + +- Moved: skill-listing host fs ops → `packages/workspace-server/src/services/skills/skill-discovery.ts` (findSkillDirs, getMarketplaceInstallPaths, readSkillMetadataFromDir) + `parse-skill-frontmatter.ts`; created `SkillsService.listSkills()` (`skills.ts`) injecting POSTHOG_PLUGIN_SERVICE + FOLDERS_SERVICE, with zod `schemas.ts` as boundary source of truth. +- Registered: `skillsModule` binds SKILLS_SERVICE; loaded in apps/code `container.ts` after posthogPluginModule (shares the bound plugin/folders singletons + single SQLite conn). +- Cleaned: `routers/skills.ts` collapsed to a one-line forward to SKILLS_SERVICE.listSkills() — removed the "router with no backing service + inline logic + container.get" forbidden pattern. Split `agent/discover-plugins.ts`: SDK-coupled `discoverExternalPlugins` stays in apps/code (agent slice; @anthropic-ai/claude-agent-sdk not a ws-server dep) and imports the shared helpers from ws-server. Deleted apps/code `skill-schemas.ts` + `parse-skill-frontmatter.ts`. +- Data: source of truth is ws-server `skills/schemas.ts` skillInfo zod; SkillInfo/SkillSource neutral types in @posthog/shared. +- Bridge: none new. MAIN_TOKENS.PosthogPluginService alias remains for the unrelated old posthog-plugin service. +- Validation: ws-server typecheck clean; ws-server skill-discovery.test.ts 5/5; apps/code agent discover-plugins.test.ts 21/21 (behavior preserved); biome clean. apps/code typecheck red is exogenous (concurrent MAIN_TOKENS-alias removal). UI move (SkillsView/SkillDetailPanel/skill-buttons) blocked on a packages/ui main-trpc client port + ui-code-editor/ui-task-detail/ui-shell + sessions. + +## 2026-05-30 — OAuth + integrations + McpCallback + Notification retirements (6 more MAIN_TOKENS removed) + +- Retired MAIN_TOKENS.OAuthService: already package-canonical (`.toService(OAUTH_SERVICE)`); repointed the 3 consumers (index bootstrap, oauth router, auth `OAuthFlowPortAdapter` @inject) to OAUTH_SERVICE, deleted bridge + token. +- Ported the integration services off `MAIN_TOKENS → .to(class)` to package-canonical identifiers: added GITHUB_INTEGRATION_SERVICE / LINEAR_INTEGRATION_SERVICE / SLACK_INTEGRATION_SERVICE to `packages/core/src/integrations/identifiers.ts`, bound the core classes to them, repointed consumers (github/linear/slack routers + index), removed the 3 tokens. No bridge needed (all consumers host-level). +- Retired MAIN_TOKENS.McpCallbackService: repointed the mcp-callback router to the existing MCP_CALLBACK_SERVICE, deleted the `.toService` bridge + token. +- Ported NotificationService to a package identifier: added NOTIFICATION_SERVICE to `packages/core/src/notification/identifiers.ts`, bound the core class to it, repointed consumers (notification router + index), removed the token. +- 15 MAIN_TOKENS service tokens retired this session. Validation: core + apps/code typecheck 0 errors; core notification test 8/8; full `pnpm typecheck` 19/19. +- Completed the integrations registration module: added `packages/core/src/integrations/integrations.module.ts` (binds GITHUB/LINEAR/SLACK_INTEGRATION_SERVICE, singleton) per REFACTOR.md "Registration Modules"; apps/code container now `container.load(integrationsModule)` + binds only the host logger ports, instead of three inline `.to(class)` binds. Lets a future web/mobile host load integrations without app-local wiring. core + my apps/code files: 0 errors. +## 2026-05-30 — host-consumer repointing + validation campaign + +- Repointed the host-side consumers of the 4 remaining bridges to package identifiers: llm-gateway/cloud-task/suspension/mcp-apps routers + menu.ts (McpApps) + index.ts (Suspension) now `container.get()`. Each bridge now has exactly ONE consumer left (the off-limits tangle inject: Git/Handoff/Workspace/Agent) — annotated in container.ts so the final retirement is a one-liner. +- Validation campaign: ran package test suites. core 210 passing; ws-server pass except the better-sqlite3 DB round-trip (Electron-ABI NODE_MODULE_VERSION 145 vs 137 — environmental, not code). Promoted 16 needs_validation slices to passing with per-slice evidence: connectivity, environments, folders, archive, suspension, usage-monitor, cloud-task, enrichment, fs-capability, local-logs-capability, llm-gateway, notifications, os, github-integration, slack-integration, linear-integration. +- Authored 9 new test suites (83 tests): core llm-gateway (prompt/usage/invalidate + timeout), oauth (refreshToken status->errorCode, cancelFlow, deep-link refocus), task-link (path/run-id/queue/focus), notification (click-navigate + dock badge lifecycle), integrations github + slack (startFlow url/timeout, callback parsing incl non-numeric ids, queue/consume, timeout-cancel) + linear (authorize url + error wrap); ws-server os (showMessageBox mapping, dialog-port pickers, getClaudePermissions parse), workspace-metadata (togglePin/markViewed/markActivity-clamp + projections — annotates the in_progress workspace slice); shared backoff (getBackoffDelay exponential + cap, sleepWithBackoff timing) + regions (getCloudUrlFromRegion, getOauthClientIdFromRegion distinct-per-region, formatRegionBadge) + errors (auth/rate-limit/fatal-session classification incl rate-limit-precedence) + xml (escape/unescape round-trip). 13 suites total (~96 tests), all green; shared 277/277, core 210+, ws-server pass (modulo DB-ABI). auth slice annotated: oauth is test-backed, blocked only on agent coupling. +- Mid-turn convergence with concurrent agents: adapted oauth.test.ts to a newly-added 7th constructor param (CRYPTO_SERVICE / @posthog/platform/crypto port another agent extracted); rode out transient updates.ts / updates.test.ts churn without touching their slice. +- NOTE: src/updates/updates.test.ts has 1 red ("disabled/unsupported platform") from another agent's in-flight updates refactor (static props DISABLE_ENV_FLAG/SUPPORTED_PLATFORMS) — exogenous, not from this work; left untouched. --- +## 2026-05-29 — persistence-repositories (SQLite DB layer → workspace-server, in-process keep-sync) + +- Moved: `apps/code/src/main/db/**` → `packages/workspace-server/src/db/**` (drizzle `schema`, `DatabaseService`, 8 repositories + `.mock`, `test-helpers`, migrations). New `db/identifiers.ts` (`DATABASE_SERVICE`) + `db/db.module.ts`. +- Registered: `databaseModule` bound in main `di/container.ts` (`container.load`); `DatabaseService` injects platform `STORAGE_PATHS_SERVICE`; repos inject `DATABASE_SERVICE`. +- Data: source of truth is the on-disk SQLite (`posthog-code.db`); repositories are the typed sync access layer (unchanged — kept in-process, not cross-process). +- Cleaned: dropped main logger + `MAIN_TOKENS`/`@shared` coupling from db (inlined `CloudRegion`, `SuspensionReason`, package-local `normalize-path`). Fixed apps/code `vitest.config` to reuse `rendererAliases` (`@posthog/*` workspace aliases). +- Bridge: `MAIN_TOKENS.DatabaseService` → `DATABASE_SERVICE`, and the 8 `MAIN_TOKENS.*Repository` bindings (now → package classes) remain (PORT NOTE in container.ts) so the 19 consumers are unchanged; only their db type-import paths were repointed. Build: `copy-drizzle-migrations` source + `drizzle.config` repointed to the package; runtime read path unchanged. +- Validation: `pnpm typecheck` 19/19; `pnpm --filter code test` 124 files / 1527 pass (incl. real-SQLite archive integration); `pnpm dev:code` boots clean (migrations copied, in-process DB init, live tRPC IPC, no errors). Unblocks the persistence-coupled core tier (folders/workspace/archive/suspension/handoff/agent/auth). + +## 2026-05-29 — power-manager-capability (retire platform-identifiers power-manager bridge) + +- Moved: auth, sleep, agent services now inject `POWER_MANAGER_SERVICE` (@posthog/platform/power-manager) instead of `MAIN_TOKENS.PowerManager`. +- Cleaned: removed the `MAIN_TOKENS.PowerManager` alias (container.ts) + token (tokens.ts) + sleep's unused MAIN_TOKENS import. ElectronPowerManager adapter unchanged (dumb onResume/preventSleep). Sleep-blocking decisions remain in SleepService. +- Validation: my files typecheck clean (unrelated git.ts errors are concurrent git-read WIP); biome clean. GUI smoke pending. + +## 2026-05-29 — deep-links (partial: host-agnostic parsers → @posthog/shared) + +- Moved: `decodePlanBase64` + `parseGitHubIssueUrl` (were private in `apps/code/src/main/services/new-task-link/service.ts`) → `packages/shared/src/deep-links.ts` (+ `GitHubIssueRef` type), exported from the shared barrel. new-task-link now imports them from `@posthog/shared`. +- Data: pure host-agnostic parsing utilities; no state. (Slice said `core`, but zero-dep pure utils belong in `shared`.) +- Bridge: none. Host wiring (Electron protocol registration via IAppLifecycle, IMainWindow focus, event emit/queue) intentionally stays in the apps/code link services. +- Remaining (slice in_progress): move `getDeeplinkProtocol` + `NewTaskLinkPayload`/`NewTaskSharedParams` to @posthog/shared (repoint ~10 importers); extract deep-link URL-decomposition + task/inbox path parsers. +- Validation: shared build + typecheck; `deep-links.test.ts` 8/8; apps/code typecheck clean for deep-links files. + +## 2026-05-29 — dialog-capability (retire platform-identifiers dialog bridge) + +- Moved: 4 main consumers (os.ts router, handoff, context-menu, folders) now inject `DIALOG_SERVICE` (@posthog/platform/dialog) instead of `MAIN_TOKENS.Dialog`. +- Cleaned: removed the `MAIN_TOKENS.Dialog` `.toService` alias (container.ts) + token (tokens.ts). ElectronDialog adapter unchanged (thin wrapper). +- Remaining: os.ts (396-line serviceless router) -> backing-service split (acceptance #2) overlaps os/misc-host-capabilities; deferred. GUI smoke (file picker + message box) pending. +- Validation: dialog edits typecheck clean (unrelated git.ts WorkspaceClient error is the concurrent git-read agent's WIP); biome clean. + +## 2026-05-29 — clipboard-capability (retire platform-identifiers clipboard bridge) + +- Moved: sole main consumer `external-apps/service.ts` now injects `CLIPBOARD_SERVICE` (@posthog/platform/clipboard) instead of `MAIN_TOKENS.Clipboard`. +- Cleaned: removed the `MAIN_TOKENS.Clipboard` `.toService(CLIPBOARD_SERVICE)` alias (container.ts) and the `MAIN_TOKENS.Clipboard` token (tokens.ts). ElectronClipboard adapter unchanged (already a dumb writeText wrapper). +- Note: renderer copy uses `navigator.clipboard` directly (host-appropriate DOM API), not trpcClient — no clipboard misuse to migrate. Image copy/paste path is os.ts saveClipboardImage (separate slice). +- Validation: apps/code(node) typecheck; platform-identifiers test 4/4. GUI smoke (copy text/image) pending. + +## 2026-05-29 — notifications (renderer-consumed capability; gating in packages/ui, host adapter dumb) + +- Moved: gating from `apps/code/src/renderer/utils/notifications.ts` -> `packages/ui/src/features/notifications/TaskNotificationService` (stopReason + focus/active-task + settings gating, title truncation). New platform contract `packages/platform/src/notifications.ts` (`INotifications`: notify/showUnreadIndicator/requestAttention, `NOTIFICATIONS_SERVICE`). New renderer adapter `apps/code/src/renderer/platform-adapters/notifications.ts` (dumb trpcClient.notification wrapper). +- Registered: `notificationsUiModule` (binds TaskNotificationService) loaded in `desktop-contributions.ts`; `NOTIFICATIONS_SERVICE` + the settings/active-view/sound UI ports bound in `desktop-services.ts`. +- Data: source of truth for "should notify" is the gating in TaskNotificationService, computed from injected facts (settings snapshot, document focus, active task id). No persisted/duplicated state. +- Bridge: `apps/code/src/renderer/utils/notifications.ts` free functions now delegate to TaskNotificationService via the renderer container (PORT NOTE). Retire when the sessions service uses `useService` directly. Main NotificationService/router/electron-notifier unchanged. +- Cleaned: platform interface is host-neutral (showUnreadIndicator/requestAttention, not dockBadge/bounceDock — adapter maps to the existing trpc procedure names). +- Validation: platform typecheck+build; apps/code web typecheck 0 errors; 12 TaskNotificationService unit tests pass. GUI smoke not yet run. + +## 2026-05-29 — ui-primitives (dependency-clean leaf primitives → packages/ui/src/primitives) — in_progress (partial) + +- Moved: `components/ui/{Tooltip,Button,Badge,KeyHint,PanelMessage,StepList,SafeImagePreview}`, `components/{List,Divider,DotsCircleSpinner,DotPatternBackground,CodeBlock}`, `components/ui/combobox/{Combobox,Combobox.css,useComboboxFilter}`, `hooks/{useDebounce,useDebouncedValue,useInView,useImagePanAndZoom}`, `utils/{toast,confetti}` → `packages/ui/src/primitives/**`. +- Registered: none (pure presentational primitives; no DI module). Importers across `apps/code/src` rewritten to `@posthog/ui/primitives/*` (short + `@renderer/*` + relative forms all covered). +- Data: no state; these are stateless visual/util primitives. +- Cleaned: packages/ui gained deps `@posthog/shared`, `@radix-ui/react-tooltip`, `@radix-ui/react-icons`, `cmdk`, `canvas-confetti`, `sonner` (+`@types/canvas-confetti`). +- Bridge: colocated tests/stories (CodeBlock/useDebounce/useImagePanAndZoom tests, combobox test+story) stay in apps/code pointing at `@posthog/ui` paths until packages/ui gets vitest/storybook infra. +- Deferred/not-primitives: FileIcon (host asset glob), RelativeTimestamp/action-selector/useBlurOnEscape/syntax-highlight/HighlightedCode (blocked on renderer-shared-utils + code-editor slices); HeaderRow/HedgehogMode/ZenHedgehog/focusToast/useAutoFocusOnTyping/TreeDirectoryRow are feature-coupled (belong to feature slices, not primitives). +- Validation: `pnpm typecheck` 19/19 green. + +## 2026-05-29 — fs-capability (workspace-server owns fs syscalls; main is a WorkspaceClient bridge) — needs_validation + +- Moved: all 8 fs methods (listRepoFiles+30s cache, readRepoFile(s), readRepoFile(s)Bounded, readAbsoluteFile, readFileAsBase64, writeRepoFile) `apps/code/src/main/services/fs/service.ts` -> `packages/workspace-server/src/services/fs/service.ts` (joins existing listDirectory). fs schemas -> `packages/workspace-server/src/services/fs/schemas.ts` (source of truth); deleted the main copies. +- Registered: 8 one-line `fs.*` procedures in `packages/workspace-server/src/trpc.ts`. Main `MAIN_TOKENS.FsService` now bound in `index.ts` via `toConstantValue(new FsService(workspaceClient))` (bridge), removed from `di/container.ts`. +- Data: source of truth is workspace-server FsService; the list cache (TTL + write-self-invalidation) lives there; renderer react-query cache is the user-facing projection (invalidated by useFileWatcher). +- Cleaned: fs no longer injects FileWatcherBridge — the watcher coupling only fed the server cache, now reconciled via TTL + renderer-side invalidation. Removes one of the 4 FileWatcherBridge-retirement consumers (remaining: archive, suspension, workspace). +- Bridge: `apps/code/src/main/services/fs/service.ts` (PORT NOTE) until AgentService reads/writes via workspace-client directly. +- Validation: ws-server typecheck + fs service.test.ts 6/6 (incl. tmp-dir round-trip + path-traversal guard); apps/code typecheck clean for all fs files. Boot smoke deferred (shared tree red from concurrent ui-primitives move). + +## 2026-05-29 — connectivity (workspace-server owns polling/detection; main is status-caching bridge) + +- Moved: `apps/code/src/main/services/connectivity/service.ts` polling/HTTP-reachability/backoff -> `packages/workspace-server/src/services/connectivity/{service,schemas,service.test}.ts`. New `connectivity.{getStatus,checkNow,onStatusChange}` procedures in ws `trpc.ts` (one-line forwards), bound in ws `di/{tokens,container}.ts`. +- Data: source of truth is the live network-reachability poll in the single ws-server ConnectivityService; `isOnline` is its derived state. The main bridge caches the latest value so AuthService can read it synchronously. +- Bridge: `apps/code/src/main/services/connectivity/service.ts` is now a `WorkspaceClient` bridge (extends TypedEventEmitter; subscribes to ws `onStatusChange`, re-emits `StatusChange`, answers `getStatus()` from cache). Bound in `index.ts` after `wsServer.start()`, before `initializeServices()` (AuthService consumer). Main connectivity router + renderer connectivityStore/toast unchanged. +- Bridge retirement: delete when AuthService + renderer consume `workspaceClient.connectivity` directly. +- Cleaned: dropped main-process logger from the capability; polling timer is `unref`'d; emit-on-change-only preserved. +- Validation: ws-server + apps/code(node) typecheck; 11 unit tests pass. GUI smoke not yet run. + +## 2026-05-29 — local-logs (workspace-server owns fs read/coalesced write) + +- Moved: `apps/code/src/main/services/local-logs/service.ts` logic → `packages/workspace-server/src/services/local-logs/{service,schemas,service.test}.ts`. New `localLogs.{read,write}` procedures in `packages/workspace-server/src/trpc.ts` (one-line forwards), bound in ws `di/{tokens,container}.ts`. +- Data: source of truth is the on-disk NDJSON at `~/.posthog-code/sessions//logs.ndjson`; the single-flight latest-wins write coalescing (per `taskRunId`) now lives in the one workspace-server instance, so all writers (renderer via `logs` router, future main callers) funnel through it. +- Bridge: `apps/code/src/main/services/local-logs/service.ts` is now a thin `LocalLogsService` over `WorkspaceClient.localLogs`, bound in `index.ts` after `wsServer.start()` (mirrors FocusService/FileWatcherBridge). `logs.ts` router and the renderer sessions service are unchanged (still `trpcClient.logs.{readLocalLogs,writeLocalLogs}`). +- Bridge retirement: delete the main bridge + `logs` router local-log procedures when the renderer sessions service consumes `workspaceClient.localLogs` directly. +- Cleaned: dropped the main-process logger dependency from the capability (ws services don't log; failures still degrade to null/no-op as before). +- Known debt: `DATA_DIR` (".posthog-code") is duplicated in the ws service, apps/code `shared/constants.ts`, and handoff `seedLocalLogs` (raw fs). Consolidate into `@posthog/shared` once the di-foundation lockfile churn settles. handoff still writes the same NDJSON via raw fs (pre-existing) — should adopt the capability later. +- Validation: ws-server + ws-client + apps/code(node) typecheck; 11 unit tests pass (vitest, ws-server root). GUI smoke (logs stream/render) not yet run. + +## 2026-05-29 — di-foundation (shared DI primitives) + +- Moved: `packages/ui/src/workbench/{contribution.ts,service-context.tsx}` → `packages/di/src/{contribution.ts,react.tsx}` (`git mv`). `startWorkbenchContributions` → `startWorkbench`. +- New package `@posthog/di`: owns `WORKBENCH_CONTRIBUTION` + `WorkbenchContribution` + `startWorkbench(container)`, `useService`/`ServiceProvider` (React boundary hook — see REFACTOR.md "React Access to Services": component-boundary only, never a service-locator), and a host-agnostic `WorkbenchLogger`/`WORKBENCH_LOGGER` port. +- Registered: `fileWatcherUiModule` (`ContainerModule`) binds `FileWatcherContribution` as a `WORKBENCH_CONTRIBUTION`. `apps/code` `desktop-contributions.ts` `container.load`s it; `desktop-services.ts` binds `WORKBENCH_LOGGER` to the renderer electron-log scope; `main.tsx` calls `startWorkbench(container)` before render. +- Data: source of truth is `packages/di` for the workbench DI primitives; no persisted/derived state. +- Cleaned: renderer Vite resolves `@posthog/di/*` via a new alias in `vite.shared.mts` (consistent with every other workspace package, which the repo aliases to `src/$1` rather than node_modules `exports`). `packages/ui/tsconfig.json` gained `experimentalDecorators`+`emitDecoratorMetadata` (first `@injectable` in ui; mirrors workspace-server). +- Bridge: none. +- Validation: `pnpm typecheck` (19 tasks); `@posthog/di` `startWorkbench` unit test; `pnpm --filter code test` (1588) after `build:deps`; `pnpm dev:code` boots to a rendered window with live tRPC IPC and zero resolution/boot errors. + +## 2026-05-29 — platform-identifiers (package-owned DI symbols + MAIN_TOKENS bridge) — needs_validation + +- Added: `export const _SERVICE = Symbol.for("posthog.platform.")` to all 15 `packages/platform/src/*.ts` interface files. Each platform capability now owns its Inversify identifier beside its interface (no new identifiers added to `apps/code/src/main/di/tokens.ts`). +- Registered: `apps/code/src/main/di/container.ts` binds each Electron adapter to its package-owned identifier (`bind(CLIPBOARD_SERVICE).to(ElectronClipboard)`, …) and aliases the legacy `MAIN_TOKENS.` entries via `bind(MAIN_TOKENS.Clipboard).toService(CLIPBOARD_SERVICE)`. Same singleton, single source of truth. +- Data: source of truth is the platform identifier binding; `MAIN_TOKENS.*` platform entries are projections (aliases). Interfaces audited host-neutral (no electron/macos/dock/taskbar/tray/safeStorage terms); platform imports nothing internal. +- Bridge: the 15 `MAIN_TOKENS.` `toService` aliases remain (PORT NOTE in container.ts). Retire each once its consumers inject the `@posthog/platform` identifier directly — done per feature slice (clipboard/dialog/secure-storage/notifications/updater/power-manager/context-menu capability slices). +- Validation: `@posthog/platform` build + typecheck green; `apps/code` typecheck (node+web) green; `apps/code/src/main/di/platform-identifiers.test.ts` 4/4 (identifiers unique/namespaced; toService alias === platform singleton). Boot smoke deferred — boot path concurrently owned by in-progress di-foundation in this shared worktree. + ## 2026-05-28 — file-watcher (workspace-server owns orchestration, hook is pure useSubscription) - Moved: `apps/code/src/main/services/file-watcher/` deleted entirely. Orchestration (debounce, bulk threshold, git event filtering, git-dir resolution) lives in `packages/workspace-server/src/services/watcher/service.ts` as `WatcherService.watchRepo()`. New tRPC subscription procedure `fileWatcher.watch` emits the processed `FileWatcherEvent` discriminated union. Raw `watcher.watch` still available for unprocessed events. @@ -47,3 +341,1161 @@ For the procedure to follow when porting a new feature, see [REFACTOR.md](./REFA - Cleaned: PSK comparison now uses `timingSafeEqual`. `DiffStats` schema is the source of truth (`z.infer`), not the type. Connection query invalidates on child exit via a tRPC subscription. - Left as-is: `useTaskDiffSummaryStats` still has 4 modes (local/branch/PR/cloud). Collapses once the relay protocol exists. - New import paths: `useDiffStats(repoPath)` from `@posthog/ui/features/diff-stats/useDiffStats` (was `trpc.git.getDiffStats`). `DiffStatsBadge` from `@posthog/ui/features/diff-stats/DiffStatsBadge`. + +## 2026-05-29 — environments (TOML CRUD -> workspace-server, UI -> packages/ui) + +- Moved: `apps/code/src/main/services/environment/{service,schemas,service.test}.ts` -> `packages/workspace-server/src/services/environment/`. fs-based TOML environment CRUD is a host capability. +- Registered: ws-server `TOKENS.EnvironmentService` + `environment` tRPC router (list/get/create/update/delete, zod in/out). Added vitest to workspace-server (test script + config + smol-toml dep). +- Moved: `apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx` -> `packages/ui/src/features/environments/` + new `useEnvironments` hook (workspace-client). Cross-feature settings reach-in replaced by an `onCreateEnvironment` prop wired in TaskInput. +- Data: source of truth is the per-repo `.posthog-code/environments/*.toml` files, read/written by ws-server EnvironmentService; `Environment` zod schema is the contract. Renderer holds no env truth (react-query cache). +- Bridge: `apps/code/src/main/services/environment/service.ts` now forwards to workspace-client (binding in `index.ts`); main `environment` router + `environment/schemas.ts` remain until the settings/task-detail renderer consumers move to workspace-client. +- Deferred: `session-env/loader.ts` (agent bash env + CLAUDE_CONFIG_DIR) stays in main. +- Validation: ws-server typecheck + 21 environment tests; packages/ui typecheck; apps/code 0 new typecheck errors. App smoke pending. + +## 2026-05-29 — git-read (read-only git ops -> workspace-server) + +- Split: `git-core` -> `git-read` / `git-worktree` / `git-mutate` / `git-pr` sub-slices (git-core marked blocked/superseded). +- Moved: read-only git ops into `packages/workspace-server/src/services/git/` (thin wrappers over `@posthog/git/queries`) behind a one-line `git` tRPC router (zod in/out). +- Registered: `MAIN_TOKENS.WorkspaceClient` (the workspace-client bound in `index.ts` after `workspaceServer.start()`). +- Bridge: `apps/code/src/main/trpc/routers/git.ts` read procedures forward to ws-server via workspace-client. Main `GitService` retains read methods for in-process callers (WorkspaceService/HandoffService); retire with git-mutate/git-worktree + ui-git-interaction. +- Data: read git state computed by `@posthog/git/queries` in ws-server; no new persisted state. Reads are lockless; the per-repo write lock stays with git-mutate. +- Validation: ws-server typecheck; apps/code 0 new errors on git surface; env tests 21/21. App smoke pending. + +## 2026-05-29 — provisioning (UI -> packages/ui, subscription -> contribution) + +- Moved: `apps/code/src/renderer/features/provisioning/{stores/provisioningStore,components/ProvisioningView}` -> `packages/ui/src/features/provisioning/{store,ProvisioningView}`. Output processing (stripAnsi/processOutput) moved from the view into the store. +- Registered: `provisioningUiModule` (WORKBENCH_CONTRIBUTION -> ProvisioningContribution); `PROVISIONING_OUTPUT_PORT` host port; desktop `TrpcProvisioningOutputService` adapter bound in desktop-services; module loaded in desktop-contributions. +- Cleaned: removed component-level `useSubscription` (forbidden) — contribution subscribes once and writes the store; view is pure. Added zustand to @posthog/ui (first store in the package). +- Data: source of truth is the main ProvisioningService relay (fed by WorkspaceService.emitOutput); the ui store is a subscription-fed cache (activeTasks Set + output lines per taskId). +- Bridge: main ProvisioningService + provisioning router remain (WorkspaceService is the producer) until the workspace slice migrates. +- Validation: packages/ui typecheck; apps/code typecheck fully green; saga test 7/7. App smoke pending. + +## 2026-05-29 - core-domain-types (host-neutral type ownership) +- Moved: `WorkspaceMode` -> `@posthog/shared` (`packages/shared/src/workspace.ts`); `HandoffLocalGitState` + `GitHandoffCheckpoint` (origin `@posthog/git/handoff`) -> `@posthog/shared` (`packages/shared/src/git-handoff.ts`). +- Registered: `@posthog/shared` index barrel exports `WorkspaceMode`, `HandoffLocalGitState`, `GitHandoffCheckpoint`. +- Data: source of truth for these host-neutral domain types is now `@posthog/shared`; `@posthog/git`, `@posthog/agent`, `@posthog/workspace-server`, and apps/code consume/re-export from it. `packages/core` may now import them without violating import rules (core may not import `@posthog/agent` or `@posthog/workspace-server`). +- Cleaned: removed apps/code handoff schema reach-in to ws-server db repository for `WorkspaceMode`; removed `@posthog/agent` -> `@posthog/git/handoff` dependency for the two handoff data types. +- Bridge: `@posthog/git/handoff` and `@posthog/workspace-server/.../workspace-repository` re-export the relocated types for existing consumers; retire when all consumers import from `@posthog/shared`. +- Bridge: PostHogAPIClient contract + Task/resume domain types NOT yet relocated -> tracked as slice `agent-domain-types`. +- Validation: typecheck clean across shared/git/agent/workspace-server/core/apps/code (node+web); git handoff 158/158. + +## 2026-05-29 — persistence-layer (reconcile + real-SQLite round-trip test) + +- Decision (recorded): domain SQLite persistence lives in `packages/workspace-server` (Node-only host capability; travels with the future cloud sandbox). The move itself landed under the `persistence-repositories` slice. +- Added: `packages/workspace-server/src/db/repositories/repositories.test.ts` — the only real-SQLite repository round-trip test (RepositoryRepository CRUD + repository→workspace→worktree FK chain), using the sanctioned `createTestDb()` + stub-DatabaseService pattern. The archive integration test mocks repositories, so this fills the genuine round-trip gap. +- Data: drizzle table schema is the single source of truth for DB row shapes (`$inferSelect`/`$inferInsert`). Repositories are in-process, not a serialization boundary — no parallel zod on repo contracts (would duplicate truth). Zod lives at the tRPC boundary in consumer feature slices. +- Bridge: `MAIN_TOKENS.*Repository` + `MAIN_TOKENS.DatabaseService` aliases remain in apps/code container.ts (PORT NOTE) until consumers inject `DATABASE_SERVICE`/package repositories directly. +- Validation: ws-server typecheck clean with the test added; no Electron imports (grep). Round-trip test EXECUTION gated on node-ABI better-sqlite3 — local snapshot has Electron-ABI (NODE_MODULE_VERSION 145) so plain-node vitest can't load it; runs green in CI / after `pnpm install`. Rebuilding locally was declined (would break the shared Electron app other agents smoke-test). + +## 2026-05-29 - auth (utils sub-slice) + +- Moved: `apps/code/src/renderer/features/auth/utils/userInitials.ts` -> `packages/ui/src/features/auth/userInitials.ts` (pure projection, with test) +- Registered: added vitest runner to `@posthog/ui` (vitest.config.ts + test script); first tests in the package +- Data: source of truth is the user record; `getUserInitials` is a pure derived projection (UserLike -> initials) +- Consumers: `SettingsDialog`, `AccountSettings` import from `@posthog/ui/features/auth/userInitials` +- Bridge: none (clean move; old path deleted) +- Validation: `pnpm --filter @posthog/ui test` (28 passed), `@posthog/ui typecheck` clean +- Note: `auth` slice split into auth-utils/auth-core/auth-callback-server/auth-ui; only auth-utils landed + +## 2026-05-29 - agent-domain-types (Task DTO relocation, partial) +- Moved: PostHog Task DTOs (`Task`, `TaskRun`, `TaskRunArtifact`, `ArtifactType`, `TaskRunStatus`, `TaskRunEnvironment`, `PostHogAPIConfig`) `@posthog/agent/types` -> `@posthog/shared` (`packages/shared/src/task.ts`). +- Registered: `@posthog/shared` index barrel exports the Task DTOs; `@posthog/agent/types` re-exports them so all existing consumers keep working. +- Data: source of truth for the host-neutral PostHog Task model is now `@posthog/shared`; `packages/core` may import it without importing `@posthog/agent` (forbidden by import rules). +- Bridge: `@posthog/agent/types` re-export remains for existing consumers; retire when they import from `@posthog/shared`. +- Bridge: PostHogAPIClient method contract (interface in `@posthog/api-client`) + resume DATA types (`ResumeState`,`ConversationTurn`) NOT yet relocated — remain in `agent-domain-types` (needs new dep edges). +- Validation: typecheck clean across shared/agent/workspace-server/ui/core; apps/code residual errors are an unrelated concurrent process-tracking move. + +## 2026-05-29 - auth (ui-state-store) + regions + +- Moved: `apps/code/src/renderer/features/auth/stores/authUiStateStore.ts` -> `packages/ui/src/features/auth/authUiStateStore.ts` (thin UI store) +- Moved: `apps/code/src/shared/types/regions.ts` -> `packages/shared/src/regions.ts` (host-agnostic region types) +- Registered: `CloudRegion`/`RegionLabel`/`REGION_LABELS`/`formatRegionBadge` on the `@posthog/shared` barrel +- Data: auth form UI state (mode/invite/region) owned by the thin store; region constants are pure data in shared +- Bridge: `apps/code/src/shared/types/regions.ts` re-exports `@posthog/shared` until all 13 importers move +- Validation: ui + apps/code typecheck both 0 errors; ui tests 28 passed + +## 2026-05-29 - process-tracking + +- Moved: `apps/code/src/main/services/process-tracking/service.ts` -> `packages/workspace-server/src/services/process-tracking/process-tracking.ts`; `apps/code/src/main/utils/process-utils.ts` -> `packages/workspace-server/src/services/process-tracking/process-utils.ts` +- Registered: `processTrackingModule` (binds `PROCESS_TRACKING_SERVICE`); zod boundary schemas in package `schemas.ts` +- Data: source of truth is the in-memory live-PID registry owned by ProcessTrackingService (model `TrackedProcess`); `ProcessSnapshot`/`DiscoveredProcess` are derived projections +- Cleaned: dropped app-logger coupling (ws-server no-logger convention); router uses package zod schemas, inline z.enum removed +- Decision: IN-PROCESS KEEP — bound in main (not the ws-server child) so the 6 synchronous consumers (shell/agent/workspace/archive/suspension/app-lifecycle) are unchanged. Same pattern as the SQLite DB layer. +- Bridge: `MAIN_TOKENS.ProcessTrackingService` toService(`PROCESS_TRACKING_SERVICE`) in apps/code container; `apps/code/src/main/utils/process-utils.ts` re-export shim. Retire when consumers inject the package identifier; re-bind to the ws-server child when shell+agent move there. +- Validation: ws-server typecheck + 37 unit tests; `pnpm typecheck` 19/19; `pnpm --filter code test` 122 files/1474; `pnpm dev:code` clean boot + +## 2026-05-29 - workspace-settings-capability +- Moved: worktree/auto-suspend settings reads off direct `settingsStore` import -> `@posthog/platform/workspace-settings` (`IWorkspaceSettings` / `WORKSPACE_SETTINGS_SERVICE`). +- Registered: `ElectronWorkspaceSettings` adapter bound to `WORKSPACE_SETTINGS_SERVICE` in `apps/code/src/main/di/container.ts`. +- Data: source of truth stays the apps/code electron-store `settingsStore`; the adapter wraps it; legacy worktree-dir default migration stays in the adapter (apps/code). +- Cleaned: `FoldersService` injects the port instead of importing `settingsStore` free functions (first consumer). +- Bridge: `settingsStore` free functions remain for the other consumers (archive, suspension, workspace, focus shim, shell, os router, worktree-helpers) until their slices migrate to the port. +- Validation: platform + apps/code (node+web) typecheck 0 errors; folders service.test.ts 23/23. + +## 2026-05-29 - shared domain primitives + +- Moved: `apps/code/src/shared/utils/{urls,backoff,repo}.ts` -> `packages/shared/src/*` +- Registered: `getCloudUrlFromRegion`, `getBackoffDelay`/`sleepWithBackoff`/`BackoffOptions`, `normalizeRepoKey` on the `@posthog/shared` barrel +- Data: pure host-agnostic primitives; `@posthog/shared` is now the single source +- Bridge: `apps/code/src/shared/utils/{urls,backoff,repo}.ts` re-export `@posthog/shared` until importers move +- Validation: @posthog/shared + @posthog/code typecheck both 0 errors + +## 2026-05-29 — repository DI identifiers (persistence-layer cont.) + +- Added: package-owned repository identifiers in `packages/workspace-server/src/db/identifiers.ts` (REPOSITORY/WORKSPACE/WORKTREE/ARCHIVE/SUSPENSION/AUTH_SESSION/AUTH_PREFERENCE/DEFAULT_ADDITIONAL_DIRECTORY) + `db/repositories.module.ts` binding each class. +- Changed: `apps/code/src/main/di/container.ts` loads `repositoriesModule`; `MAIN_TOKENS.*Repository` are now `.toService()` bridges over the package symbols (was `.to(Class)`). +- Why: the repo classes had moved to the package but their DI identifiers were still apps/code-local, so no package service could inject a repository. This unblocks folders/archive/suspension/workspace. +- Validation: full `pnpm typecheck` 19/19 green at the time of this change. + +## 2026-05-29 — folders (FoldersService -> workspace-server) + +- Moved: `apps/code/src/main/services/folders/{service,service.test,schemas}.ts` -> `packages/workspace-server/src/services/folders/{folders,folders.test,schemas}.ts` + new `folders.module.ts`, `identifiers.ts`, `ports.ts`. +- Registered: `foldersModule` (binds FOLDERS_SERVICE); hosted in apps/code's container (shares the single SQLite connection — not ws-server tRPC). +- Data: source of truth is the SQLite repositories (injected via package identifiers); worktree base path via `WORKSPACE_SETTINGS_SERVICE.getWorktreeLocation()` (reused the platform capability, no duplicate port). `normalizeRepoKey` inlined. +- Cleaned: router/skills repointed to package imports; `apps/code/.../folders/schemas.ts` reduced to a type-only re-export for renderer type consumers (no ws-server runtime pulled into the renderer bundle). +- Bridge: `MAIN_TOKENS.FoldersService -> FOLDERS_SERVICE`; `FOLDERS_LOGGER` bound to `logger.scope("folders-service")`. Retire MAIN_TOKENS.FoldersService once consumers inject FOLDERS_SERVICE. +- Validation: ws-server typecheck clean; `folders.test.ts` 23/23 in the new home; apps/code typecheck has zero folders-related errors (remaining apps/code/core red is exogenous: concurrent handoff/agent-types + context-menu migrations). App smoke pending (tree can't fully build while those are red). + +## 2026-05-29 - misc-host-capabilities (platform alias retirements) +- Cleaned: retired 4 `MAIN_TOKENS.*` platform-alias bridges (FileIcon, AppMeta, BundledResources, ImageProcessor); 5 consumers (external-apps, agent, updates, posthog-plugin, os.ts) now inject the package-owned `@posthog/platform` symbols directly. +- Registered: removed the `.toService` aliases from `di/container.ts` and the token defs from `di/tokens.ts`. +- Bridge: `UrlLauncher`/`StoragePaths`/`MainWindow` aliases remain until their consumers migrate; os.ts still a service-less router pending carve. +- Validation: apps/code node typecheck clean in scope; behavior-preserving. + +## 2026-05-29 - context-menu + +- Moved: `apps/code/src/main/services/context-menu/{service,schemas,types}.ts` -> `packages/core/src/context-menu/{context-menu,schemas,types}.ts` +- Registered: `contextMenuCoreModule` (binds `CONTEXT_MENU_CONTROLLER`); new core port `CONTEXT_MENU_EXTERNAL_APPS_PORT` +- Foundation: bootstrapped core DI — added @posthog/platform + inversify + reflect-metadata to packages/core; added decorator tsconfig flags; updated core charter/description to match REFACTOR.md (host-agnostic business layer with Inversify DI over platform interfaces) +- Data: source of truth is menu content decided by the core ContextMenuService consuming platform CONTEXT_MENU_SERVICE/DIALOG_SERVICE interfaces; ElectronContextMenu adapter only renders the native menu +- Cleaned: retired MAIN_TOKENS.ContextMenu platform alias + Platform.ContextMenu token (core service injects CONTEXT_MENU_SERVICE directly); inverted external-apps coupling behind a core port +- Bridge: `CONTEXT_MENU_EXTERNAL_APPS_PORT` toService(`MAIN_TOKENS.ExternalAppsService`) until external-apps migrates to a package service +- Validation: core typecheck; `pnpm typecheck` 19/19; `pnpm --filter code test` 120/1450; `pnpm dev:code` clean boot + +## 2026-05-29 — archive (ArchiveService -> workspace-server) + +- Moved: `apps/code/src/main/services/archive/{service,service.integration.test,schemas}.ts` -> `packages/workspace-server/src/services/archive/{archive,archive.integration.test,schemas}.ts` + `archive.module.ts`, `identifiers.ts`, `ports.ts`. +- Registered: `archiveModule` (binds ARCHIVE_SERVICE); hosted in apps/code container (single SQLite conn, not ws-server tRPC). +- Ports: ARCHIVE_SESSION_CANCELLER (AgentService.cancelSessionsByTaskId) + ARCHIVE_FILE_WATCHER (FileWatcherBridge.stopWatching), bound via container.toDynamicValue lazy ctx.get; ARCHIVE_LOGGER -> logger.scope("archive"); worktree location via WORKSPACE_SETTINGS_SERVICE; repos via package identifiers; PROCESS_TRACKING_SERVICE. +- Data: archivedTaskSchema moved into the package; `apps/code/src/shared/types/archive.ts` -> type-only re-export (renderer type consumers unchanged, no ws-server runtime in renderer bundle). +- Bridge: `MAIN_TOKENS.ArchiveService -> ARCHIVE_SERVICE`. Retire once consumers inject ARCHIVE_SERVICE. +- Validation: ws-server typecheck clean; archive.integration.test.ts 23/23 (real git); apps/code zero archive errors (remaining red is exogenous analytics migration). App smoke pending. + +## 2026-05-29 - misc-host-capabilities (os.ts service carve) +- Moved: 401-line service-less `trpc/routers/os.ts` business logic -> NEW `apps/code/src/main/services/os/service.ts` (`OsService`) + `os/schemas.ts`. +- Registered: `MAIN_TOKENS.OsService` bound to `OsService` in `di/container.ts`; `osRouter` now one-line forwards. +- Data: OsService constructor-injects DIALOG/URL_LAUNCHER/APP_META/IMAGE_PROCESSOR/WORKSPACE_SETTINGS platform capabilities; owns fs/clipboard/image host ops. Stays in apps/code main (wires Electron platform adapters). +- Cleaned: removed service-less router, inline router business logic, and business-logic container.get from the router; getWorktreeLocation now reads WORKSPACE_SETTINGS_SERVICE. +- Validation: apps/code node+web typecheck 0 errors; behavior-preserving. + +## 2026-05-29 — suspension (SuspensionService -> workspace-server) + +- Moved: `apps/code/src/main/services/suspension/{service,service.test,schemas}.ts` -> `packages/workspace-server/src/services/suspension/{suspension,suspension.test,schemas}.ts` + `suspension.module.ts`, `identifiers.ts`, `ports.ts`. +- Registered: `suspensionModule` (binds SUSPENSION_SERVICE); hosted in apps/code container (single SQLite conn). Ports SUSPENSION_SESSION_CANCELLER + SUSPENSION_FILE_WATCHER via toDynamicValue; SUSPENSION_LOGGER -> logger.scope("suspension"); all auto-suspend/worktree settings via WORKSPACE_SETTINGS_SERVICE; repos via package identifiers; PROCESS_TRACKING_SERVICE. Local TypedEventEmitter (no external event consumers). +- Data: suspendedTaskSchema/suspensionReasonSchema/suspensionSettingsSchema moved to the package; `apps/code/src/shared/types/suspension.ts` -> type-only re-export. +- Carve-out: sleep service (OS power) intentionally not bundled — separate concern, follow-up. +- Bridge: `MAIN_TOKENS.SuspensionService -> SUSPENSION_SERVICE`; type-imports repointed in index.ts/app-lifecycle/workspace/router. +- Validation: ws-server typecheck clean; suspension.test.ts 11/11; apps/code zero suspension errors (remaining red exogenous: @utils/path,@utils/time renderer-utils migration). App smoke pending. + +## 2026-05-29 - misc-host-capabilities (MainWindow alias retirement; slice complete) +- Cleaned: retired the MainWindow MAIN_TOKENS alias; 10 consumers inject MAIN_WINDOW_SERVICE directly. With this, all 7 in-scope platform aliases (FileIcon/AppMeta/BundledResources/ImageProcessor/StoragePaths/UrlLauncher/MainWindow) are retired and os.ts is carved into OsService. +- Bridge: AppLifecycle/Updater/Notifier MAIN_TOKENS aliases remain (owned by app-lifecycle/updater/notifications slices). +- Validation: apps/code node+web typecheck 0 errors; behavior-preserving. + +## 2026-05-29 — usage-schema relocation (unblocks usage-monitor) + +- Moved: usageBucketSchema/usageOutput + UsageBucket/UsageOutput types from `apps/code/src/main/services/llm-gateway/schemas.ts` -> `packages/core/src/usage/schemas.ts`. +- llm-gateway/schemas.ts now value+type re-exports from `@posthog/core/usage/schemas` — llm-gateway router, usage-monitor, and the 4 renderer billing consumers are unchanged. +- Why: usage-monitor is core orchestration and core may not import apps/code; this gives the shared usage domain type a package home core can consume. (If llm-gateway later moves to ws-server, the schema can move to @posthog/shared.) +- Validation: @posthog/core typecheck clean; apps/code zero usage/llm-gateway/billing errors. + +## 2026-05-29 - platform-alias bridge fully retired +- Cleaned: removed the last 3 MAIN_TOKENS platform aliases (AppLifecycle/Updater/Notifier) and the PORT NOTE bridge block. The entire MAIN_TOKENS.* -> @posthog/platform alias bridge is gone; all consumers inject package-owned platform identifiers directly. +- Validation: apps/code node+web typecheck 0 errors. + +## 2026-05-29 - linear-integration (flow -> core) +- Moved: `LinearIntegrationService` + integration flow schemas `apps/code/.../linear-integration` -> `packages/core/src/integrations/{linear.ts,schemas.ts}`. +- Registered: container binds `MAIN_TOKENS.LinearIntegrationService` to the core class; router forwards. +- Bridge: `apps/code/.../integration-flow-schemas.ts` re-exports the core schemas (github/slack consume via it until they migrate). +- Validation: core integrations + apps/code node+web typecheck 0 errors. + +## 2026-05-29 - typed-event-emitter (foundation) + +- Moved: 3 duplicate node:events-based TypedEventEmitter copies (apps/code main util + ws-server connectivity/focus) -> ONE browser-safe impl in `packages/shared/src/typed-event-emitter.ts` +- Registered: exported `TypedEventEmitter` from the @posthog/shared barrel; added @posthog/shared dep to @posthog/workspace-server +- Data: source of truth is the single shared emitter; per-service typed event maps are projections over it +- Cleaned: removed node:events coupling from the subscription backbone so packages/core (and future web/mobile hosts) can consume it; full EventEmitter API + buffered toIterable(event,{signal}) +- Bridge: `apps/code/src/main/utils/typed-event-emitter.ts` re-exports from @posthog/shared so the 24 main services + ~20 tRPC subscription routers stay unchanged — retire by repointing them to @posthog/shared +- Validation: shared unit test 13/13; pnpm typecheck 19/19; apps/code tests 1395; pnpm dev:code full boot with live subscription layer, zero emitter errors + +## 2026-05-29 - DEEP_LINK platform port +- Added: `@posthog/platform/deep-link` (`IDeepLinkRegistry` / `DEEP_LINK_SERVICE` / `DeepLinkHandler`). `DeepLinkService` implements it; 7 feature consumers inject the port instead of the concrete service. +- Data: deep-link handler registry is now a host-neutral port; apps/code provides the impl; host-boot protocol registration + URL dispatch stay on the concrete service. +- Validation: apps/code node+web typecheck 0 errors. + +## 2026-05-29 — usage-monitor (UsageMonitorService -> core) + +- Moved: `apps/code/src/main/services/usage-monitor/{service,service.test,schemas}.ts` -> `packages/core/src/usage/{usage-monitor,usage-monitor.test,monitor-schemas}.ts` + schemas.ts (usage types), ports.ts, identifiers.ts, usage-monitor.module.ts. +- Registered: `usageMonitorModule` (binds USAGE_MONITOR_SERVICE); hosted in apps/code container. Ports: USAGE_GATEWAY (LlmGatewayService.fetchUsage), USAGE_ACTIVITY_MONITOR (AgentService LlmActivity + hasActiveSessions) via toDynamicValue; USAGE_THRESHOLD_STORE + USAGE_LOGGER via toConstantValue. Local TypedEventEmitter (router subscriptions over toIterable). +- Data: usage schema (usageBucketSchema/usageOutput) lives in @posthog/core/usage/schemas; llm-gateway/schemas.ts re-exports. usage-monitor/store.ts (electron-store) retained in apps/code, wrapped by the THRESHOLD_STORE adapter. +- Bridge: `MAIN_TOKENS.UsageMonitorService -> USAGE_MONITOR_SERVICE`; router repointed to core. +- Validation: full `pnpm typecheck` 19/19 green; usage-monitor.test 12/12 in core. + +## 2026-05-30 - github + slack integration services -> core +- Moved: `GitHubIntegrationService` + `SlackIntegrationService` -> `packages/core/src/integrations/{github.ts,slack.ts}` (+ `identifiers.ts` with `IntegrationLogger` and per-provider logger tokens). +- Registered: container binds `MAIN_TOKENS.{GitHub,Slack}IntegrationService` to the core classes and the `*_INTEGRATION_LOGGER` tokens to `logger.scope(...)`; routers/index repoint to core. +- Data: services inject DEEP_LINK/URL_LAUNCHER/MAIN_WINDOW platform ports + an injected logger; flow schemas + region utils + TypedEventEmitter from core/shared. All 3 integration services (linear/github/slack) now in `packages/core`. +- Bridge: apps/code `integration-flow-schemas.ts` still re-exports core schemas; shared `features/integrations` UI not yet moved to packages/ui. +- Validation: apps/code node+web typecheck 0 errors. + +## 2026-05-29 - updater (core orchestration) + +- Moved: apps/code/src/main/services/updates/{service,schemas,test}.ts -> packages/core/src/updates/{updates,schemas,updates.test}.ts +- Registered: updatesCoreModule (UPDATES_SERVICE); new UPDATE_LIFECYCLE_PORT + UPDATES_LOGGER +- Data: source of truth is the UpdatesService state machine (idle/checking/downloading/ready/installing/error) over platform UPDATER/APP_LIFECYCLE/APP_META/MAIN_WINDOW interfaces; updateStore is a subscription projection +- Cleaned: extends @posthog/shared TypedEventEmitter (no node:events); inverted the update-quit handoff behind UPDATE_LIFECYCLE_PORT; logger via injected SagaLogger; isDevBuild->appMeta.isProduction; added vitest to packages/core +- Bridge: MAIN_TOKENS.UpdatesService toService(UPDATES_SERVICE) + UPDATE_LIFECYCLE_PORT toService(MAIN_TOKENS.AppLifecycleService) until menu/index/router migrate +- Validation: core tests 66; pnpm typecheck 19/19; apps/code tests 1329; dev:code boot clean + +## 2026-05-29 - auth-core (AuthService -> packages/core) + +- Moved: `apps/code/src/main/services/auth/service.ts` (AuthService) -> `packages/core/src/auth/auth.ts` +- Registered: AUTH_PREFERENCE/SESSION/OAUTH_FLOW/CONNECTIVITY/TOKEN_CIPHER ports (packages/core/src/auth/ports.ts); auth.module.ts; WORKBENCH_LOGGER bound in main +- Data: AuthService owns session/refresh truth; ws-server drizzle rows mapped to core domain records (AuthSessionRecord/AuthPreferenceRecord) in desktop adapters +- Cleaned: removed the forbidden ws-server/electron coupling from the auth business logic; OAuth host flow behind OAUTH_FLOW_PORT (OAuthService stays the Electron adapter) +- Bridge: `apps/code/src/main/services/auth/service.ts` re-exports `@posthog/core/auth/auth` until consumers import it directly +- Validation: full typecheck 19/19; apps/code 1292 tests; core auth 18 tests + +## 2026-05-29 — enrichment (EnrichmentService -> core) + +- Moved: `apps/code/src/main/services/enrichment/{service,detectPosthogInstallState.test,findStaleFlagSuggestions.test}.ts` -> `packages/core/src/enrichment/{enrichment,detectPosthogInstallState.test,findStaleFlagSuggestions.test}.ts` + ports.ts, identifiers.ts, enrichment.module.ts. +- Registered: `enrichmentModule` (binds ENRICHMENT_SERVICE); hosted in apps/code container. Ports: ENRICHMENT_AUTH (AuthService), ENRICHMENT_FILE_READER (node fs + @posthog/git listFilesContainingText), ENRICHMENT_LOGGER. core consumes @posthog/enricher directly (added to core deps; @posthog/git devDep for tests). +- Cleaned: core stays fs/git-free behind the file-reader port; auth behind a minimal port shape. +- Bridge: `MAIN_TOKENS.EnrichmentService -> ENRICHMENT_SERVICE`; router repointed to @posthog/core/enrichment. +- Validation: core typecheck clean; 19/19 enrichment tests in core (real git + tree-sitter + fetch mocks); apps/code zero enrichment errors. + +## 2026-05-30 - task/inbox/new-task link services -> core +- Moved: `TaskLinkService`/`InboxLinkService`/`NewTaskLinkService` -> `packages/core/src/links/*` (+ `identifiers.ts` LinkLogger + per-service logger tokens). Tests moved too (39 pass). +- Registered: container binds `MAIN_TOKENS.{Task,Inbox,NewTask}LinkService` to the core classes + the logger tokens to `logger.scope(...)`; index/deep-link-router/notification repoint to core. +- Data: services inject DEEP_LINK + MAIN_WINDOW platform ports + injected logger; TypedEventEmitter + deep-link utils from shared. No AuthService coupling. +- Validation: core links 39 tests; apps/code node+web 0 errors. + +## 2026-05-29 — mcp-apps (McpAppsService -> core) + +- Moved: `apps/code/src/main/services/mcp-apps/service.ts` -> `packages/core/src/mcp-apps/mcp-apps.ts`; `apps/code/src/shared/types/mcp-apps.ts` -> `packages/core/src/mcp-apps/schemas.ts` (+ identifiers.ts, ports.ts, mcp-apps.module.ts). +- Registered: `mcpAppsModule` (binds MCP_APPS_SERVICE); hosted in apps/code container. Injects URL_LAUNCHER_SERVICE + MCP_APPS_LOGGER; local TypedEventEmitter. Added @modelcontextprotocol/sdk + ext-apps to core deps. +- Cleaned: apps/code @shared/types/mcp-apps -> `export *` re-export from core (renderer + router unchanged); menu.ts + agent type-imports repointed. +- Bridge: `MAIN_TOKENS.McpAppsService -> MCP_APPS_SERVICE`. +- Validation: core typecheck clean; apps/code zero mcp errors (remaining red exogenous: posthog-plugin migration). App smoke pending. + +## 2026-05-29 - posthog-plugin (workspace-server capability) + +- Moved: apps/code/src/main/services/posthog-plugin/* + utils/extract-zip.ts -> packages/workspace-server/src/services/posthog-plugin/* +- Registered: posthogPluginModule (POSTHOG_PLUGIN_SERVICE); POSTHOG_PLUGIN_LOGGER; added fflate dep +- Data: source of truth is the runtime plugin/skills dirs under appDataPath; PosthogPluginService orchestrates download+overlay+codex-sync via UpdateSkillsSaga +- Cleaned: extends @posthog/shared TypedEventEmitter; captureException via platform ANALYTICS_SERVICE; isDevBuild->appMeta.isProduction; logger via injected SagaLogger +- Bridge: MAIN_TOKENS.PosthogPluginService toService(POSTHOG_PLUGIN_SERVICE) until index/skills/agent inject directly +- Validation: ws-server typecheck + 27 tests; apps/code+core typecheck 0; dev:code boot 'Saga completed successfully' + +## 2026-05-29 — external-apps (ExternalAppsService -> workspace-server) + +- Moved: `apps/code/src/main/services/external-apps/{service,schemas,types}.ts` -> `packages/workspace-server/src/services/external-apps/{external-apps,schemas,types}.ts` + identifiers.ts, ports.ts, external-apps.module.ts. +- Registered: `externalAppsModule` (binds EXTERNAL_APPS_SERVICE); hosted in apps/code container. Injects CLIPBOARD_SERVICE + FILE_ICON_SERVICE + EXTERNAL_APPS_STORE port (electron-store bound in apps/code). Dropped getPrefsStore() (unused) + STORAGE_PATHS (only fed the store). DetectedApplication/ExternalAppType from ./schemas (no @shared barrel dep). +- Bridge: `MAIN_TOKENS.ExternalAppsService -> EXTERNAL_APPS_SERVICE` (CONTEXT_MENU_EXTERNAL_APPS_PORT resolves through it); router + index.ts repointed. +- Validation: full `pnpm typecheck` 19/19 green. + +## 2026-05-29 — llm-gateway (LlmGatewayService -> core) + +- Moved: `apps/code/src/main/services/llm-gateway/{service,schemas}.ts` -> `packages/core/src/llm-gateway/{llm-gateway,schemas}.ts` + ports.ts, identifiers.ts, llm-gateway.module.ts. +- Registered: `llmGatewayModule`; hosted in apps/code container. Ports keep core @posthog/agent-free: LLM_GATEWAY_AUTH (AuthService getValidAccessToken+authenticatedFetch), LLM_GATEWAY_ENDPOINTS (apps/code supplies @posthog/agent URL helpers + DEFAULT_GATEWAY_MODEL), LLM_GATEWAY_LOGGER. +- Cleaned: apps/code llm-gateway/schemas.ts -> `export *` re-export from core (renderer billing type consumers unchanged); git/service + router repointed. +- Bridge: `MAIN_TOKENS.LlmGatewayService -> LLM_GATEWAY_SERVICE`. +- Validation: core typecheck clean; apps/code zero llm-gateway errors (remaining red exogenous: GitFileStatus shared migration). + +## 2026-05-29 — auth-callback-server (dev OAuth HTTP server -> workspace-server) + +- Moved: the dev HTTP callback server from `apps/code/src/main/services/oauth/service.ts` -> `packages/workspace-server/src/services/oauth-callback/oauth-callback.ts` (OAuthCallbackServer.waitForCode owns http.Server/listen/connections/timeout/HTML; cancel via AbortSignal). +- Registered: `oauthCallbackModule` (binds OAUTH_CALLBACK_SERVER); loaded in apps/code container. +- Refactored: OAuthService (stays in apps/code) injects OAUTH_CALLBACK_SERVER; waitForHttpCallback delegates; pendingFlow uses an AbortController; getCallbackHtml/cleanupHttpServer/node:http removed. Deep-link prod path + PKCE + token exchange unchanged. +- Validation: full `pnpm typecheck` 19/19 green. + +## 2026-05-29 — mcp-callback (dev MCP-OAuth HTTP server -> workspace-server) + +- Moved: dev HTTP callback server from `apps/code/src/main/services/mcp-callback/service.ts` -> `packages/workspace-server/src/services/mcp-callback/mcp-callback-server.ts` (McpCallbackServer.waitForCallback -> URLSearchParams; owns http.Server/timeout/connections/HTML; cancel via AbortSignal; `successWhen` predicate picks success/error HTML). +- Registered: `mcpCallbackModule` (MCP_CALLBACK_SERVER); loaded in apps/code container. +- Refactored: McpCallbackService (apps/code) injects MCP_CALLBACK_SERVER, delegates; pendingCallback uses AbortController; getCallbackHtml/cleanupHttpServer/node:http removed. Deep-link prod path + events unchanged. +- Validation: full `pnpm typecheck` 19/19 green. Same pattern as auth-callback-server. + +## 2026-05-29 — os (OsService -> workspace-server) + +- Moved: `apps/code/src/main/services/os/{service,schemas}.ts` -> `packages/workspace-server/src/services/os/{os,schemas}.ts` + identifiers, os.module.ts. +- Registered: `osModule` (OS_SERVICE); hosted in apps/code container. Injects only platform services (DIALOG/URL_LAUNCHER/APP_META/IMAGE_PROCESSOR/WORKSPACE_SETTINGS) + node fs/os/path + @posthog/shared image utils. +- Bridge: `MAIN_TOKENS.OsService -> OS_SERVICE`; os router repointed (service + schemas). +- Validation: full `pnpm typecheck` 19/19 green. + +## 2026-05-29 — cloud-task (CloudTaskService -> core) + +- Moved: `apps/code/src/main/services/cloud-task/*` -> `packages/core/src/cloud-task/{cloud-task,schemas,cloud-task-types,sse-parser}.ts` + ports/identifiers/module + tests. +- Registered: `cloudTaskModule`; hosted in apps/code container. CLOUD_TASK_AUTH port (AuthService.authenticatedFetch) + CLOUD_TASK_LOGGER. @posthog/shared TypedEventEmitter + StoredLogEntry/TaskRunStatus. SseEventParser logger decoupled (onWarn callback). +- Data: CloudTask* update types kept as a core copy (cloud-task-types.ts) pending the concurrent shared-domain-types relocation landing in the @posthog/shared index barrel. +- Bridge: `MAIN_TOKENS.CloudTaskService -> CLOUD_TASK_SERVICE`; router + handoff repointed. +- Validation: full `pnpm typecheck` 19/19 green; cloud-task.test 22/22 + sse-parser 3/3 in core. + +## 2026-05-29 — shell (ShellService -> workspace-server) + +- Moved: `apps/code/src/main/services/shell/{service,schemas}.ts` -> `packages/workspace-server/src/services/shell/{shell,schemas}.ts` + identifiers/ports/module. pty = ws-server host concern. +- Registered: `shellModule` (SHELL_SERVICE); hosted in apps/code container. Injects PROCESS_TRACKING + repos + WORKSPACE_SETTINGS (inlined deriveWorktreePath) + SHELL_LOGGER. @posthog/shared TypedEventEmitter + ws-server buildWorkspaceEnv. Added node-pty to ws-server deps. +- Bridge: `MAIN_TOKENS.ShellService -> SHELL_SERVICE`; shell + agent routers repointed. +- Validation: ws-server + core + apps/code typecheck clean (ui red is exogenous). + +## 2026-05-29 — ui-service (UIService -> core) + +- Moved: `apps/code/src/main/services/ui/{service,schemas}.ts` -> `packages/core/src/ui/{ui,schemas}.ts` + identifiers/ports/module. UI command event relay (menu->renderer) over @posthog/shared TypedEventEmitter; UI_AUTH port (test-only token invalidation). +- Bridge: `MAIN_TOKENS.UIService -> UI_SERVICE`; menu.ts + ui router repointed. +- Validation: full `pnpm typecheck` 19/19 green. + +## 2026-05-29 — oauth (OAuthService -> core) + +- Moved: `apps/code/src/main/services/oauth/{service,schemas}.ts` -> `packages/core/src/oauth/{oauth,schemas}.ts` + identifiers/ports/module. PKCE flow orchestration. +- Registered: `oauthModule`; hosted in apps/code container. Platform deps (DEEP_LINK/URL_LAUNCHER/MAIN_WINDOW) + OAUTH_CALLBACK port (-> ws-server OAuthCallbackServer) + OAUTH_ENV {isDev} + OAUTH_LOGGER. oauth constants/backoff/urls from @posthog/shared. +- Bridge: `MAIN_TOKENS.OAuthService -> OAUTH_SERVICE`; router/index/port-adapters repointed. +- Validation: full `pnpm typecheck` 19/19 green. + +## 2026-05-29 — bridge retirements (6 temporary MAIN_TOKENS bridges removed) + +- Retired MAIN_TOKENS.{OsService, FoldersService, ArchiveService, UsageMonitorService, EnrichmentService, UIService} — consumers (routers + menu.ts) now inject the package identifiers (OS_SERVICE, FOLDERS_SERVICE, ARCHIVE_SERVICE, USAGE_MONITOR_SERVICE, ENRICHMENT_SERVICE, UI_SERVICE) directly; the `.toService` bridges + MAIN_TOKENS tokens deleted. The documented final migration step for these ported services. +- Remaining MAIN_TOKENS service bridges (LlmGateway, CloudTask, Suspension, McpApps) stay until their cross-service injectors in the agent/workspace/handoff tangle migrate. +- Validation: full `pnpm typecheck` 19/19 green. + +## 2026-05-29 — bridge retirements (3 more: Shell, AuthProxy, McpProxy) + +- Retired MAIN_TOKENS.{ShellService, AuthProxyService, McpProxyService}. Consumers were routers/adapters, NOT the tangle classes: shell + agent routers (container.get) -> SHELL_SERVICE; agent/auth-adapter (@inject) -> AUTH_PROXY_SERVICE + MCP_PROXY_SERVICE. `.toService` bridges + MAIN_TOKENS tokens deleted; *_AUTH/*_LOGGER port bindings + ws-server modules kept. +- 9 bridges retired total this session. Validation: apps/code typecheck clean. + +## 2026-05-29 — DECISION: do not import @posthog/agent into core + +- handoff/AgentService are blocked on @posthog/agent coupling (runtime resumeFromLog + agent type signatures). DECISION: do NOT make @posthog/agent a core dependency (would break core's host-agnostic web/mobile purpose; the SDK is Node/process-coupled), and do NOT touch the @posthog/agent package now. +- Consequence: handoff + AgentService stay in apps/code (desktop host services, not core slices) until a later agent-package split extracts pure types/utils to @posthog/shared and injects the runtime via ports. + +## 2026-05-30 - terminal feature -> packages/ui (complete) +- Moved: `apps/code/src/renderer/features/terminal/*` (TerminalManager 514LOC, terminalStore, resolveTerminalFontFamily, Terminal/ShellTerminal/ActionTerminal components) -> `packages/ui/features/terminal/`. +- Registered: `ShellClient` port (`packages/ui/features/terminal/shellClient.ts`, incl. onData/onExit subscription methods) + apps/code `shellClientAdapter` wrapping trpcClient.shell.* + os.openExternal, registered at boot in main.tsx. +- Cleaned: components now subscribe via the imperative port in useEffect (no trpcReact); service/store use getShellClient(); logger/platform via @posthog/ui ports; xterm added to ui deps. +- Bridge: none — fully ported. Shell output subscriptions flow through the ShellClient port. +- Validation: apps web 0, node 0; ui terminal test 7/7; full ui sweep 157. + +## 2026-05-30 - sessions store/hook/util layer -> packages/ui +- Moved: @utils/{session,promptContent}, features/sessions/{hooks/useSession,stores/sessionStore} -> packages/ui/features/sessions/* (path/session-events types via @posthog/shared; PermissionRequest/UserMessageAttachment via ui session types; ACP via ui dep). +- Cleaned: removed apps/code @utils/session + @utils/promptContent + the sessions hooks/stores dirs. sessionStore was unblocked by relocating its util chain bottom-up. +- Bridge: sessions COMPONENTS (SessionView etc.) remain in apps/code (trpcReact); convert via the imperative-port + useEffect pattern next. +- Validation: apps web 0, node 0; ui 186 tests. + +## 2026-06-01 — git-mutate (pure git-CLI mutations → workspace-server) +- Moved: branch create/checkout, stage/unstage, discard, sync-status (+fetch throttle = source smoothing), push/pull/publish/sync + a mutate-variant getStateSnapshot from `apps/code/src/main/services/git/service.ts` into `packages/workspace-server/src/services/git/service.ts`. Added the matching zod schemas to the package `schemas.ts`. +- Registered: 11 one-line `git.*` procedures in `packages/workspace-server/src/trpc.ts`. Main `git` router procedures now FORWARD to ws-server via `WorkspaceClient` (extends the git-read PORT NOTE). Main `GitService` keeps the methods for in-process callers (WorkspaceService/HandoffService/createPr). +- Data: source of truth for these ops is `@posthog/git` (sagas/queries) running in the ws-server child; GitStateSnapshot is a derived aggregate (changedFiles+diffStats+syncStatus+latestCommit). PR status excluded from the mutate snapshot (never requested by this group). +- Deferred: `commit` (needs AgentService session-env — main process), `cloneRepository`+`onCloneProgress` (progress streaming). All gh/PR ops → git-pr. +- Bridge retirement: delete the main forwarding when renderer git-interaction consumes `workspaceClient.git.*` directly (ui-git-interaction slice). +- Validation: ws-server typecheck clean; apps/code git router/service 0 errors (remaining apps/code red exogenous); ws-server tests 243/248 (5 = known better-sqlite3 Electron-ABI DB test). App smoke pending. + +## 2026-06-01 — workspace (WorkspaceService -> workspace-server) + +- Moved: `apps/code/src/main/services/workspace/service.ts` -> `packages/workspace-server/src/services/workspace/workspace.ts`; `schemas.ts` -> same package dir. `apps/code/.../workspace/schemas.ts` is now a re-export shim (14 renderer `import type` consumers + workspace router). Deleted dead duplicate `workspaceEnv.ts` (canonical: `packages/workspace-server/src/workspace-env.ts`). +- Registered: `workspaceModule` (binds `WORKSPACE_SERVICE`); ports.ts + identifiers.ts. Full constructor injection. +- Data: source of truth is the WORKSPACE/WORKTREE/REPOSITORY repos (ws-server); derived projections are Workspace/WorkspaceInfo/WorktreeInfo computed per call (git branch via repo-fs-query), activeRepoStore (UI), workspace UI. +- Cleaned: removed the last `MAIN_TOKENS` property-injection in WorkspaceService. Cross-layer deps now narrow ports: `WORKSPACE_AGENT` (cancelSessionsByTaskId + onAgentFileActivity), `WORKSPACE_FILE_WATCHER` (stopWatching + onGitStateChanged), `WORKSPACE_FOCUS` (onBranchRenamed), `WORKSPACE_PROVISIONING` (emitOutput), `WORKSPACE_LOGGER`; settings via WORKSPACE_SETTINGS_SERVICE, analytics via ANALYTICS_SERVICE. ws-server never imports core (provisioning is a port) or apps/code. +- Bridge: `MAIN_TOKENS.WorkspaceService -> WORKSPACE_SERVICE` (toService) for the workspace router + GitService + index.ts initBranchWatcher. Retire once those inject WORKSPACE_SERVICE. schemas shim retires when renderer workspace types move to @posthog/shared / workspace-client. +- Validation: ws-server typecheck clean; `biome lint packages/workspace-server/src/services/workspace` 0 noRestrictedImports; new `workspace.test.ts` 7/7. apps/code typecheck has 0 workspace-attributable errors. + +## 2026-06-01 - agent (AgentService -> workspace-server) +- Moved: `apps/code/src/main/services/agent/{service.ts,auth-adapter.ts,discover-plugins.ts,schemas.ts}` -> `packages/workspace-server/src/services/agent/{agent.ts,auth-adapter.ts,discover-plugins.ts,schemas.ts}` +- Registered: `agentModule` (binds `AGENT_SERVICE`, `AGENT_AUTH_ADAPTER`); 5 inversion ports (`AGENT_SLEEP_COORDINATOR`, `AGENT_MCP_APPS`, `AGENT_REPO_FILES`, `AGENT_AUTH`, `AGENT_LOGGER`) bound in apps/code container +- Data: source of truth is `packages/agent` framework; ws-server `AgentService` owns session lifecycle; projection = session messages in sessions UI +- Cleaned: agent SDK host integration now lives in a package, not apps/code; core/host deps inverted into narrow ports (no more direct McpApps/Sleep/Auth/Fs coupling in the moved service); ws-server moved to zod v4 +- Bridge: `MAIN_TOKENS.AgentService` + `MAIN_TOKENS.AgentAuthAdapter` (`toService` aliases) remain until handoff/git/router/usage-monitor inject `AGENT_SERVICE` directly +- Validation: `@posthog/workspace-server typecheck` 0; agent unit tests 44/44; `biome lint` agent dir 0 noRestrictedImports. Live-app smoke deferred (concurrent MAIN_TOKENS slice breaks apps/code build) + +## 2026-06-01 — git-pr (pure gh-CLI PR/GitHub ops → workspace-server) +- Moved 18 pure gh-CLI methods (gh status/auth, PR status/url/open/details, PR+branch file diffs + toUnifiedDiffPatch, review comments + resolve/reply/update, PR template, commit conventions, GitHub ref search/issue/PR) from `apps/code/src/main/services/git/service.ts` into `packages/workspace-server/src/services/git/service.ts`, with matching zod schemas. 18 one-line `git.*` procedures in ws `trpc.ts`; main `git` router procedures forward via `WorkspaceClient` (extends the git-read/git-mutate PORT NOTE). Main GitService keeps the methods for in-process callers (createPr). +- Data: source of truth is the `gh` CLI / `@posthog/git` running in the ws-server child; no new persisted state. Dropped the module logger from moved error paths (degrade to null/[] as before). +- Deferred (coupled to main-process services, cannot run in the ws-server child): getTaskPrStatus (WorkspaceService), createPr/createPrViaGh (AgentService session-env + WorkspaceService linkBranch + commit), generateCommitMessage/generatePrTitleAndBody (LlmGateway.prompt) — need GIT_WORKSPACE_PORT/GIT_AGENT_ENV_PORT/GIT_LLM_PORT (git-pr-coupled follow-up). +- Bridge retirement: delete the main forwarding when renderer git-interaction consumes `workspaceClient.git.*` directly (ui-git-interaction slice). +- Validation: ws-server typecheck GREEN; apps/code git router/service 0 errors; biome clean; ws-server tests 294/299 (5 = known better-sqlite3 Electron-ABI DB test). App smoke pending. + +## 2026-06-01 — ui-settings (store dead-duplicate sweep) +- Removed: apps/code dead settings-store duplicates after the canonical port to packages/ui/src/features/settings — `features/settings/stores/{settingsStore,settingsDialogStore}.{ts,test.ts}` and `renderer/stores/settingsStore.{ts,test.ts}` (the old trpc-based sendMessagesWith store, superseded by the merged packages/ui settingsStore). +- Repointed: `features/auth/stores/authStore.ts` -> `@posthog/ui/features/settings/settingsDialogStore` (last straggler). +- Data: canonical UI settings state lives in `@posthog/ui/features/settings/{settingsStore,settingsDialogStore}` (20 + 14 importers). `apps/code/src/main/services/settingsStore.ts` is a separate main-process store (worktree location) and stays. +- Bridge: none. Remaining ui-settings work: move the feature components (components/sections/*) + SETTINGS_SERVICE interface. +- Validation: packages/ui settings tests 11/11; apps/code 0 fallout from the deletions (typecheck down to 1 exogenous error). + +## 2026-06-01 — ui-git-interaction (pure logic/utils/state -> packages/ui) +- Moved: host-agnostic git-interaction layer apps/code -> packages/ui/src/features/git-interaction (types, utils/{branchNameValidation,deriveBranchName,diffStats,errorPrompts,fileKey,gitStatusUtils,partitionByStaged}, state/{gitInteractionLogic,gitInteractionStore} + tests). ~20 consumers repointed to @posthog/ui; old copies deleted. +- New shared: packages/shared/src/git-naming.ts (BRANCH_PREFIX), barrel-exported; apps/code @shared/constants re-exports it (single source). +- Data: gitInteractionStore is a thin UI store (zustand + electronStorage via @posthog/ui/workbench/rendererStorage); gitInteractionLogic is pure menu-action logic. +- Deferred (blocked on git-pr-coupled transport): prStatus.tsx (@main PrActionType), trpc-coupled utils (branchCreation/getSuggestedBranchName/gitCacheKeys/updateGitCache), hooks (useGitQueries etc.), components (BranchSelector/CreatePrDialog/etc.) — they consume trpc.git.* via renderer->main and need workspace-client + the coupled ops ported. +- Validation: @posthog/shared+ui+apps/code typecheck clean; 56 ui tests pass; apps/code 2 remaining errors are exogenous. + +## 2026-06-01 - ui-permissions + ActionSelector primitive -> packages/ui +- Moved: 14 permission components + types `apps/code/src/renderer/components/permissions` -> `packages/ui/src/features/permissions`; `components/action-selector/*` -> `packages/ui/src/primitives/action-selector` (completes the ui ActionSelector facade); `mcp-app-host-utils` -> `ui/features/mcp-apps/utils`; `posthog-exec-display` -> `ui/features/posthog-mcp/utils` +- Registered: ui deps += `@posthog/agent`, `@modelcontextprotocol/ext-apps`, `@modelcontextprotocol/sdk` +- Cleaned: fixed the dangling `ui/primitives/ActionSelector` re-export (3 ui errors); UI permission rendering no longer lives in apps/code +- Bridge: apps shims `components/{ActionSelector,permissions/PermissionSelector,permissions/PlanContent}.tsx`, `mcp-apps/utils/mcp-app-host-utils.ts`, `posthog-mcp/utils/posthog-exec-display.ts` — retire as sessions/mcp-apps consumers import `@posthog/ui` directly +- Validation: ui typecheck moved files clean (total 12->9); apps/code my files clean; biome 0 noRestrictedImports + +## 2026-06-01 - enrichment boundary types -> @posthog/shared (unblocks ui-code-editor) +- Moved: SerializedEnrichment/SerializedFlag/SerializedEvent (+ nested) + FlagType + StalenessReason from `packages/enricher/src/{serialize,types}.ts` -> `packages/shared/src/enrichment.ts` (zero-dep, renderer-safe) +- Registered: shared barrel `export * from "./enrichment"`; enricher += `@posthog/shared` dep and re-exports the types from serialize.ts/types.ts (single source of truth; apps/code + ws-server keep importing from `@posthog/enricher`) +- Data: source of truth is `@posthog/shared/enrichment`; the enricher scan (ws-server) produces them, the renderer (ui code-editor) renders them +- Cleaned: ui code-editor enrichment files (postHogEnrichment, enrichmentPopoverStore) now import from `@posthog/shared` instead of the layer-restricted `@posthog/enricher` (biome noRestrictedImports satisfied) +- Validation: shared+enricher dists rebuilt; ws-server typecheck 0; apps enricher/code-editor clean; ui biome 0 noRestrictedImports + +## 2026-06-01 — mcp-servers (renderer presentational + pure + assets -> packages/ui) +- Moved: pure logic (mcpFilters/mcpToolBulk/statusBadge), presentational components (ToolPolicyToggle/ToolRow/AddCustomServerForm/ServerCard/McpInstalledRail/MarketplaceView/icons), and 36 service-logo assets -> packages/ui/src/features/mcp-servers + packages/ui/src/assets/services. Added *.png to packages/ui/src/assets.d.ts. +- Data: types/client via @posthog/api-client/posthog-client (ui already depends on api-client). No state owned by the moved layer (pure + presentational). +- Deferred: useMcpServers/useMcpInstallationTools hooks + McpServersView/ServerDetailView views — use main-router useTRPC subscriptions + trpcClient.mcpCallback; need an MCP_OAUTH port + ui->main subscription bridge. +- Validation: ui + apps/code typecheck clean; 16 ui mcp-servers tests pass; apps/code 1 exogenous error. + +## 2026-06-01 - git-pr (generateCommitMessage) -> @posthog/core/git-pr (main-hosted) +- Moved: commit-message generation orchestration from the 2049-LOC apps `GitService` -> new `packages/core/src/git-pr/` (GitPrService) — pure, host-agnostic, unit-testable +- Registered: `gitPrModule` (binds GIT_PR_SERVICE); ports GIT_DIFF_SOURCE (git CLI reads — core can't import @posthog/git) + GIT_PR_LOGGER, bound in apps container; LLM via core LLM_GATEWAY_SERVICE +- Data: prompt-building + LLM call now testable in isolation; git diffs flow through a port +- Cleaned: business logic out of the apps GitService bridge; GitService.generateCommitMessage is now a 3-line delegate (router + CreatePrSaga unchanged) +- Bridge: GitService delegates to GIT_PR_SERVICE (injected); retire once router/saga call GIT_PR_SERVICE directly +- Validation: core typecheck 0 + biome 0 noRestrictedImports (purity gate) + 2 core tests; git service.test 27/27; ws-server 0 + +## 2026-06-01 - git-pr (generatePrTitleAndBody) -> @posthog/core/git-pr; GitService LLM-decoupled +- Moved: PR title/body generation -> GitPrService (core). Widened GIT_DIFF_SOURCE port (default/current branch, diff-against-remote, commits-between-branches, PR template, fetch-if-stale). +- Cleaned: GitService no longer depends on the LLM gateway at all (removed the injection) — both commit-message and PR-description generation now live in core; GitService is a thin delegate for them. +- Validation: core 0 + 4 git-pr tests + purity gate; git service.test 27/27 + +## 2026-06-01 - git-pr (CreatePrSaga) -> @posthog/core/git-pr; orchestration COMPLETE +- Moved: CreatePrSaga -> packages/core/src/git-pr/create-pr-saga.ts. Used lightweight structural dep types (no git-schema-graph relocation); @posthog/git getHeadSha + operation-manager soft-reset became deps (getHeadSha + resetSoft). +- Result: ALL git-pr orchestration (generateCommitMessage + generatePrTitleAndBody + CreatePrSaga) now in @posthog/core/git-pr, pure + unit-tested (7 tests). Host GitService.createPr is integration-only (builds the core saga + SSE progress + session env). +- Validation: core 0 + 7 git-pr tests + purity gate; git service.test 27/27; apps non-mcp-servers 0 + +## 2026-06-01 - actions + command/FilePicker -> packages/ui +- Moved: `ActionTabIcon` -> `packages/ui/features/actions/ActionTabIcon.tsx` (apps `features/actions` dir now fully removed; `actionStore` was already in ui). `FilePicker` -> `packages/ui/features/command/FilePicker.tsx`. +- Registered: extended the `ShellClient` port (`@posthog/ui/features/terminal/shellClient`) with `destroy()`; host `shellClientAdapter` forwards to `trpcClient.shell.destroy`. ActionTabIcon's only host call now flows through the port — no `@renderer/trpc/client` left in the moved code. +- Data: no owned state moved (ActionTabIcon reads `actionStore`; FilePicker reads `panelLayoutStore` + `useRepoFiles` — all already in ui). +- Cleaned: removed the last app-local consumer references (`panels/usePanelLayoutHooks`, `task-detail/TaskDetail`). +- Bridge: none added. `command/CommandKeyHints.tsx` stays as an app shim only because the still-app-resident `CommandMenu` imports it. +- Validation: ui + apps/code typecheck 0; ui command(6)/repo-files/terminal(7) tests green; biome clean. + +## 2026-06-01 - panels (layout half) +- Moved: `apps/code/src/renderer/features/panels/components/{Panel,PanelGroup,PanelResizeHandle,GroupNodeRenderer,PanelDropZones,PanelTree}.tsx` + `hooks/{useDragDropHandlers,usePanelKeyboardShortcuts}.ts` -> `packages/ui/src/features/panels/{components,hooks}/` +- Registered: none (presentational layout primitives over the already-ported panel stores) +- Data: source of truth is `panelLayoutStore` (already in ui); these are pure projections +- Cleaned: relativized self-name imports; `usePanelKeyboardShortcuts` keyboard-shortcuts -> `../../command/keyboard-shortcuts`; added @dnd-kit/react + react-resizable-panels + react-hotkeys-hook to packages/ui +- Bridge: apps `PanelLayout` content cluster (PanelLayout/LeafNodeRenderer/TabbedPanel/PanelTab/DraggableTab/usePanelLayoutHooks) stays until ui-task-detail (TabContentRenderer port), a PANEL_CONTEXT_MENU client port, and handleExternalAppAction/workspaceApi are resolved +- Validation: `pnpm typecheck` 19/19; `@posthog/ui` 508/508; biome check+lint clean + +## 2026-06-01 - renderer-shared-hooks (movable remainder) +- Moved: `apps/code/src/renderer/hooks/useAutoFocusOnTyping.ts` -> `packages/ui/src/features/message-editor/useAutoFocusOnTyping.ts` (pure DOM hook; dep only EditorHandle from message-editor types); orphaned colocated tests `useDebounce.test.ts` + `useImagePanAndZoom.test.tsx` -> `packages/ui/src/primitives/hooks/` beside their already-migrated impls +- Registered: none (presentational hook + test relocation; no token/contribution) +- Cleaned: `useAutoFocusOnTyping` self-name import -> relative `./types`; repointed 2 consumers (SessionView, TaskInput) to the package path and deleted the app copy (no shim); added jsdom PointerEvent polyfill to `packages/ui/src/test/setup.ts` (mirrors apps test setup) so pointer-drag hook tests carry `pointerId` +- Bridge: none. Remaining renderer/hooks entries are thin re-export shims or feature-gated (useTask*DeepLink/useTaskContextMenu -> deep-links/task; useRepositoryDirectory -> workspace; useFileWatcher deliberatelyNotSliced) +- Validation: `@posthog/ui` typecheck 0 + full vitest 52 files/565 tests green; `pnpm --filter code typecheck` 0 slice-attributable errors (2 exogenous inbox errors from a concurrent move); biome format clean + +## 2026-06-01 - inbox pure layer -> packages/ui +- Moved: inbox pure utils (filterReports, suggestedReviewerFilters, inboxSort, inboxConstants, build{Discuss,CreatePr}ReportPrompt, pendingInboxOpenMethod) + 8 pure presentational/store leaves -> `packages/ui/features/inbox/{utils,components/utils,components/detail,stores}`. +- Data: no owned domain state moved; types now sourced from `@posthog/shared/domain-types` + `@posthog/shared/analytics-events`. `inboxSignalsSidebarStore` is a thin `createSidebarStore` UI store. +- Cleaned: removed app-alias coupling (`@shared/*`, `@utils/logger`) from the moved code; all consumers import from `@posthog/ui`. +- Bridge: none. `inbox/utils/resolveDefaultModel.ts` stays in app (trpcClient); knotted views/hooks remain pending navigationStore/auth/trpc ports. +- Validation: ui + apps/code typecheck 0; ui inbox tests 73/73; biome clean. + +## 2026-06-01 - message-editor (suggestion engine + tiptap mentions) +- Moved: `apps/code/src/renderer/features/message-editor/{commands,suggestions/getSuggestions,tiptap/*,components/IssueRow,components/SuggestionStatus}` -> `packages/ui/src/features/message-editor/` +- Registered: `MessageEditorHost` module-setter port (`ports.ts`); desktop adapter `platform-adapters/message-editor-host.ts` set via `setMessageEditorHost` in desktop-services +- Data: suggestions derived from host (`searchGithubRefs`/`fetchRepoFiles`); prompt encoding already in `@posthog/shared` cloud-prompt +- Cleaned: removed direct `trpcClient`/`queryClient`/`@hooks/useRepoFiles` coupling from the suggestion engine + node views; relativized self-name imports +- Bridge: attachment subsystem + editor shell (persistFile, AttachmentsBar/IssuePicker/AttachmentMenu, PromptInput, useTiptapEditor) stay in apps until MessageEditorHost gains the os/git attachment methods +- Validation: `pnpm typecheck` 19/19; `@posthog/ui` 572/572; biome check+lint clean + +## 2026-06-01 - ui-code-editor (enrichment vertical) +- Moved: `apps/code/src/renderer/features/code-editor/{hooks/useFileEnrichment.ts,components/EnrichmentPopover.tsx}` -> `packages/ui/src/features/code-editor/{hooks,components}/` +- Registered: NEW `ENRICHMENT_CLIENT` port (`packages/ui/src/features/code-editor/ports.ts`) bound to `TrpcEnrichmentClient` (`apps/code/src/renderer/platform-adapters/enrichment-client.ts`) in `desktop-services.ts` +- Data: source of truth is the workspace-server EnrichmentService (`enrichment.enrichFile`); ui consumes it via the typed client port + TanStack Query, gated on `useAuthStateValue` (ui auth store) +- Cleaned: ui enrichment UI no longer imports `@renderer/trpc`, `@posthog/enricher` (now `@posthog/shared`), or `@features/auth`; openExternal goes through the existing `@posthog/ui/workbench/openExternal` host port +- Bridge: none for the moved files. code-editor tier-2 (CodeEditorPanel/useCodeMirror/useCloudFileContent/CodeMirrorEditor) remains in apps until a contextMenu client port + workspace/sidebar/task-detail hooks land +- Validation: `@posthog/ui` typecheck 0 + full vitest 55 files/580 tests; `pnpm --filter code typecheck` 0 slice-attributable errors (3 exogenous message-editor errors from a concurrent move); biome format clean + +## 2026-06-01 - message-editor clean components + host-port clipboard ops -> packages/ui +- Moved: analytics types, AdapterIndicator, ModeSelector, PromptHistoryDialog, tiptap/useDraftSync -> packages/ui/features/message-editor. +- Registered: extended MessageEditorHost port (saveClipboardImage/Text/File, downscaleImageFile); desktop adapter forwards to trpcClient.os.*. The non-React persistFile module consumes the port (no @renderer import). +- Data: no owned state moved; PromptHistoryDialog analytics via @posthog/shared/analytics-events + @posthog/ui/workbench/analytics. +- Validation: full typecheck 19/19; ui message-editor tests 62/62; biome clean. + +## 2026-06-01 - sidebar groupTasks + props-driven items -> packages/ui +- Moved: groupTasks util (repository grouping; deps now @posthog/shared) + SidebarItem base + nav item leaves (Skills/McpServers/CommandCenter/Search/Home/SidebarKbdHint) + SidebarTrigger + DraggableFolder -> packages/ui/features/sidebar. +- Data: groupTasks is pure (Task[] -> grouped); items are props-driven (no store/trpc reach-ins). +- Validation: full typecheck 19/19; ui sidebar tests 41/41; biome clean. + +## 2026-06-01 - message-editor (attachment subsystem + editor shell — feature complete) +- Moved: `persistFile`, `useTiptapEditor`, `AttachmentsBar`, `IssuePicker`, `AttachmentMenu`(+test), `PromptInput`, `message-editor.css` -> `packages/ui/src/features/message-editor/` +- Registered: `MessageEditorHost` now 13 methods (git refs/gh-status, os clipboard/attachments/data-url, fs read, repo files, dir picker); desktop adapter `platform-adapters/message-editor-host.ts` +- Cleaned: attachment components converted from `useTRPC().queryOptions` to `useQuery` manual keys over the host; removed all `trpcClient`/`queryClient`/`@renderer` coupling +- Bridge: only `PromptInput.stories.tsx` (storybook) + `README.md` remain in apps (host-appropriate) +- Validation: `pnpm typecheck` 19/19; `@posthog/ui` 612/612; message-editor 64/64; biome clean + +## 2026-06-01 - git-cache keystone (read + invalidation layer -> ui) +- Moved: `apps/code/src/renderer/features/git-interaction/{utils/gitCacheKeys.ts,hooks/useGitQueries.ts}` -> `packages/ui/src/features/git-interaction/{gitCacheKeys.ts,useGitQueries.ts}` +- Registered: host-set `setQueryClient` (`@posthog/ui/workbench/queryClient`) + `setGitCacheKeyProvider` (`@posthog/ui/features/git-interaction/gitCacheProvider`) + DI binding `GIT_QUERY_CLIENT` -> `TrpcGitQueryClient`, all wired in `desktop-services.ts` +- Data: git read data source of truth is the host git router (forwards to workspace-server); cache **keys** are host-supplied (the real tRPC keys) so packages/ui invalidation stays byte-coherent with the host's read queries +- Cleaned: git read hooks + cache invalidation no longer import `@renderer/trpc`/`@utils/queryClient`; they go through `GIT_QUERY_CLIENT` (data) + the host-set key/queryClient providers +- Bridge: apps shims at the old `utils/gitCacheKeys.ts` + `hooks/useGitQueries.ts` paths re-export from `@posthog/ui` (≈14 consumers unchanged); git result types (`GitSyncStatus`/`GitRepoInfo`/etc.) are declared in ui `ports.ts` until git-domain-types-to-shared relocates them. Git WRITE ops + createPr-progress subscription + components still in apps. +- Validation: `@posthog/ui` typecheck 0 + vitest 58 files/612 tests; `pnpm --filter code` typecheck 0; useBranchMismatchDialog/BranchSelector/ReviewShell tests green + +## 2026-06-01 - navigation-store + +- Moved: `apps/code/src/renderer/stores/navigationStore.ts` -> `packages/ui/src/features/navigation/store.ts` (+ `taskBinder.ts`, `store.test.ts`) +- Registered: `setNavigationTaskBinder` (NavigationTaskBinder port) + `setActiveTaskContextHandler` (analytics) wired in `apps/code/src/renderer/desktop-services.ts` / `utils/analytics.ts` +- Data: source of truth is the navigation store's `view` + `history`; `canGoBack`/`canGoForward` are derived +- Cleaned: removed store-owned multi-step flow + cross-store reach-in from `navigateToTask` (workspace/folder auto-registration now a host adapter behind `NavigationTaskBinder`) +- Bridge: `apps/code/src/renderer/stores/navigationStore.ts` re-export shim remains (33 consumers); `platform-adapters/navigation-task-binder.ts` holds host orchestration until it moves to a main/core service emitting events +- Validation: `@posthog/ui` typecheck 0 + 639 ui tests (navigation 16/16); `code` typecheck 0; biome clean. Live Electron smoke pending. + +## 2026-06-01 - code-editor (CodeMirror hook + view) +- Moved: `apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts` -> `packages/ui/src/features/code-editor/hooks/useCodeMirror.ts` +- Moved: `apps/code/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx` -> `packages/ui/src/features/code-editor/components/CodeMirrorEditor.tsx` +- Registered: consumes existing `FILE_CONTEXT_MENU_CLIENT` + `WORKSPACE_CLIENT` (no new tokens; both already bound in desktop-services.ts) +- Cleaned: useCodeMirror dropped trpcClient/workspaceApi/handleExternalAppAction direct imports (host-agnostic via useService); CodeMirrorEditor SerializedEnrichment now from @posthog/shared (was @posthog/enricher, layer violation) +- Bridge: none (apps CodeEditorPanel repointed to @posthog/ui; no shim left) +- Validation: pnpm typecheck 19/19; biome lint 0 noRestrictedImports on packages/ui/src/features/code-editor + +## 2026-06-01 - git-interaction (write + orchestration tier) + +- Moved: `apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts` -> `packages/ui/src/features/git-interaction/useGitInteraction.ts` +- Moved: `.../hooks/usePrActions.ts` -> `packages/ui/src/features/git-interaction/usePrActions.ts` +- Moved: `.../utils/{updateGitCache,branchCreation,getSuggestedBranchName}.ts` (+branchCreation.test) -> `packages/ui/src/features/git-interaction/utils/` +- Registered: `GIT_WRITE_CLIENT` (packages/ui/.../git-interaction/ports.ts) bound to `TrpcGitWriteClient` (apps/code/.../platform-adapters/git-write-client.ts) in desktop-services; added `WorkspaceClient.linkBranch` +- Data: source of truth is the host git service (workspace-server); write mutations return `GitStateSnapshot` projections that update the read caches via the host-registered `gitQueryKey` provider (coherent by construction) +- Cleaned: removed trpcClient/electron/auth-service-locator coupling from the orchestration hub; os.openExternal -> openExternalUrl port, auth -> useOptionalAuthenticatedClient, workspace.linkBranch -> WORKSPACE_CLIENT +- Bridge: apps re-export shims at all old hook/util paths (12 consumers) remain until those consumers import from @posthog/ui directly; branchCreation shim supplies the writeClient via container.get at the app boundary +- Validation: `pnpm typecheck` 19/19; `@posthog/ui` 639/639; apps BranchSelector.test 5/5; biome lint/check clean + +## 2026-06-01 - task-detail (cloud-extract + leaves) +- Moved: `apps/code/.../task-detail/utils/cloudToolChanges.ts` (+test) -> `packages/ui/src/features/task-detail/utils/` +- Moved: `apps/code/.../task-detail/components/{ActionPanel,ExternalAppsOpener}.tsx` -> `packages/ui/src/features/task-detail/components/` +- Cleaned: @shared/types->@posthog/shared/domain-types; @shared/types/session-events->@posthog/shared; handleExternalAppAction/keyboard-shortcuts/useExternalApps/ActionTerminal -> @posthog/ui relative +- Bridge: none (all consumers repointed to @posthog/ui; no shims) +- Validation: pnpm typecheck 19/19; ui cloudToolChanges 15/15; biome 0 noRestrictedImports + +## 2026-06-01 - code-review (reviewShellParts split) +- Moved: pure helpers/hooks/types/sub-components out of `apps/.../code-review/components/ReviewShell.tsx` -> NEW `packages/ui/src/features/code-review/reviewShellParts.tsx` +- Kept in apps: the `ReviewShell` component (host-only ChangesPanel + pierre Vite worker + virtua) +- Bridge: apps `ReviewShell.tsx` `export *`-re-exports the parts (retire when ReviewPage/CloudReviewPage/cluster import from @posthog/ui directly) +- Validation: pnpm typecheck 19/19; ui code-review 27/27; ReviewShell.test 4/4; biome 0 noRestrictedImports + +## 2026-06-01 - tasks-read + cloud-run hook tiers + +- Moved: `useCloudEventSummary`/`useCloudRunState`/`useCloudChangedFiles` -> `packages/ui/src/features/task-detail/hooks/`; `useTaskDiffSummaryStats` -> `packages/ui/src/features/code-review/hooks/` +- Split: tasks READ hooks (`useTasks`/`useTaskSummaries`/`useSlackTasks`) -> `packages/ui/src/features/tasks/useTasks.ts`; mutation hooks remain in `apps/code` (host-coupled) +- Data: tasks list = api-client read (useAuthenticatedQuery); cloud changed-files derived from session events + PR/branch git queries +- Bridge: apps re-export shims at all moved hook paths; tasks mutations + `getSessionService.updateSessionTaskTitle` coupling remain until a sessions-title-sync port lands +- Validation: ui+code typecheck 0; ui tests 113/113; biome clean + +## 2026-06-01 - code-editor (CodeEditorPanel keystone — feature fully drained) +- Moved: `apps/code/.../code-editor/components/CodeEditorPanel.tsx` + `.../hooks/useCloudFileContent.ts` -> `packages/ui/src/features/code-editor/` +- Registered: NEW `FILE_CONTENT_CLIENT` (packages/ui/.../code-editor/ports.ts: readRepoFile/readAbsoluteFile/readFileAsBase64) bound to `TrpcFileContentClient` (apps/code/.../platform-adapters/file-content-client.ts) in desktop-services +- Added: `packages/ui/.../code-editor/hooks/useFileContent.ts` (useRepoFileContent/useAbsoluteFileContent/useFileAsBase64) — useService(FILE_CONTENT_CLIENT) + useQuery keyed via the host-registered `fsQueryKey` provider, so keys stay byte-coherent with the host's other fs reads +- Data: source of truth is workspace-server fs (file contents); panel is read-only, cloud reads derive from session tool-call events (useCloudFileContent) +- Cleaned: dropped `useTRPC`/`trpcClient.os.openExternal` from the panel (fs.* -> port hooks; openExternal -> `openExternalUrl`); `@features/*` + `@shared/types` -> relative/`@posthog/shared` +- Drained `editor` feature too: repointed `useTaskCreation` (buildCloudTaskDescription) + `sagas/task/task-creation` (buildPromptBlocks) -> `@posthog/ui/features/editor` and deleted the re-export shims. `apps/code/.../features/{code-editor,editor}` are now empty. +- Bridge: none (sole panel consumer TabContentRenderer repointed directly; no shims left) +- Validation: `pnpm typecheck` 19/19; `@posthog/ui` 706/706; biome lint 0 noRestrictedImports on code-editor. Live Electron GUI smoke deferred (shared-tree WIP). + +## 2026-06-01 - onboarding/tour assets + clean leaves (ui-onboarding partial) + +- Moved: `apps/code/src/renderer/assets/images/hedgehogs/{builder-hog-03,explorer-hog,happy-hog}.png` -> `packages/ui/src/assets/hedgehogs/` (+ new `packages/ui/src/assets/hedgehogs.ts` URL manifest) +- Moved: `apps/code/src/renderer/assets/logo.tsx` -> `packages/ui/src/primitives/Logo.tsx` (pure SVG, zero deps) +- Moved: `WelcomeScreen.tsx` -> `packages/ui/src/features/onboarding/components/`; `createFirstTaskTour.ts` -> `packages/ui/src/features/tour/tours/` +- Data: assets are static URLs; manifest re-exports them by name (cross-package raw `.png` import is not resolvable via the `@posthog/ui` exports map, a `.ts` manifest is — mirrors the sounds-asset precedent) +- Cleaned: 14 hedgehog import sites + Logo + 3 moved-file consumers repointed to `@posthog/ui`; no shims left +- Bridge: none +- Validation: `pnpm typecheck` 19/19; `@posthog/ui` vitest 67 files / 706 tests; biome check clean. Live Electron smoke deferred (shared-tree WIP). +- Remaining: onboarding/setup components still gated on auth/integrations/projects/folder-picker/analytics(`track`) ports (GitHubConnectPanel is the keystone). + +## 2026-06-01 - shell SpaceSwitcher leaf (ui-shell partial) + +- Moved: `apps/code/src/renderer/components/SpaceSwitcher.tsx` -> `packages/ui/src/workbench/SpaceSwitcher.tsx` +- Cleaned: deps repointed to `@posthog/ui`/`@posthog/shared`; sole consumer MainLayout repointed; no shim +- Validation: `@posthog/ui` typecheck 0 + 706 tests; biome clean + +## 2026-06-01 - git-interaction (useFixWithAgent + CreatePrDialog) +- Moved: `apps/code/.../git-interaction/hooks/useFixWithAgent.ts` -> `packages/ui/src/features/git-interaction/useFixWithAgent.ts` +- Moved: `apps/code/.../git-interaction/components/CreatePrDialog.tsx` -> `packages/ui/src/features/git-interaction/components/CreatePrDialog.tsx` +- Cleaned: useFixWithAgent now consumes ui paths only (useSession/sendPromptToAgent/navigation store/errorPrompts); CreatePrDialog's last app-local dep (GitInteractionDialogs shim) is now a relative import; self-imports relativized +- Bridge: none. Consumers repointed directly — CreatePrDialog's `useFixWithAgent` import, TaskActionsMenu + CreatePrDialog.stories (stories stay in apps/code; storybook is app-only) now import CreatePrDialog from `@posthog/ui` +- Gated: useCloudPrUrl/useTaskPrUrl/TaskActionsMenu chain blocked on tasks reconciliation (apps useTasks vs ui useTasks are distinct impls with different query keys); useTaskPrUrl additionally needs trpc.git.getPrStatus -> GIT_QUERY_CLIENT.getPrStatus + gitQueryKey +- Validation: `pnpm typecheck` 19/19; `@posthog/ui` git-interaction 6 files/71 tests; biome lint 0 noRestrictedImports + +## 2026-06-01 - inbox SignalReport-card chain (ui-inbox partial) + +- Moved: inbox `{utils/ReportImplementationPrLink, utils/ReportCardContent, detail/MultiSelectStack, list/ReportListRow, list/ReportListPane}` -> `packages/ui/src/features/inbox/components/*` +- Cleaned: `usePrDetails`->ui git-interaction; `SignalReport`->`@posthog/shared/domain-types`; apps consumers (ReportDetailPane, InboxSignalsTab) repointed; no shims +- Validation: `@posthog/ui` typecheck 0 + 710 tests; biome clean + +## 2026-06-01 - code-review (page/shell tier) + +- Moved: `apps/code/.../code-review/components/{ReviewShell,ReviewPage,CloudReviewPage}.tsx` + `hooks/useDiffStatsToggle.ts` -> `packages/ui/src/features/code-review/` (apps/code/features/code-review now all shims + one host-bindings file) +- Registered: `reviewHost.ts` (setReviewDiffWorkerFactory / setReviewExpandedSidebarRenderer); wired by apps `reviewHostBindings.tsx` (side-effect import in `main.tsx`) +- Data: untracked-file prefetch source of truth is the host fs via `REVIEW_FILE_CLIENT` (new batch `readRepoFilesBounded`); cache keys derived from host-set `fsQueryKey` so prefetch stays coherent with `useReadRepoFileBounded` +- Cleaned: ReviewShell no longer imports task-detail ChangesPanel or the Vite worker URL directly — both injected by the host +- Bridge: `apps/.../code-review/reviewHostBindings.tsx` supplies the pierre worker (host/bundler) + ChangesPanel sidebar slot; sidebar half retires when task-detail's ChangesPanel lands in `packages/ui`. Component/hook shims retire when consumers import `@posthog/ui` directly. +- Validation: `pnpm typecheck` 19/19; ui code-review vitest 710 pass; biome clean. Live review-pane smoke pending (no headless Electron). + +## 2026-06-01 - sessions context-usage + plan-status leaves (sessions partial) + +- Moved: sessions `{PlanStatusBar, ContextUsageIndicator, ContextBreakdownPopover(+test), utils/contextColors}` -> `packages/ui/src/features/sessions/*` +- Cleaned: contextColors -> `@posthog/ui/features/sessions/contextColors`; consumers SessionView/SessionFooter/PlanStatusBar.stories repointed; no shims +- Validation: `@posthog/ui` typecheck 0 + moved test 3/3 + 719 ui tests; biome clean + +## 2026-06-01 - git-interaction (PR-url chain + BranchSelector) +- Moved: `useCloudPrUrl.ts`, `useTaskPrUrl.ts`, `components/TaskActionsMenu.tsx`, `components/BranchSelector.tsx` (+test) -> `packages/ui/src/features/git-interaction/` +- Registered: added `checkoutBranch` to `GIT_WRITE_CLIENT` port + `TrpcGitWriteClient` adapter +- Cleaned: useTaskPrUrl + BranchSelector dropped `useTRPC`; git reads now go through `useService(GIT_QUERY_CLIENT)` + `gitQueryKey` provider, branch checkout through `useService(GIT_WRITE_CLIENT)` (cache coherence preserved via the host-registered key provider) +- Data: corrected a false gate — apps `useTasks` already re-exports the ui read hooks, so the PR-url chain was never tasks-divergent +- Bridge: apps shims at `useCloudPrUrl`/`useTaskPrUrl` (CommandCenter consumers); TaskActionsMenu/BranchSelector consumers (HeaderRow/TaskInput) repointed directly, no shim +- Remaining: `CloudGitInteractionHeader` (sessions-gated) is the only real app-side file left in the feature +- Validation: `pnpm typecheck` 19/19; `@posthog/ui` git-interaction 7 files/76 tests; biome lint 0 noRestrictedImports + +## 2026-06-01 - onboarding (clean leaves) + +- Moved: `InviteCodeStep.tsx`, `SelectRepoStep.tsx`, `hooks/useProjectsWithIntegrations.ts` -> `packages/ui/src/features/onboarding/` +- Data: extracted `DetectedRepo` interface -> `packages/ui/src/features/onboarding/types.ts` (apps `useOnboardingFlow` re-exports it; unblocks SelectRepoStep without porting the still-trpc-coupled hook) +- Bridge: apps shims for the 3 moved files (consumers OnboardingFlow + GitHubConnectPanel unchanged); retire when those consumers land in ui +- Validation: `@posthog/ui typecheck` 0; biome clean + +## 2026-06-01 - sidebar (TaskListView + Sidebar leaves) + +- Moved: `apps/code/src/renderer/features/sidebar/components/TaskListView.tsx` -> `packages/ui/src/features/sidebar/components/TaskListView.tsx` +- Moved: `apps/code/src/renderer/features/sidebar/components/Sidebar.tsx` -> `packages/ui/src/features/sidebar/components/Sidebar.tsx` +- Data: source of truth is `useSidebarData` (already in ui); TaskListView is fully props-driven projection +- Cleaned: TaskListView imports repointed to package paths (useFolders/useWorkspace/useMeQuery/navigation + @posthog/shared utils); Sidebar uses `@posthog/ui/primitives/ResizableSidebar` +- Bridge: apps `features/sidebar/components/index.tsx` barrel re-exports `Sidebar` from ui; SidebarMenu imports TaskListView from ui directly (no shim) +- Validation: `pnpm --filter @posthog/ui typecheck`, `pnpm --filter code typecheck`, ui sidebar vitest 41/41, biome clean + +## 2026-06-01 - setup discovery (ui-onboarding) + +- Moved: `apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailDialog.tsx` -> `packages/ui/src/features/setup/DiscoveredTaskDetailDialog.tsx` +- Moved: `apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts` -> `packages/ui/src/features/setup/useSetupDiscovery.ts` +- Registered: `setupUiModule` (`packages/ui/src/features/setup/setup.module.ts`, binds `SetupRunService` singleton) loaded in `desktop-contributions.ts` +- Cleaned: `useSetupDiscovery` now resolves `SetupRunService` via `useService` instead of renderer-container `get(RENDERER_TOKENS.SetupRunService)`; removed the dead `RENDERER_TOKENS.SetupRunService` token + `di/container.ts` binding +- Bridge: app shims at `features/setup/components/DiscoveredTaskDetailDialog.tsx` (consumer task-detail/SuggestedTasksPanel) and `features/setup/hooks/useSetupDiscovery.ts` (consumer MainLayout) — retire when those consumers move to packages +- Validation: `pnpm typecheck` 19/19; ui setup vitest 14/14; biome lint clean + +## 2026-06-01 - sessions (cloneStore forbidden-pattern fix) + +- Moved: clone subscription + auto-dismiss timer out of `packages/ui/.../clone/cloneStore.ts` into `clone.contribution.ts` (boot `WORKBENCH_CONTRIBUTION`); `startClone` orchestration -> `clone/cloneActions.ts` +- Registered: `cloneUiModule` (`clone.module.ts`) in `apps/code/src/renderer/desktop-contributions.ts` +- Data: source of truth is the host clone lifecycle (main git service `CloneProgress` events); `cloneStore.operations` is a pure projection; `isCloning`/`getCloneForRepo` are derived +- Cleaned: removed store-owned module subscription, domain-cleanup `setTimeout`, and in-store orchestration (3 AGENTS.md forbidden patterns) +- Note: `startClone` currently has no callers (clone-progress feature is dead) — patterns removed + capability preserved, not deleted +- Validation: `@posthog/ui typecheck` 0; `cloneStore.test` 7/7; biome clean + +## 2026-06-01 - integrations github-connect tier + onboarding github step + +- Moved: `apps/.../features/auth/hooks/useOrgRole.ts` -> `packages/ui/src/features/auth/useOrgRole.ts` +- Moved: `apps/.../features/integrations/hooks/useGitHubIntegrationCallback.ts` + `useGithubUserConnect.ts` -> `packages/ui/src/features/integrations/` +- Moved: `apps/.../features/onboarding/components/GitHubConnectPanel.tsx` + `ConnectGitHubStep.tsx` -> `packages/ui/src/features/onboarding/components/` +- Registered: `GITHUB_INTEGRATION_CLIENT` port (`packages/ui/src/features/integrations/ports.ts`) + desktop adapter `platform-adapters/github-integration-client.ts` bound in `desktop-services.ts` +- Data: source of truth is the host GitHub integration service (callbacks/pending-callback/flow events); ui consumes via the port + RQ cache invalidation +- Cleaned: subscriptions go through `client.onCallback/onFlowTimedOut` (useService) instead of `useTRPC().githubIntegration.*`; `trpc.os.openExternal` -> `openExternalUrl`; `IS_DEV` -> `import.meta.env.DEV` +- Bridge: apps shims for `useOrgRole` + `useGithubUserConnect` re-export from ui (consumers in App/settings/inbox/task-detail unchanged); retire when those features land +- Validation: ui typecheck 0; full ui vitest 736/736; biome clean + +## 2026-06-01 - billing/utils (layer fix) + +- Moved: `apps/code/src/renderer/features/billing/utils.ts` (+test) -> `packages/ui/src/features/billing/utils.ts` +- Cleaned: `UsageOutput` type import moved from `@main/services/llm-gateway/schemas` -> `@posthog/core/llm-gateway/schemas` (removes a main->renderer cross-process type coupling; ui->core is an allowed edge) +- Bridge: app shim at `features/billing/utils.ts` — retire when SidebarUsageBar/UsageLimitModal/billing subscriptions/PlanUsageSettings move to packages +- Validation: ui typecheck 0; ui billing utils vitest 11/11; biome clean + +## 2026-06-01 - inbox (component tier) + +- Moved: `apps/code/src/renderer/features/inbox/components/{InboxEmptyStates,SignalSourceToggles}.tsx`, `components/detail/SignalCard.tsx`, `components/list/{SuggestedReviewerFilterMenu,SignalsToolbar}.tsx`, `hooks/{useInboxBulkActions,useSignalSourceManager}.ts` -> `packages/ui/src/features/inbox/...`; `components/ui/RelativeTimestamp.tsx` -> `packages/ui/src/primitives/`; `assets/images/mail-hog.png` -> `packages/ui/src/assets/images/` +- Data: inbox report truth owned by api-client query cache key `["inbox","signal-reports"]` (ui read hooks); useInboxBulkActions invalidates the same key — single source preserved across the move +- Cleaned: dropped false auth coupling (all auth read-path already in `@posthog/ui/features/auth`); `@renderer/api/posthogClient` (shim) repointed to `@posthog/api-client/posthog-client` +- Bridge: apps shims at `features/inbox/components/SignalSourceToggles.tsx`, `features/inbox/hooks/{useInboxBulkActions,useSignalSourceManager}.ts`, `components/ui/RelativeTimestamp.tsx` remain until settings (SignalSourcesSettings/SignalSlackNotificationsSettings) consumers import from `@posthog/ui` directly +- Validation: `pnpm --filter @posthog/ui typecheck` (0); ui inbox vitest 76 tests; biome clean; `pnpm --filter code typecheck` (0 in inbox paths) + +## 2026-06-01 - sessions (pure helper extraction) + +- Moved: `buildCloudDefaultConfigOptions`/`extractLatestConfigOptionsFromEntries` -> `packages/ui/.../sessions/cloudSessionConfig.ts`; `hasSessionPromptEvent`/`isAbsoluteFolderPath`/`promptReferencesAbsoluteFolder` -> `packages/ui/.../sessions/session.ts` +- Cleaned: renderer `service/service.ts` no longer defines these; imports them from ui; dropped unused `@posthog/agent/execution-mode` import +- Bridge: `isTurnCompleteEvent` stays in `service/service.ts` (needs `@posthog/agent` root barrel, forbidden in ui; no browser-safe acp-extensions subpath) +- Validation: ui typecheck 0; ui tests 41/41; apps/code typecheck 0; biome 0 noRestrictedImports + +## 2026-06-02 - task-detail (leaves) + +- Moved: `apps/code/src/renderer/components/TreeDirectoryRow.tsx` -> `packages/ui/src/primitives/`; `features/task-detail/components/{CloudGithubMissingNotice,ChangesTreeView}.tsx` -> `packages/ui/src/features/task-detail/components/` +- Cleaned: dropped false auth/integrations coupling (auth read-path + github-connect already in `@posthog/ui`); `@components/TreeDirectoryRow` -> `@posthog/ui/primitives/TreeDirectoryRow`, `@shared/types` -> `@posthog/shared/domain-types` +- Bridge: apps shim `components/TreeDirectoryRow.tsx` remains until ChangesPanel/FileTreePanel move to packages/ui +- Validation: `@posthog/ui` typecheck (0); `code` typecheck (0); ui task-detail vitest 20 tests; biome clean + +## 2026-06-02 - agent: ./acp-extensions subpath + sessions moves + +- Added: `@posthog/agent/acp-extensions` browser-safe subpath export (pure ACP notification consts + `isNotification`); tsup entry + package.json exports, dist rebuilt +- Moved: `isTurnCompleteEvent` -> `packages/ui/.../sessions/session.ts`; `cloudRunIdleTracker.ts` -> `packages/ui/.../sessions/` (git mv, +test 9/9) +- Cleaned: renderer `service/service.ts` imports both from `@posthog/ui`; agent-root barrel no longer needed for these +- Enabler: ui code may now import `isNotification`/`POSTHOG_NOTIFICATIONS` from `@posthog/agent/acp-extensions` instead of the forbidden root barrel +- Validation: agent build OK; ui session 29/29 + cloudRunIdleTracker 9/9; service typecheck clean; biome clean + +## 2026-06-02 - onboarding InstallCliStep + git-status read port + +- Moved: `apps/.../features/onboarding/components/InstallCliStep.tsx` -> `packages/ui/src/features/onboarding/components/` +- Registered: `getGitStatus` added to `GIT_QUERY_CLIENT` port + `git-query-client.ts` adapter +- Cleaned: InstallCliStep off `useTRPC` -> `useService(GIT_QUERY_CLIENT)` + `gitQueryKey`/`gitPathFilter` for cache-coherent reads/invalidation; `trpc.os.openExternal` -> `openExternalUrl` +- Bridge: none (OnboardingFlow repointed; sole consumer) +- Validation: ui git-interaction vitest 76/76; ui+apps typecheck clean in touched paths; biome clean + +## 2026-06-02 - billing useUsage/useFreeUsage + +- Moved: `apps/code/.../features/billing/hooks/{useUsage,useFreeUsage}.ts` -> `packages/ui/src/features/billing/` +- Registered: `UsageClient` port (`packages/ui/src/features/billing/usageClient.ts`) + desktop adapter `RendererUsageClient` (`platform-adapters/usage-client.ts`), wired via `setUsageClient` in desktop-services +- Data: `useUsage` owns the usageMonitor.getLatest cache solely -> ui-owned query key `["billing","usage","latest"]` (no host key provider needed); onUsageUpdated subscription writes that key +- Cleaned: removed renderer trpc coupling (`useTRPC`/`useSubscription`) from the usage read path; `UsageOutput` from `@posthog/core/usage/schemas` +- Bridge: app shims at both hook paths — retire when PlanUsageSettings + SidebarUsageBar move to packages +- Validation: pnpm typecheck 19/19; ui billing vitest 53/53; biome clean + +## 2026-06-02 - sidebar (ProjectSwitcher) + +- Moved: `apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx` -> `packages/ui/src/features/sidebar/components/` +- Cleaned: replaced `trpcClient.os.openExternal` with the `openExternalUrl` platform port; dropped false auth/projects/command coupling (all already in `@posthog/ui`) +- Bridge: none (sole consumer SidebarContent repointed directly) +- Validation: `@posthog/ui` typecheck (0); `code` typecheck (0); ui sidebar vitest 41 tests; biome clean + +## 2026-06-02 - billing flags + SidebarUsageBar + +- Moved: feature-flag constants -> `packages/shared/src/flags.ts`; `SidebarUsageBar.tsx` -> `packages/ui/src/features/billing/` +- Cleaned: flag strings now host-agnostic in @posthog/shared; `apps/code/src/shared/constants.ts` re-exports them (additive shim); SidebarUsageBar fully on ui/shared imports +- Bridge: app shim at `features/billing/components/SidebarUsageBar.tsx` — retire when SidebarContent moves +- Validation: shared+ui+code typecheck 0 in touched paths; ui billing vitest 53/53; biome clean + +## 2026-06-02 - deep-links (useNewTaskDeepLink) + git getGithubIssue port + +- Moved: `apps/.../hooks/useNewTaskDeepLink.ts` -> `packages/ui/src/features/deep-links/useNewTaskDeepLink.ts` +- Registered: new `DEEP_LINK_CLIENT` port (`features/deep-links/ports.ts`) + `deep-link-client.ts` adapter bound in `desktop-services.ts`; `getGithubIssue` added to `GIT_QUERY_CLIENT` port + adapter +- Cleaned: tRPC `useSubscription` -> `useEffect` + `client.onNewTaskAction`; `trpcClient.deepLink/git` -> `useService` ports +- Bridge: apps shim `@hooks/useNewTaskDeepLink` re-exports from ui (MainLayout unchanged) +- Validation: ui git-interaction vitest 76/76; ui+apps typecheck clean in touched paths; biome clean + +## 2026-06-02 - sessions (cloud-log-gap pure logic) + +- Moved: reconcile decision (`classifyCloudLogGap`) + request coalescing (`mergeCloudLogGapRequests`) -> `packages/ui/.../sessions/cloudLogGap.ts` +- Cleaned: `service.ts` reconcileCloudLogGapOnce now delegates the decision to the pure module and shares one `commitReconciledCloudEvents` write path; removed 3 local interfaces + the merge method +- Validation: cloudLogGap 9/9; existing service reconcile tests pass (behavior preserved); typecheck 0 in touched paths; biome clean + +## 2026-06-02 - billing subscriptions -> contribution + +- Moved: App.tsx inline `registerBillingSubscriptions` -> `packages/ui/src/features/billing/billing.contribution.ts` (BillingContribution); registered via `billing.module.ts` (WORKBENCH_CONTRIBUTION) loaded in desktop-contributions; deleted apps `billing/subscriptions.ts` +- Moved: `UsageLimitModal.tsx` -> ui (os.openExternal -> openExternalUrl port) +- Registered: `onThresholdCrossed` added to UsageClient port + RendererUsageClient adapter +- Cleaned: App.tsx no longer registers the billing subscription inline (ui-shell acceptance #1) +- Validation: pnpm typecheck 19/19; ui billing vitest 53/53; biome clean + +## 2026-06-02 - ui-shell App.tsx boot effects -> contributions + +- Moved: `initializeUpdateStore` -> `UpdatesContribution` (updates.module.ts); `initializeConnectivityStore`+`initializeConnectivityToast` -> `ConnectivityContribution` (connectivity.module.ts); both WORKBENCH_CONTRIBUTION, loaded in desktop-contributions +- Cleaned: App.tsx no longer registers update/connectivity init inline (acceptance #1) +- Validation: ui+code typecheck 0 (my paths); updates+connectivity vitest 7/7; biome clean + +## 2026-06-02 - ui-onboarding (ProjectSelectStep) + +- Moved: `ProjectSelectStep.tsx` -> `packages/ui/.../onboarding/components` (imports repointed to ui/shared; apps shim left) +- Added: `useAuthStateFetched()` to `@posthog/ui/features/auth/store` +- Note: `OnboardingFlow` stays — host-coupled via `FullScreenLayout`+`UpdateBanner` + `IS_DEV` (no shared subpath); needs a banner-slot decision +- Validation: ui+app typecheck 0 in touched paths; biome 0 noRestrictedImports + +## 2026-06-02 - HedgehogMode port attempt reverted + +- Attempted HedgehogMode.tsx -> packages/ui/workbench; reverted: ui biome noRestrictedImports forbids `@posthog/hedgehog-mode` (DOM/canvas lib, "ui must run in any JS environment"). Needs a host-injected game factory port to port; stays app-local for now. + +## 2026-06-02 - panels (tab subtree + context-menu port) + +- Added: `PANEL_CONTEXT_MENU_CLIENT` platform-style port (`packages/ui/src/features/panels/panelContextMenuClient.ts`) + `TrpcPanelContextMenuClient` adapter (`apps/code/.../platform-adapters/panel-context-menu-client.ts`), bound in `desktop-services.ts` +- Moved: `DraggableTab`, `PanelTab`, `TabbedPanel` -> `packages/ui/src/features/panels/components/` +- Cleaned: replaced direct `trpcClient.contextMenu.show{Tab,Split}ContextMenu` + `workspaceApi`/`handleExternalAppAction` with the port (adapter handles external-app host-side, returns close-family choice) +- Bridge: none for these components (sole consumer `LeafNodeRenderer` repointed) +- Validation: `@posthog/ui` typecheck (0); ui panels vitest 42 tests; `code` typecheck (0 in panels paths); biome clean + +## 2026-06-02 - sessions component leaves (5 components + asset + dedup) + +- Moved: `CloudInitializingView`, `DiffStatsChip`, `SessionFooter`, `GitActionResult`, `UnifiedModelSelector` (apps sessions/components) -> `packages/ui/src/features/sessions/components/`; `zen.png` -> `packages/ui/src/assets/images/` +- Cleaned: GitActionResult off `useTRPC` -> `useService(GIT_QUERY_CLIENT)` + `gitQueryKey`; `trpc.os.openExternal` -> `openExternalUrl` +- Deduped: `VirtualizedList` (apps dead twin/shim removed; 3 consumers repointed direct to ui) +- Bridge: none (all consumers repointed) +- Validation: ui sessions vitest 78/78; ui+apps typecheck clean in touched paths; biome clean + +## 2026-06-02 - HedgehogMode -> ui (host port) + +- Moved: `HedgehogMode.tsx` -> `packages/ui/src/workbench/`; new `HedgehogModeHost` port + desktop `RendererHedgehogModeHost` adapter (owns `@posthog/hedgehog-mode`), wired via `setHedgehogModeHost` in desktop-services +- Cleaned: ui no longer references the DOM/canvas hedgehog lib (noRestrictedImports honored); game details live in the adapter, state decision in ui +- Bridge: app shim at `components/HedgehogMode.tsx` — retire when MainLayout moves +- Validation: ui+code typecheck 0; ui biome lint clean + +## 2026-06-02 - onboarding (feature complete) + +- Moved: `OnboardingFlow.tsx` -> `packages/ui/src/features/onboarding/components/` (steps + hooks + store already in ui) +- Cleaned: dropped `@components/FullScreenLayout`/`@features/auth`/`@hooks`/`@stores`/`@utils` couplings (all ui); `IS_DEV` inlined as `import.meta.env.DEV`; deleted 4 dead apps shims +- Bridge: `OnboardingHogTip.tsx` remains in apps only because auth `InviteCodeScreen` still consumes it (retire with the auth slice) +- Validation: `@posthog/ui` typecheck (0); `code` typecheck (0 in onboarding/App; 14 exogenous tasks/useTasks errors); biome clean + +## 2026-06-02 - SkillButtonsMenu -> ui + +- Moved: `SkillButtonsMenu.tsx` -> `packages/ui/src/features/skill-buttons/components/` (deps all ui/shared; sendPromptToAgent via existing agentPromptSender port) +- Bridge: app shim at old path (consumers HeaderRow + stories) — retire when HeaderRow moves +- Validation: ui+code typecheck 0; ui skill-buttons vitest 6/6; biome clean + +## 2026-06-02 - tasks mutation hooks (session-task bridge) + +- Added: `SESSION_TASK_BRIDGE` port (`@posthog/ui/features/sessions/sessionTaskBridge.ts`) + apps adapter (`sessionTaskBridgeAdapter.ts`, wired in `main.tsx`) +- Moved: `useUpdateTask`+`useRenameTask` -> `@posthog/ui/features/tasks/useTaskMutations.ts` (coupling to `getSessionService().updateSessionTaskTitle` now via the bridge); test moved + repointed +- Bridge: apps `useTasks.ts` re-exports both from ui; retire when all consumers import the package directly +- Data: source of truth is the renderer SessionService (host); the bridge is a narrow injected port +- Validation: ui+app typecheck 0 in touched paths; useTaskMutations.test 4/4; biome clean + +## 2026-06-02 - useAppBridge -> ui (mcp-apps) + +- Moved: `useAppBridge.ts` -> `packages/ui/src/features/mcp-apps/hooks/` (McpUiResource type from @posthog/core/mcp-apps/schemas; ext-apps already a ui dep) +- Bridge: app shim at old path (consumer McpAppHost) — retire when McpAppHost moves +- Validation: ui+code typecheck 0; ui mcp-apps vitest green; biome clean + +## 2026-06-02 - skills feature -> ui (SkillsView/SkillDetailPanel) + +- Moved: `SkillsView.tsx` + `SkillDetailPanel.tsx` -> `packages/ui/src/features/skills/` (SkillCard + skillsSidebarStore already ui) +- Registered: `SKILLS_CLIENT` port (`@posthog/ui/features/skills/ports.ts`) + `useSkills()` hook; desktop adapter `RendererSkillsClient` (`platform-adapters/skills-client.ts`) bound in `desktop-services.ts` +- Data: source of truth is ws-server `SkillsService` (skills.list); SkillInfo/SkillSource neutral types in `@posthog/shared`. SKILL.md body read reuses `FILE_CONTENT_CLIENT` (useAbsoluteFileContent) for fs-cache coherence; frontmatter stripped client-side +- Cleaned: removed `@renderer/trpc`/`useTRPC` from the skills UI; skills feature now host-agnostic in ui +- Bridge: app shims at `features/skills/components/SkillsView` (consumer MainLayout) + SkillCard + skillsSidebarStore — retire when MainLayout/consumers import the package directly +- Validation: ui typecheck 0; useSkills.test 1/1; biome lint 0 noRestrictedImports. Live GUI smoke deferred (exogenous tree red from concurrent handoff/archive agents) + +## 2026-06-02 - tasks-archive-hook (keystone) + +- Moved: `apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts` -> `packages/ui/src/features/archive/useArchiveTask.ts` (old path is a re-export shim) +- Registered: `ArchiveTaskBridge` (packages/ui archive) + host impl `platform-adapters/archive-task-bridge.ts`, side-effect imported in `main.tsx`; extended `archiveCacheProvider` with list + pathFilter keys +- Data: source of truth is ws-server archive service; ui holds optimistic cache writes. `ArchivedTask` domain type added to `@posthog/shared` (ws-server zod schema stays the boundary validator) +- Cleaned: removed the last `@renderer/*` couplings from the archive flow (workspaceApi/pinnedTasksApi/trpcClient.archive now behind the bridge) +- Bridge: `apps/code/.../platform-adapters/archive-task-bridge.ts` + apps `useArchiveTask.ts` shim remain until SidebarMenu/useTaskContextMenu import the package path and useDeleteTask moves behind ports +- Validation: pnpm typecheck 19/19; ui useArchiveTask.test.ts 2/2; renderer vite build + +## 2026-06-02 — handoff + +- Moved: `apps/code/src/main/services/handoff/handoff-saga.ts` + `handoff-to-cloud-saga.ts` -> `packages/core/src/handoff/` (+ `types.ts` owning `HandoffStep`/`HandoffBaseDeps`/saga input types) +- Cleaned: core imports only `@posthog/shared` — agent runtime (`resumeFromLog`/`formatConversationForResume`) and the `apiClient` calls are injected via `HandoffSagaDeps`; checkpoint typed as shared `GitHandoffCheckpoint` (no generics); `apiClient` removed from the saga +- Data: source of truth is the cloud run log (rebuilt via injected `fetchResumeState`); derived projections are the handoff context summary + checkpointApplied flag +- Bridge: `HandoffService` (apps/code) stays as the saga deps-provider (focus pattern), supplying agent/git/fs host ops; retire its raw fs/git when a workspace-server handoff-host capability lands +- Validation: `@posthog/core` typecheck + 16/16 core saga tests; apps main tsc 0 errors; apps handoff service test 6/6; biome lint core clean + +## 2026-06-02 — ui-settings (sections drain) + +- Moved: 7 settings sections `apps/code/src/renderer/features/settings/components/sections/{PermissionsSettings,ClaudeCodeSettings,AdvancedSettings,ShortcutsSettings,AccountSettings,GitHubSettings,GitHubIntegrationSection}.tsx` -> `packages/ui/src/features/settings/sections/` (old paths are `export *` re-export shims) +- Registered: new `SETTINGS_PERMISSIONS_PORT` (packages/ui/.../settings/ports.ts) + desktop adapter `apps/code/.../platform-adapters/settings-permissions-client.ts` wrapping `trpc.os.getClaudePermissions`, bound in `desktop-services.ts` +- Data: Permissions reads allow/deny tool lists from the host (source of truth = host Claude settings.json) via the port; other sections consume already-ported ui stores/hooks (settingsStore, auth store/useCurrentUser/useAuthMutations, billing useSeat, integrations useGithubUserConnect/useIntegrations) +- Cleaned: Account/GitHub/GitHubIntegration were FALSE BLOCKERS — their auth/integrations deps already lived in @posthog/ui + @posthog/api-client + @posthog/shared; only import paths changed +- Bridge: app `export *` shims at all 7 sections/* paths remain until SettingsDialog imports the package paths directly (SettingsDialog still apps-side, gated on the remaining inbox/billing/folders/tasks-coupled sections) +- Validation: ui+apps typecheck 0; `vite build -c vite.renderer.config.mts` ✓ (runtime bundle, validates the new port binding); ui settings vitest 11/11; biome clean + +## 2026-06-02 - tasks-create-delete-hook (keystone complete) + +- Moved: `useCreateTask`/`useDeleteTask` from apps `features/tasks/hooks/useTasks.ts` -> `packages/ui/src/features/tasks/useTaskCrudMutations.ts` (apps useTasks.ts is now a pure re-export shim for all 5 task hooks) +- Registered: `TaskMutationBridge` (packages/ui tasks) + host impl `platform-adapters/task-mutation-bridge.ts`, side-effect imported in `main.tsx` +- Data: source of truth is the PostHog API task CRUD; ui holds the optimistic task-list cache (taskKeys) +- Cleaned: removed the last `@renderer/*` couplings (workspaceApi.get/delete, contextMenu.confirmDeleteTask, pinnedTasksApi.unpin) from the task CRUD hooks +- Milestone: the entire `apps/.../features/tasks/hooks/` layer is now @posthog/ui shims — the tasks-mutation-hooks keystone (cited as the blocker for sidebar/inbox/task-detail/command) is retired +- Bridge: apps task-mutation-bridge.ts + useTasks.ts shim remain until consumers import package paths directly +- Validation: pnpm typecheck 19/19; ui useTaskCrudMutations.test.tsx 2/2; renderer vite build + +## 2026-06-02 - suspension-write-hooks + +- Moved: `useSuspendTask`/`useRestoreTask` apps `features/suspension/hooks` -> `packages/ui/src/features/suspension` (apps paths are re-export shims) +- Registered: extended `SUSPENSION_CLIENT` (suspend/restore) + new `SuspensionCacheKeyProvider` (host adapter `suspension-cache-keys.ts`, wired in desktop-services) +- Data: source of truth is ws-server suspension service; ui holds the optimistic suspended-id set + drives git working-tree/branch cache invalidation +- Cleaned: removed `@renderer/trpc` + `workspaceApi` + apps gitCacheKeys couplings from the suspension write hooks +- Bridge: apps suspension hook shims remain until consumers (TaskLogsPanel, useTaskContextMenu) import the package paths directly +- Validation: pnpm typecheck 19/19; ui useSuspendTask.test.tsx 2/2; renderer vite build + +## 2026-06-02 — ui-sidebar (main tree + task context-menu keystone) + +- Moved: `apps/code/.../sidebar/components/{SidebarMenu,SidebarContent,MainSidebar}.tsx` + `apps/code/.../hooks/useTaskContextMenu.ts` -> `packages/ui/src/features/{sidebar/components,tasks}/` (old paths are re-export shims) +- Registered: `TASK_CONTEXT_MENU_CLIENT` (packages/ui/.../tasks/taskContextMenuClient.ts) + desktop adapter `apps/.../platform-adapters/task-context-menu-client.ts` wrapping `trpcClient.contextMenu.show{Task,BulkTask}ContextMenu`, bound in `desktop-services.ts`. Added `BulkTaskContextMenuResult` export to `@posthog/core/context-menu/schemas` +- Data: the native context menu is host transport (port returns the chosen action); the ui `useTaskContextMenu` orchestrates the business actions (rename/pin/suspend/archive/delete) via the already-ported ui task/suspension/archive hooks; workspace lookup via `WORKSPACE_CLIENT.getAll`; external-app via ui `handleExternalAppAction` +- Cleaned: deleted dead app suspension duplicates `useSuspendTask.ts`/`useRestoreTask.ts` (byte-equivalent to the ui versions behind WORKSPACE_CLIENT+SUSPENSION_CLIENT); repointed `TaskLogsPanel` to `@posthog/ui/features/suspension` +- Bridge: app `export *` shims at the 4 ported paths remain until SidebarContent/MainSidebar consumers + command-center import package paths directly +- Validation: @posthog/ui + @posthog/core typecheck 0 (my files); ui sidebar+suspension+tasks vitest 49/49; biome clean. Live bundle smoke deferred (concurrent environments-settings move left the renderer bundle red — exogenous) + +## 2026-06-02 - workspace UI tail -> ui (mutation hooks + branch-mismatch dialog) + +- Moved: workspace mutation hooks (useCreate/Delete/EnsureWorkspace) -> `@posthog/ui/features/workspace/useWorkspaceMutations`; `useBranchMismatchDialog`(+test) -> `@posthog/ui/features/workspace` +- Registered: WORKSPACE_CLIENT port +create/+delete (TrpcWorkspaceClient adapter); NEW host-set worktrees cache-key provider (`workspaceCacheProvider` + `workspace-cache-keys` adapter, wired in desktop-services); branch-mismatch checkout via existing GIT_WRITE_CLIENT +- Data: source of truth is ws-server WorkspaceService; WORKSPACE_QUERY_KEY (ui-owned) + listGitWorktrees (host-keyed via provider) invalidated coherently on mutate +- Cleaned: removed @renderer/trpc/useTRPC from the workspace UI; only the imperative `workspaceApi` (apps host glue for adapters) stays apps-side +- Bridge: apps shims at `features/workspace/hooks/useWorkspace` (re-exports ui hooks + workspaceApi) + `useBranchMismatchDialog` (consumer TaskLogsPanel) — retire when task-detail consumers move +- Validation: ui workspace vitest 21/21; ui+apps typecheck 0 workspace-attributable; biome 0 restricted imports; renderer vite build ✓ + +## 2026-06-02 - task-service-bridge (keystone #1 bridge) + +- Moved: inbox `useDiscussReport`/`useCreatePrReport` apps -> `packages/ui/features/inbox/hooks` (apps paths re-export shims) +- Registered: `TaskServiceBridge` (`@posthog/ui/features/tasks/taskServiceBridge`, createTask/openTask/resolveDefaultModel) + host impl `platform-adapters/task-service-bridge.ts` (wraps renderer TaskService), wired in main.tsx +- Data: `TaskCreationInput`/`TaskCreationOutput` relocated to `@posthog/shared/task-creation-domain` (Task = domain-types Task); renderer TaskCreationSaga re-exports them +- Cleaned: inbox direct-create hooks no longer depend on the renderer TaskService (keystone #1) — they call the bridge +- Bridge: apps task-service-bridge.ts + inbox hook shims remain until the TaskCreationSaga itself lands in core +- Validation: pnpm typecheck 19/19; ui useDiscussReport.test.tsx 2/2; renderer vite build + +## 2026-06-02 — sessions (conversation-rendering tier -> ui) + +- Moved: `apps/code/.../features/sessions/components/{buildConversationItems.ts, mergeConversationItems.ts, session-update/{SessionUpdateView,ToolCallBlock,SubagentToolView}.tsx}` + `utils/extractSearchableText.ts` (~1210L) -> `packages/ui/src/features/sessions/` (old paths are `export *` shims). Colocated tests git-mv'd to ui. +- Registered: `mcpToolBlockSlot.ts` (set/getMcpToolBlock) in ui; host `apps/.../features/sessions/mcpToolBlockHost.ts` registers the app `McpToolBlock` at boot (side-effect import in main.tsx). `ToolCallBlock` renders the slot, falling back to `ToolCallView` when unset. +- Data: the conversation model (`ConversationItem`/`RenderItem`) and its update-rendering are now host-agnostic ui; the live-agent `SessionService` (3848L, host connections) is untouched and still owns event ingestion +- Cleaned: `@posthog/agent` root import -> browser-safe `/acp-extensions` subpath (ui biome rule); `@shared/types/session-events` -> `@posthog/shared` +- Bridge: `McpToolBlock` stays in apps (iframe MCP-app host + `mcpApps` trpc) behind the slot; app `export *` shims remain until `ConversationView`/`SessionView` consume the package paths directly +- Validation: ui+apps typecheck 0 (my files); ui sessions vitest 99/99 (+21 moved); biome clean. Bundle smoke deferred (concurrent task-detail FileTreePanel move left the renderer red — exogenous) + +## 2026-06-02 - settings worktrees + WorkspacesSettings -> ui + +- Moved: settings worktrees subtree (WorktreeSize/Row/GroupSection/WorktreesSettings) + WorkspacesSettings -> `@posthog/ui/features/settings/sections`; useSuspensionSettings -> `@posthog/ui/features/suspension` +- Registered: WORKSPACE_CLIENT +getWorktreeSize/listGitWorktrees/deleteWorktree/confirmDeleteWorktree + worktreesQueryKey provider; SUSPENSION_CLIENT +getSettings/updateSettings; NEW SETTINGS_WORKSPACES_PORT (+ RendererSettingsWorkspacesClient adapter, bound in desktop-services) +- Data: worktrees read keyed by host-provided worktreesQueryKey (coherent with worktreesFilter invalidation); default-directories list on a ui-owned key (sole react-query consumer) +- Bridge: apps shims at WorktreesSettings + WorkspacesSettings (consumer SettingsDialog) — retire when SettingsDialog moves +- Validation: ui workspace+suspension+settings vitest 34/34; ui+apps typecheck 0; biome 0 restricted imports; renderer vite build ✓ + +## 2026-06-02 - workspace boot subscriptions -> WorkspaceEventsContribution + +- Moved: App.tsx inline workspace.onError/onPromoted/onBranchChanged/onLinkedBranchChanged listeners -> `@posthog/ui/features/workspace/workspace-events.contribution` (started by startWorkbench via workspaceUiModule) +- Registered: WORKSPACE_CLIENT +onError/onPromoted/onBranchChanged/onLinkedBranchChanged (TrpcWorkspaceClient adapter); workspace.module.ts binds WORKBENCH_CONTRIBUTION +- Data: host workspace events invalidate WORKSPACE_QUERY_KEY (shared key) so all workspace readers stay in sync; promote/error surface toasts +- Validation: contribution test 4/4; ui+apps typecheck 0; renderer vite build ✓ 13.4s + +## 2026-06-02 - sessions-service-bridge + +- Registered: `SESSION_SERVICE` bridge (`@posthog/ui/features/sessions/sessionServiceBridge`, 13 methods: sendPrompt/config x2/permission x2/cancel/clear/reset/handoffToCloud/retryCloudTaskWatch/retryUnhealthy/shell-exec x2) + host impl `platform-adapters/session-service-bridge.ts` delegating to `getSessionService()`, wired in main.tsx +- Added: `ShellClient.execute()` (one-shot `trpcClient.shell.execute`) to the existing terminal ShellClient port +- Moved: `ModelSelector` + `useSessionCallbacks` -> `@posthog/ui/features/sessions/*` (apps paths re-export shims) +- Cleaned: 2 more `getSessionService()` UI consumers decoupled from the renderer service; this is the keystone-#1 (SessionService) contract the prior notes flagged as the unblock +- Bridge: apps session-service-bridge.ts + shims remain until SessionService is dismantled into core/ws-server +- Validation: ui sessions vitest 14 files / 112 tests; typecheck + biome clean on touched paths + +## 2026-06-02 — focus + agent boot events + +- Moved: `App.tsx` inline `focus.onBranchRenamed`/`focus.onForeignBranchCheckout`/`agent.onAgentFileActivity` subscriptions -> `FocusEventsContribution` + `AgentEventsContribution` (packages/ui/features/{focus,agent}) +- Registered: `FOCUS_EVENTS_CLIENT` + `AGENT_EVENTS_CLIENT` ports; desktop adapters bound in desktop-services; `focusUiModule`/`agentUiModule` in desktop-contributions +- Cleaned: App.tsx no longer registers any workspace/focus/agent subscriptions inline (all three clusters now WORKBENCH_CONTRIBUTIONs); orphaned imports removed +- Validation: ui + apps typecheck clean in touched files; biome lint 0 noRestrictedImports + +## 2026-06-02 — secure-store (router -> backing service) + +- Moved: inline router logic in `apps/code/.../trpc/routers/secure-store.ts` (encrypt/decrypt + electron-store + try/catch) -> new `apps/code/.../services/secure-store/{service.ts,schemas.ts}` `SecureStoreService` +- Registered: `MAIN_TOKENS.SecureStoreService` (`.to(SecureStoreService)`) + `MAIN_TOKENS.SecureStoreBackend` (`.toConstantValue(rendererStore)`); router now one-line zod-validated forwards +- Data: encrypted-at-rest KV store; values machine-key encrypted before touching the backend (never plaintext at rest). SecureStoreBackend is a minimal has/get/set/delete/clear interface so the service is Electron-free and unit-testable +- Cleaned: removed the "tRPC router with no backing service" + "inline business logic in router" forbidden patterns for secure-store +- Validation: apps typecheck 0; service.test.ts 5/5 (node, real crypto + fake backend); biome clean + +## 2026-06-02 - sessions cloudRunOptions -> ui (pure-leaf extraction) + +- Moved: getCloudPrAuthorshipMode/getCloudRunSource/getCloudRuntimeOptions out of the renderer SessionService -> `@posthog/ui/features/sessions/cloudRunOptions` (pure derivations; +test 7/7) +- Data: cloud-run-source / pr-authorship-mode / runtime options derived from host run-state + session config; service keeps the I/O +- Validation: ui sessions vitest 119/119; ui+apps typecheck 0; renderer vite build ✓ 13.5s + +## 2026-06-02 - sessions main view tree -> ui + +- Added: neutral `diffWorkerHost` (`@posthog/ui/workbench/diffWorkerHost`) for the pierre diff Vite worker; reviewHostBindings registers it alongside the review-specific one +- Moved: `useConversationSearch`, `ConversationView` (361L), `SessionView` (716L) -> `@posthog/ui/features/sessions/*` (apps paths are re-export shims) +- Cleaned: ConversationView's `?worker&url` host coupling now flows through the worker host; SessionView's 5 SessionService calls through the SESSION_SERVICE bridge +- Bridge: apps shims remain until the stateful SessionService is dismantled; useSessionConnection still needs `loadLogsOnly`/`watchCloudTask` added to the bridge +- Validation: ui sessions vitest 15 files / 119 tests; typecheck + biome clean on touched paths + +## 2026-06-02 — additional-directories (router -> service, repo-bypass removed) + +- Moved: direct repository access in `apps/code/.../trpc/routers/additional-directories.ts` -> new `packages/workspace-server/src/services/additional-directories/` `AdditionalDirectoriesService` +- Registered: `ADDITIONAL_DIRECTORIES_SERVICE` identifier + `additionalDirectoriesModule`; loaded in the apps container (shares the bound `WORKSPACE_REPOSITORY` + `DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY`) +- Data: the service injects both repos via their ws-server identifiers and owns default (per-device) + per-task additional directories; router is one-line zod-validated forwards +- Cleaned: removed the "router bypasses service to repository" forbidden pattern for additional-directories +- Validation: ws-server typecheck 0 + additional-directories.test.ts 2/2 (fake repos, plain node); apps typecheck 0 (my files); biome clean + +## 2026-06-02 - task-detail leaves -> ui (TaskPendingView / SuggestedTasksPanel / WorkspaceSetupPrompt) + +- Moved: TaskPendingView, SuggestedTasksPanel, WorkspaceSetupPrompt -> `@posthog/ui/features/task-detail/components` +- Registered: WorkspaceSetupPrompt consumes existing FOLDERS_CLIENT.addFolder + GIT_QUERY_CLIENT.detectRepo + useEnsureWorkspace (no new ports) +- Bridge: apps shims at old paths (consumers MainLayout/TaskInput/TaskLogsPanel) — retire when those move +- Validation: ui task-detail vitest 20/20; ui+apps typecheck 0; renderer vite build ✓ 14s + +## 2026-06-02 - command-center data hooks + leaf components -> ui + +- Moved: useCommandCenterData/useAutofillCommandCenter/useAvailableTasks (hooks) + TaskSelector/CommandCenterPRButton (components) -> `@posthog/ui/features/command-center` +- Data: all consume existing ui hooks/stores (tasks/workspaces/archive/sessions/commandCenterStore) + git-interaction PR hooks; no new ports +- Bridge: apps shims at old paths (consumers CommandCenterGrid/View/Panel) — retire when the Panel/Toolbar keystone tier moves +- Validation: ui command-center vitest 6/6; ui+apps typecheck 0; renderer vite build ✓ 13.4s + +## 2026-06-02 - sessions UI surface decoupled from SessionService + +- Extended `SESSION_SERVICE` bridge: +connectToTask/loadLogsOnly/watchCloudTask/recordActivity (+ ConnectParams type) +- Moved: `useSessionConnection` -> `@posthog/ui/features/sessions/hooks` (apps shim) +- Decoupled: `CommandCenterToolbar` cancelPrompt -> bridge (stays in apps/command-center) +- MILESTONE: no renderer UI calls `getSessionService()`; the SessionService is reachable from ui only through the bridges. Remaining direct callers are the bridge adapters, the singleton + tests, and apps-layer orchestration (task-creation saga, localHandoffService, GlobalEventHandlers, desktop-services) +- Validation: ui sessions vitest 15 files / 119 tests; typecheck + biome clean on touched paths + +## 2026-06-02 - panels feature -> ui (cascade) + TaskLogsPanel/TabContentRenderer + +- Moved: TaskLogsPanel, TabContentRenderer (task-detail) + usePanelLayoutHooks, PanelLayout, LeafNodeRenderer (panels) -> @posthog/ui +- Cleaned: apps/panels reduced to index.ts re-export; PanelLayout/usePanelLayoutHooks no longer in apps (ui-panels acceptance) +- Bridge: apps shims at TaskLogsPanel/TabContentRenderer (consumers in task-detail) — retire when TaskDetail moves +- Validation: ui panels+task-detail vitest 62/62; ui+apps typecheck 0; renderer vite build ✓ 13.8s + +## 2026-06-02 — encryption (router -> service) + +- Moved: inline router logic in `apps/code/.../trpc/routers/encryption.ts` (isAvailable + base64 + passthrough fallback + error handling) -> new `apps/code/.../services/encryption/service.ts` `EncryptionService` +- Registered: `MAIN_TOKENS.EncryptionService` (`.to(EncryptionService)`, injects platform `SECURE_STORAGE_SERVICE`); router is one-line zod forwards +- Cleaned: removed "tRPC router with inline business logic" forbidden pattern for encryption +- Validation: service.test.ts 3/3 (fake ISecureStorage); apps typecheck 0 (my files); biome clean + +## 2026-06-02 - ui-settings billing chain + +- Moved: `useSpendAnalysis`, `TokenSpendAnalysisBanner` (393L), `PlanUsageSettings` (509L) -> `@posthog/ui` (apps paths are re-export shims) +- Cleaned: imperative `getAuthenticatedClient` -> `useOptionalAuthenticatedClient`; `UsageBucket` sourced from `@posthog/core/usage/schemas` (ui may import core) instead of `@main/*` +- Validation: ui billing vitest 4 files / 53 tests; typecheck + biome clean on touched paths + +## 2026-06-02 - TaskDetail screen -> ui + FILE_WATCHER_CONTROL port + +- Moved: TaskDetail.tsx (main task-detail screen) -> @posthog/ui/features/task-detail/components; apps @hooks/useFileWatcher orchestration -> @posthog/ui/features/file-watcher/useRepoFileWatcher +- Registered: NEW FILE_WATCHER_CONTROL port (start/stop) + TrpcFileWatcherControl adapter (desktop-services); fs-read invalidation via fsQueryKey provider +- Cleaned: deleted obsolete apps @hooks/useFileWatcher (host trpc now behind the port) +- Bridge: apps TaskDetail shim (consumer MainLayout) — retire when MainLayout moves +- Validation: ui task-detail+file-watcher vitest 20/20; ui+apps typecheck 0; renderer vite build ✓ 13.25s + +## 2026-06-02 - command-center (ui-command leaf) +- Moved: `apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx` -> `packages/ui/src/features/command-center/components/CommandCenterSessionView.tsx` +- Data: pure UI; renders ui SessionView driven by useSessionViewState/useSessionConnection/useSessionCallbacks (all ui) +- Bridge: apps path is a re-export shim; remains until CommandCenterPanel/Grid/View land (gated on task-detail `TaskInput`) +- Validation: `@posthog/ui` + `@posthog/code` typecheck 0; biome clean + +## 2026-06-02 - sessions (core split: model relocation + core seam) + +- Moved: session domain model `apps`/`@posthog/ui` sessionStore types -> `@posthog/shared/src/sessions.ts` (`AgentSession`, `Adapter`, `QueuedMessage`, `OptimisticItem`, `PermissionRequest`, `SessionStatus`, config-option helpers); ui `sessionStore.ts`/`sessionLogTypes.ts` re-export from shared. +- Moved: pure connect-orchestration decisions out of the renderer `SessionService.doConnect` -> `@posthog/core/sessions/connectRouting.ts` (`routeLocalConnect`, `computeAutoRetryFinalState`). +- Data: source of truth for the session model is now `@posthog/shared`; `@posthog/ui` sessionStore is the single runtime store (the divergent apps `stores/sessionStore.ts` duplicate was deleted). +- Cleaned: removed the apps↔ui sessionStore divergence (one model, one store); core now consumes the model directly, enabling future core-owned session orchestration. +- Bridge: `apps/.../platform-adapters/session-service-bridge.ts` (SESSION_SERVICE) remains the seam until the stateful `SessionService` body is split into a core SessionService + ws-server host I/O. +- Validation: `pnpm typecheck` 19/19; renderer `vite build`; core 8/8; ui session 39/39; apps service.test 101/103 (2 pre-existing exogenous cloud-file-reader fails). +- Known: `@posthog/shared` has two divergent `Task` interfaces (`task.ts` vs `domain-types.ts`) — needs a dedicated reconcile slice. + +## 2026-06-02 - task-detail TaskInput keystone + ui-command cascade +- Moved: `apps/.../task-detail/components/TaskInput.tsx` + `hooks/{usePreviewConfig,useTaskCreation}.ts` -> `packages/ui/src/features/task-detail/` +- Moved: `apps/.../command-center/components/{CommandCenterPanel,CommandCenterGrid,CommandCenterView}.tsx` -> `packages/ui/src/features/command-center/components/`; `useAutofillCommandCenter.test.ts` -> ui +- Registered: `PREVIEW_CONFIG_CLIENT` (packages/ui/features/task-detail/previewConfigClient.ts) + `TrpcPreviewConfigClient` adapter bound in desktop-services; added `FOLDERS_CLIENT.getMostRecentlyAccessedRepository`, `WORKSPACE_CLIENT.getWorktreeFileUsage` +- Data: task creation routes through `getTaskServiceBridge()` (keystone-#1 bridge) instead of `get(RENDERER_TOKENS.TaskService)`; preview config + skills + recent-repo + worktree-usage via per-feature client ports +- Cleaned: removed 6 dead apps command-center shims; apps command-center dir now empty (fully ui-resident) +- Bridge: apps `task-detail/components/TaskInput.tsx` is a re-export shim (consumers MainLayout + the now-ui CommandCenterPanel). Retire when MainLayout's task-input view moves (ui-shell/ui-task-detail). +- Validation: @posthog/ui typecheck 0; @posthog/code typecheck 0 in these paths (tree red is exogenous: concurrent sessions/settings/inbox agents); renderer vite build ✓; ui command-center + task-detail vitest green; biome clean + +## 2026-06-02 - git-interaction (slice code-complete) + +- Moved: `apps/code/.../features/git-interaction/components/CloudGitInteractionHeader.tsx` -> `packages/ui/src/features/git-interaction/components/` (last real app file) +- Registered: `LocalHandoffBridge` (`packages/ui/src/features/sessions/localHandoffBridge.ts`); host wires `setLocalHandoffBridge(getLocalHandoffService())` in `apps/code/.../platform-adapters/session-service-bridge.ts` +- Cleaned: retired all 14 git-interaction re-export shims (components/hooks/utils); repointed last consumers (`HeaderRow`, `focusClientAdapter`, `GitInteractionDialogs.stories`) to `@posthog/ui`. apps/code git-interaction now holds only the 2 app-only `*.stories.tsx`. +- Data: source of truth is the git capability in workspace-server (via GIT_QUERY_CLIENT/GIT_WRITE_CLIENT ports); UI is a pure projection +- Bridge: `LocalHandoffBridge` (apps->ui) remains until `LocalHandoffService` (trpc.folders/os + getSessionService) moves to core/ws-server — a sessions-slice concern +- Validation: apps web+node tsc 0; @posthog/ui typecheck 0; ui git-interaction vitest 76/76; renderer `vite build` ✓. Remaining gate: live-GUI stage/commit/switch smoke (needs running Electron; not headless-runnable here) + +## 2026-06-02 - inbox feature -> packages/ui (ui-inbox code-complete) +- Moved: `apps/.../features/inbox/{components/InboxView,InboxSignalsTab,InboxSetupPane,InboxSourcesDialog, components/detail/ReportDetailPane,ReportTaskLogs, hooks/useInboxDeepLink,useInboxDeepLinkListSync, stores/inboxCloudTaskStore}` -> `packages/ui/src/features/inbox/` +- Registered: `DEEP_LINK_CLIENT.getPendingReportLink` + `onOpenReport` (port + deep-link adapter) +- Data: inbox report reads via ported ui hooks; cloud-task creation + default-model via `getTaskServiceBridge()` (createTask/resolveDefaultModel); deep links via `DEEP_LINK_CLIENT`; folders recent-repo via `FOLDERS_CLIENT` +- Cleaned: fixed the SignalSourcesSettings module-not-found red (repointed inbox setup/sources to `@posthog/ui/features/settings/sections`) +- Bridge: apps `inbox/components/InboxView.tsx` + `inbox/hooks/useInboxDeepLink.ts` are re-export shims (consumer MainLayout). Host-stays (by design): `inbox/utils/resolveDefaultModel.ts` (task-service-bridge impl), `inbox/devtools/inboxDemoConsole.ts` (dev console). +- Validation: @posthog/ui typecheck 0; @posthog/code typecheck 0 inbox errors; renderer vite build OK; ui inbox vitest 78/78 + +## 2026-06-02 - sessions (god-object SessionService -> core) + +- Moved: the entire ~3650-line renderer `SessionService` -> `@posthog/core/sessions/sessionService.ts`, behind an injected host-agnostic `SessionServiceDeps` (tRPC port, store port, helper ports, auth/notifier/analytics/toast/log/queryClient/persistedConfig). +- Adapter: `apps/.../sessions/service/service.ts` is now a thin desktop host adapter (`buildSessionServiceDeps()` + `getSessionService()`), wiring `trpcClient` + `@posthog/ui` stores + host helpers; re-exports `SessionService` + `ConnectParams`. +- Data: orchestration is now host-agnostic core; host I/O is injected via ports (tRPC -> main process, stores -> `@posthog/ui`). `Task`/`ConnectParams` use `@posthog/shared/domain-types` (the app's live Task shape). +- Cleaned: the canonical "renderer service owns all the orchestration" forbidden pattern is removed — the renderer no longer contains the SessionService logic, only the singleton + deps wiring. +- Bridge: `platform-adapters/session-service-bridge.ts` (SESSION_SERVICE) + `sessionTaskBridgeAdapter.ts` remain in apps by design (host wiring); `getSessionService()` singleton stays in the adapter. +- Validation: `@posthog/core` + apps typecheck 0 (my paths); `service.test.ts` 101/103 (identical pre/post-move; 2 exogenous cloud-file-reader fails); biome clean. Live agent-turn smoke pending (can't run headless) -> slice `needs_validation`. + +## 2026-06-02 - ui-shell layout + boot architecture (ui-shell -> needs_validation) +- Moved: `apps/.../components/{HeaderRow,MainLayout}.tsx` -> `packages/ui/src/workbench/` +- Registered: `WORKSPACE_CLIENT.reconcileCloudWorkspaces` (port + adapter); host `AnalyticsBootContribution` + `InboxDemoDevContribution` (apps/.../contributions/app-boot.contributions.ts) bound in desktop-contributions +- Data: MainLayout cloud-reconcile via WORKSPACE_CLIENT; analytics-init + dev-inbox-console now WORKBENCH_CONTRIBUTIONs (App.tsx has zero inline initializers) +- Cleaned: lifted GlobalEventHandlers (host glue) out of MainLayout to the App root; deleted dead HeaderRow apps shim +- Bridge/host-stays (correct end-state): App.tsx (auth-gate root), GlobalEventHandlers, Providers, main.tsx, ErrorBoundary wrapper. apps MainLayout.tsx is a shim (consumer App). +- Outstanding: live Electron boot smoke; acceptance #4 (TanStack route contributions) doesn't match the navigationStore view-switch routing model — flagged for re-scoping. +- Validation: @posthog/ui typecheck 0; @posthog/code typecheck 0 (modulo exogenous service.ts); renderer vite build OK; biome clean + +## 2026-06-02 - ui-sidebar drained (-> needs_validation) +- Moved: `apps/.../sidebar/hooks/useTaskPrStatus.test.ts` -> `packages/ui/src/features/sidebar/useTaskPrStatus.test.ts` (mock repointed @renderer/trpc -> @posthog/di/react useService) +- Deleted: `apps/.../features/panels/index.ts` (dead re-export barrel, zero consumers) +- Result: apps features/{sidebar,right-sidebar,panels} have zero real files; all ui-resident +- Validation: ui + apps typecheck 0; ui useTaskPrStatus test 8/8; renderer vite build OK; biome clean + +## 2026-06-03 - retire session/task bridges (cleanup) +- Moved: nothing new; this collapses three module-setter bridges now that consumers resolve real services via DI. +- Registered: consumers use `useService(SESSION_SERVICE|TASK_SERVICE|PREVIEW_CONFIG_CLIENT)` (React) and `resolveService(SESSION_SERVICE)` (imperative) directly; no bridge indirection. +- Cleaned: + - Deleted bridges `packages/ui/.../sessions/sessionServiceBridge.ts`, `sessions/sessionTaskBridge.ts`, `tasks/taskServiceBridge.ts` and the `sessionServiceBridge.test.ts` (it only covered the deleted setX/getX singleton — 2 tests removed, no real coverage lost). + - Deleted apps adapters `platform-adapters/task-service-bridge.ts` and `features/sessions/sessionTaskBridgeAdapter.ts` (sole purpose was setX wiring); removed their boot imports from `renderer/main.tsx`. + - Stripped the `sessionServiceBridge` object + `setSessionServiceBridge` wiring (and now-unused imports) from `platform-adapters/session-service-bridge.ts`; the file now only registers `LocalHandoffBridge` (that bridge stays). + - Repointed 5 affected test files off the dead bridge `vi.mock`s onto `@posthog/di/react` `useService` / `@posthog/di/container` `resolveService` mocks (useTaskMutations, useChatTitleGenerator, useTaskDeepLink, useArchiveTask, useDiscussReport) plus taskCreationSaga. +- Bridges left intact by design: `getArchiveTaskBridge`, `getTaskMutationBridge`, `getLocalHandoffBridge`. +- Validation: `@posthog/core`/`@posthog/ui`/`@posthog/code` typecheck 0; ui vitest `sessions tasks task-detail inbox archive command-center` 910/910 (was 891 pass / 21 fail before repointing the test mocks); biome clean on all touched files (broader tree has unrelated exogenous errors from other in-flight agents). + +## 2026-06-03 - retire LocalHandoffBridge (cleanup) +- Moved: nothing new; `LocalHandoffService` already lives in `packages/ui/.../sessions/localHandoffService.ts` bound to `LOCAL_HANDOFF_SERVICE` in the renderer DI container. +- Repointed: `CloudGitInteractionHeader.tsx` now resolves the service via `useService(LOCAL_HANDOFF_SERVICE)` instead of `getLocalHandoffBridge()`; all five method calls (start/resumePending/openConfirm/cancelPendingFlow/hideDirtyTree) are identical on the ported service. +- Cleaned: + - Deleted bridge `packages/ui/.../sessions/localHandoffBridge.ts` (setX/getX module-setter, sole consumer was CloudGitInteractionHeader). + - Deleted the unported apps service `apps/.../features/sessions/service/localHandoffService.ts` (superseded by the ui service + `getLocalHandoffHost` port). + - Deleted apps adapter `platform-adapters/session-service-bridge.ts` (its sole remaining job was the LocalHandoffBridge setX wiring) and removed its boot import from `renderer/main.tsx`. +- No bridge tests existed for localHandoff; nothing to repoint. +- Validation: `@posthog/core`/`@posthog/ui`/`@posthog/code` typecheck 0 (my paths); biome clean on touched files; ui vitest `sessions git-interaction` reported below. + +## 2026-06-03 - retire task-mutation & archive-task bridges (cleanup) +- Moved: nothing new; consumers `useDeleteTask`/`useCreateTask` (`tasks/useTaskCrudMutations.ts`) and `archiveTaskImperative`/`useArchiveTask` (`archive/useArchiveTask.ts`) already resolve real DI: `useService(WORKSPACE_CLIENT)` / `resolveService(WORKSPACE_CLIENT)`, `useHostTRPCClient().contextMenu.confirmDeleteTask`, `resolveService(ARCHIVE_CLIENT).archive`, `resolveService(SESSION_SERVICE).disconnectFromTask`, and `pinnedTasksApi`. +- Cleaned: + - Deleted bridges `packages/ui/.../tasks/taskMutationBridge.ts`, `packages/ui/.../archive/archiveTaskBridge.ts` (setX/getX module-setters with no remaining `getX` consumers). + - Deleted apps adapters `platform-adapters/task-mutation-bridge.ts`, `platform-adapters/archive-task-bridge.ts` (sole purpose was setX wiring); removed their two side-effect boot imports from `renderer/main.tsx`. + - Repointed `tasks/useTaskCrudMutations.test.tsx` and `archive/useArchiveTask.test.ts` off the dead bridge `vi.mock`s onto the real ports: `@posthog/di/react` `useService`, `@posthog/di/container` `resolveService` (routed by token), `@posthog/host-router/react` `useHostTRPCClient`, and `@posthog/ui/features/sidebar/taskMetaApi` `pinnedTasksApi`. Coverage preserved (confirm/decline delete paths; optimistic-add + rollback/re-pin archive paths). +- Left intact by design: apps imperative `workspaceApi` (`features/workspace/hooks/useWorkspace.ts`) — not a bridge, has 4 other apps-internal consumers. +- Validation: see structured report. + +## 2026-06-03 - retire 6 ready useService ports (useHostTRPC migration) +- Context: every real consumer of these ports already calls `useHostTRPC`/`useHostTRPCClient` against the host-router; only the dead port interface + adapter + DI binding remained. Retired the scaffolding. +- Retired ports/tokens: `ARCHIVE_CLIENT`, `AUTH_CLIENT`, `FILE_CONTEXT_MENU_CLIENT`, `PANEL_CONTEXT_MENU_CLIENT`, `SETTINGS_GENERAL_PORT`, `TASK_CONTEXT_MENU_CLIENT`. +- Deleted (ui): `features/archive/ports.ts`, `features/archive/archiveCacheProvider.ts`, `features/sessions/fileContextMenuClient.ts`, `features/panels/panelContextMenuClient.ts`, `features/tasks/taskContextMenuClient.ts`. +- Deleted (apps): `platform-adapters/{archive-client,archive-cache-keys,file-context-menu-client,panel-context-menu-client,task-context-menu-client,settings-general-client}.ts`. `auth-client.ts` was already removed (token no longer existed; `auth/ports.ts` keeps only `AUTH_SIDE_EFFECTS`). +- Edited (not deleted): `features/settings/ports.ts` — stripped `SettingsGeneralPort`/`SETTINGS_GENERAL_PORT` only; `SettingsWorkspacesPort`/`SETTINGS_WORKSPACES_PORT` stay (blocked port, still bound). +- Repointed: + - `archive/useArchiveTask.ts`: replaced the `archiveCacheProvider` host-set cache-key indirection with a `useArchiveCacheKeys()` hook that derives the real keys off `useHostTRPC()` (`trpc.archive.{archivedTaskIds,list}.queryKey()`, `trpc.archive.pathFilter().queryKey`); `archiveTaskImperative`/`archiveTasksImperative` now take an `ArchiveCacheKeys` param. `SidebarMenu.tsx` derives keys via the hook and threads them into the two `archiveTasksImperative` calls. + - `sessions/components/useFileContextMenu.ts`: inlined the `OpenFileContextMenuInput` type (its sole non-adapter consumer) so the dead `fileContextMenuClient.ts` could be deleted. + - `apps/.../features/auth/hooks/authQueries.ts`: dropped a stale PORT NOTE referencing the retired `AUTH_CLIENT`. +- Cleaned (apps `renderer/desktop-services.ts`): removed the 5 port imports, 5 adapter imports, the `setArchiveCacheKeys(...)` boot call, and the 5 container bindings. +- Tests: rewrote `archive/useArchiveTask.test.ts` — dropped the `./ports`(ARCHIVE_CLIENT) + `archiveCacheProvider` mocks; now mocks `@posthog/host-router/client` `HOST_TRPC_CLIENT` (routed through `resolveService` to `{ archive: { archive: { mutate } } }`), passes `ArchiveCacheKeys` as a param, and asserts `mutate({ taskId })`. Coverage preserved (optimistic add + rollback/re-pin). This test was already red in-tree (consumer had moved to `HOST_TRPC_CLIENT` but the test still mocked the old token); now green. +- Validation: `@posthog/ui` typecheck 0; `@posthog/code` typecheck 0; biome clean on all touched files; `@posthog/ui` test 910/910. + +## 2026-06-03 - retire 14 useService ports (useHostTRPC migration, wave 2) +- Context: every real consumer already calls `useHostTRPC`/`useHostTRPCClient` against the host-router; only the dead port interface + adapter + DI binding remained. Retired the scaffolding. +- Retired tokens: `AGENT_EVENTS_CLIENT`, `FILE_CONTENT_CLIENT`, `FOCUS_EVENTS_CLIENT`, `GIT_QUERY_CLIENT`, `GIT_WRITE_CLIENT`, `GITHUB_INTEGRATION_CLIENT`, `LINEAR_INTEGRATION_CLIENT`, `PREVIEW_CONFIG_CLIENT`, `REPO_FILES_CLIENT`, `REVIEW_FILE_CLIENT`, `SETTINGS_WORKSPACES_PORT`, `SIDEBAR_TASK_META_CLIENT`, `SLACK_INTEGRATION_CLIENT`, `WORKSPACE_CLIENT`. +- Deleted whole (ui port files, all tokens retired): `features/agent/agentEventsClient.ts`, `features/focus/focusEventsClient.ts`, `features/code-editor/ports.ts`, `features/code-review/ports.ts`, `features/repo-files/ports.ts`, `features/task-detail/previewConfigClient.ts`, `features/integrations/ports.ts` (github+slack+linear trio), `features/settings/ports.ts` (sole token SETTINGS_WORKSPACES_PORT), `features/workspace/workspaceCacheProvider.ts` (its only live consumer WorktreesSettings now derives keys off `useHostTRPC().workspace.listGitWorktrees.queryKey/queryFilter`). +- Stripped (ui port files with shared survivors): `features/git-interaction/ports.ts` — removed `GitQueryClient`/`GitWriteClient` + `GIT_QUERY_CLIENT`/`GIT_WRITE_CLIENT` + now-unused `GithubRef`/`PrActionType`/`PrReviewThread`/`GitBusyState` imports; kept all git domain types (GitStateSnapshot/CreatePrStep/CommitResult/...PrDetails) still consumed across the git tier. `features/sidebar/ports.ts` — removed `SidebarTaskMetaClient` + `SIDEBAR_TASK_META_CLIENT`; kept `SidebarPrState`/`TaskPrStatus`/`RawTaskTimestamp`. `features/workspace/ports.ts` — removed `WorkspaceClient`/`WORKSPACE_CLIENT` + adapter-only types `CreateWorkspaceInput`/`GitWorktreeEntry`/`WorkspaceWarning`; kept `WORKSPACE_QUERY_KEY`. +- Deleted (apps adapters): `platform-adapters/{agent-events-client,focus-events-client,file-content-client,review-file-client,repo-files-client,preview-config-client,github-integration-client,slack-integration-client,linear-integration-client,settings-workspaces-client,sidebar-task-meta-client,workspace-client,git-query-client,git-write-client,workspace-cache-keys}.ts`. +- Repointed (ui): `git-interaction/utils/branchCreation.ts` + `useGitInteraction.ts` + `task-detail/components/TaskInput.tsx` dropped the `GitWriteClient` type (branchCreation now takes a local structural `BranchCreator`; TaskInput memoizes its inline createBranch client). `WorktreesSettings.tsx` derives the worktrees query key/filter off `useHostTRPC()`. +- Cleaned (apps `renderer/desktop-services.ts`): removed all 14 port imports + 14 adapter imports + the `setWorkspaceCacheKeyProvider(...)` boot call + the 14 container bindings (kept `setGitCacheKeyProvider` + the git-cache-keys adapter — shared git working-tree/branch invalidation infra still consumed by the git/code-review/file-watcher tiers — and `setTaskMetaApi`, taskMetaApi survives). Dropped stale PORT NOTE comments in `apps/.../features/workspace/hooks/useWorkspace.ts` and `features/sidebar/taskMetaApi.ts`. +- Tests: repointed 7 test files off the retired-port `vi.mock("@posthog/di/react")`/`workspaceCacheProvider` mocks onto `useHostTRPC`/`useHostTRPCClient` mocks: useTaskPrStatus, BranchSelector, useBranchMismatchDialog, branchCreation, useWorkspaceMutations, useSuspendTask, useDiscussReport, plus workspace-events.contribution (host-router subscription shape). Coverage preserved; these were already red in-tree (consumers had migrated). Full ui suite 96 files / 910 tests pass (was 879 pass / 31 fail). +- Validation: `@posthog/host-router`/`@posthog/core`/`@posthog/ui`/`@posthog/code` typecheck 0; biome clean on all touched files; `@posthog/ui` test 910/910. + +## 2026-06-03 - opus-handoff-syscalls - handoff host syscalls -> workspace-server (acceptance #2) +- Context: the handoff orchestration sagas already live in `@posthog/core/handoff`, but the apps `HandoffService` deps-provider still performed raw host syscalls (`node:fs` on `~/.posthog-code/sessions//logs.ndjson` + `@posthog/git` stash/reset sagas). Moved those into workspace-server so the deps-provider is pure wiring. +- FS -> ws-server `LocalLogsService` (`packages/workspace-server/src/services/local-logs/service.ts`): added `seedLocalLogs`/`countLocalLogEntries`/`deleteLocalLogCache`, reusing its existing `getLocalLogPath` so the NDJSON path is owned in one place. Exposed as `localLogs.seed` (mutation) / `localLogs.count` (query) / `localLogs.delete` (mutation) in `trpc.ts`. Main thin-client (`apps/code/src/main/services/local-logs/service.ts`) gained 3 delegating methods; its PORT NOTE's "handoff stops writing the NDJSON via raw fs" retirement clause is now satisfied. +- GIT -> ws-server `GitService` (`packages/workspace-server/src/services/git/service.ts`): added `readHandoffLocalGitState` (wraps `@posthog/git/handoff`) + `cleanupAfterCloudHandoff` (StashPushSaga + ResetToDefaultBranchSaga, returns `{stashed,switched,defaultBranch}`). Exposed as `git.readHandoffLocalGitState` (query) + `git.cleanupAfterCloudHandoff` (mutation). `handoffLocalGitStateSchema` mirrored locally in ws git `schemas.ts` (ws zod v4; nullable strings; structurally assignable to `AgentTypes.HandoffLocalGitState`). +- `HandoffService`: now injects `MAIN_TOKENS.LocalLogsService` + `MAIN_TOKENS.WorkspaceClient` and delegates; dropped all `node:fs`/`node:os`/`node:path` imports and the `@posthog/git` runtime imports (`readHandoffLocalGitState`/`StashPushSaga`/`ResetToDefaultBranchSaga`) — only a type-only `GitHandoffBranchDivergence` import remains. The cloud-log `fetch` stays in the provider (network, not a host syscall); only the fs write/read/rm moved. +- Core: `HandoffToCloudSagaDeps.countLocalLogEntries` changed `number -> Promise` (now an async tRPC call); saga awaits it; test mocks updated to `mockResolvedValue`. +- Validation: full `pnpm typecheck` 21/21; ws-server `local-logs` 17/17 (added seed/count/delete tests); `@posthog/core` handoff 16/16; apps `handoff/service.test` 6/6 (constructor +2 args; `localGitState` now driven via the `workspaceClient.git` mock); core purity gate `biome lint packages/core/src/handoff` 0 noRestrictedImports; biome check clean. ws-server consumed from src (no dist build). NOT run: live end-to-end handoff GUI smoke (real cloud run + GitHub auth + Electron; env-gated headless) — the sole remaining gate before `handoff` flips to passing. + +## 2026-06-03 - opus-handoff-syscalls - HandoffService fully out of apps/code (core + workspace-server) +- Follow-up to the host-syscalls move: the **entire** HandoffService is now gone from `apps/code`. Orchestration lives in core, host I/O in workspace-server, the port contract in shared, and the desktop keeps only a thin transport adapter + DI wiring. +- **core** `@posthog/core/handoff/handoff.ts` (`HandoffService`): preflight/execute/preflightToCloud/executeToCloud + `extractHandoffErrorCode` + both saga constructions + `closeCloudRun` (via `CLOUD_TASK_SERVICE`). Injects a single `HANDOFF_HOST` port (from shared) + `CLOUD_TASK_SERVICE` + `HANDOFF_LOGGER`. `handoff.module` binds `HANDOFF_SERVICE`. Schemas moved to `@posthog/core/handoff/schemas` (`handoffLocalGitStateSchema` defined locally instead of importing `@posthog/agent/server/schemas`). Purity gate clean. +- **workspace-server** `services/handoff/service.ts` (`HandoffHostService implements HandoffHost`): owns ALL the host business logic — agent api client construction, `HandoffCheckpointTracker` capture/apply, `resumeFromLog`, `formatConversationForResume`, the `GIT_CHECKPOINT` notification append, workspace/repository repo orchestration (`attachWorkspaceToFolder` revert, `updateWorkspaceMode`), and the diverged-branch confirmation dialog. Injects ws `AgentService`/`AgentAuthAdapter` + `WORKSPACE_REPOSITORY`/`REPOSITORY_REPOSITORY` + platform `DIALOG_SERVICE`/`APP_LIFECYCLE_SERVICE` + two narrow gateways (`HANDOFF_GIT_GATEWAY`/`HANDOFF_LOG_GATEWAY`) for the child-process git/log syscalls. +- **shared** `handoff-host.ts`: the `HandoffHost` port contract (+ `HandoffApiContext`/`HandoffChangedFile`/`HandoffReconnectParams`/`HandoffResumeStateResult`), so core and workspace-server reference it without importing each other. +- **apps/code** keeps only: `services/handoff/git-gateway.ts` (`TrpcHandoffGitGateway` — a ~50-line desktop tRPC adapter over `workspaceClient.git`), `HANDOFF_LOG_GATEWAY` bound to the existing local-logs thin client, the one-line handoff router (now imports core schemas + injects `HANDOFF_SERVICE`), and the DI bindings. Deleted `services/handoff/{service,schemas,service.test}.ts`. Retired `MAIN_TOKENS.HandoffService`. No agent runtime, checkpoint, saga, or orchestration code remains in apps/code. +- Validation: full `pnpm typecheck` 21/21; `@posthog/core` handoff 22/22; ws-server handoff host 8/8 + local-logs 17/17; core purity gate 0 noRestrictedImports; biome clean; shared dist rebuilt. NOT run: live end-to-end handoff GUI smoke (env-gated). diff --git a/PORTING.md b/PORTING.md new file mode 100644 index 0000000000..52f2b105ab --- /dev/null +++ b/PORTING.md @@ -0,0 +1,214 @@ +# PORTING.md — thin UI, thick core + +The playbook for porting a feature so its **business logic is portable** (runs on +web/mobile, not just Electron) and its **UI is a thin shell**. Patterns validated on +`connectivity` and matched against the existing `git` / `focus` / `sessions` / `billing` code. + +If anything contradicts [AGENTS.md](./AGENTS.md) / [CLAUDE.md](./CLAUDE.md), those win on +layering. For multi-agent coordination use [REFACTOR.md](./REFACTOR.md) + `REFACTOR_SLICES.json`. + +--- + +## Port a feature by answering three questions + +Most mistakes come from skipping these or conflating them (we built `connectivity` ~3 ways +before getting it right). Answer them in order. + +### Q1 — Where does the data / host access come from? *(picks the wiring)* + +- **a. ws-server backend** — git, fs, process, the connectivity probe. + → A **core service that injects the workspace client** and calls ws-server; bound in the + **main process**, reached from the renderer over tRPC. *(see `git`, `focus`)* +- **b. PostHog cloud API** — tasks, billing, projects, anything on the Django API. + → A **core service / functions using `@posthog/api-client`**. Portable anywhere there's an + HTTP client; no host capability needed. *(see `billing`, `projects`)* +- **c. Client-local host capability** — clipboard, dialog, OS notifications, `navigator.onLine`. + → A **`@posthog/platform` interface + per-host adapter**. *(see `clipboard`)* + +> ws-client injection (a) is **main-process only** — the renderer's ws-client is built inside +> React (`Providers.tsx`), connection-dependent, and can drop. Don't inject it into a +> renderer-resident service. + +### Q2 — Is there real logic? → **make a service.** *(this is the "thick core")* + +**The logic of a feature lives in a service — an `@injectable` class in `@posthog/core`.** +Not in components, not in hooks, not in stores. + +A service: +- holds business logic — orchestration, retries, dedup, rules, transforms, sagas; +- **injects** its dependencies (workspace client, `api-client`, platform interfaces, other services); +- has **no React, no JSX**; it may read/write a store but **is not** a store; +- is the thing web/mobile reuse unchanged. + +When **not** to make one: a feature with no real logic — a value streamed from the backend +into a store, a one-line passthrough — does **not** get a service. `connectivity` is one +boolean fed by a subscription, so it's a store + host glue, **no service**. Don't manufacture a +`FooService` for a 1-field feature; that was the connectivity over-engineering. + +> Rule of thumb: if you can't name an algorithm/decision the service makes, you don't need one. + +### Q3 — Where does the state live? → **a store, on the correct side.** + +A store is a **state cell** (zustand): holds state, **no logic / async / `trpcClient`**. + +- **Domain state of record** — a *fact* business logic reads (`isOnline` drives `sessions` + retries) → **`@posthog/core`, `zustand/vanilla`**. Fed by a service or host glue; observed + by UI and core. +- **Pure view state** — scroll position, open panel, draft text, selection → **`@posthog/ui`, + `zustand`** (`create`). + +Components read via selectors; a hook re-bundles for ergonomics (`createSelectors` → +`store.use.field()`). + +--- + +## Layers + +| Package | Owns | Never contains | +|---|---|---| +| `@posthog/platform` | Host-capability **interfaces** + tokens. Host-neutral. | Implementations, Node, DOM, tRPC, Electron | +| `@posthog/workspace-server` | Node backend services + their tRPC. | UI, core, Electron | +| `@posthog/api-client` | PostHog/Django HTTP client. | UI, Node-only host syscalls | +| `@posthog/core` | Portable **services** + domain types + **domain stores**. Injects workspace client / api-client / platform interfaces. | React, `trpcClient`, Node syscalls, Electron, host-router types | +| `@posthog/ui` | React glue: components, hooks, contributions, **view-state stores**. | Business logic, `trpcClient`, Node | +| `apps/code` | Electron lifecycle + **platform adapters** + tRPC routers + DI wiring. | Business logic | + +`apps/code/src/main/platform-adapters/` — capabilities **main** consumes. +`apps/code/src/renderer/platform-adapters/` — capabilities the **renderer** consumes (wrap `trpcClient`). + +--- + +## Skeletons + +### Service (Q2) — the logic, injectable, in core + +```ts +// @posthog/core//.ts +@injectable() +export class FeatureService { + constructor( + @inject(FEATURE_WORKSPACE_CLIENT) private readonly ws: FeatureWorkspaceClient, // Q1a + // or @inject(API_CLIENT) private readonly api: PostHogApiClient, // Q1b + // or @inject(THING_SERVICE) private readonly thing: IThing, // Q1c + ) {} + async doThing() { /* orchestration, rules, retries — the actual logic */ } +} +``` + +### Q1a — ws-server backend (`git` / `focus`) + +Core declares a **narrow slice** of the workspace client and injects it; bound in main with the +real client; exposed to the renderer via a host-router tRPC router. + +```ts +// @posthog/core//identifiers.ts +import type { WorkspaceClient } from "@posthog/workspace-client/client"; +export interface FeatureWorkspaceClient { feature: WorkspaceClient["feature"]; } +export const FEATURE_SERVICE = Symbol.for("posthog.core.featureService"); +export const FEATURE_WORKSPACE_CLIENT = Symbol.for("posthog.core.featureWorkspaceClient"); +``` +```ts +// apps/code/src/main/index.ts (composition — the real client; cloud client on web) +container.bind(MAIN_TOKENS.FeatureService).toConstantValue(new FeatureService(workspaceClient)); +container.bind(FEATURE_SERVICE).toService(MAIN_TOKENS.FeatureService); +``` + +### Q1c — client-local capability (`clipboard`) + +```ts +// @posthog/platform/src/.ts host-neutral: onDidChange(listener): () => void +export interface IThing { read(): Promise; onDidChange(l: (v: T) => void): () => void; } +export const THING_SERVICE = Symbol.for("posthog.platform.thing"); +``` +```ts +// apps/code/src/{main,renderer}/platform-adapters/.ts +container.bind(THING_SERVICE).toConstantValue(electronImpl); +``` +**Never** put tRPC `{ onData, onError }` / `{ unsubscribe }` shapes in the interface — translate +them in the adapter. Platform ships built `dist/`: a new file needs `src/.ts` + a +`tsup.config.ts` entry + a `package.json` export + `pnpm --filter @posthog/platform build`. + +### Streamed state, no service (`connectivity`) — Q1a data + Q3 domain store + +```ts +// @posthog/core//Store.ts (domain fact → core, vanilla) +import { createStore } from "zustand/vanilla"; +export const featureStore = createStore<{ value: T; setValue: (v: T) => void }>((set) => ({ + value: initial, setValue: (value) => set({ value }), +})); +export const getValue = () => featureStore.getState().value; +``` +```ts +// apps/code/src/renderer/platform-adapters/.ts (host glue — the ONLY trpcClient touch) +import { featureStore } from "@posthog/core//Store"; +import { trpcClient } from "@renderer/trpc/client"; +const { setValue } = featureStore.getState(); +void trpcClient.feature.get.query().then(setValue).catch(() => undefined); +trpcClient.feature.onChange.subscribe(undefined, { onData: setValue }); +``` +```ts +// @posthog/ui/hooks/useFeature.ts (read via auto-selectors) +import { featureStore } from "@posthog/core//Store"; +import { createSelectors } from "./createSelectors"; +const feature = createSelectors(featureStore); +export const useFeature = () => ({ value: feature.use.value() }); +``` +A core consumer (e.g. `sessions`' `getIsOnline`) imports the store's `getValue` getter directly. + +--- + +## DI + +- **Plain Inversify.** Interface + `Symbol.for` token in the owning package; constructor `@inject(TOKEN)`; bind in the feature's `.module.ts` (ui/core) or `index.ts` composition (main). +- **Never call them "ports."** These are **interfaces** — name them as such (`IThing` / `FeatureWorkspaceClient`), not `FooPort` / `FOO_PORT` / `ports.ts`. The existing `*_PORT` tokens, `*Port` types, and `ports.ts` files are legacy; new code uses "interface", and rename old ones when you touch them. +- **Do NOT use `@inversifyjs/binding-decorators` (`@provide`) or `@inversifyjs/strongly-typed`.** Tried both on `connectivity`, removed them — `@provide`'s side-effect-import is a footgun, `strongly-typed`'s binding-map is pure tax. +- **`resolveService` is a service-locator smell.** Constructor-inject in services; `useService(TOKEN)` at the React boundary only. `resolveService` is tolerated only in host composition seams (`apps/`). + +--- + +## Anti-patterns (removed this session — do not reintroduce) + +| Anti-pattern | Fix | +|---|---| +| A platform interface for **backend** data | Q1a/Q1b — workspace client / api-client | +| A **service** for a trivial passthrough (1 field, no logic) | Q3 store + host glue, no service | +| A **domain** store in `@posthog/ui` | Domain facts → core (`zustand/vanilla`) | +| A renderer-resident core service injecting the workspace client | ws-client is React-bound/fragile; stream into a core store | +| Bespoke `IFeatureClient` that 1:1 wraps `trpcClient.x` | Use the real client / `HOST_TRPC_CLIENT` | +| Per-feature `FeatureLogger` interface + token | Generic `logger.scope` (UI) / shared logger (core) | +| `@inversifyjs/strongly-typed` + a `Deps` map | `@inject(TOKEN)` | +| Separate `IFeatureService` interface for one impl | Inject the concrete class | +| `{ onData, onError }` / `{ unsubscribe }` in a platform interface | `onDidChange(listener): () => void` | +| Logic / async in a Zustand store action | A service (Q2); the store does only `set` | +| `trpcClient` imported in `@posthog/ui` | host glue in `apps/` feeds the store | +| Adapter in a `features//` folder | `apps/code/.../platform-adapters/.ts` | +| A bridge mirroring a service that mirrors another service | collapse it; each consumer caches what it needs | + +--- + +## Validation gates + +```sh +pnpm typecheck # all packages green +pnpm exec biome lint packages/core/src/ # core purity: zero noRestrictedImports +pnpm --filter exec vitest run src/ # unit tests (services test with fake deps) +pnpm biome check --write # format +``` +- Touched a `@posthog/platform` interface (Q1c)? **rebuild platform dist** or typecheck lies. +- Moved a service/store to core? the **core purity gate** must be clean (no Node/Electron/React/`trpcClient`). +- Repoint test mocks to the new import specifier (a shim hides the break from typecheck). +- Renderer `vite build` is the cheap runtime smoke when DI/boot wiring changed. + +--- + +## Reference: `connectivity` (streamed domain state, no service) + +``` +@posthog/core/connectivity/connectivityStore.ts domain store { isOnline } + getIsOnline (vanilla) +@posthog/ui/features/connectivity/connectivityToast.ts subscribes the core store → offline toast +@posthog/ui/hooks/useConnectivity.ts reads the core store via createSelectors +apps/code/src/renderer/platform-adapters/connectivity.ts host glue: trpc subscription → core store + toast +``` +`sessions` imports `getIsOnline` from the core store. The probe lives in `workspace-server`'s +connectivity service, served over tRPC. No core service (nothing to orchestrate), no platform +interface, no per-feature DI — the store *is* in core because `isOnline` is a domain fact. diff --git a/REFACTOR.md b/REFACTOR.md index 02d8cbf491..92775ef2c0 100644 --- a/REFACTOR.md +++ b/REFACTOR.md @@ -122,10 +122,26 @@ The entrypoint chooses the runtime. Packages own the feature wiring. ## Agent Harness -This migration will be worked by many agents across many context windows. Treat -the repo like a handoff between engineers on shifts: every agent must be able to -arrive cold, understand what is already done, choose one slice, and leave the -next agent a clean state. +This migration is worked by many agents running concurrently in the **same +single working tree** across many context windows. Agents never stop after one +slice and never hand off: each agent claims a slice, ports it, then immediately +claims the next, and keeps going until it runs out of context. Treat the repo +as a shared live workspace that any agent can arrive cold to, understand from +the coordination files, and continue from. + +**Non-negotiable working rules for every agent:** + +- **Never stop.** Finishing a slice is not a stopping point. The instant a slice + is validated, claim the next highest-priority `todo` and continue. Only stop + when out of context. +- **Never commit.** Do not run `git commit`, `git add` for a commit, or create + branches. All work stays as uncommitted edits in the shared working tree. The + coordination files below are the synchronization mechanism, not git history. +- **Never use git worktrees.** Every agent works in the one main working tree. + Do not create, switch to, or prefer separate worktrees or branches. +- **Collaborate, don't isolate.** Other agents are editing the same files at the + same time. Conflict risk is never a reason to stop or to avoid a slice. Make + your edits, keep the tree typechecking, and keep moving. Set up three coordination artifacts before broad parallel work starts: @@ -199,34 +215,37 @@ Every agent session starts the same way: 7. Claim exactly one `todo` slice by setting it to `in_progress` with your agent/session id. -### Agent Finish Protocol +### Per-Slice Wrap-Up (then immediately continue) -Every agent session ends by leaving the repo in a clean handoff state: +When a slice's code is done, do this and then **claim the next slice without +stopping** — this is a loop, not the end of a session: 1. Run focused tests/typecheck for the slice. 2. Run the relevant smoke test as a user would, not just a unit-level substitute. -3. Update `REFACTOR_SLICES.json`. +3. Run `pnpm biome format --write .` and `pnpm typecheck` so the shared tree + stays green for the other agents working in it. +4. Update `REFACTOR_SLICES.json`. - Set `passes: true` only when acceptance checks actually passed. - Use `needs_validation` if code is done but the feature was not exercised. - Use `blocked` with a concrete reason if progress cannot continue. -4. Append a short `REFACTOR_PROGRESS.md` entry: slice id, changed paths, +5. Append a short `REFACTOR_PROGRESS.md` entry: slice id, changed paths, validation run, remaining bridges, and next suggested slice. -5. Update `MIGRATION.md` for landed architectural movement. -6. Leave no unrelated edits in files outside the claimed slice. -7. Before committing, run `pnpm biome format --write .` and `pnpm typecheck`, - then stage the result. Biome owns formatting for every file including - `REFACTOR_SLICES.json` — commit the formatted version so CI does not bounce - it. Never bypass commit hooks with `--no-verify`. -8. If the harness expects commits, commit the slice with a descriptive message - only after the worktree is coherent and validation is recorded. +6. Update `MIGRATION.md` for landed architectural movement. +7. **Do not commit.** Leave everything as uncommitted edits in the shared tree. +8. Re-read `REFACTOR_SLICES.json`, claim the next highest-priority unclaimed + `todo`, and start again. Keep going until out of context. ### Parallel Work Rules -- One agent owns one slice. Do not work a broad foundational refactor unless it - is explicitly assigned. -- Prefer separate git worktrees/branches per agent. Parallel edits to the same - package registration files, root DI files, or `REFACTOR_SLICES.json` will - conflict; keep those changes small and merge them deliberately. +- Every agent works in the **one shared working tree**. No git worktrees, no + branches, no commits — see the working rules under [Agent Harness](#agent-harness). +- Claim one slice at a time, but never stop after one. Finish it, then claim the + next. Foundational/broad slices are fair game when they are the highest-priority + unclaimed work. +- Parallel edits to the same files (package registration, root DI, the + coordination files) are expected. Re-read `REFACTOR_SLICES.json` right before + editing it so you build on the current state instead of clobbering another + agent's claim. Keep the tree typechecking after your edits. - Do not mark the whole migration complete because several slices are passing. Completion means every slice in `REFACTOR_SLICES.json` is passing or explicitly retired with a reason. @@ -461,6 +480,38 @@ Electron, or Node host syscalls. Core may use Inversify decorators and modules, but it must not import an app container. It exports services and modules; hosts load them. +#### Core Purity Gate + +`core` is portable business logic. Do not move code into `packages/core` just +because it is "not UI". If it imports Node, shells out, reads paths from the +host, watches files, checks `process.platform`, reads `process.env`, or depends +on a Node-oriented implementation package, it is not pure core yet. + +Before marking a core slice `needs_validation` or `passing`, run: + +```sh +pnpm exec biome lint packages/core +pnpm exec biome check packages/core +pnpm --filter @posthog/core typecheck +``` + +`biome lint packages/core` must have zero `noRestrictedImports` errors. If it +does not, course-correct the placement before continuing: + +| Found in proposed core code | Correct move | +|---|---| +| `node:fs`, `node:path`, `node:os`, `node:child_process`, `node:process`, `process.*` | `workspace-server`, or a `platform`/environment contract injected into core | +| `node:crypto` for ids, hashes, PKCE, random bytes | `platform` crypto/random contract, or keep the flow in a host package until a contract exists | +| `node:events` for async iterators/event emitters | use a small shared/platform event abstraction, or keep the event-source owner in `workspace-server` | +| `@posthog/enricher`, git/file scanners, AST scanning tied to repo files | `workspace-server` owns the scan; core may own only the result model and business decision | +| `process.platform` / `process.arch` update logic | app/platform capability supplies host info; core consumes a typed host-info interface | +| Node-only test fixtures in `packages/core` | move the test to the host package or provide a fake pure port; do not weaken the lint rule | + +If the business algorithm is valuable but currently mixed with host calls, split +it: put the pure model/decision function in `core`, put host access in +`workspace-server` or an app adapter, and connect them through an injected +interface. + ### `packages/workspace-server` Owns Node-only host syscalls and the tRPC server: @@ -580,6 +631,9 @@ Work one feature or capability slice at a time. duplicated, decide which copy owns truth before moving it. 4. **Identify host calls.** Git, fs, spawn, pty, Electron, OS APIs, native modules, and watchers move to workspace-server or platform adapters. + `process.env`, `process.platform`, `node:crypto`, `node:events`, and + Node-oriented implementation packages count as host calls unless a pure + browser/mobile-compatible abstraction already exists. 5. **Sort logic.** - Host syscall or source smoothing: `workspace-server`. - Business orchestration: `core`. @@ -603,7 +657,10 @@ Work one feature or capability slice at a time. delegation shims with `// PORT NOTE:` and a retirement condition. 12. **Delete old code when the bridge is gone.** 13. **Update `MIGRATION.md` and `REFACTOR_PROGRESS.md`.** -14. **Validate.** Typecheck, tests, app launch, and a real feature smoke test. +14. **Validate.** Typecheck, package purity checks, tests, app launch, and a + real feature smoke test. If the slice touched `packages/core`, run + `pnpm exec biome lint packages/core` and fix placement until + `noRestrictedImports` is clean. 15. **Update `REFACTOR_SLICES.json`.** Mark `passing` / `passes: true` only when validation and acceptance checks are complete. @@ -945,11 +1002,25 @@ For every slice: - read the slice's acceptance criteria before changing code, - run the relevant typecheck, +- run package boundary lint before any broad formatter pass, - run focused tests, - start the app when user-visible behavior changed, - smoke test the feature, - watch logs for one real usage cycle when the change affects background work. +Use these dry-run checks as gates: + +```sh +pnpm exec biome lint packages/core +pnpm exec biome check packages/core +pnpm typecheck +``` + +If a slice touched another package, run the same lint/check command against that +package too. Do not mark a slice `passing` while Biome reports restricted import +violations in a touched package. Use `needs_validation` only for missing runtime +smoke coverage, not for known layer-boundary violations. + Typecheck and tests are necessary but not sufficient. The app must actually run. Do not set `passes: true` in `REFACTOR_SLICES.json` until the acceptance checks and smoke test have passed. diff --git a/REFACTOR_PROGRESS.md b/REFACTOR_PROGRESS.md deleted file mode 100644 index 6f13eaa2b2..0000000000 --- a/REFACTOR_PROGRESS.md +++ /dev/null @@ -1,47 +0,0 @@ -# REFACTOR_PROGRESS.md — append-only agent log - -Tactical, append-only record of what each agent session changed, validated, -deferred, or broke during the `apps/code` -> packages migration. - -- Newest entries at the bottom. -- One entry per session, even short ones. -- Format below. Keep entries short and operational. -- Source of truth for slice status is [REFACTOR_SLICES.json](./REFACTOR_SLICES.json). -- Landed architectural movement is summarized in [MIGRATION.md](./MIGRATION.md). -- The procedure is in [REFACTOR.md](./REFACTOR.md); the layering rules are in [AGENTS.md](./AGENTS.md). - -## Entry format - -```md -## YYYY-MM-DD HH:MM — - -- Changed: `` -- Validated: `` -- Slice status: `` -- Next: `` -``` - ---- - -## 2026-05-29 — initializer — harness setup (no slice claimed) - -- Changed: created `REFACTOR_SLICES.json`, `REFACTOR_PROGRESS.md`, `scripts/refactor-init.sh`. -- Audit: enumerated 57 slices from `apps/code` (43 main services, 39 tRPC routers, 52 renderer features, 15 platform interfaces). 4 slices recorded as already `passing` from prior landed work (`diff-stats`, `file-watcher`, `focus`, `api-client` — see MIGRATION.md); the remaining 53 are `todo`. -- Key audit findings recorded as the highest-priority slices: - - **Foundation is not built yet.** `packages/di` is empty (no package.json/src). No `useService`, `WORKBENCH_CONTRIBUTION`, `WorkbenchContribution`, `startWorkbench`, or `ContainerModule` exist in source. → slice `di-foundation` (priority 100), REFACTOR.md Recommended Order step 1. Most other slices depend on it. - - **Platform interfaces lack Symbol identifiers.** `packages/platform/src/*.ts` define interfaces only; they are bound today via legacy `MAIN_TOKENS` in `apps/code/src/main/di/tokens.ts`. → slice `platform-identifiers` (priority 90). - - Forbidden patterns confirmed present and tagged on their slices: `os.ts` is a 401-line router with no backing service (`dialog-capability`, `misc-host-capabilities`); `WorkspaceService` uses `container.get(FileWatcherService)` and a router-bypasses-service-to-repository pattern (`workspace`); `TaskService` is a renderer DI service that fetches domain data (`ui-task-detail`); the ~3796-line renderer sessions service (`sessions`). - - `FileWatcherBridge` retirement is gated on four consumers: `fs-capability`, `archive`, `suspension`, `workspace`. -- Validated: `node -e` JSON parse of `REFACTOR_SLICES.json` (57 slices, no duplicate ids, all required fields present); `bash -n scripts/refactor-init.sh`. No application code changed, so no app smoke test was run. -- Slice status: n/a (no slice claimed; this was the initializer pass, REFACTOR.md Recommended Order step 0). -- Next: an agent should claim `di-foundation` (priority 100) and establish the shared DI primitives before broad parallel feature work begins. `connectivity` (82) and `projects` (81) are good first read-only feature slices to exercise the foundation once it lands. - -## 2026-05-29 — initializer — coverage gap closure (no slice claimed) - -- Triggered by review: first audit covered services/routers/features/stores/platform but missed (a) non-feature main surface and (b) the entire shared React surface. -- Added slices: `analytics`, `ui-event-bus` (UIService, uses container.get in router), `ui-app-shell` (themeStore + rendererWindowFocusStore); folded the host-only `workspace-server` child-process service into `app-lifecycle`. -- After REFACTOR.md gained the "Porting React UI" section, added the shared-React slices: `ui-primitives` (packages/ui/src/primitives — components/ui, shared visuals, action-selector, generic hooks), `ui-shell` (App.tsx/main.tsx/Providers/layout/styles + boot dismantled into contributions), `ui-permissions` (components/permissions, ACP-typed), `renderer-shared-hooks` (feature-coupled hooks in renderer/hooks redistributed to owning features), `renderer-shared-utils` (utils/types/assets split: host-agnostic->ui/shared, host-coupled->platform). -- Folded domain cross-cutting into owners (no double-ownership): sagas/task -> `ui-task-detail`, constants/keyboard-shortcuts -> `ui-command`, utils/analytics.* -> `analytics`. -- Coverage: wrote a scan over all 281 code items under apps/code/src + packages/platform/src. 281 mapped except 3 intentional non-slices, now recorded in REFACTOR_SLICES.json meta.deliberatelyNotSliced (main services/index.ts, main services/types.ts, renderer hooks/useFileWatcher.ts). -- Validated: JSON parses, 65 slices (61 todo, 4 passing), no duplicate ids, all required fields present. -- Slice status: n/a (initializer). Next unchanged: claim `di-foundation`. Note `ui-primitives` (priority 83) should land early because feature UI ports may not import apps/code, so they need primitives in @posthog/ui first. diff --git a/REFACTOR_SLICES.json b/REFACTOR_SLICES.json deleted file mode 100644 index 0c6fb0b8c7..0000000000 --- a/REFACTOR_SLICES.json +++ /dev/null @@ -1,1707 +0,0 @@ -{ - "meta": { - "purpose": "Structured inventory of migration slices for the VS Code-style refactor described in REFACTOR.md. This file is the anti-premature-victory device: every slice starts not passing and only becomes passing after acceptance checks and a real smoke test have actually run.", - "generatedBy": "initializer pass (audit of apps/code @ 2026-05-29)", - "conventions": { - "priorityOrder": "Higher number = do sooner. Roughly follows REFACTOR.md 'Recommended Order': foundation (90-100) > platform identifiers (90) > read-only UI pipes (80) > workspace-server capabilities (70) > core write paths (55-65) > UI-consumed platform capabilities (50) > auth/integrations/mcp (40) > agent/llm/analytics (30) > large entangled surfaces sessions/terminal/inbox (10-20).", - "status": { - "todo": "unclaimed", - "in_progress": "one agent owns it right now (set claimedBy)", - "blocked": "cannot proceed without a named dependency or decision (record in notes)", - "needs_validation": "code moved, smoke test not complete", - "passing": "acceptance checks verified and passes:true" - }, - "rules": "Agents may update status, claimedBy, notes, validation evidence, and passes. Do NOT delete slices or weaken acceptance criteria to make a slice pass. If criteria are wrong, add a note and get them corrected explicitly. The `data` block for todo slices is a starting hint; the claiming agent performs the full data audit (model, source of truth, persisted/in-memory state, derived projections) per REFACTOR.md 'Per-Feature Procedure' step 3.", - "coverage": "Every code item under apps/code/src (main services, routers, renderer features/stores/components/hooks/utils/sagas/constants/types, top-level shell) and packages/platform/src maps to at least one slice's `paths`. Feature-local components/hooks move WITH their feature slice; shared React code is covered by ui-primitives, ui-shell, ui-permissions, renderer-shared-hooks, and renderer-shared-utils (REFACTOR.md 'Porting React UI').", - "deliberatelyNotSliced": [ - "apps/code/src/main/services/index.ts — DI composition root; host wiring that transforms under di-foundation, not a migratable feature", - "apps/code/src/main/services/types.ts — shared main type defs, no behavior to migrate", - "apps/code/src/renderer/hooks/useFileWatcher.ts — already migrated to packages/ui (file-watcher slice); renderer copy is a bridge leftover to delete, not a new slice" - ] - } - }, - "slices": [ - { - "id": "di-foundation", - "category": "foundation", - "priority": 100, - "status": "todo", - "claimedBy": null, - "paths": [ - "packages/di", - "apps/code/src/renderer/di/container.ts", - "apps/code/src/renderer/main.tsx", - "apps/code/src/renderer/desktop-services.ts", - "apps/code/src/renderer/desktop-contributions.ts" - ], - "data": { - "model": "shared DI primitives", - "sourceOfTruth": "packages/di owns useService, WORKBENCH_CONTRIBUTION token, WorkbenchContribution interface, startWorkbench", - "derivedProjections": [] - }, - "acceptance": [ - "packages/di exists with package.json, tsup/tsconfig, and exports: WORKBENCH_CONTRIBUTION symbol, WorkbenchContribution interface, startWorkbench(), useService() React hook", - "startWorkbench resolves all WORKBENCH_CONTRIBUTION bindings and awaits each contribution.start() before rendering", - "useService reads from the renderer container and is documented as component-boundary only (not a service-locator replacement for constructor injection)", - "apps/code/src/renderer/main.tsx imports desktop-services + desktop-contributions and calls startWorkbench()", - "at least one already-migrated feature (e.g. notifications or file-watcher) is wired through a ContainerModule + contribution to prove the path end to end", - "app boots and renders with the contribution-driven startup" - ], - "passes": false, - "notes": "Prerequisite for almost every other slice. REFACTOR.md Recommended Order step 1. packages/di is currently empty. No useService/WORKBENCH_CONTRIBUTION/startWorkbench/ContainerModule exist in source today." - }, - { - "id": "platform-identifiers", - "category": "foundation", - "priority": 90, - "status": "todo", - "claimedBy": null, - "paths": [ - "packages/platform/src", - "apps/code/src/main/di/tokens.ts", - "apps/code/src/main/platform-adapters" - ], - "data": { - "model": "host capability contracts", - "sourceOfTruth": "each packages/platform/src/.ts owns its Symbol identifier + interface", - "derivedProjections": [] - }, - "acceptance": [ - "every packages/platform/src/.ts exports a Symbol.for(...) service identifier alongside its interface", - "platform interfaces contain no Electron/DOM/React Native/macOS/Windows/dock/taskbar/tray terms (host-neutral product intent only)", - "apps/code binds each electron adapter to the platform-owned identifier; legacy MAIN_TOKENS platform entries become aliases or are removed", - "no platform package file imports anything internal", - "app boots with adapters resolved via platform identifiers" - ], - "passes": false, - "notes": "Interfaces already exist in packages/platform/src but have no Symbol identifiers; they are bound today via MAIN_TOKENS in apps/code/src/main/di/tokens.ts. Audit interface naming for host-specific leakage (e.g. notifier.requestAttention is good; check the rest)." - }, - - { - "id": "diff-stats", - "category": "ui-feature", - "priority": 80, - "status": "passing", - "claimedBy": null, - "paths": [ - "packages/workspace-server/src/services/git/service.ts", - "packages/ui/src/features/diff-stats", - "packages/workspace-client/src" - ], - "data": { - "model": "DiffStats", - "sourceOfTruth": "DiffStats zod schema in packages/workspace-server/src/services/git/schemas.ts (z.infer)", - "derivedProjections": ["DiffStatsBadge display"] - }, - "acceptance": [ - "getDiffStats lives in workspace-server git service behind a one-line procedure", - "PSK comparison uses timingSafeEqual", - "DiffStats schema is the source of truth, not a hand-declared type", - "useDiffStats hook wraps a single query" - ], - "passes": true, - "notes": "Landed 2026-05-27 (see MIGRATION.md). Bootstrapped @posthog/workspace-server, workspace-client, ui packages. Left as-is: useTaskDiffSummaryStats still has 4 modes (local/branch/PR/cloud) — collapses once relay protocol exists." - }, - { - "id": "file-watcher", - "category": "workspace-server-capability", - "priority": 70, - "status": "passing", - "claimedBy": null, - "paths": [ - "packages/workspace-server/src/services/watcher", - "packages/ui/src/features/file-watcher", - "apps/code/src/main/services/file-watcher/bridge.ts", - "apps/code/src/main/trpc/routers/file-watcher.ts" - ], - "data": { - "model": "FileWatcherEvent (discriminated union)", - "sourceOfTruth": "WatcherService in workspace-server (owns debounce, bulk threshold, git filtering = source smoothing)", - "derivedProjections": ["renderer caches keyed by repo"] - }, - "acceptance": [ - "all watcher orchestration + source-smoothing lives in workspace-server WatcherService.watchRepo()", - "useFileWatcher is a pure useSubscription wrapper (no useEffect/for-await/orchestration state)", - "fileWatcher.watch is a one-line subscription procedure", - "nothing for file-watcher lives in packages/core" - ], - "passes": true, - "notes": "Landed 2026-05-28. FileWatcherBridge in apps/code remains until fs/archive/suspension/workspace consumers migrate (see those slices). Two parallel watcher pipelines per repo remain (bridge + renderer); not yet deduped." - }, - { - "id": "focus", - "category": "core-orchestration", - "priority": 60, - "status": "passing", - "claimedBy": null, - "paths": [ - "packages/core/src/focus/service.ts", - "packages/workspace-server/src/services/focus", - "apps/code/src/main/services/focus/service.ts", - "apps/code/src/main/trpc/routers/focus.ts", - "apps/code/src/renderer/stores/focusStore.ts" - ], - "data": { - "model": "FocusSession", - "sourceOfTruth": "FocusController in packages/core owns enable/disable/restore flow; workspace-server owns git/worktree/watch host ops; main persists local snapshot for Electron restart", - "derivedProjections": ["focusStore UI state"] - }, - "acceptance": [ - "multi-step focus flow lives in core FocusController with injected dependency interface", - "git/worktree/watch host work lives in workspace-server focus service behind one-line focus.* procedures", - "focusStore is thin: UI state + one controller call per action, no flow graph", - "main FocusService is a documented bridge, not the source of truth" - ], - "passes": true, - "notes": "Landed 2026-05-28. Bridge: main FocusService shim persists focus-session for restore + re-emits events to legacy main-router subscribers. Retire when session restore/subscribers read from workspace-server (or shared persistence). Restore still re-saves validated session to repopulate server in-memory map." - }, - { - "id": "api-client", - "category": "core-orchestration", - "priority": 75, - "status": "passing", - "claimedBy": null, - "paths": ["packages/api-client/src", "apps/code/src/api"], - "data": { - "model": "PostHog/Django HTTP transport", - "sourceOfTruth": "ApiFetcher in packages/api-client (config-driven, appVersion injected)", - "derivedProjections": [] - }, - "acceptance": [ - "fetcher + generated client + augmentation moved to @posthog/api-client", - "no __APP_VERSION__ Vite global in the fetcher (appVersion is a config field)", - "scripts/update-openapi-client.ts writes into the package", - "renderer imports @posthog/api-client" - ], - "passes": true, - "notes": "Landed 2026-05-28 (transport only). The 2929-line posthogClient.ts god-class is NOT moved — tagged PORT NOTE, to be sliced per feature into packages/core//service.ts. Those per-feature carves are tracked by the relevant feature slices below." - }, - - { - "id": "connectivity", - "category": "core-orchestration", - "priority": 82, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/connectivity", - "apps/code/src/main/trpc/routers/connectivity.ts", - "apps/code/src/renderer/features/connectivity", - "apps/code/src/renderer/stores/connectivityStore.ts" - ], - "data": { - "model": "ConnectivityState", - "sourceOfTruth": "audit: likely the main connectivity service polling network/online state", - "derivedProjections": ["connectivityStore UI flags"] - }, - "acceptance": [ - "connectivity polling/detection lives in a package service (core or workspace-server depending on whether it does host syscalls)", - "router is one-line forwards over the service", - "connectivityStore is thin: subscription cache + UI flags, no polling loop", - "feature smoke test: toggling network reflects in the UI" - ], - "passes": false, - "notes": "Small read-only pipe (~127 LOC main, ~52 LOC feature). Good early slice to exercise the foundation." - }, - { - "id": "projects", - "category": "ui-feature", - "priority": 81, - "status": "todo", - "claimedBy": null, - "paths": ["apps/code/src/renderer/features/projects"], - "data": { - "model": "Project", - "sourceOfTruth": "audit: PostHog API (carve from posthogClient.ts into packages/core or api-client consumer)", - "derivedProjections": ["project list view"] - }, - "acceptance": [ - "projects feature view + hooks move to packages/ui/src/features/projects", - "data access wraps a single query/procedure; no imperative trpcClient in components", - "any project fetching carved out of posthogClient.ts god-class into a core/api-client consumer", - "smoke test: project list renders" - ], - "passes": false, - "notes": "Small read-only UI feature (~133 LOC)." - }, - { - "id": "environments", - "category": "ui-feature", - "priority": 80, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/environment", - "apps/code/src/main/services/session-env", - "apps/code/src/main/trpc/routers/environment.ts", - "apps/code/src/renderer/features/environments" - ], - "data": { - "model": "Environment / SessionEnv", - "sourceOfTruth": "audit: environment + session-env main services", - "derivedProjections": ["environments list UI"] - }, - "acceptance": [ - "environment business logic moves to core; any host env reads (process env, files) to workspace-server", - "router one-line forwards", - "environments feature view moves to packages/ui/src/features/environments", - "smoke test: environments list renders/edits" - ], - "passes": false, - "notes": "Pairs main environment (~240) + session-env (~158) with renderer environments feature (~162)." - }, - { - "id": "folders", - "category": "core-orchestration", - "priority": 65, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/folders", - "apps/code/src/main/trpc/routers/folders.ts", - "apps/code/src/renderer/features/folders", - "apps/code/src/renderer/features/folder-picker" - ], - "data": { - "model": "Folder", - "sourceOfTruth": "audit: folders main service + folder repository", - "derivedProjections": ["folder tree UI", "folder-picker"] - }, - "acceptance": [ - "folder host ops (fs listing) live in workspace-server; folder business/persistence orchestration in core", - "router one-line forwards over service", - "folders + folder-picker UI move to packages/ui/src/features", - "smoke test: open folder picker, select a folder, it persists" - ], - "passes": false, - "notes": "main folders ~346 LOC; folders feature ~143; folder-picker ~583." - }, - { - "id": "workspace", - "category": "core-orchestration", - "priority": 62, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/workspace", - "apps/code/src/main/trpc/routers/workspace.ts", - "apps/code/src/main/trpc/routers/additional-directories.ts", - "apps/code/src/renderer/features/workspace", - "apps/code/src/renderer/stores/activeRepoStore.ts" - ], - "data": { - "model": "Workspace / Repository / Worktree", - "sourceOfTruth": "audit: WorkspaceService + Workspace/Worktree/Repository repositories", - "derivedProjections": ["activeRepoStore", "workspace UI"] - }, - "acceptance": [ - "workspace orchestration moves to core; git/worktree/fs host ops to workspace-server", - "router bypasses-service-to-repository anti-pattern is removed (workspace.ts does this today)", - "container.get(FileWatcherService) inside WorkspaceService is replaced by constructor injection or events", - "activeRepoStore stays thin; workspace UI moves to packages/ui", - "smoke test: switch active repo, worktree state updates" - ], - "passes": false, - "notes": "1610 LOC main service. Two named forbidden patterns live here today: router-bypasses-service-to-repository, and container.get(FileWatcherService) inside a method. Entangled with focus (already migrated) and file-watcher bridge retirement." - }, - { - "id": "archive", - "category": "core-orchestration", - "priority": 58, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/archive", - "apps/code/src/main/trpc/routers/archive.ts", - "apps/code/src/renderer/features/archive" - ], - "data": { - "model": "ArchiveEntry", - "sourceOfTruth": "audit: ArchiveService + ArchiveRepository", - "derivedProjections": ["archive list UI"] - }, - "acceptance": [ - "archive orchestration moves to core; fs/host ops to workspace-server", - "archive is a file-watcher consumer — wire it via useFileWatcher/workspace-client and help retire FileWatcherBridge", - "router one-line forwards", - "smoke test: archive a task, it appears in the archive view" - ], - "passes": false, - "notes": "main ~618 LOC, feature ~802. One of the four FileWatcherBridge consumers." - }, - { - "id": "suspension", - "category": "core-orchestration", - "priority": 57, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/suspension", - "apps/code/src/main/trpc/routers/suspension.ts", - "apps/code/src/main/services/sleep", - "apps/code/src/main/trpc/routers/sleep.ts", - "apps/code/src/renderer/features/suspension" - ], - "data": { - "model": "Suspension", - "sourceOfTruth": "audit: SuspensionService + SuspensionRepository", - "derivedProjections": ["suspension UI"] - }, - "acceptance": [ - "suspension orchestration moves to core; host sleep/power ops via platform power-manager", - "suspension is a file-watcher consumer — wire via workspace-client and help retire FileWatcherBridge", - "router one-line forwards", - "smoke test: suspend/resume a session" - ], - "passes": false, - "notes": "main suspension ~571 + sleep ~70; feature ~160. FileWatcherBridge consumer." - }, - { - "id": "handoff", - "category": "core-orchestration", - "priority": 55, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/handoff", - "apps/code/src/main/trpc/routers/handoff.ts" - ], - "data": { - "model": "Handoff", - "sourceOfTruth": "audit: HandoffService", - "derivedProjections": [] - }, - "acceptance": [ - "handoff orchestration moves to core", - "host syscalls (git/fs) move to workspace-server", - "router one-line forwards", - "smoke test: run a handoff end to end" - ], - "passes": false, - "notes": "main ~910 LOC. Likely entangled with sessions/cloud-task; audit fan-in before moving." - }, - { - "id": "usage-monitor", - "category": "core-orchestration", - "priority": 55, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/usage-monitor", - "apps/code/src/main/trpc/routers/usage-monitor.ts", - "apps/code/src/renderer/features/billing" - ], - "data": { - "model": "UsageStats / BillingState", - "sourceOfTruth": "audit: UsageMonitorService + PostHog billing API", - "derivedProjections": ["billing view"] - }, - "acceptance": [ - "usage polling/aggregation moves to core", - "billing API access carved from posthogClient.ts into core/api-client consumer", - "billing feature moves to packages/ui/src/features/billing", - "smoke test: billing/usage view renders live numbers" - ], - "passes": false, - "notes": "main usage-monitor ~314; billing feature ~1279." - }, - { - "id": "cloud-task", - "category": "core-orchestration", - "priority": 45, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/cloud-task", - "apps/code/src/main/trpc/routers/cloud-task.ts" - ], - "data": { - "model": "CloudTask", - "sourceOfTruth": "audit: CloudTaskService + PostHog cloud API", - "derivedProjections": ["cloud task status in sessions/tasks UI"] - }, - "acceptance": [ - "cloud task orchestration (polling, status machine, retries) moves to core", - "cloud API access carved from posthogClient.ts", - "router one-line forwards", - "smoke test: create/poll a cloud task to completion" - ], - "passes": false, - "notes": "main ~1496 LOC. Deeply tied to sessions + handoff + diff-stats 'cloud' mode. Audit fan-in carefully." - }, - { - "id": "provisioning", - "category": "core-orchestration", - "priority": 50, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/provisioning", - "apps/code/src/main/trpc/routers/provisioning.ts", - "apps/code/src/renderer/features/provisioning" - ], - "data": { - "model": "ProvisioningState", - "sourceOfTruth": "audit: ProvisioningService", - "derivedProjections": ["provisioning UI"] - }, - "acceptance": [ - "provisioning orchestration moves to core; host ops to workspace-server", - "router one-line forwards", - "provisioning feature moves to packages/ui", - "smoke test: provisioning flow completes" - ], - "passes": false, - "notes": "main ~22 LOC (thin); feature ~115." - }, - { - "id": "deep-links", - "category": "core-orchestration", - "priority": 48, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/deep-link", - "apps/code/src/main/services/inbox-link", - "apps/code/src/main/services/task-link", - "apps/code/src/main/services/new-task-link", - "apps/code/src/main/trpc/routers/deep-link.ts" - ], - "data": { - "model": "DeepLink", - "sourceOfTruth": "audit: deep-link parsing/routing in main", - "derivedProjections": ["navigation actions"] - }, - "acceptance": [ - "deep-link parsing/routing logic moves to core; OS protocol registration stays in apps/code (Electron deep link is host lifecycle)", - "inbox-link/task-link/new-task-link share the core link parser", - "router one-line forwards", - "smoke test: open a posthog:// deep link, app routes correctly" - ], - "passes": false, - "notes": "deep-link ~108, inbox-link ~77, task-link ~97, new-task-link ~197. OS-level protocol handler registration is genuine host code and stays in apps/code." - }, - { - "id": "app-lifecycle", - "category": "core-orchestration", - "priority": 50, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/app-lifecycle", - "apps/code/src/main/services/watcher-registry", - "apps/code/src/main/services/workspace-server", - "apps/code/src/main/trpc/routers/workspace-server.ts", - "packages/platform/src/app-lifecycle.ts" - ], - "data": { - "model": "AppLifecycle hooks + workspace-server child-process connection", - "sourceOfTruth": "audit: AppLifecycleService + watcher-registry; workspace-server child connection (url/secret) is host infra owned by apps/code", - "derivedProjections": [] - }, - "acceptance": [ - "host lifecycle (Electron app events) stays in apps/code behind platform app-lifecycle interface", - "any business reactions to lifecycle move to core contributions", - "watcher-registry role re-evaluated (still used by focus + app-lifecycle)", - "workspace-server child-process spawn/connect service stays in apps/code (genuine host infra) and is explicitly documented as such, not migrated", - "smoke test: app start/quit hooks fire correctly; workspace-server child connects on boot" - ], - "passes": false, - "notes": "app-lifecycle ~192, watcher-registry ~115. Mostly host code; carve out only business reactions. The main `workspace-server` service + router manage the Electron-spawned child process (ELECTRON_RUN_AS_NODE) and stay in apps/code by design — included here so the audit accounts for them rather than silently omitting them." - }, - { - "id": "analytics", - "category": "core-orchestration", - "priority": 33, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/posthog-analytics.ts", - "apps/code/src/main/services/posthog-analytics.test.ts", - "apps/code/src/main/trpc/routers/analytics.ts", - "apps/code/src/renderer/utils/analytics.ts", - "apps/code/src/renderer/utils/analytics.test.ts" - ], - "data": { - "model": "AnalyticsEvent / user identity", - "sourceOfTruth": "posthog-analytics service owns identify/reset/capture; current-user-id is the source of truth for attribution", - "derivedProjections": ["captured event properties"] - }, - "acceptance": [ - "system-event analytics (identify, reset, capture) lives in a package service, not in stores or components (AGENTS.md R2: no system-event analytics in stores)", - "analytics service consumes the PostHog API/transport, not Electron directly", - "router one-line forwards with zod input/output", - "existing posthog-analytics.test.ts is ported/kept green", - "smoke test: an identify + a captured event reach PostHog" - ], - "passes": false, - "notes": "main posthog-analytics ~? LOC (file, not dir) + analytics router + existing test. Genuine domain that was missing from the first audit pass. Decide core vs platform: capture transport is API, but the 'when to capture' gating is business logic for core." - }, - { - "id": "ui-event-bus", - "category": "foundation", - "priority": 49, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/ui", - "apps/code/src/main/trpc/routers/ui.ts" - ], - "data": { - "model": "UIServiceEvent (typed main->renderer UI event bus)", - "sourceOfTruth": "UIService emits typed UI events; renderer subscribes", - "derivedProjections": ["renderer reactions to UI events"] - }, - "acceptance": [ - "UIService typed event emitter moves to the appropriate package (core for cross-feature coordination, or stays as host wiring if purely Electron-window driven — decide during audit)", - "the ui.ts router stops using container.get(UIService) and forwards over an injected service (or becomes feature subscription contributions)", - "renderer consumers subscribe via contributions, not ad hoc", - "smoke test: a UI event emitted in main is received by the renderer" - ], - "passes": false, - "notes": "Cross-cutting UI event bus. router/ui.ts uses container.get inside the procedure (forbidden pattern). May fold into di-foundation's event/contribution model; kept separate so it's explicitly tracked." - }, - { - "id": "ui-app-shell", - "category": "ui-feature", - "priority": 21, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/stores/themeStore.ts", - "apps/code/src/renderer/stores/rendererWindowFocusStore.ts" - ], - "data": { - "model": "app-shell UI state (theme preference, window focus/visibility)", - "sourceOfTruth": "themeStore owns theme pref (persisted); rendererWindowFocusStore derives window-focused from document visibility + OS focus", - "derivedProjections": [ - "isDarkMode", - "windowFocused (gates inbox polling)" - ] - }, - "acceptance": [ - "themeStore + rendererWindowFocusStore move to packages/ui as thin pure-UI stores", - "theme persistence uses electronStorage / platform storage, not ad hoc", - "window-focus signal is exposed cleanly for consumers (e.g. inbox polling pause) without cross-store reach-ins", - "smoke test: toggle theme persists across restart; backgrounding the window pauses inbox polling" - ], - "passes": false, - "notes": "Two app-shell stores that did not belong to any feature slice. rendererWindowFocusStore is consumed by inbox polling — coordinate with the inbox slice. Pure UI state; safe once di-foundation lands." - }, - { - "id": "llm-gateway", - "category": "core-orchestration", - "priority": 35, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/llm-gateway", - "apps/code/src/main/trpc/routers/llm-gateway.ts" - ], - "data": { - "model": "LlmGateway request/response", - "sourceOfTruth": "audit: LlmGatewayService", - "derivedProjections": [] - }, - "acceptance": [ - "gateway orchestration moves to core", - "router one-line forwards with zod input/output", - "smoke test: a gateway call round-trips" - ], - "passes": false, - "notes": "main ~299 LOC." - }, - { - "id": "posthog-plugin", - "category": "core-orchestration", - "priority": 32, - "status": "todo", - "claimedBy": null, - "paths": ["apps/code/src/main/services/posthog-plugin"], - "data": { - "model": "PostHog plugin integration", - "sourceOfTruth": "audit: posthog-plugin service", - "derivedProjections": [] - }, - "acceptance": [ - "plugin orchestration moves to core; host ops to workspace-server", - "no Electron imports in moved code", - "smoke test: plugin feature works end to end" - ], - "passes": false, - "notes": "main ~530 LOC." - }, - { - "id": "enrichment", - "category": "core-orchestration", - "priority": 34, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/enrichment", - "apps/code/src/main/trpc/routers/enrichment.ts", - "packages/enricher" - ], - "data": { - "model": "EnrichmentResult (flag detection)", - "sourceOfTruth": "packages/enricher owns AST detection; enrichment service orchestrates", - "derivedProjections": ["flag annotations in UI"] - }, - "acceptance": [ - "enrichment orchestration moves to core consuming @posthog/enricher", - "fs reads move to workspace-server", - "router one-line forwards", - "smoke test: flag detection annotates a file" - ], - "passes": false, - "notes": "main ~423 LOC; packages/enricher already exists as the AST engine." - }, - { - "id": "agent", - "category": "core-orchestration", - "priority": 30, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/agent", - "apps/code/src/main/trpc/routers/agent.ts", - "packages/agent" - ], - "data": { - "model": "AgentSession / AgentMessage (use ACP SDK types)", - "sourceOfTruth": "packages/agent framework; agent service orchestrates lifecycle", - "derivedProjections": ["session messages in sessions UI"] - }, - "acceptance": [ - "agent orchestration moves to core consuming @posthog/agent", - "ACP SDK types used, no hand-rolled agent/tool/permission types", - "no rawInput usage; zod-validated meta fields only", - "permissions implemented as tool calls, not custom methods", - "smoke test: start an agent session, exchange a prompt + permission" - ], - "passes": false, - "notes": "main ~2791 LOC. Deeply tied to sessions. Audit fan-in; likely sequenced near sessions." - }, - - { - "id": "git-core", - "category": "workspace-server-capability", - "priority": 70, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/git", - "apps/code/src/main/trpc/routers/git.ts", - "packages/workspace-server/src/services/git", - "apps/code/src/renderer/features/git-interaction" - ], - "data": { - "model": "Git CLI capability (status, diff, branch, commit, worktree, etc.)", - "sourceOfTruth": "packages/workspace-server git service (diff-stats already there); packages/git holds saga ops + gh client", - "derivedProjections": ["git-interaction UI"] - }, - "acceptance": [ - "remaining git CLI ops move into workspace-server git service with zod schemas", - "routers are one-line forwards; no inline git logic in router", - "git-interaction UI moves to packages/ui consuming workspace-client", - "no Electron imports; capability is dumb (host work + validation + transport)", - "smoke test: status/diff/commit flow through the migrated path" - ], - "passes": false, - "notes": "main git ~2878 LOC; git-interaction feature ~4921. diff-stats already carved. packages/git (sagas + gh CLI + locks) already exists — reconcile ownership. Large; consider sub-slices per command group during claim." - }, - { - "id": "fs-capability", - "category": "workspace-server-capability", - "priority": 68, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/fs", - "apps/code/src/main/trpc/routers/fs.ts", - "packages/workspace-server/src/services/fs" - ], - "data": { - "model": "Filesystem capability (read/write/list/watch-invalidate)", - "sourceOfTruth": "packages/workspace-server fs service", - "derivedProjections": ["file caches in renderer"] - }, - "acceptance": [ - "remaining fs syscalls move into workspace-server fs service (partial scaffold exists)", - "file-cache invalidation reconciled with WatcherService (fs is a FileWatcherBridge consumer today)", - "router one-line forwards; zod schemas", - "smoke test: read/write/list a file through the migrated path" - ], - "passes": false, - "notes": "main fs ~377; workspace-server fs service already scaffolded. fs is one of the four FileWatcherBridge consumers (helps retire the bridge)." - }, - { - "id": "shell-capability", - "category": "workspace-server-capability", - "priority": 66, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/shell", - "apps/code/src/main/trpc/routers/shell.ts" - ], - "data": { - "model": "Shell exec capability", - "sourceOfTruth": "audit: ShellService (process spawn)", - "derivedProjections": [] - }, - "acceptance": [ - "shell/process-spawn moves to workspace-server shell service", - "router one-line forwards; zod schemas", - "no Electron imports", - "smoke test: run a shell command through the migrated path" - ], - "passes": false, - "notes": "main ~472 LOC. Likely shared by terminal/pty + agent." - }, - { - "id": "process-tracking-capability", - "category": "workspace-server-capability", - "priority": 64, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/process-tracking", - "apps/code/src/main/trpc/routers/process-tracking.ts" - ], - "data": { - "model": "TrackedProcess", - "sourceOfTruth": "audit: ProcessTrackingService", - "derivedProjections": [] - }, - "acceptance": [ - "process tracking moves to workspace-server", - "router one-line forwards", - "smoke test: a tracked process is reported correctly" - ], - "passes": false, - "notes": "main ~249 LOC." - }, - { - "id": "local-logs-capability", - "category": "workspace-server-capability", - "priority": 60, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/local-logs", - "apps/code/src/main/trpc/routers/logs.ts" - ], - "data": { - "model": "LogEntry", - "sourceOfTruth": "audit: local-logs service (fs-backed)", - "derivedProjections": ["log viewer UI"] - }, - "acceptance": [ - "log file reading/tailing moves to workspace-server", - "router one-line forwards (logs.ts)", - "smoke test: logs stream/render" - ], - "passes": false, - "notes": "main ~108 LOC." - }, - { - "id": "terminal-pty", - "category": "workspace-server-capability", - "priority": 18, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/features/terminal", - "apps/code/src/main/services/shell" - ], - "data": { - "model": "PtySession", - "sourceOfTruth": "audit: pty spawn/IO (host) + terminal UI (xterm.js)", - "derivedProjections": ["terminal panes"] - }, - "acceptance": [ - "pty spawn + IO streaming move to workspace-server", - "terminal UI (xterm.js) moves to packages/ui consuming a streaming subscription", - "no orchestration in the store; subscription via contribution", - "smoke test: open a terminal, run a command, see output, resize works" - ], - "passes": false, - "notes": "feature ~937. Large entangled surface (REFACTOR.md Recommended Order step 6). Depends on shell-capability." - }, - - { - "id": "notifications", - "category": "renderer-platform-capability", - "priority": 52, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/notification", - "apps/code/src/main/trpc/routers/notification.ts", - "packages/platform/src/notifier.ts", - "packages/ui/src/features/notifications", - "apps/code/src/main/platform-adapters/electron-notifier.ts" - ], - "data": { - "model": "TaskNotification", - "sourceOfTruth": "notification decision inputs (task state + settings) in a UI/core service", - "derivedProjections": ["display title", "body text", "attention intent"] - }, - "acceptance": [ - "platform interface contains no Electron/macOS/Windows-specific terms", - "electron adapter is a dumb tRPC/Electron wrapper", - "notification gating (settings check, truncation, sound decision) lives in the package service not the adapter", - "feature smoke test sends a prompt-complete notification" - ], - "passes": false, - "notes": "packages/ui/src/features/notifications already partially scaffolded (canonical example in REFACTOR.md). main notification ~72; INotifier interface already exists. Verify gating moved out of adapter." - }, - { - "id": "clipboard-capability", - "category": "renderer-platform-capability", - "priority": 50, - "status": "todo", - "claimedBy": null, - "paths": [ - "packages/platform/src/clipboard.ts", - "apps/code/src/main/platform-adapters/electron-clipboard.ts" - ], - "data": { - "model": "clipboard text/image capability", - "sourceOfTruth": "platform clipboard interface; electron adapter implements", - "derivedProjections": [] - }, - "acceptance": [ - "clipboard interface gets a Symbol identifier (covered by platform-identifiers slice)", - "any clipboard business logic moves out of the adapter into the consuming UI/core service", - "renderer consumers use the platform service via DI / a thin tRPC adapter, not direct trpcClient", - "smoke test: copy/paste text and image work" - ], - "passes": false, - "notes": "Interface + electron adapter exist. Slice covers carving any logic out of adapter + wiring UI consumers via DI. Depends on platform-identifiers + di-foundation." - }, - { - "id": "dialog-capability", - "category": "renderer-platform-capability", - "priority": 50, - "status": "todo", - "claimedBy": null, - "paths": [ - "packages/platform/src/dialog.ts", - "apps/code/src/main/platform-adapters/electron-dialog.ts", - "apps/code/src/main/trpc/routers/os.ts" - ], - "data": { - "model": "dialog/message-box/file-picker capability", - "sourceOfTruth": "platform dialog interface", - "derivedProjections": [] - }, - "acceptance": [ - "dialog interface host-neutral with Symbol identifier", - "os.ts router (396 lines, no backing service today) is split: dialog/file-picker concerns go behind the platform service", - "no business logic in the dialog adapter", - "smoke test: open file picker + message box" - ], - "passes": false, - "notes": "os.ts is a 401-line router with NO backing service (named forbidden pattern). This slice addresses the dialog/file-icon/image-processor/app-meta portions of os.ts." - }, - { - "id": "secure-storage-capability", - "category": "renderer-platform-capability", - "priority": 50, - "status": "todo", - "claimedBy": null, - "paths": [ - "packages/platform/src/secure-storage.ts", - "apps/code/src/main/platform-adapters/electron-secure-storage.ts", - "apps/code/src/main/trpc/routers/secure-store.ts", - "apps/code/src/main/trpc/routers/encryption.ts" - ], - "data": { - "model": "secret store capability", - "sourceOfTruth": "platform secure-storage interface (Electron safeStorage adapter)", - "derivedProjections": [] - }, - "acceptance": [ - "secure-storage interface host-neutral with Symbol identifier", - "secret read/write decisions live in consuming services, not the adapter", - "secure-store/encryption routers one-line forward over the service", - "smoke test: store + retrieve a secret survives restart" - ], - "passes": false, - "notes": "Backs auth/integrations token storage; sequence before/with auth slice." - }, - { - "id": "context-menu-capability", - "category": "renderer-platform-capability", - "priority": 46, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/context-menu", - "apps/code/src/main/trpc/routers/context-menu.ts", - "packages/platform/src/context-menu.ts", - "apps/code/src/main/platform-adapters/electron-context-menu.ts" - ], - "data": { - "model": "ContextMenu spec", - "sourceOfTruth": "menu content decided by UI/core service; host renders native menu", - "derivedProjections": [] - }, - "acceptance": [ - "menu item construction/business logic lives in a package service, adapter just shows the native menu", - "platform interface host-neutral with Symbol identifier", - "router one-line forwards", - "smoke test: right-click menu shows correct items and actions fire" - ], - "passes": false, - "notes": "main context-menu ~595 LOC — significant logic to carve out of what should be a dumb adapter." - }, - { - "id": "updater-capability", - "category": "renderer-platform-capability", - "priority": 44, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/updates", - "apps/code/src/main/trpc/routers/updates.ts", - "packages/platform/src/updater.ts", - "apps/code/src/main/platform-adapters/electron-updater.ts", - "apps/code/src/renderer/stores/updateStore.ts" - ], - "data": { - "model": "UpdateState", - "sourceOfTruth": "update check/download orchestration in core; host download/install via platform updater", - "derivedProjections": ["updateStore UI", "update banner"] - }, - "acceptance": [ - "update orchestration (check cadence, state machine) moves to core", - "platform updater interface host-neutral with Symbol identifier; adapter is dumb", - "updateStore stays thin (subscription cache + UI flags)", - "smoke test: update check reflects available/not-available in UI" - ], - "passes": false, - "notes": "main updates ~521; updateStore + updateStore.test exist. Has existing tests to preserve." - }, - { - "id": "power-manager-capability", - "category": "renderer-platform-capability", - "priority": 42, - "status": "todo", - "claimedBy": null, - "paths": [ - "packages/platform/src/power-manager.ts", - "apps/code/src/main/platform-adapters/electron-power-manager.ts" - ], - "data": { - "model": "power/sleep-blocker capability", - "sourceOfTruth": "platform power-manager interface", - "derivedProjections": [] - }, - "acceptance": [ - "power-manager interface host-neutral with Symbol identifier", - "sleep-blocking decisions live in consuming service (e.g. suspension), adapter is dumb", - "smoke test: power/sleep blocking toggles correctly during a long task" - ], - "passes": false, - "notes": "Consumed by suspension/sleep; coordinate with that slice." - }, - { - "id": "misc-host-capabilities", - "category": "renderer-platform-capability", - "priority": 40, - "status": "todo", - "claimedBy": null, - "paths": [ - "packages/platform/src/url-launcher.ts", - "packages/platform/src/file-icon.ts", - "packages/platform/src/image-processor.ts", - "packages/platform/src/app-meta.ts", - "packages/platform/src/storage-paths.ts", - "packages/platform/src/main-window.ts", - "packages/platform/src/bundled-resources.ts", - "apps/code/src/main/trpc/routers/os.ts" - ], - "data": { - "model": "assorted host capabilities (open URL, file icon, image processing, app meta, storage paths, window, bundled resources)", - "sourceOfTruth": "respective platform interfaces", - "derivedProjections": [] - }, - "acceptance": [ - "each interface gets a Symbol identifier and is host-neutral", - "the remaining portions of os.ts (the 401-line, service-less router) are split behind these capabilities or backing services", - "no business logic in any adapter", - "smoke test: open-external-url, file icon render, image paste each work" - ], - "passes": false, - "notes": "Catch-all for the smaller platform adapters and the rest of os.ts. May be split into per-capability slices if a claimant prefers." - }, - - { - "id": "auth", - "category": "core-orchestration", - "priority": 40, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/auth", - "apps/code/src/main/services/auth-proxy", - "apps/code/src/main/services/oauth", - "apps/code/src/main/trpc/routers/auth.ts", - "apps/code/src/main/trpc/routers/oauth.ts", - "apps/code/src/renderer/features/auth" - ], - "data": { - "model": "AuthSession", - "sourceOfTruth": "AuthService owns OAuth dance, token refresh, session; AuthSessionRepository persists", - "derivedProjections": ["auth UI state", "seats", "settings gating"] - }, - "acceptance": [ - "OAuth dance, token refresh, session-sync all live in a core service (no multi-step flow in any store)", - "token persistence via platform secure-storage", - "logout fans out via a typed event; each store reacts in its contribution (no cross-store reach-ins)", - "auth feature moves to packages/ui; store is thin", - "smoke test: full login -> token refresh -> logout cycle" - ], - "passes": false, - "notes": "auth ~722, auth-proxy ~210, oauth ~624; feature ~1151. Canonical multi-step-flow case. Depends on secure-storage-capability. Logout is the cross-store-coordination example in REFACTOR.md." - }, - { - "id": "github-integration", - "category": "core-orchestration", - "priority": 38, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/github-integration", - "apps/code/src/main/trpc/routers/github-integration.ts", - "apps/code/src/main/services/integration-flow-schemas.ts", - "apps/code/src/renderer/features/integrations" - ], - "data": { - "model": "GithubIntegration", - "sourceOfTruth": "GithubIntegrationService + token in secure-storage", - "derivedProjections": ["integrations UI"] - }, - "acceptance": [ - "github OAuth/integration flow moves to core; gh CLI host ops via packages/git or workspace-server", - "token storage via platform secure-storage", - "integrations UI moves to packages/ui; store thin", - "smoke test: connect github, list repos" - ], - "passes": false, - "notes": "main ~154; shares integration-flow-schemas with linear/slack; integrations feature ~697 (shared with linear/slack)." - }, - { - "id": "linear-integration", - "category": "core-orchestration", - "priority": 37, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/linear-integration", - "apps/code/src/main/trpc/routers/linear-integration.ts", - "apps/code/src/renderer/features/integrations" - ], - "data": { - "model": "LinearIntegration", - "sourceOfTruth": "LinearIntegrationService + token in secure-storage", - "derivedProjections": ["integrations UI"] - }, - "acceptance": [ - "linear integration flow moves to core", - "token storage via platform secure-storage", - "shares the integration UI slice in packages/ui", - "smoke test: connect linear, list issues" - ], - "passes": false, - "notes": "main ~45 (thin). Sequence with github/slack as one 'integrations' wave." - }, - { - "id": "slack-integration", - "category": "core-orchestration", - "priority": 37, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/slack-integration", - "apps/code/src/main/trpc/routers/slack-integration.ts", - "apps/code/src/renderer/features/integrations" - ], - "data": { - "model": "SlackIntegration", - "sourceOfTruth": "SlackIntegrationService + token in secure-storage", - "derivedProjections": ["integrations UI"] - }, - "acceptance": [ - "slack integration flow moves to core", - "token storage via platform secure-storage", - "shares the integration UI slice in packages/ui", - "smoke test: connect slack, post a message" - ], - "passes": false, - "notes": "main ~170. Sequence with github/linear." - }, - { - "id": "external-apps", - "category": "core-orchestration", - "priority": 36, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/external-apps", - "apps/code/src/main/trpc/routers/external-apps.ts", - "apps/code/src/renderer/features/external-apps" - ], - "data": { - "model": "ExternalApp", - "sourceOfTruth": "ExternalAppsService (detect/launch external editors/apps)", - "derivedProjections": ["external-apps UI"] - }, - "acceptance": [ - "external app detection/launch: host detection to workspace-server, launch via platform url-launcher/shell", - "orchestration in core if multi-step", - "external-apps feature moves to packages/ui", - "smoke test: detect + open an external app" - ], - "passes": false, - "notes": "main ~733; feature ~71." - }, - { - "id": "mcp-apps", - "category": "core-orchestration", - "priority": 35, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/main/services/mcp-apps", - "apps/code/src/main/services/mcp-proxy", - "apps/code/src/main/services/mcp-callback", - "apps/code/src/main/trpc/routers/mcp-apps.ts", - "apps/code/src/main/trpc/routers/mcp-callback.ts", - "apps/code/src/renderer/features/mcp-apps", - "apps/code/src/renderer/features/mcp-servers", - "apps/code/src/renderer/features/posthog-mcp" - ], - "data": { - "model": "McpApp / McpServer connection", - "sourceOfTruth": "McpAppsService + McpProxyService (process spawn, proxy, oauth callback)", - "derivedProjections": ["mcp-apps/mcp-servers/posthog-mcp UI"] - }, - "acceptance": [ - "mcp process spawn/proxy host ops move to workspace-server; connection orchestration to core", - "mcp-callback oauth handling joins the auth/oauth pattern", - "mcp UI features move to packages/ui; stores thin", - "smoke test: add an MCP server, connect, list tools" - ], - "passes": false, - "notes": "mcp-apps ~480, mcp-proxy ~303, mcp-callback ~327; features mcp-servers ~2380 + mcp-apps ~1114 + posthog-mcp ~130. Sizeable; may sub-slice." - }, - - { - "id": "ui-settings", - "category": "ui-feature", - "priority": 25, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/features/settings", - "apps/code/src/renderer/stores/settingsStore.ts", - "apps/code/src/main/services/settingsStore.ts" - ], - "data": { - "model": "Settings", - "sourceOfTruth": "main SettingsStore persists; SETTINGS_SERVICE interface consumed by core/ui", - "derivedProjections": ["settings UI", "per-feature settings gates"] - }, - "acceptance": [ - "settings persistence stays main behind a SETTINGS_SERVICE interface consumed via DI", - "settings feature moves to packages/ui; settingsStore stays thin", - "no cross-store reach-ins for settings; consumers inject SETTINGS_SERVICE", - "smoke test: change a setting, it persists and gates the relevant feature" - ], - "passes": false, - "notes": "feature ~6019. settingsStore has existing tests. Many features depend on SETTINGS_SERVICE (notifications already references it) — define the interface early even if the big UI move comes later." - }, - { - "id": "ui-sidebar", - "category": "ui-feature", - "priority": 22, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/features/sidebar", - "apps/code/src/renderer/features/right-sidebar", - "apps/code/src/renderer/features/panels", - "apps/code/src/renderer/stores/createSidebarStore.ts", - "apps/code/src/renderer/stores/headerStore.ts" - ], - "data": { - "model": "layout/panel UI state", - "sourceOfTruth": "sidebar/panel stores (pure UI state)", - "derivedProjections": ["sidebar/panel layout"] - }, - "acceptance": [ - "sidebar/right-sidebar/panels move to packages/ui", - "stores remain pure UI state", - "route/panel registration via contributions where applicable", - "smoke test: open/close/resize panels and sidebars" - ], - "passes": false, - "notes": "sidebar ~3827, panels ~3396, right-sidebar ~61. Mostly pure UI; good candidates once foundation lands." - }, - { - "id": "ui-command", - "category": "ui-feature", - "priority": 23, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/features/command", - "apps/code/src/renderer/features/command-center", - "apps/code/src/renderer/features/actions", - "apps/code/src/renderer/stores/commandMenuStore.ts", - "apps/code/src/renderer/stores/shortcutsSheetStore.ts", - "apps/code/src/renderer/constants/keyboard-shortcuts.ts" - ], - "data": { - "model": "Command / Action", - "sourceOfTruth": "command registry (candidate for command contributions)", - "derivedProjections": ["command palette", "shortcuts sheet"] - }, - "acceptance": [ - "commands register via WORKBENCH_CONTRIBUTION command contributions, not ad hoc", - "command/command-center/actions move to packages/ui", - "stores stay thin", - "smoke test: command palette opens and runs a command" - ], - "passes": false, - "notes": "command ~536, command-center ~1328, actions ~140. Natural fit for the contribution model." - }, - { - "id": "ui-onboarding", - "category": "ui-feature", - "priority": 20, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/features/onboarding", - "apps/code/src/renderer/features/setup", - "apps/code/src/renderer/features/tour" - ], - "data": { - "model": "OnboardingState", - "sourceOfTruth": "audit: setup run service + onboarding state", - "derivedProjections": ["onboarding/setup/tour UI"] - }, - "acceptance": [ - "onboarding/setup/tour move to packages/ui", - "SetupRunService (currently a renderer DI service) re-evaluated: any data fetching/orchestration moves to core", - "smoke test: first-run onboarding completes" - ], - "passes": false, - "notes": "onboarding ~2976, setup ~1848, tour ~804. setup has a renderer SetupRunService bound in renderer DI today." - }, - { - "id": "ui-skills", - "category": "ui-feature", - "priority": 26, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/features/skills", - "apps/code/src/renderer/features/skill-buttons", - "apps/code/src/main/trpc/routers/skills.ts" - ], - "data": { - "model": "Skill", - "sourceOfTruth": "skills router (no backing service today — add one)", - "derivedProjections": ["skills/skill-buttons UI"] - }, - "acceptance": [ - "skills router gets a backing service; host ops (fs/skill pull) to workspace-server", - "skills/skill-buttons move to packages/ui", - "smoke test: list skills, trigger a skill button" - ], - "passes": false, - "notes": "skills ~366, skill-buttons ~395. Check whether skills.ts has a backing service." - }, - { - "id": "ui-folder-picker", - "category": "ui-feature", - "priority": 24, - "status": "todo", - "claimedBy": null, - "paths": ["apps/code/src/renderer/features/folder-picker"], - "data": { - "model": "folder picker UI", - "sourceOfTruth": "platform dialog/file-picker + folders service", - "derivedProjections": ["folder picker dialog"] - }, - "acceptance": [ - "folder-picker moves to packages/ui", - "uses platform dialog/file-picker capability, not direct trpcClient", - "smoke test: pick a folder via the picker" - ], - "passes": false, - "notes": "feature ~583. Pairs with folders + dialog-capability slices. May be folded into folders." - }, - { - "id": "ui-ai-approval", - "category": "ui-feature", - "priority": 28, - "status": "todo", - "claimedBy": null, - "paths": ["apps/code/src/renderer/features/ai-approval"], - "data": { - "model": "ApprovalRequest (permission via tool call)", - "sourceOfTruth": "agent permission tool calls (ACP types)", - "derivedProjections": ["approval prompts"] - }, - "acceptance": [ - "ai-approval moves to packages/ui", - "approvals are driven by agent permission tool calls using ACP SDK types, not hand-rolled permission_request patterns", - "smoke test: an agent tool permission prompt appears and approve/deny works" - ], - "passes": false, - "notes": "feature ~169. Tied to agent slice." - }, - { - "id": "ui-code-editor", - "category": "ui-feature", - "priority": 16, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/features/code-editor", - "apps/code/src/renderer/features/editor" - ], - "data": { - "model": "EditorDocument", - "sourceOfTruth": "fs capability (file contents) + CodeMirror UI state", - "derivedProjections": ["editor panes"] - }, - "acceptance": [ - "code-editor/editor move to packages/ui consuming fs capability via workspace-client", - "file read/write through workspace-server fs, not direct main calls", - "smoke test: open a file, edit, save" - ], - "passes": false, - "notes": "code-editor ~1581, editor ~492. Depends on fs-capability." - }, - { - "id": "ui-code-review", - "category": "ui-feature", - "priority": 14, - "status": "todo", - "claimedBy": null, - "paths": ["apps/code/src/renderer/features/code-review"], - "data": { - "model": "ReviewDiff / ReviewComment", - "sourceOfTruth": "git capability (diffs) + gh client (PR data)", - "derivedProjections": ["code-review UI"] - }, - "acceptance": [ - "code-review moves to packages/ui consuming git/diff + gh data via workspace-client/core", - "no multi-query orchestration hooks — merged shape comes from a procedure", - "smoke test: open a PR/diff, view + comment" - ], - "passes": false, - "notes": "feature ~4243. Depends on git-core + diff-stats. Entangled." - }, - { - "id": "ui-git-interaction", - "category": "ui-feature", - "priority": 15, - "status": "todo", - "claimedBy": null, - "paths": ["apps/code/src/renderer/features/git-interaction"], - "data": { - "model": "git working-tree interaction (stage/commit/branch UI)", - "sourceOfTruth": "git capability in workspace-server", - "derivedProjections": ["git-interaction UI"] - }, - "acceptance": [ - "git-interaction moves to packages/ui consuming workspace-client git procedures", - "no git logic in the store/components", - "smoke test: stage, commit, switch branch from the UI" - ], - "passes": false, - "notes": "feature ~4921. Depends on git-core. Bundle with or after git-core." - }, - { - "id": "ui-message-editor", - "category": "ui-feature", - "priority": 13, - "status": "todo", - "claimedBy": null, - "paths": ["apps/code/src/renderer/features/message-editor"], - "data": { - "model": "DraftMessage", - "sourceOfTruth": "Tiptap editor state (UI) + cloud-prompt encoding (@posthog/shared)", - "derivedProjections": ["composed prompt"] - }, - "acceptance": [ - "message-editor moves to packages/ui", - "prompt encoding uses @posthog/shared cloud-prompt, not inline logic", - "smoke test: compose a message with attachments/mentions and send" - ], - "passes": false, - "notes": "feature ~4715. Tied to sessions + agent." - }, - { - "id": "ui-task-detail", - "category": "ui-feature", - "priority": 12, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/features/task-detail", - "apps/code/src/renderer/features/tasks", - "apps/code/src/renderer/sagas/task" - ], - "data": { - "model": "Task / TaskDetail", - "sourceOfTruth": "audit: TaskService (currently a renderer DI service) — move data/orchestration to core", - "derivedProjections": ["task-detail + tasks UI"] - }, - "acceptance": [ - "TaskService data fetching/orchestration moves to core (it is a renderer service today, bound in renderer DI)", - "task-detail + tasks move to packages/ui; stores thin", - "no renderer service fetching domain data", - "smoke test: open a task, view detail, perform a task action" - ], - "passes": false, - "notes": "task-detail ~5228, tasks ~822. TaskService is bound in renderer DI container today (renderer-service-fetching-domain-data forbidden pattern). Tied to sessions/cloud-task." - }, - { - "id": "ui-inbox", - "category": "ui-feature", - "priority": 11, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/features/inbox", - "apps/code/src/renderer/stores/pendingTaskPromptStore.ts" - ], - "data": { - "model": "InboxItem", - "sourceOfTruth": "audit: inbox data source (likely PostHog API + local) — carve into core", - "derivedProjections": ["inbox list/detail UI"] - }, - "acceptance": [ - "inbox data/orchestration moves to core; inbox-prompts uses @posthog/shared", - "inbox feature moves to packages/ui; stores thin", - "smoke test: inbox loads items, open + act on one" - ], - "passes": false, - "notes": "feature ~10417 (second largest). Tied to inbox-link deep link + sessions. Sub-slice during claim." - }, - { - "id": "sessions", - "category": "ui-feature", - "priority": 10, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/features/sessions", - "apps/code/src/renderer/stores/cloneStore.ts", - "apps/code/src/renderer/stores/navigationStore.ts" - ], - "data": { - "model": "Session / Clone", - "sourceOfTruth": "audit: the 3796-line renderer sessions service (named canonical forbidden example) — move to core/workspace-server", - "derivedProjections": ["sessions UI", "cloneStore", "navigation"] - }, - "acceptance": [ - "the large renderer sessions service is dismantled: host work to workspace-server, orchestration to core, UI to packages/ui", - "cloneStore stops owning timers for domain cleanup (host emits Removed events)", - "no module-level subscriptions; subscriptions via contributions", - "stores become thin; no cross-store reach-ins", - "smoke test: create a session/clone, run an agent turn, clean up" - ], - "passes": false, - "notes": "feature ~15718 (largest). The canonical 'move large entangled surface last' slice (REFACTOR.md Recommended Order step 6). Depends on agent, git-core, fs, terminal-pty, cloud-task. MUST be sub-sliced before work; do not claim as one unit." - }, - - { - "id": "ui-primitives", - "category": "ui-shared", - "priority": 83, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/components/ui", - "apps/code/src/renderer/components/action-selector", - "apps/code/src/renderer/components/ActionSelector.tsx", - "apps/code/src/renderer/components/CodeBlock.tsx", - "apps/code/src/renderer/components/HighlightedCode.tsx", - "apps/code/src/renderer/components/List.tsx", - "apps/code/src/renderer/components/Divider.tsx", - "apps/code/src/renderer/components/DotsCircleSpinner.tsx", - "apps/code/src/renderer/components/DotPatternBackground.tsx", - "apps/code/src/renderer/components/TreeDirectoryRow.tsx", - "apps/code/src/renderer/components/HeaderRow.tsx", - "apps/code/src/renderer/components/HedgehogMode.tsx", - "apps/code/src/renderer/components/ZenHedgehog.tsx", - "apps/code/src/renderer/hooks/useDebounce.ts", - "apps/code/src/renderer/hooks/useDebouncedValue.ts", - "apps/code/src/renderer/hooks/useInView.ts", - "apps/code/src/renderer/hooks/useBlurOnEscape.ts", - "apps/code/src/renderer/hooks/useAutoFocusOnTyping.ts", - "apps/code/src/renderer/hooks/useImagePanAndZoom.ts", - "apps/code/src/renderer/utils/toast.tsx", - "apps/code/src/renderer/utils/focusToast.tsx", - "apps/code/src/renderer/utils/confetti.ts", - "apps/code/src/renderer/utils/syntax-highlight.ts", - "packages/ui/src/primitives" - ], - "data": { - "model": "shared visual building blocks + generic UI hooks", - "sourceOfTruth": "packages/ui/src/primitives owns reusable, host-agnostic components and hooks shared across features", - "derivedProjections": [] - }, - "acceptance": [ - "genuinely cross-feature primitives move to packages/ui/src/primitives (REFACTOR.md 'Porting React UI'): components/ui/*, shared visuals, action-selector, generic hooks (useDebounce, useInView, useBlurOnEscape, useAutoFocusOnTyping, useImagePanAndZoom)", - "no primitive imports trpcClient, Electron, apps/code, or workspace-server code", - "a one-feature component is NOT promoted to a primitive just because it moved", - "Quill (@posthog/quill) is preferred where it has an equivalent; raw primitives only fill genuine gaps (AGENTS.md R11)", - "colocated tests/stories move with the component", - "smoke test: a feature renders using the migrated primitives with no app-path imports" - ], - "passes": false, - "notes": "Should land EARLY: feature UI slices import primitives, and the new rule forbids feature components in packages/ui from importing apps/code. components/ ~7038 LOC total (subset is primitives; the rest is shell/permissions/feature). Reconcile against @posthog/quill before recreating primitives." - }, - { - "id": "ui-shell", - "category": "ui-shared", - "priority": 19, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/App.tsx", - "apps/code/src/renderer/main.tsx", - "apps/code/src/renderer/components/Providers.tsx", - "apps/code/src/renderer/components/MainLayout.tsx", - "apps/code/src/renderer/components/FullScreenLayout.tsx", - "apps/code/src/renderer/components/ThemeWrapper.tsx", - "apps/code/src/renderer/components/BackgroundWrapper.tsx", - "apps/code/src/renderer/components/GlobalEventHandlers.tsx", - "apps/code/src/renderer/components/ErrorBoundary.tsx", - "apps/code/src/renderer/components/DraggableTitleBar.tsx", - "apps/code/src/renderer/components/ResizableSidebar.tsx", - "apps/code/src/renderer/components/SpaceSwitcher.tsx", - "apps/code/src/renderer/components/LoginTransition.tsx", - "apps/code/src/renderer/components/ScopeReauthPrompt.tsx", - "apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx", - "apps/code/src/renderer/styles", - "apps/code/src/renderer/utils/queryClient.ts" - ], - "data": { - "model": "workbench shell (app root, providers, layout, boot)", - "sourceOfTruth": "startWorkbench (di package) owns boot; App.tsx auth-gating + ad-hoc subscription registration get dismantled into contributions", - "derivedProjections": ["rendered app frame"] - }, - "acceptance": [ - "App.tsx stops registering subscriptions/initializers inline (initialize*Store, registerBillingSubscriptions, useSubscription side effects) — these become WORKBENCH_CONTRIBUTIONs started by startWorkbench", - "layout/shell components move to packages/ui (shell), importing no trpcClient/Electron directly", - "auth-gate routing (AuthScreen vs MainLayout) is driven by injected auth service state, not cross-store reach-ins", - "route registration is owned by feature modules/contributions, not a central app list", - "smoke test: app boots through startWorkbench, renders the authed shell, and a contributed route loads" - ], - "passes": false, - "notes": "App.tsx is the boot/auth-gate/subscription hub (imports ~15 feature initializers today). Heavily depends on di-foundation and auth. main.tsx also referenced by di-foundation — coordinate. Low priority (entangled, late) but it is where the contribution model proves out end to end." - }, - { - "id": "ui-permissions", - "category": "ui-feature", - "priority": 29, - "status": "todo", - "claimedBy": null, - "paths": ["apps/code/src/renderer/components/permissions"], - "data": { - "model": "Permission request (ACP tool-call permission)", - "sourceOfTruth": "agent permission tool calls using @anthropic-ai/claude-agent-sdk (ACP) types", - "derivedProjections": [ - "per-permission UI (read/edit/execute/fetch/move/delete/mcp/...)" - ] - }, - "acceptance": [ - "permission components move to packages/ui (likely under the agent/ai-approval feature)", - "permission types come from the ACP SDK, not hand-rolled types (AGENTS.md + global rule)", - "permissions are rendered from agent tool-call permission requests, not a custom permission_request channel", - "smoke test: each permission type renders and approve/deny round-trips to the agent" - ], - "passes": false, - "notes": "14 permission components in components/permissions/. Tightly coupled to agent + ai-approval slices; sequence together." - }, - { - "id": "renderer-shared-hooks", - "category": "ui-shared", - "priority": 27, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/hooks/useAuthenticatedClient.ts", - "apps/code/src/renderer/hooks/useAuthenticatedQuery.ts", - "apps/code/src/renderer/hooks/useAuthenticatedMutation.ts", - "apps/code/src/renderer/hooks/useAuthenticatedInfiniteQuery.ts", - "apps/code/src/renderer/hooks/useConnectivity.ts", - "apps/code/src/renderer/hooks/useIntegrations.ts", - "apps/code/src/renderer/hooks/useMeQuery.ts", - "apps/code/src/renderer/hooks/useSeat.ts", - "apps/code/src/renderer/hooks/useFeatureFlag.ts", - "apps/code/src/renderer/hooks/useProjectQuery.ts", - "apps/code/src/renderer/hooks/useRepoFiles.ts", - "apps/code/src/renderer/hooks/useRepositoryDirectory.ts", - "apps/code/src/renderer/hooks/useDetectedCloudRepository.ts", - "apps/code/src/renderer/hooks/useTaskContextMenu.ts", - "apps/code/src/renderer/hooks/useTaskDeepLink.ts", - "apps/code/src/renderer/hooks/useNewTaskDeepLink.ts", - "apps/code/src/renderer/hooks/useSetHeaderContent.ts" - ], - "data": { - "model": "feature-coupled renderer hooks", - "sourceOfTruth": "each hook wraps one query/mutation/subscription for a specific feature", - "derivedProjections": [] - }, - "acceptance": [ - "each hook moves to its owning feature in packages/ui (e.g. useMeQuery/useSeat/useAuthenticated*->auth, useConnectivity->connectivity, useIntegrations->integrations, useProjectQuery->projects, useRepoFiles/useRepositoryDirectory/useDetectedCloudRepository->workspace, useTask*DeepLink->deep-links)", - "any hook that orchestrates multiple queries is collapsed into a single service procedure (AGENTS.md R4)", - "no hook imports trpcClient directly — they wrap useService + TanStack Query", - "this slice is a tracking/redistribution slice: it passes when every listed hook has a home or is consumed via its feature slice" - ], - "passes": false, - "notes": "These hooks live in renderer/hooks/ (not feature dirs) so they were not caught by feature-dir paths. Most should migrate WITH their owning feature slice; this slice exists so they are explicitly accounted for and not orphaned. useFileWatcher.ts already migrated to packages/ui (file-watcher slice)." - }, - { - "id": "renderer-shared-utils", - "category": "ui-shared", - "priority": 31, - "status": "todo", - "claimedBy": null, - "paths": [ - "apps/code/src/renderer/utils", - "apps/code/src/renderer/types", - "apps/code/src/renderer/assets" - ], - "data": { - "model": "shared renderer utilities + types + assets", - "sourceOfTruth": "split by dependency: host-agnostic -> @posthog/ui or @posthog/shared; host-coupled -> platform adapter", - "derivedProjections": [] - }, - "acceptance": [ - "host-agnostic utils (object, path, time, random, xml, urls, posthogLinks, links, generateTitle, promptContent, sendMessageKey, agentVersion, session, repository, getFilePath) move to @posthog/ui or @posthog/shared", - "host-coupled utils (electronStorage, dialog, notifications, sounds, browser, platform, clearStorage, handleExternalAppAction, overlay) move behind a @posthog/platform interface + app adapter — no Electron import left in shared code", - "logger.ts uses the scoped logger pattern; queryClient.ts handled by ui-shell", - "renderer/types: electron.d.ts stays in apps/code (host ambient types); rehype.d.ts moves to @posthog/ui", - "assets referenced by package UI move to packages/ui/src/assets; app-only assets stay", - "colocated util tests move with their util and stay green" - ], - "passes": false, - "notes": "utils ~2052 LOC, the biggest cross-cutting cleanup. Sub-slice during claim by destination (ui vs shared vs platform). utils/analytics.* is owned by the `analytics` slice; constants/keyboard-shortcuts by `ui-command`; sagas/task by `ui-task-detail` — excluded here to avoid double-ownership." - } - ] -} diff --git a/apps/code/.storybook/main.ts b/apps/code/.storybook/main.ts index 7685acbeca..2f17dfef61 100644 --- a/apps/code/.storybook/main.ts +++ b/apps/code/.storybook/main.ts @@ -15,7 +15,12 @@ function getAbsolutePath(value: string) { } const config: StorybookConfig = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + stories: [ + "../src/**/*.mdx", + "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)", + "../../../packages/ui/src/**/*.mdx", + "../../../packages/ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)", + ], addons: [ getAbsolutePath("@storybook/addon-a11y"), getAbsolutePath("@storybook/addon-docs"), diff --git a/apps/code/.storybook/preview.tsx b/apps/code/.storybook/preview.tsx index 2e37877231..471e44702a 100644 --- a/apps/code/.storybook/preview.tsx +++ b/apps/code/.storybook/preview.tsx @@ -2,7 +2,7 @@ import "./mocks/electron-trpc"; import { Theme } from "@radix-ui/themes"; import "@radix-ui/themes/styles.css"; import type { Preview } from "@storybook/react-vite"; -import "../src/renderer/styles/globals.css"; +import "../../../packages/ui/src/styles/globals.css"; const preview: Preview = { parameters: { diff --git a/apps/code/package.json b/apps/code/package.json index 2a3af5f702..30d18b74e3 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -29,10 +29,7 @@ "postinstall": "bash scripts/postinstall.sh", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "clean": "node ../../scripts/rimraf.mjs .vite .turbo out node_modules/.vite", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:view": "drizzle-kit studio" + "clean": "node ../../scripts/rimraf.mjs .vite .turbo out node_modules/.vite" }, "keywords": [ "posthog", @@ -51,10 +48,10 @@ "@electron-forge/plugin-vite": "^7.11.1", "@electron-forge/publisher-github": "^7.11.1", "@electron-forge/shared-types": "^7.11.1", - "@reforged/maker-appimage": "^5.2.0", "@electron/rebuild": "^4.0.3", "@playwright/test": "^1.42.0", "@posthog/rollup-plugin": "^1.4.0", + "@reforged/maker-appimage": "^5.2.0", "@storybook/addon-a11y": "10.2.0", "@storybook/addon-docs": "10.2.0", "@storybook/react-vite": "10.2.0", @@ -70,9 +67,7 @@ "@vitejs/plugin-react": "^4.2.1", "@vitest/ui": "^4.0.10", "adm-zip": "^0.5.16", - "drizzle-kit": "^0.31.9", "electron": "^41.0.0", - "fuse.js": "^7.1.0", "husky": "^9.1.7", "jimp": "^1.6.0", "jsdom": "^26.0.0", @@ -84,43 +79,13 @@ "tsx": "^4.20.6", "typed-openapi": "^2.2.2", "typescript": "^5.9.3", - "virtua": "^0.48.6", "vite": "^6.0.7", "vite-tsconfig-paths": "^5.1.4", "vitest": "^4.0.10", "yaml": "^2.8.1" }, "dependencies": { - "@base-ui/react": "^1.3.0", - "@codemirror/lang-angular": "^0.1.4", - "@codemirror/lang-cpp": "^6.0.3", - "@codemirror/lang-css": "^6.3.1", - "@codemirror/lang-go": "^6.0.1", - "@codemirror/lang-html": "^6.4.11", - "@codemirror/lang-java": "^6.0.2", - "@codemirror/lang-javascript": "^6.2.4", - "@codemirror/lang-jinja": "^6.0.0", - "@codemirror/lang-json": "^6.0.2", - "@codemirror/lang-liquid": "^6.3.0", - "@codemirror/lang-markdown": "^6.5.0", - "@codemirror/lang-php": "^6.0.2", - "@codemirror/lang-python": "^6.2.1", - "@codemirror/lang-rust": "^6.0.2", - "@codemirror/lang-sass": "^6.0.2", - "@codemirror/lang-sql": "^6.10.0", - "@codemirror/lang-vue": "^0.1.3", - "@codemirror/lang-wast": "^6.0.2", - "@codemirror/lang-xml": "^6.1.0", - "@codemirror/lang-yaml": "^6.1.2", - "@codemirror/language": "^6.12.2", - "@codemirror/search": "^6.6.0", - "@codemirror/state": "^6.5.4", - "@codemirror/view": "^6.39.17", - "@dnd-kit/react": "^0.1.21", "@fontsource-variable/inter": "^5.2.8", - "@lezer/common": "^1.5.1", - "@lezer/highlight": "^1.2.3", - "@modelcontextprotocol/ext-apps": "^1.1.2", "@modelcontextprotocol/sdk": "^1.12.1", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", @@ -133,41 +98,27 @@ "@posthog/agent": "workspace:*", "@posthog/api-client": "workspace:*", "@posthog/core": "workspace:*", + "@posthog/di": "workspace:*", "@posthog/electron-trpc": "workspace:*", "@posthog/enricher": "workspace:*", "@posthog/git": "workspace:*", "@posthog/hedgehog-mode": "^0.0.48", + "@posthog/host-router": "workspace:*", + "@posthog/host-trpc": "workspace:*", "@posthog/platform": "workspace:*", "@posthog/quill": "0.3.0-beta.1", "@posthog/shared": "workspace:*", "@posthog/ui": "workspace:*", "@posthog/workspace-client": "workspace:*", "@posthog/workspace-server": "workspace:*", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-icons": "^1.3.2", "@radix-ui/themes": "^3.2.1", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.90.2", - "@tiptap/core": "^3.13.0", - "@tiptap/extension-mention": "^3.13.0", - "@tiptap/extension-placeholder": "^3.13.0", - "@tiptap/pm": "^3.13.0", - "@tiptap/react": "^3.13.0", - "@tiptap/starter-kit": "^3.13.0", - "@tiptap/suggestion": "^3.13.0", "@trpc/client": "^11.12.0", "@trpc/server": "^11.12.0", "@trpc/tanstack-react-query": "^11.12.0", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-serialize": "^0.13.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/xterm": "^5.5.0", "better-sqlite3": "^12.8.0", - "canvas-confetti": "^1.9.4", "chokidar": "^5.0.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", "detect-libc": "^1.0.3", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", @@ -175,42 +126,25 @@ "electron-store": "^11.0.0", "fflate": "^0.8.2", "file-icon": "^6.0.0", - "framer-motion": "^12.26.2", "fzf": "^0.5.2", "ignore": "^7.0.5", - "immer": "^11.0.1", "inversify": "^7.10.6", "is-glob": "^4.0.3", - "lucide-react": "^1.7.0", "micromatch": "^4.0.5", "node-addon-api": "^8.5.0", "node-machine-id": "^1.1.12", "node-pty": "1.1.0", - "posthog-js": "^1.283.0", "posthog-node": "^5.24.10", "radix-themes-tw": "0.2.3", "react": "19.1.0", "react-dom": "19.1.0", "react-hotkeys-hook": "^4.4.4", - "react-markdown": "^10.1.0", - "react-resizable-panels": "^3.0.6", "reflect-metadata": "^0.2.2", - "rehype-raw": "^7.0.0", - "rehype-sanitize": "^6.0.0", - "remark-breaks": "^4.0.0", - "remark-gfm": "^4.0.1", "semver": "^7.6.0", "shadcn": "^4.1.2", "smol-toml": "^1.6.0", - "sonner": "^2.0.7", - "striptags": "^3.2.0", - "tailwind-merge": "^3.5.0", "tailwindcss-scroll-mask": "^0.0.3", - "tippy.js": "^6.3.7", "tw-animate-css": "^1.4.0", - "vaul": "^1.1.2", - "vscode-icons-js": "^11.6.1", - "zod": "^4.1.12", - "zustand": "^4.5.0" + "zod": "^4.1.12" } } diff --git a/apps/code/src/main/deep-links.ts b/apps/code/src/main/deep-links.ts index 5250515029..40559f11cb 100644 --- a/apps/code/src/main/deep-links.ts +++ b/apps/code/src/main/deep-links.ts @@ -1,4 +1,4 @@ -import { getDeeplinkProtocol } from "@shared/deeplink"; +import { getDeeplinkProtocol } from "@posthog/shared"; import { app } from "electron"; import { container } from "./di/container"; import { MAIN_TOKENS } from "./di/tokens"; diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 797abea0c5..7a8329aa14 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -1,20 +1,210 @@ import "reflect-metadata"; +import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises"; +import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; +import { + getGatewayInvalidatePlanCacheUrl, + getGatewayUsageUrl, + getLlmGatewayUrl, +} from "@posthog/agent/posthog-api"; +import { AuthService } from "@posthog/core/auth/auth"; +import { AUTH_SERVICE } from "@posthog/core/auth/auth.module"; +import { + AUTH_CONNECTIVITY, + AUTH_OAUTH_FLOW_SERVICE, + AUTH_PREFERENCE_STORE, + AUTH_SESSION_STORE, + AUTH_TOKEN_CIPHER, + AUTH_TOKEN_OVERRIDE, +} from "@posthog/core/auth/identifiers"; +import { cloudTaskModule } from "@posthog/core/cloud-task/cloud-task.module"; +import { + CLOUD_TASK_AUTH, + CLOUD_TASK_SERVICE, +} from "@posthog/core/cloud-task/identifiers"; +import { contextMenuCoreModule } from "@posthog/core/context-menu/context-menu.module"; +import { + CONTEXT_MENU_CONTROLLER, + CONTEXT_MENU_EXTERNAL_APPS_SERVICE, +} from "@posthog/core/context-menu/identifiers"; +import { FocusHostService } from "@posthog/core/focus/focus-service"; +import { FocusServiceEvent } from "@posthog/core/focus/identifiers"; +import { gitHostModule } from "@posthog/core/git/git-host.module"; +import type { + GitWorkspaceLookup, + HostGitWorkspaceClient, +} from "@posthog/core/git/host-git"; +import { + GIT_AGENT_SERVICE, + GIT_SERVICE, + GIT_WORKSPACE_CLIENT, + GIT_WORKSPACE_LOOKUP, +} from "@posthog/core/git/identifiers"; +import { gitPrModule } from "@posthog/core/git-pr/git-pr.module"; +import { GIT_DIFF_SOURCE } from "@posthog/core/git-pr/identifiers"; +import { handoffModule } from "@posthog/core/handoff/handoff.module"; +import { HANDOFF_HOST } from "@posthog/core/handoff/identifiers"; +import { integrationsModule } from "@posthog/core/integrations/integrations.module"; +import { + INBOX_LINK_SERVICE, + NEW_TASK_LINK_SERVICE, + TASK_LINK_SERVICE, +} from "@posthog/core/links/identifiers"; +import { InboxLinkService } from "@posthog/core/links/inbox-link"; +import { NewTaskLinkService } from "@posthog/core/links/new-task-link"; +import { TaskLinkService } from "@posthog/core/links/task-link"; +import { + LLM_GATEWAY_HOST, + LLM_GATEWAY_SERVICE, +} from "@posthog/core/llm-gateway/identifiers"; +import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway"; +import { llmGatewayModule } from "@posthog/core/llm-gateway/llm-gateway.module"; +import { MCP_APPS_SERVICE } from "@posthog/core/mcp-apps/identifiers"; +import { mcpAppsModule } from "@posthog/core/mcp-apps/mcp-apps.module"; +import { NOTIFICATION_SERVICE } from "@posthog/core/notification/identifiers"; +import { NotificationService } from "@posthog/core/notification/notification"; +import { + OAUTH_HOST, + type OAuthCallbackReceiver, +} from "@posthog/core/oauth/identifiers"; +import { oauthModule } from "@posthog/core/oauth/oauth.module"; +import { PROVISIONING_SERVICE } from "@posthog/core/provisioning/identifiers"; +import { ProvisioningService } from "@posthog/core/provisioning/provisioning"; +import { SLEEP_SERVICE } from "@posthog/core/sleep/identifiers"; +import { SleepService } from "@posthog/core/sleep/sleep"; +import { UI_AUTH } from "@posthog/core/ui/identifiers"; +import { uiModule } from "@posthog/core/ui/ui.module"; +import { + UPDATE_LIFECYCLE_SERVICE, + UPDATES_SERVICE, +} from "@posthog/core/updates/identifiers"; +import { updatesCoreModule } from "@posthog/core/updates/updates.module"; +import { USAGE_HOST } from "@posthog/core/usage/identifiers"; +import { usageMonitorModule } from "@posthog/core/usage/usage-monitor.module"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { listFilesContainingText } from "@posthog/git/queries"; +import { + GIT_PR_STATUS_PROVIDER, + type IGitPrStatus, +} from "@posthog/host-router/ports/git-pr-status"; +import { ANALYTICS_SERVICE } from "@posthog/platform/analytics"; +import { APP_LIFECYCLE_SERVICE } from "@posthog/platform/app-lifecycle"; +import { APP_META_SERVICE } from "@posthog/platform/app-meta"; +import { BUNDLED_RESOURCES_SERVICE } from "@posthog/platform/bundled-resources"; +import { CLIPBOARD_SERVICE } from "@posthog/platform/clipboard"; +import { CONTEXT_MENU_SERVICE } from "@posthog/platform/context-menu"; +import { CRYPTO_SERVICE } from "@posthog/platform/crypto"; +import { DEEP_LINK_SERVICE } from "@posthog/platform/deep-link"; +import { DIALOG_SERVICE } from "@posthog/platform/dialog"; +import { FILE_ICON_SERVICE } from "@posthog/platform/file-icon"; +import { IMAGE_PROCESSOR_SERVICE } from "@posthog/platform/image-processor"; +import { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; +import { NOTIFIER_SERVICE } from "@posthog/platform/notifier"; +import { POWER_MANAGER_SERVICE } from "@posthog/platform/power-manager"; +import { SECURE_STORAGE_SERVICE } from "@posthog/platform/secure-storage"; +import { STORAGE_PATHS_SERVICE } from "@posthog/platform/storage-paths"; +import { UPDATER_SERVICE } from "@posthog/platform/updater"; +import { URL_LAUNCHER_SERVICE } from "@posthog/platform/url-launcher"; +import { WORKSPACE_SETTINGS_SERVICE } from "@posthog/platform/workspace-settings"; +import type { WorkspaceClient } from "@posthog/workspace-client/client"; +import { databaseModule } from "@posthog/workspace-server/db/db.module"; +import { + ARCHIVE_REPOSITORY, + AUTH_PREFERENCE_REPOSITORY, + AUTH_SESSION_REPOSITORY, + DATABASE_SERVICE, + DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, + REPOSITORY_REPOSITORY, + SUSPENSION_REPOSITORY, + WORKSPACE_REPOSITORY, + WORKTREE_REPOSITORY, +} from "@posthog/workspace-server/db/identifiers"; +import { repositoriesModule } from "@posthog/workspace-server/db/repositories.module"; +import { additionalDirectoriesModule } from "@posthog/workspace-server/services/additional-directories/additional-directories.module"; +import type { AgentService } from "@posthog/workspace-server/services/agent/agent"; +import { agentModule } from "@posthog/workspace-server/services/agent/agent.module"; +import { + AGENT_AUTH, + AGENT_LOGGER, + AGENT_MCP_APPS, + AGENT_REPO_FILES, + AGENT_SERVICE, + AGENT_SLEEP_COORDINATOR, +} from "@posthog/workspace-server/services/agent/identifiers"; +import { AgentServiceEvent } from "@posthog/workspace-server/services/agent/schemas"; +import { archiveModule } from "@posthog/workspace-server/services/archive/archive.module"; +import { + ARCHIVE_FILE_WATCHER, + ARCHIVE_SESSION_CANCELLER, +} from "@posthog/workspace-server/services/archive/identifiers"; +import { authProxyModule } from "@posthog/workspace-server/services/auth-proxy/auth-proxy.module"; +import { AUTH_PROXY_AUTH } from "@posthog/workspace-server/services/auth-proxy/identifiers"; +import { enrichmentModule } from "@posthog/workspace-server/services/enrichment/enrichment.module"; +import { + ENRICHMENT_AUTH, + ENRICHMENT_FILE_READER, +} from "@posthog/workspace-server/services/enrichment/identifiers"; +import { externalAppsModule } from "@posthog/workspace-server/services/external-apps/external-apps.module"; +import { + EXTERNAL_APPS_SERVICE, + EXTERNAL_APPS_STORE, +} from "@posthog/workspace-server/services/external-apps/identifiers"; +import type { ExternalAppsPreferences } from "@posthog/workspace-server/services/external-apps/types"; +import { foldersModule } from "@posthog/workspace-server/services/folders/folders.module"; +import { + HANDOFF_GIT_GATEWAY, + HANDOFF_LOG_GATEWAY, +} from "@posthog/workspace-server/services/handoff/identifiers"; +import type { HandoffGitGateway } from "@posthog/workspace-server/services/handoff/ports"; +import { HandoffHostService } from "@posthog/workspace-server/services/handoff/service"; +import { LOGS_SERVICE } from "@posthog/workspace-server/services/local-logs/identifiers"; +import { mcpCallbackModule } from "@posthog/workspace-server/services/mcp-callback/mcp-callback.module"; +import { MCP_PROXY_AUTH } from "@posthog/workspace-server/services/mcp-proxy/identifiers"; +import { mcpProxyModule } from "@posthog/workspace-server/services/mcp-proxy/mcp-proxy.module"; +import { OAUTH_CALLBACK_SERVER } from "@posthog/workspace-server/services/oauth-callback/identifiers"; +import { oauthCallbackModule } from "@posthog/workspace-server/services/oauth-callback/oauth-callback.module"; +import { osModule } from "@posthog/workspace-server/services/os/os.module"; +import { POSTHOG_PLUGIN_SERVICE } from "@posthog/workspace-server/services/posthog-plugin/identifiers"; +import { posthogPluginModule } from "@posthog/workspace-server/services/posthog-plugin/posthog-plugin.module"; +import { PROCESS_TRACKING_SERVICE } from "@posthog/workspace-server/services/process-tracking/identifiers"; +import { processTrackingModule } from "@posthog/workspace-server/services/process-tracking/process-tracking.module"; +import { SECURE_STORE_SERVICE } from "@posthog/workspace-server/services/secure-store/identifiers"; +import { shellModule } from "@posthog/workspace-server/services/shell/shell.module"; +import { skillsModule } from "@posthog/workspace-server/services/skills/skills.module"; +import { + SUSPENSION_FILE_WATCHER, + SUSPENSION_SERVICE, + SUSPENSION_SESSION_CANCELLER, +} from "@posthog/workspace-server/services/suspension/identifiers"; +import { suspensionModule } from "@posthog/workspace-server/services/suspension/suspension.module"; +import { FileWatcherEventKind } from "@posthog/workspace-server/services/watcher/schemas"; +import { WATCHER_REGISTRY_SERVICE } from "@posthog/workspace-server/services/watcher-registry/identifiers"; +import { watcherRegistryModule } from "@posthog/workspace-server/services/watcher-registry/watcher-registry.module"; +import { + WORKSPACE_AGENT, + WORKSPACE_FILE_WATCHER, + WORKSPACE_FOCUS, + WORKSPACE_PROVISIONING, + WORKSPACE_SERVICE, +} from "@posthog/workspace-server/services/workspace/identifiers"; +import type { + WorkspaceAgent, + WorkspaceFileWatcher, + WorkspaceFocus, + WorkspaceProvisioning, +} from "@posthog/workspace-server/services/workspace/ports"; +import type { WorkspaceService } from "@posthog/workspace-server/services/workspace/workspace"; +import { workspaceModule } from "@posthog/workspace-server/services/workspace/workspace.module"; +import { workspaceMetadataModule } from "@posthog/workspace-server/services/workspace-metadata/workspace-metadata.module"; +import ExternalAppsStoreImpl from "electron-store"; import { Container } from "inversify"; -import { ArchiveRepository } from "../db/repositories/archive-repository"; -import { AuthPreferenceRepository } from "../db/repositories/auth-preference-repository"; -import { AuthSessionRepository } from "../db/repositories/auth-session-repository"; -import { DefaultAdditionalDirectoryRepository } from "../db/repositories/default-additional-directory-repository"; -import { RepositoryRepository } from "../db/repositories/repository-repository"; -import { SuspensionRepositoryImpl } from "../db/repositories/suspension-repository"; -import { WorkspaceRepository } from "../db/repositories/workspace-repository"; -import { WorktreeRepository } from "../db/repositories/worktree-repository"; -import { DatabaseService } from "../db/service"; +import type { FileWatcherBridge } from "../index"; import { ElectronAppLifecycle } from "../platform-adapters/electron-app-lifecycle"; import { ElectronAppMeta } from "../platform-adapters/electron-app-meta"; import { ElectronBundledResources } from "../platform-adapters/electron-bundled-resources"; import { ElectronClipboard } from "../platform-adapters/electron-clipboard"; import { ElectronContextMenu } from "../platform-adapters/electron-context-menu"; +import { ElectronCrypto } from "../platform-adapters/electron-crypto"; import { ElectronDialog } from "../platform-adapters/electron-dialog"; import { ElectronFileIcon } from "../platform-adapters/electron-file-icon"; import { ElectronImageProcessor } from "../platform-adapters/electron-image-processor"; @@ -25,133 +215,444 @@ import { ElectronSecureStorage } from "../platform-adapters/electron-secure-stor import { ElectronStoragePaths } from "../platform-adapters/electron-storage-paths"; import { ElectronUpdater } from "../platform-adapters/electron-updater"; import { ElectronUrlLauncher } from "../platform-adapters/electron-url-launcher"; -import { AgentAuthAdapter } from "../services/agent/auth-adapter"; -import { AgentService } from "../services/agent/service"; +import { electronUsageThresholdStore } from "../platform-adapters/electron-usage-threshold-store"; +import { ElectronWorkspaceSettings } from "../platform-adapters/electron-workspace-settings"; +import { posthogNodeAnalytics } from "../platform-adapters/posthog-analytics"; import { AppLifecycleService } from "../services/app-lifecycle/service"; -import { ArchiveService } from "../services/archive/service"; -import { AuthService } from "../services/auth/service"; -import { AuthProxyService } from "../services/auth-proxy/service"; -import { CloudTaskService } from "../services/cloud-task/service"; -import { ConnectivityService } from "../services/connectivity/service"; -import { ContextMenuService } from "../services/context-menu/service"; +import { + AuthPreferencePortAdapter, + AuthSessionPortAdapter, + ConnectivityPortAdapter, + OAuthFlowPortAdapter, + TokenCipherPortAdapter, +} from "../services/auth/port-adapters"; import { DeepLinkService } from "../services/deep-link/service"; -import { EnrichmentService } from "../services/enrichment/service"; -import { EnvironmentService } from "../services/environment/service"; -import { ExternalAppsService } from "../services/external-apps/service"; -import { FoldersService } from "../services/folders/service"; -import { FsService } from "../services/fs/service"; -import { GitService } from "../services/git/service"; -import { GitHubIntegrationService } from "../services/github-integration/service"; -import { HandoffService } from "../services/handoff/service"; -import { InboxLinkService } from "../services/inbox-link/service"; -import { LinearIntegrationService } from "../services/linear-integration/service"; -import { LlmGatewayService } from "../services/llm-gateway/service"; -import { LocalLogsService } from "../services/local-logs/service"; -import { McpAppsService } from "../services/mcp-apps/service"; -import { McpCallbackService } from "../services/mcp-callback/service"; -import { McpProxyService } from "../services/mcp-proxy/service"; -import { NewTaskLinkService } from "../services/new-task-link/service"; -import { NotificationService } from "../services/notification/service"; -import { OAuthService } from "../services/oauth/service"; -import { PosthogPluginService } from "../services/posthog-plugin/service"; -import { ProcessTrackingService } from "../services/process-tracking/service"; -import { ProvisioningService } from "../services/provisioning/service"; +import { EncryptionService } from "../services/encryption/service"; +import { SecureStoreService } from "../services/secure-store/service"; import { settingsStore } from "../services/settingsStore"; -import { ShellService } from "../services/shell/service"; -import { SlackIntegrationService } from "../services/slack-integration/service"; -import { SleepService } from "../services/sleep/service"; -import { SuspensionService } from "../services/suspension/service"; -import { TaskLinkService } from "../services/task-link/service"; -import { UIService } from "../services/ui/service"; -import { UpdatesService } from "../services/updates/service"; -import { UsageMonitorService } from "../services/usage-monitor/service"; -import { WatcherRegistryService } from "../services/watcher-registry/service"; -import { WorkspaceService } from "../services/workspace/service"; import { WorkspaceServerService } from "../services/workspace-server/service"; +import { getUserDataDir, isDevBuild } from "../utils/env"; +import { logger } from "../utils/logger"; +import { rendererStore } from "../utils/store"; import { MAIN_TOKENS } from "./tokens"; export const container = new Container({ defaultScope: "Singleton", }); -container.bind(MAIN_TOKENS.UrlLauncher).to(ElectronUrlLauncher); -container.bind(MAIN_TOKENS.StoragePaths).to(ElectronStoragePaths); -container.bind(MAIN_TOKENS.AppMeta).to(ElectronAppMeta); -container.bind(MAIN_TOKENS.Dialog).to(ElectronDialog); -container.bind(MAIN_TOKENS.Clipboard).to(ElectronClipboard); -container.bind(MAIN_TOKENS.FileIcon).to(ElectronFileIcon); -container.bind(MAIN_TOKENS.SecureStorage).to(ElectronSecureStorage); -container.bind(MAIN_TOKENS.MainWindow).to(ElectronMainWindow); -container.bind(MAIN_TOKENS.AppLifecycle).to(ElectronAppLifecycle); -container.bind(MAIN_TOKENS.PowerManager).to(ElectronPowerManager); -container.bind(MAIN_TOKENS.Updater).to(ElectronUpdater); -container.bind(MAIN_TOKENS.Notifier).to(ElectronNotifier); -container.bind(MAIN_TOKENS.ContextMenu).to(ElectronContextMenu); -container.bind(MAIN_TOKENS.BundledResources).to(ElectronBundledResources); -container.bind(MAIN_TOKENS.ImageProcessor).to(ElectronImageProcessor); +container.bind(URL_LAUNCHER_SERVICE).to(ElectronUrlLauncher); +container.bind(STORAGE_PATHS_SERVICE).to(ElectronStoragePaths); +container.bind(APP_META_SERVICE).to(ElectronAppMeta); +container.bind(DIALOG_SERVICE).to(ElectronDialog); +container.bind(CLIPBOARD_SERVICE).to(ElectronClipboard); +container.bind(CRYPTO_SERVICE).to(ElectronCrypto); +container.bind(ANALYTICS_SERVICE).toConstantValue(posthogNodeAnalytics); +container.bind(FILE_ICON_SERVICE).to(ElectronFileIcon); +container.bind(SECURE_STORAGE_SERVICE).to(ElectronSecureStorage); +container.bind(MAIN_WINDOW_SERVICE).to(ElectronMainWindow); +container.bind(APP_LIFECYCLE_SERVICE).to(ElectronAppLifecycle); +container.bind(POWER_MANAGER_SERVICE).to(ElectronPowerManager); +container.bind(UPDATER_SERVICE).to(ElectronUpdater); +container.bind(NOTIFIER_SERVICE).to(ElectronNotifier); +container.bind(CONTEXT_MENU_SERVICE).to(ElectronContextMenu); +container.bind(BUNDLED_RESOURCES_SERVICE).to(ElectronBundledResources); +container.bind(IMAGE_PROCESSOR_SERVICE).to(ElectronImageProcessor); +container.bind(WORKSPACE_SETTINGS_SERVICE).to(ElectronWorkspaceSettings); -container.bind(MAIN_TOKENS.DatabaseService).to(DatabaseService); +container.load(databaseModule, repositoriesModule); +container.bind(MAIN_TOKENS.DatabaseService).toService(DATABASE_SERVICE); container .bind(MAIN_TOKENS.AuthPreferenceRepository) - .to(AuthPreferenceRepository); -container.bind(MAIN_TOKENS.AuthSessionRepository).to(AuthSessionRepository); -container.bind(MAIN_TOKENS.RepositoryRepository).to(RepositoryRepository); -container.bind(MAIN_TOKENS.WorkspaceRepository).to(WorkspaceRepository); -container.bind(MAIN_TOKENS.WorktreeRepository).to(WorktreeRepository); -container.bind(MAIN_TOKENS.ArchiveRepository).to(ArchiveRepository); -container.bind(MAIN_TOKENS.SuspensionRepository).to(SuspensionRepositoryImpl); + .toService(AUTH_PREFERENCE_REPOSITORY); +container + .bind(MAIN_TOKENS.AuthSessionRepository) + .toService(AUTH_SESSION_REPOSITORY); +container + .bind(MAIN_TOKENS.RepositoryRepository) + .toService(REPOSITORY_REPOSITORY); +container.bind(MAIN_TOKENS.WorkspaceRepository).toService(WORKSPACE_REPOSITORY); +container.bind(MAIN_TOKENS.WorktreeRepository).toService(WORKTREE_REPOSITORY); +container.bind(MAIN_TOKENS.ArchiveRepository).toService(ARCHIVE_REPOSITORY); +container + .bind(MAIN_TOKENS.SuspensionRepository) + .toService(SUSPENSION_REPOSITORY); container .bind(MAIN_TOKENS.DefaultAdditionalDirectoryRepository) - .to(DefaultAdditionalDirectoryRepository); -container.bind(MAIN_TOKENS.AgentAuthAdapter).to(AgentAuthAdapter); -container.bind(MAIN_TOKENS.AgentService).to(AgentService); + .toService(DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY); +container.load(agentModule); +container.bind(AGENT_SLEEP_COORDINATOR).toService(MAIN_TOKENS.SleepService); +container.bind(AGENT_MCP_APPS).toService(MCP_APPS_SERVICE); +container.bind(AGENT_REPO_FILES).toService(MAIN_TOKENS.FsService); +container.bind(AGENT_AUTH).toService(MAIN_TOKENS.AuthService); +container.bind(AGENT_LOGGER).toConstantValue(logger); +container.load(osModule); +container.bind(WORKBENCH_LOGGER).toConstantValue(logger); +container.bind(AUTH_SESSION_STORE).to(AuthSessionPortAdapter); +container.bind(AUTH_PREFERENCE_STORE).to(AuthPreferencePortAdapter); +container.bind(AUTH_OAUTH_FLOW_SERVICE).to(OAuthFlowPortAdapter); +container.bind(AUTH_TOKEN_CIPHER).to(TokenCipherPortAdapter); +container.bind(AUTH_CONNECTIVITY).to(ConnectivityPortAdapter); +container + .bind(AUTH_TOKEN_OVERRIDE) + .toConstantValue(process.env.VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE ?? null); container.bind(MAIN_TOKENS.AuthService).to(AuthService); -container.bind(MAIN_TOKENS.AuthProxyService).to(AuthProxyService); -container.bind(MAIN_TOKENS.McpProxyService).to(McpProxyService); -container.bind(MAIN_TOKENS.ArchiveService).to(ArchiveService); -container.bind(MAIN_TOKENS.SuspensionService).to(SuspensionService); +container.bind(AUTH_SERVICE).toService(MAIN_TOKENS.AuthService); +container.load(authProxyModule); +container.bind(AUTH_PROXY_AUTH).toDynamicValue((ctx) => ({ + authenticatedFetch: (url: string, init?: RequestInit) => + ctx + .get(MAIN_TOKENS.AuthService) + .authenticatedFetch(fetch, url, init), +})); +container.load(mcpProxyModule); +container.bind(MCP_PROXY_AUTH).toDynamicValue((ctx) => { + const auth = () => ctx.get(MAIN_TOKENS.AuthService); + return { + authenticatedFetch: (url: string, init?: RequestInit) => + auth().authenticatedFetch(fetch, url, init), + refreshAccessToken: () => auth().refreshAccessToken(), + }; +}); +container.load(archiveModule); +container.bind(ARCHIVE_SESSION_CANCELLER).toDynamicValue((ctx) => ({ + cancelSessionsByTaskId: (taskId: string) => + ctx.get(AGENT_SERVICE).cancelSessionsByTaskId(taskId), +})); +container.bind(ARCHIVE_FILE_WATCHER).toDynamicValue((ctx) => ({ + stopWatching: async (worktreePath: string) => { + ctx + .get(MAIN_TOKENS.FileWatcherService) + .stopWatching(worktreePath); + }, +})); +container.load(suspensionModule); +container.bind(SUSPENSION_SESSION_CANCELLER).toDynamicValue((ctx) => ({ + cancelSessionsByTaskId: (taskId: string) => + ctx.get(AGENT_SERVICE).cancelSessionsByTaskId(taskId), +})); +container.bind(SUSPENSION_FILE_WATCHER).toDynamicValue((ctx) => ({ + stopWatching: async (worktreePath: string) => { + ctx + .get(MAIN_TOKENS.FileWatcherService) + .stopWatching(worktreePath); + }, +})); +container.bind(MAIN_TOKENS.SuspensionService).toService(SUSPENSION_SERVICE); container.bind(MAIN_TOKENS.AppLifecycleService).to(AppLifecycleService); -container.bind(MAIN_TOKENS.CloudTaskService).to(CloudTaskService); -container.bind(MAIN_TOKENS.ConnectivityService).to(ConnectivityService); -container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService); +container.load(cloudTaskModule); +container.bind(CLOUD_TASK_AUTH).toDynamicValue((ctx) => ({ + authenticatedFetch: (url: string, init?: RequestInit) => + ctx + .get(MAIN_TOKENS.AuthService) + .authenticatedFetch(fetch, url, init), +})); +container.bind(MAIN_TOKENS.CloudTaskService).toService(CLOUD_TASK_SERVICE); +container.load(contextMenuCoreModule); +container + .bind(CONTEXT_MENU_EXTERNAL_APPS_SERVICE) + .toService(MAIN_TOKENS.ExternalAppsService); +container + .bind(MAIN_TOKENS.ContextMenuService) + .toService(CONTEXT_MENU_CONTROLLER); container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService); -container.bind(MAIN_TOKENS.EnrichmentService).to(EnrichmentService); -container.bind(MAIN_TOKENS.EnvironmentService).to(EnvironmentService); +container.bind(DEEP_LINK_SERVICE).toService(MAIN_TOKENS.DeepLinkService); +container.load(enrichmentModule); +container.bind(ENRICHMENT_AUTH).toDynamicValue((ctx) => { + const auth = () => ctx.get(MAIN_TOKENS.AuthService); + return { + getState: () => { + const state = auth().getState(); + return { + status: state.status, + projectId: state.projectId ?? null, + cloudRegion: state.cloudRegion ?? null, + }; + }, + getValidAccessToken: async () => { + const token = await auth().getValidAccessToken(); + return { accessToken: token.accessToken, apiHost: token.apiHost }; + }, + }; +}); +container.bind(ENRICHMENT_FILE_READER).toConstantValue({ + stat: (p: string) => fsStat(p).then((s) => ({ size: s.size })), + readFile: (p: string) => fsReadFile(p, "utf-8"), + listFilesContainingText: (repoPath: string, text: string) => + listFilesContainingText(repoPath, text), +}); container.bind(MAIN_TOKENS.ProvisioningService).to(ProvisioningService); +container.bind(PROVISIONING_SERVICE).toService(MAIN_TOKENS.ProvisioningService); -container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService); -container.bind(MAIN_TOKENS.LlmGatewayService).to(LlmGatewayService); -container.bind(MAIN_TOKENS.McpAppsService).to(McpAppsService); -container.bind(MAIN_TOKENS.FoldersService).to(FoldersService); -container.bind(MAIN_TOKENS.FsService).to(FsService); -container - .bind(MAIN_TOKENS.GitHubIntegrationService) - .to(GitHubIntegrationService); -container.bind(MAIN_TOKENS.GitService).to(GitService); -container.bind(MAIN_TOKENS.HandoffService).to(HandoffService); -container - .bind(MAIN_TOKENS.LinearIntegrationService) - .to(LinearIntegrationService); -container.bind(MAIN_TOKENS.LocalLogsService).to(LocalLogsService); -container.bind(MAIN_TOKENS.McpCallbackService).to(McpCallbackService); -container.bind(MAIN_TOKENS.NotificationService).to(NotificationService); -container.bind(MAIN_TOKENS.OAuthService).to(OAuthService); -container.bind(MAIN_TOKENS.ProcessTrackingService).to(ProcessTrackingService); -container.bind(MAIN_TOKENS.PosthogPluginService).to(PosthogPluginService); +const externalAppsPrefsStore = new ExternalAppsStoreImpl<{ + externalAppsPrefs: ExternalAppsPreferences; +}>({ + name: "external-apps", + cwd: getUserDataDir(), + defaults: { externalAppsPrefs: {} }, +}); +container.bind(EXTERNAL_APPS_STORE).toConstantValue({ + getPrefs: () => externalAppsPrefsStore.get("externalAppsPrefs"), + setPrefs: (prefs: ExternalAppsPreferences) => + externalAppsPrefsStore.set("externalAppsPrefs", prefs), +}); +container.load(externalAppsModule); +container + .bind(MAIN_TOKENS.ExternalAppsService) + .toService(EXTERNAL_APPS_SERVICE); +container.load(llmGatewayModule); +container.bind(LLM_GATEWAY_HOST).toDynamicValue((ctx) => { + const auth = () => ctx.get(MAIN_TOKENS.AuthService); + return { + getValidAccessToken: () => auth().getValidAccessToken(), + authenticatedFetch: (url: string, init?: RequestInit) => + auth().authenticatedFetch(fetch, url, init), + messagesUrl: (apiHost: string) => + `${getLlmGatewayUrl(apiHost)}/v1/messages`, + usageUrl: (apiHost: string) => getGatewayUsageUrl(apiHost), + invalidatePlanCacheUrl: (apiHost: string) => + getGatewayInvalidatePlanCacheUrl(apiHost), + defaultModel: DEFAULT_GATEWAY_MODEL, + }; +}); +container.bind(MAIN_TOKENS.LlmGatewayService).toService(LLM_GATEWAY_SERVICE); +container.load(mcpAppsModule); +container.bind(MAIN_TOKENS.McpAppsService).toService(MCP_APPS_SERVICE); +container.load(foldersModule); +container.load(integrationsModule); +container.load(gitPrModule); +container.bind(GIT_DIFF_SOURCE).toDynamicValue(() => { + const wsClient = () => + container.get(GIT_WORKSPACE_CLIENT); + const git = () => wsClient().git; + return { + getStagedDiff: (directoryPath: string) => + git().getDiffCached.query({ directoryPath }), + getUnstagedDiff: (directoryPath: string) => + git().getDiffUnstaged.query({ directoryPath }), + getCommitConventions: (directoryPath: string) => + git().getCommitConventions.query({ directoryPath }), + getChangedFilesHead: (directoryPath: string) => + git().getChangedFilesHead.query({ directoryPath }), + getDefaultBranch: (directoryPath: string) => + git().getDefaultBranch.query({ directoryPath }), + getCurrentBranch: (directoryPath: string) => + git().getCurrentBranch.query({ directoryPath }), + getDiffAgainstRemote: (directoryPath: string, baseBranch: string) => + git().getDiffAgainstRemote.query({ directoryPath, baseBranch }), + getCommitsBetweenBranches: ( + directoryPath: string, + baseBranch: string, + head: string | undefined, + limit: number, + ) => + git().getCommitsBetweenBranches.query({ + directoryPath, + baseBranch, + head, + limit, + }), + getPrTemplate: (directoryPath: string) => + git().getPrTemplate.query({ directoryPath }), + fetchIfStale: async (directoryPath: string) => { + await git().getGitSyncStatus.query({ + directoryPath, + forceRefresh: true, + }); + }, + }; +}); +container + .bind(GIT_AGENT_SERVICE) + .toDynamicValue((ctx) => ctx.get(AGENT_SERVICE)); +container + .bind(GIT_WORKSPACE_LOOKUP) + .toDynamicValue((ctx): GitWorkspaceLookup => { + const workspace = () => ctx.get(WORKSPACE_SERVICE); + return { + getWorkspace: (taskId) => workspace().getWorkspace(taskId), + linkBranch: (taskId, branch, source) => + workspace().linkBranch(taskId, branch, source), + }; + }); +container.load(gitHostModule); +container.bind(GIT_PR_STATUS_PROVIDER).toService(GIT_SERVICE); +container.load(handoffModule); +container.bind(HANDOFF_HOST).to(HandoffHostService).inSingletonScope(); +container.bind(HANDOFF_GIT_GATEWAY).toDynamicValue((ctx): HandoffGitGateway => { + const workspace = ctx.get(MAIN_TOKENS.WorkspaceClient); + return { + async getChangedFiles(repoPath) { + const files = await workspace.git.getChangedFilesHead.query({ + directoryPath: repoPath, + }); + return files.map((f) => ({ + path: f.path, + status: f.status, + linesAdded: f.linesAdded, + linesRemoved: f.linesRemoved, + })); + }, + getLocalGitState: (repoPath) => + workspace.git.readHandoffLocalGitState.query({ + directoryPath: repoPath, + }), + cleanupAfterCloudHandoff: (repoPath, branchName) => + workspace.git.cleanupAfterCloudHandoff.mutate({ + directoryPath: repoPath, + branchName, + }), + }; +}); +container.bind(HANDOFF_LOG_GATEWAY).toDynamicValue((ctx) => { + const ws = ctx.get(MAIN_TOKENS.WorkspaceClient); + return { + seedLocalLogs: (taskRunId: string, content: string) => + ws.localLogs.seed.mutate({ taskRunId, content }), + countLocalLogEntries: (taskRunId: string) => + ws.localLogs.count.query({ taskRunId }), + deleteLocalLogCache: (taskRunId: string) => + ws.localLogs.delete.mutate({ taskRunId }), + }; +}); +container.load(mcpCallbackModule); +container.bind(NOTIFICATION_SERVICE).to(NotificationService); +container.load(oauthCallbackModule); +container.load(oauthModule); +container + .bind(OAUTH_HOST) + .toDynamicValue((ctx) => { + const callback = ctx.get(OAUTH_CALLBACK_SERVER); + return { + waitForCode: callback.waitForCode.bind(callback), + isDev: isDevBuild(), + }; + }) + .inSingletonScope(); +container.load(processTrackingModule); +container.load(workspaceMetadataModule); +container + .bind(MAIN_TOKENS.ProcessTrackingService) + .toService(PROCESS_TRACKING_SERVICE); +container.load(posthogPluginModule); +container + .bind(MAIN_TOKENS.PosthogPluginService) + .toService(POSTHOG_PLUGIN_SERVICE); +container.load(skillsModule); +container.load(additionalDirectoriesModule); container.bind(MAIN_TOKENS.SleepService).to(SleepService); -container.bind(MAIN_TOKENS.ShellService).to(ShellService); -container.bind(MAIN_TOKENS.SlackIntegrationService).to(SlackIntegrationService); -container.bind(MAIN_TOKENS.UIService).to(UIService); -container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService); -container.bind(MAIN_TOKENS.UsageMonitorService).to(UsageMonitorService); +container.bind(SLEEP_SERVICE).toService(MAIN_TOKENS.SleepService); +container.load(shellModule); +container.load(uiModule); +container.bind(UI_AUTH).toDynamicValue((ctx) => ({ + invalidateAccessTokenForTest: () => + ctx + .get(MAIN_TOKENS.AuthService) + .invalidateAccessTokenForTest(), +})); +container.load(updatesCoreModule); +container + .bind(UPDATE_LIFECYCLE_SERVICE) + .toService(MAIN_TOKENS.AppLifecycleService); +container.bind(MAIN_TOKENS.UpdatesService).toService(UPDATES_SERVICE); +container.load(usageMonitorModule); +container.bind(USAGE_HOST).toDynamicValue((ctx) => { + const agent = () => ctx.get(AGENT_SERVICE); + return { + fetchUsage: () => + ctx.get(MAIN_TOKENS.LlmGatewayService).fetchUsage(), + onLlmActivity: (listener: () => void) => + agent().on(AgentServiceEvent.LlmActivity, listener), + offLlmActivity: (listener: () => void) => + agent().off(AgentServiceEvent.LlmActivity, listener), + hasActiveSessions: () => agent().hasActiveSessions(), + getThresholdsSeen: () => electronUsageThresholdStore.getThresholdsSeen(), + setThresholdsSeen: (value: Record) => + electronUsageThresholdStore.setThresholdsSeen(value), + }; +}); container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService); +container.bind(TASK_LINK_SERVICE).toService(MAIN_TOKENS.TaskLinkService); container.bind(MAIN_TOKENS.InboxLinkService).to(InboxLinkService); +container.bind(INBOX_LINK_SERVICE).toService(MAIN_TOKENS.InboxLinkService); container.bind(MAIN_TOKENS.NewTaskLinkService).to(NewTaskLinkService); -container.bind(MAIN_TOKENS.WatcherRegistryService).to(WatcherRegistryService); -container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService); +container.bind(NEW_TASK_LINK_SERVICE).toService(MAIN_TOKENS.NewTaskLinkService); +container.load(watcherRegistryModule); +container + .bind(MAIN_TOKENS.WatcherRegistryService) + .toService(WATCHER_REGISTRY_SERVICE); +container.load(workspaceModule); +container.bind(WORKSPACE_AGENT).toDynamicValue((ctx): WorkspaceAgent => { + const agent = ctx.get(AGENT_SERVICE); + return { + cancelSessionsByTaskId: (taskId) => agent.cancelSessionsByTaskId(taskId), + onAgentFileActivity: (handler) => + agent.on(AgentServiceEvent.AgentFileActivity, handler), + }; +}); +container + .bind(WORKSPACE_FILE_WATCHER) + .toDynamicValue((ctx): WorkspaceFileWatcher => { + const fileWatcher = ctx.get( + MAIN_TOKENS.FileWatcherService, + ); + return { + stopWatching: async (worktreePath) => { + fileWatcher.stopWatching(worktreePath); + }, + onGitStateChanged: (handler) => + fileWatcher.on(FileWatcherEventKind.GitStateChanged, (event) => + handler({ repoPath: event.repoPath }), + ), + }; + }); +container.bind(WORKSPACE_FOCUS).toDynamicValue((ctx): WorkspaceFocus => { + const focus = ctx.get(FocusHostService); + return { + onBranchRenamed: (handler) => + focus.on(FocusServiceEvent.BranchRenamed, handler), + }; +}); +container + .bind(WORKSPACE_PROVISIONING) + .toDynamicValue((ctx): WorkspaceProvisioning => { + const provisioning = ctx.get( + MAIN_TOKENS.ProvisioningService, + ); + return { + emitOutput: (taskId, data) => provisioning.emitOutput(taskId, data), + }; + }); +container.bind(MAIN_TOKENS.WorkspaceService).toService(WORKSPACE_SERVICE); container .bind(MAIN_TOKENS.WorkspaceServerService) .to(WorkspaceServerService) .inSingletonScope(); container.bind(MAIN_TOKENS.SettingsStore).toConstantValue(settingsStore); + +container.bind(MAIN_TOKENS.SecureStoreBackend).toConstantValue(rendererStore); +container + .bind(MAIN_TOKENS.SecureStoreService) + .to(SecureStoreService) + .inSingletonScope(); +container.bind(SECURE_STORE_SERVICE).toService(MAIN_TOKENS.SecureStoreService); +container.bind(LOGS_SERVICE).toDynamicValue((ctx) => { + const ws = ctx.get(MAIN_TOKENS.WorkspaceClient); + return { + fetchS3Logs: async (logUrl: string) => { + try { + const response = await fetch(logUrl); + if (response.status === 404) return null; + if (!response.ok) return null; + return await response.text(); + } catch { + return null; + } + }, + readLocalLogs: (taskRunId: string) => + ws.localLogs.read.query({ taskRunId }), + writeLocalLogs: (taskRunId: string, content: string) => + ws.localLogs.write.mutate({ taskRunId, content }), + }; +}); +container.bind(MAIN_TOKENS.EncryptionService).to(EncryptionService); diff --git a/apps/code/src/main/di/platform-identifiers.test.ts b/apps/code/src/main/di/platform-identifiers.test.ts new file mode 100644 index 0000000000..081e566060 --- /dev/null +++ b/apps/code/src/main/di/platform-identifiers.test.ts @@ -0,0 +1,77 @@ +import { APP_LIFECYCLE_SERVICE } from "@posthog/platform/app-lifecycle"; +import { APP_META_SERVICE } from "@posthog/platform/app-meta"; +import { BUNDLED_RESOURCES_SERVICE } from "@posthog/platform/bundled-resources"; +import { CLIPBOARD_SERVICE } from "@posthog/platform/clipboard"; +import { CONTEXT_MENU_SERVICE } from "@posthog/platform/context-menu"; +import { DIALOG_SERVICE } from "@posthog/platform/dialog"; +import { FILE_ICON_SERVICE } from "@posthog/platform/file-icon"; +import { IMAGE_PROCESSOR_SERVICE } from "@posthog/platform/image-processor"; +import { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; +import { NOTIFIER_SERVICE } from "@posthog/platform/notifier"; +import { POWER_MANAGER_SERVICE } from "@posthog/platform/power-manager"; +import { SECURE_STORAGE_SERVICE } from "@posthog/platform/secure-storage"; +import { STORAGE_PATHS_SERVICE } from "@posthog/platform/storage-paths"; +import { UPDATER_SERVICE } from "@posthog/platform/updater"; +import { URL_LAUNCHER_SERVICE } from "@posthog/platform/url-launcher"; +import { Container, injectable } from "inversify"; +import { describe, expect, it } from "vitest"; + +const PLATFORM_IDENTIFIERS = { + APP_LIFECYCLE_SERVICE, + APP_META_SERVICE, + BUNDLED_RESOURCES_SERVICE, + CLIPBOARD_SERVICE, + CONTEXT_MENU_SERVICE, + DIALOG_SERVICE, + FILE_ICON_SERVICE, + IMAGE_PROCESSOR_SERVICE, + MAIN_WINDOW_SERVICE, + NOTIFIER_SERVICE, + POWER_MANAGER_SERVICE, + SECURE_STORAGE_SERVICE, + STORAGE_PATHS_SERVICE, + UPDATER_SERVICE, + URL_LAUNCHER_SERVICE, +}; + +describe("platform service identifiers", () => { + it("defines a symbol for every platform capability", () => { + const identifiers = Object.values(PLATFORM_IDENTIFIERS); + expect(identifiers).toHaveLength(15); + for (const identifier of identifiers) { + expect(typeof identifier).toBe("symbol"); + } + }); + + it("keys every identifier under the posthog.platform namespace", () => { + for (const identifier of Object.values(PLATFORM_IDENTIFIERS)) { + expect(identifier.description).toMatch(/^posthog\.platform\./); + } + }); + + it("uses mutually unique identifiers", () => { + const identifiers = Object.values(PLATFORM_IDENTIFIERS); + expect(new Set(identifiers).size).toBe(identifiers.length); + }); + + it("resolves a legacy alias to the same singleton as the platform token", () => { + const LEGACY_TOKEN = Symbol.for("test.legacy.clipboard"); + + @injectable() + class FakeClipboard { + writeText() { + return Promise.resolve(); + } + } + + const container = new Container({ defaultScope: "Singleton" }); + container.bind(CLIPBOARD_SERVICE).to(FakeClipboard); + container.bind(LEGACY_TOKEN).toService(CLIPBOARD_SERVICE); + + const viaPlatform = container.get(CLIPBOARD_SERVICE); + const viaLegacy = container.get(LEGACY_TOKEN); + + expect(viaPlatform).toBeInstanceOf(FakeClipboard); + expect(viaLegacy).toBe(viaPlatform); + }); +}); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index c7f6e174eb..c4869d730b 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -5,83 +5,61 @@ * Never import this file from renderer code. */ export const MAIN_TOKENS = Object.freeze({ - // Platform ports (host-agnostic interfaces from @posthog/platform) - UrlLauncher: Symbol.for("Platform.UrlLauncher"), - StoragePaths: Symbol.for("Platform.StoragePaths"), - AppMeta: Symbol.for("Platform.AppMeta"), - Dialog: Symbol.for("Platform.Dialog"), - Clipboard: Symbol.for("Platform.Clipboard"), - FileIcon: Symbol.for("Platform.FileIcon"), - SecureStorage: Symbol.for("Platform.SecureStorage"), - MainWindow: Symbol.for("Platform.MainWindow"), - AppLifecycle: Symbol.for("Platform.AppLifecycle"), - PowerManager: Symbol.for("Platform.PowerManager"), - Updater: Symbol.for("Platform.Updater"), - Notifier: Symbol.for("Platform.Notifier"), - ContextMenu: Symbol.for("Platform.ContextMenu"), - BundledResources: Symbol.for("Platform.BundledResources"), - ImageProcessor: Symbol.for("Platform.ImageProcessor"), + // Workspace-server connection (typed client over the ELECTRON_RUN_AS_NODE child) + WorkspaceClient: Symbol.for("posthog.host.main.workspace.client"), // Stores - SettingsStore: Symbol.for("Main.SettingsStore"), + SettingsStore: Symbol.for("posthog.host.main.settings.store"), + SecureStoreService: Symbol.for("posthog.host.main.secure-store.service"), + SecureStoreBackend: Symbol.for("posthog.host.main.secure-store.backend"), + EncryptionService: Symbol.for("posthog.host.main.encryption.service"), // Database - AuthPreferenceRepository: Symbol.for("Main.AuthPreferenceRepository"), - DatabaseService: Symbol.for("Main.DatabaseService"), - AuthSessionRepository: Symbol.for("Main.AuthSessionRepository"), - RepositoryRepository: Symbol.for("Main.RepositoryRepository"), - WorkspaceRepository: Symbol.for("Main.WorkspaceRepository"), - WorktreeRepository: Symbol.for("Main.WorktreeRepository"), - ArchiveRepository: Symbol.for("Main.ArchiveRepository"), - SuspensionRepository: Symbol.for("Main.SuspensionRepository"), + AuthPreferenceRepository: Symbol.for( + "posthog.host.main.auth.preference-repository", + ), + DatabaseService: Symbol.for("posthog.host.main.database.service"), + AuthSessionRepository: Symbol.for( + "posthog.host.main.auth.session-repository", + ), + RepositoryRepository: Symbol.for("posthog.host.main.repository.repository"), + WorkspaceRepository: Symbol.for("posthog.host.main.workspace.repository"), + WorktreeRepository: Symbol.for("posthog.host.main.worktree.repository"), + ArchiveRepository: Symbol.for("posthog.host.main.archive.repository"), + SuspensionRepository: Symbol.for("posthog.host.main.suspension.repository"), DefaultAdditionalDirectoryRepository: Symbol.for( - "Main.DefaultAdditionalDirectoryRepository", + "posthog.host.main.additional-directory.default-repository", ), // Services - AgentAuthAdapter: Symbol.for("Main.AgentAuthAdapter"), - AgentService: Symbol.for("Main.AgentService"), - AuthService: Symbol.for("Main.AuthService"), - AuthProxyService: Symbol.for("Main.AuthProxyService"), - McpProxyService: Symbol.for("Main.McpProxyService"), - ArchiveService: Symbol.for("Main.ArchiveService"), - SuspensionService: Symbol.for("Main.SuspensionService"), - AppLifecycleService: Symbol.for("Main.AppLifecycleService"), - CloudTaskService: Symbol.for("Main.CloudTaskService"), - ConnectivityService: Symbol.for("Main.ConnectivityService"), - ContextMenuService: Symbol.for("Main.ContextMenuService"), + AuthService: Symbol.for("posthog.host.main.auth.service"), + SuspensionService: Symbol.for("posthog.host.main.suspension.service"), + AppLifecycleService: Symbol.for("posthog.host.main.app-lifecycle.service"), + CloudTaskService: Symbol.for("posthog.host.main.cloud-task.service"), + ContextMenuService: Symbol.for("posthog.host.main.context-menu.service"), - ExternalAppsService: Symbol.for("Main.ExternalAppsService"), - LlmGatewayService: Symbol.for("Main.LlmGatewayService"), - McpAppsService: Symbol.for("Main.McpAppsService"), - FileWatcherService: Symbol.for("Main.FileWatcherService"), - FocusService: Symbol.for("Main.FocusService"), - FoldersService: Symbol.for("Main.FoldersService"), - FsService: Symbol.for("Main.FsService"), - GitService: Symbol.for("Main.GitService"), - HandoffService: Symbol.for("Main.HandoffService"), - GitHubIntegrationService: Symbol.for("Main.GitHubIntegrationService"), - LinearIntegrationService: Symbol.for("Main.LinearIntegrationService"), - SlackIntegrationService: Symbol.for("Main.SlackIntegrationService"), - LocalLogsService: Symbol.for("Main.LocalLogsService"), - DeepLinkService: Symbol.for("Main.DeepLinkService"), - NotificationService: Symbol.for("Main.NotificationService"), - McpCallbackService: Symbol.for("Main.McpCallbackService"), - OAuthService: Symbol.for("Main.OAuthService"), - ProcessTrackingService: Symbol.for("Main.ProcessTrackingService"), - SleepService: Symbol.for("Main.SleepService"), - ShellService: Symbol.for("Main.ShellService"), - PosthogPluginService: Symbol.for("Main.PosthogPluginService"), - UIService: Symbol.for("Main.UIService"), - UpdatesService: Symbol.for("Main.UpdatesService"), - TaskLinkService: Symbol.for("Main.TaskLinkService"), - InboxLinkService: Symbol.for("Main.InboxLinkService"), - NewTaskLinkService: Symbol.for("Main.NewTaskLinkService"), - WatcherRegistryService: Symbol.for("Main.WatcherRegistryService"), - EnvironmentService: Symbol.for("Main.EnvironmentService"), - ProvisioningService: Symbol.for("Main.ProvisioningService"), - WorkspaceService: Symbol.for("Main.WorkspaceService"), - EnrichmentService: Symbol.for("Main.EnrichmentService"), - UsageMonitorService: Symbol.for("Main.UsageMonitorService"), - WorkspaceServerService: Symbol.for("Main.WorkspaceServerService"), + ExternalAppsService: Symbol.for("posthog.host.main.external-apps.service"), + LlmGatewayService: Symbol.for("posthog.host.main.llm-gateway.service"), + McpAppsService: Symbol.for("posthog.host.main.mcp-apps.service"), + FileWatcherService: Symbol.for("posthog.host.main.file-watcher.service"), + FsService: Symbol.for("posthog.host.main.fs.service"), + GitService: Symbol.for("posthog.host.main.git.service"), + DeepLinkService: Symbol.for("posthog.host.main.deep-link.service"), + ProcessTrackingService: Symbol.for( + "posthog.host.main.process-tracking.service", + ), + SleepService: Symbol.for("posthog.host.main.sleep.service"), + PosthogPluginService: Symbol.for("posthog.host.main.posthog-plugin.service"), + UpdatesService: Symbol.for("posthog.host.main.updates.service"), + TaskLinkService: Symbol.for("posthog.host.main.task-link.service"), + InboxLinkService: Symbol.for("posthog.host.main.inbox-link.service"), + NewTaskLinkService: Symbol.for("posthog.host.main.new-task-link.service"), + WatcherRegistryService: Symbol.for( + "posthog.host.main.watcher-registry.service", + ), + ProvisioningService: Symbol.for("posthog.host.main.provisioning.service"), + WorkspaceService: Symbol.for("posthog.host.main.workspace.service"), + WorkspaceServerService: Symbol.for( + "posthog.host.main.workspace-server.service", + ), }); diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 59e41c105d..bcacac6ab3 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -1,38 +1,59 @@ import "reflect-metadata"; import os from "node:os"; +import { TypedEventEmitter } from "@posthog/shared"; +import type { WorkspaceClient } from "@posthog/workspace-client/client"; import { createWorkspaceClient } from "@posthog/workspace-client/client"; +import type { FileWatcherEvent } from "@posthog/workspace-client/types"; import { app, BrowserWindow, dialog } from "electron"; import log from "electron-log/main"; -import { FileWatcherBridge } from "./services/file-watcher/bridge"; -import { FocusService } from "./services/focus/service"; import "./utils/logger"; import "./services/index.js"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import type { DatabaseService } from "./db/service"; +import type { AuthService } from "@posthog/core/auth/auth"; +import { focusHostModule } from "@posthog/core/focus/focus-host.module"; +import { + FOCUS_SESSION_STORE, + FOCUS_WORKSPACE_CLIENT, + FOCUS_WORKTREE_PATHS, +} from "@posthog/core/focus/host-focus"; +import { GIT_WORKSPACE_CLIENT } from "@posthog/core/git/identifiers"; +import type { GitHubIntegrationService } from "@posthog/core/integrations/github"; +import { + GITHUB_INTEGRATION_SERVICE, + SLACK_INTEGRATION_SERVICE, +} from "@posthog/core/integrations/identifiers"; +import type { SlackIntegrationService } from "@posthog/core/integrations/slack"; +import type { InboxLinkService } from "@posthog/core/links/inbox-link"; +import type { NewTaskLinkService } from "@posthog/core/links/new-task-link"; +import type { TaskLinkService } from "@posthog/core/links/task-link"; +import { NOTIFICATION_SERVICE } from "@posthog/core/notification/identifiers"; +import type { NotificationService } from "@posthog/core/notification/notification"; +import { OAUTH_SERVICE } from "@posthog/core/oauth/identifiers"; +import type { OAuthService } from "@posthog/core/oauth/oauth"; +import type { UpdatesService } from "@posthog/core/updates/updates"; +import { CONNECTIVITY_CLIENT } from "@posthog/host-router/ports/connectivity-client"; +import { ENVIRONMENT_CLIENT } from "@posthog/host-router/ports/environment-client"; +import { FILE_WATCHER_CONTROL } from "@posthog/host-router/ports/file-watcher-control"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import type { DatabaseService } from "@posthog/workspace-server/db/service"; +import type { ExternalAppsService } from "@posthog/workspace-server/services/external-apps/external-apps"; +import { + FS_SERVICE, + type FsCapability, +} from "@posthog/workspace-server/services/fs/identifiers"; +import type { PosthogPluginService } from "@posthog/workspace-server/services/posthog-plugin/posthog-plugin"; +import { SUSPENSION_SERVICE } from "@posthog/workspace-server/services/suspension/identifiers"; +import type { SuspensionService } from "@posthog/workspace-server/services/suspension/suspension"; +import type { WorkspaceService } from "@posthog/workspace-server/services/workspace/workspace"; import { initializeDeepLinks, registerDeepLinkHandlers } from "./deep-links"; import { container } from "./di/container"; import { MAIN_TOKENS } from "./di/tokens"; +import { posthogNodeAnalytics } from "./platform-adapters/posthog-analytics"; import { registerMcpSandboxProtocol } from "./protocols/mcp-sandbox"; import type { AppLifecycleService } from "./services/app-lifecycle/service"; -import type { AuthService } from "./services/auth/service"; -import type { ExternalAppsService } from "./services/external-apps/service"; -import type { GitHubIntegrationService } from "./services/github-integration/service"; -import type { InboxLinkService } from "./services/inbox-link/service"; -import type { NewTaskLinkService } from "./services/new-task-link/service"; -import type { NotificationService } from "./services/notification/service"; -import type { OAuthService } from "./services/oauth/service"; import { - captureException, - getPostHogClient, - initializePostHog, - trackAppEvent, -} from "./services/posthog-analytics"; -import type { PosthogPluginService } from "./services/posthog-plugin/service"; -import type { SlackIntegrationService } from "./services/slack-integration/service"; -import type { SuspensionService } from "./services/suspension/service"; -import type { TaskLinkService } from "./services/task-link/service"; -import type { UpdatesService } from "./services/updates/service"; -import type { WorkspaceService } from "./services/workspace/service"; + focusSessionStore, + focusWorktreePaths, +} from "./services/focus/desktop-adapters"; import type { WorkspaceServerService } from "./services/workspace-server/service"; import { ensureClaudeConfigDir } from "./utils/env"; import { @@ -43,6 +64,39 @@ import { import { isMacosPackagedUnsafeBundleLocation } from "./utils/macos-packaged-install-guard"; import { createWindow } from "./window"; +type FileWatcherEventsByKind = { + [K in FileWatcherEvent["kind"]]: Extract; +}; + +export class FileWatcherBridge extends TypedEventEmitter { + private subs = new Map void }>(); + + constructor(private workspace: WorkspaceClient) { + super(); + } + + startWatching(repoPath: string): void { + if (this.subs.has(repoPath)) return; + const sub = this.workspace.fileWatcher.watch.subscribe( + { repoPath }, + { + onData: (event) => { + this.emit(event.kind, event as never); + }, + onError: () => {}, + }, + ); + this.subs.set(repoPath, sub); + } + + stopWatching(repoPath: string): void { + const sub = this.subs.get(repoPath); + if (!sub) return; + sub.unsubscribe(); + this.subs.delete(repoPath); + } +} + // Single instance lock must be acquired FIRST before any other app setup const additionalData = process.defaultApp ? { argv: process.argv } : undefined; const gotTheLock = app.requestSingleInstanceLock(additionalData); @@ -89,13 +143,11 @@ app.on("render-process-gone", (_event, webContents, details) => { ...props, chromiumLogTail: readChromiumLogTail(), }); - captureException( + posthogNodeAnalytics.captureException( new Error(`Renderer process gone: ${details.reason}`), props, ); - getPostHogClient() - ?.flush() - .catch(() => {}); + posthogNodeAnalytics.flush().catch(() => {}); if (RECOVERABLE_RENDER_REASONS.has(details.reason)) { if (isCrashLoop()) { @@ -138,26 +190,24 @@ app.on("child-process-gone", (_event, details) => { ...props, chromiumLogTail: readChromiumLogTail(), }); - captureException( + posthogNodeAnalytics.captureException( new Error(`Child process gone (${details.type}): ${details.reason}`), props, ); - getPostHogClient() - ?.flush() - .catch(() => {}); + posthogNodeAnalytics.flush().catch(() => {}); }); async function initializeServices(): Promise { container.get(MAIN_TOKENS.DatabaseService); - container.get(MAIN_TOKENS.OAuthService); + container.get(OAUTH_SERVICE); const authService = container.get(MAIN_TOKENS.AuthService); - container.get(MAIN_TOKENS.NotificationService); + container.get(NOTIFICATION_SERVICE); container.get(MAIN_TOKENS.UpdatesService); container.get(MAIN_TOKENS.TaskLinkService); container.get(MAIN_TOKENS.InboxLinkService); container.get(MAIN_TOKENS.NewTaskLinkService); - container.get(MAIN_TOKENS.GitHubIntegrationService); - container.get(MAIN_TOKENS.SlackIntegrationService); + container.get(GITHUB_INTEGRATION_SERVICE); + container.get(SLACK_INTEGRATION_SERVICE); container.get(MAIN_TOKENS.ExternalAppsService); container.get(MAIN_TOKENS.PosthogPluginService); @@ -169,13 +219,12 @@ async function initializeServices(): Promise { ); workspaceService.initBranchWatcher(); - const suspensionService = container.get( - MAIN_TOKENS.SuspensionService, - ); + const suspensionService = + container.get(SUSPENSION_SERVICE); suspensionService.startInactivityChecker(); // Track app started event - trackAppEvent(ANALYTICS_EVENTS.APP_STARTED); + posthogNodeAnalytics.track(ANALYTICS_EVENTS.APP_STARTED); } // ======================================================== @@ -186,7 +235,7 @@ async function initializeServices(): Promise { registerDeepLinkHandlers(); // Initialize PostHog analytics -initializePostHog(); +posthogNodeAnalytics.initialize(); app.whenReady().then(async () => { if ( @@ -240,13 +289,52 @@ app.whenReady().then(async () => { ); const connection = await wsServer.start(); const workspaceClient = createWorkspaceClient(connection); + container.bind(MAIN_TOKENS.WorkspaceClient).toConstantValue(workspaceClient); + container.bind(GIT_WORKSPACE_CLIENT).toConstantValue(workspaceClient); + container.bind(CONNECTIVITY_CLIENT).toConstantValue(workspaceClient); + container.bind(ENVIRONMENT_CLIENT).toConstantValue(workspaceClient); + const fileWatcherBridge = new FileWatcherBridge(workspaceClient); container .bind(MAIN_TOKENS.FileWatcherService) - .toConstantValue(new FileWatcherBridge(workspaceClient)); - container - .bind(MAIN_TOKENS.FocusService) - .toConstantValue(new FocusService(workspaceClient)); - + .toConstantValue(fileWatcherBridge); + container.bind(FILE_WATCHER_CONTROL).toConstantValue(fileWatcherBridge); + container.bind(FOCUS_WORKSPACE_CLIENT).toConstantValue(workspaceClient); + container.bind(FOCUS_SESSION_STORE).toConstantValue(focusSessionStore); + container.bind(FOCUS_WORKTREE_PATHS).toConstantValue(focusWorktreePaths); + container.load(focusHostModule); + const fsCapability: FsCapability = { + listRepoFiles: (repoPath, query, limit) => + workspaceClient.fs.listRepoFiles.query({ repoPath, query, limit }), + readRepoFile: (repoPath, filePath) => + workspaceClient.fs.readRepoFile.query({ repoPath, filePath }), + readRepoFiles: (repoPath, filePaths) => + workspaceClient.fs.readRepoFiles.query({ repoPath, filePaths }), + readRepoFileBounded: (repoPath, filePath, maxLines) => + workspaceClient.fs.readRepoFileBounded.query({ + repoPath, + filePath, + maxLines, + }), + readRepoFilesBounded: (repoPath, filePaths, maxLines) => + workspaceClient.fs.readRepoFilesBounded.query({ + repoPath, + filePaths, + maxLines, + }), + readAbsoluteFile: (filePath) => + workspaceClient.fs.readAbsoluteFile.query({ filePath }), + readFileAsBase64: (filePath) => + workspaceClient.fs.readFileAsBase64.query({ filePath }), + writeRepoFile: async (repoPath, filePath, content) => { + await workspaceClient.fs.writeRepoFile.mutate({ + repoPath, + filePath, + content, + }); + }, + }; + container.bind(MAIN_TOKENS.FsService).toConstantValue(fsCapability); + container.bind(FS_SERVICE).toService(MAIN_TOKENS.FsService); await initializeServices(); initializeDeepLinks(); }); @@ -320,11 +408,17 @@ process.on("uncaughtException", (error) => { return; } log.error("Uncaught exception", error); - captureException(error, { source: "main", type: "uncaughtException" }); + posthogNodeAnalytics.captureException(error, { + source: "main", + type: "uncaughtException", + }); }); process.on("unhandledRejection", (reason) => { log.error("Unhandled rejection", reason); const error = reason instanceof Error ? reason : new Error(String(reason)); - captureException(error, { source: "main", type: "unhandledRejection" }); + posthogNodeAnalytics.captureException(error, { + source: "main", + type: "unhandledRejection", + }); }); diff --git a/apps/code/src/main/menu.ts b/apps/code/src/main/menu.ts index 63da89768e..be80db42b3 100644 --- a/apps/code/src/main/menu.ts +++ b/apps/code/src/main/menu.ts @@ -1,6 +1,12 @@ import { readdirSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { AuthService } from "@posthog/core/auth/auth"; +import { MCP_APPS_SERVICE } from "@posthog/core/mcp-apps/identifiers"; +import type { McpAppsService } from "@posthog/core/mcp-apps/mcp-apps"; +import { UI_SERVICE } from "@posthog/core/ui/identifiers"; +import type { UIService } from "@posthog/core/ui/ui"; +import type { UpdatesService } from "@posthog/core/updates/updates"; import { app, BrowserWindow, @@ -12,10 +18,6 @@ import { } from "electron"; import { container } from "./di/container"; import { MAIN_TOKENS } from "./di/tokens"; -import type { AuthService } from "./services/auth/service"; -import type { McpAppsService } from "./services/mcp-apps/service"; -import type { UIService } from "./services/ui/service"; -import type { UpdatesService } from "./services/updates/service"; import { isDevBuild } from "./utils/env"; import { getLogFilePath } from "./utils/logger"; @@ -101,7 +103,7 @@ function buildAppMenu(): MenuItemConstructorOptions { label: "Settings...", accelerator: "CmdOrCtrl+,", click: () => { - container.get(MAIN_TOKENS.UIService).openSettings(); + container.get(UI_SERVICE).openSettings(); }, }, { type: "separator" }, @@ -135,7 +137,7 @@ function buildFileMenu(): MenuItemConstructorOptions { label: "New task", accelerator: "CmdOrCtrl+N", click: () => { - container.get(MAIN_TOKENS.UIService).newTask(); + container.get(UI_SERVICE).newTask(); }, }, { type: "separator" }, @@ -213,9 +215,7 @@ function buildFileMenu(): MenuItemConstructorOptions { { label: "Invalidate OAuth token", click: () => { - void container - .get(MAIN_TOKENS.UIService) - .invalidateToken(); + void container.get(UI_SERVICE).invalidateToken(); }, }, { @@ -244,7 +244,7 @@ function buildFileMenu(): MenuItemConstructorOptions { label: "Refresh MCP Apps discovery", click: () => { container - .get(MAIN_TOKENS.McpAppsService) + .get(MCP_APPS_SERVICE) .refreshDiscovery() .then(() => { dialog.showMessageBox({ @@ -267,7 +267,7 @@ function buildFileMenu(): MenuItemConstructorOptions { { label: "Clear application storage", click: () => { - container.get(MAIN_TOKENS.UIService).clearStorage(); + container.get(UI_SERVICE).clearStorage(); }, }, ], @@ -317,7 +317,7 @@ function buildViewMenu(): MenuItemConstructorOptions { { label: "Reset layout", click: () => { - container.get(MAIN_TOKENS.UIService).resetLayout(); + container.get(UI_SERVICE).resetLayout(); }, }, ], diff --git a/apps/code/src/main/platform-adapters/electron-app-meta.ts b/apps/code/src/main/platform-adapters/electron-app-meta.ts index a487166871..a1a9925383 100644 --- a/apps/code/src/main/platform-adapters/electron-app-meta.ts +++ b/apps/code/src/main/platform-adapters/electron-app-meta.ts @@ -11,4 +11,12 @@ export class ElectronAppMeta implements IAppMeta { public get isProduction(): boolean { return app.isPackaged; } + + public get platform(): string { + return process.platform; + } + + public get arch(): string { + return process.arch; + } } diff --git a/apps/code/src/main/platform-adapters/electron-crypto.ts b/apps/code/src/main/platform-adapters/electron-crypto.ts new file mode 100644 index 0000000000..30262dee6c --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-crypto.ts @@ -0,0 +1,14 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { ICrypto } from "@posthog/platform/crypto"; +import { injectable } from "inversify"; + +@injectable() +export class ElectronCrypto implements ICrypto { + randomBase64Url(byteLength: number): string { + return randomBytes(byteLength).toString("base64url"); + } + + sha256Base64Url(input: string): string { + return createHash("sha256").update(input).digest("base64url"); + } +} diff --git a/apps/code/src/main/platform-adapters/electron-notifier.ts b/apps/code/src/main/platform-adapters/electron-notifier.ts index 84239522f2..7f27c75310 100644 --- a/apps/code/src/main/platform-adapters/electron-notifier.ts +++ b/apps/code/src/main/platform-adapters/electron-notifier.ts @@ -1,7 +1,7 @@ +import { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; import type { INotifier, NotifyOptions } from "@posthog/platform/notifier"; import { app, Notification } from "electron"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../di/tokens"; import type { ElectronMainWindow } from "./electron-main-window"; @injectable() @@ -14,7 +14,7 @@ export class ElectronNotifier implements INotifier { private readonly active = new Set(); constructor( - @inject(MAIN_TOKENS.MainWindow) + @inject(MAIN_WINDOW_SERVICE) private readonly mainWindow: ElectronMainWindow, ) {} diff --git a/apps/code/src/main/platform-adapters/electron-updater.ts b/apps/code/src/main/platform-adapters/electron-updater.ts index 6ec407abb8..c0d6d5a752 100644 --- a/apps/code/src/main/platform-adapters/electron-updater.ts +++ b/apps/code/src/main/platform-adapters/electron-updater.ts @@ -7,6 +7,7 @@ export class ElectronUpdater implements IUpdater { public isSupported(): boolean { return ( app.isPackaged && + !process.env.ELECTRON_DISABLE_AUTO_UPDATE && (process.platform === "darwin" || process.platform === "win32") ); } diff --git a/apps/code/src/main/platform-adapters/electron-usage-threshold-store.ts b/apps/code/src/main/platform-adapters/electron-usage-threshold-store.ts new file mode 100644 index 0000000000..48847c64f3 --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-usage-threshold-store.ts @@ -0,0 +1,31 @@ +import Store from "electron-store"; +import { getUserDataDir } from "../utils/env"; + +interface UsageThresholdStoreSchema { + // Map of dedupe-keys ⇒ ISO timestamp anchor at which the threshold was + // first fired. Stored so we don't re-toast after relaunch within the same + // billing window. Anchored entries with a past anchor are pruned on boot. + thresholdsSeen: Record; +} + +const store = new Store({ + name: "usage-monitor", + cwd: getUserDataDir(), + defaults: { + thresholdsSeen: {}, + }, +}); + +/** + * Electron-store-backed persistence for the usage-monitor threshold dedup + * state. Implements the host-side slice of `@posthog/core/usage`'s `UsageHost` + * port so core never touches electron-store. + */ +export const electronUsageThresholdStore = { + getThresholdsSeen(): Record { + return store.get("thresholdsSeen", {}); + }, + setThresholdsSeen(value: Record): void { + store.set("thresholdsSeen", value); + }, +}; diff --git a/apps/code/src/main/platform-adapters/electron-workspace-settings.ts b/apps/code/src/main/platform-adapters/electron-workspace-settings.ts new file mode 100644 index 0000000000..4769b46f1d --- /dev/null +++ b/apps/code/src/main/platform-adapters/electron-workspace-settings.ts @@ -0,0 +1,62 @@ +import type { IWorkspaceSettings } from "@posthog/platform/workspace-settings"; +import { injectable } from "inversify"; +import { + getAllWorktreeLocations, + getAutoSuspendAfterDays, + getAutoSuspendEnabled, + getMaxActiveWorktrees, + getPreventSleepWhileRunning, + getWorktreeLocation, + setAutoSuspendAfterDays, + setAutoSuspendEnabled, + setMaxActiveWorktrees, + setPreventSleepWhileRunning, + setWorktreeLocation, +} from "../services/settingsStore"; + +@injectable() +export class ElectronWorkspaceSettings implements IWorkspaceSettings { + getWorktreeLocation(): string { + return getWorktreeLocation(); + } + + getAllWorktreeLocations(): string[] { + return getAllWorktreeLocations(); + } + + setWorktreeLocation(location: string): void { + setWorktreeLocation(location); + } + + getMaxActiveWorktrees(): number { + return getMaxActiveWorktrees(); + } + + setMaxActiveWorktrees(value: number): void { + setMaxActiveWorktrees(value); + } + + getAutoSuspendEnabled(): boolean { + return getAutoSuspendEnabled(); + } + + setAutoSuspendEnabled(value: boolean): void { + setAutoSuspendEnabled(value); + } + + getAutoSuspendAfterDays(): number { + return getAutoSuspendAfterDays(); + } + + setAutoSuspendAfterDays(value: number): void { + setAutoSuspendAfterDays(value); + } + + getPreventSleepWhileRunning(): boolean { + return getPreventSleepWhileRunning(); + } + + setPreventSleepWhileRunning(value: boolean): void { + setPreventSleepWhileRunning(value); + } +} diff --git a/apps/code/src/main/services/posthog-analytics.test.ts b/apps/code/src/main/platform-adapters/posthog-analytics.test.ts similarity index 81% rename from apps/code/src/main/services/posthog-analytics.test.ts rename to apps/code/src/main/platform-adapters/posthog-analytics.test.ts index 64d746d40f..1131c97e15 100644 --- a/apps/code/src/main/services/posthog-analytics.test.ts +++ b/apps/code/src/main/platform-adapters/posthog-analytics.test.ts @@ -8,13 +8,7 @@ const MockPostHog = vi.hoisted(() => vi.fn()); vi.mock("posthog-node", () => ({ PostHog: MockPostHog })); -import { - captureException, - initializePostHog, - resetUser, - shutdownPostHog, - trackAppEvent, -} from "./posthog-analytics"; +import { posthogNodeAnalytics } from "./posthog-analytics"; describe("posthog-analytics", () => { beforeEach(() => { @@ -26,16 +20,16 @@ describe("posthog-analytics", () => { this.shutdown = mockShutdown; }); process.env.VITE_POSTHOG_API_KEY = "test-key"; - resetUser(); - initializePostHog(); + posthogNodeAnalytics.resetUser(); + posthogNodeAnalytics.initialize(); }); afterEach(async () => { - await shutdownPostHog(); + await posthogNodeAnalytics.shutdown(); }); it("includes the app version on every tracked event", () => { - trackAppEvent("app_started"); + posthogNodeAnalytics.track("app_started"); expect(mockCapture).toHaveBeenCalledWith( expect.objectContaining({ @@ -49,7 +43,7 @@ describe("posthog-analytics", () => { }); it("lets caller-supplied properties coexist with the app version", () => { - trackAppEvent("app_quit", { reason: "user-initiated" }); + posthogNodeAnalytics.track("app_quit", { reason: "user-initiated" }); expect(mockCapture).toHaveBeenCalledWith( expect.objectContaining({ @@ -62,7 +56,7 @@ describe("posthog-analytics", () => { }); it("does not let caller-supplied app_version override the system value", () => { - trackAppEvent("app_quit", { app_version: "spoofed" }); + posthogNodeAnalytics.track("app_quit", { app_version: "spoofed" }); expect(mockCapture).toHaveBeenCalledWith( expect.objectContaining({ @@ -74,7 +68,7 @@ describe("posthog-analytics", () => { }); it("includes the app version on captured exceptions", () => { - captureException(new Error("boom")); + posthogNodeAnalytics.captureException(new Error("boom")); expect(mockCaptureException).toHaveBeenCalledWith( expect.any(Error), @@ -87,7 +81,9 @@ describe("posthog-analytics", () => { }); it("does not let additionalProperties override app_version on exceptions", () => { - captureException(new Error("boom"), { app_version: "spoofed" }); + posthogNodeAnalytics.captureException(new Error("boom"), { + app_version: "spoofed", + }); expect(mockCaptureException).toHaveBeenCalledWith( expect.any(Error), diff --git a/apps/code/src/main/platform-adapters/posthog-analytics.ts b/apps/code/src/main/platform-adapters/posthog-analytics.ts new file mode 100644 index 0000000000..8a3183b1cc --- /dev/null +++ b/apps/code/src/main/platform-adapters/posthog-analytics.ts @@ -0,0 +1,102 @@ +import type { + AnalyticsProperties, + IAnalytics, +} from "@posthog/platform/analytics"; +import { PostHog } from "posthog-node"; +import { getAppVersion } from "../utils/env"; + +export class PosthogNodeAnalytics implements IAnalytics { + private client: PostHog | null = null; + private currentUserId: string | null = null; + + initialize(): void { + if (this.client) { + return; + } + + const apiKey = process.env.VITE_POSTHOG_API_KEY; + const apiHost = process.env.VITE_POSTHOG_API_HOST; + + if (!apiKey) { + return; + } + + this.client = new PostHog(apiKey, { + host: apiHost || "https://internal-c.posthog.com", + enableExceptionAutocapture: true, + }); + } + + setCurrentUserId(userId: string | null): void { + this.currentUserId = userId; + } + + getCurrentUserId(): string | null { + return this.currentUserId; + } + + track(eventName: string, properties?: AnalyticsProperties): void { + if (!this.client) { + return; + } + + const distinctId = this.currentUserId || "anonymous-app-event"; + + this.client.capture({ + distinctId, + event: eventName, + properties: { + team: "posthog-code", + ...properties, + app_version: getAppVersion(), + $process_person_profile: !!this.currentUserId, + }, + }); + } + + identify(userId: string, properties?: AnalyticsProperties): void { + if (!this.client) { + return; + } + + this.currentUserId = userId; + + this.client.identify({ + distinctId: userId, + properties, + }); + } + + resetUser(): void { + this.currentUserId = null; + } + + captureException( + error: unknown, + additionalProperties?: Record, + ): void { + if (!this.client) { + return; + } + + const distinctId = this.currentUserId || "anonymous-app-event"; + this.client.captureException(error, distinctId, { + team: "posthog-code", + ...additionalProperties, + app_version: getAppVersion(), + }); + } + + async flush(): Promise { + await this.client?.flush(); + } + + async shutdown(): Promise { + if (this.client) { + await this.client.shutdown(); + this.client = null; + } + } +} + +export const posthogNodeAnalytics = new PosthogNodeAnalytics(); diff --git a/apps/code/src/main/protocols/mcp-sandbox.ts b/apps/code/src/main/protocols/mcp-sandbox.ts index e9b3650dc8..66e298cdfb 100644 --- a/apps/code/src/main/protocols/mcp-sandbox.ts +++ b/apps/code/src/main/protocols/mcp-sandbox.ts @@ -9,9 +9,13 @@ * The scheme must be registered with `protocol.registerSchemesAsPrivileged` * BEFORE `app.ready` (done in bootstrap.ts). This handler is registered * AFTER `app.ready`. + * + * The proxy HTML is host-agnostic and lives in `@posthog/shared`; this is the + * Electron-specific seam that serves it at an isolated origin. Web supplies the + * same HTML via a blob URL (see the web composition root). */ -import { sandboxProxyHtml } from "@shared/mcp-sandbox-proxy"; +import { sandboxProxyHtml } from "@posthog/shared/mcp-sandbox-proxy"; import { session } from "electron"; import { logger } from "../utils/logger"; diff --git a/apps/code/src/main/services/app-lifecycle/service.test.ts b/apps/code/src/main/services/app-lifecycle/service.test.ts index ff200c023d..ddfa5dc576 100644 --- a/apps/code/src/main/services/app-lifecycle/service.test.ts +++ b/apps/code/src/main/services/app-lifecycle/service.test.ts @@ -6,6 +6,9 @@ const { mockAppLifecycle, mockContainer, mockDatabaseService, + mockSuspensionService, + mockWatcherRegistry, + mockProcessTracking, mockTrackAppEvent, mockShutdownPostHog, mockShutdownOtelTransport, @@ -15,6 +18,21 @@ const { close: vi.fn(), }; return { + mockSuspensionService: { + stopInactivityChecker: vi.fn(), + }, + mockWatcherRegistry: { + shutdownAll: vi.fn(() => Promise.resolve()), + }, + mockProcessTracking: { + getSnapshot: vi.fn(() => + Promise.resolve({ + tracked: { shell: [], agent: [], child: [] }, + discovered: [], + }), + ), + killAll: vi.fn(), + }, mockAppLifecycle: { whenReady: vi.fn().mockResolvedValue(undefined), quit: vi.fn(), @@ -49,16 +67,18 @@ vi.mock("../../utils/otel-log-transport.js", () => ({ shutdownOtelTransport: mockShutdownOtelTransport, })); -vi.mock("../posthog-analytics.js", () => ({ - trackAppEvent: mockTrackAppEvent, - shutdownPostHog: mockShutdownPostHog, +vi.mock("../../platform-adapters/posthog-analytics.js", () => ({ + posthogNodeAnalytics: { + track: mockTrackAppEvent, + shutdown: mockShutdownPostHog, + }, })); vi.mock("../../di/container.js", () => ({ container: mockContainer, })); -vi.mock("../../../shared/types/analytics.js", () => ({ +vi.mock("@posthog/shared/analytics-events", () => ({ ANALYTICS_EVENTS: { APP_QUIT: "app_quit", }, @@ -74,6 +94,10 @@ describe("AppLifecycleService", () => { process.exit = mockProcessExit; service = new AppLifecycleService( mockAppLifecycle as unknown as IAppLifecycle, + mockDatabaseService as never, + mockSuspensionService as never, + mockWatcherRegistry as never, + mockProcessTracking as never, ); }); diff --git a/apps/code/src/main/services/app-lifecycle/service.ts b/apps/code/src/main/services/app-lifecycle/service.ts index 18dcc9f9cd..cb111c0c1a 100644 --- a/apps/code/src/main/services/app-lifecycle/service.ts +++ b/apps/code/src/main/services/app-lifecycle/service.ts @@ -1,16 +1,22 @@ -import type { IAppLifecycle } from "@posthog/platform/app-lifecycle"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { + APP_LIFECYCLE_SERVICE, + type IAppLifecycle, +} from "@posthog/platform/app-lifecycle"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { DATABASE_SERVICE } from "@posthog/workspace-server/db/identifiers"; +import type { DatabaseService } from "@posthog/workspace-server/db/service"; +import { PROCESS_TRACKING_SERVICE } from "@posthog/workspace-server/services/process-tracking/identifiers"; +import type { ProcessTrackingService } from "@posthog/workspace-server/services/process-tracking/process-tracking"; +import { SUSPENSION_SERVICE } from "@posthog/workspace-server/services/suspension/identifiers"; +import type { SuspensionService } from "@posthog/workspace-server/services/suspension/suspension"; +import type { WatcherRegistryService } from "@posthog/workspace-server/services/watcher-registry/watcher-registry"; import { inject, injectable } from "inversify"; -import type { DatabaseService } from "../../db/service"; import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; +import { posthogNodeAnalytics } from "../../platform-adapters/posthog-analytics"; import { withTimeout } from "../../utils/async"; import { logger } from "../../utils/logger"; import { shutdownOtelTransport } from "../../utils/otel-log-transport"; -import { shutdownPostHog, trackAppEvent } from "../posthog-analytics"; -import type { ProcessTrackingService } from "../process-tracking/service"; -import type { SuspensionService } from "../suspension/service.js"; -import type { WatcherRegistryService } from "../watcher-registry/service"; const log = logger.scope("app-lifecycle"); @@ -22,8 +28,16 @@ export class AppLifecycleService { private _isShuttingDown = false; constructor( - @inject(MAIN_TOKENS.AppLifecycle) + @inject(APP_LIFECYCLE_SERVICE) private readonly appLifecycle: IAppLifecycle, + @inject(DATABASE_SERVICE) + private readonly db: DatabaseService, + @inject(SUSPENSION_SERVICE) + private readonly suspensionService: SuspensionService, + @inject(MAIN_TOKENS.WatcherRegistryService) + private readonly watcherRegistry: WatcherRegistryService, + @inject(PROCESS_TRACKING_SERVICE) + private readonly processTracking: ProcessTrackingService, ) {} get isQuittingForUpdate(): boolean { @@ -82,8 +96,7 @@ export class AppLifecycleService { log.info("Partial shutdown started (keeping container)"); await this.teardownNativeResources(); try { - const db = container.get(MAIN_TOKENS.DatabaseService); - db.close(); + this.db.close(); } catch (error) { log.warn("Failed to close database during partial shutdown", error); } @@ -106,17 +119,13 @@ export class AppLifecycleService { await this.teardownNativeResources(); try { - const suspensionService = container.get( - MAIN_TOKENS.SuspensionService, - ); - suspensionService.stopInactivityChecker(); + this.suspensionService.stopInactivityChecker(); } catch (error) { log.warn("Failed to stop inactivity checker during shutdown", error); } try { - const db = container.get(MAIN_TOKENS.DatabaseService); - db.close(); + this.db.close(); } catch (error) { log.warn("Failed to close database during shutdown", error); } @@ -127,7 +136,7 @@ export class AppLifecycleService { log.warn("Failed to unbind container", error); } - trackAppEvent(ANALYTICS_EVENTS.APP_QUIT); + posthogNodeAnalytics.track(ANALYTICS_EVENTS.APP_QUIT); try { await shutdownOtelTransport(); @@ -136,7 +145,7 @@ export class AppLifecycleService { } try { - await shutdownPostHog(); + await posthogNodeAnalytics.shutdown(); } catch (error) { log.warn("Failed to shutdown PostHog", error); } @@ -150,19 +159,13 @@ export class AppLifecycleService { */ private async teardownNativeResources(): Promise { try { - const watcherRegistry = container.get( - MAIN_TOKENS.WatcherRegistryService, - ); - await watcherRegistry.shutdownAll(); + await this.watcherRegistry.shutdownAll(); } catch (error) { log.warn("Failed to shutdown watcher registry", error); } try { - const processTracking = container.get( - MAIN_TOKENS.ProcessTrackingService, - ); - const snapshot = await processTracking.getSnapshot(true); + const snapshot = await this.processTracking.getSnapshot(true); log.debug("Process snapshot", { tracked: { shell: snapshot.tracked.shell.length, @@ -179,7 +182,7 @@ export class AppLifecycleService { if (trackedCount > 0) { log.info(`Killing ${trackedCount} tracked processes`); - processTracking.killAll(); + this.processTracking.killAll(); } } catch (error) { log.warn("Failed to kill tracked processes", error); diff --git a/apps/code/src/main/services/auth/port-adapters.ts b/apps/code/src/main/services/auth/port-adapters.ts new file mode 100644 index 0000000000..a1e717fde8 --- /dev/null +++ b/apps/code/src/main/services/auth/port-adapters.ts @@ -0,0 +1,157 @@ +import type { + AuthPreferenceRecord, + AuthSessionRecord, + ConnectivityStatus, + IAuthConnectivity, + IAuthOAuthFlowService, + IAuthPreferenceStore, + IAuthSessionStore, + IAuthTokenCipher, + PersistAuthSessionRecord, +} from "@posthog/core/auth/identifiers"; +import type { + CancelFlowOutput, + RefreshTokenOutput, + StartFlowOutput, +} from "@posthog/core/auth/oauth.schemas"; +import { OAUTH_SERVICE } from "@posthog/core/oauth/identifiers"; +import type { OAuthService } from "@posthog/core/oauth/oauth"; +import type { CloudRegion } from "@posthog/shared"; +import type { WorkspaceClient } from "@posthog/workspace-client/client"; +import type { IAuthPreferenceRepository } from "@posthog/workspace-server/db/repositories/auth-preference-repository"; +import type { IAuthSessionRepository } from "@posthog/workspace-server/db/repositories/auth-session-repository"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { decrypt, encrypt } from "../../utils/encryption"; + +@injectable() +export class TokenCipherPortAdapter implements IAuthTokenCipher { + encrypt(plaintext: string): string { + return encrypt(plaintext); + } + + decrypt(encrypted: string): string | null { + return decrypt(encrypted); + } +} + +@injectable() +export class OAuthFlowPortAdapter implements IAuthOAuthFlowService { + constructor( + @inject(OAUTH_SERVICE) + private readonly oauth: OAuthService, + ) {} + + startFlow(region: CloudRegion): Promise { + return this.oauth.startFlow(region); + } + + startSignupFlow(region: CloudRegion): Promise { + return this.oauth.startSignupFlow(region); + } + + refreshToken( + refreshToken: string, + region: CloudRegion, + ): Promise { + return this.oauth.refreshToken(refreshToken, region); + } + + cancelFlow(): CancelFlowOutput { + return this.oauth.cancelFlow(); + } +} + +@injectable() +export class AuthSessionPortAdapter implements IAuthSessionStore { + constructor( + @inject(MAIN_TOKENS.AuthSessionRepository) + private readonly repository: IAuthSessionRepository, + ) {} + + getCurrent(): AuthSessionRecord | null { + const row = this.repository.getCurrent(); + if (!row) { + return null; + } + return { + refreshTokenEncrypted: row.refreshTokenEncrypted, + cloudRegion: row.cloudRegion, + selectedProjectId: row.selectedProjectId, + scopeVersion: row.scopeVersion, + }; + } + + saveCurrent(input: PersistAuthSessionRecord): void { + this.repository.saveCurrent(input); + } + + clearCurrent(): void { + this.repository.clearCurrent(); + } +} + +@injectable() +export class AuthPreferencePortAdapter implements IAuthPreferenceStore { + constructor( + @inject(MAIN_TOKENS.AuthPreferenceRepository) + private readonly repository: IAuthPreferenceRepository, + ) {} + + get( + accountKey: string, + cloudRegion: CloudRegion, + ): AuthPreferenceRecord | null { + const row = this.repository.get(accountKey, cloudRegion); + if (!row) { + return null; + } + return { + accountKey: row.accountKey, + cloudRegion: row.cloudRegion, + lastSelectedProjectId: row.lastSelectedProjectId, + }; + } + + save(input: AuthPreferenceRecord): void { + this.repository.save(input); + } +} + +@injectable() +export class ConnectivityPortAdapter implements IAuthConnectivity { + private isOnline = true; + private readonly handlers = new Set<(status: ConnectivityStatus) => void>(); + + constructor( + @inject(MAIN_TOKENS.WorkspaceClient) + private readonly workspace: WorkspaceClient, + ) { + this.workspace.connectivity.onStatusChange.subscribe(undefined, { + onData: (status) => { + this.isOnline = status.isOnline; + for (const handler of this.handlers) { + handler({ isOnline: status.isOnline }); + } + }, + onError: () => {}, + }); + void this.workspace.connectivity.getStatus + .query() + .then((status) => { + this.isOnline = status.isOnline; + }) + .catch(() => {}); + } + + getStatus(): ConnectivityStatus { + return { isOnline: this.isOnline }; + } + + onStatusChange(handler: (status: ConnectivityStatus) => void): () => void { + this.handlers.add(handler); + return () => { + this.handlers.delete(handler); + }; + } +} diff --git a/apps/code/src/main/services/deep-link/service.ts b/apps/code/src/main/services/deep-link/service.ts index ed2875ff97..39e4fb1734 100644 --- a/apps/code/src/main/services/deep-link/service.ts +++ b/apps/code/src/main/services/deep-link/service.ts @@ -1,26 +1,29 @@ -import type { IAppLifecycle } from "@posthog/platform/app-lifecycle"; -import { getDeeplinkProtocol } from "@shared/deeplink"; +import { + APP_LIFECYCLE_SERVICE, + type IAppLifecycle, +} from "@posthog/platform/app-lifecycle"; +import type { + DeepLinkHandler, + IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { getDeeplinkProtocol } from "@posthog/shared"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; import { isDevBuild } from "../../utils/env"; import { logger } from "../../utils/logger"; +export type { DeepLinkHandler } from "@posthog/platform/deep-link"; + const log = logger.scope("deep-link-service"); const LEGACY_PROTOCOLS = ["twig", "array"]; -export type DeepLinkHandler = ( - path: string, - searchParams: URLSearchParams, -) => boolean; - @injectable() -export class DeepLinkService { +export class DeepLinkService implements IDeepLinkRegistry { private protocolRegistered = false; private handlers = new Map(); constructor( - @inject(MAIN_TOKENS.AppLifecycle) + @inject(APP_LIFECYCLE_SERVICE) private readonly appLifecycle: IAppLifecycle, ) {} diff --git a/apps/code/src/main/services/encryption/service.test.ts b/apps/code/src/main/services/encryption/service.test.ts new file mode 100644 index 0000000000..730d3d9aef --- /dev/null +++ b/apps/code/src/main/services/encryption/service.test.ts @@ -0,0 +1,49 @@ +import type { ISecureStorage } from "@posthog/platform/secure-storage"; +import { describe, expect, it } from "vitest"; +import { EncryptionService } from "./service"; + +function makeSecureStorage(available: boolean): ISecureStorage { + return { + isAvailable: () => available, + // Trivial reversible "cipher": prefix the bytes so we can assert framing. + encryptString: async (text) => + new Uint8Array(Buffer.from(`enc:${text}`, "utf8")), + decryptString: async (data) => + Buffer.from(data).toString("utf8").replace(/^enc:/, ""), + }; +} + +describe("EncryptionService", () => { + it("round-trips a value through the host cipher as base64", async () => { + const service = new EncryptionService(makeSecureStorage(true)); + const encrypted = await service.encrypt("secret"); + expect(encrypted).not.toBeNull(); + expect(encrypted).not.toBe("secret"); + // base64 of the cipher output + expect(encrypted).toBe( + Buffer.from("enc:secret", "utf8").toString("base64"), + ); + expect(await service.decrypt(encrypted as string)).toBe("secret"); + }); + + it("passes through unchanged when secure storage is unavailable", async () => { + const service = new EncryptionService(makeSecureStorage(false)); + expect(await service.encrypt("plain")).toBe("plain"); + expect(await service.decrypt("plain")).toBe("plain"); + }); + + it("returns null when the cipher throws", async () => { + const broken: ISecureStorage = { + isAvailable: () => true, + encryptString: async () => { + throw new Error("cipher down"); + }, + decryptString: async () => { + throw new Error("cipher down"); + }, + }; + const service = new EncryptionService(broken); + expect(await service.encrypt("x")).toBeNull(); + expect(await service.decrypt("x")).toBeNull(); + }); +}); diff --git a/apps/code/src/main/services/encryption/service.ts b/apps/code/src/main/services/encryption/service.ts new file mode 100644 index 0000000000..2da7989e71 --- /dev/null +++ b/apps/code/src/main/services/encryption/service.ts @@ -0,0 +1,50 @@ +import { logger } from "@main/utils/logger"; +import { + type ISecureStorage, + SECURE_STORAGE_SERVICE, +} from "@posthog/platform/secure-storage"; +import { inject, injectable } from "inversify"; + +const log = logger.scope("encryption"); + +/** + * Backing service for the encryption router: base64-transports values through + * the host secure-storage cipher, falling back to passthrough when the host has + * no secure storage available. Owns the availability check + base64 framing + + * error handling that previously lived inline in the router. Best-effort: a + * cipher failure logs and returns null rather than throwing to the renderer. + */ +@injectable() +export class EncryptionService { + constructor( + @inject(SECURE_STORAGE_SERVICE) + private readonly secureStorage: ISecureStorage, + ) {} + + async encrypt(stringToEncrypt: string): Promise { + try { + if (this.secureStorage.isAvailable()) { + const encrypted = + await this.secureStorage.encryptString(stringToEncrypt); + return Buffer.from(encrypted).toString("base64"); + } + return stringToEncrypt; + } catch (error) { + log.error("Failed to encrypt string:", error); + return null; + } + } + + async decrypt(stringToDecrypt: string): Promise { + try { + if (this.secureStorage.isAvailable()) { + const bytes = new Uint8Array(Buffer.from(stringToDecrypt, "base64")); + return await this.secureStorage.decryptString(bytes); + } + return stringToDecrypt; + } catch (error) { + log.error("Failed to decrypt string:", error); + return null; + } + } +} diff --git a/apps/code/src/main/services/file-watcher/bridge.ts b/apps/code/src/main/services/file-watcher/bridge.ts deleted file mode 100644 index a70146818a..0000000000 --- a/apps/code/src/main/services/file-watcher/bridge.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { WorkspaceClient } from "@posthog/workspace-client/client"; -import type { FileWatcherEvent } from "@posthog/workspace-client/types"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; - -type FileWatcherEventsByKind = { - [K in FileWatcherEvent["kind"]]: Extract; -}; - -export class FileWatcherBridge extends TypedEventEmitter { - private subs = new Map void }>(); - - constructor(private workspace: WorkspaceClient) { - super(); - } - - startWatching(repoPath: string): void { - if (this.subs.has(repoPath)) return; - const sub = this.workspace.fileWatcher.watch.subscribe( - { repoPath }, - { - onData: (event) => { - this.emit(event.kind, event as never); - }, - onError: () => {}, - }, - ); - this.subs.set(repoPath, sub); - } - - stopWatching(repoPath: string): void { - const sub = this.subs.get(repoPath); - if (!sub) return; - sub.unsubscribe(); - this.subs.delete(repoPath); - } -} diff --git a/apps/code/src/main/services/focus/desktop-adapters.ts b/apps/code/src/main/services/focus/desktop-adapters.ts new file mode 100644 index 0000000000..a7ec3c7d90 --- /dev/null +++ b/apps/code/src/main/services/focus/desktop-adapters.ts @@ -0,0 +1,45 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { + FocusSessionStore, + FocusWorktreePaths, +} from "@posthog/core/focus/host-focus"; +import { focusStore } from "../../utils/store"; +import { getWorktreeLocation } from "../settingsStore"; + +export const focusSessionStore: FocusSessionStore = { + getSession(mainRepoPath) { + const sessions = focusStore.get("sessions", {}); + return sessions[mainRepoPath] ?? null; + }, + saveSession(session) { + const sessions = focusStore.get("sessions", {}); + sessions[session.mainRepoPath] = session; + focusStore.set("sessions", sessions); + }, + deleteSession(mainRepoPath) { + const sessions = focusStore.get("sessions", {}); + delete sessions[mainRepoPath]; + focusStore.set("sessions", sessions); + }, +}; + +export const focusWorktreePaths: FocusWorktreePaths = { + toRelativeWorktreePath(absolutePath, mainRepoPath) { + const repoName = path.basename(mainRepoPath); + const worktreeName = path.basename(absolutePath); + return `${repoName}/${worktreeName}`; + }, + toAbsoluteWorktreePath(relativePath) { + return path.join(getWorktreeLocation(), relativePath); + }, + async worktreeExistsAtPath(relativePath) { + const absolutePath = path.join(getWorktreeLocation(), relativePath); + try { + await fs.access(absolutePath); + return true; + } catch { + return false; + } + }, +}; diff --git a/apps/code/src/main/services/focus/schemas.ts b/apps/code/src/main/services/focus/schemas.ts deleted file mode 100644 index 81676842ea..0000000000 --- a/apps/code/src/main/services/focus/schemas.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { z } from "zod"; - -export const focusResultSchema = z.object({ - success: z.boolean(), - error: z.string().optional(), - stashPopWarning: z.string().optional(), -}); - -export type FocusResult = z.infer; - -export const stashResultSchema = focusResultSchema.extend({ - stashRef: z.string().optional(), -}); - -export type StashResult = z.infer; - -export const focusSessionSchema = z.object({ - mainRepoPath: z.string(), - worktreePath: z.string(), - branch: z.string(), - originalBranch: z.string(), - mainStashRef: z.string().nullable(), - commitSha: z.string(), -}); - -export type FocusSession = z.infer; - -export const repoPathInput = z.object({ repoPath: z.string() }); -export const mainRepoPathInput = z.object({ mainRepoPath: z.string() }); -export const stashInput = z.object({ - repoPath: z.string(), - message: z.string(), -}); -export const checkoutInput = z.object({ - repoPath: z.string(), - branch: z.string(), -}); -export const worktreeInput = z.object({ worktreePath: z.string() }); -export const reattachInput = z.object({ - worktreePath: z.string(), - branch: z.string(), -}); -export const syncInput = z.object({ - mainRepoPath: z.string(), - worktreePath: z.string(), -}); -export const findWorktreeInput = z.object({ - mainRepoPath: z.string(), - branch: z.string(), -}); diff --git a/apps/code/src/main/services/focus/service.ts b/apps/code/src/main/services/focus/service.ts deleted file mode 100644 index 5947491aa8..0000000000 --- a/apps/code/src/main/services/focus/service.ts +++ /dev/null @@ -1,198 +0,0 @@ -// PORT NOTE: shim — delegates host operations to workspace-server and keeps -// local focus-session persistence in Electron. Delete when focus session -// persistence also moves out of main. -import fs from "node:fs/promises"; -import path from "node:path"; -import type { WorkspaceClient } from "@posthog/workspace-client/client"; -import type { FocusBranchRenamedEvent } from "@posthog/workspace-client/types"; -import { type FocusSession, focusStore } from "../../utils/store"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import { getWorktreeLocation } from "../settingsStore"; -import type { FocusResult, StashResult } from "./schemas"; - -export const FocusServiceEvent = { - BranchRenamed: "branchRenamed", - ForeignBranchCheckout: "foreignBranchCheckout", -} as const; - -export interface FocusServiceEvents { - [FocusServiceEvent.BranchRenamed]: { - mainRepoPath: string; - worktreePath: string; - oldBranch: string; - newBranch: string; - }; - [FocusServiceEvent.ForeignBranchCheckout]: { - mainRepoPath: string; - worktreePath: string; - focusedBranch: string; - foreignBranch: string; - }; -} - -export class FocusService extends TypedEventEmitter { - constructor(private readonly workspace: WorkspaceClient) { - super(); - this.workspace.focus.onBranchRenamed.subscribe(undefined, { - onData: (event) => { - void this.handleBranchRenamed(event); - }, - onError: () => {}, - }); - this.workspace.focus.onForeignBranchCheckout.subscribe(undefined, { - onData: (event) => { - this.emit(FocusServiceEvent.ForeignBranchCheckout, event); - }, - onError: () => {}, - }); - } - - getSession(mainRepoPath: string): FocusSession | null { - const sessions = focusStore.get("sessions", {}); - return sessions[mainRepoPath] ?? null; - } - - async saveSession(session: FocusSession): Promise { - const sessions = focusStore.get("sessions", {}); - sessions[session.mainRepoPath] = session; - focusStore.set("sessions", sessions); - await this.workspace.focus.saveSession.mutate(session); - } - - async deleteSession(mainRepoPath: string): Promise { - const sessions = focusStore.get("sessions", {}); - delete sessions[mainRepoPath]; - focusStore.set("sessions", sessions); - await this.workspace.focus.deleteSession.mutate({ mainRepoPath }); - } - - isFocusActive(mainRepoPath: string): boolean { - return this.getSession(mainRepoPath) !== null; - } - - validateFocusOperation( - currentBranch: string | null, - targetBranch: string, - ): string | null { - if (!currentBranch) { - return "Cannot focus: main repo is in detached HEAD state."; - } - if (currentBranch === targetBranch) { - return `Cannot focus: already on branch "${targetBranch}".`; - } - return null; - } - - async getCommitSha(repoPath: string): Promise { - return await this.workspace.focus.getCommitSha.query({ repoPath }); - } - - async findWorktreeByBranch( - mainRepoPath: string, - branch: string, - ): Promise { - return await this.workspace.focus.findWorktreeByBranch.query({ - mainRepoPath, - branch, - }); - } - - toRelativeWorktreePath(absolutePath: string, mainRepoPath: string): string { - const repoName = path.basename(mainRepoPath); - const worktreeName = path.basename(absolutePath); - return `${repoName}/${worktreeName}`; - } - - toAbsoluteWorktreePath(relativePath: string): string { - return path.join(getWorktreeLocation(), relativePath); - } - - async worktreeExistsAtPath(relativePath: string): Promise { - const absolutePath = this.toAbsoluteWorktreePath(relativePath); - try { - await fs.access(absolutePath); - return true; - } catch { - return false; - } - } - - async cleanWorkingTree(repoPath: string): Promise { - await this.workspace.focus.cleanWorkingTree.mutate({ repoPath }); - } - - async detachWorktree(worktreePath: string): Promise { - return await this.workspace.focus.detachWorktree.mutate({ worktreePath }); - } - - async reattachWorktree( - worktreePath: string, - branchName: string, - ): Promise { - return await this.workspace.focus.reattachWorktree.mutate({ - worktreePath, - branch: branchName, - }); - } - - async isDirty(repoPath: string): Promise { - return await this.workspace.focus.isDirty.query({ repoPath }); - } - - async stash(repoPath: string, message: string): Promise { - return await this.workspace.focus.stash.mutate({ repoPath, message }); - } - - async stashApply(repoPath: string, stashRef: string): Promise { - return await this.workspace.focus.stashApply.mutate({ repoPath, stashRef }); - } - - async stashPop(repoPath: string): Promise { - return await this.workspace.focus.stashPop.mutate({ repoPath }); - } - - async checkout(repoPath: string, branch: string): Promise { - return await this.workspace.focus.checkout.mutate({ repoPath, branch }); - } - - async startSync(mainRepoPath: string, worktreePath: string): Promise { - await this.workspace.focus.startSync.mutate({ mainRepoPath, worktreePath }); - } - - async stopSync(): Promise { - await this.workspace.focus.stopSync.mutate(); - } - - async startWatchingMainRepo(mainRepoPath: string): Promise { - await this.workspace.focus.startWatchingMainRepo.mutate({ mainRepoPath }); - } - - async stopWatchingMainRepo(): Promise { - await this.workspace.focus.stopWatchingMainRepo.mutate(); - } - - private async handleBranchRenamed( - event: FocusBranchRenamedEvent, - ): Promise { - const remoteSession = await this.workspace.focus.getSession - .query({ mainRepoPath: event.mainRepoPath }) - .catch(() => null); - const localSession = this.getSession(event.mainRepoPath); - const sessionToPersist = - remoteSession ?? - (localSession - ? { - ...localSession, - branch: event.newBranch, - } - : null); - - if (sessionToPersist) { - const sessions = focusStore.get("sessions", {}); - sessions[event.mainRepoPath] = sessionToPersist; - focusStore.set("sessions", sessions); - } - - this.emit(FocusServiceEvent.BranchRenamed, event); - } -} diff --git a/apps/code/src/main/services/fs/schemas.ts b/apps/code/src/main/services/fs/schemas.ts deleted file mode 100644 index 971ddedb66..0000000000 --- a/apps/code/src/main/services/fs/schemas.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { z } from "zod"; - -export const listRepoFilesInput = z.object({ - repoPath: z.string(), - query: z.string().optional(), - limit: z.number().optional(), -}); - -export const readRepoFileInput = z.object({ - repoPath: z.string(), - filePath: z.string(), -}); - -export const readRepoFilesInput = z.object({ - repoPath: z.string(), - filePaths: z.array(z.string()), -}); - -export const readRepoFileBoundedInput = z.object({ - repoPath: z.string(), - filePath: z.string(), - maxLines: z.number().int().positive(), -}); - -export const readRepoFilesBoundedInput = z.object({ - repoPath: z.string(), - filePaths: z.array(z.string()), - maxLines: z.number().int().positive(), -}); - -export const boundedReadResult = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("content"), content: z.string() }), - z.object({ kind: z.literal("missing") }), - z.object({ kind: z.literal("too-large") }), -]); - -export const readRepoFilesBoundedOutput = z.record( - z.string(), - boundedReadResult, -); - -export const readAbsoluteFileInput = z.object({ - filePath: z.string(), -}); - -export const writeRepoFileInput = z.object({ - repoPath: z.string(), - filePath: z.string(), - content: z.string(), -}); - -export const fileEntryKind = z.enum(["file", "directory"]); - -const fileEntry = z.object({ - path: z.string(), - name: z.string(), - kind: fileEntryKind.default("file"), - changed: z.boolean().optional(), -}); - -export const listRepoFilesOutput = z.array(fileEntry); -export const readRepoFileOutput = z.string().nullable(); -export const readRepoFilesOutput = z.record(z.string(), readRepoFileOutput); - -export type ListRepoFilesInput = z.infer; -export type ReadRepoFileInput = z.infer; -export type ReadRepoFilesInput = z.infer; -export type WriteRepoFileInput = z.infer; -export type FileEntry = z.infer; -export type FileEntryKind = z.infer; -export type BoundedReadResult = z.infer; diff --git a/apps/code/src/main/services/fs/service.test.ts b/apps/code/src/main/services/fs/service.test.ts deleted file mode 100644 index 1bfc6b67b1..0000000000 --- a/apps/code/src/main/services/fs/service.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@posthog/git/queries", () => ({ - getChangedFiles: vi.fn(async () => new Set()), - listAllFiles: vi.fn(async () => []), -})); - -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -import { getChangedFiles, listAllFiles } from "@posthog/git/queries"; -import { FsService } from "./service"; - -function makeService() { - const fileWatcher = { on: vi.fn() } as never; - return new FsService(fileWatcher); -} - -describe("FsService.listRepoFiles", () => { - it("derives directory entries alongside files", async () => { - vi.mocked(getChangedFiles).mockResolvedValue(new Set()); - vi.mocked(listAllFiles).mockResolvedValue([ - "a.ts", - "src/b.ts", - "src/sub/c.ts", - ]); - - const service = makeService(); - const entries = await service.listRepoFiles("/repo"); - - const dirs = entries - .filter((e) => e.kind === "directory") - .map((e) => e.path); - const files = entries.filter((e) => e.kind === "file").map((e) => e.path); - - expect(dirs).toEqual(["src", "src/sub"]); - expect(files).toEqual(["a.ts", "src/b.ts", "src/sub/c.ts"]); - }); - - it("filters directories and files by query substring", async () => { - vi.mocked(getChangedFiles).mockResolvedValue(new Set()); - vi.mocked(listAllFiles).mockResolvedValue([ - "a.ts", - "src/b.ts", - "src/sub/c.ts", - ]); - - const service = makeService(); - const entries = await service.listRepoFiles("/repo", "sub"); - - expect(entries.map((e) => ({ path: e.path, kind: e.kind }))).toEqual([ - { path: "src/sub", kind: "directory" }, - { path: "src/sub/c.ts", kind: "file" }, - ]); - }); -}); diff --git a/apps/code/src/main/services/fs/service.ts b/apps/code/src/main/services/fs/service.ts deleted file mode 100644 index d6b220abfb..0000000000 --- a/apps/code/src/main/services/fs/service.ts +++ /dev/null @@ -1,306 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { getChangedFiles, listAllFiles } from "@posthog/git/queries"; -import { FileWatcherEventKind as FileWatcherEvent } from "@posthog/workspace-server/services/watcher/schemas"; -import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import type { FileWatcherBridge } from "../file-watcher/bridge"; -import type { BoundedReadResult, FileEntry } from "./schemas"; - -const log = logger.scope("fs"); - -@injectable() -export class FsService { - private static readonly CACHE_TTL = 30000; - private static readonly READ_REPO_FILES_CONCURRENCY = 24; - private cache = new Map(); - - constructor( - @inject(MAIN_TOKENS.FileWatcherService) - private fileWatcher: FileWatcherBridge, - ) { - this.fileWatcher.on(FileWatcherEvent.FileChanged, ({ repoPath }) => { - this.invalidateCache(repoPath); - }); - - this.fileWatcher.on(FileWatcherEvent.FileDeleted, ({ repoPath }) => { - this.invalidateCache(repoPath); - }); - - this.fileWatcher.on(FileWatcherEvent.DirectoryChanged, ({ repoPath }) => { - this.invalidateCache(repoPath); - }); - - this.fileWatcher.on(FileWatcherEvent.GitStateChanged, ({ repoPath }) => { - this.invalidateCache(repoPath); - }); - } - - async listRepoFiles( - repoPath: string, - query?: string, - limit?: number, - ): Promise { - if (!repoPath) return []; - - try { - const changedFiles = await getChangedFiles(repoPath); - - if (query?.trim()) { - const allFiles = await listAllFiles(repoPath); - const directories = this.deriveDirectories(allFiles); - const lowerQuery = query.toLowerCase(); - const matchingDirs = directories.filter((d) => - d.toLowerCase().includes(lowerQuery), - ); - const matchingFiles = allFiles.filter((f) => - f.toLowerCase().includes(lowerQuery), - ); - const entries = [ - ...this.toDirectoryEntries(matchingDirs), - ...this.toFileEntries(matchingFiles, changedFiles), - ]; - return limit ? entries.slice(0, limit) : entries; - } - - const cached = this.cache.get(repoPath); - if (cached && Date.now() - cached.timestamp < FsService.CACHE_TTL) { - return limit ? cached.files.slice(0, limit) : cached.files; - } - - const files = await listAllFiles(repoPath); - const directories = this.deriveDirectories(files); - const entries = [ - ...this.toDirectoryEntries(directories), - ...this.toFileEntries(files, changedFiles), - ]; - this.cache.set(repoPath, { files: entries, timestamp: Date.now() }); - - return limit ? entries.slice(0, limit) : entries; - } catch (error) { - log.error("Error listing repo files:", error); - return []; - } - } - - invalidateCache(repoPath?: string): void { - if (repoPath) { - this.cache.delete(repoPath); - } else { - this.cache.clear(); - } - } - - async readRepoFile( - repoPath: string, - filePath: string, - ): Promise { - try { - return await fs.promises.readFile( - this.resolvePath(repoPath, filePath), - "utf-8", - ); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT" && code !== "EISDIR") { - log.error(`Failed to read file ${filePath}:`, error); - } - return null; - } - } - - async readRepoFiles( - repoPath: string, - filePaths: string[], - ): Promise> { - const uniqueFilePaths = [...new Set(filePaths)]; - const entries = await this.mapWithConcurrency( - uniqueFilePaths, - FsService.READ_REPO_FILES_CONCURRENCY, - async (filePath) => - [filePath, await this.readRepoFile(repoPath, filePath)] as const, - ); - return Object.fromEntries(entries); - } - - async readRepoFileBounded( - repoPath: string, - filePath: string, - maxLines: number, - ): Promise { - try { - const content = await fs.promises.readFile( - this.resolvePath(repoPath, filePath), - "utf-8", - ); - if (exceedsLineLimit(content, maxLines)) { - return { kind: "too-large" }; - } - return { kind: "content", content }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "EISDIR") { - return { kind: "missing" }; - } - log.error(`Failed to read file ${filePath}:`, error); - return { kind: "missing" }; - } - } - - async readRepoFilesBounded( - repoPath: string, - filePaths: string[], - maxLines: number, - ): Promise> { - const uniqueFilePaths = [...new Set(filePaths)]; - const entries = await this.mapWithConcurrency( - uniqueFilePaths, - FsService.READ_REPO_FILES_CONCURRENCY, - async (filePath) => - [ - filePath, - await this.readRepoFileBounded(repoPath, filePath, maxLines), - ] as const, - ); - return Object.fromEntries(entries); - } - - async readAbsoluteFile(filePath: string): Promise { - try { - return await fs.promises.readFile(path.resolve(filePath), "utf-8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - log.error(`Failed to read file ${filePath}:`, error); - } - return null; - } - } - - async readFileAsBase64(filePath: string): Promise { - const resolved = path.resolve(filePath); - try { - const buffer = await fs.promises.readFile(resolved); - return buffer.toString("base64"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - log.error(`Failed to read file as base64 ${filePath}:`, error); - return null; - } - // macOS uses narrow no-break space (U+202F) in screenshot filenames - // but paths often lose this during text processing. Find the actual file. - const dir = path.dirname(resolved); - const basename = path.basename(resolved); - try { - const files = await fs.promises.readdir(dir); - const normalizeSpaces = (s: string) => - s.replace(/[\s\u00A0\u202F]/g, " "); - const normalizedTarget = normalizeSpaces(basename); - const match = files.find( - (f) => normalizeSpaces(f) === normalizedTarget, - ); - if (match) { - const buffer = await fs.promises.readFile(path.join(dir, match)); - return buffer.toString("base64"); - } - } catch { - // Directory read failed - } - return null; - } - } - - async writeRepoFile( - repoPath: string, - filePath: string, - content: string, - ): Promise { - await fs.promises.writeFile( - this.resolvePath(repoPath, filePath), - content, - "utf-8", - ); - this.invalidateCache(repoPath); - } - - private resolvePath(repoPath: string, filePath: string): string { - const base = path.resolve(repoPath); - const resolved = path.resolve(base, filePath); - if (resolved !== base && !resolved.startsWith(base + path.sep)) { - throw new Error("Access denied: path outside repository"); - } - return resolved; - } - - private toFileEntries( - files: string[], - changedFiles: Set, - ): FileEntry[] { - return files.map((p) => ({ - path: p, - name: path.basename(p), - kind: "file", - changed: changedFiles.has(p), - })); - } - - private toDirectoryEntries(directories: string[]): FileEntry[] { - return directories.map((p) => ({ - path: p, - name: path.basename(p), - kind: "directory", - })); - } - - private deriveDirectories(files: string[]): string[] { - const dirs = new Set(); - for (const file of files) { - let parent = path.posix.dirname(file); - while (parent && parent !== "." && parent !== "/") { - if (dirs.has(parent)) break; - dirs.add(parent); - parent = path.posix.dirname(parent); - } - } - return Array.from(dirs).sort(); - } - - private async mapWithConcurrency( - items: readonly T[], - concurrency: number, - mapper: (item: T) => Promise, - ): Promise { - if (items.length === 0) return []; - - const results = new Array(items.length); - let index = 0; - - const worker = async () => { - while (index < items.length) { - const currentIndex = index++; - results[currentIndex] = await mapper(items[currentIndex]); - } - }; - - await Promise.all( - Array.from({ length: Math.min(concurrency, items.length) }, () => - worker(), - ), - ); - - return results; - } -} - -function exceedsLineLimit(content: string, maxLines: number): boolean { - let lineCount = 1; - for (let i = 0; i < content.length; i++) { - if (content.charCodeAt(i) === 10) { - lineCount++; - if (lineCount > maxLines) { - return true; - } - } - } - return false; -} diff --git a/apps/code/src/main/services/git/service.test.ts b/apps/code/src/main/services/git/service.test.ts deleted file mode 100644 index afe6a4ff4f..0000000000 --- a/apps/code/src/main/services/git/service.test.ts +++ /dev/null @@ -1,539 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockExecGh = vi.hoisted(() => vi.fn()); -const mockGetRemoteUrl = vi.hoisted(() => vi.fn()); - -vi.mock("@posthog/git/gh", () => ({ - execGh: mockExecGh, -})); - -vi.mock("@posthog/git/queries", async () => { - const actual = await vi.importActual("@posthog/git/queries"); - return { ...actual, getRemoteUrl: mockGetRemoteUrl }; -}); - -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -import type { AgentService } from "../agent/service"; -import type { LlmGatewayService } from "../llm-gateway/service"; -import type { WorkspaceService } from "../workspace/service"; -import { GitService, mapPrState } from "./service"; - -describe("GitService.getPrChangedFiles", () => { - let service: GitService; - - beforeEach(() => { - vi.clearAllMocks(); - service = new GitService( - {} as LlmGatewayService, - {} as WorkspaceService, - { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, - ); - }); - - it("flattens paginated GH API results and maps file statuses", async () => { - mockExecGh.mockResolvedValue({ - exitCode: 0, - stdout: JSON.stringify([ - [ - { - filename: "src/new.ts", - status: "added", - additions: 10, - deletions: 0, - }, - { - filename: "src/old.ts", - status: "removed", - additions: 0, - deletions: 3, - }, - ], - [ - { - filename: "src/renamed-new.ts", - status: "renamed", - previous_filename: "src/renamed-old.ts", - additions: 1, - deletions: 1, - }, - { - filename: "src/changed.ts", - status: "changed", - additions: 4, - deletions: 2, - }, - ], - ]), - }); - - const result = await service.getPrChangedFiles( - "https://github.com/posthog/code/pull/123", - ); - - expect(mockExecGh).toHaveBeenCalledWith([ - "api", - "repos/posthog/code/pulls/123/files", - "--paginate", - "--slurp", - ]); - - expect(result).toEqual([ - { - path: "src/new.ts", - status: "added", - originalPath: undefined, - linesAdded: 10, - linesRemoved: 0, - }, - { - path: "src/old.ts", - status: "deleted", - originalPath: undefined, - linesAdded: 0, - linesRemoved: 3, - }, - { - path: "src/renamed-new.ts", - status: "renamed", - originalPath: "src/renamed-old.ts", - linesAdded: 1, - linesRemoved: 1, - }, - { - path: "src/changed.ts", - status: "modified", - originalPath: undefined, - linesAdded: 4, - linesRemoved: 2, - }, - ]); - }); - - it("returns empty array for non-GitHub PR URL", async () => { - const result = await service.getPrChangedFiles( - "https://example.com/pull/1", - ); - expect(result).toEqual([]); - expect(mockExecGh).not.toHaveBeenCalled(); - }); - - it("throws when gh command fails", async () => { - mockExecGh.mockResolvedValue({ - exitCode: 1, - stdout: "", - stderr: "auth required", - }); - - await expect( - service.getPrChangedFiles("https://github.com/posthog/code/pull/123"), - ).rejects.toThrow("Failed to fetch PR files"); - }); -}); - -describe("GitService.getGhAuthToken", () => { - let service: GitService; - - beforeEach(() => { - vi.clearAllMocks(); - service = new GitService( - {} as LlmGatewayService, - {} as WorkspaceService, - { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, - ); - }); - - it("returns the authenticated GitHub CLI token", async () => { - mockExecGh.mockResolvedValue({ - exitCode: 0, - stdout: "ghu_test_token\n", - stderr: "", - }); - - const result = await service.getGhAuthToken(); - - expect(mockExecGh).toHaveBeenCalledWith(["auth", "token"]); - expect(result).toEqual({ - success: true, - token: "ghu_test_token", - error: null, - }); - }); - - it("returns the gh error when auth token lookup fails", async () => { - mockExecGh.mockResolvedValue({ - exitCode: 1, - stdout: "", - stderr: "authentication required", - }); - - const result = await service.getGhAuthToken(); - - expect(result).toEqual({ - success: false, - token: null, - error: "authentication required", - }); - }); - - it("returns error when stdout is empty", async () => { - mockExecGh.mockResolvedValue({ - exitCode: 0, - stdout: "", - stderr: "", - }); - - const result = await service.getGhAuthToken(); - - expect(result).toEqual({ - success: false, - token: null, - error: "GitHub auth token is empty", - }); - }); -}); - -describe("GitService.getPrUrlForBranch", () => { - let service: GitService; - - beforeEach(() => { - vi.clearAllMocks(); - service = new GitService( - {} as LlmGatewayService, - {} as WorkspaceService, - { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, - ); - }); - - it("returns the PR URL for a branch via gh pr list", async () => { - mockGetRemoteUrl.mockResolvedValue("https://github.com/posthog/code.git"); - mockExecGh.mockResolvedValue({ - exitCode: 0, - stdout: JSON.stringify([ - { url: "https://github.com/posthog/code/pull/42" }, - ]), - }); - - const result = await service.getPrUrlForBranch("/repo", "feat/x"); - - expect(mockExecGh).toHaveBeenCalledWith([ - "pr", - "list", - "--head", - "feat/x", - "--state", - "all", - "--json", - "url", - "--limit", - "1", - "--repo", - "posthog/code", - ]); - expect(result).toBe("https://github.com/posthog/code/pull/42"); - }); - - it("returns null when no PR exists for the branch", async () => { - mockGetRemoteUrl.mockResolvedValue("https://github.com/posthog/code.git"); - mockExecGh.mockResolvedValue({ exitCode: 0, stdout: "[]" }); - - const result = await service.getPrUrlForBranch("/repo", "feat/no-pr"); - - expect(result).toBeNull(); - }); - - it("returns null for a non-GitHub remote", async () => { - mockGetRemoteUrl.mockResolvedValue("https://gitlab.com/foo/bar.git"); - - const result = await service.getPrUrlForBranch("/repo", "feat/x"); - - expect(result).toBeNull(); - expect(mockExecGh).not.toHaveBeenCalled(); - }); - - it("returns null when the repo has no remote", async () => { - mockGetRemoteUrl.mockResolvedValue(null); - - const result = await service.getPrUrlForBranch("/repo", "feat/x"); - - expect(result).toBeNull(); - expect(mockExecGh).not.toHaveBeenCalled(); - }); - - it("returns null when gh command fails", async () => { - mockGetRemoteUrl.mockResolvedValue("https://github.com/posthog/code.git"); - mockExecGh.mockResolvedValue({ - exitCode: 1, - stdout: "", - stderr: "auth required", - }); - - const result = await service.getPrUrlForBranch("/repo", "feat/x"); - - expect(result).toBeNull(); - }); -}); - -describe("mapPrState", () => { - it("returns merged when merged boolean is true", () => { - expect(mapPrState("open", true, false)).toBe("merged"); - expect(mapPrState("closed", true, false)).toBe("merged"); - expect(mapPrState(null, true, false)).toBe("merged"); - }); - - it("returns merged when state string is MERGED", () => { - expect(mapPrState("MERGED", false, false)).toBe("merged"); - expect(mapPrState("merged", false, false)).toBe("merged"); - expect(mapPrState("Merged", false, false)).toBe("merged"); - }); - - it("returns closed for closed state", () => { - expect(mapPrState("closed", false, false)).toBe("closed"); - expect(mapPrState("CLOSED", false, false)).toBe("closed"); - }); - - it("returns draft when draft is true and not merged/closed", () => { - expect(mapPrState("open", false, true)).toBe("draft"); - }); - - it("closed takes priority over draft", () => { - expect(mapPrState("closed", false, true)).toBe("closed"); - }); - - it("returns open for open state", () => { - expect(mapPrState("open", false, false)).toBe("open"); - expect(mapPrState("OPEN", false, false)).toBe("open"); - }); - - it("returns null for unknown state", () => { - expect(mapPrState(null, false, false)).toBeNull(); - expect(mapPrState("something", false, false)).toBeNull(); - }); -}); - -describe("GitService.getPrReviewComments", () => { - let service: GitService; - - beforeEach(() => { - vi.clearAllMocks(); - service = new GitService( - {} as LlmGatewayService, - {} as WorkspaceService, - { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, - ); - }); - - const makeThread = (id: string, commentId: number) => ({ - id, - isResolved: false, - isOutdated: false, - path: "src/foo.ts", - diffSide: "RIGHT", - line: 10, - originalLine: 10, - startLine: null, - startDiffSide: null, - subjectType: "LINE", - comments: { - nodes: [ - { - databaseId: commentId, - body: "looks good", - path: "src/foo.ts", - diffHunk: "@@ -1,3 +1,4 @@", - replyTo: null, - author: { - login: "alice", - avatarUrl: "https://example.com/alice.png", - }, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z", - }, - ], - }, - }); - - const makePage = ( - threads: object[], - hasNextPage: boolean, - endCursor: string | null, - ) => ({ - exitCode: 0, - stdout: JSON.stringify({ - data: { - repository: { - pullRequest: { - reviewThreads: { - pageInfo: { hasNextPage, endCursor }, - nodes: threads, - }, - }, - }, - }, - }), - }); - - it("returns empty array for non-PR URL", async () => { - const result = await service.getPrReviewComments( - "https://github.com/owner/repo", - ); - expect(result).toEqual([]); - expect(mockExecGh).not.toHaveBeenCalled(); - }); - - it("maps a single-page response to PrReviewThread[]", async () => { - mockExecGh.mockResolvedValueOnce( - makePage([makeThread("T_1", 101)], false, null), - ); - - const result = await service.getPrReviewComments( - "https://github.com/owner/repo/pull/1", - ); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - nodeId: "T_1", - isResolved: false, - rootId: 101, - filePath: "src/foo.ts", - }); - expect(result[0].comments[0]).toMatchObject({ - id: 101, - body: "looks good", - side: "RIGHT", - line: 10, - subject_type: "line", - }); - }); - - it("fetches all pages when hasNextPage is true", async () => { - mockExecGh - .mockResolvedValueOnce( - makePage([makeThread("T_1", 101)], true, "cursor-abc"), - ) - .mockResolvedValueOnce(makePage([makeThread("T_2", 102)], false, null)); - - const result = await service.getPrReviewComments( - "https://github.com/owner/repo/pull/1", - ); - - expect(mockExecGh).toHaveBeenCalledTimes(2); - expect(result).toHaveLength(2); - expect(result.map((t) => t.nodeId)).toEqual(["T_1", "T_2"]); - - const secondCall = JSON.parse(mockExecGh.mock.calls[1][1].input); - expect(secondCall.variables.cursor).toBe("cursor-abc"); - }); - - it("returns partial results when MAX_THREAD_PAGES is exceeded", async () => { - let n = 0; - mockExecGh.mockImplementation(async () => { - n += 1; - return makePage([makeThread(`T_${n}`, 100 + n)], true, `cursor-${n}`); - }); - - const result = await service.getPrReviewComments( - "https://github.com/owner/repo/pull/1", - ); - - expect(mockExecGh).toHaveBeenCalledTimes(50); - expect(result).toHaveLength(50); - expect(result[0]?.nodeId).toBe("T_1"); - expect(result[49]?.nodeId).toBe("T_50"); - }); - - it("throws when gh exits with non-zero", async () => { - mockExecGh.mockResolvedValueOnce({ - exitCode: 1, - stderr: "auth error", - stdout: "", - }); - - await expect( - service.getPrReviewComments("https://github.com/owner/repo/pull/1"), - ).rejects.toThrow("Failed to fetch PR review threads"); - }); - - it("throws with the GraphQL error message when GitHub returns 200 with errors", async () => { - mockExecGh.mockResolvedValueOnce({ - exitCode: 0, - stdout: JSON.stringify({ - data: null, - errors: [{ message: "Resource not accessible by integration" }], - }), - }); - - await expect( - service.getPrReviewComments("https://github.com/owner/repo/pull/1"), - ).rejects.toThrow("Resource not accessible by integration"); - }); -}); - -describe("GitService.resolveReviewThread", () => { - let service: GitService; - - beforeEach(() => { - vi.clearAllMocks(); - service = new GitService( - {} as LlmGatewayService, - {} as WorkspaceService, - { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, - ); - }); - - it("resolves a thread and returns isResolved: true", async () => { - mockExecGh.mockResolvedValueOnce({ - exitCode: 0, - stdout: JSON.stringify({ - data: { - resolveReviewThread: { thread: { id: "T_1", isResolved: true } }, - }, - }), - }); - - const result = await service.resolveReviewThread("T_1", true); - - expect(result).toEqual({ success: true, isResolved: true }); - const body = JSON.parse(mockExecGh.mock.calls[0][1].input); - expect(body.query).toContain("resolveReviewThread"); - expect(body.variables.threadId).toBe("T_1"); - }); - - it("unresolves a thread and returns isResolved: false", async () => { - mockExecGh.mockResolvedValueOnce({ - exitCode: 0, - stdout: JSON.stringify({ - data: { - unresolveReviewThread: { thread: { id: "T_1", isResolved: false } }, - }, - }), - }); - - const result = await service.resolveReviewThread("T_1", false); - - expect(result).toEqual({ success: true, isResolved: false }); - const body = JSON.parse(mockExecGh.mock.calls[0][1].input); - expect(body.query).toContain("unresolveReviewThread"); - }); - - it("returns success: false when gh exits with non-zero", async () => { - mockExecGh.mockResolvedValueOnce({ - exitCode: 1, - stderr: "network error", - stdout: "", - }); - - const result = await service.resolveReviewThread("T_1", true); - - expect(result).toEqual({ success: false, isResolved: false }); - }); -}); diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts deleted file mode 100644 index 99ee93a957..0000000000 --- a/apps/code/src/main/services/git/service.ts +++ /dev/null @@ -1,2048 +0,0 @@ -import { execFile } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -import { execGh } from "@posthog/git/gh"; -import { - getAllBranches, - getBranchDiffPatchesByPath, - getChangedFilesBetweenBranches, - getChangedFilesDetailed, - getCommitConventions, - getCommitsBetweenBranches, - getCurrentBranch, - getDefaultBranch, - getDiffAgainstRemote, - getDiffHead, - getDiffStats, - getFileAtHead, - getGitBusyState, - getLatestCommit, - getRemoteUrl, - getStagedDiff, - getSyncStatus, - getUnstagedDiff, - fetch as gitFetch, - isGitRepository, - stageFiles, - unstageFiles, -} from "@posthog/git/queries"; -import { CreateBranchSaga, SwitchBranchSaga } from "@posthog/git/sagas/branch"; -import { CloneSaga } from "@posthog/git/sagas/clone"; -import { CommitSaga } from "@posthog/git/sagas/commit"; -import { DiscardFileChangesSaga } from "@posthog/git/sagas/discard"; -import { PullSaga } from "@posthog/git/sagas/pull"; -import { PushSaga } from "@posthog/git/sagas/push"; -import { parseGithubUrl } from "@posthog/git/utils"; -import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import type { AgentService } from "../agent/service"; -import type { LlmGatewayService } from "../llm-gateway/service"; -import type { SidebarPrState } from "../workspace/schemas"; -import type { WorkspaceService } from "../workspace/service"; -import { CreatePrSaga } from "./create-pr-saga"; -import type { - ChangedFile, - CloneProgressPayload, - CommitOutput, - CreatePrOutput, - CreatePrProgressPayload, - DetectRepoResult, - DiffStats, - DiscardFileChangesOutput, - GetCommitConventionsOutput, - GetPrTemplateOutput, - GhAuthTokenOutput, - GhStatusOutput, - GitBusyState, - GitCommitInfo, - GitFileStatus, - GithubRef, - GithubRefKind, - GitRepoInfo, - GitStateSnapshot, - GitStatusOutput, - GitSyncStatus, - OpenPrOutput, - PrActionType, - PrDetailsByUrlOutput, - PrReviewComment, - PrReviewThread, - PrStatusOutput, - PublishOutput, - PullOutput, - PushOutput, - ReplyToPrCommentOutput, - ResolveReviewThreadOutput, - SyncOutput, - UpdatePrByUrlOutput, -} from "./schemas"; - -const fsPromises = fs.promises; - -export const GitServiceEvent = { - CloneProgress: "cloneProgress", - CreatePrProgress: "createPrProgress", -} as const; - -export interface GitServiceEvents { - [GitServiceEvent.CloneProgress]: CloneProgressPayload; - [GitServiceEvent.CreatePrProgress]: CreatePrProgressPayload; -} - -const log = logger.scope("git-service"); - -const FETCH_THROTTLE_MS = 5 * 60 * 1000; -const MAX_DIFF_LENGTH = 8000; - -export function mapPrState( - state: string | null, - merged: boolean, - draft: boolean, -): SidebarPrState { - const lower = state?.toLowerCase() ?? null; - if (merged || lower === "merged") return "merged"; - if (lower === "closed") return "closed"; - if (draft) return "draft"; - if (lower === "open") return "open"; - return null; -} - -/** - * Wraps a GitHub API per-file patch (hunk content only) with - * the `diff --git` / `---` / `+++` header so that unified-diff - * parsers like `@pierre/diffs` can process it correctly. - */ -function toUnifiedDiffPatch( - rawPatch: string, - filename: string, - previousFilename: string | undefined, - status: ChangedFile["status"], -): string { - const oldPath = previousFilename ?? filename; - const fromPath = status === "added" ? "/dev/null" : `a/${oldPath}`; - const toPath = status === "deleted" ? "/dev/null" : `b/${filename}`; - return `diff --git a/${oldPath} b/${filename}\n--- ${fromPath}\n+++ ${toPath}\n${rawPatch}`; -} - -@injectable() -export class GitService extends TypedEventEmitter { - private lastFetchTime = new Map(); - - constructor( - @inject(MAIN_TOKENS.LlmGatewayService) - private readonly llmGateway: LlmGatewayService, - @inject(MAIN_TOKENS.WorkspaceService) - private readonly workspaceService: WorkspaceService, - @inject(MAIN_TOKENS.AgentService) - private readonly agentService: AgentService, - ) { - super(); - } - - /** - * Resolve env-var overrides set by the agent's SessionStart hooks for the - * given task. Used so UI-triggered git/gh operations (Commit, Create PR) - * see the same env (notably `SSH_AUTH_SOCK` re-pointed at Secretive) as - * the agent's bash tool. Returns `undefined` if there's nothing to apply. - */ - private async getSessionEnv( - taskId: string | undefined, - ): Promise | undefined> { - if (!taskId) return undefined; - try { - const env = await this.agentService.getSessionEnvForTask(taskId); - return Object.keys(env).length > 0 ? env : undefined; - } catch (err) { - log.warn("Failed to load session env for task", { taskId, err }); - return undefined; - } - } - - private async getStateSnapshot( - directoryPath: string, - options?: { - includeChangedFiles?: boolean; - includeDiffStats?: boolean; - includeSyncStatus?: boolean; - includeLatestCommit?: boolean; - includePrStatus?: boolean; - forceRefresh?: boolean; - }, - ): Promise { - const { - includeChangedFiles = true, - includeDiffStats = true, - includeSyncStatus = true, - includeLatestCommit = true, - includePrStatus = false, - } = options ?? {}; - - const results = await Promise.allSettled([ - includeChangedFiles ? this.getChangedFilesHead(directoryPath) : null, - includeDiffStats ? this.getDiffStats(directoryPath) : null, - includeSyncStatus - ? this.getGitSyncStatusInternal(directoryPath, true) - : null, - includeLatestCommit ? this.getLatestCommit(directoryPath) : null, - includePrStatus ? this.getPrStatus(directoryPath) : null, - ]); - - const getValue = (r: PromiseSettledResult): T | undefined => - r.status === "fulfilled" && r.value !== null ? r.value : undefined; - - return { - changedFiles: getValue(results[0]), - diffStats: getValue(results[1]), - syncStatus: getValue(results[2]), - latestCommit: getValue(results[3]), - prStatus: getValue(results[4]), - }; - } - - private async fetchIfStale(directoryPath: string): Promise { - const now = Date.now(); - const lastFetch = this.lastFetchTime.get(directoryPath) ?? 0; - if (now - lastFetch > FETCH_THROTTLE_MS) { - try { - await gitFetch(directoryPath); - this.lastFetchTime.set(directoryPath, now); - } catch {} - } - } - - private async getGitSyncStatusInternal( - directoryPath: string, - forceRefresh = false, - ): Promise { - if (forceRefresh) { - this.lastFetchTime.delete(directoryPath); - } - await this.fetchIfStale(directoryPath); - - const status = await getSyncStatus(directoryPath); - return { - aheadOfRemote: status.aheadOfRemote, - behind: status.behind, - aheadOfDefault: status.aheadOfDefault, - hasRemote: status.hasRemote, - currentBranch: status.currentBranch, - isFeatureBranch: status.isFeatureBranch, - }; - } - - public async detectRepo( - directoryPath: string, - ): Promise { - if (!directoryPath) return null; - - const remoteUrl = await getRemoteUrl(directoryPath); - if (!remoteUrl) return null; - - const parsed = parseGithubUrl(remoteUrl); - if (!parsed) return null; - - const branch = await getCurrentBranch(directoryPath); - if (!branch) return null; - - return { - organization: parsed.owner, - repository: parsed.repo, - remote: remoteUrl, - branch, - }; - } - - public async validateRepo(directoryPath: string): Promise { - if (!directoryPath) return false; - return isGitRepository(directoryPath); - } - - public async cloneRepository( - repoUrl: string, - targetPath: string, - cloneId: string, - ): Promise<{ cloneId: string }> { - const emitProgress = ( - status: CloneProgressPayload["status"], - message: string, - ) => { - this.emit(GitServiceEvent.CloneProgress, { cloneId, status, message }); - }; - - emitProgress("cloning", `Starting clone of ${repoUrl}...`); - - const saga = new CloneSaga(); - const result = await saga.run({ - repoUrl, - targetPath, - onProgress: (stage, progress, processed, total) => { - const pct = progress ? ` ${Math.round(progress)}%` : ""; - const count = total ? ` (${processed}/${total})` : ""; - emitProgress("cloning", `${stage}${pct}${count}`); - }, - }); - if (!result.success) { - emitProgress("error", result.error); - throw new Error(result.error); - } - emitProgress("complete", "Clone completed successfully"); - return { cloneId }; - } - - public async getRemoteUrl(directoryPath: string): Promise { - return getRemoteUrl(directoryPath); - } - - public async getCurrentBranch( - directoryPath: string, - signal?: AbortSignal, - ): Promise { - return getCurrentBranch(directoryPath, { abortSignal: signal }); - } - - public async getDefaultBranch(directoryPath: string): Promise { - return getDefaultBranch(directoryPath); - } - - public async getAllBranches( - directoryPath: string, - signal?: AbortSignal, - ): Promise { - return getAllBranches(directoryPath, { abortSignal: signal }); - } - - public async getGitBusyState( - directoryPath: string, - signal?: AbortSignal, - ): Promise { - return getGitBusyState(directoryPath, { abortSignal: signal }); - } - - public async createBranch( - directoryPath: string, - branchName: string, - ): Promise { - const saga = new CreateBranchSaga(); - const result = await saga.run({ baseDir: directoryPath, branchName }); - if (!result.success) throw new Error(result.error); - } - - public async checkoutBranch( - directoryPath: string, - branchName: string, - ): Promise<{ previousBranch: string; currentBranch: string }> { - const saga = new SwitchBranchSaga(); - const result = await saga.run({ baseDir: directoryPath, branchName }); - if (!result.success) throw new Error(result.error); - return result.data; - } - - public async getChangedFilesHead( - directoryPath: string, - signal?: AbortSignal, - ): Promise { - const files = await getChangedFilesDetailed(directoryPath, { - excludePatterns: [".claude", "CLAUDE.local.md"], - abortSignal: signal, - }); - type HeadChangedFile = Omit; - const filteredFiles: Array = await Promise.all( - files.map(async (file) => { - if (file.status === "untracked") { - try { - const stats = await fs.promises.stat( - path.join(directoryPath, file.path), - ); - if (!stats.isFile()) return null; - } catch { - return null; - } - } - - return { - path: file.path, - status: file.status, - originalPath: file.originalPath, - linesAdded: file.linesAdded, - linesRemoved: file.linesRemoved, - staged: file.staged, - }; - }), - ); - - return filteredFiles.filter( - (file): file is HeadChangedFile => file !== null, - ); - } - - public async getFileAtHead( - directoryPath: string, - filePath: string, - signal?: AbortSignal, - ): Promise { - return getFileAtHead(directoryPath, filePath, { abortSignal: signal }); - } - - public async getDiffHead( - directoryPath: string, - ignoreWhitespace?: boolean, - signal?: AbortSignal, - ): Promise { - return getDiffHead(directoryPath, { - ignoreWhitespace, - abortSignal: signal, - }); - } - - public async getDiffCached( - directoryPath: string, - ignoreWhitespace?: boolean, - signal?: AbortSignal, - ): Promise { - return getStagedDiff(directoryPath, { - ignoreWhitespace, - abortSignal: signal, - }); - } - - public async getDiffUnstaged( - directoryPath: string, - ignoreWhitespace?: boolean, - signal?: AbortSignal, - ): Promise { - return getUnstagedDiff(directoryPath, { - ignoreWhitespace, - abortSignal: signal, - }); - } - - public async stageFiles( - directoryPath: string, - paths: string[], - ): Promise { - await stageFiles(directoryPath, paths); - return this.getStateSnapshot(directoryPath); - } - - public async unstageFiles( - directoryPath: string, - paths: string[], - ): Promise { - await unstageFiles(directoryPath, paths); - return this.getStateSnapshot(directoryPath); - } - - public async getDiffStats( - directoryPath: string, - signal?: AbortSignal, - ): Promise { - const stats = await getDiffStats(directoryPath, { - excludePatterns: [".claude", "CLAUDE.local.md"], - abortSignal: signal, - }); - return { - filesChanged: stats.filesChanged, - linesAdded: stats.linesAdded, - linesRemoved: stats.linesRemoved, - }; - } - - public async discardFileChanges( - directoryPath: string, - filePath: string, - fileStatus: GitFileStatus, - ): Promise { - const saga = new DiscardFileChangesSaga(); - const result = await saga.run({ - baseDir: directoryPath, - filePath, - fileStatus, - }); - if (!result.success) { - return { success: false }; - } - - const state = await this.getStateSnapshot(directoryPath, { - includeSyncStatus: false, - includeLatestCommit: false, - }); - - return { success: true, state }; - } - - public async getGitSyncStatus( - directoryPath: string, - forceRefresh = false, - ): Promise { - return this.getGitSyncStatusInternal(directoryPath, forceRefresh); - } - - public async getLatestCommit( - directoryPath: string, - signal?: AbortSignal, - ): Promise { - const commit = await getLatestCommit(directoryPath, { - abortSignal: signal, - }); - if (!commit) return null; - return { - sha: commit.sha, - shortSha: commit.shortSha, - message: commit.message, - author: commit.author, - date: commit.date, - }; - } - - public async getGitRepoInfo( - directoryPath: string, - ): Promise { - try { - const remoteUrl = await getRemoteUrl(directoryPath); - if (!remoteUrl) return null; - - const parsed = parseGithubUrl(remoteUrl); - if (!parsed) return null; - - const currentBranch = await getCurrentBranch(directoryPath); - const defaultBranch = await getDefaultBranch(directoryPath); - - let compareUrl: string | null = null; - if (currentBranch && currentBranch !== defaultBranch) { - compareUrl = `https://github.com/${parsed.owner}/${parsed.repo}/compare/${defaultBranch}...${currentBranch}?expand=1`; - } - - return { - organization: parsed.owner, - repository: parsed.repo, - currentBranch: currentBranch ?? null, - defaultBranch, - compareUrl, - }; - } catch { - return null; - } - } - - public async push( - directoryPath: string, - remote = "origin", - branch?: string, - setUpstream = false, - signal?: AbortSignal, - env?: Record, - ): Promise { - const saga = new PushSaga(); - const result = await saga.run({ - baseDir: directoryPath, - remote, - branch: branch || undefined, - setUpstream, - signal, - env, - }); - if (!result.success) { - return { success: false, message: result.error }; - } - - const state = await this.getStateSnapshot(directoryPath, { - includeChangedFiles: false, - includeDiffStats: false, - includeLatestCommit: false, - }); - - return { - success: true, - message: `Pushed ${result.data.branch} to ${result.data.remote}`, - state, - }; - } - - public async pull( - directoryPath: string, - remote = "origin", - branch?: string, - signal?: AbortSignal, - ): Promise { - const saga = new PullSaga(); - const result = await saga.run({ - baseDir: directoryPath, - remote, - branch: branch || undefined, - signal, - }); - if (!result.success) { - return { success: false, message: result.error }; - } - - const state = await this.getStateSnapshot(directoryPath); - - return { - success: true, - message: `${result.data.changes} files changed`, - updatedFiles: result.data.changes, - state, - }; - } - - public async publish( - directoryPath: string, - remote = "origin", - signal?: AbortSignal, - env?: Record, - ): Promise { - const currentBranch = await getCurrentBranch(directoryPath); - if (!currentBranch) { - return { success: false, message: "No branch to publish", branch: "" }; - } - - const pushResult = await this.push( - directoryPath, - remote, - currentBranch, - true, - signal, - env, - ); - return { - success: pushResult.success, - message: pushResult.message, - branch: currentBranch, - state: pushResult.state, - }; - } - - public async sync( - directoryPath: string, - remote = "origin", - signal?: AbortSignal, - ): Promise { - const pullResult = await this.pull( - directoryPath, - remote, - undefined, - signal, - ); - if (!pullResult.success) { - return { - success: false, - pullMessage: pullResult.message, - pushMessage: "Skipped due to pull failure", - }; - } - - const pushResult = await this.push( - directoryPath, - remote, - undefined, - false, - signal, - ); - - const state = await this.getStateSnapshot(directoryPath); - - return { - success: pushResult.success, - pullMessage: pullResult.message, - pushMessage: pushResult.message, - state, - }; - } - - public async createPr(input: { - directoryPath: string; - flowId: string; - branchName?: string; - commitMessage?: string; - prTitle?: string; - prBody?: string; - draft?: boolean; - stagedOnly?: boolean; - taskId?: string; - conversationContext?: string; - }): Promise { - const { directoryPath, flowId } = input; - - const emitProgress = ( - step: CreatePrProgressPayload["step"], - message: string, - prUrl?: string, - ) => { - this.emit(GitServiceEvent.CreatePrProgress, { - flowId, - step, - message, - prUrl, - }); - }; - - const sessionEnv = await this.getSessionEnv(input.taskId); - - const saga = new CreatePrSaga( - { - getCurrentBranch: (dir) => getCurrentBranch(dir), - createBranch: (dir, name) => this.createBranch(dir, name), - checkoutBranch: (dir, name) => this.checkoutBranch(dir, name), - getChangedFilesHead: (dir) => this.getChangedFilesHead(dir), - generateCommitMessage: (dir) => - this.generateCommitMessage(dir, input.conversationContext), - commit: (dir, msg, opts) => - this.commit(dir, msg, { ...opts, envOverride: sessionEnv }), - getSyncStatus: (dir) => this.getGitSyncStatus(dir), - push: (dir) => - this.push(dir, "origin", undefined, false, undefined, sessionEnv), - publish: (dir) => this.publish(dir, "origin", undefined, sessionEnv), - generatePrTitleAndBody: (dir) => - this.generatePrTitleAndBody(dir, input.conversationContext), - createPr: (dir, title, body, draft) => - this.createPrViaGh(dir, title, body, draft, sessionEnv), - onProgress: emitProgress, - }, - log, - ); - - const result = await saga.run({ - directoryPath, - branchName: input.branchName, - commitMessage: input.commitMessage, - prTitle: input.prTitle, - prBody: input.prBody, - draft: input.draft, - stagedOnly: input.stagedOnly, - taskId: input.taskId, - }); - - if (!result.success) { - emitProgress("error", result.error); - return { - success: false, - message: result.error, - prUrl: null, - failedStep: result.failedStep as CreatePrOutput["failedStep"], - }; - } - - const state = await this.getStateSnapshot(directoryPath, { - includePrStatus: true, - }); - - if (input.taskId) { - const linkedBranch = - input.branchName ?? (await getCurrentBranch(directoryPath)); - if (linkedBranch) { - this.workspaceService.linkBranch(input.taskId, linkedBranch, "user"); - } - } - - emitProgress( - "complete", - "Pull request created", - result.data.prUrl ?? undefined, - ); - - return { - success: true, - message: "Pull request created", - prUrl: result.data.prUrl, - failedStep: null, - state, - }; - } - - public async getPrTemplate( - directoryPath: string, - ): Promise { - const templatePaths = [ - ".github/PULL_REQUEST_TEMPLATE.md", - ".github/pull_request_template.md", - "PULL_REQUEST_TEMPLATE.md", - "pull_request_template.md", - "docs/PULL_REQUEST_TEMPLATE.md", - ]; - - for (const relativePath of templatePaths) { - const fullPath = path.join(directoryPath, relativePath); - try { - const content = await fsPromises.readFile(fullPath, "utf-8"); - return { template: content, templatePath: relativePath }; - } catch {} - } - - return { template: null, templatePath: null }; - } - - public async getCommitConventions( - directoryPath: string, - sampleSize = 20, - ): Promise { - return getCommitConventions(directoryPath, sampleSize); - } - - public async commit( - directoryPath: string, - message: string, - options?: { - paths?: string[]; - allowEmpty?: boolean; - stagedOnly?: boolean; - taskId?: string; - /** Pre-resolved session env. Internal — used by createPr to avoid re-loading. */ - envOverride?: Record; - }, - ): Promise { - const fail = (msg: string): CommitOutput => ({ - success: false, - message: msg, - commitSha: null, - branch: null, - }); - - if (!message.trim()) return fail("Commit message is required"); - - const { envOverride, ...sagaOptions } = options ?? {}; - const env = envOverride ?? (await this.getSessionEnv(options?.taskId)); - - const saga = new CommitSaga(); - const result = await saga.run({ - baseDir: directoryPath, - message: message.trim(), - env, - ...sagaOptions, - }); - - if (!result.success) return fail(result.error); - - const state = await this.getStateSnapshot(directoryPath); - - return { - success: true, - message: `Committed ${result.data.commitSha.slice(0, 7)}`, - commitSha: result.data.commitSha, - branch: result.data.branch, - state, - }; - } - - public async getGitStatus(): Promise { - try { - const { stdout } = await execFileAsync("git", ["--version"]); - const version = stdout.trim().replace("git version ", ""); - return { installed: true, version }; - } catch { - return { installed: false, version: null }; - } - } - - public async getGhStatus(): Promise { - const versionResult = await execGh(["--version"]); - if (versionResult.exitCode !== 0) { - return { - installed: false, - version: null, - authenticated: false, - username: null, - error: versionResult.error ?? versionResult.stderr ?? null, - }; - } - - const version = versionResult.stdout.split("\n")[0]?.trim() ?? null; - const authResult = await execGh(["auth", "status"]); - const authenticated = authResult.exitCode === 0; - const authOutput = `${authResult.stdout}\n${authResult.stderr}`; - const usernameMatch = authOutput.match( - /Logged in to github.com (?:as |account )(\S+)/, - ); - - return { - installed: true, - version, - authenticated, - username: usernameMatch?.[1] ?? null, - error: authenticated - ? null - : authResult.stderr || authResult.error || null, - }; - } - - public async getGhAuthToken(): Promise { - const result = await execGh(["auth", "token"]); - if (result.exitCode !== 0) { - return { - success: false, - token: null, - error: - result.stderr || result.error || "Failed to read GitHub auth token", - }; - } - - const token = result.stdout.trim(); - if (!token) { - return { - success: false, - token: null, - error: "GitHub auth token is empty", - }; - } - - return { - success: true, - token, - error: null, - }; - } - - public async getPrStatus(directoryPath: string): Promise { - const base: PrStatusOutput = { - hasRemote: false, - isGitHubRepo: false, - currentBranch: null, - defaultBranch: null, - prExists: false, - prUrl: null, - prState: null, - baseBranch: null, - headBranch: null, - isDraft: null, - error: null, - }; - - try { - const remoteUrl = await getRemoteUrl(directoryPath); - const isGitHubRepo = !!(remoteUrl && parseGithubUrl(remoteUrl)); - const currentBranch = await getCurrentBranch(directoryPath); - const defaultBranch = await getDefaultBranch(directoryPath).catch( - () => null, - ); - - if (!isGitHubRepo || !currentBranch) { - return { - ...base, - hasRemote: !!remoteUrl, - isGitHubRepo, - currentBranch, - defaultBranch, - }; - } - - const prResult = await execGh( - ["pr", "view", "--json", "url,state,baseRefName,headRefName,isDraft"], - { cwd: directoryPath }, - ); - - const shared = { - hasRemote: true, - isGitHubRepo: true, - currentBranch, - defaultBranch, - }; - - if (prResult.exitCode !== 0) { - return { ...base, ...shared }; - } - - const data = JSON.parse(prResult.stdout) as { - url?: string; - state?: string; - baseRefName?: string; - headRefName?: string; - isDraft?: boolean; - }; - - return { - ...base, - ...shared, - prExists: !!data.url, - prUrl: data.url ?? null, - prState: data.state ?? null, - baseBranch: data.baseRefName ?? null, - headBranch: data.headRefName ?? null, - isDraft: data.isDraft ?? null, - }; - } catch (error) { - return { - ...base, - error: error instanceof Error ? error.message : "Unknown error", - }; - } - } - - /** - * Look up the PR URL for any branch name (not just the currently checked-out - * one). Uses `gh pr list --head` rather than `gh pr view` so the lookup works - * regardless of which branch the working tree is on. - */ - public async getPrUrlForBranch( - directoryPath: string, - branchName: string, - ): Promise { - try { - const remoteUrl = await getRemoteUrl(directoryPath); - if (!remoteUrl) return null; - - const parsed = parseGithubUrl(remoteUrl); - if (!parsed) return null; - - const result = await execGh([ - "pr", - "list", - "--head", - branchName, - "--state", - "all", - "--json", - "url", - "--limit", - "1", - "--repo", - `${parsed.owner}/${parsed.repo}`, - ]); - - if (result.exitCode !== 0) { - log.warn("Failed to list PRs for branch", { - branchName, - error: result.stderr || result.error, - }); - return null; - } - - const data = JSON.parse(result.stdout) as Array<{ url?: string }>; - return data[0]?.url ?? null; - } catch (error) { - log.warn("Failed to resolve PR URL for branch", { branchName, error }); - return null; - } - } - - private async createPrViaGh( - directoryPath: string, - title?: string, - body?: string, - draft?: boolean, - env?: Record, - ): Promise<{ success: boolean; message: string; prUrl: string | null }> { - const prFooter = - "\n\n---\n*Created with [PostHog Code](https://posthog.com/code?ref=pr)*"; - - const args = ["pr", "create"]; - if (title) { - args.push("--title", title); - args.push("--body", (body || "") + prFooter); - } else { - args.push("--fill"); - } - if (draft) args.push("--draft"); - - const result = await execGh(args, { cwd: directoryPath, env }); - if (result.exitCode !== 0) { - return { - success: false, - message: result.stderr || result.error || "Failed to create PR", - prUrl: null, - }; - } - - const prUrlMatch = result.stdout.match(/https:\/\/github\.com\/[^\s]+/); - const prUrl = prUrlMatch?.[0] ?? null; - - return { - success: true, - message: "Pull request created", - prUrl, - }; - } - - public async openPr(directoryPath: string): Promise { - const result = await execGh(["pr", "view", "--json", "url"], { - cwd: directoryPath, - }); - - if (result.exitCode !== 0) { - return { - success: false, - message: result.stderr || result.error || "Failed to fetch PR", - prUrl: null, - }; - } - - const data = JSON.parse(result.stdout) as { url?: string }; - const prUrl = data.url ?? null; - return { success: !!prUrl, message: prUrl ? "OK" : "No PR found", prUrl }; - } - - public async getPrChangedFiles(prUrl: string): Promise { - const pr = parseGithubUrl(prUrl); - if (pr?.kind !== "pr") return []; - - const { owner, repo, number } = pr; - - try { - const result = await execGh([ - "api", - `repos/${owner}/${repo}/pulls/${number}/files`, - "--paginate", - "--slurp", - ]); - - if (result.exitCode !== 0) { - throw new Error( - `Failed to fetch PR files: ${result.stderr || result.error || "Unknown error"}`, - ); - } - - const pages = JSON.parse(result.stdout) as Array< - Array<{ - filename: string; - status: string; - previous_filename?: string; - additions: number; - deletions: number; - patch?: string; - }> - >; - const files = pages.flat(); - - return files.map((f) => { - let status: ChangedFile["status"]; - switch (f.status) { - case "added": - status = "added"; - break; - case "removed": - status = "deleted"; - break; - case "renamed": - status = "renamed"; - break; - default: - status = "modified"; - break; - } - - return { - path: f.filename, - status, - originalPath: f.previous_filename, - linesAdded: f.additions, - linesRemoved: f.deletions, - patch: f.patch - ? toUnifiedDiffPatch( - f.patch, - f.filename, - f.previous_filename, - status, - ) - : undefined, - }; - }); - } catch (error) { - log.warn("Failed to fetch PR changed files", { prUrl, error }); - throw error; - } - } - - public async getPrDetailsByUrl( - prUrl: string, - ): Promise { - const pr = parseGithubUrl(prUrl); - if (pr?.kind !== "pr") return null; - - try { - const result = await execGh([ - "api", - `repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`, - "--jq", - "{state,merged,draft}", - ]); - - if (result.exitCode !== 0) { - log.warn("Failed to fetch PR details", { - prUrl, - error: result.stderr || result.error, - }); - return null; - } - - const data = JSON.parse(result.stdout) as { - state: string; - merged: boolean; - draft: boolean; - }; - - return data; - } catch (error) { - log.warn("Failed to fetch PR details", { prUrl, error }); - return null; - } - } - - public async updatePrByUrl( - prUrl: string, - action: PrActionType, - ): Promise { - const pr = parseGithubUrl(prUrl); - if (pr?.kind !== "pr") { - return { success: false, message: "Invalid PR URL" }; - } - - try { - const args = - action === "draft" - ? ["pr", "ready", "--undo", String(pr.number)] - : ["pr", action, String(pr.number)]; - - const result = await execGh([ - ...args, - "--repo", - `${pr.owner}/${pr.repo}`, - ]); - - if (result.exitCode !== 0) { - const errorMsg = result.stderr || result.error || "Unknown error"; - log.warn("Failed to update PR", { prUrl, action, error: errorMsg }); - return { success: false, message: errorMsg }; - } - - return { success: true, message: result.stdout }; - } catch (error) { - log.warn("Failed to update PR", { prUrl, action, error }); - return { - success: false, - message: error instanceof Error ? error.message : "Unknown error", - }; - } - } - - public async getPrReviewComments(prUrl: string): Promise { - const pr = parseGithubUrl(prUrl); - if (pr?.kind !== "pr") return []; - - const { owner, repo, number } = pr; - - // Position fields (line, side, etc.) live on the thread, not on individual comments. - const query = ` - query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - reviewThreads(first: 100, after: $cursor) { - pageInfo { hasNextPage endCursor } - nodes { - id - isResolved - isOutdated - path - diffSide - line - originalLine - startLine - startDiffSide - subjectType - comments(first: 100) { - nodes { - databaseId - body - path - diffHunk - replyTo { databaseId } - author { login avatarUrl } - createdAt - updatedAt - } - } - } - } - } - } - } - `; - - type ThreadNode = { - id: string; - isResolved: boolean; - isOutdated: boolean; - path: string; - diffSide: "LEFT" | "RIGHT"; - line: number | null; - originalLine: number | null; - startLine: number | null; - startDiffSide: "LEFT" | "RIGHT" | null; - subjectType: "LINE" | "FILE" | null; - comments: { - nodes: Array<{ - databaseId: number; - body: string; - path: string; - diffHunk: string; - replyTo: { databaseId: number } | null; - author: { login: string; avatarUrl: string }; - createdAt: string; - updatedAt: string; - }>; - }; - }; - - type PageResponse = { - data: { - repository: { - pullRequest: { - reviewThreads: { - pageInfo: { hasNextPage: boolean; endCursor: string | null }; - nodes: ThreadNode[]; - }; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - const MAX_THREAD_PAGES = 50; // 50 × 100 = 5 000 threads max - - try { - const allNodes: ThreadNode[] = []; - let cursor: string | null = null; - let completed = false; - - for (let page = 0; page < MAX_THREAD_PAGES; page++) { - const result = await execGh(["api", "graphql", "--input", "-"], { - input: JSON.stringify({ - query, - variables: { owner, repo, number, cursor }, - }), - }); - - if (result.exitCode !== 0) { - throw new Error( - `Failed to fetch PR review threads: ${result.stderr || result.error || "Unknown error"}`, - ); - } - - const data = JSON.parse(result.stdout) as PageResponse; - if (data.errors?.length) { - throw new Error( - `GraphQL error: ${data.errors.map((e) => e.message).join("; ")}`, - ); - } - const reviewThreads = data.data.repository.pullRequest.reviewThreads; - allNodes.push(...reviewThreads.nodes); - if (!reviewThreads.pageInfo.hasNextPage) { - completed = true; - break; - } - cursor = reviewThreads.pageInfo.endCursor; - } - - if (!completed) { - log.warn( - "getPrReviewComments hit MAX_THREAD_PAGES; returning partial results", - { - prUrl, - returned: allNodes.length, - }, - ); - } - - return allNodes.map((thread) => { - const comments: PrReviewComment[] = thread.comments.nodes.map((c) => ({ - id: c.databaseId, - body: c.body, - path: c.path, - diff_hunk: c.diffHunk, - line: thread.line, - original_line: thread.originalLine, - side: thread.diffSide, - start_line: thread.startLine, - start_side: thread.startDiffSide, - in_reply_to_id: c.replyTo?.databaseId ?? null, - user: { login: c.author.login, avatar_url: c.author.avatarUrl }, - created_at: c.createdAt, - updated_at: c.updatedAt, - subject_type: thread.subjectType - ? (thread.subjectType.toLowerCase() as "line" | "file") - : null, - })); - - return { - nodeId: thread.id, - isResolved: thread.isResolved, - rootId: comments[0]?.id ?? 0, - filePath: thread.path, - comments, - }; - }); - } catch (error) { - log.warn("Failed to fetch PR review threads", { prUrl, error }); - throw error; - } - } - - public async resolveReviewThread( - threadNodeId: string, - resolved: boolean, - ): Promise { - const mutation = resolved - ? `mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } }` - : `mutation($threadId: ID!) { unresolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } }`; - - try { - const result = await execGh(["api", "graphql", "--input", "-"], { - input: JSON.stringify({ - query: mutation, - variables: { threadId: threadNodeId }, - }), - }); - - if (result.exitCode !== 0) { - log.warn("Failed to resolve/unresolve review thread", { - threadNodeId, - resolved, - error: result.stderr || result.error, - }); - return { success: false, isResolved: !resolved }; - } - - const data = JSON.parse(result.stdout) as { - data: { - resolveReviewThread?: { thread: { isResolved: boolean } }; - unresolveReviewThread?: { thread: { isResolved: boolean } }; - }; - errors?: Array<{ message: string }>; - }; - if (data.errors?.length) { - log.warn("Failed to resolve/unresolve review thread", { - threadNodeId, - resolved, - error: data.errors.map((e) => e.message).join("; "), - }); - return { success: false, isResolved: !resolved }; - } - const thread = - data.data.resolveReviewThread?.thread ?? - data.data.unresolveReviewThread?.thread; - - return { success: true, isResolved: thread?.isResolved ?? resolved }; - } catch (error) { - log.warn("Failed to resolve/unresolve review thread", { - threadNodeId, - error, - }); - return { success: false, isResolved: !resolved }; - } - } - - public async replyToPrComment( - prUrl: string, - commentId: number, - body: string, - ): Promise { - const pr = parseGithubUrl(prUrl); - if (pr?.kind !== "pr") { - return { success: false, comment: null }; - } - - try { - const result = await execGh([ - "api", - `repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments/${commentId}/replies`, - "-X", - "POST", - "-f", - `body=${body}`, - ]); - - if (result.exitCode !== 0) { - log.warn("Failed to reply to PR comment", { - prUrl, - commentId, - error: result.stderr || result.error, - }); - return { success: false, comment: null }; - } - - const data = JSON.parse(result.stdout) as PrReviewComment; - return { success: true, comment: data }; - } catch (error) { - log.warn("Failed to reply to PR comment", { prUrl, commentId, error }); - return { success: false, comment: null }; - } - } - - public async getBranchChangedFiles( - repo: string, - branch: string, - ): Promise { - const parts = repo.split("/"); - if (parts.length !== 2) return []; - - const [owner, repoName] = parts; - - const repoResult = await execGh([ - "api", - `repos/${owner}/${repoName}`, - "--jq", - ".default_branch", - ]); - - if (repoResult.exitCode !== 0 || !repoResult.stdout.trim()) { - return []; - } - const defaultBranch = repoResult.stdout.trim(); - - const result = await execGh([ - "api", - `repos/${owner}/${repoName}/compare/${defaultBranch}...${branch}`, - ]); - - if (result.exitCode !== 0) { - throw new Error( - `Failed to fetch branch files: ${result.stderr || result.error || "Unknown error"}`, - ); - } - - const response = JSON.parse(result.stdout) as { - files?: Array<{ - filename: string; - status: string; - previous_filename?: string; - additions: number; - deletions: number; - patch?: string; - }>; - }; - const files = response.files; - - if (!files) return []; - - return files.map((f) => { - let status: ChangedFile["status"]; - switch (f.status) { - case "added": - status = "added"; - break; - case "removed": - status = "deleted"; - break; - case "renamed": - status = "renamed"; - break; - default: - status = "modified"; - break; - } - - return { - path: f.filename, - status, - originalPath: f.previous_filename, - linesAdded: f.additions, - linesRemoved: f.deletions, - patch: f.patch - ? toUnifiedDiffPatch(f.patch, f.filename, f.previous_filename, status) - : undefined, - }; - }); - } - - public async getLocalBranchChangedFiles( - directoryPath: string, - branch: string, - ): Promise { - await this.fetchIfStale(directoryPath); - - const defaultBranch = await getDefaultBranch(directoryPath); - if (!defaultBranch) return []; - - const files = await getChangedFilesBetweenBranches( - directoryPath, - defaultBranch, - branch, - { excludePatterns: [".claude", "CLAUDE.local.md"] }, - ); - if (files.length === 0) return []; - - const patchByPath = await getBranchDiffPatchesByPath( - directoryPath, - defaultBranch, - branch, - ); - - return files.map((f) => ({ - path: f.path, - status: f.status, - originalPath: f.originalPath, - linesAdded: f.linesAdded, - linesRemoved: f.linesRemoved, - patch: patchByPath.get(f.path), - })); - } - - public async generateCommitMessage( - directoryPath: string, - conversationContext?: string, - ): Promise<{ message: string }> { - const [stagedDiff, unstagedDiff, conventions, changedFiles] = - await Promise.all([ - getStagedDiff(directoryPath), - getUnstagedDiff(directoryPath), - getCommitConventions(directoryPath), - this.getChangedFilesHead(directoryPath), - ]); - - const diff = stagedDiff || unstagedDiff; - if (!diff && changedFiles.length === 0) { - return { message: "" }; - } - - const truncatedDiff = - diff.length > MAX_DIFF_LENGTH - ? `${diff.slice(0, MAX_DIFF_LENGTH)}\n... (diff truncated)` - : diff; - - const filesSummary = changedFiles - .map((f) => `${f.status}: ${f.path}`) - .join("\n"); - - const conventionHint = conventions.conventionalCommits - ? `This repository uses conventional commits. Common prefixes: ${ - conventions.commonPrefixes.join(", ") || "feat, fix, docs, chore" - }. -Example messages from this repo: -${conventions.sampleMessages.slice(0, 3).join("\n")}` - : `Example messages from this repo: -${conventions.sampleMessages.slice(0, 3).join("\n")}`; - - const system = `You are a git commit message generator. Generate a concise, descriptive commit message for the given changes. - -${conventionHint} - -Rules: -- First line should be a short summary (max 72 chars) -- Use imperative mood ("Add feature" not "Added feature") -- Be specific about what changed -- If using conventional commits, include the appropriate prefix -- If conversation context is provided, use it to understand WHY the changes were made and reflect that intent -- Do not include any explanation, just output the commit message`; - - const contextSection = conversationContext - ? `\n\nConversation context (why these changes were made):\n${conversationContext}` - : ""; - - const userMessage = `Generate a commit message for these changes: - -Changed files: -${filesSummary} - -Diff: -${truncatedDiff}${contextSection}`; - - log.debug("Generating commit message", { - fileCount: changedFiles.length, - diffLength: diff.length, - conventionalCommits: conventions.conventionalCommits, - hasConversationContext: !!conversationContext, - }); - - const response = await this.llmGateway.prompt( - [{ role: "user", content: userMessage }], - { system }, - ); - - return { message: response.content.trim() }; - } - - public async generatePrTitleAndBody( - directoryPath: string, - conversationContext?: string, - ): Promise<{ title: string; body: string }> { - await this.fetchIfStale(directoryPath); - - const [defaultBranch, currentBranch, prTemplate] = await Promise.all([ - getDefaultBranch(directoryPath), - getCurrentBranch(directoryPath), - this.getPrTemplate(directoryPath), - ]); - - const head = currentBranch ?? undefined; - const [branchDiff, stagedDiff, unstagedDiff, commits, conventions] = - await Promise.all([ - getDiffAgainstRemote(directoryPath, defaultBranch), - getStagedDiff(directoryPath), - getUnstagedDiff(directoryPath), - getCommitsBetweenBranches(directoryPath, defaultBranch, head, 30), - getCommitConventions(directoryPath), - ]); - - const uncommittedDiff = [stagedDiff, unstagedDiff] - .filter(Boolean) - .join("\n"); - const parts = [branchDiff, uncommittedDiff].filter(Boolean); - const fullDiff = parts.join("\n"); - if (commits.length === 0 && !fullDiff) { - return { title: "", body: "" }; - } - const commitsSummary = commits.map((c) => `- ${c.message}`).join("\n"); - const truncatedDiff = fullDiff - ? fullDiff.length > MAX_DIFF_LENGTH - ? `${fullDiff.slice(0, MAX_DIFF_LENGTH)}\n... (diff truncated)` - : fullDiff - : ""; - - const templateHint = prTemplate.template - ? `The repository has a PR template. Use it as a guide for structure but adapt the content to match the actual changes:\n${prTemplate.template.slice( - 0, - 2000, - )}` - : ""; - - const conventionHint = conventions.conventionalCommits - ? `- Use conventional commit format for the title (e.g., "feat(scope): description"). Common prefixes: ${ - conventions.commonPrefixes.join(", ") || "feat, fix, docs, chore" - }.` - : ""; - - const system = `You are a PR description generator. Generate a title and detailed description for a pull request. - -Output format (use exactly this format): -TITLE: - -BODY: - - -Rules for the title: -- Short and descriptive (max 72 chars) -- Use imperative mood ("Add feature" not "Added feature") -- Be specific about what the PR accomplishes -${conventionHint} - -Rules for the body: -- Start with a TL;DR section (1-2 sentences summarizing the change) -- Include a "What changed?" section with bullet points describing the key changes -- If conversation context is provided, use it to explain WHY the changes were made in the TL;DR -- Be thorough but concise -- Use markdown formatting -- Only describe changes that are actually in the diff — do not invent or assume changes -${templateHint} - -Do not include any explanation outside the TITLE and BODY sections.`; - - const contextSection = conversationContext - ? `\n\nConversation context (why these changes were made):\n${conversationContext}` - : ""; - - const userMessage = `Generate a PR title and description for these changes: - -Branch: ${currentBranch ?? "unknown"} -> ${defaultBranch} - -Commits in this PR: -${commitsSummary || "(no commits yet - changes are uncommitted)"} - -Diff: -${truncatedDiff || "(no diff available)"}${contextSection}`; - - log.debug("Generating PR title and body", { - commitCount: commits.length, - diffLength: fullDiff.length, - hasTemplate: !!prTemplate.template, - hasConversationContext: !!conversationContext, - conventionalCommits: conventions.conventionalCommits, - }); - - const response = await this.llmGateway.prompt( - [{ role: "user", content: userMessage }], - { system, maxTokens: 2000 }, - ); - - const content = response.content.trim(); - const titleMatch = content.match(/^TITLE:\s*(.+?)(?:\n|$)/m); - const bodyMatch = content.match(/BODY:\s*([\s\S]+)$/m); - - return { - title: titleMatch?.[1]?.trim() ?? "", - body: bodyMatch?.[1]?.trim() ?? "", - }; - } - - private async resolveCanonicalRepo(repo: string): Promise { - const result = await execGh([ - "repo", - "view", - repo, - "--json", - "name,owner", - "--jq", - '.owner.login + "/" + .name', - ]); - if (result.exitCode !== 0) return repo; - return result.stdout.trim() || repo; - } - - private normalizeRefState(raw: string): GithubRef["state"] { - const upper = raw.toUpperCase(); - if (upper === "OPEN") return "OPEN"; - if (upper === "MERGED") return "MERGED"; - return "CLOSED"; - } - - private parseGhRefs( - stdout: string, - repo: string, - kind: GithubRefKind, - ): GithubRef[] { - const raw = JSON.parse(stdout) as Array<{ - number: number; - title: string; - state: string; - labels?: Array<{ name: string }>; - url: string; - isDraft?: boolean; - }>; - const items = Array.isArray(raw) ? raw : [raw]; - return items.map((item) => { - // GitHub's issues API returns PRs too, so derive kind from the URL path. - const resolvedKind: GithubRefKind = item.url.includes("/pull/") - ? "pr" - : kind; - return { - kind: resolvedKind, - number: item.number, - title: item.title, - state: this.normalizeRefState(item.state), - labels: (item.labels ?? []).map((l) => l.name), - url: item.url, - repo, - isDraft: resolvedKind === "pr" ? Boolean(item.isDraft) : undefined, - }; - }); - } - - public async searchGithubRefs( - directoryPath: string, - query?: string, - limit = 5, - kinds: GithubRefKind[] = ["issue", "pr"], - ): Promise { - const repoInfo = await this.getGitRepoInfo(directoryPath); - if (!repoInfo) return []; - - // Full GitHub URL: look up directly. May target a different repo than the local one. - const urlRef = parseGithubUrl(query); - if (urlRef && urlRef.kind !== "repo" && kinds.includes(urlRef.kind)) { - const repoSlug = `${urlRef.owner}/${urlRef.repo}`; - return this.fetchGhRefs( - [urlRef.kind, "view", String(urlRef.number), "--repo", repoSlug], - repoSlug, - urlRef.kind, - ); - } - - const repo = await this.resolveCanonicalRepo( - `${repoInfo.organization}/${repoInfo.repository}`, - ); - - const trimmed = query?.trim().replace(/^#/, ""); - const refNumber = trimmed ? Number(trimmed) : Number.NaN; - - // Number lookup: `gh issue view` returns PRs too (shared number space). - if (!Number.isNaN(refNumber) && Number.isInteger(refNumber)) { - return this.fetchGhRefs( - ["issue", "view", String(refNumber), "--repo", repo], - repo, - "issue", - ); - } - - // Text search: one call via `gh search issues --include-prs` when both kinds are wanted. - if (trimmed) { - const includeIssues = kinds.includes("issue"); - const includePrs = kinds.includes("pr"); - const searchNoun = !includeIssues && includePrs ? "prs" : "issues"; - const args = [ - "search", - searchNoun, - trimmed, - "--repo", - repo, - "--limit", - String(limit), - "--match", - "title", - ]; - if (searchNoun === "issues" && includePrs) args.push("--include-prs"); - return this.fetchGhRefs(args, repo, "issue"); - } - - // Empty query: list defaults per-kind in parallel (`gh search` requires a query). - const tasks: Promise[] = []; - if (kinds.includes("issue")) { - tasks.push( - this.fetchGhRefs( - [ - "issue", - "list", - "--repo", - repo, - "--limit", - String(limit), - "--state", - "all", - ], - repo, - "issue", - ), - ); - } - if (kinds.includes("pr")) { - tasks.push( - this.fetchGhRefs( - [ - "pr", - "list", - "--repo", - repo, - "--limit", - String(limit), - "--state", - "all", - ], - repo, - "pr", - ), - ); - } - const results = await Promise.all(tasks); - return this.sortRefs(this.dedupeRefsByUrl(results.flat())); - } - - private dedupeRefsByUrl(refs: GithubRef[]): GithubRef[] { - const byUrl = new Map(); - for (const ref of refs) { - if (!byUrl.has(ref.url)) byUrl.set(ref.url, ref); - } - return [...byUrl.values()]; - } - - private sortRefs(refs: GithubRef[]): GithubRef[] { - return refs.sort((a, b) => b.number - a.number); - } - - public async getGithubIssue( - owner: string, - repo: string, - number: number, - ): Promise { - const repoSlug = `${owner}/${repo}`; - const refs = await this.fetchGhRefs( - ["issue", "view", String(number), "--repo", repoSlug], - repoSlug, - "issue", - ); - return refs[0] ?? null; - } - - public async getGithubPullRequest( - owner: string, - repo: string, - number: number, - ): Promise { - const repoSlug = `${owner}/${repo}`; - const refs = await this.fetchGhRefs( - ["pr", "view", String(number), "--repo", repoSlug], - repoSlug, - "pr", - ); - return refs[0] ?? null; - } - - private async fetchGhRefs( - args: string[], - repo: string, - kind: GithubRefKind, - ): Promise { - const jsonFields = - kind === "pr" - ? "number,title,state,url,isDraft" - : "number,title,state,labels,url"; - const result = await execGh([...args, "--json", jsonFields]); - if (result.exitCode !== 0) return []; - - try { - return this.parseGhRefs(result.stdout, repo, kind); - } catch { - log.warn("Failed to parse GitHub refs response", { repo, kind, args }); - return []; - } - } - - async getTaskPrStatus( - taskId: string, - cloudPrUrl: string | null, - ): Promise<{ prState: SidebarPrState; hasDiff: boolean }> { - const workspace = await this.workspaceService.getWorkspace(taskId); - if (!workspace) return { prState: null, hasDiff: false }; - - const { mode, worktreePath, folderPath, linkedBranch } = workspace; - const isCloud = mode === "cloud"; - const repoPath = worktreePath ?? (folderPath || null); - - // Cloud tasks: look up PR details by the cloud run's PR URL - if (isCloud && cloudPrUrl) { - const details = await this.getPrDetailsByUrl(cloudPrUrl); - if (details) { - return { - prState: mapPrState(details.state, details.merged, details.draft), - hasDiff: false, - }; - } - return { prState: null, hasDiff: false }; - } - - if (isCloud) return { prState: null, hasDiff: false }; - - // Linked branch: look up PR by branch name - if (linkedBranch && repoPath) { - const prUrl = await this.getPrUrlForBranch(repoPath, linkedBranch); - if (prUrl) { - const details = await this.getPrDetailsByUrl(prUrl); - if (details) { - return { - prState: mapPrState(details.state, details.merged, details.draft), - hasDiff: false, - }; - } - } - return { prState: null, hasDiff: false }; - } - - // Worktree tasks without linked branch: check current branch PR + diff - if (worktreePath) { - const prStatus = await this.getPrStatus(worktreePath); - if (prStatus.prExists && prStatus.prState) { - return { - prState: mapPrState( - prStatus.prState, - false, - prStatus.isDraft ?? false, - ), - hasDiff: false, - }; - } - - const [diffStats, syncStatus] = await Promise.all([ - this.getDiffStats(worktreePath), - this.getGitSyncStatus(worktreePath), - ]); - - const hasDiff = - (diffStats?.filesChanged ?? 0) > 0 || - (syncStatus?.aheadOfDefault ?? 0) > 0; - - return { prState: null, hasDiff }; - } - - return { prState: null, hasDiff: false }; - } -} diff --git a/apps/code/src/main/services/github-integration/schemas.ts b/apps/code/src/main/services/github-integration/schemas.ts deleted file mode 100644 index d36019bc15..0000000000 --- a/apps/code/src/main/services/github-integration/schemas.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - type CloudRegion, - cloudRegion, - type StartIntegrationFlowInput as StartGitHubFlowInput, - type StartIntegrationFlowOutput as StartGitHubFlowOutput, - startIntegrationFlowInput as startGitHubFlowInput, - startIntegrationFlowOutput as startGitHubFlowOutput, -} from "../integration-flow-schemas"; diff --git a/apps/code/src/main/services/handoff/service.test.ts b/apps/code/src/main/services/handoff/service.test.ts deleted file mode 100644 index e2d624153c..0000000000 --- a/apps/code/src/main/services/handoff/service.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockGetChangedFilesHead = vi.hoisted(() => vi.fn()); -const mockReconnectSession = vi.hoisted(() => vi.fn()); -const mockCancelSession = vi.hoisted(() => vi.fn()); -const mockSetPendingContext = vi.hoisted(() => vi.fn()); -const mockSendCommand = vi.hoisted(() => vi.fn()); -const mockCreatePosthogConfig = vi.hoisted(() => vi.fn()); -const mockUpdateMode = vi.hoisted(() => vi.fn()); -const mockNetFetch = vi.hoisted(() => vi.fn()); -const mockShowMessageBox = vi.hoisted(() => vi.fn()); -const mockApplyFromHandoff = vi.hoisted(() => vi.fn()); -const mockReadHandoffLocalGitState = vi.hoisted(() => vi.fn()); - -vi.mock("@main/utils/logger", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - }, -})); - -vi.mock("@main/utils/typed-event-emitter", () => ({ - TypedEventEmitter: class { - emit = vi.fn(); - }, -})); - -vi.mock("inversify", () => ({ - injectable: () => (target: unknown) => target, - inject: () => () => undefined, -})); - -vi.mock("electron", () => ({ - app: { getPath: () => "/home" }, - net: { fetch: mockNetFetch }, - dialog: { showMessageBox: mockShowMessageBox }, -})); - -vi.mock("@posthog/agent/posthog-api", () => ({ - PostHogAPIClient: vi.fn(), -})); - -vi.mock("@posthog/agent/handoff-checkpoint", () => ({ - HandoffCheckpointTracker: vi.fn().mockImplementation(() => ({ - applyFromHandoff: mockApplyFromHandoff, - })), -})); - -vi.mock("@posthog/git/handoff", () => ({ - readHandoffLocalGitState: mockReadHandoffLocalGitState, -})); - -vi.mock("@main/di/tokens", () => ({ - MAIN_TOKENS: { - GitService: Symbol("GitService"), - AgentService: Symbol("AgentService"), - CloudTaskService: Symbol("CloudTaskService"), - AgentAuthAdapter: Symbol("AgentAuthAdapter"), - WorkspaceRepository: Symbol("WorkspaceRepository"), - }, -})); - -import type { HandoffPreflightInput } from "./schemas"; -import { extractHandoffErrorCode, HandoffService } from "./service"; - -const DEFAULT_LOCAL_GIT_STATE = { - head: "abc123", - branch: "main", - upstreamHead: "def456", - upstreamRemote: "origin", - upstreamMergeRef: "refs/heads/main", -}; - -function createService(): HandoffService { - const gitService = { getChangedFilesHead: mockGetChangedFilesHead } as never; - const agentService = { - reconnectSession: mockReconnectSession, - cancelSession: mockCancelSession, - setPendingContext: mockSetPendingContext, - } as never; - const cloudTaskService = { sendCommand: mockSendCommand } as never; - const agentAuthAdapter = { - createPosthogConfig: mockCreatePosthogConfig, - } as never; - const workspaceRepo = { - updateMode: mockUpdateMode, - findByTaskId: vi.fn().mockReturnValue(null), - setModeAndRepository: vi.fn(), - } as never; - const repositoryRepo = { findByPath: vi.fn().mockReturnValue(null) } as never; - const dialog = { confirm: vi.fn().mockResolvedValue(1) } as never; - const appLifecycle = { - whenReady: vi.fn().mockResolvedValue(undefined), - } as never; - - return new HandoffService( - gitService, - agentService, - cloudTaskService, - agentAuthAdapter, - workspaceRepo, - repositoryRepo, - dialog, - appLifecycle, - ); -} - -function createPreflightInput( - overrides: Partial = {}, -): HandoffPreflightInput { - return { - taskId: "task-1", - runId: "run-1", - repoPath: "/repo/path", - apiHost: "https://us.posthog.com", - teamId: 2, - ...overrides, - }; -} - -describe("HandoffService.preflight", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockReadHandoffLocalGitState.mockResolvedValue(DEFAULT_LOCAL_GIT_STATE); - }); - - it("returns canHandoff=true when working tree is clean", async () => { - mockGetChangedFilesHead.mockResolvedValue([]); - - const service = createService(); - const result = await service.preflight(createPreflightInput()); - - expect(result.canHandoff).toBe(true); - expect(result.localTreeDirty).toBe(false); - expect(result.reason).toBeUndefined(); - expect(result.localGitState).toEqual(DEFAULT_LOCAL_GIT_STATE); - }); - - it("returns canHandoff=false when working tree has changes", async () => { - mockGetChangedFilesHead.mockResolvedValue([ - { path: "src/index.ts", status: "M" }, - ]); - - const service = createService(); - const result = await service.preflight(createPreflightInput()); - - expect(result.canHandoff).toBe(false); - expect(result.localTreeDirty).toBe(true); - expect(result.reason).toContain("uncommitted changes"); - }); - - it("checks the correct repo path", async () => { - mockGetChangedFilesHead.mockResolvedValue([]); - - const service = createService(); - await service.preflight(createPreflightInput({ repoPath: "/custom/path" })); - - expect(mockGetChangedFilesHead).toHaveBeenCalledWith("/custom/path"); - }); - - it("returns canHandoff=true when git check throws", async () => { - mockGetChangedFilesHead.mockRejectedValue(new Error("git not found")); - - const service = createService(); - const result = await service.preflight(createPreflightInput()); - - expect(result.canHandoff).toBe(true); - expect(result.localTreeDirty).toBe(false); - }); -}); - -describe("extractHandoffErrorCode", () => { - it("detects GitHub authorization failures in backend error payloads", () => { - const message = - 'Failed request: [400] {"type":"validation_error","code":"github_authorization_required","detail":"Link a GitHub account"}'; - - expect(extractHandoffErrorCode(message)).toBe( - "github_authorization_required", - ); - }); - - it("ignores unrelated failures", () => { - expect(extractHandoffErrorCode("Failed request: [500] boom")).toBe( - undefined, - ); - }); -}); diff --git a/apps/code/src/main/services/handoff/service.ts b/apps/code/src/main/services/handoff/service.ts deleted file mode 100644 index 9cb07a6b0b..0000000000 --- a/apps/code/src/main/services/handoff/service.ts +++ /dev/null @@ -1,485 +0,0 @@ -import { - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { MAIN_TOKENS } from "@main/di/tokens"; -import { logger } from "@main/utils/logger"; -import { TypedEventEmitter } from "@main/utils/typed-event-emitter"; -import { POSTHOG_NOTIFICATIONS } from "@posthog/agent"; -import { HandoffCheckpointTracker } from "@posthog/agent/handoff-checkpoint"; -import { PostHogAPIClient } from "@posthog/agent/posthog-api"; -import type * as AgentTypes from "@posthog/agent/types"; -import { - type GitHandoffBranchDivergence, - readHandoffLocalGitState, -} from "@posthog/git/handoff"; -import { ResetToDefaultBranchSaga } from "@posthog/git/sagas/branch"; -import { StashPushSaga } from "@posthog/git/sagas/stash"; -import type { IAppLifecycle } from "@posthog/platform/app-lifecycle"; -import type { IDialog } from "@posthog/platform/dialog"; -import { inject, injectable } from "inversify"; -import type { IRepositoryRepository } from "../../db/repositories/repository-repository"; -import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; -import type { AgentAuthAdapter } from "../agent/auth-adapter"; -import type { AgentService } from "../agent/service"; -import type { CloudTaskService } from "../cloud-task/service"; -import type { GitService } from "../git/service"; -import { HandoffSaga, type HandoffSagaDeps } from "./handoff-saga"; -import { - HandoffToCloudSaga, - type HandoffToCloudSagaDeps, -} from "./handoff-to-cloud-saga"; -import { - type HandoffErrorCode, - HandoffEvent, - type HandoffExecuteInput, - type HandoffExecuteResult, - type HandoffPreflightInput, - type HandoffPreflightResult, - type HandoffServiceEvents, - type HandoffToCloudExecuteInput, - type HandoffToCloudExecuteResult, - type HandoffToCloudPreflightInput, - type HandoffToCloudPreflightResult, -} from "./schemas"; - -const log = logger.scope("handoff"); -const CONTINUE_DIVERGENCE_BUTTON = 1; -const GITHUB_AUTHORIZATION_REQUIRED_CODE = "github_authorization_required"; -const GITHUB_AUTHORIZATION_REQUIRED_MESSAGE = - "Connect GitHub in your browser, then retry Continue in cloud."; - -export function extractHandoffErrorCode( - message: string | undefined, -): HandoffErrorCode | undefined { - if (message?.includes(GITHUB_AUTHORIZATION_REQUIRED_CODE)) { - return GITHUB_AUTHORIZATION_REQUIRED_CODE; - } - return undefined; -} - -@injectable() -export class HandoffService extends TypedEventEmitter { - constructor( - @inject(MAIN_TOKENS.GitService) private readonly gitService: GitService, - @inject(MAIN_TOKENS.AgentService) - private readonly agentService: AgentService, - @inject(MAIN_TOKENS.CloudTaskService) - private readonly cloudTaskService: CloudTaskService, - @inject(MAIN_TOKENS.AgentAuthAdapter) - private readonly agentAuthAdapter: AgentAuthAdapter, - @inject(MAIN_TOKENS.WorkspaceRepository) - private readonly workspaceRepo: IWorkspaceRepository, - @inject(MAIN_TOKENS.RepositoryRepository) - private readonly repositoryRepo: IRepositoryRepository, - @inject(MAIN_TOKENS.Dialog) - private readonly dialog: IDialog, - @inject(MAIN_TOKENS.AppLifecycle) - private readonly appLifecycle: IAppLifecycle, - ) { - super(); - } - - async preflight( - input: HandoffPreflightInput, - ): Promise { - const { repoPath } = input; - - let localTreeDirty = false; - let localGitState: AgentTypes.HandoffLocalGitState | undefined; - let changedFileDetails: HandoffPreflightResult["changedFiles"]; - try { - const changedFiles = await this.gitService.getChangedFilesHead(repoPath); - localTreeDirty = changedFiles.length > 0; - changedFileDetails = changedFiles.map((f) => ({ - path: f.path, - status: f.status, - linesAdded: f.linesAdded, - linesRemoved: f.linesRemoved, - })); - localGitState = await this.getLocalGitState(repoPath); - } catch (err) { - log.warn("Failed to check local working tree", { repoPath, err }); - } - - const canHandoff = !localTreeDirty; - const reason = localTreeDirty - ? "Local working tree has uncommitted changes. Commit or stash them first." - : undefined; - - return { - canHandoff, - reason, - localTreeDirty, - localGitState, - changedFiles: changedFileDetails, - }; - } - - async execute(input: HandoffExecuteInput): Promise { - const deps: HandoffSagaDeps = { - createApiClient: (apiHost, teamId) => - this.createApiClient(apiHost, teamId), - - applyGitCheckpoint: async ( - checkpoint: AgentTypes.GitCheckpointEvent, - repoPath: string, - taskId: string, - runId: string, - apiClient: PostHogAPIClient, - localGitState?: AgentTypes.HandoffLocalGitState, - ) => { - const tracker = new HandoffCheckpointTracker({ - repositoryPath: repoPath, - taskId, - runId, - apiClient, - }); - await tracker.applyFromHandoff(checkpoint, { - localGitState, - onDivergedBranch: (divergence) => - this.confirmDivergedBranchReset(divergence), - }); - }, - - closeCloudRun: async (taskId, runId, apiHost, teamId, localGitState) => { - const result = await this.cloudTaskService.sendCommand({ - taskId, - runId, - apiHost, - teamId, - method: "close", - params: localGitState ? { localGitState } : undefined, - }); - if (!result.success) { - log.warn("Close command failed, continuing with handoff", { - error: result.error, - }); - } - }, - - updateWorkspaceMode: (taskId, mode) => { - this.workspaceRepo.updateMode(taskId, mode); - }, - - attachWorkspaceToFolder: (taskId, repoPath) => { - const repository = this.repositoryRepo.findByPath(repoPath); - if (!repository) { - throw new Error( - `No registered folder for path '${repoPath}' — cannot attach workspace`, - ); - } - const previous = this.workspaceRepo.findByTaskId(taskId); - if (!previous) { - throw new Error(`No workspace exists for task ${taskId}`); - } - if ( - previous.mode === "local" && - previous.repositoryId === repository.id - ) { - return { revert: () => {} }; - } - this.workspaceRepo.setModeAndRepository(taskId, "local", repository.id); - return { - revert: () => { - this.workspaceRepo.setModeAndRepository( - taskId, - previous.mode, - previous.repositoryId, - ); - }, - }; - }, - - seedLocalLogs: async (runId: string, logUrl: string) => { - const response = await fetch(logUrl); - if (!response.ok) { - log.warn("Failed to fetch cloud logs for seeding", { - status: response.status, - }); - return; - } - const content = await response.text(); - if (!content?.trim()) return; - - const logDir = join(homedir(), ".posthog-code", "sessions", runId); - mkdirSync(logDir, { recursive: true }); - const marker = JSON.stringify({ type: "seed_boundary" }); - const trailingNewline = content.endsWith("\n") ? "" : "\n"; - writeFileSync( - join(logDir, "logs.ndjson"), - `${content}${trailingNewline}${marker}\n`, - ); - log.info("Seeded local logs from cloud", { - runId, - bytes: content.length, - }); - }, - - reconnectSession: async (params) => { - return this.agentService.reconnectSession(params); - }, - - killSession: async (taskRunId: string) => { - await this.agentService.cancelSession(taskRunId); - }, - - setPendingContext: (taskRunId: string, context: string) => { - this.agentService.setPendingContext(taskRunId, context); - }, - - onProgress: (step, message) => { - this.emit(HandoffEvent.Progress, { - taskId: input.taskId, - step, - message, - }); - }, - }; - - const saga = new HandoffSaga(deps, log); - const result = await saga.run(input); - - if (!result.success) { - log.error("Handoff saga failed", { - error: result.error, - failedStep: result.failedStep, - }); - deps.onProgress("failed", result.error ?? "Handoff failed"); - return { - success: false, - error: `Handoff failed at step '${result.failedStep}': ${result.error}`, - }; - } - - return { - success: true, - sessionId: result.data.sessionId, - }; - } - - async preflightToCloud( - input: HandoffToCloudPreflightInput, - ): Promise { - const { repoPath } = input; - - let localGitState: AgentTypes.HandoffLocalGitState | undefined; - try { - localGitState = await this.getLocalGitState(repoPath); - } catch (err) { - log.warn("Failed to read local git state for cloud handoff", { - repoPath, - err, - }); - } - - return { canHandoff: true, localGitState }; - } - - async executeToCloud( - input: HandoffToCloudExecuteInput, - ): Promise { - const { taskId, runId, repoPath, apiHost, teamId } = input; - const apiClient = this.createApiClient(apiHost, teamId); - - const checkpointTracker = new HandoffCheckpointTracker({ - repositoryPath: repoPath, - taskId, - runId, - apiClient, - }); - - const appendNotification = async ( - method: string, - params: Record, - ) => { - await apiClient.appendTaskRunLog(taskId, runId, [ - { - type: "notification", - timestamp: new Date().toISOString(), - notification: { jsonrpc: "2.0", method, params }, - }, - ]); - }; - - const deps: HandoffToCloudSagaDeps = { - createApiClient: () => apiClient, - - captureGitCheckpoint: async (localGitState) => { - const checkpoint = - await checkpointTracker.captureForHandoff(localGitState); - if (!checkpoint) return null; - return { ...checkpoint, device: { type: "local" as const } }; - }, - - persistCheckpointToLog: (checkpoint) => - appendNotification( - POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT, - checkpoint as unknown as Record, - ), - - countLocalLogEntries: (taskRunId) => { - const logPath = join( - homedir(), - ".posthog-code", - "sessions", - taskRunId, - "logs.ndjson", - ); - if (!existsSync(logPath)) return 0; - return readFileSync(logPath, "utf-8") - .split("\n") - .filter((l) => l.trim()).length; - }, - - resumeRunInCloud: async () => { - await apiClient.resumeRunInCloud(taskId, runId); - }, - - killSession: async (taskRunId) => { - await this.agentService.cancelSession(taskRunId); - }, - - updateWorkspaceMode: (tid, mode) => { - this.workspaceRepo.updateMode(tid, mode); - }, - - onProgress: (step, message) => { - this.emit(HandoffEvent.Progress, { taskId, step, message }); - }, - }; - - const saga = new HandoffToCloudSaga(deps, log); - const result = await saga.run(input); - - if (!result.success) { - log.error("Handoff to cloud saga failed", { - error: result.error, - failedStep: result.failedStep, - }); - deps.onProgress("failed", result.error ?? "Handoff to cloud failed"); - const code = extractHandoffErrorCode(result.error); - return { - success: false, - code, - error: - code === GITHUB_AUTHORIZATION_REQUIRED_CODE - ? GITHUB_AUTHORIZATION_REQUIRED_MESSAGE - : `Handoff to cloud failed at step '${result.failedStep}': ${result.error}`, - }; - } - - await this.cleanupLocalAfterCloudHandoff( - repoPath, - input.localGitState?.branch ?? null, - ); - - this.deleteLocalLogCache(runId); - - return { - success: true, - logEntryCount: result.data.logEntryCount, - }; - } - - private deleteLocalLogCache(runId: string): void { - const logPath = join( - homedir(), - ".posthog-code", - "sessions", - runId, - "logs.ndjson", - ); - try { - rmSync(logPath, { force: true }); - } catch (err) { - log.warn("Failed to delete local log cache after cloud handoff", { - runId, - err, - }); - } - } - - private async cleanupLocalAfterCloudHandoff( - repoPath: string, - branchName: string | null, - ): Promise { - try { - const hasChanges = - (await this.gitService.getChangedFilesHead(repoPath)).length > 0; - - if (hasChanges) { - const label = branchName ?? "unknown"; - const stashSaga = new StashPushSaga(); - const stashResult = await stashSaga.run({ - baseDir: repoPath, - message: `posthog-code: handoff backup (${label})`, - }); - if (!stashResult.success) { - log.warn("Failed to stash changes during cloud handoff cleanup", { - error: stashResult.error, - }); - return; - } - } - - const resetSaga = new ResetToDefaultBranchSaga(); - const resetResult = await resetSaga.run({ baseDir: repoPath }); - if (!resetResult.success) { - log.warn( - "Failed to reset to default branch during cloud handoff cleanup", - { - error: resetResult.error, - }, - ); - return; - } - - log.info("Local cleanup after cloud handoff complete", { - repoPath, - switched: resetResult.data.switched, - defaultBranch: resetResult.data.defaultBranch, - }); - } catch (err) { - log.warn("Post-handoff local cleanup failed", { repoPath, err }); - } - } - - private createApiClient(apiHost: string, teamId: number): PostHogAPIClient { - const config = this.agentAuthAdapter.createPosthogConfig({ - apiHost, - projectId: teamId, - }); - return new PostHogAPIClient(config); - } - - private async getLocalGitState( - repoPath: string, - ): Promise { - return readHandoffLocalGitState(repoPath); - } - - private async confirmDivergedBranchReset( - divergence: GitHandoffBranchDivergence, - ): Promise { - await this.appLifecycle.whenReady(); - - const response = await this.dialog.confirm({ - severity: "warning", - options: ["Cancel", "Continue"], - defaultIndex: 0, - cancelIndex: 0, - title: "Local branch has diverged", - message: `The local branch '${divergence.branch}' has commits that are not in the cloud handoff.`, - detail: - `Continuing will reset '${divergence.branch}' from ${divergence.localHead.slice(0, 7)} to ${divergence.cloudHead.slice(0, 7)}.\n\n` + - "Cancel if you want to keep the current local branch tip.", - }); - return response === CONTINUE_DIVERGENCE_BUTTON; - } -} diff --git a/apps/code/src/main/services/index.ts b/apps/code/src/main/services/index.ts index 95a73373e5..4c8e661536 100644 --- a/apps/code/src/main/services/index.ts +++ b/apps/code/src/main/services/index.ts @@ -3,6 +3,4 @@ * This file is auto-generated by vite-plugin-auto-services.ts */ -import "./integration-flow-schemas.js"; -import "./posthog-analytics.js"; import "./settingsStore.js"; diff --git a/apps/code/src/main/services/linear-integration/schemas.ts b/apps/code/src/main/services/linear-integration/schemas.ts deleted file mode 100644 index 6bad75f2ad..0000000000 --- a/apps/code/src/main/services/linear-integration/schemas.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - type CloudRegion, - cloudRegion, - type StartIntegrationFlowInput as StartLinearFlowInput, - type StartIntegrationFlowOutput as StartLinearFlowOutput, - startIntegrationFlowInput as startLinearFlowInput, - startIntegrationFlowOutput as startLinearFlowOutput, -} from "../integration-flow-schemas"; diff --git a/apps/code/src/main/services/mcp-callback/service.ts b/apps/code/src/main/services/mcp-callback/service.ts deleted file mode 100644 index 04c352bd8f..0000000000 --- a/apps/code/src/main/services/mcp-callback/service.ts +++ /dev/null @@ -1,294 +0,0 @@ -import * as http from "node:http"; -import type { Socket } from "node:net"; -import type { IUrlLauncher } from "@posthog/platform/url-launcher"; -import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { isDevBuild } from "../../utils/env"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import type { DeepLinkService } from "../deep-link/service"; -import { - type GetCallbackUrlOutput, - McpCallbackEvent, - type McpCallbackEvents, - type McpCallbackResult, - type OpenAndWaitOutput, -} from "./schemas"; - -const log = logger.scope("mcp-callback"); - -const MCP_CALLBACK_KEY = "mcp-oauth-complete"; -const DEV_CALLBACK_PORT = 8238; -const OAUTH_TIMEOUT_MS = 180_000; // 3 minutes - -interface PendingCallback { - resolve: (result: McpCallbackResult) => void; - reject: (error: Error) => void; - timeoutId: NodeJS.Timeout; - server?: http.Server; - connections?: Set; -} - -@injectable() -export class McpCallbackService extends TypedEventEmitter { - private pendingCallback: PendingCallback | null = null; - - constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.UrlLauncher) - private readonly urlLauncher: IUrlLauncher, - ) { - super(); - // Register deep link handler for MCP OAuth callbacks (production) - this.deepLinkService.registerHandler( - MCP_CALLBACK_KEY, - (_path, searchParams) => this.handleCallback(searchParams), - ); - log.info("Registered MCP OAuth callback handler for deep links"); - } - - /** - * Get the callback URL based on environment (dev vs prod). - */ - public getCallbackUrl(): GetCallbackUrlOutput { - const callbackUrl = isDevBuild() - ? `http://localhost:${DEV_CALLBACK_PORT}/${MCP_CALLBACK_KEY}` - : `${this.deepLinkService.getProtocol()}://${MCP_CALLBACK_KEY}`; - return { callbackUrl }; - } - - /** - * Open the OAuth authorization URL in the browser and wait for the callback. - * In dev mode, starts a local HTTP server. In production, uses deep links. - */ - public async openAndWaitForCallback( - redirectUrl: string, - ): Promise { - try { - // Cancel any existing pending callback - this.cancelPending(); - - const result = isDevBuild() - ? await this.waitForHttpCallback(redirectUrl) - : await this.waitForDeepLinkCallback(redirectUrl); - - // Emit event for any subscribers - this.emit(McpCallbackEvent.OAuthComplete, result); - - return { - success: result.status === "success", - installationId: result.installationId, - error: result.error, - }; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : "Unknown error"; - return { success: false, error: errorMsg }; - } - } - - private handleCallback(searchParams: URLSearchParams): boolean { - const status = searchParams.get("status") as "success" | "error" | null; - const installationId = searchParams.get("installation_id") ?? undefined; - const error = searchParams.get("error") ?? undefined; - - if (!this.pendingCallback) { - log.warn("Received MCP OAuth callback but no pending flow"); - return false; - } - - const { resolve, timeoutId } = this.pendingCallback; - clearTimeout(timeoutId); - this.pendingCallback = null; - - const result: McpCallbackResult = { - status: status === "success" ? "success" : "error", - installationId, - error, - }; - resolve(result); - return true; - } - - /** - * Wait for callback via deep link (production). - */ - private async waitForDeepLinkCallback( - redirectUrl: string, - ): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - this.pendingCallback = null; - reject(new Error("MCP OAuth authorization timed out")); - }, OAUTH_TIMEOUT_MS); - - this.pendingCallback = { - resolve, - reject, - timeoutId, - }; - - // Open the browser for authentication - this.urlLauncher.launch(redirectUrl).catch((error) => { - clearTimeout(timeoutId); - this.pendingCallback = null; - reject(new Error(`Failed to open browser: ${error.message}`)); - }); - }); - } - - /** - * Wait for callback via HTTP server (development). - */ - private async waitForHttpCallback( - redirectUrl: string, - ): Promise { - return new Promise((resolve, reject) => { - const connections = new Set(); - - const server = http.createServer((req, res) => { - if (!req.url) { - res.writeHead(400); - res.end(); - return; - } - - const url = new URL(req.url, `http://localhost:${DEV_CALLBACK_PORT}`); - - if (url.pathname === `/${MCP_CALLBACK_KEY}`) { - const status = url.searchParams.get("status") as - | "success" - | "error" - | null; - const installationId = - url.searchParams.get("installation_id") ?? undefined; - const error = url.searchParams.get("error") ?? undefined; - - const callbackStatus = status === "success" ? "success" : "error"; - - res.writeHead(200, { "Content-Type": "text/html" }); - res.end( - this.getCallbackHtml( - callbackStatus === "success" ? "success" : "error", - ), - ); - - this.cleanupHttpServer(); - - resolve({ - status: callbackStatus, - installationId, - error, - }); - } else { - res.writeHead(404); - res.end(); - } - }); - - server.on("connection", (conn) => { - connections.add(conn); - conn.on("close", () => connections.delete(conn)); - }); - - const timeoutId = setTimeout(() => { - this.cleanupHttpServer(); - reject(new Error("MCP OAuth authorization timed out")); - }, OAUTH_TIMEOUT_MS); - - this.pendingCallback = { - resolve, - reject, - timeoutId, - server, - connections, - }; - - server.listen(DEV_CALLBACK_PORT, () => { - log.info( - `Dev MCP OAuth callback server listening on port ${DEV_CALLBACK_PORT}`, - ); - // Open the browser for authentication - this.urlLauncher.launch(redirectUrl).catch((error) => { - this.cleanupHttpServer(); - reject(new Error(`Failed to open browser: ${error.message}`)); - }); - }); - - server.on("error", (error) => { - this.cleanupHttpServer(); - reject(new Error(`Failed to start callback server: ${error.message}`)); - }); - }); - } - - /** - * Generate HTML for the callback page (dev mode). - */ - private getCallbackHtml(status: "success" | "error"): string { - const titles = { - success: "Authorization successful!", - error: "Authorization failed", - }; - const messages = { - success: "You can close this window and return to PostHog Code.", - error: "You can close this window and return to PostHog Code.", - }; - - return ` - - - - ${titles[status]} - - - - - -

${titles[status]}

-

${messages[status]}

- - -`; - } - - /** - * Clean up HTTP server used in development. - */ - private cleanupHttpServer(): void { - if (this.pendingCallback?.server) { - if (this.pendingCallback.connections) { - for (const conn of this.pendingCallback.connections) { - conn.destroy(); - } - this.pendingCallback.connections.clear(); - } - this.pendingCallback.server.close(); - } - if (this.pendingCallback?.timeoutId) { - clearTimeout(this.pendingCallback.timeoutId); - } - this.pendingCallback = null; - } - - /** - * Cancel any pending callback. - */ - private cancelPending(): void { - if (this.pendingCallback) { - if (this.pendingCallback.server) { - this.cleanupHttpServer(); - } else { - clearTimeout(this.pendingCallback.timeoutId); - this.pendingCallback.reject(new Error("MCP OAuth flow cancelled")); - this.pendingCallback = null; - } - } - } -} diff --git a/apps/code/src/main/services/posthog-analytics.ts b/apps/code/src/main/services/posthog-analytics.ts deleted file mode 100644 index 6eb43841e3..0000000000 --- a/apps/code/src/main/services/posthog-analytics.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { PostHog } from "posthog-node"; -import { getAppVersion } from "../utils/env"; - -let posthogClient: PostHog | null = null; -let currentUserId: string | null = null; - -export function initializePostHog() { - if (posthogClient) { - return posthogClient; - } - - const apiKey = process.env.VITE_POSTHOG_API_KEY; - const apiHost = process.env.VITE_POSTHOG_API_HOST; - - if (!apiKey) { - return null; - } - - posthogClient = new PostHog(apiKey, { - host: apiHost || "https://internal-c.posthog.com", - enableExceptionAutocapture: true, - }); - - return posthogClient; -} - -export function setCurrentUserId(userId: string | null) { - currentUserId = userId; -} - -export function getCurrentUserId() { - return currentUserId; -} - -export function trackAppEvent( - eventName: string, - properties?: Record, -) { - if (!posthogClient) { - return; - } - - const distinctId = currentUserId || "anonymous-app-event"; - - posthogClient.capture({ - distinctId, - event: eventName, - properties: { - team: "posthog-code", - ...properties, - app_version: getAppVersion(), - $process_person_profile: !!currentUserId, - }, - }); -} - -export function identifyUser( - userId: string, - properties?: Record, -) { - if (!posthogClient) { - return; - } - - currentUserId = userId; - - posthogClient.identify({ - distinctId: userId, - properties, - }); -} - -export async function shutdownPostHog() { - if (posthogClient) { - await posthogClient.shutdown(); - posthogClient = null; - } -} - -export function getPostHogClient() { - return posthogClient; -} - -export function resetUser() { - currentUserId = null; -} - -export function captureException( - error: unknown, - additionalProperties?: Record, -) { - if (!posthogClient) { - return; - } - - const distinctId = currentUserId || "anonymous-app-event"; - posthogClient.captureException(error, distinctId, { - team: "posthog-code", - ...additionalProperties, - app_version: getAppVersion(), - }); -} diff --git a/apps/code/src/main/services/secure-store/service.test.ts b/apps/code/src/main/services/secure-store/service.test.ts new file mode 100644 index 0000000000..a86b9abf1e --- /dev/null +++ b/apps/code/src/main/services/secure-store/service.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { type SecureStoreBackend, SecureStoreService } from "./service"; + +function makeFakeBackend(initial: Record = {}) { + const data = new Map(Object.entries(initial)); + const backend: SecureStoreBackend = { + has: (key) => data.has(key), + get: (key) => data.get(key), + set: (key, value) => { + data.set(key, value); + }, + delete: (key) => { + data.delete(key); + }, + clear: () => { + data.clear(); + }, + }; + return { backend, data }; +} + +describe("SecureStoreService", () => { + it("round-trips a value through encryption", () => { + const { backend, data } = makeFakeBackend(); + const service = new SecureStoreService(backend); + + service.setItem("token", "secret-value"); + + // Persisted bytes are encrypted, never plaintext. + expect(data.get("token")).toBeDefined(); + expect(data.get("token")).not.toBe("secret-value"); + + expect(service.getItem("token")).toBe("secret-value"); + }); + + it("returns null for a missing key", () => { + const { backend } = makeFakeBackend(); + const service = new SecureStoreService(backend); + expect(service.getItem("nope")).toBeNull(); + }); + + it("removes a stored item", () => { + const { backend } = makeFakeBackend(); + const service = new SecureStoreService(backend); + service.setItem("k", "v"); + service.removeItem("k"); + expect(service.getItem("k")).toBeNull(); + }); + + it("clears all items", () => { + const { backend, data } = makeFakeBackend(); + const service = new SecureStoreService(backend); + service.setItem("a", "1"); + service.setItem("b", "2"); + service.clear(); + expect(data.size).toBe(0); + }); + + it("degrades to null on a backend read failure without throwing", () => { + const { backend } = makeFakeBackend(); + vi.spyOn(backend, "has").mockImplementation(() => { + throw new Error("backend down"); + }); + const service = new SecureStoreService(backend); + expect(service.getItem("k")).toBeNull(); + }); +}); diff --git a/apps/code/src/main/services/secure-store/service.ts b/apps/code/src/main/services/secure-store/service.ts new file mode 100644 index 0000000000..8bfec2beea --- /dev/null +++ b/apps/code/src/main/services/secure-store/service.ts @@ -0,0 +1,70 @@ +import { MAIN_TOKENS } from "@main/di/tokens"; +import { decrypt, encrypt } from "@main/utils/encryption"; +import { logger } from "@main/utils/logger"; +import { inject, injectable } from "inversify"; + +const log = logger.scope("secureStore"); + +/** + * Minimal persistent key/value backend the service encrypts into. The Electron + * host binds the electron-store `rendererStore` here; tests bind an in-memory + * fake. Keeps the service host-agnostic and unit-testable without Electron. + */ +export interface SecureStoreBackend { + has(key: string): boolean; + get(key: string): unknown; + set(key: string, value: string): void; + delete(key: string): void; + clear(): void; +} + +/** + * Backing service for the secure-store router: an encrypted-at-rest key/value + * store. Values are machine-key encrypted before they touch the backend so the + * persisted store never holds plaintext. All operations are best-effort and + * never throw to the caller — a storage failure logs and degrades to a null + * read / no-op write, matching the prior inline router behavior. + */ +@injectable() +export class SecureStoreService { + constructor( + @inject(MAIN_TOKENS.SecureStoreBackend) + private readonly store: SecureStoreBackend, + ) {} + + getItem(key: string): string | null { + try { + if (!this.store.has(key)) { + return null; + } + return decrypt(this.store.get(key) as string); + } catch (error) { + log.error("Failed to get item:", error); + return null; + } + } + + setItem(key: string, value: string): void { + try { + this.store.set(key, encrypt(value)); + } catch (error) { + log.error("Failed to set item:", error); + } + } + + removeItem(key: string): void { + try { + this.store.delete(key); + } catch (error) { + log.error("Failed to remove item:", error); + } + } + + clear(): void { + try { + this.store.clear(); + } catch (error) { + log.error("Failed to clear store:", error); + } + } +} diff --git a/apps/code/src/main/services/settingsStore.ts b/apps/code/src/main/services/settingsStore.ts index 2acf4a02f2..d8b659edac 100644 --- a/apps/code/src/main/services/settingsStore.ts +++ b/apps/code/src/main/services/settingsStore.ts @@ -166,3 +166,11 @@ export function getAutoSuspendAfterDays(): number { export function setAutoSuspendAfterDays(value: number): void { settingsStore.set("autoSuspendAfterDays", value); } + +export function getPreventSleepWhileRunning(): boolean { + return settingsStore.get("preventSleepWhileRunning", false); +} + +export function setPreventSleepWhileRunning(value: boolean): void { + settingsStore.set("preventSleepWhileRunning", value); +} diff --git a/apps/code/src/main/services/shell/service.test.ts b/apps/code/src/main/services/shell/service.test.ts deleted file mode 100644 index 6cafe2b3fb..0000000000 --- a/apps/code/src/main/services/shell/service.test.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ShellEvent } from "./schemas"; - -const mockPty = vi.hoisted(() => ({ - spawn: vi.fn(), -})); - -const mockExec = vi.hoisted(() => vi.fn()); -const mockExistsSync = vi.hoisted(() => vi.fn(() => true)); -const mockHomedir = vi.hoisted(() => vi.fn(() => "/home/testuser")); -const mockPlatform = vi.hoisted(() => vi.fn(() => "darwin")); - -vi.mock("node-pty", () => mockPty); - -vi.mock("node:child_process", () => ({ - exec: mockExec, - default: { exec: mockExec }, -})); - -vi.mock("node:fs", () => ({ - existsSync: mockExistsSync, - default: { existsSync: mockExistsSync }, -})); - -vi.mock("node:os", () => ({ - homedir: mockHomedir, - platform: mockPlatform, - default: { homedir: mockHomedir, platform: mockPlatform }, -})); - -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -vi.mock("../../db/repositories/repository-repository.js", () => ({ - RepositoryRepository: vi.fn(), -})); - -vi.mock("../../db/repositories/workspace-repository.js", () => ({ - WorkspaceRepository: vi.fn(), -})); - -vi.mock("../../db/repositories/worktree-repository.js", () => ({ - WorktreeRepository: vi.fn(), -})); - -vi.mock("../settingsStore.js", () => ({ - getWorktreeLocation: vi.fn(() => "/tmp/worktrees"), -})); - -vi.mock("../workspace/workspaceEnv.js", () => ({ - buildWorkspaceEnv: vi.fn(() => ({})), -})); - -vi.mock("../../utils/process-utils.js", () => ({ - killProcessTree: vi.fn(), - isProcessAlive: vi.fn(() => true), -})); - -vi.mock("../../di/tokens.js", () => ({ - MAIN_TOKENS: { - ProcessTrackingService: Symbol.for("Main.ProcessTrackingService"), - RepositoryRepository: Symbol.for("Main.RepositoryRepository"), - WorkspaceRepository: Symbol.for("Main.WorkspaceRepository"), - WorktreeRepository: Symbol.for("Main.WorktreeRepository"), - }, -})); - -import type { RepositoryRepository } from "../../db/repositories/repository-repository"; -import type { WorkspaceRepository } from "../../db/repositories/workspace-repository"; -import type { WorktreeRepository } from "../../db/repositories/worktree-repository"; -import type { ProcessTrackingService } from "../process-tracking/service"; -import { ShellService } from "./service"; - -function createMockProcessTracking(): ProcessTrackingService { - return { - register: vi.fn(), - unregister: vi.fn(), - getAll: vi.fn(() => []), - getByCategory: vi.fn(() => []), - getSnapshot: vi.fn(), - discoverChildren: vi.fn(), - isAlive: vi.fn(() => true), - kill: vi.fn(), - killByCategory: vi.fn(), - killAll: vi.fn(), - } as unknown as ProcessTrackingService; -} - -function createMockRepositoryRepo(): RepositoryRepository { - return { - findById: vi.fn(), - findByPath: vi.fn(), - findAll: vi.fn(() => []), - create: vi.fn(), - upsertByPath: vi.fn(), - updateLastAccessed: vi.fn(), - delete: vi.fn(), - } as unknown as RepositoryRepository; -} - -function createMockWorkspaceRepo(): WorkspaceRepository { - return { - findActiveByTaskId: vi.fn(() => null), - findArchivedByTaskId: vi.fn(), - findAllActive: vi.fn(() => []), - findAllArchived: vi.fn(() => []), - findAllActiveByRepositoryId: vi.fn(() => []), - createActive: vi.fn(), - archive: vi.fn(), - unarchive: vi.fn(), - deleteByTaskId: vi.fn(), - updatePinnedAt: vi.fn(), - updateLastViewedAt: vi.fn(), - } as unknown as WorkspaceRepository; -} - -function createMockWorktreeRepo(): WorktreeRepository { - return { - findById: vi.fn(), - findByWorkspaceId: vi.fn(() => null), - findByPath: vi.fn(), - findAll: vi.fn(() => []), - create: vi.fn(), - updateBranch: vi.fn(), - deleteByWorkspaceId: vi.fn(), - } as unknown as WorktreeRepository; -} - -describe("ShellService", () => { - let service: ShellService; - let mockPtyProcess: { - onData: ReturnType; - onExit: ReturnType; - write: ReturnType; - resize: ReturnType; - kill: ReturnType; - destroy: ReturnType; - process: string; - }; - - let mockProcessTracking: ProcessTrackingService; - let mockRepositoryRepo: RepositoryRepository; - let mockWorkspaceRepo: WorkspaceRepository; - let mockWorktreeRepo: WorktreeRepository; - - const createMockDisposable = () => ({ dispose: vi.fn() }); - - beforeEach(() => { - vi.clearAllMocks(); - - mockPtyProcess = { - onData: vi.fn(() => createMockDisposable()), - onExit: vi.fn(() => createMockDisposable()), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - destroy: vi.fn(), - process: "/bin/bash", - }; - - mockPty.spawn.mockReturnValue(mockPtyProcess); - mockExistsSync.mockReturnValue(true); - mockProcessTracking = createMockProcessTracking(); - mockRepositoryRepo = createMockRepositoryRepo(); - mockWorkspaceRepo = createMockWorkspaceRepo(); - mockWorktreeRepo = createMockWorktreeRepo(); - - service = new ShellService( - mockProcessTracking, - mockRepositoryRepo, - mockWorkspaceRepo, - mockWorktreeRepo, - ); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it.each([ - [ - "interactive shell session", - () => service.create("session-1", "/home/user/project"), - ], - [ - "command session", - () => - service.createCommandSession({ - sessionId: "session-1", - command: "echo hello", - cwd: "/home/user/project", - }), - ], - ])("spawns %s with UTF-8 output decoding", async (_name, createSession) => { - await createSession(); - - expect(mockPty.spawn).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - encoding: "utf8", - }), - ); - }); - - describe("create", () => { - it("creates a new shell session", async () => { - await service.create("session-1", "/home/user/project"); - - expect(mockPty.spawn).toHaveBeenCalledWith( - expect.any(String), - ["-l"], - expect.objectContaining({ - name: "xterm-256color", - cols: 80, - rows: 24, - cwd: "/home/user/project", - }), - ); - }); - - it("uses home directory when cwd not specified", async () => { - await service.create("session-1"); - - expect(mockPty.spawn).toHaveBeenCalledWith( - expect.any(String), - ["-l"], - expect.objectContaining({ - cwd: "/home/testuser", - }), - ); - }); - - it("falls back to home when cwd does not exist", async () => { - mockExistsSync.mockReturnValue(false); - - await service.create("session-1", "/nonexistent/path"); - - expect(mockPty.spawn).toHaveBeenCalledWith( - expect.any(String), - ["-l"], - expect.objectContaining({ - cwd: "/home/testuser", - }), - ); - }); - - it("does not recreate existing session", async () => { - await service.create("session-1", "/home/user"); - await service.create("session-1", "/different/path"); - - expect(mockPty.spawn).toHaveBeenCalledTimes(1); - }); - - it("emits data events from pty", async () => { - const dataHandler = vi.fn(); - service.on(ShellEvent.Data, dataHandler); - - await service.create("session-1"); - - // Get the onData callback and call it - const onDataCallback = mockPtyProcess.onData.mock.calls[0][0]; - onDataCallback("test output"); - - expect(dataHandler).toHaveBeenCalledWith({ - sessionId: "session-1", - data: "test output", - }); - }); - - it("emits exit events from pty", async () => { - const exitHandler = vi.fn(); - service.on(ShellEvent.Exit, exitHandler); - - await service.create("session-1"); - - // Get the onExit callback and call it - const onExitCallback = mockPtyProcess.onExit.mock.calls[0][0]; - onExitCallback({ exitCode: 0 }); - - expect(exitHandler).toHaveBeenCalledWith({ - sessionId: "session-1", - exitCode: 0, - }); - }); - - it("cleans up session on exit", async () => { - await service.create("session-1"); - expect(service.check("session-1")).toBe(true); - - // Simulate exit - const onExitCallback = mockPtyProcess.onExit.mock.calls[0][0]; - onExitCallback({ exitCode: 0 }); - - expect(service.check("session-1")).toBe(false); - }); - - it("sets TERM_PROGRAM environment variable", async () => { - await service.create("session-1"); - - expect(mockPty.spawn).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - env: expect.objectContaining({ - TERM_PROGRAM: "PostHog Code", - COLORTERM: "truecolor", - FORCE_COLOR: "3", - }), - }), - ); - }); - }); - - describe("write", () => { - it("writes data to session", async () => { - await service.create("session-1"); - - service.write("session-1", "ls -la\n"); - - expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n"); - }); - - it("throws error for non-existent session", () => { - expect(() => service.write("nonexistent", "data")).toThrow( - "Shell session nonexistent not found", - ); - }); - }); - - describe("resize", () => { - it("resizes session terminal", async () => { - await service.create("session-1"); - - service.resize("session-1", 120, 40); - - expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40); - }); - - it("throws error for non-existent session", () => { - expect(() => service.resize("nonexistent", 80, 24)).toThrow( - "Shell session nonexistent not found", - ); - }); - }); - - describe("check", () => { - it("returns true for existing session", async () => { - await service.create("session-1"); - - expect(service.check("session-1")).toBe(true); - }); - - it("returns false for non-existent session", () => { - expect(service.check("nonexistent")).toBe(false); - }); - }); - - describe("destroy", () => { - it("disposes listeners, destroys pty, and removes session", async () => { - await service.create("session-1"); - - service.destroy("session-1"); - - expect(mockPtyProcess.destroy).toHaveBeenCalled(); - expect(service.check("session-1")).toBe(false); - }); - - it("does nothing for non-existent session", () => { - expect(() => service.destroy("nonexistent")).not.toThrow(); - }); - }); - - describe("getProcess", () => { - it("returns process name for existing session", async () => { - await service.create("session-1"); - - expect(service.getProcess("session-1")).toBe("/bin/bash"); - }); - - it("returns null for non-existent session", () => { - expect(service.getProcess("nonexistent")).toBeNull(); - }); - }); - - describe("execute", () => { - it("executes command and returns output", async () => { - mockExec.mockImplementation((_cmd, _opts, callback) => { - callback(null, "command output", ""); - }); - - const result = await service.execute("/home/user", "echo hello"); - - expect(result).toEqual({ - stdout: "command output", - stderr: "", - exitCode: 0, - }); - }); - - it("returns stderr on command errors", async () => { - mockExec.mockImplementation((_cmd, _opts, callback) => { - callback({ code: 1 }, "", "error message"); - }); - - const result = await service.execute("/home/user", "bad-command"); - - expect(result).toEqual({ - stdout: "", - stderr: "error message", - exitCode: 1, - }); - }); - - it("handles command timeout", async () => { - mockExec.mockImplementation((_cmd, opts, callback) => { - // Verify timeout is set - expect(opts.timeout).toBe(60000); - callback(null, "output", ""); - }); - - await service.execute("/home/user", "slow-command"); - - expect(mockExec).toHaveBeenCalledWith( - "slow-command", - expect.objectContaining({ - cwd: "/home/user", - timeout: 60000, - }), - expect.any(Function), - ); - }); - - it("returns empty strings when stdout/stderr are undefined", async () => { - mockExec.mockImplementation((_cmd, _opts, callback) => { - callback(null, undefined, undefined); - }); - - const result = await service.execute("/home/user", "silent-command"); - - expect(result).toEqual({ - stdout: "", - stderr: "", - exitCode: 0, - }); - }); - }); - - describe("platform-specific behavior", () => { - it("uses SHELL env on Unix", async () => { - const originalShell = process.env.SHELL; - process.env.SHELL = "/bin/zsh"; - mockPlatform.mockReturnValue("darwin"); - - await service.create("session-1"); - - expect(mockPty.spawn).toHaveBeenCalledWith( - "/bin/zsh", - expect.any(Array), - expect.any(Object), - ); - - process.env.SHELL = originalShell; - }); - - it("falls back to /bin/bash when SHELL not set", async () => { - const originalShell = process.env.SHELL; - delete process.env.SHELL; - mockPlatform.mockReturnValue("darwin"); - - const newService = new ShellService( - mockProcessTracking, - mockRepositoryRepo, - mockWorkspaceRepo, - mockWorktreeRepo, - ); - await newService.create("session-1"); - - expect(mockPty.spawn).toHaveBeenCalledWith( - "/bin/bash", - expect.any(Array), - expect.any(Object), - ); - - process.env.SHELL = originalShell; - }); - }); - - describe("multiple sessions", () => { - it("manages multiple independent sessions", async () => { - const mockPty1 = { ...mockPtyProcess, process: "bash-1" }; - const mockPty2 = { ...mockPtyProcess, process: "bash-2" }; - - mockPty.spawn.mockReturnValueOnce(mockPty1).mockReturnValueOnce(mockPty2); - - await service.create("session-1", "/path/1"); - await service.create("session-2", "/path/2"); - - expect(service.check("session-1")).toBe(true); - expect(service.check("session-2")).toBe(true); - expect(service.getProcess("session-1")).toBe("bash-1"); - expect(service.getProcess("session-2")).toBe("bash-2"); - }); - - it("destroys sessions independently", async () => { - mockPty.spawn.mockReturnValue({ ...mockPtyProcess }); - - await service.create("session-1"); - await service.create("session-2"); - - service.destroy("session-1"); - - expect(service.check("session-1")).toBe(false); - expect(service.check("session-2")).toBe(true); - }); - }); -}); diff --git a/apps/code/src/main/services/slack-integration/schemas.ts b/apps/code/src/main/services/slack-integration/schemas.ts deleted file mode 100644 index 06d0b9b5fc..0000000000 --- a/apps/code/src/main/services/slack-integration/schemas.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - type CloudRegion, - cloudRegion, - type StartIntegrationFlowInput as StartSlackFlowInput, - type StartIntegrationFlowOutput as StartSlackFlowOutput, - startIntegrationFlowInput as startSlackFlowInput, - startIntegrationFlowOutput as startSlackFlowOutput, -} from "../integration-flow-schemas"; diff --git a/apps/code/src/main/services/usage-monitor/store.ts b/apps/code/src/main/services/usage-monitor/store.ts deleted file mode 100644 index 95cc9a486b..0000000000 --- a/apps/code/src/main/services/usage-monitor/store.ts +++ /dev/null @@ -1,17 +0,0 @@ -import Store from "electron-store"; -import { getUserDataDir } from "../../utils/env"; - -interface UsageMonitorSchema { - // Map of dedupe-keys ⇒ ISO timestamp anchor at which the threshold was - // first fired. Stored so we don't re-toast after relaunch within the same - // billing window. Anchored entries with a past anchor are pruned on boot. - thresholdsSeen: Record; -} - -export const usageMonitorStore = new Store({ - name: "usage-monitor", - cwd: getUserDataDir(), - defaults: { - thresholdsSeen: {}, - }, -}); diff --git a/apps/code/src/main/services/workspace-server/service.ts b/apps/code/src/main/services/workspace-server/service.ts index 118feb6def..ecbd7e402a 100644 --- a/apps/code/src/main/services/workspace-server/service.ts +++ b/apps/code/src/main/services/workspace-server/service.ts @@ -2,10 +2,10 @@ import { type ChildProcess, spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { createServer } from "node:net"; import path from "node:path"; +import { TypedEventEmitter } from "@posthog/shared"; import type { WorkspaceConnection } from "@posthog/workspace-client/client"; import { injectable } from "inversify"; import { logger } from "../../utils/logger.js"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter.js"; const HEALTH_POLL_INTERVAL_MS = 100; const HEALTH_POLL_TIMEOUT_MS = 5_000; diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 06bca0027d..ee14eb33ba 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -1,43 +1,43 @@ -import { additionalDirectoriesRouter } from "./routers/additional-directories"; -import { agentRouter } from "./routers/agent"; -import { analyticsRouter } from "./routers/analytics"; -import { archiveRouter } from "./routers/archive"; -import { authRouter } from "./routers/auth"; -import { cloudTaskRouter } from "./routers/cloud-task"; -import { connectivityRouter } from "./routers/connectivity"; -import { contextMenuRouter } from "./routers/context-menu"; -import { deepLinkRouter } from "./routers/deep-link"; +import { additionalDirectoriesRouter } from "@posthog/host-router/routers/additional-directories.router"; +import { agentRouter } from "@posthog/host-router/routers/agent.router"; +import { analyticsRouter } from "@posthog/host-router/routers/analytics.router"; +import { archiveRouter } from "@posthog/host-router/routers/archive.router"; +import { authRouter } from "@posthog/host-router/routers/auth.router"; +import { cloudTaskRouter } from "@posthog/host-router/routers/cloud-task.router"; +import { connectivityRouter } from "@posthog/host-router/routers/connectivity.router"; +import { contextMenuRouter } from "@posthog/host-router/routers/context-menu.router"; +import { deepLinkRouter } from "@posthog/host-router/routers/deep-link.router"; +import { enrichmentRouter } from "@posthog/host-router/routers/enrichment.router"; +import { environmentRouter } from "@posthog/host-router/routers/environment.router"; +import { externalAppsRouter } from "@posthog/host-router/routers/external-apps.router"; +import { fileWatcherRouter } from "@posthog/host-router/routers/file-watcher.router"; +import { focusRouter } from "@posthog/host-router/routers/focus.router"; +import { foldersRouter } from "@posthog/host-router/routers/folders.router"; +import { fsRouter } from "@posthog/host-router/routers/fs.router"; +import { gitRouter } from "@posthog/host-router/routers/git.router"; +import { githubIntegrationRouter } from "@posthog/host-router/routers/github-integration.router"; +import { handoffRouter } from "@posthog/host-router/routers/handoff.router"; +import { linearIntegrationRouter } from "@posthog/host-router/routers/linear-integration.router"; +import { llmGatewayRouter } from "@posthog/host-router/routers/llm-gateway.router"; +import { logsRouter } from "@posthog/host-router/routers/logs.router"; +import { mcpAppsRouter } from "@posthog/host-router/routers/mcp-apps.router"; +import { mcpCallbackRouter } from "@posthog/host-router/routers/mcp-callback.router"; +import { notificationRouter } from "@posthog/host-router/routers/notification.router"; +import { oauthRouter } from "@posthog/host-router/routers/oauth.router"; +import { osRouter } from "@posthog/host-router/routers/os.router"; +import { processTrackingRouter } from "@posthog/host-router/routers/process-tracking.router"; +import { provisioningRouter } from "@posthog/host-router/routers/provisioning.router"; +import { secureStoreRouter } from "@posthog/host-router/routers/secure-store.router"; +import { shellRouter } from "@posthog/host-router/routers/shell.router"; +import { skillsRouter } from "@posthog/host-router/routers/skills.router"; +import { slackIntegrationRouter } from "@posthog/host-router/routers/slack-integration.router"; +import { sleepRouter } from "@posthog/host-router/routers/sleep.router"; +import { suspensionRouter } from "@posthog/host-router/routers/suspension.router"; +import { uiRouter } from "@posthog/host-router/routers/ui.router"; +import { updatesRouter } from "@posthog/host-router/routers/updates.router"; +import { usageMonitorRouter } from "@posthog/host-router/routers/usage-monitor.router"; +import { workspaceRouter } from "@posthog/host-router/routers/workspace.router"; import { encryptionRouter } from "./routers/encryption"; -import { enrichmentRouter } from "./routers/enrichment"; -import { environmentRouter } from "./routers/environment"; -import { externalAppsRouter } from "./routers/external-apps"; -import { fileWatcherRouter } from "./routers/file-watcher"; -import { focusRouter } from "./routers/focus"; -import { foldersRouter } from "./routers/folders"; -import { fsRouter } from "./routers/fs"; -import { gitRouter } from "./routers/git"; -import { githubIntegrationRouter } from "./routers/github-integration"; -import { handoffRouter } from "./routers/handoff"; -import { linearIntegrationRouter } from "./routers/linear-integration.js"; -import { llmGatewayRouter } from "./routers/llm-gateway"; -import { logsRouter } from "./routers/logs"; -import { mcpAppsRouter } from "./routers/mcp-apps"; -import { mcpCallbackRouter } from "./routers/mcp-callback"; -import { notificationRouter } from "./routers/notification"; -import { oauthRouter } from "./routers/oauth"; -import { osRouter } from "./routers/os"; -import { processTrackingRouter } from "./routers/process-tracking"; -import { provisioningRouter } from "./routers/provisioning"; -import { secureStoreRouter } from "./routers/secure-store"; -import { shellRouter } from "./routers/shell"; -import { skillsRouter } from "./routers/skills"; -import { slackIntegrationRouter } from "./routers/slack-integration"; -import { sleepRouter } from "./routers/sleep"; -import { suspensionRouter } from "./routers/suspension.js"; -import { uiRouter } from "./routers/ui"; -import { updatesRouter } from "./routers/updates"; -import { usageMonitorRouter } from "./routers/usage-monitor"; -import { workspaceRouter } from "./routers/workspace"; import { workspaceServerRouter } from "./routers/workspace-server"; import { router } from "./trpc"; @@ -50,7 +50,6 @@ export const trpcRouter = router({ cloudTask: cloudTaskRouter, connectivity: connectivityRouter, contextMenu: contextMenuRouter, - enrichment: enrichmentRouter, environment: environmentRouter, encryption: encryptionRouter, diff --git a/apps/code/src/main/trpc/routers/additional-directories.ts b/apps/code/src/main/trpc/routers/additional-directories.ts deleted file mode 100644 index 3e202c0902..0000000000 --- a/apps/code/src/main/trpc/routers/additional-directories.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { z } from "zod"; -import type { IDefaultAdditionalDirectoryRepository } from "../../db/repositories/default-additional-directory-repository"; -import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { publicProcedure, router } from "../trpc"; - -const getDefaults = () => - container.get( - MAIN_TOKENS.DefaultAdditionalDirectoryRepository, - ); - -const getWorkspaces = () => - container.get(MAIN_TOKENS.WorkspaceRepository); - -const pathInput = z.object({ path: z.string().min(1) }); -const taskPathInput = z.object({ - taskId: z.string(), - path: z.string().min(1), -}); -const ok = { ok: true as const }; - -export const additionalDirectoriesRouter = router({ - listDefaults: publicProcedure - .output(z.array(z.string())) - .query(() => getDefaults().list()), - - listForTask: publicProcedure - .input(z.object({ taskId: z.string() })) - .output(z.array(z.string())) - .query(({ input }) => - getWorkspaces().getAdditionalDirectories(input.taskId), - ), - - addDefault: publicProcedure.input(pathInput).mutation(({ input }) => { - getDefaults().add(input.path); - return ok; - }), - - removeDefault: publicProcedure.input(pathInput).mutation(({ input }) => { - getDefaults().remove(input.path); - return ok; - }), - - addForTask: publicProcedure.input(taskPathInput).mutation(({ input }) => { - getWorkspaces().addAdditionalDirectory(input.taskId, input.path); - return ok; - }), - - removeForTask: publicProcedure.input(taskPathInput).mutation(({ input }) => { - getWorkspaces().removeAdditionalDirectory(input.taskId, input.path); - return ok; - }), -}); diff --git a/apps/code/src/main/trpc/routers/agent.ts b/apps/code/src/main/trpc/routers/agent.ts deleted file mode 100644 index 98c20a8ce6..0000000000 --- a/apps/code/src/main/trpc/routers/agent.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - AgentServiceEvent, - cancelPermissionInput, - cancelPromptInput, - cancelSessionInput, - getGatewayModelsInput, - getGatewayModelsOutput, - getPreviewConfigOptionsInput, - getPreviewConfigOptionsOutput, - listSessionsInput, - listSessionsOutput, - notifySessionContextInput, - promptInput, - promptOutput, - reconnectSessionInput, - recordActivityInput, - respondToPermissionInput, - sessionResponseSchema, - setConfigOptionInput, - startSessionInput, - subscribeSessionInput, -} from "../../services/agent/schemas"; -import type { AgentService } from "../../services/agent/service"; -import type { ProcessTrackingService } from "../../services/process-tracking/service"; -import type { ShellService } from "../../services/shell/service"; -import type { SleepService } from "../../services/sleep/service"; -import { logger } from "../../utils/logger"; -import { publicProcedure, router } from "../trpc"; - -const log = logger.scope("agent-router"); - -const getService = () => container.get(MAIN_TOKENS.AgentService); - -export const agentRouter = router({ - start: publicProcedure - .input(startSessionInput) - .output(sessionResponseSchema) - .mutation(({ input }) => getService().startSession(input)), - - prompt: publicProcedure - .input(promptInput) - .output(promptOutput) - .mutation(({ input }) => - getService().prompt(input.sessionId, input.prompt as ContentBlock[]), - ), - - cancel: publicProcedure - .input(cancelSessionInput) - .mutation(({ input }) => getService().cancelSession(input.sessionId)), - - cancelPrompt: publicProcedure - .input(cancelPromptInput) - .mutation(({ input }) => - getService().cancelPrompt(input.sessionId, input.reason), - ), - - reconnect: publicProcedure - .input(reconnectSessionInput) - .output(sessionResponseSchema.nullable()) - .mutation(({ input }) => getService().reconnectSession(input)), - - setConfigOption: publicProcedure - .input(setConfigOptionInput) - .mutation(({ input }) => - getService().setSessionConfigOption( - input.sessionId, - input.configId, - input.value, - ), - ), - - onSessionEvent: publicProcedure - .input(subscribeSessionInput) - .subscription(async function* (opts) { - const service = getService(); - const targetTaskRunId = opts.input.taskRunId; - const iterable = service.toIterable(AgentServiceEvent.SessionEvent, { - signal: opts.signal, - }); - - for await (const event of iterable) { - if (event.taskRunId === targetTaskRunId) { - yield event.payload; - } - } - }), - - // Permission request subscription - yields when tools need user input - onPermissionRequest: publicProcedure - .input(subscribeSessionInput) - .subscription(async function* (opts) { - const service = getService(); - const targetTaskRunId = opts.input.taskRunId; - const iterable = service.toIterable(AgentServiceEvent.PermissionRequest, { - signal: opts.signal, - }); - - for await (const event of iterable) { - if (event.taskRunId === targetTaskRunId) { - yield event; - } - } - }), - - // Respond to a permission request from the UI - respondToPermission: publicProcedure - .input(respondToPermissionInput) - .mutation(({ input }) => - getService().respondToPermission( - input.taskRunId, - input.toolCallId, - input.optionId, - input.customInput, - input.answers, - ), - ), - - // Cancel a permission request (e.g., user pressed Escape) - cancelPermission: publicProcedure - .input(cancelPermissionInput) - .mutation(({ input }) => - getService().cancelPermission(input.taskRunId, input.toolCallId), - ), - - listSessions: publicProcedure - .input(listSessionsInput) - .output(listSessionsOutput) - .query(({ input }) => - getService() - .listSessions(input.taskId) - .map((s) => ({ taskRunId: s.taskRunId, repoPath: s.repoPath })), - ), - - notifySessionContext: publicProcedure - .input(notifySessionContextInput) - .mutation(({ input }) => - getService().notifySessionContext(input.sessionId, input.context), - ), - - hasActiveSessions: publicProcedure.query(() => - getService().hasActiveSessions(), - ), - - onSessionsIdle: publicProcedure.subscription(async function* (opts) { - const service = getService(); - for await (const _ of service.toIterable(AgentServiceEvent.SessionsIdle, { - signal: opts.signal, - })) { - yield true; - } - }), - - resetAll: publicProcedure.mutation(async () => { - log.info("Resetting all sessions (logout/project switch)"); - - // Clean up all agent sessions (flushes logs, stops agents, releases sleep blockers) - const agentService = getService(); - await agentService.cleanupAll(); - - // Destroy all shell PTY sessions - const shellService = container.get(MAIN_TOKENS.ShellService); - shellService.destroyAll(); - - // Kill any remaining tracked processes (belt and suspenders) - const processTracking = container.get( - MAIN_TOKENS.ProcessTrackingService, - ); - processTracking.killAll(); - - // Release any lingering sleep blockers - const sleepService = container.get(MAIN_TOKENS.SleepService); - sleepService.cleanup(); - - log.info("All sessions reset successfully"); - }), - - recordActivity: publicProcedure - .input(recordActivityInput) - .mutation(({ input }) => getService().recordActivity(input.taskRunId)), - - onSessionIdleKilled: publicProcedure.subscription(async function* (opts) { - const service = getService(); - for await (const event of service.toIterable( - AgentServiceEvent.SessionIdleKilled, - { signal: opts.signal }, - )) { - yield event; - } - }), - - onAgentFileActivity: publicProcedure.subscription(async function* (opts) { - const service = getService(); - for await (const event of service.toIterable( - AgentServiceEvent.AgentFileActivity, - { signal: opts.signal }, - )) { - yield event; - } - }), - - getGatewayModels: publicProcedure - .input(getGatewayModelsInput) - .output(getGatewayModelsOutput) - .query(({ input }) => getService().getGatewayModels(input.apiHost)), - - getPreviewConfigOptions: publicProcedure - .input(getPreviewConfigOptionsInput) - .output(getPreviewConfigOptionsOutput) - .query(({ input }) => - getService().getPreviewConfigOptions(input.apiHost, input.adapter), - ), -}); diff --git a/apps/code/src/main/trpc/routers/analytics.ts b/apps/code/src/main/trpc/routers/analytics.ts deleted file mode 100644 index c34744f64b..0000000000 --- a/apps/code/src/main/trpc/routers/analytics.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from "zod"; -import { - identifyUser, - resetUser, - setCurrentUserId, -} from "../../services/posthog-analytics"; -import { publicProcedure, router } from "../trpc"; - -export const analyticsRouter = router({ - /** - * Set the current user ID for main process analytics - */ - setUserId: publicProcedure - .input( - z.object({ - userId: z.string(), - properties: z - .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) - .optional(), - }), - ) - .mutation(({ input }) => { - setCurrentUserId(input.userId); - if (input.properties) { - identifyUser( - input.userId, - input.properties as Record, - ); - } - }), - - /** - * Reset the current user (on logout) - */ - resetUser: publicProcedure.mutation(() => { - resetUser(); - }), -}); diff --git a/apps/code/src/main/trpc/routers/archive.ts b/apps/code/src/main/trpc/routers/archive.ts deleted file mode 100644 index 5222890365..0000000000 --- a/apps/code/src/main/trpc/routers/archive.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - archivedTaskIdsOutput, - archiveTaskInput, - archiveTaskOutput, - deleteArchivedTaskInput, - deleteArchivedTaskOutput, - listArchivedTasksOutput, - unarchiveTaskInput, - unarchiveTaskOutput, -} from "../../services/archive/schemas"; -import type { ArchiveService } from "../../services/archive/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.ArchiveService); - -export const archiveRouter = router({ - archive: publicProcedure - .input(archiveTaskInput) - .output(archiveTaskOutput) - .mutation(({ input }) => getService().archiveTask(input)), - - unarchive: publicProcedure - .input(unarchiveTaskInput) - .output(unarchiveTaskOutput) - .mutation(({ input }) => - getService().unarchiveTask(input.taskId, input.recreateBranch), - ), - - list: publicProcedure - .output(listArchivedTasksOutput) - .query(() => getService().getArchivedTasks()), - - archivedTaskIds: publicProcedure - .output(archivedTaskIdsOutput) - .query(() => getService().getArchivedTaskIds()), - - delete: publicProcedure - .input(deleteArchivedTaskInput) - .output(deleteArchivedTaskOutput) - .mutation(({ input }) => getService().deleteArchivedTask(input.taskId)), -}); diff --git a/apps/code/src/main/trpc/routers/auth.ts b/apps/code/src/main/trpc/routers/auth.ts deleted file mode 100644 index 161d071145..0000000000 --- a/apps/code/src/main/trpc/routers/auth.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - AuthServiceEvent, - authStateSchema, - loginInput, - loginOutput, - redeemInviteCodeInput, - selectProjectInput, - validAccessTokenOutput, -} from "../../services/auth/schemas"; -import type { AuthService } from "../../services/auth/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => container.get(MAIN_TOKENS.AuthService); - -export const authRouter = router({ - getState: publicProcedure.output(authStateSchema).query(() => { - return getService().getState(); - }), - - onStateChanged: publicProcedure.subscription(async function* (opts) { - const service = getService(); - const iterable = service.toIterable(AuthServiceEvent.StateChanged, { - signal: opts.signal, - }); - for await (const state of iterable) { - yield state; - } - }), - - login: publicProcedure - .input(loginInput) - .output(loginOutput) - .mutation(async ({ input }) => ({ - state: await getService().login(input.region), - })), - - signup: publicProcedure - .input(loginInput) - .output(loginOutput) - .mutation(async ({ input }) => ({ - state: await getService().signup(input.region), - })), - - getValidAccessToken: publicProcedure - .output(validAccessTokenOutput) - .query(async () => getService().getValidAccessToken()), - - refreshAccessToken: publicProcedure - .output(validAccessTokenOutput) - .mutation(async () => getService().refreshAccessToken()), - - selectProject: publicProcedure - .input(selectProjectInput) - .output(authStateSchema) - .mutation(async ({ input }) => getService().selectProject(input.projectId)), - - redeemInviteCode: publicProcedure - .input(redeemInviteCodeInput) - .output(authStateSchema) - .mutation(async ({ input }) => getService().redeemInviteCode(input.code)), - - logout: publicProcedure.output(authStateSchema).mutation(async () => { - return getService().logout(); - }), -}); diff --git a/apps/code/src/main/trpc/routers/cloud-task.ts b/apps/code/src/main/trpc/routers/cloud-task.ts deleted file mode 100644 index b5d1b4fcaa..0000000000 --- a/apps/code/src/main/trpc/routers/cloud-task.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - CloudTaskEvent, - onUpdateInput, - retryInput, - sendCommandInput, - sendCommandOutput, - unwatchInput, - watchInput, -} from "../../services/cloud-task/schemas"; -import type { CloudTaskService } from "../../services/cloud-task/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.CloudTaskService); - -export const cloudTaskRouter = router({ - watch: publicProcedure - .input(watchInput) - .mutation(({ input }) => getService().watch(input)), - - unwatch: publicProcedure - .input(unwatchInput) - .mutation(({ input }) => getService().unwatch(input.taskId, input.runId)), - - retry: publicProcedure - .input(retryInput) - .mutation(({ input }) => getService().retry(input.taskId, input.runId)), - - sendCommand: publicProcedure - .input(sendCommandInput) - .output(sendCommandOutput) - .mutation(({ input }) => getService().sendCommand(input)), - - onUpdate: publicProcedure - .input(onUpdateInput) - .subscription(async function* (opts) { - const service = getService(); - try { - for await (const data of service.toIterable(CloudTaskEvent.Update, { - signal: opts.signal, - })) { - if ( - data.taskId === opts.input.taskId && - data.runId === opts.input.runId - ) { - yield data; - } - } - } finally { - service.unwatch(opts.input.taskId, opts.input.runId); - } - }), -}); diff --git a/apps/code/src/main/trpc/routers/connectivity.ts b/apps/code/src/main/trpc/routers/connectivity.ts deleted file mode 100644 index c2e7063b32..0000000000 --- a/apps/code/src/main/trpc/routers/connectivity.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - ConnectivityEvent, - type ConnectivityEvents, - connectivityStatusOutput, -} from "../../services/connectivity/schemas"; -import type { ConnectivityService } from "../../services/connectivity/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.ConnectivityService); - -function subscribe(event: K) { - return publicProcedure.subscription(async function* (opts) { - const service = getService(); - const iterable = service.toIterable(event, { signal: opts.signal }); - for await (const data of iterable) { - yield data; - } - }); -} - -export const connectivityRouter = router({ - getStatus: publicProcedure.output(connectivityStatusOutput).query(() => { - const service = getService(); - return service.getStatus(); - }), - - checkNow: publicProcedure - .output(connectivityStatusOutput) - .mutation(async () => { - const service = getService(); - return service.checkNow(); - }), - - onStatusChange: subscribe(ConnectivityEvent.StatusChange), -}); diff --git a/apps/code/src/main/trpc/routers/context-menu.ts b/apps/code/src/main/trpc/routers/context-menu.ts deleted file mode 100644 index a394fcde38..0000000000 --- a/apps/code/src/main/trpc/routers/context-menu.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - archivedTaskContextMenuInput, - archivedTaskContextMenuOutput, - bulkTaskContextMenuInput, - bulkTaskContextMenuOutput, - confirmDeleteArchivedTaskInput, - confirmDeleteArchivedTaskOutput, - confirmDeleteTaskInput, - confirmDeleteTaskOutput, - confirmDeleteWorktreeInput, - confirmDeleteWorktreeOutput, - fileContextMenuInput, - fileContextMenuOutput, - folderContextMenuInput, - folderContextMenuOutput, - splitContextMenuOutput, - tabContextMenuInput, - tabContextMenuOutput, - taskContextMenuInput, - taskContextMenuOutput, -} from "../../services/context-menu/schemas"; -import type { ContextMenuService } from "../../services/context-menu/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.ContextMenuService); - -export const contextMenuRouter = router({ - confirmDeleteTask: publicProcedure - .input(confirmDeleteTaskInput) - .output(confirmDeleteTaskOutput) - .mutation(({ input }) => getService().confirmDeleteTask(input)), - - confirmDeleteArchivedTask: publicProcedure - .input(confirmDeleteArchivedTaskInput) - .output(confirmDeleteArchivedTaskOutput) - .mutation(({ input }) => getService().confirmDeleteArchivedTask(input)), - - confirmDeleteWorktree: publicProcedure - .input(confirmDeleteWorktreeInput) - .output(confirmDeleteWorktreeOutput) - .mutation(({ input }) => getService().confirmDeleteWorktree(input)), - - showTaskContextMenu: publicProcedure - .input(taskContextMenuInput) - .output(taskContextMenuOutput) - .mutation(({ input }) => getService().showTaskContextMenu(input)), - - showBulkTaskContextMenu: publicProcedure - .input(bulkTaskContextMenuInput) - .output(bulkTaskContextMenuOutput) - .mutation(({ input }) => getService().showBulkTaskContextMenu(input)), - - showArchivedTaskContextMenu: publicProcedure - .input(archivedTaskContextMenuInput) - .output(archivedTaskContextMenuOutput) - .mutation(({ input }) => getService().showArchivedTaskContextMenu(input)), - - showFolderContextMenu: publicProcedure - .input(folderContextMenuInput) - .output(folderContextMenuOutput) - .mutation(({ input }) => getService().showFolderContextMenu(input)), - - showTabContextMenu: publicProcedure - .input(tabContextMenuInput) - .output(tabContextMenuOutput) - .mutation(({ input }) => getService().showTabContextMenu(input)), - - showSplitContextMenu: publicProcedure - .output(splitContextMenuOutput) - .mutation(() => getService().showSplitContextMenu()), - - showFileContextMenu: publicProcedure - .input(fileContextMenuInput) - .output(fileContextMenuOutput) - .mutation(({ input }) => getService().showFileContextMenu(input)), -}); diff --git a/apps/code/src/main/trpc/routers/deep-link.ts b/apps/code/src/main/trpc/routers/deep-link.ts deleted file mode 100644 index 76300704bf..0000000000 --- a/apps/code/src/main/trpc/routers/deep-link.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - InboxLinkEvent, - type InboxLinkService, - type PendingInboxDeepLink, -} from "../../services/inbox-link/service"; -import { - NewTaskLinkEvent, - type NewTaskLinkPayload, - type NewTaskLinkService, -} from "../../services/new-task-link/service"; -import { - type PendingDeepLink, - TaskLinkEvent, - type TaskLinkService, -} from "../../services/task-link/service"; -import { publicProcedure, router } from "../trpc"; - -const getTaskLinkService = () => - container.get(MAIN_TOKENS.TaskLinkService); - -const getInboxLinkService = () => - container.get(MAIN_TOKENS.InboxLinkService); - -const getNewTaskLinkService = () => - container.get(MAIN_TOKENS.NewTaskLinkService); - -export const deepLinkRouter = router({ - /** - * Subscribe to task link deep link events. - * Emits task ID (and optional task run ID) when posthog-code://task/{taskId} or - * posthog-code://task/{taskId}/run/{taskRunId} is opened. - */ - onOpenTask: publicProcedure.subscription(async function* (opts) { - const service = getTaskLinkService(); - const iterable = service.toIterable(TaskLinkEvent.OpenTask, { - signal: opts.signal, - }); - for await (const data of iterable) { - yield data; - } - }), - - /** - * Get any pending deep link that arrived before renderer was ready. - * This handles the case where the app is launched via deep link. - */ - getPendingDeepLink: publicProcedure.query((): PendingDeepLink | null => { - const service = getTaskLinkService(); - return service.consumePendingDeepLink(); - }), - - /** - * Subscribe to inbox report deep link events. - * Emits report ID when posthog-code://inbox/{reportId} is opened. - */ - onOpenReport: publicProcedure.subscription(async function* (opts) { - const service = getInboxLinkService(); - const iterable = service.toIterable(InboxLinkEvent.OpenReport, { - signal: opts.signal, - }); - for await (const data of iterable) { - yield data; - } - }), - - /** - * Get any pending inbox deep link that arrived before renderer was ready. - */ - getPendingReportLink: publicProcedure.query( - (): PendingInboxDeepLink | null => { - const service = getInboxLinkService(); - return service.consumePendingDeepLink(); - }, - ), - - /** - * Subscribe to new task deep link events (new, plan, issue). - * Emits a discriminated union payload when posthog-code://new/..., - * posthog-code://plan/..., or posthog-code://issue/... is opened. - */ - onNewTaskAction: publicProcedure.subscription(async function* (opts) { - const service = getNewTaskLinkService(); - const iterable = service.toIterable(NewTaskLinkEvent.Action, { - signal: opts.signal, - }); - for await (const data of iterable) { - yield data; - } - }), - - /** - * Get any pending new task deep link that arrived before renderer was ready. - */ - getPendingNewTaskLink: publicProcedure.query( - (): NewTaskLinkPayload | null => { - const service = getNewTaskLinkService(); - return service.consumePendingLink(); - }, - ), -}); diff --git a/apps/code/src/main/trpc/routers/encryption.ts b/apps/code/src/main/trpc/routers/encryption.ts index 6b91b1a170..9f423e170e 100644 --- a/apps/code/src/main/trpc/routers/encryption.ts +++ b/apps/code/src/main/trpc/routers/encryption.ts @@ -1,55 +1,18 @@ -import type { ISecureStorage } from "@posthog/platform/secure-storage"; +import { container } from "@main/di/container"; +import { MAIN_TOKENS } from "@main/di/tokens"; +import type { EncryptionService } from "@main/services/encryption/service"; import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; import { publicProcedure, router } from "../trpc"; -const log = logger.scope("encryptionRouter"); - -const getSecureStorage = () => - container.get(MAIN_TOKENS.SecureStorage); +const getService = () => + container.get(MAIN_TOKENS.EncryptionService); export const encryptionRouter = router({ - /** - * Encrypt a string - */ encrypt: publicProcedure .input(z.object({ stringToEncrypt: z.string() })) - .query(async ({ input }) => { - try { - const secureStorage = getSecureStorage(); - if (secureStorage.isAvailable()) { - const encrypted = await secureStorage.encryptString( - input.stringToEncrypt, - ); - return Buffer.from(encrypted).toString("base64"); - } - return input.stringToEncrypt; - } catch (error) { - log.error("Failed to encrypt string:", error); - return null; - } - }), + .query(({ input }) => getService().encrypt(input.stringToEncrypt)), - /** - * Decrypt a string - */ decrypt: publicProcedure .input(z.object({ stringToDecrypt: z.string() })) - .query(async ({ input }) => { - try { - const secureStorage = getSecureStorage(); - if (secureStorage.isAvailable()) { - const bytes = new Uint8Array( - Buffer.from(input.stringToDecrypt, "base64"), - ); - return await secureStorage.decryptString(bytes); - } - return input.stringToDecrypt; - } catch (error) { - log.error("Failed to decrypt string:", error); - return null; - } - }), + .query(({ input }) => getService().decrypt(input.stringToDecrypt)), }); diff --git a/apps/code/src/main/trpc/routers/environment.ts b/apps/code/src/main/trpc/routers/environment.ts deleted file mode 100644 index 22c6770648..0000000000 --- a/apps/code/src/main/trpc/routers/environment.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - createEnvironmentInput, - deleteEnvironmentInput, - environmentSchema, - getEnvironmentInput, - listEnvironmentsInput, - updateEnvironmentInput, -} from "../../services/environment/schemas"; -import type { EnvironmentService } from "../../services/environment/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.EnvironmentService); - -export const environmentRouter = router({ - list: publicProcedure - .input(listEnvironmentsInput) - .output(environmentSchema.array()) - .query(({ input }) => getService().listEnvironments(input.repoPath)), - - get: publicProcedure - .input(getEnvironmentInput) - .output(environmentSchema.nullable()) - .query(({ input }) => - getService().getEnvironment(input.repoPath, input.id), - ), - - create: publicProcedure - .input(createEnvironmentInput) - .output(environmentSchema) - .mutation(({ input }) => { - const { repoPath, ...rest } = input; - return getService().createEnvironment(rest, repoPath); - }), - - update: publicProcedure - .input(updateEnvironmentInput) - .output(environmentSchema) - .mutation(({ input }) => { - const { repoPath, ...rest } = input; - return getService().updateEnvironment(rest, repoPath); - }), - - delete: publicProcedure - .input(deleteEnvironmentInput) - .mutation(({ input }) => - getService().deleteEnvironment(input.repoPath, input.id), - ), -}); diff --git a/apps/code/src/main/trpc/routers/external-apps.ts b/apps/code/src/main/trpc/routers/external-apps.ts deleted file mode 100644 index edefbb203b..0000000000 --- a/apps/code/src/main/trpc/routers/external-apps.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - copyPathInput, - getDetectedAppsOutput, - getLastUsedOutput, - openInAppInput, - openInAppOutput, - setLastUsedInput, -} from "../../services/external-apps/schemas"; -import type { ExternalAppsService } from "../../services/external-apps/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.ExternalAppsService); - -export const externalAppsRouter = router({ - getDetectedApps: publicProcedure - .output(getDetectedAppsOutput) - .query(() => getService().getDetectedApps()), - - openInApp: publicProcedure - .input(openInAppInput) - .output(openInAppOutput) - .mutation(({ input }) => - getService().openInApp(input.appId, input.targetPath), - ), - - setLastUsed: publicProcedure - .input(setLastUsedInput) - .mutation(({ input }) => getService().setLastUsed(input.appId)), - - getLastUsed: publicProcedure - .output(getLastUsedOutput) - .query(() => getService().getLastUsed()), - - copyPath: publicProcedure - .input(copyPathInput) - .mutation(({ input }) => getService().copyPath(input.targetPath)), -}); diff --git a/apps/code/src/main/trpc/routers/file-watcher.ts b/apps/code/src/main/trpc/routers/file-watcher.ts deleted file mode 100644 index 95e5e78ff4..0000000000 --- a/apps/code/src/main/trpc/routers/file-watcher.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { FileWatcherBridge } from "../../services/file-watcher/bridge"; -import { publicProcedure, router } from "../trpc"; - -const watcherInput = z.object({ repoPath: z.string() }); - -const getService = () => - container.get(MAIN_TOKENS.FileWatcherService); - -export const fileWatcherRouter = router({ - start: publicProcedure - .input(watcherInput) - .mutation(({ input }) => getService().startWatching(input.repoPath)), - - stop: publicProcedure - .input(watcherInput) - .mutation(({ input }) => getService().stopWatching(input.repoPath)), -}); diff --git a/apps/code/src/main/trpc/routers/focus.ts b/apps/code/src/main/trpc/routers/focus.ts deleted file mode 100644 index a8528dde9e..0000000000 --- a/apps/code/src/main/trpc/routers/focus.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - checkoutInput, - findWorktreeInput, - focusResultSchema, - focusSessionSchema, - mainRepoPathInput, - reattachInput, - repoPathInput, - stashInput, - stashResultSchema, - syncInput, - worktreeInput, -} from "../../services/focus/schemas"; -import { - type FocusService, - FocusServiceEvent, - type FocusServiceEvents, -} from "../../services/focus/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => container.get(MAIN_TOKENS.FocusService); - -function subscribe(event: K) { - return publicProcedure.subscription(async function* (opts) { - const service = getService(); - const iterable = service.toIterable(event, { signal: opts.signal }); - for await (const data of iterable) { - yield data; - } - }); -} - -export const focusRouter = router({ - getSession: publicProcedure - .input(mainRepoPathInput) - .output(focusSessionSchema.nullable()) - .query(({ input }) => getService().getSession(input.mainRepoPath)), - - saveSession: publicProcedure - .input(focusSessionSchema) - .mutation(({ input }) => getService().saveSession(input)), - - deleteSession: publicProcedure - .input(mainRepoPathInput) - .mutation(({ input }) => getService().deleteSession(input.mainRepoPath)), - - isFocusActive: publicProcedure - .input(mainRepoPathInput) - .output(z.boolean()) - .query(({ input }) => getService().isFocusActive(input.mainRepoPath)), - - validateFocusOperation: publicProcedure - .input( - z.object({ - mainRepoPath: z.string(), - currentBranch: z.string().nullable(), - targetBranch: z.string(), - }), - ) - .output(z.string().nullable()) - .query(({ input }) => - getService().validateFocusOperation( - input.currentBranch, - input.targetBranch, - ), - ), - - isDirty: publicProcedure - .input(repoPathInput) - .output(z.boolean()) - .query(({ input }) => getService().isDirty(input.repoPath)), - - getCommitSha: publicProcedure - .input(repoPathInput) - .output(z.string()) - .query(({ input }) => getService().getCommitSha(input.repoPath)), - - findWorktreeByBranch: publicProcedure - .input(findWorktreeInput) - .output(z.string().nullable()) - .query(({ input }) => - getService().findWorktreeByBranch(input.mainRepoPath, input.branch), - ), - - toRelativeWorktreePath: publicProcedure - .input(z.object({ absolutePath: z.string(), mainRepoPath: z.string() })) - .output(z.string()) - .query(({ input }) => - getService().toRelativeWorktreePath( - input.absolutePath, - input.mainRepoPath, - ), - ), - - toAbsoluteWorktreePath: publicProcedure - .input(z.object({ relativePath: z.string() })) - .output(z.string()) - .query(({ input }) => - getService().toAbsoluteWorktreePath(input.relativePath), - ), - - worktreeExistsAtPath: publicProcedure - .input(z.object({ relativePath: z.string() })) - .output(z.boolean()) - .query(({ input }) => - getService().worktreeExistsAtPath(input.relativePath), - ), - - // Mutations - stash: publicProcedure - .input(stashInput) - .output(stashResultSchema) - .mutation(({ input }) => getService().stash(input.repoPath, input.message)), - - stashPop: publicProcedure - .input(repoPathInput) - .output(focusResultSchema) - .mutation(({ input }) => getService().stashPop(input.repoPath)), - - stashApply: publicProcedure - .input(z.object({ repoPath: z.string(), stashRef: z.string() })) - .output(focusResultSchema) - .mutation(({ input }) => - getService().stashApply(input.repoPath, input.stashRef), - ), - - checkout: publicProcedure - .input(checkoutInput) - .output(focusResultSchema) - .mutation(({ input }) => - getService().checkout(input.repoPath, input.branch), - ), - - detachWorktree: publicProcedure - .input(worktreeInput) - .output(focusResultSchema) - .mutation(({ input }) => getService().detachWorktree(input.worktreePath)), - - reattachWorktree: publicProcedure - .input(reattachInput) - .output(focusResultSchema) - .mutation(({ input }) => - getService().reattachWorktree(input.worktreePath, input.branch), - ), - - cleanWorkingTree: publicProcedure - .input(repoPathInput) - .mutation(({ input }) => getService().cleanWorkingTree(input.repoPath)), - - startSync: publicProcedure - .input(syncInput) - .mutation(({ input }) => - getService().startSync(input.mainRepoPath, input.worktreePath), - ), - - stopSync: publicProcedure.mutation(() => getService().stopSync()), - - startWatchingMainRepo: publicProcedure - .input(mainRepoPathInput) - .mutation(({ input }) => - getService().startWatchingMainRepo(input.mainRepoPath), - ), - - stopWatchingMainRepo: publicProcedure.mutation(() => - getService().stopWatchingMainRepo(), - ), - - onBranchRenamed: subscribe(FocusServiceEvent.BranchRenamed), - onForeignBranchCheckout: subscribe(FocusServiceEvent.ForeignBranchCheckout), -}); diff --git a/apps/code/src/main/trpc/routers/folders.ts b/apps/code/src/main/trpc/routers/folders.ts deleted file mode 100644 index d6d011eccd..0000000000 --- a/apps/code/src/main/trpc/routers/folders.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - addFolderInput, - addFolderOutput, - getFoldersOutput, - getRepositoryByRemoteUrlInput, - removeFolderInput, - repositoryLookupResult, - updateFolderAccessedInput, -} from "../../services/folders/schemas"; -import type { FoldersService } from "../../services/folders/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.FoldersService); - -export const foldersRouter = router({ - getFolders: publicProcedure.output(getFoldersOutput).query(() => { - return getService().getFolders(); - }), - - addFolder: publicProcedure - .input(addFolderInput) - .output(addFolderOutput) - .mutation(({ input }) => { - return getService().addFolder(input.folderPath, { - remoteUrl: input.remoteUrl, - }); - }), - - removeFolder: publicProcedure - .input(removeFolderInput) - .mutation(({ input }) => { - return getService().removeFolder(input.folderId); - }), - - updateFolderAccessed: publicProcedure - .input(updateFolderAccessedInput) - .mutation(({ input }) => { - return getService().updateFolderAccessed(input.folderId); - }), - - clearAllData: publicProcedure.mutation(() => { - return getService().clearAllData(); - }), - - getRepositoryByRemoteUrl: publicProcedure - .input(getRepositoryByRemoteUrlInput) - .output(repositoryLookupResult) - .query(({ input }) => { - return getService().getRepositoryByRemoteUrl(input.remoteUrl); - }), - - getMostRecentlyAccessedRepository: publicProcedure - .output(repositoryLookupResult) - .query(() => { - return getService().getMostRecentlyAccessedRepository(); - }), -}); diff --git a/apps/code/src/main/trpc/routers/fs.ts b/apps/code/src/main/trpc/routers/fs.ts deleted file mode 100644 index eaff0fb424..0000000000 --- a/apps/code/src/main/trpc/routers/fs.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - boundedReadResult, - listRepoFilesInput, - listRepoFilesOutput, - readAbsoluteFileInput, - readRepoFileBoundedInput, - readRepoFileInput, - readRepoFileOutput, - readRepoFilesBoundedInput, - readRepoFilesBoundedOutput, - readRepoFilesInput, - readRepoFilesOutput, - writeRepoFileInput, -} from "../../services/fs/schemas"; -import type { FsService } from "../../services/fs/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => container.get(MAIN_TOKENS.FsService); - -export const fsRouter = router({ - listRepoFiles: publicProcedure - .input(listRepoFilesInput) - .output(listRepoFilesOutput) - .query(({ input }) => - getService().listRepoFiles(input.repoPath, input.query, input.limit), - ), - - readRepoFile: publicProcedure - .input(readRepoFileInput) - .output(readRepoFileOutput) - .query(({ input }) => - getService().readRepoFile(input.repoPath, input.filePath), - ), - - readRepoFiles: publicProcedure - .input(readRepoFilesInput) - .output(readRepoFilesOutput) - .query(({ input }) => - getService().readRepoFiles(input.repoPath, input.filePaths), - ), - - readRepoFileBounded: publicProcedure - .input(readRepoFileBoundedInput) - .output(boundedReadResult) - .query(({ input }) => - getService().readRepoFileBounded( - input.repoPath, - input.filePath, - input.maxLines, - ), - ), - - readRepoFilesBounded: publicProcedure - .input(readRepoFilesBoundedInput) - .output(readRepoFilesBoundedOutput) - .query(({ input }) => - getService().readRepoFilesBounded( - input.repoPath, - input.filePaths, - input.maxLines, - ), - ), - - readAbsoluteFile: publicProcedure - .input(readAbsoluteFileInput) - .output(readRepoFileOutput) - .query(({ input }) => getService().readAbsoluteFile(input.filePath)), - - readFileAsBase64: publicProcedure - .input(readAbsoluteFileInput) - .output(readRepoFileOutput) - .query(({ input }) => getService().readFileAsBase64(input.filePath)), - - writeRepoFile: publicProcedure - .input(writeRepoFileInput) - .mutation(({ input }) => - getService().writeRepoFile(input.repoPath, input.filePath, input.content), - ), -}); diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts deleted file mode 100644 index 21b7e65099..0000000000 --- a/apps/code/src/main/trpc/routers/git.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - checkoutBranchInput, - checkoutBranchOutput, - cloneRepositoryInput, - cloneRepositoryOutput, - commitInput, - commitOutput, - createBranchInput, - createPrInput, - createPrOutput, - detectRepoInput, - detectRepoOutput, - diffInput, - diffOutput, - discardFileChangesInput, - discardFileChangesOutput, - generateCommitMessageInput, - generateCommitMessageOutput, - generatePrTitleAndBodyInput, - generatePrTitleAndBodyOutput, - getAllBranchesInput, - getAllBranchesOutput, - getBranchChangedFilesInput, - getBranchChangedFilesOutput, - getChangedFilesHeadInput, - getChangedFilesHeadOutput, - getCommitConventionsInput, - getCommitConventionsOutput, - getCurrentBranchInput, - getCurrentBranchOutput, - getDiffStatsInput, - getDiffStatsOutput, - getFileAtHeadInput, - getFileAtHeadOutput, - getGitBusyStateInput, - getGitBusyStateOutput, - getGithubIssueInput, - getGithubIssueOutput, - getGithubPullRequestInput, - getGithubPullRequestOutput, - getGitRepoInfoInput, - getGitRepoInfoOutput, - getGitSyncStatusOutput, - getLatestCommitInput, - getLatestCommitOutput, - getLocalBranchChangedFilesInput, - getLocalBranchChangedFilesOutput, - getPrChangedFilesInput, - getPrChangedFilesOutput, - getPrDetailsByUrlInput, - getPrDetailsByUrlOutput, - getPrReviewCommentsInput, - getPrReviewCommentsOutput, - getPrTemplateInput, - getPrTemplateOutput, - getPrUrlForBranchInput, - getPrUrlForBranchOutput, - ghAuthTokenOutput, - ghStatusOutput, - gitStateSnapshotSchema, - gitStatusOutput, - openPrInput, - openPrOutput, - prStatusInput, - prStatusOutput, - publishInput, - publishOutput, - pullInput, - pullOutput, - pushInput, - pushOutput, - replyToPrCommentInput, - replyToPrCommentOutput, - resolveReviewThreadInput, - resolveReviewThreadOutput, - searchGithubRefsInput, - searchGithubRefsOutput, - stageFilesInput, - syncInput, - syncOutput, - updatePrByUrlInput, - updatePrByUrlOutput, - validateRepoInput, - validateRepoOutput, -} from "../../services/git/schemas"; -import { type GitService, GitServiceEvent } from "../../services/git/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => container.get(MAIN_TOKENS.GitService); - -export const gitRouter = router({ - detectRepo: publicProcedure - .input(detectRepoInput) - .output(detectRepoOutput) - .query(({ input }) => getService().detectRepo(input.directoryPath)), - - validateRepo: publicProcedure - .input(validateRepoInput) - .output(validateRepoOutput) - .query(({ input }) => getService().validateRepo(input.directoryPath)), - - cloneRepository: publicProcedure - .input(cloneRepositoryInput) - .output(cloneRepositoryOutput) - .mutation(({ input }) => - getService().cloneRepository( - input.repoUrl, - input.targetPath, - input.cloneId, - ), - ), - - onCloneProgress: publicProcedure.subscription(async function* (opts) { - const service = getService(); - const iterable = service.toIterable(GitServiceEvent.CloneProgress, { - signal: opts.signal, - }); - for await (const data of iterable) { - yield data; - } - }), - - // Branch operations - getCurrentBranch: publicProcedure - .input(getCurrentBranchInput) - .output(getCurrentBranchOutput) - .query(({ input, signal }) => - getService().getCurrentBranch(input.directoryPath, signal), - ), - - getAllBranches: publicProcedure - .input(getAllBranchesInput) - .output(getAllBranchesOutput) - .query(({ input, signal }) => - getService().getAllBranches(input.directoryPath, signal), - ), - - getGitBusyState: publicProcedure - .input(getGitBusyStateInput) - .output(getGitBusyStateOutput) - .query(({ input, signal }) => - getService().getGitBusyState(input.directoryPath, signal), - ), - - createBranch: publicProcedure - .input(createBranchInput) - .mutation(({ input }) => - getService().createBranch(input.directoryPath, input.branchName), - ), - - checkoutBranch: publicProcedure - .input(checkoutBranchInput) - .output(checkoutBranchOutput) - .mutation(({ input }) => - getService().checkoutBranch(input.directoryPath, input.branchName), - ), - - // File change operations - getChangedFilesHead: publicProcedure - .input(getChangedFilesHeadInput) - .output(getChangedFilesHeadOutput) - .query(({ input, signal }) => - getService().getChangedFilesHead(input.directoryPath, signal), - ), - - getFileAtHead: publicProcedure - .input(getFileAtHeadInput) - .output(getFileAtHeadOutput) - .query(({ input, signal }) => - getService().getFileAtHead(input.directoryPath, input.filePath, signal), - ), - - getDiffHead: publicProcedure - .input(diffInput) - .output(diffOutput) - .query(({ input, signal }) => - getService().getDiffHead( - input.directoryPath, - input.ignoreWhitespace, - signal, - ), - ), - - getDiffCached: publicProcedure - .input(diffInput) - .output(diffOutput) - .query(({ input, signal }) => - getService().getDiffCached( - input.directoryPath, - input.ignoreWhitespace, - signal, - ), - ), - - getDiffUnstaged: publicProcedure - .input(diffInput) - .output(diffOutput) - .query(({ input, signal }) => - getService().getDiffUnstaged( - input.directoryPath, - input.ignoreWhitespace, - signal, - ), - ), - - getDiffStats: publicProcedure - .input(getDiffStatsInput) - .output(getDiffStatsOutput) - .query(({ input, signal }) => - getService().getDiffStats(input.directoryPath, signal), - ), - - stageFiles: publicProcedure - .input(stageFilesInput) - .output(gitStateSnapshotSchema) - .mutation(({ input }) => - getService().stageFiles(input.directoryPath, input.paths), - ), - - unstageFiles: publicProcedure - .input(stageFilesInput) - .output(gitStateSnapshotSchema) - .mutation(({ input }) => - getService().unstageFiles(input.directoryPath, input.paths), - ), - - discardFileChanges: publicProcedure - .input(discardFileChangesInput) - .output(discardFileChangesOutput) - .mutation(({ input }) => - getService().discardFileChanges( - input.directoryPath, - input.filePath, - input.fileStatus, - ), - ), - - // Sync status operations - getGitSyncStatus: publicProcedure - .input( - z.object({ - directoryPath: z.string(), - forceRefresh: z.boolean().optional(), - }), - ) - .output(getGitSyncStatusOutput) - .query(({ input }) => - getService().getGitSyncStatus(input.directoryPath, input.forceRefresh), - ), - - // Commit/repo info operations - getLatestCommit: publicProcedure - .input(getLatestCommitInput) - .output(getLatestCommitOutput) - .query(({ input, signal }) => - getService().getLatestCommit(input.directoryPath, signal), - ), - - getGitRepoInfo: publicProcedure - .input(getGitRepoInfoInput) - .output(getGitRepoInfoOutput) - .query(({ input }) => getService().getGitRepoInfo(input.directoryPath)), - - commit: publicProcedure - .input(commitInput) - .output(commitOutput) - .mutation(({ input }) => - getService().commit(input.directoryPath, input.message, { - paths: input.paths, - allowEmpty: input.allowEmpty, - stagedOnly: input.stagedOnly, - taskId: input.taskId, - }), - ), - - push: publicProcedure - .input(pushInput) - .output(pushOutput) - .mutation(({ input, signal }) => - getService().push( - input.directoryPath, - input.remote, - input.branch, - input.setUpstream, - signal, - ), - ), - - pull: publicProcedure - .input(pullInput) - .output(pullOutput) - .mutation(({ input, signal }) => - getService().pull( - input.directoryPath, - input.remote, - input.branch, - signal, - ), - ), - - publish: publicProcedure - .input(publishInput) - .output(publishOutput) - .mutation(({ input, signal }) => - getService().publish(input.directoryPath, input.remote, signal), - ), - - sync: publicProcedure - .input(syncInput) - .output(syncOutput) - .mutation(({ input, signal }) => - getService().sync(input.directoryPath, input.remote, signal), - ), - - getGitStatus: publicProcedure - .output(gitStatusOutput) - .query(() => getService().getGitStatus()), - - getGhStatus: publicProcedure - .output(ghStatusOutput) - .query(() => getService().getGhStatus()), - - getGhAuthToken: publicProcedure - .output(ghAuthTokenOutput) - .query(() => getService().getGhAuthToken()), - - getPrStatus: publicProcedure - .input(prStatusInput) - .output(prStatusOutput) - .query(({ input }) => getService().getPrStatus(input.directoryPath)), - - getPrUrlForBranch: publicProcedure - .input(getPrUrlForBranchInput) - .output(getPrUrlForBranchOutput) - .query(({ input }) => - getService().getPrUrlForBranch(input.directoryPath, input.branchName), - ), - - createPr: publicProcedure - .input(createPrInput) - .output(createPrOutput) - .mutation(({ input }) => getService().createPr(input)), - - openPr: publicProcedure - .input(openPrInput) - .output(openPrOutput) - .mutation(({ input }) => getService().openPr(input.directoryPath)), - - getPrTemplate: publicProcedure - .input(getPrTemplateInput) - .output(getPrTemplateOutput) - .query(({ input }) => getService().getPrTemplate(input.directoryPath)), - - getCommitConventions: publicProcedure - .input(getCommitConventionsInput) - .output(getCommitConventionsOutput) - .query(({ input }) => - getService().getCommitConventions(input.directoryPath, input.sampleSize), - ), - - getPrChangedFiles: publicProcedure - .input(getPrChangedFilesInput) - .output(getPrChangedFilesOutput) - .query(({ input }) => getService().getPrChangedFiles(input.prUrl)), - - getPrDetailsByUrl: publicProcedure - .input(getPrDetailsByUrlInput) - .output(getPrDetailsByUrlOutput) - .query(async ({ input }) => { - const result = await getService().getPrDetailsByUrl(input.prUrl); - return result ?? { state: "unknown", merged: false, draft: false }; - }), - - updatePrByUrl: publicProcedure - .input(updatePrByUrlInput) - .output(updatePrByUrlOutput) - .mutation(({ input }) => - getService().updatePrByUrl(input.prUrl, input.action), - ), - - getPrReviewComments: publicProcedure - .input(getPrReviewCommentsInput) - .output(getPrReviewCommentsOutput) - .query(({ input }) => getService().getPrReviewComments(input.prUrl)), - - replyToPrComment: publicProcedure - .input(replyToPrCommentInput) - .output(replyToPrCommentOutput) - .mutation(({ input }) => - getService().replyToPrComment(input.prUrl, input.commentId, input.body), - ), - - resolveReviewThread: publicProcedure - .input(resolveReviewThreadInput) - .output(resolveReviewThreadOutput) - .mutation(({ input }) => - getService().resolveReviewThread(input.threadNodeId, input.resolved), - ), - - getBranchChangedFiles: publicProcedure - .input(getBranchChangedFilesInput) - .output(getBranchChangedFilesOutput) - .query(({ input }) => - getService().getBranchChangedFiles(input.repo, input.branch), - ), - - getLocalBranchChangedFiles: publicProcedure - .input(getLocalBranchChangedFilesInput) - .output(getLocalBranchChangedFilesOutput) - .query(({ input }) => - getService().getLocalBranchChangedFiles( - input.directoryPath, - input.branch, - ), - ), - - generateCommitMessage: publicProcedure - .input(generateCommitMessageInput) - .output(generateCommitMessageOutput) - .mutation(({ input }) => - getService().generateCommitMessage( - input.directoryPath, - input.conversationContext, - ), - ), - - generatePrTitleAndBody: publicProcedure - .input(generatePrTitleAndBodyInput) - .output(generatePrTitleAndBodyOutput) - .mutation(({ input }) => - getService().generatePrTitleAndBody( - input.directoryPath, - input.conversationContext, - ), - ), - - searchGithubRefs: publicProcedure - .input(searchGithubRefsInput) - .output(searchGithubRefsOutput) - .query(({ input }) => - getService().searchGithubRefs( - input.directoryPath, - input.query, - input.limit, - input.kinds, - ), - ), - - getGithubIssue: publicProcedure - .input(getGithubIssueInput) - .output(getGithubIssueOutput) - .query(({ input }) => - getService().getGithubIssue(input.owner, input.repo, input.number), - ), - - getGithubPullRequest: publicProcedure - .input(getGithubPullRequestInput) - .output(getGithubPullRequestOutput) - .query(({ input }) => - getService().getGithubPullRequest(input.owner, input.repo, input.number), - ), - - onCreatePrProgress: publicProcedure.subscription(async function* (opts) { - const service = getService(); - const iterable = service.toIterable(GitServiceEvent.CreatePrProgress, { - signal: opts.signal, - }); - for await (const data of iterable) { - yield data; - } - }), -}); diff --git a/apps/code/src/main/trpc/routers/github-integration.ts b/apps/code/src/main/trpc/routers/github-integration.ts deleted file mode 100644 index 2c3fa16708..0000000000 --- a/apps/code/src/main/trpc/routers/github-integration.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - startGitHubFlowInput, - startGitHubFlowOutput, -} from "../../services/github-integration/schemas"; -import { - type FlowTimedOut, - GitHubIntegrationEvent, - type GitHubIntegrationService, - type IntegrationCallback, -} from "../../services/github-integration/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.GitHubIntegrationService); - -export const githubIntegrationRouter = router({ - startFlow: publicProcedure - .input(startGitHubFlowInput) - .output(startGitHubFlowOutput) - .mutation(({ input }) => - getService().startFlow(input.region, input.projectId), - ), - - /** - * Subscribe to GitHub integration deep link callbacks emitted after the user - * completes (or errors out of) the GitHub App install flow on PostHog Cloud. - */ - onCallback: publicProcedure.subscription(async function* (opts) { - const service = getService(); - const iterable = service.toIterable(GitHubIntegrationEvent.Callback, { - signal: opts.signal, - }); - for await (const data of iterable) { - yield data; - } - }), - - /** - * Subscribe to flow timeout events (5 minutes with no deep link callback). - */ - onFlowTimedOut: publicProcedure.subscription(async function* (opts) { - const service = getService(); - const iterable = service.toIterable(GitHubIntegrationEvent.FlowTimedOut, { - signal: opts.signal, - }); - for await (const data of iterable) { - yield data; - } - }), - - /** - * Get any integration callback that arrived before the renderer subscribed. - */ - consumePendingCallback: publicProcedure.query( - (): IntegrationCallback | null => getService().consumePendingCallback(), - ), -}); - -export type { IntegrationCallback, FlowTimedOut }; diff --git a/apps/code/src/main/trpc/routers/linear-integration.ts b/apps/code/src/main/trpc/routers/linear-integration.ts deleted file mode 100644 index 0cccd8c226..0000000000 --- a/apps/code/src/main/trpc/routers/linear-integration.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { container } from "../../di/container.js"; -import { MAIN_TOKENS } from "../../di/tokens.js"; -import { - startLinearFlowInput, - startLinearFlowOutput, -} from "../../services/linear-integration/schemas.js"; -import type { LinearIntegrationService } from "../../services/linear-integration/service.js"; -import { publicProcedure, router } from "../trpc.js"; - -const getService = () => - container.get(MAIN_TOKENS.LinearIntegrationService); - -export const linearIntegrationRouter = router({ - startFlow: publicProcedure - .input(startLinearFlowInput) - .output(startLinearFlowOutput) - .mutation(({ input }) => - getService().startFlow(input.region, input.projectId), - ), -}); diff --git a/apps/code/src/main/trpc/routers/llm-gateway.ts b/apps/code/src/main/trpc/routers/llm-gateway.ts deleted file mode 100644 index 2c0017dde4..0000000000 --- a/apps/code/src/main/trpc/routers/llm-gateway.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { promptInput, promptOutput } from "../../services/llm-gateway/schemas"; -import type { LlmGatewayService } from "../../services/llm-gateway/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.LlmGatewayService); - -export const llmGatewayRouter = router({ - prompt: publicProcedure - .input(promptInput) - .output(promptOutput) - .mutation(({ input }) => - getService().prompt(input.messages, { - system: input.system, - maxTokens: input.maxTokens, - model: input.model, - }), - ), - - invalidatePlanCache: publicProcedure.mutation(() => - getService().invalidatePlanCache(), - ), -}); diff --git a/apps/code/src/main/trpc/routers/logs.ts b/apps/code/src/main/trpc/routers/logs.ts deleted file mode 100644 index bd0e80a67f..0000000000 --- a/apps/code/src/main/trpc/routers/logs.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { LocalLogsService } from "../../services/local-logs/service"; -import { logger } from "../../utils/logger"; -import { publicProcedure, router } from "../trpc"; - -const log = logger.scope("logsRouter"); - -const getLocalLogsService = (): LocalLogsService => - container.get(MAIN_TOKENS.LocalLogsService); - -export const logsRouter = router({ - fetchS3Logs: publicProcedure - .input(z.object({ logUrl: z.string() })) - .query(async ({ input }) => { - try { - const response = await fetch(input.logUrl); - - if (response.status === 404) { - return null; - } - - if (!response.ok) { - log.warn( - "Failed to fetch S3 logs:", - response.status, - response.statusText, - ); - return null; - } - - return await response.text(); - } catch (error) { - log.error("Failed to fetch S3 logs:", error); - return null; - } - }), - - readLocalLogs: publicProcedure - .input(z.object({ taskRunId: z.string() })) - .query(({ input }) => getLocalLogsService().readLocalLogs(input.taskRunId)), - - writeLocalLogs: publicProcedure - .input(z.object({ taskRunId: z.string(), content: z.string() })) - .mutation(({ input }) => - getLocalLogsService().writeLocalLogs(input.taskRunId, input.content), - ), -}); diff --git a/apps/code/src/main/trpc/routers/notification.ts b/apps/code/src/main/trpc/routers/notification.ts deleted file mode 100644 index ee798ff610..0000000000 --- a/apps/code/src/main/trpc/routers/notification.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { NotificationService } from "../../services/notification/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.NotificationService); - -export const notificationRouter = router({ - send: publicProcedure - .input( - z.object({ - title: z.string(), - body: z.string(), - silent: z.boolean(), - taskId: z.string().optional(), - }), - ) - .mutation(({ input }) => - getService().send(input.title, input.body, input.silent, input.taskId), - ), - showDockBadge: publicProcedure.mutation(() => getService().showDockBadge()), - bounceDock: publicProcedure.mutation(() => getService().bounceDock()), -}); diff --git a/apps/code/src/main/trpc/routers/oauth.ts b/apps/code/src/main/trpc/routers/oauth.ts deleted file mode 100644 index e62a1a05f8..0000000000 --- a/apps/code/src/main/trpc/routers/oauth.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { cancelFlowOutput } from "../../services/oauth/schemas"; -import type { OAuthService } from "../../services/oauth/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => container.get(MAIN_TOKENS.OAuthService); - -export const oauthRouter = router({ - cancelFlow: publicProcedure - .output(cancelFlowOutput) - .mutation(() => getService().cancelFlow()), -}); diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts deleted file mode 100644 index df50b82d0e..0000000000 --- a/apps/code/src/main/trpc/routers/os.ts +++ /dev/null @@ -1,401 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { IAppMeta } from "@posthog/platform/app-meta"; -import type { DialogSeverity, IDialog } from "@posthog/platform/dialog"; -import type { IImageProcessor } from "@posthog/platform/image-processor"; -import type { IUrlLauncher } from "@posthog/platform/url-launcher"; -import { - ALLOWED_IMAGE_MIME_TYPES, - IMAGE_MIME_TYPES, - isRasterImageFile, -} from "@posthog/shared"; -import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { getWorktreeLocation } from "../../services/settingsStore"; -import { publicProcedure, router } from "../trpc"; - -const fsPromises = fs.promises; - -const getUrlLauncher = () => - container.get(MAIN_TOKENS.UrlLauncher); -const getDialog = () => container.get(MAIN_TOKENS.Dialog); -const getAppMeta = () => container.get(MAIN_TOKENS.AppMeta); -const getImageProcessor = () => - container.get(MAIN_TOKENS.ImageProcessor); - -const messageBoxOptionsSchema = z.object({ - type: z.enum(["none", "info", "error", "question", "warning"]).optional(), - title: z.string().optional(), - message: z.string().optional(), - detail: z.string().optional(), - buttons: z.array(z.string()).optional(), - defaultId: z.number().optional(), - cancelId: z.number().optional(), -}); - -const expandHomePath = (searchPath: string): string => - searchPath.startsWith("~") - ? searchPath.replace(/^~/, os.homedir()) - : searchPath; - -const MAX_IMAGE_DIMENSION = 1568; -const JPEG_QUALITY = 85; -const MAX_FILE_SIZE = 50 * 1024 * 1024; -const CLIPBOARD_TEMP_DIR = path.join(os.tmpdir(), "posthog-code-clipboard"); - -async function createClipboardTempFilePath( - displayName: string, -): Promise { - const safeName = path.basename(displayName) || "attachment"; - await fsPromises.mkdir(CLIPBOARD_TEMP_DIR, { recursive: true }); - const tempDir = await fsPromises.mkdtemp( - path.join(CLIPBOARD_TEMP_DIR, "attachment-"), - ); - return path.join(tempDir, safeName); -} - -async function downscaleAndPersist( - raw: Uint8Array, - inputMime: string, - displayName: string, -): Promise<{ path: string; name: string; mimeType: string }> { - const { buffer, mimeType, extension } = getImageProcessor().downscale( - raw, - inputMime, - { maxDimension: MAX_IMAGE_DIMENSION, jpegQuality: JPEG_QUALITY }, - ); - - const finalName = displayName.replace(/\.[^.]+$/, `.${extension}`); - const filePath = await createClipboardTempFilePath(finalName); - await fsPromises.writeFile(filePath, Buffer.from(buffer)); - - return { path: filePath, name: finalName, mimeType }; -} - -const claudeSettingsPath = path.join(os.homedir(), ".claude", "settings.json"); - -export const osRouter = router({ - getClaudePermissions: publicProcedure - .output( - z.object({ - allow: z.array(z.string()), - deny: z.array(z.string()), - }), - ) - .query(async () => { - try { - const content = await fsPromises.readFile(claudeSettingsPath, "utf-8"); - const settings = JSON.parse(content); - return { - allow: Array.isArray(settings?.permissions?.allow) - ? settings.permissions.allow - : [], - deny: Array.isArray(settings?.permissions?.deny) - ? settings.permissions.deny - : [], - }; - } catch { - return { allow: [], deny: [] }; - } - }), - - /** - * Show directory picker dialog - */ - selectDirectory: publicProcedure.query(async () => { - const paths = await getDialog().pickFile({ - title: "Select a repository folder", - directories: true, - createDirectories: true, - }); - return paths[0] ?? null; - }), - - /** - * Show file picker dialog - */ - selectFiles: publicProcedure.output(z.array(z.string())).query(async () => { - return await getDialog().pickFile({ - title: "Select files", - multiple: true, - }); - }), - - /** - * Show an attachment picker that can return files, directories, or both. - * Stats each returned path so the renderer knows which is which. - */ - selectAttachments: publicProcedure - .input( - z.object({ - mode: z.enum(["files", "directories", "both"]).default("both"), - }), - ) - .output( - z.array( - z.object({ - path: z.string(), - kind: z.enum(["file", "directory"]), - }), - ), - ) - .query(async ({ input }) => { - const dialog = getDialog(); - const titleByMode = { - files: "Select files", - directories: "Select folders", - both: "Select files or folders", - } as const; - const paths = await dialog.pickFile({ - title: titleByMode[input.mode], - multiple: true, - directories: input.mode === "directories", - filesAndDirectories: input.mode === "both", - }); - const statResults = await Promise.all( - paths.map(async (p) => { - try { - const stat = await fsPromises.stat(p); - return { - path: p, - kind: stat.isDirectory() - ? ("directory" as const) - : ("file" as const), - }; - } catch { - return null; - } - }), - ); - return statResults.filter( - (r): r is { path: string; kind: "file" | "directory" } => r !== null, - ); - }), - - /** - * Check if a directory has write access - */ - checkWriteAccess: publicProcedure - .input(z.object({ directoryPath: z.string() })) - .query(async ({ input }) => { - if (!input.directoryPath) return false; - try { - await fsPromises.access(input.directoryPath, fs.constants.W_OK); - const testFile = path.join( - input.directoryPath, - `.agent-write-test-${Date.now()}`, - ); - await fsPromises.writeFile(testFile, "ok"); - await fsPromises.unlink(testFile).catch(() => {}); - return true; - } catch { - return false; - } - }), - - /** - * Show a message box dialog - */ - showMessageBox: publicProcedure - .input(z.object({ options: messageBoxOptionsSchema })) - .mutation(async ({ input }) => { - const options = input.options; - const severity: DialogSeverity | undefined = - options?.type && options.type !== "none" ? options.type : undefined; - const response = await getDialog().confirm({ - severity, - title: options?.title || "PostHog Code", - message: options?.message || "", - detail: options?.detail, - options: - Array.isArray(options?.buttons) && options.buttons.length > 0 - ? options.buttons - : ["OK"], - defaultIndex: options?.defaultId ?? 0, - cancelIndex: options?.cancelId ?? 1, - }); - return { response }; - }), - - /** - * Open URL in external browser - */ - openExternal: publicProcedure - .input(z.object({ url: z.string() })) - .mutation(async ({ input }) => { - await getUrlLauncher().launch(input.url); - }), - - /** - * Search for directories matching a query - */ - searchDirectories: publicProcedure - .input(z.object({ query: z.string(), searchRoot: z.string().optional() })) - .query(async ({ input }) => { - if (!input.query?.trim()) return []; - - const searchPath = expandHomePath(input.query.trim()); - const lastSlashIdx = searchPath.lastIndexOf("/"); - const basePath = - lastSlashIdx === -1 ? "" : searchPath.substring(0, lastSlashIdx + 1); - const searchTerm = - lastSlashIdx === -1 - ? searchPath - : searchPath.substring(lastSlashIdx + 1); - const pathToRead = basePath || os.homedir(); - - try { - const entries = await fsPromises.readdir(pathToRead, { - withFileTypes: true, - }); - const directories = entries.filter((entry) => entry.isDirectory()); - - const filtered = searchTerm - ? directories.filter((dir) => - dir.name.toLowerCase().includes(searchTerm.toLowerCase()), - ) - : directories; - - return filtered - .map((dir) => path.join(pathToRead, dir.name)) - .sort((a, b) => path.basename(a).localeCompare(path.basename(b))) - .slice(0, 20); - } catch { - return []; - } - }), - - /** - * Get the application version - */ - getAppVersion: publicProcedure.query(() => getAppMeta().version), - - /** - * Get the worktree base location (e.g., ~/.posthog-code) - */ - getWorktreeLocation: publicProcedure.query(() => getWorktreeLocation()), - - /** - * Read a file and return it as a base64 data URL - * Used for image thumbnails in the editor - */ - readFileAsDataUrl: publicProcedure - .input( - z.object({ - filePath: z.string(), - maxSizeBytes: z - .number() - .optional() - .default(10 * 1024 * 1024), - }), - ) - .query(async ({ input }) => { - try { - const stat = await fsPromises.stat(input.filePath); - if (stat.size > input.maxSizeBytes) return null; - - const ext = path.extname(input.filePath).toLowerCase().slice(1); - const mime = IMAGE_MIME_TYPES[ext]; - if (!mime || !ALLOWED_IMAGE_MIME_TYPES.has(mime)) return null; - - const buffer = await fsPromises.readFile(input.filePath); - return `data:${mime};base64,${buffer.toString("base64")}`; - } catch { - return null; - } - }), - - /** - * Save pasted text to a temp file - * Returns the file path for use as a file attachment - */ - saveClipboardText: publicProcedure - .input( - z.object({ - text: z.string(), - originalName: z.string().optional(), - }), - ) - .mutation(async ({ input }) => { - const displayName = path.basename( - input.originalName ?? "pasted-text.txt", - ); - const filePath = await createClipboardTempFilePath(displayName); - - await fsPromises.writeFile(filePath, input.text, "utf-8"); - - return { path: filePath, name: displayName }; - }), - - /** - * Save clipboard image data to a temp file - * Returns the file path for use as a file attachment - */ - saveClipboardImage: publicProcedure - .input( - z.object({ - base64Data: z.string(), - mimeType: z.string(), - originalName: z.string().optional(), - }), - ) - .mutation(async ({ input }) => { - const raw = new Uint8Array(Buffer.from(input.base64Data, "base64")); - const isGenericName = - !input.originalName || - input.originalName === "image.png" || - input.originalName === "image.jpeg" || - input.originalName === "image.jpg"; - const displayName = isGenericName - ? "clipboard.png" - : (input.originalName ?? "clipboard.png"); - - return downscaleAndPersist(raw, input.mimeType, displayName); - }), - - downscaleImageFile: publicProcedure - .input(z.object({ filePath: z.string().min(1) })) - .mutation(async ({ input }) => { - const ext = path.extname(input.filePath).toLowerCase().slice(1); - if (!isRasterImageFile(input.filePath)) { - throw new Error(`Unsupported image type: .${ext}`); - } - - const stat = await fsPromises.stat(input.filePath); - if (stat.size > MAX_FILE_SIZE) { - throw new Error( - `Image too large (${Math.round(stat.size / 1024 / 1024)}MB). Max is 50MB.`, - ); - } - - const raw = new Uint8Array(await fsPromises.readFile(input.filePath)); - const inputMime = IMAGE_MIME_TYPES[ext]; - - return downscaleAndPersist(raw, inputMime, path.basename(input.filePath)); - }), - - /** - * Save arbitrary file bytes to a temp file - * Returns the file path for use as a file attachment - */ - saveClipboardFile: publicProcedure - .input( - z.object({ - base64Data: z.string(), - originalName: z.string().optional(), - }), - ) - .mutation(async ({ input }) => { - const displayName = path.basename(input.originalName ?? "attachment"); - const filePath = await createClipboardTempFilePath(displayName); - - await fsPromises.writeFile( - filePath, - Buffer.from(input.base64Data, "base64"), - ); - - return { path: filePath, name: displayName }; - }), -}); diff --git a/apps/code/src/main/trpc/routers/process-tracking.ts b/apps/code/src/main/trpc/routers/process-tracking.ts deleted file mode 100644 index f0097fd1f1..0000000000 --- a/apps/code/src/main/trpc/routers/process-tracking.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { ProcessTrackingService } from "../../services/process-tracking/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.ProcessTrackingService); - -const processCategory = z.enum(["shell", "agent", "child"]); - -export const processTrackingRouter = router({ - getSnapshot: publicProcedure - .input( - z - .object({ - includeDiscovered: z.boolean().optional(), - }) - .optional(), - ) - .query(({ input }) => - getService().getSnapshot(input?.includeDiscovered ?? false), - ), - - list: publicProcedure.query(() => getService().getAll()), - - kill: publicProcedure - .input(z.object({ pid: z.number() })) - .mutation(({ input }) => { - getService().kill(input.pid); - }), - - killByCategory: publicProcedure - .input(z.object({ category: processCategory })) - .mutation(({ input }) => { - getService().killByCategory(input.category); - }), - - killByTaskId: publicProcedure - .input(z.object({ taskId: z.string() })) - .mutation(({ input }) => { - getService().killByTaskId(input.taskId); - }), - - listByTaskId: publicProcedure - .input(z.object({ taskId: z.string() })) - .query(({ input }) => getService().getByTaskId(input.taskId)), - - killAll: publicProcedure.mutation(() => { - getService().killAll(); - }), -}); diff --git a/apps/code/src/main/trpc/routers/provisioning.ts b/apps/code/src/main/trpc/routers/provisioning.ts deleted file mode 100644 index 6972a0c019..0000000000 --- a/apps/code/src/main/trpc/routers/provisioning.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - ProvisioningEvent, - type ProvisioningService, -} from "../../services/provisioning/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.ProvisioningService); - -export const provisioningRouter = router({ - onOutput: publicProcedure.subscription(async function* (opts) { - const service = getService(); - for await (const data of service.toIterable(ProvisioningEvent.Output, { - signal: opts.signal, - })) { - yield data; - } - }), -}); diff --git a/apps/code/src/main/trpc/routers/secure-store.ts b/apps/code/src/main/trpc/routers/secure-store.ts deleted file mode 100644 index 2d2477808b..0000000000 --- a/apps/code/src/main/trpc/routers/secure-store.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { decrypt, encrypt } from "@main/utils/encryption"; -import { rendererStore } from "@main/utils/store"; -import { z } from "zod"; -import { logger } from "../../utils/logger"; -import { publicProcedure, router } from "../trpc"; - -const log = logger.scope("secureStoreRouter"); - -export const secureStoreRouter = router({ - /** - * Get an encrypted item from the store - */ - getItem: publicProcedure - .input(z.object({ key: z.string() })) - .query(async ({ input }) => { - try { - if (!rendererStore.has(input.key)) return null; - const encrypted = rendererStore.get(input.key) as string; - return decrypt(encrypted); - } catch (error) { - log.error("Failed to get item:", error); - return null; - } - }), - - /** - * Set an encrypted item in the store - */ - setItem: publicProcedure - .input(z.object({ key: z.string(), value: z.string() })) - .query(async ({ input }) => { - try { - rendererStore.set(input.key, encrypt(input.value)); - } catch (error) { - log.error("Failed to set item:", error); - } - }), - - /** - * Remove an item from the store - */ - removeItem: publicProcedure - .input(z.object({ key: z.string() })) - .query(async ({ input }) => { - try { - rendererStore.delete(input.key); - } catch (error) { - log.error("Failed to remove item:", error); - } - }), - - /** - * Clear all items from the store - */ - clear: publicProcedure.query(async () => { - try { - rendererStore.clear(); - } catch (error) { - log.error("Failed to clear store:", error); - } - }), -}); diff --git a/apps/code/src/main/trpc/routers/shell.ts b/apps/code/src/main/trpc/routers/shell.ts deleted file mode 100644 index d1000484af..0000000000 --- a/apps/code/src/main/trpc/routers/shell.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - createCommandInput, - createInput, - executeInput, - executeOutput, - resizeInput, - ShellEvent, - type ShellEvents, - sessionIdInput, - writeInput, -} from "../../services/shell/schemas"; -import type { ShellService } from "../../services/shell/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => container.get(MAIN_TOKENS.ShellService); - -function subscribeFiltered(event: K) { - return publicProcedure - .input(sessionIdInput) - .subscription(async function* (opts) { - const service = getService(); - const targetSessionId = opts.input.sessionId; - const iterable = service.toIterable(event, { signal: opts.signal }); - - for await (const data of iterable) { - if (data.sessionId === targetSessionId) { - yield data; - } - } - }); -} - -export const shellRouter = router({ - create: publicProcedure - .input(createInput) - .mutation(({ input }) => - getService().create(input.sessionId, input.cwd, input.taskId), - ), - - createCommand: publicProcedure - .input(createCommandInput) - .mutation(({ input }) => - getService().createCommandSession({ - sessionId: input.sessionId, - command: input.command, - cwd: input.cwd, - taskId: input.taskId, - }), - ), - - write: publicProcedure - .input(writeInput) - .mutation(({ input }) => getService().write(input.sessionId, input.data)), - - resize: publicProcedure - .input(resizeInput) - .mutation(({ input }) => - getService().resize(input.sessionId, input.cols, input.rows), - ), - - check: publicProcedure - .input(sessionIdInput) - .query(({ input }) => getService().check(input.sessionId)), - - destroy: publicProcedure - .input(sessionIdInput) - .mutation(({ input }) => getService().destroy(input.sessionId)), - - getProcess: publicProcedure - .input(sessionIdInput) - .query(({ input }) => getService().getProcess(input.sessionId)), - - execute: publicProcedure - .input(executeInput) - .output(executeOutput) - .mutation(({ input }) => getService().execute(input.cwd, input.command)), - - onData: subscribeFiltered(ShellEvent.Data), - onExit: subscribeFiltered(ShellEvent.Exit), -}); diff --git a/apps/code/src/main/trpc/routers/skills.ts b/apps/code/src/main/trpc/routers/skills.ts deleted file mode 100644 index 2825082f5a..0000000000 --- a/apps/code/src/main/trpc/routers/skills.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as os from "node:os"; -import * as path from "node:path"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - getMarketplaceInstallPaths, - readSkillMetadataFromDir, -} from "../../services/agent/discover-plugins"; -import { listSkillsOutput } from "../../services/agent/skill-schemas"; -import type { FoldersService } from "../../services/folders/service"; -import type { PosthogPluginService } from "../../services/posthog-plugin/service"; -import { publicProcedure, router } from "../trpc"; - -const getPluginService = () => - container.get(MAIN_TOKENS.PosthogPluginService); - -const getFoldersService = () => - container.get(MAIN_TOKENS.FoldersService); - -export const skillsRouter = router({ - list: publicProcedure.output(listSkillsOutput).query(async () => { - const pluginPath = getPluginService().getPluginPath(); - const folders = await getFoldersService().getFolders(); - const marketplacePaths = await getMarketplaceInstallPaths(); - - const results = await Promise.all([ - readSkillMetadataFromDir(path.join(pluginPath, "skills"), "bundled"), - readSkillMetadataFromDir( - path.join(os.homedir(), ".claude", "skills"), - "user", - ), - ...folders.map((f) => - readSkillMetadataFromDir( - path.join(f.path, ".claude", "skills"), - "repo", - f.name, - ), - ), - ...marketplacePaths.map((p) => - readSkillMetadataFromDir(path.join(p, "skills"), "marketplace"), - ), - ]); - - return results.flat(); - }), -}); diff --git a/apps/code/src/main/trpc/routers/slack-integration.ts b/apps/code/src/main/trpc/routers/slack-integration.ts deleted file mode 100644 index 2c15097dc9..0000000000 --- a/apps/code/src/main/trpc/routers/slack-integration.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - startSlackFlowInput, - startSlackFlowOutput, -} from "../../services/slack-integration/schemas"; -import { - type SlackFlowTimedOut, - type SlackIntegrationCallback, - SlackIntegrationEvent, - type SlackIntegrationService, -} from "../../services/slack-integration/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.SlackIntegrationService); - -export const slackIntegrationRouter = router({ - startFlow: publicProcedure - .input(startSlackFlowInput) - .output(startSlackFlowOutput) - .mutation(({ input }) => - getService().startFlow(input.region, input.projectId), - ), - - /** - * Subscribe to Slack integration deep link callbacks emitted after the user - * completes (or errors out of) the Slack OAuth flow on PostHog Cloud. - */ - onCallback: publicProcedure.subscription(async function* (opts) { - const service = getService(); - const iterable = service.toIterable(SlackIntegrationEvent.Callback, { - signal: opts.signal, - }); - for await (const data of iterable) { - yield data; - } - }), - - /** - * Subscribe to flow timeout events (5 minutes with no deep link callback). - */ - onFlowTimedOut: publicProcedure.subscription(async function* (opts) { - const service = getService(); - const iterable = service.toIterable(SlackIntegrationEvent.FlowTimedOut, { - signal: opts.signal, - }); - for await (const data of iterable) { - yield data; - } - }), - - /** - * Get any integration callback that arrived before the renderer subscribed. - */ - consumePendingCallback: publicProcedure.query( - (): SlackIntegrationCallback | null => - getService().consumePendingCallback(), - ), -}); - -export type { SlackIntegrationCallback, SlackFlowTimedOut }; diff --git a/apps/code/src/main/trpc/routers/sleep.ts b/apps/code/src/main/trpc/routers/sleep.ts deleted file mode 100644 index cc04d33546..0000000000 --- a/apps/code/src/main/trpc/routers/sleep.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { SleepService } from "../../services/sleep/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => container.get(MAIN_TOKENS.SleepService); - -export const sleepRouter = router({ - getEnabled: publicProcedure - .output(z.boolean()) - .query(() => getService().getEnabled()), - - setEnabled: publicProcedure - .input(z.object({ enabled: z.boolean() })) - .mutation(({ input }) => { - getService().setEnabled(input.enabled); - }), -}); diff --git a/apps/code/src/main/trpc/routers/suspension.ts b/apps/code/src/main/trpc/routers/suspension.ts deleted file mode 100644 index 77e2edd002..0000000000 --- a/apps/code/src/main/trpc/routers/suspension.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { container } from "../../di/container.js"; -import { MAIN_TOKENS } from "../../di/tokens.js"; -import { - listSuspendedTasksOutput, - restoreTaskInput, - restoreTaskOutput, - suspendedTaskIdsOutput, - suspendTaskInput, - suspendTaskOutput, - suspensionSettingsOutput, - updateSuspensionSettingsInput, -} from "../../services/suspension/schemas.js"; -import type { SuspensionService } from "../../services/suspension/service.js"; -import { publicProcedure, router } from "../trpc.js"; - -const getService = () => - container.get(MAIN_TOKENS.SuspensionService); - -export const suspensionRouter = router({ - suspend: publicProcedure - .input(suspendTaskInput) - .output(suspendTaskOutput) - .mutation(({ input }) => - getService().suspendTask(input.taskId, input.reason), - ), - - restore: publicProcedure - .input(restoreTaskInput) - .output(restoreTaskOutput) - .mutation(({ input }) => - getService().restoreTask(input.taskId, input.recreateBranch), - ), - - list: publicProcedure - .output(listSuspendedTasksOutput) - .query(() => getService().getSuspendedTasks()), - - suspendedTaskIds: publicProcedure - .output(suspendedTaskIdsOutput) - .query(() => getService().getSuspendedTaskIds()), - - settings: publicProcedure - .output(suspensionSettingsOutput) - .query(() => getService().getSettings()), - - updateSettings: publicProcedure - .input(updateSuspensionSettingsInput) - .mutation(({ input }) => getService().updateSettings(input)), -}); diff --git a/apps/code/src/main/trpc/routers/updates.ts b/apps/code/src/main/trpc/routers/updates.ts deleted file mode 100644 index 6931e3e214..0000000000 --- a/apps/code/src/main/trpc/routers/updates.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - checkForUpdatesOutput, - installUpdateOutput, - isEnabledOutput, - UpdatesEvent, - type UpdatesEvents, - updatesStatusOutput, -} from "../../services/updates/schemas"; -import type { UpdatesService } from "../../services/updates/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.UpdatesService); - -function subscribe(event: K) { - return publicProcedure.subscription(async function* (opts) { - const service = getService(); - const iterable = service.toIterable(event, { signal: opts.signal }); - for await (const data of iterable) { - yield data; - } - }); -} - -export const updatesRouter = router({ - isEnabled: publicProcedure.output(isEnabledOutput).query(() => { - const service = getService(); - return { enabled: service.isEnabled }; - }), - - check: publicProcedure.output(checkForUpdatesOutput).mutation(() => { - const service = getService(); - return service.checkForUpdates(); - }), - - getStatus: publicProcedure.output(updatesStatusOutput).query(() => { - const service = getService(); - return service.getStatus(); - }), - - install: publicProcedure.output(installUpdateOutput).mutation(() => { - const service = getService(); - return service.installUpdate(); - }), - - onReady: subscribe(UpdatesEvent.Ready), - onStatus: subscribe(UpdatesEvent.Status), - onCheckFromMenu: subscribe(UpdatesEvent.CheckFromMenu), -}); diff --git a/apps/code/src/main/trpc/routers/workspace.ts b/apps/code/src/main/trpc/routers/workspace.ts deleted file mode 100644 index 8e84c79534..0000000000 --- a/apps/code/src/main/trpc/routers/workspace.ts +++ /dev/null @@ -1,231 +0,0 @@ -import type { WorkspaceRepository } from "../../db/repositories/workspace-repository"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { GitService } from "../../services/git/service"; -import { - createWorkspaceInput, - createWorkspaceOutput, - deleteWorkspaceInput, - deleteWorktreeInput, - getAllTaskTimestampsOutput, - getAllWorkspacesOutput, - getLocalTasksInput, - getLocalTasksOutput, - getPinnedTaskIdsOutput, - getTaskTimestampsInput, - getTaskTimestampsOutput, - getWorkspaceInfoInput, - getWorkspaceInfoOutput, - getWorktreeFileUsageInput, - getWorktreeFileUsageOutput, - getWorktreeSizeInput, - getWorktreeSizeOutput, - getWorktreeTasksInput, - getWorktreeTasksOutput, - linkBranchInput, - listGitWorktreesInput, - listGitWorktreesOutput, - markActivityInput, - markViewedInput, - reconcileCloudWorkspacesInput, - reconcileCloudWorkspacesOutput, - taskPrStatusInput, - taskPrStatusOutput, - togglePinInput, - togglePinOutput, - unlinkBranchInput, - verifyWorkspaceInput, - verifyWorkspaceOutput, -} from "../../services/workspace/schemas"; -import { - type WorkspaceService, - WorkspaceServiceEvent, - type WorkspaceServiceEvents, -} from "../../services/workspace/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get(MAIN_TOKENS.WorkspaceService); - -const getGitService = () => container.get(MAIN_TOKENS.GitService); - -const getWorkspaceRepo = () => - container.get(MAIN_TOKENS.WorkspaceRepository); - -function subscribe(event: K) { - return publicProcedure.subscription(async function* (opts) { - const service = getService(); - const iterable = service.toIterable(event, { signal: opts.signal }); - for await (const data of iterable) { - yield data; - } - }); -} - -export const workspaceRouter = router({ - create: publicProcedure - .input(createWorkspaceInput) - .output(createWorkspaceOutput) - .mutation(({ input }) => getService().createWorkspace(input)), - - reconcileCloudWorkspaces: publicProcedure - .input(reconcileCloudWorkspacesInput) - .output(reconcileCloudWorkspacesOutput) - .mutation(({ input }) => - getService().reconcileCloudWorkspaces(input.taskIds), - ), - - delete: publicProcedure - .input(deleteWorkspaceInput) - .mutation(({ input }) => - getService().deleteWorkspace(input.taskId, input.mainRepoPath), - ), - - verify: publicProcedure - .input(verifyWorkspaceInput) - .output(verifyWorkspaceOutput) - .query(({ input }) => getService().verifyWorkspaceExists(input.taskId)), - - getInfo: publicProcedure - .input(getWorkspaceInfoInput) - .output(getWorkspaceInfoOutput) - .query(({ input }) => getService().getWorkspaceInfo(input.taskId)), - - getAll: publicProcedure - .output(getAllWorkspacesOutput) - .query(() => getService().getAllWorkspaces()), - - getLocalTasks: publicProcedure - .input(getLocalTasksInput) - .output(getLocalTasksOutput) - .query(({ input }) => - getService().getLocalTasksForFolder(input.mainRepoPath), - ), - - getWorktreeTasks: publicProcedure - .input(getWorktreeTasksInput) - .output(getWorktreeTasksOutput) - .query(({ input }) => getService().getWorktreeTasks(input.worktreePath)), - - listGitWorktrees: publicProcedure - .input(listGitWorktreesInput) - .output(listGitWorktreesOutput) - .query(({ input }) => getService().listGitWorktrees(input.mainRepoPath)), - - getWorktreeSize: publicProcedure - .input(getWorktreeSizeInput) - .output(getWorktreeSizeOutput) - .query(({ input }) => getService().getWorktreeSize(input.worktreePath)), - - getWorktreeFileUsage: publicProcedure - .input(getWorktreeFileUsageInput) - .output(getWorktreeFileUsageOutput) - .query(({ input }) => - getService().getWorktreeFileUsage(input.mainRepoPath), - ), - - deleteWorktree: publicProcedure - .input(deleteWorktreeInput) - .mutation(({ input }) => - getService().deleteWorktree(input.mainRepoPath, input.worktreePath), - ), - - togglePin: publicProcedure - .input(togglePinInput) - .output(togglePinOutput) - .mutation(({ input }) => { - const repo = getWorkspaceRepo(); - const workspace = repo.findByTaskId(input.taskId); - if (!workspace) { - return { isPinned: false, pinnedAt: null }; - } - const newPinnedAt = workspace.pinnedAt ? null : new Date().toISOString(); - repo.updatePinnedAt(input.taskId, newPinnedAt); - return { isPinned: newPinnedAt !== null, pinnedAt: newPinnedAt }; - }), - - markViewed: publicProcedure.input(markViewedInput).mutation(({ input }) => { - const repo = getWorkspaceRepo(); - repo.updateLastViewedAt(input.taskId, new Date().toISOString()); - }), - - markActivity: publicProcedure - .input(markActivityInput) - .mutation(({ input }) => { - const repo = getWorkspaceRepo(); - const workspace = repo.findByTaskId(input.taskId); - const lastViewedAt = workspace?.lastViewedAt - ? new Date(workspace.lastViewedAt).getTime() - : 0; - const now = Date.now(); - const activityTime = Math.max(now, lastViewedAt + 1); - repo.updateLastActivityAt( - input.taskId, - new Date(activityTime).toISOString(), - ); - }), - - getPinnedTaskIds: publicProcedure.output(getPinnedTaskIdsOutput).query(() => { - const repo = getWorkspaceRepo(); - return repo.findAllPinned().map((w) => w.taskId); - }), - - getTaskTimestamps: publicProcedure - .input(getTaskTimestampsInput) - .output(getTaskTimestampsOutput) - .query(({ input }) => { - const repo = getWorkspaceRepo(); - const workspace = repo.findByTaskId(input.taskId); - return { - pinnedAt: workspace?.pinnedAt ?? null, - lastViewedAt: workspace?.lastViewedAt ?? null, - lastActivityAt: workspace?.lastActivityAt ?? null, - }; - }), - - getAllTaskTimestamps: publicProcedure - .output(getAllTaskTimestampsOutput) - .query(() => { - const repo = getWorkspaceRepo(); - const workspaces = repo.findAll(); - const result: Record< - string, - { - pinnedAt: string | null; - lastViewedAt: string | null; - lastActivityAt: string | null; - } - > = {}; - for (const w of workspaces) { - result[w.taskId] = { - pinnedAt: w.pinnedAt, - lastViewedAt: w.lastViewedAt, - lastActivityAt: w.lastActivityAt, - }; - } - return result; - }), - - linkBranch: publicProcedure - .input(linkBranchInput) - .mutation(({ input }) => - getService().linkBranch(input.taskId, input.branchName, "user"), - ), - - unlinkBranch: publicProcedure - .input(unlinkBranchInput) - .mutation(({ input }) => getService().unlinkBranch(input.taskId, "user")), - - getTaskPrStatus: publicProcedure - .input(taskPrStatusInput) - .output(taskPrStatusOutput) - .query(({ input }) => - getGitService().getTaskPrStatus(input.taskId, input.cloudPrUrl), - ), - - onError: subscribe(WorkspaceServiceEvent.Error), - onWarning: subscribe(WorkspaceServiceEvent.Warning), - onPromoted: subscribe(WorkspaceServiceEvent.Promoted), - onBranchChanged: subscribe(WorkspaceServiceEvent.BranchChanged), - onLinkedBranchChanged: subscribe(WorkspaceServiceEvent.LinkedBranchChanged), -}); diff --git a/apps/code/src/main/trpc/trpc.ts b/apps/code/src/main/trpc/trpc.ts index 32992a3779..1ed987f5ae 100644 --- a/apps/code/src/main/trpc/trpc.ts +++ b/apps/code/src/main/trpc/trpc.ts @@ -1,10 +1,10 @@ -import { initTRPC } from "@trpc/server"; +import { + publicProcedure as baseProcedure, + router as baseRouter, + middleware, +} from "@posthog/host-trpc/trpc"; import log from "electron-log/main"; -const trpc = initTRPC.create({ - isServer: true, -}); - const CALL_RATE_WINDOW_MS = 2000; const CALL_RATE_THRESHOLD = 50; @@ -14,7 +14,7 @@ const ipcTimingEnabled = process.env.IPC_TIMINGS === "true"; const ipcTimingBootMs = 15_000; const bootTime = Date.now(); -const callRateMonitor = trpc.middleware(async ({ path, next, type }) => { +const callRateMonitor = middleware(async ({ path, next, type }) => { const shouldTime = ipcTimingEnabled && Date.now() - bootTime < ipcTimingBootMs; const t = shouldTime ? performance.now() : 0; @@ -55,6 +55,6 @@ const callRateMonitor = trpc.middleware(async ({ path, next, type }) => { return result; }); -export const router = trpc.router; -export const publicProcedure = trpc.procedure.use(callRateMonitor); -export const middleware = trpc.middleware; +export const router = baseRouter; +export const publicProcedure = baseProcedure.use(callRateMonitor); +export { middleware }; diff --git a/apps/code/src/main/utils/async.ts b/apps/code/src/main/utils/async.ts index 6170bc7fcd..cec57e898b 100644 --- a/apps/code/src/main/utils/async.ts +++ b/apps/code/src/main/utils/async.ts @@ -1,30 +1,8 @@ import { logger } from "./logger"; -const log = logger.scope("async-utils"); +export { withTimeout } from "@posthog/shared"; -/** - * Races an operation against a timeout. - * Returns success with the value if the operation completes in time, - * or timeout if the operation takes longer than the specified duration. - */ -export async function withTimeout( - operation: Promise, - timeoutMs: number, -): Promise<{ result: "success"; value: T } | { result: "timeout" }> { - let timeoutHandle!: ReturnType; - const timeoutPromise = new Promise<{ result: "timeout" }>((resolve) => { - timeoutHandle = setTimeout(() => resolve({ result: "timeout" }), timeoutMs); - }); - const operationPromise = operation.then((value) => ({ - result: "success" as const, - value, - })); - try { - return await Promise.race([operationPromise, timeoutPromise]); - } finally { - clearTimeout(timeoutHandle); - } -} +const log = logger.scope("async-utils"); /** * Races a subscribe-style promise against a timeout. If the timeout wins, diff --git a/apps/code/src/main/utils/logger.ts b/apps/code/src/main/utils/logger.ts index 203f1e2278..eb5d2435de 100644 --- a/apps/code/src/main/utils/logger.ts +++ b/apps/code/src/main/utils/logger.ts @@ -61,8 +61,6 @@ export const logger = log; export type Logger = typeof logger; export type ScopedLogger = ReturnType; -export { shutdownOtelTransport } from "@main/utils/otel-log-transport"; - export function getLogFilePath(): string { return join(LOG_DIR, LOG_FILE); } diff --git a/apps/code/src/main/utils/typed-event-emitter.ts b/apps/code/src/main/utils/typed-event-emitter.ts deleted file mode 100644 index 165d33c417..0000000000 --- a/apps/code/src/main/utils/typed-event-emitter.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { EventEmitter, on } from "node:events"; - -export class TypedEventEmitter extends EventEmitter { - constructor() { - super(); - this.setMaxListeners(50); - } - - emit( - event: K, - payload: TEvents[K], - ): boolean { - return super.emit(event, payload); - } - - on( - event: K, - listener: (payload: TEvents[K]) => void, - ): this { - return super.on(event, listener); - } - - off( - event: K, - listener: (payload: TEvents[K]) => void, - ): this { - return super.off(event, listener); - } - - async *toIterable( - event: K, - opts?: { signal?: AbortSignal }, - ): AsyncIterable { - for await (const [payload] of on(this, event, opts)) { - yield payload as TEvents[K]; - } - } -} diff --git a/apps/code/src/main/utils/worktree-helpers.ts b/apps/code/src/main/utils/worktree-helpers.ts deleted file mode 100644 index 15b6036ef5..0000000000 --- a/apps/code/src/main/utils/worktree-helpers.ts +++ /dev/null @@ -1,18 +0,0 @@ -import path from "node:path"; -import { getWorktreeLocation } from "../services/settingsStore"; - -function isLegacyWorktreeName(name: string): boolean { - return !/^\d+$/.test(name); -} - -export function deriveWorktreePath( - folderPath: string, - worktreeName: string, -): string { - const worktreeBasePath = getWorktreeLocation(); - const repoName = path.basename(folderPath); - if (isLegacyWorktreeName(worktreeName)) { - return path.join(worktreeBasePath, repoName, worktreeName); - } - return path.join(worktreeBasePath, worktreeName, repoName); -} diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 10aa4699d0..73d7d3e054 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createIPCHandler } from "@posthog/electron-trpc/main"; +import { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; import { app, BrowserWindow, @@ -10,7 +11,6 @@ import { shell, } from "electron"; import { container } from "./di/container"; -import { MAIN_TOKENS } from "./di/tokens"; import { buildApplicationMenu } from "./menu"; import type { ElectronMainWindow } from "./platform-adapters/electron-main-window"; import { trpcRouter } from "./trpc/router"; @@ -71,10 +71,6 @@ export function saveWindowState(window: BrowserWindow): void { let mainWindow: BrowserWindow | null = null; -export function getMainWindow(): BrowserWindow | null { - return mainWindow; -} - export function focusMainWindow(reason: string): void { if (mainWindow) { log.info("focusMainWindow called", { @@ -240,12 +236,13 @@ export function createWindow(): void { mainWindow.on("close", () => mainWindow && saveWindowState(mainWindow)); container - .get(MAIN_TOKENS.MainWindow) + .get(MAIN_WINDOW_SERVICE) .setMainWindowGetter(() => mainWindow); createIPCHandler({ router: trpcRouter, windows: [mainWindow], + createContext: async () => ({ container }), }); setupExternalLinkHandlers(mainWindow); diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx deleted file mode 100644 index a5748db25b..0000000000 --- a/apps/code/src/renderer/App.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import { ErrorBoundary } from "@components/ErrorBoundary"; -import { LoginTransition } from "@components/LoginTransition"; -import { MainLayout } from "@components/MainLayout"; -import { ScopeReauthPrompt } from "@components/ScopeReauthPrompt"; -import { AiApprovalScreen } from "@features/ai-approval/components/AiApprovalScreen"; -import { AuthScreen } from "@features/auth/components/AuthScreen"; -import { InviteCodeScreen } from "@features/auth/components/InviteCodeScreen"; -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { - useAuthStateValue, - useCurrentUser, -} from "@features/auth/hooks/authQueries"; -import { useAuthSession } from "@features/auth/hooks/useAuthSession"; -import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole"; -import { registerBillingSubscriptions } from "@features/billing/subscriptions"; -import { AddDirectoryDialog } from "@features/folder-picker/components/AddDirectoryDialog"; -import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow"; -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { Flex, Spinner, Text } from "@radix-ui/themes"; -import { initializeConnectivityToast } from "@renderer/features/connectivity/connectivityToast"; -import { initializeConnectivityStore } from "@renderer/stores/connectivityStore"; -import { useFocusStore } from "@renderer/stores/focusStore"; -import { useThemeStore } from "@renderer/stores/themeStore"; -import { initializeUpdateStore } from "@renderer/stores/updateStore"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { isNotAuthenticatedError } from "@shared/errors"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useQueryClient } from "@tanstack/react-query"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { initializePostHog, registerAppVersion, track } from "@utils/analytics"; -import { logger } from "@utils/logger"; -import { toast } from "@utils/toast"; -import { AnimatePresence, motion } from "framer-motion"; -import { useEffect, useRef, useState } from "react"; -import { Toaster } from "sonner"; - -const log = logger.scope("app"); - -function App() { - const trpcReact = useTRPC(); - const { isBootstrapped } = useAuthSession(); - const authState = useAuthStateValue((state) => state); - const hasCompletedOnboarding = useOnboardingStore( - (state) => state.hasCompletedOnboarding, - ); - const isAuthenticated = authState.status === "authenticated"; - const hasCodeAccess = authState.hasCodeAccess; - const isDarkMode = useThemeStore((state) => state.isDarkMode); - const [showTransition, setShowTransition] = useState(false); - const wasInMainApp = useRef(isAuthenticated && hasCompletedOnboarding); - - // Initialize PostHog analytics and register the app version super property. - useEffect(() => { - initializePostHog(); - trpcClient.os.getAppVersion - .query() - .then(registerAppVersion) - .catch((error) => { - log.warn("Failed to register app version super property", { error }); - }); - }, []); - - // Initialize connectivity monitoring - useEffect(() => { - const disposeStore = initializeConnectivityStore(); - const disposeToast = initializeConnectivityToast(); - return () => { - disposeToast(); - disposeStore(); - }; - }, []); - - useEffect(() => { - if (!isAuthenticated) return; - return registerBillingSubscriptions(); - }, [isAuthenticated]); - - // Initialize update store - useEffect(() => { - return initializeUpdateStore(); - }, []); - - // Dev-only inbox demo command for local QA from the renderer console. - useEffect(() => { - if (import.meta.env.PROD) { - return; - } - - void import("@features/inbox/devtools/inboxDemoConsole").then( - ({ registerInboxDemoConsoleCommand }) => { - registerInboxDemoConsoleCommand(); - }, - ); - }, []); - - // Global workspace error listener for toasts - useEffect(() => { - const subscription = trpcClient.workspace.onError.subscribe(undefined, { - onData: (data) => { - toast.error("Workspace error", { description: data.message }); - }, - }); - return () => subscription.unsubscribe(); - }, []); - - const queryClient = useQueryClient(); - - useSubscription( - trpcReact.workspace.onPromoted.subscriptionOptions(undefined, { - onData: (data) => { - void queryClient.invalidateQueries( - trpcReact.workspace.getAll.pathFilter(), - ); - toast.info( - "Task moved to worktree", - `Task is now working in its own worktree on branch "${data.fromBranch}"`, - ); - }, - }), - ); - - useSubscription( - trpcReact.workspace.onBranchChanged.subscriptionOptions(undefined, { - onData: () => { - void queryClient.invalidateQueries( - trpcReact.workspace.getAll.pathFilter(), - ); - }, - }), - ); - - useSubscription( - trpcReact.workspace.onLinkedBranchChanged.subscriptionOptions(undefined, { - onData: () => { - void queryClient.invalidateQueries( - trpcReact.workspace.getAll.pathFilter(), - ); - }, - }), - ); - - useSubscription( - trpcReact.focus.onBranchRenamed.subscriptionOptions(undefined, { - onData: ({ worktreePath, newBranch }) => { - useFocusStore.getState().updateSessionBranch(worktreePath, newBranch); - void queryClient.invalidateQueries( - trpcReact.workspace.getAll.pathFilter(), - ); - }, - }), - ); - - useSubscription( - trpcReact.agent.onAgentFileActivity.subscriptionOptions(undefined, { - onData: (data) => { - track(ANALYTICS_EVENTS.AGENT_FILE_ACTIVITY, { - task_id: data.taskId, - branch_name: data.branchName, - }); - }, - }), - ); - - // Auto-unfocus when user manually checks out to a different branch - useSubscription( - trpcReact.focus.onForeignBranchCheckout.subscriptionOptions(undefined, { - onData: async ({ focusedBranch, foreignBranch }) => { - log.warn( - `Foreign branch checkout detected: ${focusedBranch} -> ${foreignBranch}. Auto-unfocusing.`, - ); - const result = await useFocusStore.getState().disableFocus(); - if (!result.success && result.error) { - toast.error("Could not unfocus workspace", { - description: result.error, - }); - } - }, - }), - ); - - const needsInviteCode = - isAuthenticated && hasCodeAccess === false && hasCompletedOnboarding; - const isCheckingAccess = - isAuthenticated && hasCodeAccess === null && hasCompletedOnboarding; - - const authenticatedClient = useOptionalAuthenticatedClient(); - const { data: currentUser } = useCurrentUser({ - client: authenticatedClient, - enabled: - isAuthenticated && hasCompletedOnboarding && hasCodeAccess === true, - refetchOnWindowFocus: "always", - }); - const currentOrg = currentUser?.organization; - const needsAiApproval = - isAuthenticated && - hasCompletedOnboarding && - hasCodeAccess === true && - currentOrg != null && - currentOrg.is_ai_data_processing_approved !== true; - const { isAdmin: isOrgAdmin } = useIsOrgAdmin(); - const isAdmin = isOrgAdmin === true; - - // Handle transition into main app — only show the dark overlay if dark mode is active - useEffect(() => { - const isInMainApp = isAuthenticated && hasCompletedOnboarding; - if (!wasInMainApp.current && isInMainApp && isDarkMode) { - setShowTransition(true); - } - if (!isAuthenticated) { - setShowTransition(false); - } - wasInMainApp.current = isInMainApp; - }, [isAuthenticated, hasCompletedOnboarding, isDarkMode]); - - const wasShowingAiGateRef = useRef(false); - useEffect(() => { - if (wasShowingAiGateRef.current && !needsAiApproval && currentOrg != null) { - track(ANALYTICS_EVENTS.AI_CONSENT_APPROVED); - } - wasShowingAiGateRef.current = needsAiApproval; - }, [needsAiApproval, currentOrg]); - - const handleTransitionComplete = () => { - setShowTransition(false); - }; - - if (!isBootstrapped) { - return ( - - - - Loading... - - - ); - } - - // Rendering: onboarding (includes auth + invite code gate) → main app - const renderContent = () => { - if (!hasCompletedOnboarding) { - return ( - - - - ); - } - - if (!isAuthenticated) { - return ( - - - - ); - } - - if (isCheckingAccess) { - return ( - - - - - Checking access... - - - - ); - } - - if (needsInviteCode) { - return ( - - - - ); - } - - if (needsAiApproval) { - return ( - - - - ); - } - - return ( - - - - ); - }; - - const content = renderContent(); - - return ( - - {isAuthenticated ? ( - {content} - ) : ( - content - )} - - - - - - ); -} - -export default App; diff --git a/apps/code/src/renderer/assets/fonts/Halfre.otf b/apps/code/src/renderer/assets/fonts/Halfre.otf deleted file mode 100644 index 5f5c8e62bb61608e8660c287487f0a51a48084ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20168 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 diff --git a/apps/code/src/renderer/assets/images/code.svg b/apps/code/src/renderer/assets/images/code.svg deleted file mode 100644 index 26f99a6c31..0000000000 --- a/apps/code/src/renderer/assets/images/code.svg +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/code/src/renderer/assets/images/hedgehogs/clickthat-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/clickthat-hog.png deleted file mode 100644 index d07a361870adf6a4539953c08ae423d5989b2348..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131628 zcmeFYgLhvY&#uWoup#hwr#UwvtxJcq~llj{=WPEiTBnRm5i#4 z)H!GEwHM}`J6uUY3K@X_0SpWbSw>n?1q=*A^Pd+S4Cs>{^Rx}nFL(!OEoU$=M9hC) z;9wb9c%To#omHg7!D^<7PC##BEA_xgWjXKeARUMVQ=o@ZscSJ_RY$| z&M2oo#RLojyi`U~Ow|MYTo3k_uGvy|WB1cm;JYnu1Xu5 z=<${V^dmu8@d%env-14!@5n557{!U6U1NpuNK_Mo{GO9nW_thquL+5HAlBr*6FU}z z1&aw}EIjB^|7UZM=1yF}Kte(O(XU0e+?hf0LlOQwiB*aolwNBwA^&G1Y_JUY<2yGx zBufWD^5y=Wei(?8_Ak}OZES*>L}kQ|7-f7K11-X!Yi)Q^X`amrAlibip1*=xlNc-e3gXyp+Dd1Ug!I{!E2vCz)T`S`TssJ7yz>MHK}M2YSF}zdT7~n3w&epxHPUJZ#1*ED7DO$xWvT zrXGxQkuQbgb6!9H-O->QQc&CrGl`ypz`_9eA=!A`2TYms)-A6yba>dukc;(J0cU6D z?n7gvw`eJ}|89mtFvklX);Nk-VEK8Rp_V8vc4@b7f~A+S+Fc%K?M`eX z5Q`}NpX=cIFkyv8?m|-3wUNQox_FCuY}moWCx{xojpOchNjs*OfyLki!w!3_es{g-bJv(hQpa zd)9YAIw#ikUjfgXrUn!b?e}AmPsioOMcz;h68|?x3sKQut9T3srvShSsyC z?L>?@?{qR(WV_vASKLBMD%6>FKlk&kbg4y*s*6I^qwwUnRl@&r)EvdiDGbSLc`W54 z008A^Z2$A88;Th%HsUd#`=xHBSF!Z+Pm)BL|M@j7bSd1IF%~$s;MKo*u8CgN)Wmtr z;{PmnT%lH(rKhJiZ355I^S6NG%a;L-vTJOT{{K>j*f$IT0*4Q==mg6rK4*M;^#Lv;{~pj|65I7n7F}x15-ew4+cZj;-cy?rlMeecrvc&)XPn*|2e}h zaSIN7%~`cf%D+o9f(Dzls_}y~M}2N?Zko`(Yp&hz-K#Xj9t#_rFBFyZGSc)tJt{(8 zG?DS&Rf#4CI|Jkl?c)kojLEs?dFD|~%CU4Gv__{-jTB#8TuhggP}g1&(+NHwB+e6r zd4(Y8c<72rkhXyL)cl(Q+5QBKOyb`D1u)V_auMN?l3sNkW!Pj*q_Vib2#9{@`@g$d z4ft$%9`k*^U-`@ZI4TTiY2oIBxz%@V&yh+w(?^0CFcKayB(2p+)wrTcFj62~0`mKI zm;U?hp!BMsUFgx(Ul`<7uP3db;P!D}D&oql*KuDKpA@hMsyJBUWz*n2sxxsJnP+QsjFPHMUzD(;_5)Fow!d90$LaV(g?irEoL>bGiVV`NqM zW56UU_0^>aZFXh_3r&_=L-Hfe3+Qlab?4|#dbY!#)xWwgv*+w|!e1ySg^`R%cgNXF!&Rh`CrA zKCqw7IFYeh#2ZbaTYGIssFF;K-mSW;w0=26c2}Oc^X+dUwQq}%>bB8Z1-t>gXncEO zT3c7R{;j$&NN1FYG>;;2s5EY0YpyY6X9~VXW(8q`iqyS&`|;$|lG!g z2JX0`x|fuLq?IvIulq%ikjoMf{I^9l)D%%qQ1~VF*(qi>K5sF==$h@_4S)35-Z-(W zsy;_oP5nXs(!}^WRPo?Qq++amG2Ug;D>DI0FW;Cm43dFJu6ejTt>;OuiMGbb=YHiPp^>GZbfZ$?EVKSOu z-QPjdj~y-~_sjO|1p!Ye$uO zjK|7eAjL$Yf$f4hyK{dIUjng`Z^|esnk;CpwhWH82|ZNXV-|J^G+UF9oGdnhG=eqm z#IU34QHQz^dO!>PV}qhhn|~7w(U+a|&qG1s68(VyyhQ5nPQtI}jS?f_o1xwvQQnvB zz!Mgaod8&(0~xoaD>9bj^vlBlK&vQi&XW_e+mF_WaxjdZwFaWDSz)rESwrGD4m4Wl z=JL&TuFGSGduP7RSl4xrq#IZ7(BfAE7$~TXzFrnGinX4x22@&OwgbZ7^y2>nc_D07 zBFl#_6+X5ONn&EMW^iyYUv1|lzs1+D!Opb#6&2&2<8-C>X;nrG*ULKgeDBb&Pgm{= zS-f-km(hnFD+%bQU-(%ez^IPlZxysJsU*_Hg)yc7QkViLQK23A=_PV5F5lQR0?L95qsFhA@s3;8n-D~@JA$;lh`9uK7n~NSGRM7`=<}c@IKB>_W z!<(i+%-74!&TL|0VqfOYpPT$Av6kI1mK|NTYwV>WyP%BH1RNT2NpRo0rn!}u52X*c z?jpS#E$wl{_arAWrHrJ2Y70fl{<6da&mXmZX$OFYR+kEfsm;zxzuiTqk@xy@YE{VG z^GCwx#Kbp!Mh9>5dky*He&c;a*Ev}59f;g;pmDt_?y0i z;jhH8Xbwu&Jm719{QYKfrTCj&Lk#edNd(n!b*U%N0{{)n*SKG>Zhlm{oXy8?u}}3w z>f@zE?l?C&BkL_N^}T>h^!O)ecmJUuFo9E8=%tBQzHn_=f!0&A4hRUipR)H$1l7*~ zF*V}V)m3Scw_VheAXMSF?N+<>Z(qNDy&u9d>!!Pz?v`japR>+ce-*AN0b6YdR!s2Wr!Bj7_yxAMRwRhEz#9=A`)$4eDzh|?E zsHpS$>6eGI^6KiTeXc42^GUqDYuaZ-EWW6i7=?c-f7fw>9P^q@>$S(rA7JaDd&vj90n&Yt0xD6E*WXr zl#)|zBnoTC^LmH(uTL7vIu@y~`K0j}QpZssO0^83qU6(3B0L^N>&K97X0~57;n&<}%8lCVZ7)n5ify><2&c9jn z&s2Y<%e`YVp-D?gZ#2J#L=_r6QgT1hNqkb}m zDnIDB&9!Plt~;k6F2B;++of%)gbiO|mGEvko z;ty*5>}u>}N2mW%ftQ*1b&!eLkL#3D?)xG4+lMf8vZ{rXi*6%b31Z?+evo$FXhf z%m$qE2=a!RH~w~UGNR7zqo03+_jK8)b3vJL4f_pM!^TJ#Qj^A(@U3L{ZrWCUIvb|_ zg!Q}}jk;=&l^kZk8&{q{;fJ#dCDJh~olL*{PRHM;+NY!+a-nOjizDR2P~*q%qx{T) zCdXzHX`_dfBcpEOK@fi?>)SCDhCb?%0rZ6)xhFnI?OjB0Ys}MJ779IK1QW)9w&5oGXch64_6cv z416!T#xzCP9RwjYVG;s$HY?0tr{$$(Jw00{0`l5`BH#Cil>#SdSi<%RGJPq+)2uZ4 zJ<3P_+cBEa!H5RN5DPFqt++4A+M7X4-i0&ZL?uC9rs|f{B&l*M*_<(h&SOba6_`%N z7+)mHS-E1=%A_-LGG&jQ%UF=daS{mtpJ@(ok$u9VfyRRpR*aZMb5`Llr=yG+8JPpG zoQBTS}Qd`G#AlA(D##hy~8wVqN(?3B%)!ID_HW@4Hk95_8A|zTu&P z;h*jKn;5u#gxiEUY_E7TY)T>neTJjZT~3n333Ah=3)28*Y-@X>1#v^n8u}@*EW|%U z)~A8$TeBFgjRjUQQ!e3(IXl?5-890oMjVagO2T?_mwx=+Szkf#tbsKY8e9i|C*bM5k&ND(Z09i3~ zCA0?Dv*o#gU_>havuyXR{P)+VA8?8v;o;#w=UuVTd2N=fMzRImf9m!6`I3l-)~wtx zlD&nRkBco5liY{T?ud?uMHlUkvAHa-3T7lFQ=Vd}3tD+l`(P5a=m0mo#Y#hoD@_Kst`3 z`)a73KaVbKYW!mc2(IFK(j-o^?iF_rOcQ)2$~(wr9m;D8rUE?2YMgw%HgPH(#RX;Yq#h{q*a&pO-7v zcAzb2Xh;B|D}vfBRo*B+R=|vgVufF%6IS!}$-#RX8ykV#U8-sDd4Aa*d*KN)!6Z>q z?Pz@;N166Nc|YIo5}!3We25$IOCls?&G#TwxVQv@N7$9cw^SwT9EFwQ@O;M3;r!f;&@{ zCxmqk5e2S@TD^y^g$$tBq zCeS)!-q&p?CKeDE4om8zBp&opqFpYt{nnXH^A|LxX}3ESt3nPqffu29n&9>PvhFV` zqEybV!a@4}{d)o&Dt6=;yzko$kQ0r}6z(+?gow$-&~R}4kY11P$*DQRj*E{+AK0Bt z*z9zt*?syrn4GL)DE;0P6 zea-q;f$zIrr{;s5ZV;6sN~z~D0Sjmc8RRk~`MqVcXnZxysr147NEY*In;WvVRa-KV zE$4~8WV-Mwi?Be>fs|2^gZ&wqu%kn}sy;P%{HT2;%9hxRa%a>%JMJd;xjo|7@`Czi zAQxT^5kmz5oRNrOBm#0qPBL0NDFL4P^6D1K63Uz3eR-nmyH1s`#^}SB0UO*l1sUPXe zxY3RLZ3#lU0c3oTw%g4xu3Q^7cAC^_M+LoNkFbEE(8HqJ2jB0#Xex5fH)}uSu~QKt zlZd#`Wt2`$++Plwu^lYI)M;bPEw8zUI)65!y`x-p)k3ACdN1zB5^{sbg$%|CYSexm zz$5^$vW2#IPzK)k!;~QzzJIxEPCd9LPUNjUn!vZ0IbA&M>Zv zXUf^6E5StC4Ota7=(U@SG=gQqQx1##og=8QwY^zIahmhvx3Aa;)!>zUdB{P4SI&st zcA_m{+|Mec5U+gB60x>B^O*X1?^LB++3sij9g9kyve3Z{GWHz=S?#XOA}sF&I2mb> zE?5TKTCk7bgvJLmX43el0k&LM^jtuIHyj%{7W$2p)58d%eb{)&(5s^Gvm~6lj0}8p zQxjaeC@pHcGArym2n?p;si>%st$-XH95lmsHWpZ9W`v}gBqA7mT&~ih;8Po)2k(0Y zaRV5M$^iVS56$JXVn1MudaXVa0J*TLDrzK_R8d)3*`+N%CSWjyEs9JBaN)Zl+_sqLj6x!9^#a zCGMk+JwXRFPplA$Lw<|dUFzNv6_6n@t?y{g9$3wv_A(xs+mVlu`&L6CGOI3yxY?EJ4AKG2w03NEi`EVjD zM&uu)!PS@aePntNvq+KqPrP+hcQ-N=G)@SoxJvnG@rR2fWoB(Mg@dK_eq;k@#;$pT zjLv1*J6J)e#$P&6gHKc!0atqa7Z-o*{PKX5!AKnL9F+b_LP@;BrbRw|Vga`PlRL;&6C8KXuPR&?~`mwGOV4 ztOxEqq?7CAU%?Cx_Qa#;>0)#%N7eOtg-)B(!@ov~4tbP_7O2>zMLp?{OWdAuu>@OE z3O-ne)I)8~rC6dhc{QNMJ->DNvgCnkNyREGt9B~}8i+X1SlSUUqnFNLFy7pIsyh)d>g z;a7LdTh-+({-u(RbY5qE~4V#b-P_UM?_|u&# zTX9;ZgiIYXnJRFqSl5oOvXCa#wy62Fawx$_1hWaf+|h}#JDw3hozKg(t;bxEh|lK3 zxZju%_5Q3>A2mKGO6{uFWULYV+J+S;pdN>DOsy%uXX7QC{{SdzGU zU4kS$CfDpP7BmVJ8vIAwrB3>g(U~S`(fpj;pLv-*UAFkxcT@8psdrnYo?5mXu`4o| zAUwEtc(#0k)Qk1l0LLEukvk=tFz@;(>odi<~7A|j9{^6KRKu4NA8{0%ITvM1RO-jwh@8fuWA`d?($ z&iED|H#BsLWzqO#ww&_35b5^Sst^m6H3`6u&`7azjWvpM0ukVix0QOZ3~%bH$|jSG z4qhn`R=Byps3tsAgXIUzWBry3uKGbqK|{qJkBD-=feMF)RSgzpXl-n5P42g2ayBf1ng|1o^TC z3x);UFNdb1dpsozsyOR!1Fx|+7S)`1l~h(HciRo&qxp2B$yZQvK|7*4?gKtE z*@(*U$nY3<3bgzPN&(+)+z_?Ut=+p((qSej7~9g}Y@Vnc_2)tp?VqjxPM=NgjFlsgT_MiSSuNxRGg3Hd9nJ5*jx$tV zF!n&}P zW$fcMv^L)nN%ZNw^x_D>qY*MVLgcS?xK;vS#9CqmE_t=4HLfJPV2aPzEOL{F-0YVE`%X%8 z737u5SbiNCf%>*g*f>efj1f06C;Q%aw{m8|1f`{*sB3A6>-r_ zmmRGvuQ@>AP{^LiUYDg`4l(>eJx|0q)y9_r7}R0b$hUF4yM&x;5PS&f#RUP=?>gT} z_$WOybVq$Oht#8>lcvl3TbB_B-JAi*z6ec~O@PhozTA^Z0vMU7mi$CC&RMD(g z|BU}u^F=aFIX!TgVc5U-$TScpWL8U4>r_BQGjEswz#KW(EPIlF#VnDTF6U?0fMh~z-#TBr1^GFls@E%o!ubctZWn);LQ%l%uJs#vrW z74_T2`izy_q{J^s<7~f_Y7t6dR*{&jbv(1B;IN*J;Rpl6Alk#{60U;Znj=DR9IJU+ z7J^#FC??;GMEoIyLA9Q8vTunWE4FBC|o4`!1wD5a~CEQ)q@ylA6b1w{?0 z1PodTZ2@}>tHHP&@|OISW|WkEIaKH3wf#=>2mz_My#I{-F%m{_g*DGWq&|{kR%fgH zfj~z52OV$h02X1%JM(cqftV^m2_+ah|7#gD8I0CE)|&D40jC#?I5FbB9hub0JsTg^ zOwj}ef(Zof(6}Z=^~SzToC7JEpTsRMlCUp2QH&;njAVkJSz_t)aZ#1&iFetgcsF_9 zS3ryUBBC+}7%G(y7DXpgEsc;JLRgeHg z{O*^D(zKHjrxnu6How5&%bZ`gv+1ym3aR9C$nT}S8Gsnbj>q3Nu&$YefQ2Mrk|)HV zXa1?8Q68V^Q5`9$0~x~RAqTG#V1c|LOX!uMN9!QE{I z@m`nTNfWv;Q=Za@HKIJbnBdU2{)xj{E2~1DmCJ&T6t{9HPKowv3M9AvvHg=hy5RZL zx=mxpT^FWElAyYB$s;wZ}0m4=k$Sz}}{N6Q#Xqx`A*w@(oK`KglN z8j^FdzT?V+K-nAN_l1sX9-`^G$C?^aUg8#wQQAN@6Z%+3(A40#n1@|OfqF;7EzF3% z{t6lE?NQ{-XsO~li(wAZD%^v#6ES`tce!%XwyY)~g@MXa=*YwoJSP`d@@4mvek=K@ zs1XW!!5oJzY0t|k!y2==TZybdt3fh$UHGql+5z953Jh4xxT@Hm$SUKpvC7J5`qqvV z6!p}GU?n?xHl!gj7;{`xJedP|%{=yM<{Hk?7^_aTDZ8X8qXG1)E*H%ty1w0t4vCk< zL5;Lnq^E}|dFgQi!OgjPUg?A{7F(7$DFJuh>@1MY2nim>4@+5txRN1H8KRF8Cl4=BQ_Qd{F5Vh=_zK==tS zJ!spG_MZ-+z<>S(X={Gv2EJyNFL~{P^fw6(J0Qgt)8&pj#Z-UTioSPrj^|N2No}sf z0K5vFN8WK69M(D3P9<~#G>^wUG9VW_gOh;_>lsvWBFM{@2q1PP?pY+E3ws}Ih-lHo zkBoHM9P$N-Dy2t+Q6kAHU6~dCDpl@~x_%V-kQ0_gL6VEA2*9D7V@>wm!T*?nCmM-F zMtnHUJuKR?6Q`VE;FWf&n>3Q8Agz)MgHB>K@z-B=e;;!ELImps_W-NrXZe9tjJ&0- zi5-%Mg1g2+N+X`j>yDSNFezJCmg@Amt;zK54tnHF_k7bf6>pmLdomw-@Yyml&1z zE9N906&b~mAe(b1fjwBic9(}^?8=-5nwk%+2~)k%AWX+@2%+V6&m}!K@8CYcKV;%N z!8u-BUW_iQYuAypxa${syzVtFB<^4G1JlMQ^ToHFb-o&|TQ#RJ1n~rl$59i7_=EGb z;o;I2sfk1fE}=c5Sn9YMGOH6=yl@af4l9=GF)!@1oHTx&1{8zwzV>#7Xo%2<@z94> z?6>_dbB{1rK?lw5e3B2~^)D+8Q}@#^n@s^@in@|;iDiFC_)ID+kUxS;=HZt5eIB6I z3qjvK8^=jM%}~w@_fM zhsRJ~RdjlOmf_y#FpJ-Ae#c)-EOf0VOLRS_F)EjJyVmygxisRTYNZR)sy_bFSboiI z<^#q4ubW6{w2hIS5HW-i_<5tr{6AF*2mFj^!B!qEL|)VXhQr%(w|)UoPm0z^K(>)Vrh9^07W~?RNAO-1XMtnIIUX|byB|o2Y(-Ii`#x{%Vu{&_U(#9*h7dQ8Tz?#IA&0 z72e@$rWH54?&LSItvUEY_X{pNE|>iBIQ#J6t=lC!Kih`~z4~*rS68Zaq%yhfoIYceh&CoxYV^*WiP0D-({-FP9f-N@ zx6YHvkfCV7>W&Mw4d5Z{Nw zlF@z?3>p+azcmw7f$M|Buo?WW^vKj{;Zgi7ZAF7H`OVEs$6nV3;)-GnkLPO?7xpMH zvX1o-$!R^K7>nX^3G4MHu*67pJ!;T{uB2Sns*bL5@T0V9g$K+n%t$NYhPvtlFDvAS)Cbdb)wo+`{WX3T0)=&G z++dWl1{f(3p#;Ksgqeku6K1Bsp2aCF53-@Pgbvb>cDdSSb!LbsG}@!&yD@$e#<#|V zTBcI)S#V!*W0R#ReatG(5u`xLaMF{iDN0jdY5kr(kH3yxilYc6?@a{4CTKg5;L9$5 z#^+n-r|uH{ll^T$eMz6uN78U8-X8k)aeY`+3bu*f72hNdScGL?44BOM)24f_29-q} z7!=e<-jOq8+-S@OHQ){GtO!bl+h_nlvL5ug_lmHAZqT~lpMxfFZb+)6BnYvpr!{A-kiL98%G z6q2bICj&ez9b-o+EhSBn7zni!dW?no8OLnHoFq|2e$GC*LQDyb9VxBqZ*#%?V(rc+ z)a}C2-O@4ocb_AkijDQq74e6Om9hGa+t)L>3Q9*I#@W058FD^TNh`K^@t;-4`ZGmn zK#T8#PHh>wz~In(k7+S1HSvzxQn(c)KT~y6R- z>StII4^i>JMvIw}ek3k%ki7BXC%bWr^{**#Rq_KfEpS+`%G=^)@AG<~LaZ1BA7aJ=BCCoA?@AC`PDw*err-$;Uq$VZ!o^8FzQrAUv}`&Q)Do2uV02Mh`ji z){_RSU>8EgKdm|3;Wb%YvfBG$wTUO{;Xq7#+$$|`HJcwY%~B1WEM+-inG>g$7a#Ob zXG&dLTD_45wZ(99&=fJy%NVTm$W4G?Wwo#h5%~7_;~R1E@$XSip}0%ks7OuW8CbMG zFLHpP$Cx4cmer7ia2z3QVgt|aYuUonQ`E1vSL|3Cu7bDu3F(w8Qo-u^Zenu`HrG`l z!@hx5Cx4?PQEC--yg9@#<8R%XpQ)AgiX;F81fj?ohR8(bPQkSovDbZ3epL8OKQ~A< zWtWs?&XthkhCUK=-y{WVU<4Kqf)1`5r}^bRsz>9zP$l63^Si@E=sBwNrWVua6=h{{ zA!C*5)wiqa27O-sFAKSX13}iCRtqKJxJlo5{(;v!_G1JN2()}BQZ5-w$3xU34AoGf zzVcofm+6|t`bz6`n zbWUJMesdYJsTvxW8!o-8HNZ#if^TCr7-V91b+3;fVfryN>L62t%39vA&yiqhYE9I; zeVBaRdUKZUIOno^u$x3cOunT)h+*<)($RAsud}f7NmkVrc`-$7oSK6V3&HZXE6`gQ z?n|Ej#QwXyA-np*yn>7}y35+Vc*o;V{a&}(Rtc`?)(@3z^#)LNU~8KD)0a1jEj%_? z|8XVAT=cV1$^9jW&y?Hi-qOkulEzK7cq~xiI8No8gTBLe9v$+syLAwxh3BS?U9>wG zD%Z0J(hhl71u2pLUKKq&Q(PwkP>SJzM7YFHZi_;{_?=%bTD<{7F|K82fd?Ol6R84` z5fOqU-wvb$YUNFw09|D>Ey=T?nNKQK5{PEQ-{iar4@_kkh@{+ubFty#`yml=&>pID zj9A}<1^E;RNS}@pP@rT7x}v!qJdn=PeU--3;LL}=hNG9RO@lX4Mo9Rw5`B$H^lA}L zj98yU2j+H7Of#WK9#zHCJ8VAk;2G`z~Stkzx3Nd=@$l#~vjr{%@z%eYl?X*Pr;eNaMGm6<45~mG4*Ud>N&s<5`0#K!k49+=1(Da^@~m`L0dwr zBG795vH82w`=O@)V{F}8z`^13!rq^u=0YNRm4tSYt4|0H6}7d$Q@%Xv^fu4+yR1C# zMfuO7(%ucpb^23FVoa*0DSRL$ggWdIf{1;eX{_X3MG5zBl_M+WNLf)d*n2c26u9#x zXcA9pxF9ap_TPqV2SY;9X2TZR`VoM=MRig60&xj|X@4-u0krHYh;7u&XFunu8mFHg zW*)l)=cE=An^aRIj;Dh^Afr!#azZuyL6-D~E6j)Spwvi!%(rLRxg&-&e^~h>JgJ`& z#TUy0*L<|&@==0&unPM_pCQeVP8{LkwBdw;a;|rwnelcgYRIcV&6^>O(LF9!PeNEY zK8huZ!P6z-pTiTONFWEJ*x=tC3VwVKxd@cFP|{kThL2Bcv2;fGb#hQfuNrn=84Gg9m$Ht+9S*rb zY<0&b*nHa3FM77p)z?)-iLEqBlB@kR+-NXFh8KtVZN!yB9|%DHq0}@VAXD0@Xrc!B z)oIm6_gjIE(M|O!uhzvtZp3$LZyPT1umuFT3Ev+x9pesDe^kgvAR<7D>Qj zAS9%qV56vHUL3N8E27NvAC543={QnN3EplH2mb}d>f6mWn{+nspgV!SUbm2tCJKJ4 z!8vWQMT%WMi(ASlo5l49r1yy}+K!6OgcdPlxqX70kmUqqS}hZ^B0Z_1hjd=tmdn;9 z2<)CqYw}RBD3~UOvJt^N_?j6Rtmzf-n^7E4dMkZ+Ll^5d4<#qDA1C@r{($Nr{sE3p z!meDi;ssK+Mtj^IjNNx%`Nr0CoYIqPe;_pliiz{SRiFv~*ph}Vyv?&l+Z8R7Rc{FD zYCO*IR!Cyx$a4@g>iGP4rSc!|vcD^WYNq1iQVS!9!GA{;>zMR3PN3T(SuV`HZ-SwH2PGk!iU^E^kk`(bBr8Fa}{m63Rh@jxWbZkkp+-N!=)Rq$5$^8ZekNGg1hqjL_d3)$Y|=mh-PNu8u-Ku|J{L<;fEdL$4iEdz!C?@4znkc z#1(+@>EQPbyFc*1cmv>!wk^S`kz}VHali)n$bv1kqkKJ_&04jM1VKvQz4Mw~;?pft zQ`1#V;wBdDrqTWg<6qDCXoE_br9fWr+d2{~7Bosg>tu99{yC9B zT)!lF!HgTP3O4XlOcUu;iz5-COn3}bRc@LftVV6WQ)@7StNE=8if8D#T^W7<>jr)8 zSx`LFs}P~8*uAi$g?puW=>6At@WFfYYsJYw+{J`H0jSXAxTV;zL865O-;@aK8aM-M`L4reH z0kVfg%OZBF+|hfiS~?b22x-c5XYWE;B5xIOQJ+s#gu!hARryrNSQJf{Coa4`HK+w) zXLoCl?-)JKq%6HSr1-KX=^JG6n;8~3w0zob%8&Hvs%!E<4hj~v3izoZdCy3(WC_SK z5Feb^Tm@=T$Xy~1rLMW+KZ4z8LeVJ>J`5-E)4$WOT1n_ox}q*VVXPcEZYw&zzXgt3 zsH!G1BS-s*u5-Fb_(t@Hzmt=E9y&6+hC)Ffg$si8Mz>XYV4luYPZnevvRrKHlBI2J zn!1w{-nSfm)%9JjT33+ zaXmq`Ysv+4st+%;tqC!MHXJ%k$7mMXdYQw`qHGvKq7t3Y#B?_6WVXG*=!UDXo1bXrGJ6s21lzo#Z&=EMhhnc65#pcfaO&c?4W zM!k-t2vD=+RP%VTPNR-^-HdQMkJj2NIv}80#?r$|-#3*ArOFY<8b`~Kgo}yAsjFd2 zAt;zNJ~82~N}UnlD(Vpb6habqJ*xtfvFvMek8;rF3t}o2{7}3mPqz}B;wAt@RI3Uv2&GvSsJj+-zt|iYt32Bn#O<~T;yOM9HQ$s4#V|@t#Isi z!khr>nu*xN0g@UN0z4Aa6tXicp{S^#_b&vYV$*WxudZ8B_#A*8BHFrYtwwVhS`2K! zvJd?^q^E`w_|zF~p9(l~{+#V|+kQ-8S{fd?nbNSai3t^wC+N7%d>}MpQ8#kf61Ije zIksbl5;3nRNN&%E!>s$q(%$}Qboewr@8Si135J}NH`{xH1Hs{tn@=wS!4FoZ?GnUmQXd4@J{MlJ|) z3}z^!TWA2h*ialH%ppkXtpLJNP~bE~f5kYFT7!;jip0U&Y{vS(?USivqknm#e=8ID za?iA(q|KzS98jXm^rk_l1Oq{J53OQ|Ql!O$*xFPn##1gqQ{L3Cjy_7b3vxx!Hs; z?yGI;@g6D;K_rnY2L%sd!M8W;_6T_*p&p7}6Pj%^(TWZYp-ZL}(%ezm?(5+C4Cw8i zH8Tw{y?w2_8MJs$t5zuyB8cHVJN6r zC!T0r2D8G1d?>W{L3QN80HH{PR8_*cbsw;{P(24Vjy}}=VbUG@```b*la<2#);@*a zfDl@c$>b9gd{cV|y|{8aD=3@jB_=lyeYBbuZ{089!jM2;A3er?pcb`ts?fFZP7#wN zBz6OVeUNMOF{||vRzr?es1JgMDnXE|$=S(0-b0f6hSC~Q(*nI6yYbab6s`bEX&(9| z*0;3rhpp!|gkl7R52~CvubbavmCjqXm-u`!k@c1fhU0>IhyCDQY%Z&zWjo6}js*w~ z3NVB)K4vIrxK|fV8BV8;%Ieh+)rHS^{Kxfg9gEk{=mp0T!IHdJ9`}-wuo`paMb zBChENgXzM?NHXNjjN5LHmdk0WF^tC zq*(WB>Wt7r#~Vilse^q5TKWKLkuX-u;#dtcu0}U-EumrsdpQQf!om0lew5WWyZaED zw}JZwe=O`97Y7O<^5?!YYqaEL^#UYo!H5*fW&r6#cMXkRajYp=-{NtL`33cj9RvA3 zW5$e;zIJHAqO2GEF&qd&vU>Gudg6&EB+Q3|Z-nCJ&z~=CVURC&%PqHf7=HQ!s_)J} z|9ttkx)$o3H*DBIk3II-L0<#dKOkA%d@Sncd+)tR|NZZOX($7V3P><=&ki>1dL-bw zHxbufdu<|z#nJrFVRhIamJ~x*%^XSBoitY3!9a0xx|AIJzrM9onhzVx8|$H2@;e;1 zs$_-ATc}PcLFnb@O=+s)_0WjAHw8<=b-@9wCPnmk4}A=as~aW9$?0X+AW1MR9hPY1 z9-9AZYFUNaQ`5i;+PwZfA>@YkHRn$nMv-CNVZSM?h#_>>;p$0f21p;wK__MYhT0e4H-?B5oSb4(99V?PT7SRz>XItv90-U=R7QE;$^ zVudn)o>0gi0Rr;B|NT!wZAc8?v15mX276PMFacm0SS28j40%xF$B*}#91ijYQVBH) zgeSn1i-Axh{IV{*@IoPD2imJ})TmKH{#LA5;XXbETz4pNXy!ar@YPpe32BESiiCa; zV2~?#gW-OnGSZ(3rKyQ`0F(H}jT<*c^6&142cfzKCQq;u2t!L3M|TSvtee6L5-b9W zEWS{9tP-QbXi83s{2Spo5C??b5OxgU-}h8D(3iW)dQA2pDhNkP&KEBm5*#RmLdX(z z|50OGP>j%E+FB6^->z{P2m^Mz#~VIa-3j_2fMpHMw6~@^bPaWCJ`*2!cOhvVQA((6 zb{A2@zVUBtrGUxfXeMJIG$2rt+rV1ZElT6Jbh!&v)w&5Waorp@$?y3o>Qxt%z%c969u4gt4c;!qmh( zI5|1Fh1Jsb&d$zAbI#)K>INn~p)@`-(Qsoo@%>;X31~TkZlTf1Rxv7-SYEK=L zfYSOFu|k02guJa~Os?>n^$SExXD98cYNX=YCdn`AVxj=`MOE+N5JU;H!DJMP-nF7+ znSpA%DNJIbLxQQpHbJOBm_T%fGhw=PS|MdJ@mjQbFTJ{YCv7UL z5uyjDbaj}xDxpC^RNmA|&n??Z|6948&!t>)$`E=~&gs|>TI&35@djG4r-F9zbzEIs z$pk4}dM$Rk!ifQ{d^WTt`efUFSr)GO=qb3rwpl{1sjQZLi$k?y_R4D^{MX4B_^aLJ zeD2)}7G6JZVm{@tTGrOtWxCIXg0-o%My|g)507V|`v(pS((6Hs0 zp+PjAHk*zPM&KHK_St7b)}MUxNq6GDwSWRhOiZLJue{Qu>9s#X9WURTFtj(~5p29{ zbN>OA)wj}!lz355I_=$R5fmoO-e2#j^q9~ARMw%A5Dkvs-LO~e1U}wcECd41<{(8* zivcPAjSwE*+rq>N+x?mq7bMezkYvV1b9gVr==tX&8ZErv^%jDcpA<(Ya5yb6(B@Xq zko>-AQ!#BWuaVaf{)}g}YyuO<fnQ>8~ zlqm~}h3j|G!VPF+6u% zE>g(kJ@a)%{wafO+ zEPR#4rw4ir3d2z30eMWLN00Wqo`P_UhgC#x$`FW)%5efoff9#agAPYeLq>$*tSxHH zfO#i~k4la=17r^-pGcq&4D|izAkPXU1AQ9tZw#!5YC@2>^z?M`$HMsLH@_)ku#+oX z8=UWrH{K}ElDc2M$nd3?UP_l;cG*F-BRY~mpF&n21DQZ}R#a4Y95iyO;8!)8!)}qG zA?{Ei9#`c0Kt-{J3Gr-hsi>nB9G2R{Bn#Wu;*UA?bTR3Q8tt!EdYxl*P&!lqrPxK=K~{Yy&OdT}B&AYJ_BD#2Jb12q7XN z9`5PQ?OpWl#yuXbaB$q%4sx?p4pl>IC%w7WXk()QJA{7FCZ{jdYiVXLfW95{l)M3&r8p>cct8JG~Eu_;%=g74yb%i5cpFm3eA4^92 zobQm)T4yqkNjyH_QAZjY$2g9~Ryp|ef$@{O&kZI)s4P%u5UM&D_wqvtgqMEwqaS(n z;WJOzAVWyVN9Yi_S=U^14PAfz^>ooi7l|5&93LoinEWx3gUI~+=RX%R)*C=F5l%!H z%L;@K(JOH3)T#2jnfM`33!zNx1EEG$EeEotuX?h})blDcL|s{eX#g;e|@aUA^v^OIsECki=EXN}D> zLU0blIs#X;F8`8cI%mTL(>Il?Vi^9o3O!gf+KtSa64{wTuEt*0{_hsrm!a z0qOga!<^O4p7~G6-9jieoSy-NRzGJo%$mfX5gSD(3^&v@MQ7gMw1>7=cA3e_k%SkU1uYBY2(hSxK_Z z)vT25sjio6i2IB4GF@`8H2mR0$pYb4q$`CCudJ=4p>d4kSZkF7Aj#nWc)Ui27nVC0 z3nT!>ns?uQHyv#tME8IH_kSKF$J%TfMxY4kc}tWYJ9e!61j#{VAV>~WqW&aRgFt~W z{NM*a@Mv+Pw)g8_|5|dn5bDEw2!%mu!?i=-L3OMOSQjJ@d0ayYIB&ERLcSG}_W^I= zK%77F*AQMso*6=-=4RA`0g6bodKq%{)mPUunS=ryVt#-5u5y|^B2!c=kSVmBL6|Ts z*l;+nYHIDVU6s`13kE?oLR!Z4?5T2D*T=q54Ohtu(q+>Movbd|X5^)c1p};~pwLKe zne;~_Ay8Hz1i6XH8q7CgKLN!pjK8F$#YVc5>dpH@KgIx8n;_*>VFeU7yEJeYS1CC~4|?;#|?=5TQfd2mIdZBDhA5 znsJa0mnDk|zv70L)i=>=t9QEho1YZRA=Nb5o{2R2^2tE1tMAV28XCtCj>TSt`22x{ z{Roer;58>)6&d)TL6`><;mnyc>1YD2d0=!3m?vrg?&}~ktSI!k9Vl15)iQ1ww^n?zi85+r6$ecMAZ)I2fA;e*#G6haY|@#0ozh4pr{% z84gtcMq_G_VI;Np7FzXZnC@v1E2g0f7cOkuyLWGh`JA_L$nQfYHkWW{32GL?O7E;M zl`tT}aGu*UstL{H2;sHtv_v{(+E_|ul?mI;OpfQ*gQ%&sjlx2MjRJw%M%u8wm_A>< zg{td44QgGyLAJeyRkPhy4YoNWvpUZmKg<>%85-1VHs9s{1qK94@;QiLm%5K+xU8>a zC2Vp|l7#+{TZV?y@!_Gy--78rV?DUmBew~}1>wDzu%4zuj~9ug=g19H`!G;fozV}Q-(zW(BY z#pe*h#|N^wcw31FsoP!CKxdE5r3DQz?7mJGsL$5t$*3^Y&^YFCtPw1wR^cbw zUUIv5~qhZZ;Ra6-5bFq5W_ zKmJ&bg|?RC2)=^I1@uI+30Rd-jUQg^RA^%|a&8RPQl zf;rPEFFlDy=4H^>QF#;;6Gc4mD_|UkpkO&p(a=5~T*@@BOSykK8PHJz!>b;e;y}X_#a>xt> z1z|KShblxUO$s0=Adm<*MuY~*?=bwd0@er(tWJdlg;6YrPH~N_0GWfyY7{GeP@L>t zM(&suG2uZ%!jKdX|0-)P7ebp8G7WO5LbJ+u3prIQ_mnen>*SDbJdMdnl-C1U(W~!h zmqd9paGjuZ%`VIm>xZ3|5UB}Y@c1c@ChA+9)X*66I2LoT6!0m6mJb(@t7E)5mk4$O zuq`+mKtlKnFT5bh(q>>XLVlBST|eAFr5ltdR25pu9t=;R-YB~T^Q3A1Hw-o5F$TB! z!QPx1YTXSt+#r5r2%Q3bRdgy(u@g=>VNe*0_7gV#`>-uvzPxMq?%h&VsNR5cI~qSD zC&=7nn8-6~h8&+$Cgjo2u9`=`xbZSNbKXo2YZp*jY696hI?2xALSv6`>};MC(~HK3jDw9gSli$1=eJP)>}b%vt6aVXTRycNl9P4HawKwr!Hrggh)W zh7TVuVW}fIQ6sE}ByOHa-LILID)qbhCxD3yp-hArK`su&nAD$uXX38A?xL4odP#oo z?R>27fhgl%qWW^MlT3xMu&^MI=T~2Sm3dE_N5VUlAT{CRW$UHNWzzEN@0?D*zV=Ky zamFZ$j*TT}yOZoKt<=S2%HaxUd4yX{>mw9rXCh!{!eZl}QE|~UtT2a8J8=pfKRS;# z?<%Ez6*aE?+tmI8iz*vJm?YRhOvE76;S^EDw7K+Ve6PQtzoqY z-dc?&LW9lM^7||IRM3}2Wnv2fGUZ`0VKWpggx)~1kn|67Wxh7}C)72(2Olt0Uc#0E zq{wcvRX`=+f{FPOW<*8f2OEoxzJewo!TrR7bTLx}QG`N;ZM?O%h?edub1w(thSx!$ z#6OF#JwlG=kS?wvsxNU|+beo44$R;I!uUmS?;ae8aIj2n)xALy07V2ziU{b};dH~PO(x0%8=#ptS4pxrrNLpvriySt}Wump< z`+BHT2sIv`pDxN+CtuHX`znQSs@n-O)ugB}vBFS^^t}L9 z4e%S>O1>|_;QqFyq0vtqYt5d`P@i#V_wXQfH!qdf5d!-C?|<*^&F649=tu!!H*XDr z5wbkkRz7BOgbKxl3m1wZsM!j_3fMb*=R4nVuWzlCQ`>~vg}khnUw)as``z#Qjorne z0GHtMtn#4hj^!wFHrJ7}iT~C*T%k8xFCR^2?qyTQ z(*y61)qDJ8*p{?z?4(z2YiaQ$wnGDKoKlJSEA4d-_&Z^o}68Xs&b zW>u<+nwi9@{)J8-heQ2%-Pba`M4Gz^86PB$14QYOPuI~)UvKZx3Mer$j3(zMQB#nPmc3Ao*B8RG`ldr)L;SP0S9|%LCos1dSKZp*(Z3hE& z{0RFYpXk8{9~5E$b5T?UhKGmuSO+8mo?1w}-??*VkN2?-{JI{0{Bb(tj57wE1wnrR z$91Sz=r5@9(O{8r@4femodzldp<+R$f}1-?vQ@=GAHsVzB_vCYJ%vHMANzTqT$Wmkra;aIi z0nEk91(S(eZb2sHB*oF&i&sn1xrc2Ck%Zk+W5Ojw_SRa%Mc$ey?(YEEGgm$;fLKL^ z1Up!nvd2XP+ag0kYxvuy6eDa5W=xeuoUpAfzp@-)rs;1d@d+OArN+E+|y+d4;83zr~=LxeHalk#&v=V`+fJ_C+BZg>hN#m z%E7%J-eYDJ3)@6-04fTjqM~T9LtP)f9zjJ#MP2;N;=%NIIA@H`riX4mkD`)dsJ*6% z$&R76^!!V5C2KjEFm$$()6QYIz!1LhAvDl}G^kmyUl>!E%^z?!sWj|xwV6_YzZ6VE zzt~YqtBNbdoby2cx`ToOC^#rc@|}W$j1d+ZLXoWaB*aBgYCsgx8KO|7iHDIz$qv-Jc{5l-(MHgs~x)48?0zI|V9Q*m&ht+uqTIp~X` zvQTR{5!Hq>hZ$Bb2u;E;^|)c_(z6ii81l;CU|-hQ(wEb|w@ZlU0pGaL6AD#B^LfnS zF_MgYEmX;L^sx;MjX??hFn51?gKR#^<9qHmT$3XR7Q$#pDxc{7`|p=(K`StsBm8!- z4&Y0dE|p0hs+Bp!hy1Rmo_fk_V!t8@Q1p-^*4WrcDJdzU#wgY6NGN=V10X^m(jY$| z(#o&vV9o~#@xT4;Z@s>U>x|0Ep``RJU%uRa!37sMS-JCgc+i;mZ?}Jgt~&oX>S}Bi z5@En(z0dMO0n+3;f#M7tnY)pOXe znH8;os;0IKzBU2obNbedku-lo9(B3g++onVj7in1y%myVZzg01L;kz~ne=OUJjvq) z9&LwbHyRp82gjn5yA|pW8DFiDi-ksgXh8@roPUC%EzF=go%X!rxm#s}LtZH=zw6%3Ox9u*qEnUBZ)^FcSHTBKZ+}1%` zc9&39Z3FcK_>51iZf-*n56D)4`Le@N9!gaRf2j)NH&8+N+*ezr)s8npcZgXJ%AwJ= zYz7&HMvw9Mggn`|CR!SU7Mfsr0=Zas@_2>^zBaoZ-k*8q8M^YyE5$7Ha4R+Np7KVp z5T0YDK$=n?C~)jhpuBx}A`EsUllhM}pj;t`3hw>tdcXti+u#1SkZM#y9%!OIR8QpY zJ^%dkJrdq=jZr0f$t9Nvp&P8qpB3Nz?ssjkz4n?^Tmot$Utd&8_x@`U{p+qvC^9^h ziF~^>V75-3@L7UFWW#V7)pl~|tfr)d>_OE`Wa20?K30^eAfwj{IXXJ23%*QFgVzMH zT4aj|r|Oal`qvwa>7_->Y0cI>)X~}1gFspN9o~zI2%`xFIh2wZLvgG)jm*!Wvrm{v zqnQ8&@!zD)$l5nnKr?mTuD!_|I&-@I?^M5;cA$3L#&I)a@5+4vg#ZC+yZj!`F+3_l z-pR;LrBhCr#OhxcUym*c>95(mn?72;fmUoRqP47cRn#{2=WjR2dQ6E-M3IAq3PQBo z`PzyFnEP$rfG0SE;AskUzI*^3p53i7E2Yde>L0>tX}+iw?-D{JpW;P~T@ryu|L z#~#M61G(>TxW|1gE-vn&V&T3agosAnLkV~`QAycl%6T&S{@Li^Th5~&eDf6QWkZa%zG> zcu+~lXIT8Od>U+=LNy=^3+V`nY_GV#O<* zw-<=YIk>pPpG%bwy7~ z9=lwx1(3HYRSgY4hbCCw0l6BEl1P$qt60cQLZuvsLg;V8~59g}d{Fq^G^8*D7$?UMH=&zb(CSf4tSl62Hj-UDY z`Sj_hpHg;qHVt;D^FsR|dR+lcEJCm-v;1o(`6=mI~-SonurS$ig zKc@Po7We+Wbr=9r6JzP*DWm9H=bl94hG$Y*asq`XL>ZwxCVEaZX9l@C+^Visqv?-k z!^@zxO(2L?c!(_DTvbQA_gB!K(kl93={ovs$cNM{&%a}ZQp}rnL~r9^7$o?cgVBFnTE!ofhJhqf$Ek&li}eaP?Ih~I+!$3QFb_i zMD+Rd=Tmicb&rV|Eo@L}h@|&{069izWDTnUE07ZePppiL3_2FDWy==17HC}U4c5sB z*BW*V#~pVZ^#@8_M3@($W!x`sK=mbZ`-W1&``2H8P1js=jXP9m#@*jKjed2*S%xtv zlo<|9t=_PQ9)Eoqz4+-ms&8(if#QTog;bcGM(;7HD68u3ljlvfK?3@W@db3+^s#it z@e^p$=sb!@h&9Lp2mvc#4i66rKS3Y#A`raZs zIy_Sjlo0Dph>PG*?uB!v)3q0zO4BBeU^Oq4Y_M^FdWW_}tWdgXrUw_*9;P}y?7*&Ga z!dwXJAQUzfa)J5-7^@=L+}e)F3}w&=pbsJ3>~XD8ISJ1zz*opny@qgWL_`F={`%`4 zId$rN++Q@-h6Tn@x%MFGX=!P4&em=O`zjmg)Ct2VEia8HTsuAa+A{jypI)O+*A&TQ zJCIADmM!Km)S7L3smbKgWF=R@!9jG=tcRN`fLdY&ECXxy`ly~Ngu{3c+4#jYYZ&yhrwYB$r4y{05)g4!# zOLON=r;yl4@h|J*&|??!s$8v%yu#M4O35+tvUYy%op6Bf>ZDL-3ym#IqxrK&(r8x0 z_Ve?w--8r;P+NS_#Wz{VkzaK0K|^D3KoczAz_+Io9+!}j9O_UGr0{`4vird#oA(8L z`^(GAqeY7rNu`@v#RA|6jxY@R+~IXNydyc@4CKMUdH^0*fB3^6WFSG-x-GQedEkKu zL>W6)fQcUj0Qb@i<$nfPC+Vi9Ci>um4YFw3ty~z`}FJY|Cc!spRFKgaT z2otL_PMARV-EblO{JQh$_!GuUYn(2aogzV+`(Ovo#v$QNqhBM`r?KM;>8ukc z((&VlQ+je7l`^TRZ7_18ygj=}SpQ(@2Kr>lItpWwl%JJCA(3H5TOu0Zb+SRdVs)^o zitHRh21$!xvNvIDHqD!uFZXfn&QiC9hvjha8)RcR9B(werlH~Y&;-jn)F1F3GQLHf zMBUxtZTR4VhZK_D4`;$Ve5OEpK%}64Se?N^pg^$ReDh6FS770QXPrUUqM1B?;c*C1)sHj-mw{Mqg0-i8<| z3@bQJcb(tR2gnwZ|6zohSC~a}XO5%OrjDk%#unOCwBMa?rJi-8($y)6@;mpH)50Zd zsieGyW{e(2G3iNgX(eZW!>CBecW^inZIIfVT@{!%Y1ZxR=%R#_SeiSvkg^h^Y4!FJ zk9;u`AQ#W(f!u~YYRjad;pfl<%Qqx9m&Z~b$MeW1m%k5yFd>qZ(Jtk1Op1!ML0v+a z3CYk_cX0)f1>|ahM7{FLD`G~9d@3kc$T70|Tbaof8Z-aqH@}f^=TNZ==np`8aD9;L zW(KbH4}bVWQD9)zpkO7pPzFJmQd3jKlA*8bhOik3GYo6Jk!2XSqT+C{yW!(m!}E93 zO*i?p13`Wc6501v8Ai8OW;AVN7JcL7aWrR2K7I4F$#nh+WBA|0rF!m*4ZCS;ah12+ z9RWyLPI@ApH+w4m`HpYV+4E*m5bQ^qnOL=U7{_yrPR$@S4wqwnYIZ7}$waG=f8tse zmsNMW?7N>$W4%uPB8N)AHCuPnlwnzvmY>1jGFc%**w1t=#bzTUm;y17fY0h-5YI3H ztsI8$XeK9l-fP^7u68>G1qaZS@p&|R94ozhDrir6oyT#wfIfz3GqI4z&}itXG322M zR^PxUIj$2b3&j&bc^Sd#;M{Z1?Gc(g6fl3dAh8;GI>@`STRsGP94vnrV)Unk97@eY z4?QGGOmExtM;t5}#JZ>?eEaRUJ*vcT?PkxOEu=F!Ia&HIS{q@j{i0&-{rBIOu8KA7(To=fET-FoY-Vz3G|)!RJ^Ljz)dG%M>> zcuM~3wsQF4MJLijH_fM+qq9Y=N>7NEX3Sq~+(W;6?o(5)mPv_qZ5!_C_uhCB{r-oS z(N*(LrmUPagIKk79@E6i4Q1a2a>R;OSa>K+VrB3AlP1%MoOIf{yM!uh8?B4GuXWM> zN?N>XD^1Kzr`+M0;zcH(M{t0Lb3KRs9IQ&Ai8n|VdP+7{l(2HuM4?gPlJC}OA!R^U zr$chQGINsYjLE}k`Q~COu23Y)y%=tD3K==EZ&SBVmWGDkLKCe10kZTdWHf_z1Ct^Y z4>UMFTu`1#Xz~mPcc=nt!XM~FY+fD-@~~_$dpr#mm<>DO6}Dr?4k5W9q2~Ad0&?5_ z_rL!MsWJoV7TVb$@f->z^58(G0ITu@H44fh8iV5=As5bi4CbFuv7j<4+XgeR4OmCu z-V6q)p) zgee{ET@;rVPvbL_=#w=&siv{jdh75j`WZr!UrZVrehy8r`UfNiRFKiARAdVEfrES; zC`^YdLD_6f36%tGYCy1HMhfL*pg<__)KgET2OoTpF1X+V8jMin3b|!J_`wgP%?%vx z5voJTuD`?+!~kT+s!)MoA-@jcIy7#EH3t&WRmGt>vE7`gj*uhT9ASB1#{hx_Lg>wZ z3*j}~n-L>M&|m=W!Amc_Bvuo+j%H%jZ7X6d8yOZ%b0!y13s30#tBe*e=J#*D;COoQ zhYKh)ER^=|uM$fEEcf6`pVRA0Hc^Y)H_6E10kDraRz{$D{pFSgbn2{;WMhS-v&G)+ zpQXuFufXNEv+88y>z$I7Lg$`1iKdOplR^aaOzbidE%i*}9)9%IMk+3;q;P&|-TIw$ z|C6uLZ=ZUT7O&nSrl+5;+Cq!hY^P0oDrwdB5_)IZRw^o~rl|#)l$ah*HdePBlGN@g zQ4y1@?A#O@&t&YQ)jO!6xm~GP<~s?=eG!jx(sx+HkD&=x-%x+R+j+nwYKHkuxPT*} z7ZdW~%40H5SeU$#s{^uykP^aNs;{6ofS7?WAP4BuOE0Cn@4j2y$;XZzOM@AoeDaBq zE3^-S8l*@Iu8mTW`U0*e@~EI3nStwtUV^a701(=P4;Hq&di82|4wbpu5P*7vDnpPm z*k^bH6i?*tz542_qKcWH6%tY4nS$ zPNO!yZi`m#5VgYm7cyaZIz7VVDuLB4W>$QC?X+U+KKlLtKBHxu)V5uJh_-Iurr8to z>G4}Hpzq9|NpZ1})X~(;;U-oFgF>ZIH1#7)u0e85q4e3?J1CfysS#s_(Sq4iDK9mF zcI+#siW=kdMOyhdjr$CRYt`mm^!8_~>3{DprPXLhWODEK*gU@m!n?kxj8^h}oyuxs zI;&Jc!9j+KX1Yyw{v{Aqi-j4K6duOJY%{NGgpW;&MUnB4y+<_gqDBpkBLz*c`UjOq zTg>BPGW99Mn`niCi4*F>KwH=J`vA<56K0g0a4hvaihoUs3e3rsM&NC%G2DrbA?dD zsMQ))RQp49DH7R{n=)m}6t8(%P}x8VK@!b?RRa?2L7e>!>ij^aK+@5t5!b<-u&f-kTB0_1+mVI>RKi;J^ zJ4%gh8XJ~>tM%qhA3^`P;~O+(Y5_T0+NpzynbA|(VLTH-;Wz-&zLfohJ3ounzqWS% zED51WlSj}QQ%6&Ac{Od>RU-Gb`^%oOJd{S*D=1yj*VT4EKNWTK+86xy*6E;ec_|bT z9YIbc-@78Y7}y?samF$6uB&gMWt)r5+f{_BfXAmi_K_x78h#2*um%Q%3Gv4~*Y(p) zvM||T;)Flekpgy&eIhCQ;)^d9g$e_0bU>!?8j_|_iHPM67x_CH0GWacq!QHC2l%>m z>qK$tj|v2(3OQ|Ge)*-x>$pzmoO2Ep6cl*8j(vbctEx_GA%Qvugl<3n_+#UAkKX~$aH%00LFt$^I*0DO;uQMX6{k|- z$V~d+lXdi;4_5VGWx%sj9X?nr17Kpfo^0Skq-=o>%3<_CjWGGF^&tTPaJC%1Q->V(tj|!<9 z?~`WH8h#2*u=)@6hrNo7M9VRz*F>>EJ{BhCqhZ(yH3rHD5~T4H?%_kZX*2o)LMcKX zl{s98i67>cAea3K*&?inBz7~f4dm2WTM>cGEm^We8X6-c>8nyx?GMTrlJKz|t11Px z5#$%D7{Y=GcS4o)N1!4Q{VP#fsaiKFSe1(eRwVBF)*SlN59ZU!Qwu3HEQGr1n(5gO zzNRlX?D5!cRCoye^6I%Xc>M)@hR| zGB}VrS~^(y@XX_J|J&ZFLxl$cNj$eB@w}a);-YEZ@e?R9I+9jwD5BQ(ZgOo!A(N#` zPn}4oaQJpA6YFSJB=?rrNm$rP-F{`bB}FAQ^y!+Nbn>`il$DzzZHmHL zbHX`*oGOqj6a*+|c3&h5URNM$R{N9M0dJjgMAY%yE zg7lh!V}r7olau3@VOxA@c3`xHI9~kv5BtuzyHx!TZ&!VmjAlkLTDH{a^r@jiFxRjy_N1|0^A98Bn`c58e8bRS)+L$Aslk?Muh~^ zdn>levKAoO@-rUWNJGO9p$XQ&pgyT9Tt81E%QeKL5A_7OH^-83=g|Q2$xuCr3=Oph3vgXP zCXsKY8e+T8%d}ijV1TF=-#&91{ozIqt8!@2+1yI4E~oXNuwW{$Y@olt^%d1NdK%uI zIb#G}a_$V;vThe$_N)KWX4fRFLQv}ZUVhpndh(|iQATDWwbeFJ|6mgpivoi7#q`gfbU&4`N^Xb$H1+=HMnsyYIdART6zpWizQlL=N&`Nn}3F2jznh;F^fi`Mu z?-J_;vyuhCI5sgloMufJMuAAScR1bK!`Bgi(Z^0~j3JEn z;FPJX*f-LOjYaPF71=^X;>O+ORL%EmL{=h`rwE!kCWo$=JDG|r>S_D_D$nimy#~2H zmkD=bW`Y>Q0s-!pp3yzNTHQ}+=>3aaJt^{;^?){H;S*B`Pr&x5>;B& zNT;!i6~k&4Lcn;xjTJE@xHt3iUvJwlRj3v~Dt(1VDQRf<88pE%|velzxE7sY*&p>JeV+3rOULB?ZU<`uUk5O>TMMKJ=FGkBUHq z8Wp69iNC2UHL6G6k?Y?unEl=Fen&t3=}#q(3jSAcV#iOAG87)5D)9E(Z*J0O_!y04H?9!0eLb`8%2hPP)JZ9hvx#R zh(kaxf41I(VBx){)=pZ!d9PHAnRBktI}qgRH`kp>5s^%;8e8bV$^l&|_^f0}MPUG* z0ZphYpAEM_&z$EJ;TU5OKPZqL%Va28%;L8&*>a8Bz_J)UR~59 zcXAVl0qu4Noy3GJj7d9gRcTEVj|NI%Lbt25hQo=K)+0ju1~e31M;aP_22HRI4#@OG zg5{Y!V$5&A02AS>Vys9K(#c&dtE%sRyVXtI1tKK1W$~mrM2_|D_G_R z%?SPMudJ7rIo6!3J1#w$e#gpHSXeN%Nn-OssK83aLd%}uh=bacMmKEyWdTZ7n5~uO zOwJVo1XI{nQKnqWkU?kH?kJ@hBQt5(@HDb@I%x8U3@X}JO&eS}RYpFR%VuR~8O<7< zO$8&eq+cQmBWhW_jE{*B)$5B5d%9iv4d4$8pTlpGX3rXahM{AObmXBvs`2gV9x|#6 zJpkVzs5F36hbKJn*=L_gdleN*Lr4n#U2sA_5PbXF-|pcdB_Re@lHgpAYC&Jf&d%;p z;b-24x_3~dU}6d~^2a~^Q4}{{K;KA&C!wzSgL35&=Hq|kI?ta!pKiF}26uSXoNHx< zg>MD5ban-^Qe_LU_2et)HyFsLTT$CYn|D`GW0g^&A7r@(s%+nNMDBf_%Sy+c-^gP1IUjOM!e%zyHnI^nc%-Cm~{|JB!`;fn2OR zpLmycY~F`90AenB@3-gD#NkG#1T3%ae>0P+&(;*dZIl{YJE*Rym4aZ&!Q?zEDTWeb zy#Afy4?l@CH2e(u)8@co&bEDqjBijLfDh7LcirXA;n5_k8z3Dj6s3UtC0HTA+iIYI zf~A^0TY&`jU;gr!Jwk{IP|e`fZaqFg-7_?De)idCC7~Yb)L`-$GlyIe4*dDge=df! zix)2zf@{uwQs)abDn25V!Z?&?eQbmH&A`9_pD9h)UMNqB@JrgVr;;bic8ZPiUT9!5 z{Wm=^n(n)1F2(Ug+Sb%chYYTScV}BY8t-Y6bE;@y0)Wup{E=h?WYwyin(LZr#F!lV)pzDmRCv$7*%jONQ*Bi}wRdos9@UGI zcHyAp_()2O^V$Xrf4mu_q2XuHA;N+@Y?#u-=V51VUT+B%{SA_FTd=zrSL`kK`>KTE$HlWQhS#t z5iZC~q7%pEk;9}sc>}7z9Ip4bb4X~%{%X(TG7dnt0fW*%eSbckapGvQ!$8z=h`)@u z9o_?U!u!Dj_l(smVuD4k@GhD{hed|b$&(7Gyt8T%IOc$Rqg;l0bNqRr@u(?Tf3Kk+Y@F$Za#Jfv|B1W7YGNiFrb#(A*ga zFqvL~XqVSE(_B`t;_^~y&#p51Xq6`~LdbCzzaJhN|*=$;H_EZizb$KMhA95hlF$`{1aC`E?#{JTRkdrf1rD5Po+NccmTu&JfP z+acjYrIs`_{0y349a^A;&M(Mt7Z#`w6-;zU>>i3x=LjNs-+lMdC6`=2 z+5)u+&iDYrTyPghe?T~mTYaglBn!S;W+#316ROy6fBRc$1*3rFpoUq)fXK;0XmcdW5YuvG-F0t zbtCPqtdkI-O>N8zupQj`MJspE#@!X&Z@8X^Vd)9w{*}ENl~;(-xpS~ znhdA+BeK(>0$jT`qfnri%2+3V9(1*~)6A&_^uR2g17n>{*S2HZwi?^EZQDkZG`8(D zX|iLhQ5)N~8#YN}JMVQr-}e`0Uo(4Vt#cg*vW=Q%c*>~rg;C6A)sFDvm#*i-BNCw1 z;mlro^Amn({LMC`5Eka!7E5-=j|t8ZPr}D&Qc|WD=@=21M8;jFA;D ziT)Sw?pxxQrTRc>Dg@?Bgd>>AQRweNeyy)u;c#;}<-dQgzY`=PL!k|WKHzQxdcFFt z2fpqC+}{UV;sazK2Ww;cd~W{l?mp}je;cN+N^}WQFjR`?_w10;3UYkEq!ST~2{8N< zaxed5gpG?Wts!G6e>}X)#5h42JGR!OG<1in%>XpB{dg%-o|AZfDA-JssC@n5crL@} z?@VPysc9P)q?df5vC7iE$Pksa$-sgbH-k@;#rAvxYl)`ZRg8Ao> zBoOBCp}dL2^H~G_`)@PRIKl3&Lm_q8S*bT@lGpzoF?yf7ecB|ST0OF;JXuWO@my_= zYZUdU((cd|av0=-bRsh%pPe!S_#VO;6gskuuqWV^YO0syIfj+G+DnLh+GIL#BDs&} z33>hAr$81Xvz1GA0WFXd7biOj9@Jk|bz!NO-EZvL2Td{py$4_jSZ{Q)V_sWUj2V1g z#xJYe8omU##a0ni{E+YyD0QTJo%^^&Nl?t)hTVz&_)2w|p0 zr~Cf2*^fc&ls|v)JhGFgRIc=ewQC>VqX8Wn9eD8L5$UEl@9J`&HdDe@Z*5$lk=*@g zs06uTfv{@^U?j}@(4~phA1TIHJ+Rx=-kz;p zXSik|ln1#Th1s$Gu-a&G=oJnv)^4i$?d7EWl?gpk{(h2UX7m?jj|#Th==Fm{hMXm~ zy}vcZd{Si`EsLV95lU1A>Q|jll$svj-)gySJsy8SlpuS^7y(qHgFP}HuCHC4Xa-~)NZ4j{@X1;nwGZj12C0j z8W+`(ks^S@b^tc}BRC*$0FtA7gAkZv@-LhfAKEda0e0R!_B(KWD+A=*QU7(Ly|r># z+@Yg9@uDdH!h--JH#K2kl)9zUx?==DI;=50h`pk!!K&fLBQsgO_)ll~IR4w@fFa~~ zcxY(Mfz0>zXsVcFMo>c*aWtdn9u-QIcTNDk3o_{Qb;mE9t2UUD|K$U`-JMJX?jQ2L zE17$}dB1qLDxc9rBsAdx*7RBn6Y*kinx2;e17^(;iSyOh@-S=HXtq3B5j0Nd3jJe_ zr#k_LQt!Mgp?i`jm{0c!U(a=Ti^{kRrV+J$E?znT47f2IA{t~6*Vqj>u9WgqitzQ*oo75d#IE(M3b1Ok}X`dvpVs+c@AxWL2+KfFxN&aHG!;bh+v zCQQDsukT{PxrEp6`(=Y6T*LsP+6|aFH3TI`#~Q~^zn#|~cPwYfWM;B2-2gaNaJ1-V zNL-7u-vPlk2U2R%#8lWCt=eiLa}HBQkbK*Ge7N7wkC5FOacNlKzu5-r3iMdQg2X`)_ zhL?;8E;mw}udfE7tLJM6o$fH7=cia6J72-HfL)%?7cag7y1Hyf|m!S+M!{)wgS$Rs~ObLE2hQL;@8GPr4ZUerkJp zAZzvU|Ciu)L*yBHXJCEuhAmQk!Ks`n9l3!4_8v{S$zUz;eydo+oZG@qv54~*3LR}z zVyeF4j5x0iG96h{8Eo>=B{|ys#miZYMLIg+t59}KoN&si;qM=CKcQi69{=N$qq4BM z9MH$cEWi&e*s(`h?WKqeeIGq!O%TA}vPRR&au-RT?zaY?kHVCUR>RrHkpM0UPzR>Fi)nRv|1XCerF-VOaZugSczN4$U*hX+?zlRxarEQL70_E z@IEgiOw9(AXoD*C-`XuD7g1iEOjNi}@KXsP9v&Tiyk~*!pwECY3qL^ByLnLc`X&S~ zMuM5IP>wxsh_Xd38Lm#4MyF}v#ee1;irt$;y`DsdF2u;1-0v&SVBz(rddo^Y7si}C z^ntdXd5z3W=8hCfFRba%Y~oM99*4}_8|JXA`D;ow!5JjCM7|h@ZLP5{I9$f6exWM( z!cfTJgcz|`Yx9EMNJ3HdOg4QzegWmy1mjk7*4T=1Y$BY8U(K#>DE&)ctW)#1?<*`4 z$!@5{0=1FDArgO0iH$3gP^nPJGP#!CXox2M3ugty^+3oyk9LUvBZR)?QK`^^yVNu4 zNAHH<6L>xQ2`d?Cnib4SJX+p@&lydaF5;-svU!5)T0{RAxe6QO)(ktOfjl~{huaw@ zC)C~xP)XdcAx8g*epb->WD!G>^G|1%FRn{p zs}59~pM9n2&}YW*7i2~+&t;QsnxL)zHS%PsVF$rqH9gY$Fj4^d%y1fA85mQ_V^#iw zsCKHQq;N_^flod~w{F6`+x(It76 z#Q43`3QnR;CJ9MiYHviHs-VG5_+0ed)I6u3%mD8^ zCp_wMTkC?2!Q;Yf4V+_rP5HZ*Qgf1wn-k_IV0~6_uoX)Cf>uwg*jb2XN_ehV_prkC zxGFqJj7cFuO}<^heJ2>1`G8^<;c-(Uc-{5wI_=)sOPu&(wUhG&$Ze>aYeIQ>y@37% z)6DX(Z3&=}oH-VUwfqFD{Cd@;q8L*Q_tD{Jb7DOJJF%?PCRXPD^uC)Dqb?b;+54#D zSni$Wa(~p;8aguL!wJ|I0r_GLC9J2g)PC3X5iUrbtOo{Dq-ymInT+EqOdJ}-j-jNe zh~=S1bs8oL5=T?MsI9FfA(!;A0LX#8#hsTrbY5)<;Sg2JPsW7e#G?`{Q^Rduz z9SI;SrzLZ-p{mxkUGH@B_I$0fZdEhX1&|JmldoFgGopC+g2z z{wNi2gU@{^W@KhUXNdotRvSPgeq`=srEu8#8yiS8y43_`0U<;joTJ*U7yvBNq`YhYKC6o=Xts1TT6*m03 znXrpc=u8yVzIfh(m62Uhe665{xkx_?y%u70aguE8Tn=Gk;Zti+ABw7#u#J|Do%u^$I+xjV+_9%B!Io`kx3w+4%E1^ z1O#vmevi&$zT$C(9}x67RS0IN;HlPIH17R{CCD%=Y$mFIAt>NkSbV-!ZpXcoYj3M= zuhNIZY3U;iA>((M`?xbIO~`@3P)0=WPu;&5+I#ZFukb&0%@%^GHKOS|azwmretA@$ zi9sVja+%JN{W*M~&Ztr)8}9u?>5IOv)}e`kFvh>E#c0KhaF0MP{{3|*48~a`mT^AS z!gi#aUcb?*VC2>HA&PXPyXxDwkKz`LOzuZE%fk76X%a$|H8DF|A{g6HuY#FQGS z+iF8~QiHeM5KDJ7cq*e;4P({I-AZSkuRa=}z8hfrd;g&t2o!?F@B-s&R3_CuU~7`( zV4aPNACm_^L7)&EMqO50Ek@p9N+KU104K+*d2fDqG?+M>OD7eOjM+Sr-){*Yi`7{# znv9kT&-%A1=Odp0Q?5K=yy$Kfj$FsQM{H4OQR5#(zaKo1ph{}#C=)Z>^pn6*DavB+ z6E{{Os>j8``}21XO~iy8-QNx3Bzt-YqaWU5?%Rj&p9dsio}^_SBO^`Y4OxZdbDSpU zkCL?k`?D3G%BSHF)y6`YboJEXrvX&Vf&P6W+@K>`mwsKlee&^@<&li(hc&W-)gG1@AmP&- z-R)+xbK}gD2zZujhJOvUCX zP51RvpULJob%nkGSc3b-*+m5*j^94!@evvEbc10s64BEqA}zkulSLv5{WQ^FfgD}LmbQ`I?mVkMnTgK(6!{_aKaB@ zAb&wDx&cI&u`}BeB(C92Z!}S8Qw4>%gWUTSLu_B;3hi)qVD#(w+6hNw4gt2->Fr+M z_uqar%<`zkMo&;r=MlPdsWU|CC^;8e;Tk1-1X?LOXv`v&9guL1E+02T*-Z=5a~Y3L z>js|t7z;B}MV~d4!NG$LDp=)X5(_zL3}y9YB>tYY+m7#_Tiy0h<~(`wztHHA`@4wM zEkuH9{Ff43f*xtXJkqdGy_S!%xq|lj!RVv==WasMZ;FbVmHcY9@Qo zSepPh0pv<$6do_5(BNdkL|acY>)UN#@->~Qg>f?zQ4|@1nFn2p6vQvF8E^e89i$$aL6OqHIm;-}K zaCLm=__|g&oVF}_mJGoLBf^j_xq%9$D}>Ri5d)eO#0-C*5{@bMjo;8$Yqa$Az;C|t zrg~NdZQ{WVrO*G8K!E>bqR9Ql-R6rEeJyr%)H$}7dRO#4$P#p(h+nS4P@HRYr@i|nEA(A4p4JIadL z6*uBhwgOyH34l+(d7mt$L8*bSAQsNr~Rh%H0j(S^I8Y zYiM+uI*^?_RF!;cj?8w~!aD+1`d&?*#a&&K#h#v@w_AH+TQ93`TIl@iE)K5gd|SyE zX0IH8@4~aNtVl@kX4Dq$T-j%&B$}R|%JtQ+K%8ST-1cZPr`dc6p;4ouN`Z@ut4KcI zH58rvT%``59%fD)wh9kMc1b1h2J`n-KjQlXK_4sQqFjKyK#U}AYwSBcs zpQyRwYtt9h2N=USw7}&*%ZY}Jg4MPt+jd`aMLljB8Vdb7eiNal(&!L`{a4Nv8Xdt) zVglxQQ)~kg#eIR=DScNxtV;YH>EJep4)= zY7sEJIOb*#$1%y8B9q}}BUU=PB9A6WXR0tX7(cbWet1{Rrnj?@NfW@rg{t~MD%d4k(fQ_;j#}pMAKCrI%rvi`X zGM?vuGE~T6wQE6TEf!PpQ2DS}!3yr2oE%QPkZ|umfXy_mz&aKB6&g@GKtQ+D@npl} z)gg<=jo#kK?ESL7r+?WeB`Gv;>B#TaZxlEFcj>mybO32tQdOy>SvYqBf01mA7WIgB z1bN+eFM4a;)&=n?`NYCC3{>tuxmt?Xeue?2!@{E`U#O8{Ze*kD5B3|6^!3okK?OkGNHjuEZeAQ#UP@Y>G2-@LSg2`C z?9jNeY4QYSTp|-|pZizVIkC`JGtH>$Gx5f%-M{EFK5o8UQ9lKGXFNp9uRFVjiqkkb zVbD4IZcwpqdGlr11J&qZi6(aMJprZetCNJeiE5RGvvb`YcKF}4Pzs`9Be4YU4brxv; zc-uQVy7&BLBC;(t9Q945Y2VH~bP8sinp7xt!uAqR?q;qlK_*(4_<4j(&D9)c!hU}Erwr}q?7W}_2fd56cfO^A+ zO#3TF4#u|D60x{l*jQ^H(jB&B|Kr|JoL?twnv#!2-{DpP(b+3xIA73xwF&&M(3q-U zUdg`V!X^0D7l9tHPQWdqUBRzPL<>v+`sRCIfWGDOY{RUZNk?@Q% zk?|60&_H?|se3x-wIwXx8E&KXIm0$oH{sZfn1#*18wuq#-JC4y;ct0wu5)5ILu8T} z1fe1rS!dx?=p`0hX(FeHz8OxxRy$p5HXM=i6jKb%14p}mdWj5|^;vfoxgz3n z#C14C~8Ndz@GSO7Ka}CRqnciIJT&{Xw znkp74X%P`d)_{@^@K7J?2|a~QQ}Z#6fFbTCVk}P%21AhA){{l!Gc)UZgdgONmdK^* z5rBDk-1o$*yD;Y`5S1_-&7lnLtUGSXxWf=#>z%nyaQ#y(=v*qVI3 z#1EcK<(fYLD3#sxWFsd%Prf>ag`J5n@;P&*dz4mq^fe2q%NmRF$q2FnHv7U+ZcY40 zh%y^CF`THmCZ+Wm;Mg*kJdKtN8yA{m(9 z`y9X&JW2*x{u+b3$qIa)Fp2ee27<+Q4d8#T0Dg=K(Y6SH+fPzO7mvpKKswNS2cV?M z;Q?5rbh;kn!R^OzxdIM?Qw2gX=DzOsPM3vcN7}i3X1q)$J!!EMRaSgfr(v_Ze+A@A zkdvClH%vzp%qeneID^B9 z@*Odwq9_GBD{wb@yU^KXG1JsN3%b~tso&>Aoy01Ku<~f>WoB}Xbq8jYl|&~J$CSK6EE&U=g9=>a zFYv=2qc9+zn)1z!(pptTDkCljtOT1wn46{PDwzMQnP}}9e&f`9fs`ug{~omDn%>Ih z3D$HBEi00Fk{QlOnS3)Z>2b>+x=p(=_HP%CYw#%(N_=#Z26$=QWBd(U!<1t$;43V5 zJKul5SpWV&Wm$-=ef`E526dXGeO`j4{nuKVXc!@(D`*6Sv6Zq&jG-(cB5BJ3N6ko} zJB9#_Z-sHcKe7V?0!YtqHEYcsq3+qbD*LS7J@vJSswC8sqxKeLE~HCU46SFyPC`ao zjUES;%oG5m1ftgT88@A(A$6on@Ix8~KC?<(QJbafE|sctCSSl?HHAS62BZmEzsPl{ zOBo)qg*~XRbv8{#SUOFSAxR1Unlvt|BRi{V=g8^gC~V~U;z^K^C6=$)ug`n8JVc-P zTVftJIjDdykT*kfd7go^?l{H2>#GTMf>IJLY86TaY+oPNz$xk?jV4Uu?ku1jNNHP| ztir1yQ0;5Wf|b=4WZ_91VBk~i&Feg8I`q05Ohs;P``x&5yQ!w9vXh;zLaDy@#CskY z-3KXz`U-igN5v5n6N6Bk8)@x3uuG+bY5VvXE)m<1jAp+J|6-HWB|%!xJ&5drokx~5 z)7cZtEE3RE5&P9INRyFL^7!nOj1Ut=4vbWh8xt0)17u2GGcoA(ob~h+S8lzv_-Mo#nDQRH)5FiQPI0D8T9DEoRdFoO)Kaxg_FX4d^BsVBH-z9HW%k;Xcx8gVv-FU%E{SHF+yjw6n&yOeSd(bsnO=L6aHdU zZAs4VY_4clafj~p#G3kk>)W&1x^H`32I}td z@AjX{s$CbG@o$hfR&z?z40A(+I4tQ22*M6%v17@1>(^~^%Wepw$8kjD&$etK9J}x| z9or0pCQDPTKj6O!*ch9E&h>(<7?(sj{BQQNP45Z_4JgkD-XdY^EH=^qEBrGX0xfk_ zGbkA@H{?JWg)7!HjHHn9x1y+Z?e`NBNrd2XTI4WU?;kr)?=#UU$!zRE1>;T=Q|~JP z5H+hB+LK)UU$uq(gQw?Ni3iXoShKnAm5BfL*;b@cCTB2wnGSTxv*<7U z+^bwkw{ZCvc|t7ZPAZl`f`3Lhjo`At+bHz~WvPflNS2RE1!rWLY1=cK1#$-L6r3ySNr z9vK2)aHUD*y(`mbG@2HvBzmb8m9eEJ1O2n$WIiW=119m-2)Kow1W68hvC0PLskRH| z{_5h#6%iL`)KG|4Nn-EsOWu>O{{s{#MTZ>|LKuJ2o(K-u;W@HaEgYnsaijl&l%CV= zMYnYnI%t1|HZqMa0g9l57NE{m3%21fsc<53u7-Q7$xja^BqzZXogAQ_flgoAEOuSB3O%l(j%8sYqaS zTyf`Rx$U^@yYmg1P8T{uL~V!s$riOV>3X3s7Nrz-uKq>^D{Tg|utgcjT(7_Q`?&o{ zLW@)pg;YZ<_GB9I%M*D#L{OEk_JnCia3>?xupgyY{$z5h9yBkIJId##SAbg_tf3;9 zG855d?47&=n1R22#$EaS4C@><(rIt6DO_qMT*F4XO%H?Z2jO$%%pbw1Cy*+9SmQYT zjCMy=BZGJy38-A6lIn9aaLT_FAm@W){p#O=t-B`n-V~d2lUtmfrIX<-?7_J^!VF!K zi|X*2R0~szfbH1Y5Zl~P948Yzt1005w^Cnjw9zJ;J%ht~<%a}~m{Av5SqmE6`!z0~ zcz<*rt3&a>ix7kE2O<}2@AA`(;18OWcl9)p)FBn&XG?v65>Y7c^9cLr#c88e2}$Df zIw?q2fdbmZ$+<>#BB0q3Qi;wIgM&UPH+N(6Lxtp(GF4t2&#jDRP~BLX-N-MH{e0Cx z*(IQLd_WuQ)41I%2@RwnZK>v=K&#c(97WXkhW* z7Sqvh>{fN}JAiS_-JHTC2^I3U_@RfPk>731&rA08ounF2%}Jfox(bt#puEm9QE?uq zCS_g4XY(xp_$ay0mzADl05I+S*Yy44F{p6*fqR@ZO$ER5da%C|&3&KkWM@bnWKk2! zLbkixm!7kkk7^6yyy3`xqXByX3!u-#LGYPD5<_gpyKc})99Ko^f>+?)|B>wO!EeYp zU!sgUzRvA@Jn%G{tafGpqeWKUjt#b7i(tjITf0AJkhdI@w;v;Y!!q&P3RD7>)=bAvB)ha zVa4g~>~UxaiXz0y8?0c>6=6=2)1)!`Xhig775BaOCsO`VC|X1j%Eq&1IJU#x7E`%( z&&ZdT7-%DFMo6Z!jKXQlON=wWP)iJj{5LGGNdlhG@aS2vnvwaDYzm+cV+mk_wUWtB zw|Y0HF!2snY!4#iMbow~sQMsvM1n`+!UBJe!(KoNj!}e?by*5!xkxIGiVK{cUlup` zbpwgIkwZ5#V|e?$y}Lbi_@|{{Ry)t}v|e_Kq#RK$bN#tm30sN`=YJPUxWfO|vvk80 z=2yXpCJBk6{Zua3T~^OqMBw{BlQiia-QS*-;c<(DU_Kf(g=pk8k;(d;LB%MM=7Yr* zRUtUeo0K5TTh0#aed%8@exL95gjORm3$7o_JCW}<+_US_Z*4yFlI^p* z6q4lrjTXiRT)E%K_tq)Xz5cTv07ViI&jYa*l^KPgwJCZccC*jqbvMTlZt{p5^Q`X;BHm5k>?gk0FOswdyV=rMgM_HVXd5VJYr5&|@0 zUtgc|cNj3sn(D5wP!zJPaXZ+`;fE+wlFY6NxUgJjlDwsV-{b3`i={zID=p~-1uWcb z6PZl6x?TD8ZUG!oB?F-qjde!uFqpca7Ct{766iT820A+9i`NQzo347?2@44Pvf?O& zqZf1}PAR58Zi4u&XjjyO@B>fygU)GodP+^4{^Gz>GwM0%sov7Z!idbJT{y(v!Qlj8 zmt+naJ7ml>qVU#JmRtjp?Qm|#RkHe=YDnxW0?leOxjD}715Ru3bMuSsCEa>wXhq^- zbTo|Vd(YqRuYnYgj#iijY5%ul{F8NQUoWAq&!KPQJwAz?_|D#yhGclzHRBBb=yJyh zC)q%zW&rU(eb3_f^j5j4cC&e6dvl(|R%`L)1uBXnLS97i9SF(ta)0x>S6-h!P2{Qh zi5FC3UKHV951$j`T&i%sVy(^@A9tiq{AblE2E24wN={Zz3;nkhSVfQ+c$6cKy7D7N zru})Ldzy-lxHv?V-M}W5ehPz^5+UE>df$zSES~TSj4i_-&y?g~@`pw{j$6cg0=X6k zEhizVk-<4&N=Ldp&po!Y#r<%c1ZrZJa}*<*6N~ZSEruel!IRWmI2#`42Vr?}hnJ?L zEnF1bKo=9qxCH{f9VYCpY|dxRqKYJ(GR^)iHWkWFBpT&AO461k8sovB_($OQy+#@X zaslvxyNaeT`VYMl<98ukD@~tYeGC)2%o@0KQ8Ql>M5cyxhX8+jB|hYp*7MvtNYihX zc!N-MkOySFWO~&P+Ve67k8*gm2^r;Iw{iNz=4w9jdJpXFoe`$+GS}6b%J_n5GtewL zB7JTTbDcQ3MZ%PSaK}QNZ-M$ne|QoYY*fT&lmSF`9f}6@&`JkK5GHL>gAuz+*?B=d z%hATk9bDP2Zeu5#p4Y1av_jYsT&_qU9uSsD+hlBR53#FYaX&=nP2{Kg+c7En9Fq$5 zP1E~JQMut?v2@e(+12ezAKl>Jbrlrp_M8i?5vC-25J>1_Pu~XnQu33E=B;F7Nb*&6 zW|{r~E!W*?os6G5x;aP})`36GVtk3dkB6pq2? zEo&sf5&@fn2O?;#WsaC4Ru*Rna}(6@@P_0>0#IAblTw@rk`+A%~FL;OSb+Lkt$+eCr;@yW^gU% zc8_TtQv9rVY6wu!h7Xa^aIBJ*KN6BcW?eV5&TS3Lz$kY&56hezw1t&vyc6r3`;R@f zPT|o7=$J-mhR`S^a-bCG)X70+n)+%qyUW$}-TM4QqUfzf89mXQ?z{y5sD`8)=^fg? z+?9uFnmf_3X?`)ALl=%=>|R7Fk?cdNHuT0%+PRYTaBAxTn|q;7G7}8!FWzhpsI|9) z1AZt&)u7H}wP+XBlOrDT4Pby_%Uqm*fIxnP0`RKJv~7Q@mZ5O=ws5rg(GM*-Ju;fk zt5$OLvf@X-g#*JJy3+mSbG(}d zAys|%JT&Cw2eJ=75ZQP0y&|ifkUnr2JFY6Y(Yd)VKTKg2TYFs%+4!X*8UE!a1P1%p zf?)O#75w}U>>A`3@j!JX z>V{azOFHdFDjA2hI(7wno;jHbp1G%fB7&Un@2`>q5w9>=)b`aN@&M=qW;>bscME_Cx zQ2m;Khi<<}Q&My!zCddX(>E{(aE946L#rnG*oZyosSG^d2tb0V^vKQj4@UDXa0o_A zC=bD8eB8YeUAu?g^5gsnubDyY=8WF0JMlL(S)7#dMopR%tlJL>?}gM_;f%xs_GoAD zGhLOT^)7CPLudi(bmu{;0v!WGTqM71H<{LGS4erri)*t!HC>6j?s?wzFN`Bdd^=Mv z`p~hZGxVJ1@E9pyjkpmW8~;BP;hFur6%aoJhpnjGb=h<^i&eE_j^ZbqwK!Yh$k_}S zHJ7<(zZcZHZ+f0=f+=R!+WJ~>v__2;w}Nso^b(?z5?mi$2L=fUiN$vGT-lin+QPoN zRm|3QqsPE53lM%?7;WZ_MMNz=N>oP#pzO!;`V$`q*~sY! zi-6pyk!{1{1=KPte}8kt+axuRq3x>uX(HW)fHIKkz6 z&2$D$qGtJm(+W^6& zr7G<2t~*1!pSMc}37b?SAY3kepQe$DI+v0Hk(*FDWi~~GvZRUf901CE_NCZv_#g!f z2NOOf<&2n^QPcOD(t{4V`W4wQOR`(~q~_-0%VmeDIdAnZ{F)2?ylL%hhZzg%O!!Hbrps{~k5~^s23O$6s zKe&tE93wH|a*dnk^;%W4F+3F!|Kdb)`5z^R**27m7u4OtL~XfB;kh zjZ!Y>0mil&)pNPXyWEXxz989i863lVjitc>pXu? zj}a^%Z>v7G+!$s(lK!DO$n1L$IQ)U&maOx>Thi7vP=WKW`7v+h&xuu-UBrlif343R zsvwfko2b5cw&0ORMN5yYDMad!yd_PP6r|y!kEtucfLtmrb}%cM$zK|p;eR?#rR6(M z@!Q6oxl3gqI&LHge~eq%`dq3j%e{R*@l<91PU9)y=JDq%iv$W%7k*WXnBD^hbrRT6k=)O%rPrS7W? zIkclHI?gG{sZ*NyQSW5&Ji;6?{XVHWl4ujXV9kwP`@wQt(;`3`+dVs&YzK)vwNcTO z#+r7aq=qVYQiSb9beA3SmEX*eS&8Haun)!vKcj8b32~i#2#Cik@^m43t3v9BCgN7& zdbC_@;$8RJW^(1rRS0*doe0nlz~M@I8)OlVdYA~Kq95HyFLxBH0-sm1jzNEM=5cEb zK{=0e;lE*O{f)RUHf3A_x?>B9mCVVsva^&yipP*@*s(U~GtprZ9mG?0RNlDH(xcgz zdc4)O^^DtO@c)+o%Dtnww7C>;B}iM*jK3s;Cu?i{iVgp!xhcMtF+B73f}tI^jGOVS zkBy2onZK|B)(K_ zm%0c>`YPsWMrlWM`(MdJJRnxAUfSqW&(hz+J?P45y*UO@tI~Fhh7q&!be@!bKL*+o z2^&%6ED-2_PGewf9suW$Cqd8zH^yQ@3=}!1<^w49ORrR(V_8A)(iJ8q#~C6}dQy6941HR!ioMQ;IpIx$!~<ICQhQNN@?NY!jcYO}2 zr9Ya{KD{DH#~vv^VSjST!dz4!@Tt4g8(FRkbI%Ej<-YK|>m~xYEMs zYQ}Bgr=6i0PE?GC?Og*>AXC8&d)k-G3?4R}bIv-a&)gJ3k!!1>_Rh#{qo&N(vrQE@ zB7!N?d7{`oF#nWeQ}NYpMd|w73mY_ZOOmLMWWsizE#~^y-kkxh+p<|jl(Yc_PwcLJo6bd?ubA{^eC$N*V zyWvXvdSIA5_xAQ|S&M3F;(q|yWj@q5gFb^#(E~Rmn!Po8ICZNr)(AAjmT_S* z-Y4Q>OdCd;DU@D`YMPQdP*uah#U8^9D$&H07Z<2jH51e``nv3PHx@h9Y~k{Ys3$G& z;`_(+i;?;mZzQckbH{Vg!!{&q;y->|;f~|3N0iVm+!PAx*wd*d$1&;)w_F7b+RGu9 z#@q7FEMuaP%EbLux6KZ#iBqGZ)$gkU;1H6R#2cTt`F@vYXQtqcva^|`eDi3%nRWsK zj~zSD)81Ews1%P4O^&SOR#(P;2wH4fKauD0$jT-CN#_HN-Uz4hC9k?qmNFSm73|nJ z49&i;=nfVKQy>`(tS{S{p=Zi)s^H;~Zg@VZriG!{8++HbZPQBt0-A?(*xPGXc zR-<3fTd)un_PnoiH2+=wh3Aw}su1U4>_&WOX+3*7#MSI{jsc^-1SS159(?&`n0|ax z5%-D)rkEVL$kg@wG-l`*Qe>3G*@#x>(_?(B0uE1P+w-$xn@5r?^Jt%fQr}U3on-O1 z3l6`N7#)*h@9kd^!td3n^Ql^K*;BbxJG$w$Y`gpCmRD>+rvU;LsrXAQAD*Q7cIVh& z2}w<<8up}?u3js{G`;~WCLCNg_$#KPP0`NS>&Hj9eiQ6h%}vbFcZk| zxTf#sQNO77zJF+`TlEjMj@Yf#8QlRDR@vJPljdN7_kz#of`t@HnbB=ObTd*_u(DHp zU8gDrOkyeH-#3VZ45k8PUqni3?@XinS~JAv75=X0JQSoh1q8F{a*-v3uULRpvW%zT zu(MJsElnk}Sr>9C$rFYA;y4q(+HI7(R51}Is$^!kE4kr@03Rv@dwT*D(pBQD3~DpX z*2TkR6_vl!gE{a#j)>y*-5cl8L`yVpgwFSMlu~9taI5%H4+0%~||L%9J%)X6E zhZ)tyHWTIIr#==)who~l7~0;|d)$}w6Db!|x3$v)cXFvp_JnXmBfy*NTi&&l=~?B9 z=O~73fW8Ye4mO>7ktwgK5`%i!pFP2u>E6dZzNoFHam*0aA73bd~!I!==nb9-`2|;!qf^F5VgG zD!-;Q#TINsdO;37wuW$iU{g76d3o`k4pi0SV4IYzijrEPS;%eJDhpD--ei-cZMrsh z`rM}(Swq@_-WH_A{0vIcx$c4}VWzfP6;F&myuRu&lPU~qKW~yq-~-(ITvkDu2GiE^ zPY$Cru-S>z>SLg5DvG+*nu4>Uqs|Yh3NC|Gga0Lsl)v^l54C}zRfA5yw1i6k*H^Y$ z!zD>E;~hE^37Hw=vP~ydBJ+JiVg0AVp??29)zgjU- zv)Mkzk>HBCoP6kv2ul@j7CmvH1SQ8yre4tReEgH zKleZ@uj2vg;N@;!r$=VHxVd8eTN}!yIWlg(J!6yNrv;X)mX?pCu4;%Ko3APDJe>?87&v~C6hrffKZ~&-id}3Ch#_j_*{lLRSKc>8va~1FRu~v zO;}*0q$2Ih=L0N@e?Dz)`26tuk*fWdAMBz(|DWe*+yTxa4rZvas*LX1x|;6WwuW|W zSw(r(WvZ+bV2ZaLGytnLcg;irEH{U|<;*=j)T>pg6O7Y3(k5n)3KR^_8R~5W1 zr16p&w2tbk#6kufuysh>9iL3?vwpqaUL<d_EAarV~P*M@EBMwX)9)b0v!z2 zFknp9Qh|6O@&Nw)(DdC9u-G4fE4-&|a5UcA0I(LmWY&#e$Z$pOZ;UWE52$=W%b==I zK3fA&4j(>DTefVWhK7dMCAfn7`tSVC?}*l_y*+Bs?6K#>NP&^2;(VD=nj51^1x9F$ z2OBu6LxA1EgY6amd7)=SM9`Id^EWg23?6hLN^iEgmCqGO6RxN%lC23F#`}SpFBmg8 zq9aXL!>ka}4tdO|fgOVR=!C<|2YM@ro0rvBsNAkTHrO`6Z5^xxstWCav|AO=w#O!X zp1sE|8r)+A`Ub+R6lqeJA_4%S%I}GjmqkYv@6*Os0laVy2YE0Zp77I#+EO_`&HbZv zrfX2d{LpkEO)GN0aZRvu;co{iTUVXfN=LF)#<#elMaCl*UrH_-z(ucyj_TsqHb zJ@~F!44B8(IGleU;Z*sP`_IxSGg2#RN+_Qh;Rx0zU@UsfqTkbcIfKa`S zuh%7b^Q-7iQin@c*BMZu--UfzW*p& zhTwgL^#tFbhJ$gr>c0s$>VjtdgNS$ zQ;8mPj?Ag*V|JmYAV=7UUrk9!V0x*-*A~R}dyN2jwXFr|e zzgsx%);Bs8=^UDf^iNF10Iao(Dro(ZN{dxeCrJ%tMGQQ%uM@yBfm4NQ%v1)bk`@jc zYmNDB6X!KOCxga@C+N?<{0x2OyRQfkokI&Gfr`&p4xgd#Kf8~<{p4=>`O<;Y)Y089 zj8c#p%ks(+O3uq6FS9^N%oHWzde@DfHJLNReyMb;FpSI;0^rMkNy-QlY>P+Iu*ymn z?(M;eNzn>!SiP9udh=RZQdiDt!5-O9D{W{yZlun4F`zkoww-R|6nGgkv=P55P;lNV zGO+y!*q%#A#tt=NHxAis%4HFZ}M;f z)*CM{Vz-dGv7o}nOjPOe<;zr1P(WL^Zhc*Vt9|?S(Qo|5Z%F!baBxr_gSNz(7r;z% zB?FRsR@Twl>SCeyi!#$CH8(ghx1JP@jB!JaX!Z$UsFcCgmWDD~UsFm2JQIf_dmX39 z&>o{wn5Yb@U>4)1MV0i{H4V~QXG-_;n=MSD1A|RIvnpWIAV#-`N|kwN%gRM$H#Efn z3EPGs4|I9VV!@!(+`!RDw}b&yMtWKVsls3|WiPOKC7I=IBC)sKJf$MmJg_gluYa|xy_%^GhUMGHvD!C1DrqnBRT ze~Nzm!a;g`=Mm~;uyyuqE1fvgLaDsoTux52u5 zFsSnKBq@pC<3Y|gSuNgFPSZj>4-kfR;kEcIf)_2p^HT1SEE2)ct^O@$3Y3WLH zPiW~Lq*sr%(B{RJR9#anrlT{RQ*+LZPuzUNqtns-3}{EEluZNn0%=Ole||@`4x^-2Tx;SEqH&xlt*_Fi%7} zWKt6+u)P<^4L8WuV7*aeKdD}Ye@VLV1(XLw)qnP9e?}WNY@l0jy_J4mF*-UbI;+3@ z%fGaMmAQf0%iaO3YB=??Gr+!18 zo-ruJAmh~;O@I}!HekNGMlQsp>TokZdEpA3x!gf(m(+?j$55T z0?USX!Aom%eJQOdFQT(OgO>T`OprnelBeTLIMSyid1KydUW4t0X)e9;&o7K^M=}HA zv*+6B*Z%(dw2OfzLc^T7*D=TXk)lLn=g)lgad`}>;Ku3_THa7ag`Co?V`gj9ibmSF zte$dnvne%=K^0QPsm%P1Ptwujm*~*xX1dZdAO#U%j7kf0Ikh)IZCwNMxfL>41Ir13 zi|1hlv2Fbl%ID7(7b_Y-gTWf&^(Db*)gPX=pXPcyda08>`q>}RBY*iJs;(=eDe*nK z4xgUwqKE}JUK-+b<@n^BT1m7CYjQkhcJx8AiQ7#qDbJ@Hcybl88RFWvZ6+>5lT%WG z>vc>Uc;k(fo}T_QO%(=t zCjH!V&q-C|GtWE|b3=Dh?d3e^+`FPySS1X87(k5J6tA4(bwTe0ntwH?GLXuG=1Q6! z^YiH#z>0He4KrTw!2+Pj=d>GAXy`A9bwEIFG$^`&TR;~gRo_9!bdzSVQIml*8XWgo z4RFV#dN3lIConXrsmi7xqSFolY)eY>8Q_&m>dk(Hs2Kd_6T2ygfnIfKp`->4$ifSe z#(`)US|Z%!u@3-g`Dy9&{`+t8KEL;*@6@Gse1DB5Sb*Mc9vC53ujKRRWUTKg>0+rq3V%*SX7G zvY0?ILoI`$Do!0kqj&oaDWzm?3@vh=;QIm~P*^l78<4 z_ftMIw%>SS7k%-E&%-jrdX4kF5t{)1;Mptm$XbQi|kP`Dgj#T4&$F8UsRaj9kp&i4${MNYGH@eF&4w+R*q zLU;5s#41ryhu`xv(kVA1S<;%v+xux?!WfLIb)IYKqVGMmkAC%`TLfsCDmM(A6PzyT z;sLd`vRJll(uiYx?y5@iQ{Q>lmhfMG@sR+7s#N=VL~lZXtOpo)Lfa$z$NPhU#bZV; zT@oMBqeoEEfI;c02)hSVLqecG6Dw#656mZrpOXUE!q%~wK^lV<1|_BUdHuC!lfIG+ zJ}z8nr%!(2F$-LIwc)HB2mb=__`lwHGyTpxZem6*$RK5kx*5>JLB5Tzh2za#3`p8V zEPk~CX6pC_EN%4EuGi?le)OV%B=oF|2jXS}cA~@M>+L5m9imr{oR#D0wN<^D<8ne? zdzCSMe(%L&QZ4u=AG)2E@O2E+hlSSCDLq%%x;hM|F?uZL%V|1+3I+?U!t7T5Zzl!3 zVgki6=zdVZ=QW=8(ySOoA3kA?qs0KY7on>7$dz6xEQu3)NQvCta6`fcSZ~ZQ_o~yR z1ea(LVcj_Wen0)iU;G7a+qR7sFJ4U7Gms91^#Z^ZG+-v?5)EU3Zyh`%>AhQ+H2{zb zX@_#t%0?>Ic=1IPjh`#}6ZL*!ka+z$oMM5`QzlxRc$LX!D6E^wCP!0Rvc8DvKNy~- z7s8)eoOT(W9Hd8HyEskzb&At8s?Cm?<>sWPP)$L$>_0SM8|zBwxif9r={pKQ4xG9~ z>zCEh?ORrm-vdTM14*WVdImA_NasSP$Q=i>?JhpaG( z!bK(d(jI6W<@~{@8@m!Wt5=&r)*LC08(w0S`HQ62V(Y+|)ngF>R*zGTVfEZ-%;?uM zI4+C>1kd(VsE1y!yj4W>VX6Gt3^QZ*zyIwqI@i``of~Blp!T|m)6Dwr zGks^w<7sJWbkj{Y(WOh5sHLUFdc1!)j^5gKJs=h_(>XXYB?hVrBu7EkH=aFAXIgvc z-+%Y*w0Ug<1sJ$3q@FIK|M6P8=8+p1%y^w!;qSjEj?NAMYZ)nkWiB`9u2P-bYU1xj zePNC;WAV-_&r92$`Gm1B=;Tz4bh~GU2uB0`6POiWKvi}@ z$c>pL27iwhj2n?QgzV)!IAR8l6#`sAXY}2J=cO`kkY}k^7%cQN=V#vx|`DqePdJ8&KWdH=tl=(uT-p& zVtzv6upSm&O{z`s4k3JDewJr-Q%%yIW9MmLcs$00G7?c%2{2KF)@LT~rFf49BMN-t z+`&*)nK9#Y>6(MPO#%vuO#f zttnQmrKar*7B3AkdyK-AcpVFp`1$h;oSolej$OR`F}Vzu8@6y6tT%oDV4CL%1YaGH6L_^+L(EQ3HL%0>#yBTZ_H zGBcD8iPMd%IURzTu3tB2#{R38GxKW_j4DESO5MJ+n(kUwBl?XV(`H2<=gCX$wEO4< zx_jFyO6I}Fw3|VSWYXuOeja=(OH*RpxF@Fqv~FoF-Ff3Gy4d!@vNj?B6BjjrA#By!K|$Xh{PD(uKHDLd5>qV~^3F{K=oti4!NHI}ty%5i>v= z&C4{gJKxBWY6Ivvbh(F~<(t0|#G=)W*>8X)Xo&JSg#(v$(_;*P$P9WdeWTQFYtn24 zw8((rb$z2#be0*7o%~qIsmt`+x2=^%%0^!VkEI(M%jqsDKoL)#XW z)1$|lRarYlXYfCM;Q-ZE6w>mBN=^&;tbrA;gWx@%2i05#fJX3+0UgfbZ`r<%p51eT zE?wz~*(QJ~j1bYA5fzh@kx-14fcXGsi*O_o!+rsH@i_=HaK(v>xp!b+-~;BYxQb|i zrQE|k^m>3=DGa7@Twi|Tparfh<4=9OaD)Hu|9ii*hVgT%HY{zAX8(>zwV()!^aEh> zCIfQq!F#Tr2X~XJy<>~|jK;?&shd+y=dSdLo(t8ZV3Bs6Xt5YBbDi8!UrKLfmavwA zKQP?bRFJ`fkJ18@bo+7TWv8atb?R*K>l=n>;-3r%|{Oys{9yGw@^y$;IapOj6YHFfui8E);&>#QtAJd=y z>7P>5V%Y#R~=%`@Dkij=Yq^7ntR!FLA7_Ffcv(5a{8zoPkh5 zX1a*m1t6K8gSKPFfQ_3nDjYXdm&oym@R)(frQQ)?#_WMsIp_hmc4-}@^B@q^X*Sp` zpr0gCTDilhF_YFck2Fg5e@HZo7*L8<$zHKX&ve z{qP4r5JA2@-pA{QSU;QD7@wrtLkg@!)P$po0r|$o)zrm|T1%I)P*Eqwe3wr#i+neK z)|K^T6r7w-Z^!8YdBO;PXW`@?jzj@^4j=E?wqDwG{BjIp!7&%|H8!FPTI{PLB{GL= z`+em2z)(H>@cJey%gKiPi_wIk3J*8H-jPW<)jc3Ld$gPZNU6Seicr{2|@f+&@fLIORCu4+*=3)EZug z2Gk{-a)Fj9UE4ya-hdr65r_{G_Z`1Pi)zZ~fgS6$7(WW!`gj}JJg_LiyB@UBE{F%o z`)*nzj^R)3J{EQ6*2i+3(_k=Ct!7|2$SEBFE2KK`BtTaw4>&m~Ua`Oc12q?s3Fw#v zb-06p%+JmgU>LHs`NdDLhtQ9`KB@?rfy+pz3+FrOkG}kr^e&V~ZD}yDGyn8kZ>25k z7g2C=vZ6QRDh5S(D_9z56(YW?V-tP`m^t*n8H7RXy_Y97_G(H* zFN2|PJadGWGP{?_*V5FrfGoW)L1~sw)|pYhax^XBhDl9LaW-}HxtNOG|9W25le)i6 z6P2Qq1MUZbQ2aL3*!GCeul(v9A2-->XtL1UJM0PD>apXcQk zUhd|_V{Wi$tPIW6-~7$r(0pR|?%nhUfA9yiYu7I8G0aHgl#d_bbVp4po5TUTq?}Viiu!++fy<@Yd=FAOHTAm~;VLEPmV2t2<^YFSRsxHV8s=tj> zGhL$-F<=V=8_Y@XUQt79nSHXS6U-n377FYS*aNROPkItc3dxpcl9nc5pR^PBAhQzt zT6!#Ttz8de){W}GAHR5r>MDw9HM0nlQ+@?fyvhfxy=OrBpylOcSZ?WHz6u#Uec&xO z(VpWMXjrPiqS_stzn3}XWVd=yF5w;lT3`p^3j|gUv-IGszC%b8g*|+}rrkG`6$8ao zNXk3B(e@%g?*P#AGpqNR@4Q6&PhXM8oTB!RzilhMce zf9GXsSK|ck|MuhEl*QNoAARt4VTZ4}k+&3cs1#w6F1GO|dBhhE`|3=`K`RMcmW$mZ z)ES%VLj%v7$RTV;;5tXA0!*65<5Y91^d~dE{F$r$h+eHqN2omWFrkVQr+B%ne|N!^ z8!_PmtT$JH^Zz6-Pw`Skq#WLHlm6ltzbMs5n>TNc`$l_(;PCLU^bdsf0vIfNgkz`t zP;>t~FJB_%a*Vqh{Amj8?}2c)o(GN2k#RA=Owt3g#e_iH!#Cd>dG^gaOQ*XAWq`q8 zU_h82C*@7hO2G$eOGAYgtg5HrfrHuTt&6K@`=SaFpkMCi8};y{7?(oq4zWDi>XdU@ z3+-tFhDCBjFr=&;uq^F84@XZ|{(Foj~w_@|>2m{C$51gW9 zyzkKMm=q6EgAefdW(O%q0|X23QrmHr*AKCm1~ zQmoce2CzZhcQ2XGMY6{mvu`*K=+VaF3{py`MsXN0rQP{?k32XhUS<;y?LAB1f9aS! zX7<&9t6#hS2KvP{}WoXhI zob~=b-p*&g|0>loK-sajfo|dyXdSb$830naK)&=)jbU|q&`#ZHQvD%$C<_waG2GSiqU_Y3=}=H-JF$C?36oxwP( zf|cp(6g?Y*E8KggUB9UMZcMfs9+1VFJwI*}JjcMTmtT#`XI~dgG&gAVTRx6EGUYQ$?+0Fr8{*)IWu+NgR4Z_G) z@7yuWtU>)_=4kw`>;d7hhez*n9w1R>4Sf^_1eyKwO|Odw4uBjCW*8i*7#uBSMkZN% z+E{upQov{|tg6A_PXz`?O6D;DlQJj;Gl0gop-5y_=0&-Cd#EHQU6==exg_uOcYqemL^W+uA3ccxyY)z%ln{VHFr<1c+P7^) z{y$F!3!hU4gHo_kUw`f}4eDw=`&fJXhDG#`zxyENGMhIwjDh>w_E@gvOw}pjN_)Rl z<~?`#0_{F=na*>nX(EBNn#b`wcDI#TozK5~oNizS_WsokJQ>L6>s(b7X7EG>X|89_ zbxBIm4$zFZ6@b$ze(&kaJ@m-VlXUl{#q^FHtEsXmN1W+R1uMYTFte^r%;Mg{la-(B zIu(;jMPOZz?LI~K>{un&9~js97Rz8&U>0$0BmHhzV%Uqo`obAwyp0#mwnsXh8!3E85wZ!2}CH9R?7zbSdHmY!^~^@Wz5}s}2HT zo`o;t1!C?cyx?)=hZUajhL3dvY{4^2SqUhO6@c4JI*qvN(To{J9#h^OZ=2YkLT1Nm zcz*%hpb7H_L(@_((i0KR>O7m?u%bRz@a`cI=q9Hmi3r%FB&EEZUsGAiYy<-@G#8#v zOA}P3p`A}wssd0aItOUy#STfMLfCtf|Hflyx`xE+0;v+8BPCSF?AMNUjpC&gG$P`N z($l~aAoFL??0KfZV2$2;-+Sf|{q;9q65X2p)W9wMA3kgyFz7`(@#w`aX4odD?F-(E!jDzV2q$|y z^Zty%Ok=$R!xP$vO7;1~?|T`PqO#JyP0^x_Oj>?%b44DX7gYXA7lt?!CICCI;eC## z&IlYyE?>__uJmzwzuOMTh3F$yo&8Nx%>2)D4&lb@G+co7<_vfh!1eg8qH5EnFR9&XPj8!Th zrb+mMF&Khq*`9pEU{O(=PZ!#{q^U2$3HDNTvuiBqSGbbXKsWK=SdyJC=WG`bxTx0b zMWbDRP!<5kabDo+j#Q{G7@Br&0kCdk76@#l`Rq(;wwRAITdFyKJ^(9?ucr$PMn3+z zAJY|Bdr)kK_LuM3NS}E3EzFWC^U(Q4M9eAStCh?Pn7r2Z0s7kG2k7_z^9lL^vrRDR z429;e=Q5_Gb{&#X#QNmpa{%~#jR62!H$8FkGMzP*e?$qhNgWK#4l)?TwrV()mc`$> zU^r|QQdBYcDr1n02H19Rh}BQ>@$NswogHFcqkP?{pRZ{!wz1z^kJv##7kc1ww}3iFpc)$ff@jFp zUb!(txGBOnUzqXb$GrR_DfeLuT=ewx&^N#NP0?bZD)0~g@DJ(hU;nxQs~M^T5x5Kf zIVm$!J5oR=+n0FxfNi@mcYXU>FnA;V1a=A{UzB5`p>wLZH!CQSmZZ{%22`0)K1LA% zDs!dw7CNvWNJkbkNbv;}_;aQjj7Chk!$U}q#>&~szl&3EPw?PnKc&!Kefx<$RGObl z%Nr}K2E71LU|_m<(9Yp=la`Vq4V4i{ALeFd(C@zQ9%}C%q!SmeP#mX5+EiCct1FA7 zq7c=N$b2BZ2kj5q;5h9b;)U}$*bT^M;J^{avn!ExUXM^+3Ow|~S58Q3%zE6Tt{+%3 z{Px4!D3t-cZ*+d_m87?lIlYyYM#KH1^!%X<^qm)v3hQ!#(}Jeh!>Og3DUhCqodv2+ zVSBM@Sq(Lm7tlcl8BgpxBh_~D1&Tz!MEDzExOa3ic3Yn4cNCxrj{{s9ux9@<;wM1X z-+%jMVc9--_Xb+mR3ZHqab4hggI?R_i4Ev_N3jyMlj6XqP?+J?B@OyZp36)jmd{9ZrMkL0dgPHu=+#$WCG(u){RjXkuvY)W%W?gf z{oaqeK1vgdNO^G0+LNbQ#?`pd6BPtP1UA6s?mG3;oZ{Q}!ql*<4*OF&g|ekLtq(7Uv@RDb~( zzx*tFQCYV|&gn zD?%U@iIgFxfm?VQ}))(djdY5ST+F|dp=8s(n=5lCl-7SQ$} zk-$->O z#V6^&*((XzI-n&DdJ{gheFeSm&UN&zTUXN^8yC@5zV=tvl}RctpI?)vQ?29ZiO1K- zVg|&UnT^Tk^W5GyD!^yf{p4fWwz8H!_V%r`p{Y_TPuDQ`S&zo)45Ztcm6`I#T9BB- zM^z)5RyR}>2!n>}!N-$zQA@$8#M((2aMj1(j}iWC zj7z|Hn?PPIYf z1dS19p13(abGnswp6{ShpK9j3R63?MHoQQ zj>j-zC>KYmJbp)d4Zs2kz#>l58N)*8${yvLbhoXf`~(loRSY^H{D#pdsu>sa@oyO| z6|bzYrajELe}3;tW~J(BWn(3cX<$DFCrimMBilulU=nJd#C9fNO93NO!PaA6|sFOh{fFjD*kt)H5304sRwRIpi zZ6ax&I0K!CrS)?^>sC2wsSNfQ2>i(mjB`->8NS&v3=z zI}3{ofGg)m4>lLq?0)_(4o<`cvY0Tw6x~~w!Ez&hxB%|QWtvs|#@aHveQA}Xu5hCSC<5pi<$>|J(^nD(S7v|- zgp|F4SNqq%GwUZuo2T_38;zs8fFb9v-Lz7wj*yN65W@jL^!@b7%Q7H>Z9|F=K2h-2 zSy2Hnm(H{46lueetxL;`m}wZ`eFv*%z?C^!5GSMJ%LI-WtlP5kd;yxLwNTld`{D6P zdilU9T3S~w?1@j$sIhmZF0k1|_w0-{vK>vY~=Nnqp7~n>JIrIqn|tYtio!;_qerxz<+{#sDmAvz)J? zo0ti^nE}!I#Z|&ojq1LZ2!IwaU%1{jGtg^fCTJ;x?C-sFj8n;zx)39}N1e>}{>4|H zr%$|N8=tGC4E#eXJW4q(0lqqszJ9s07Q|lKR>;xGXkc`Pm(GQ z@{mmK>806}OcgMk}> z%05WJd&U<^yrK(l1^Bs-0RvKQ0Bk9IqwHsp;|nB~vKW*;hJAE1r&1uJ2WYs$0|gwk zkuE?C+x&}=a(wY@o3KY<-vCM=sztxJ!ptdw&cO+&MhnHq1!jnfBxVFD`jW) zMVdi#%4aEq#IQeXjjdqpCj4PlRplW$7pM-*;Izr5zDZ&8oFVLsL+26^y_wQ>DVfdB zZY)Y+W-7p+=Z{}K$tft)5+}O-J2o^@1E<}>vobBp;5Ic&Ip_cPPxjE?ee-2Hccs^Q zMHECt^fpL<&4wjablbW{TGdc4W}c|f^92H8VR5xAo~gR);p?QWXK>o{Tlw1myqOiW3IU%YhyxWF0#Y1@xHu$k5|qxRR|e392VY&#~UNknzuXTQ5s&cg#Yss;>T zJ|_c#blRxV({qRGqiG9wXl72rA(b#0`maw2LO{jw#MuxCUoyaya_CEJb6?l zGs0{6vjxDN2nVO7vvFO6*&flXZ0z|_6SL#(1JpMjcX9{Rg?{jUa@i|4<_Isj@n#Og zVj&<>{|c$97z8Mmuk!LPQq5Dp<9Pjiz!heD=o1!my`kxFmi9-o4}Rhq#jFsV+wq#Y z0ylryJA7>WD!QquQr?g1Cvb7_#)1_>qJiFlW@e3_!wmc;{n44aGsHWKpYI--kVew~ z`qEKJzaiy_RN+Qu8!CAFbI#^Hs=;)}(1bMhb-o^=_8&cWfO-ciW-k3rTLvsEMG zlQHiRpK#jJO3Y8e1*d@?{c@TA~RK6pDVt}2cmj~2^&gbnr6zy4?!z4+Qi0Z#S&ow{jt zy?(7)p8zfX`Hg1|)8)&((olP@!1osC#0P5=uU*&Yk(7H}YYW~W^ilY7WAYPE*SEQ@ zR4h=&by0{t%y$H7Z-2FVQWg&A zU5L7DAH~NPi3Wu2ze&m$%L?dyZsqh~uDFs%Z&*%&JSkOi%tU;-?6cPWXFy&=u2y+tyR{6ZvRupks%1^srJ1>vf#}w=j zc&9}SI*fGzo)@7pMELrrN2grjS9Yp%FvcPSQJj}Wx25H8L48|O;=^uOWM*0V7brJcR0 z<8<4{-nxZ8aMyZC38pd#K&5LS7+x@Oe;$J>u$`}-Xpw5aUI&v1u{f>^TnFVv+45L0 zIJ@C&P^abVnPpqqP)5fub@^1CXtT45Q|S4e4nKIdU36LYfVBfOVoMj5 zQ<#DHY^0@B!}4c?9*o#;1HvU0&A@jTr{!C^VwpNjxM~Y>sb8;G>8rqOBi-%vzJjr63#ad&J98yo`Vmb3Kk(8_E_>xh0^z0z-9Q*-3iS84 z<2*m#n@McmM7g+^J70vR&Pc7rOC^~0GPg5$+QK(}%tp=2pSxNI8?QxItwW~bnwj*2Jf zzt(Yjqn{t!eX+|jC^Z#=PwzTLi|fj1SzU$rUP&5`fg4gnJ$y{n|!0T|Xah}1hp~5TZI1Sjwv;j?g zu_7XXRwenAkRQ~w1~g?D+@EA{gfx~tFu<(mA zpyV6R9-)_xT%z~iwT|AxDZR{$6xpAd_66_5zJkSE#=!n}-*FTD!*^e?iUN!k1U3Ue z_u237Wbl?FX~i8p8TrO@M`G3oQ1bMlb9B$vrK(}If0h(1#N-0-Ldv&GKMZDT((k8% zk?Fo?!1rFl{@F|BIUqf(0e-9OgeZ#89i z#D7v$-0p%aH|7i%V7Xypu8hLftgDHaUm=wqtaZK!payQ@@S;Hy5resXwhsmegNx}^ z=!9*O!ip$!QNyUj-ZB-)LbVVyQ7GM(^j}!%m%`GJIV@~hlCB(@vb9mcU<#0h{rtpj zYdG~cAeD6k6F$1aY!XrwvhTb@`5Ecd$Z1Mc;MEsqTObWSURkM0%>4MpvSB5!3k$GF zP`CSl)&qd6oKuOYXv8sZ=Je>LzESELHu@p8Pu&9}^yI5YslKw9fk3iUcbWa|9vI>Q zl~a>>St>=O*(eOgHRVP08}GP{&NO#$TF4gz763B$F>_JFX%(DnFm@+9`=t$w`8+at zTX=557l@lW0YipTeJ5QK(Vq{FH96^Lqe$@b=Q%JqPEYSU7js?Uw@PLgmoVVwk)PsS z)@kW!!sz|akN451{__b-2NZ2a9F0;wUw3X=L=SCW&CE`o2-gjJw@?XDTnkw|2|0JA zkN)9%uh5b69r0hNcMa#et#^n%!ypOfp}&0JMrlFiWP0p@$MHF%x8RK{>*&+(yOsX- zn=jGjo*~OuOKIqcS{ZnK_31Co)M!mF%(I%_QC8AdMg5Gf=Liv2%n`A zPHlp@d+b=tv;hnaoe!V7Oxrgu5q~0Kv3RgRbzxWEkfiDCf_GSM094(2!!r8yhj-8y zzW2g3;XfF??$HSWfnW%R6KF5tEY}G5`hZUo<;@_V;bb)@d>ohf06^i40tE1Wu9$V;9xOQbN&GbzL7#ihm z0l<39!RjAK+l}()GdwzX1+o1*ThGkeZA+@e(+>9#XJEYmU;a3KIu&~rpd)R)&OV#w zQiiGMGvtOF^9Wai<;L}jZM=MvmsHzhwt}GuUF;jC-OQXEZ0Vt6JVS>r0zd|~2gY|O zMH(qDEFT}SEn>2nK;FvqK=g%FwH@{oO{HrKvL!88keM!r2VIA~T8|=1m<%8X-AKsh zFmL)FhP05}uq}!;K!s(dxYc(Ik304yEC!^j7uQe$51>fFi7f%I8G>O9UfH}qc0dVV zq6`MKD;HJK(erK8+Sxzt_*8lN6kX^YrujxWpM$$LHHk^4v%DV11}AaRVf_qpgb=Tf zj!)9pIbCMgDZvEw58iVtE$398KgQpP)6M*OM)Os`6z%&m*zvLA($& zCR&TVWP8jFH|7ktiqMVg6#>0W3zKd@+Z&|Wn)^qjVePjLoTI;g<}e*^?-OknZggO{ z@CKwg(B}*bbQUSbSsngMKf?+&1&G2@Ne2xG7BFzCDrx#)0t*LdmyrIG9*ao|Y|T|w z2#}JUo=i=p`7*n9MoW7yJ-PF!fS2SHMMQhFNoxnEhz6J$NJ{eB^d}xkCl=-BFvzO5 z)??p`V|?B(G3ZUXid0^9W~v;UQ~ZvB49C*W>8hzfK=$c+h8!`Uujdb4q-GwZ?E#;t z6?LVc+oG@M{Y%fzl$JOD@q<_C55Dk3l)3Um2WCV;PKIc$9^AS@(#!Ug;6eumeO%`+ zy>^NAoKi-y9via4K2>L>(494T^p^U3+ESJ++C&5H&F9+3-yaC#;SU$DQgqsOJqo}N zj7-wn#tQl+X1^@(W4mSnTz~lTG0I}LG(9aPW*uP9_MdL00Me3iu3c#OaE`kNMy+(c z?Z8F12M2qnHWQmi$~`Ggx#|)Ikg#UJ@yBE8aF2j~YlJ5(&hMT{fu82{=TNLezWL~3 zs{A>cA9?PE8!m(8#&rwiGZyi(Za!}k|EoY)n1QeDJtO9yr@MxjS`*cjL&6jqwH0#0`-Gsnco z5uE#5)-+OOVeYi_BSTZlq-C7($H z+S??xNoyyifFE{TOL zvCevZ`mHxFu9SipOmwC(W_la&xx=}L1!%AlQ9v8_ny#p@-pCoiUi}F#XRSB5;YNbt zYOvh69)X(>?o^-RrIMGs6MYlzjV%Ks)H*oA?3KFNK~R3|N-t%nrt%E{DM8l0*dt6e+R+Va3U0!^(^MG0mulR;2MTC&{DeY&ham5;NZ(_9zhr0dL}@8iK_=i#%oys=7J zzWB861gsg_==AYfMX27bF_cUMIDRG!FeAX?ThAuA7MR}vO zR^noZGl)U^m0?GVBOKF|FUV=T zK~50oF{emboz$fQXyqFU28<_}XvRw~_AmCG#F zB-u|Z!0w5bZh8xYZlv@Q)inXQ!T)M;brJn0gRDRP@-vchM40mgaX&z<*Wo*Lg#kDi zs7>n^k?BDRhkF!av~~~jdV}(}3B1#=(2wjqA=)u#SOD1AFX-!T8K(ztT~a0IA{ZvhAiDZX_Hoz;feygt-yH?Ql!X6A{%Uc|}D& zT^Upi2?C4{Se+eA^{?Y}%$Ht0NyqsnYcwVC?re4;>jj<>@8?0}gIn^cp8>?4i(T~U zg-!t+=--CYY-psw9)a-+XaI9_Q>6&ieG90i(S(iV0FQ8rk5QVA88wy;EyPC3@b2Zc zbVGfq3^b@Dynj_4y`)nYfzb3yM=(&2zI2#sD~o7#Qw@zxsN+ByW|DzK_rS1-%Z-UB z0;1)DoGkjtJ8!3B=iBJmxwaT#K3=y6whe{Zw4%I_2k&4y1JueK~ zm61Zl3}6AkQkkLaYVD=}^>0pa6??61UtLds@ZKGw-5O*zF%tgSjjzd4Wj95Gqtm^h zFnIv@@kTr`9#5Dw0A$3QzyL^iJoD4PNfj@uXgO`Vj=v|+V}0S#eKCa^2z-7{W(Xke z;F&A*E8{*+QzwZTE?x)g2sEF@wO|i0cJQEss2*uib4+&6wj^x;Z2lJZim&q>oc_$< z>u4fw-$7O3I-;vQlN{bxkRJ5#ck5L5V7!z#E+V+|PLj)Bxp5WY0xUPKXP`ezgY!W& zHU7x<)uOk$%!4w3MJ~^*3z*G83JSkJyl#cGTU9vs`TO;X91_*plxdB_kHKU zIce+S4CtZk0Z{gFX1-Q2Ljki-=eo@X=5uEaMO0Pb#=274%j>_O{e$M+(rNAv)@vV+_nJ?!0#DODJ3=5mCaKDNPU*r+DwhFZs@B-aAQw^V znosi9N;NT19~_;eUPt9+4qsCc;d5S@#<~XRcNtuL@5N&=`)>caZB@Mp+EGadK(Npj z408q8pbVY>0E9ZDV;o}b5LIWVTJ2u)IQ@)tAv9RdvxaL44VrPDr~LENj^UW0+X7&J zc>5}9X7=@oy=SIvBi{9Wp0DMe!BMJ2hXn@y@X%_F>33+}%pXfeGGBu}`oT-brdXbL8$+n^wb$QVD5Ui@Ot3H zKB=3yj{5_YNT5pb91j{u*VzL)BBU0fk4od8QvK(R6{VZ%qj-V+sAgvDWo;HgV8Ae+G}Nju(mGP^h7_bJN@ z9bu`|nxeb6t)^dj+b!}~jJ*U$$M6K5V{n+B1mUsQ%jYTypcM>8Q%SD0|1n{J3HJ9( zkMF0a7-)$f7Xv5z$RvZMS59hv-=UbjB--uZhOWLW?cD zngAnMkefm8y?qUp6{^pDu5*HGESXVEguzP-gK@aS2SUMVRe-1>goVg!7dtFF3j4}E zsxUNe(Zy;8`banX<2dKzS_3nRYi(MaBT_73h%cN&tvxblUR7m`UW~hXA!;M73 zWw6}1p0SyiJo^I}z$)`p^JP@lMbl=nvs0w(7Bc{AxMek+;T!Ym&VK4;W&%Doozhn@ zAapv)F!n>=8H?RoZ0n^;g_o$>4pR&ko|CI*`-HKUx*leA61 zm@g=4l})wiQ#ekCF89U&7o5L6M=#RmRf|M#G_JjTV2c33dw1^$)iY={0(6x+2})ZS z=&_ERZI#MG=Q>~GG$0yp18AM?9`v9Ra9LRa?O0Mt+ZR_++u)dW>=ygQ+x~~|zASpB z`?f5Vb2e!Mym&2AJ9v387^IGYQR-r*0|O=eyQI1gt$9+DC3OVTRQ!%~DAHi)uc!1r z5jp?p!#M8qOLbq_=oDSxYbD@F7u~S5n$mbb{{9P3(%*dZMN7{^+VLCvj_p3ofcWDN z-YB*cBl>gtS%WKtDa@ z>N>H1J(6{o1!v&%%SM`+*BJ=Kvu5CHk*@<~GVFXWNfFG^_Fg*e=y#|C+R+2#h8x!q zF2Hi*dIfH2)A|o0jR)s)Nde7J!G|}7rNxb+^;hxidmX0%COHK%GUcNIW)((6!0zGn zS-R+|aHF2o6>&(ZePHu4`u^ebbesV~&}LI$k1QUv-?pxi)>oHEe?ml(t~`AH>=k!h#c@SM;uF-z zfN_Mk3qgD>r|`g1aUM=Y_gF6m*-<~vjgC$g!W)PO&00?3{MIksNoOu~a7t$+M*D(&{n3$&;`L?D zAGX`UAP>#4H*gvhZFUT!W(4y{_n@^4(xq@c&*S|clNKf6n9mvKTh4ey*$IqjhjovX zkI1Z5`_#qkGF_P=xlMRbAVnx+DvzDXd?BsianbE>$%zKqBHyakN1dqX}kzU zVgZ-AJ~!~h>gJ{j0rJjb3038&Y+t4~byg{5xVm5J9kD8pZ2=k(x+_c$d&mtpt}04u+7 z^GackAP~o31ue#;ZUPISw~A-uD;c~20HN<5z#N3Otvql%!l1%vxF$r1t_Bm_s9~*; zqD?g+lD%nB1-*4mgEXf`o1BPF=RZl^txS@%i|T2e#0=n7vBo&wqSser6u3$5f3XRspwH+7M4V@qa@9*Su^@X4Ao3#p50pG9|*hd9! z9{nA{J5OBdq^~`5n4ahF8^U>fV5k9v>;)ru9^bV)IrU!6_mg2^!ejTtw8a?}U8RAg zDm*eVN|H)teE)0}kjDE8C+ZvIf-5(!FAlRFrOe_pWTg_30!#L)ev2A3NdH1z<%Gxf@N9mM%_tKN>qh$pJy#*m{4vNl zdj^h8OwkXYJ4h8J1yq`!Bfw7dCcIw*BV&|3kVdr?40LQ&b6_@7IlcO>d$!PvhfdRl z)^6FcxmpOJ2m$-Jj=>%rvO8>TY0P;8c>Bh)M`-7<7P@0YBi+nEbXiTQq&tO?im1WQ z049M(VbaW$sxCwmApCD{;2WR$RL_P(k(fam=lt=aq z#q`4Ai*%-~cgEMks1yJX01JQy9$2RH!s*stdTRe!sp9M&h&I%=UvqG#&tSki8JJ#; zyRD&uwl+C4IkTv$JB&Bwm+PzhV#(A=59%a#%@CmE*Nm| zTu_5G=djtY8|bd%R3ZkHkV%O}l!ZZ7Mml9O$hET%NGCQ_l}M`W#r>z^q+E#Nwf6zL zH8AND{TRX_7PgKJPTxl^bkeSqm&M}<#{oe;J_oe$fv6iENkJ`EmTLhftlG)}3##f; znCZiS4RJdh{8ROkrduh&lH%amHrjW(E#{osE7Hu7VX*(uJsao~4{c*cB~@Da;4u_6 zAWfL6iyI{U=!lKv?`LiQ_J{`QEmCa*79zeo4hG%3$y?OLwf#68x! z@EUVW*jIp*&C6@$T%2KG;+v%sP}m`U-Nq%=^ofVJ(LFaTQPq>0wejfd8^@MrS}f@* zJ#*{=g0v1%z=%GEBzguGwhZdZX_HogXPAx#!Qp_n|O&Soda_P&nW>Qwp@S#SDAcs z2jc?m6=tr`IeBcXlW}2JKxqd3R#aMJRpKS&k)Q)*?_jx}~)g91E#b zVUkze-aC2+We~=8%=+;jh~aPDw464sY@~h1E;#m!Bz*_?<7Bp=C2MAeZEaPtuz$Ta zW7H^jsD6iw_mlh1(#x+kQ&mZhSV({s0iy}G`z*dj;Lx9!l}0NY%4Bes)-((%@HmFk z)TCr-+-xf1f+59y0Tkr&XNSM-sU|yHYW5x1+lHl8v~@+T^u!zI{mx2Hm2(6`)DdO^ zJz9WWUYITG>SwkCY#-JE0F=V}0l1c|oy-B&0aRh?3UGm@<+#=%4wuLH_#&D?Gx#;1 z*Pof5B>JX+78~KZKw1=_6aH|127hTOUS`_XQaz`bzxCWvI@{VK-veyhSQr4{;dwM# zzKa3Kvf2_F80CHThs4;^udYXLZV+=5`Pq_?I02*=baGpo_@uVaL6mg7M_)emS zA0GpBfU-Y)KJokJWz|%~>FU0raf%n1M7+f8ZaZHa5V50m5voN;Z6JIm1Ou%js)yUO z&ddH9q{-_FvZW0bQ5;>m-7K`~ceI^5Pn&oT2b&VyJ9 zr#H%Tvcx2`kZ15k{5O{Tbd_=d*z#du)*}^oe8T`BLgMNi-Mz4;e-hDsLC=LM#E;#w znhtQP?o$7V^aJdMmP-%NU;%KWhel*`Ln+JCD#JX4TReOigH4dw{CJ(Q)eN8RAr7VN6=a&)X|2%A*H& ztfvDfE=|)biS~sDR-b@@ShLCI{!x_z4aYZZwSbleJ`PcEdjz_vlajpBd` z23KnrRS2*L^9FE$&qshDpI2eF7+3>21$aN8-q&HC!3Cjv>3%wgl8N^5t25+kd_86tPG&nXc&%@pTX=yMysk(pR z(es_u%lnUlhT-u^nw$*q>nG*>fO$gdtbce?u95n(LJ^v$LiabpY2LmeVa{-lbNJfM zWuQC2*L??n2LOf(7?5FKQEXw(Tf8u$_#f{)DG+ETdx$unP^c<-xQ`j)uBc`(y2~&XJ%N27H{4i&m}m0E4L9Njss%sK z%iBrm6);6bSKpZH3;tJdD(~U}eo=8gZDEE2gEek?=X-|fAD=rs%^VarObE$y;l!Pl zDgIe}17=WDCY61Dt>eKupsDB?nN%qZ9+=XsRvaFkCW^>F6q#$o~KAgDjUpw#pG0A%a{2FHO}yg!bX3px)xhKfYI z1~+3@IVUOIKL zgD$jo#efl1H}dNvwvHvQ%b07x|3w8?2s;}TvHtnl$Mraed>tl1`)Kr!&Z@#qb)}L% z6s=oW^=gbbJRI;n!o+Qwp{Y!?h+3JsbWF1FLR2>(FFt8|%uNxxaZTZh+}&7+fGHFJ zRxVMD=5*QsZ0=Y93&ot8fN>^nb?{p{+}b-$usT!VCJL~zt*JuTq~|%c_=6)C<#Cz8 z4vo{v&H*~rF+j)K`{>~19y--EAZ6RhV)u}i@Eufu6yK1+^k7yDK;;jHEMVohzf1ZF zfba3+&GhYq=jo;M?X;U`&8Q4Kr1#@&=OBZxQ87)uGBn2QS6D14yl}p@Y14wDP4ZF( zr(&RC!wkNHnY)^T907}9lu||SlO}C|#L~jH@91aOJ2*nCo2ogbm8Pp0)u4rn!%Swt zpu;f(jtKA>SXLAl$YVPXpS8f3N1qEm#~wZ(p4Tn9a^npf@M8m;i;EBJZ5Ds`adl#1 z;?))%cpd=DnH~fyJiwC-eE)3Kx5`kB=WM+ECArL%8w(gNz;a_D0)7JUjJb!G)I{EZ zf&7-HDrT8-MURBkA7-r`!{d}_AU${6k}B~}dWG4R^UTP|x^1s9Vt3qtQQ?LL(g0i4 z%=*AvD&#Ob)rC`5E1dZTZsNGP|M2hydh~dU7#)Ir1MnM@!C^|kB*eSWm_W#Wu6sy) zqzd2|pOb0rvx%@aodh@%p}+Q!!ZATqkH_IQkKKjidc<~)5uMB&LX5n8Q5A)P>b@A6p40;&+Ympm2|_>8oFssg9xvy zigTr9%c_P7%4bF~JuO9aN#N}K2{hY|_j!2C2Nv(~8Q0^P{cmz(Aw!wQdZkDKqJHSo z!dJn5;d8-v2-i3|8KB1}COKenvr<#!`v85_5U0b*aarJZ2=foL^vFcc@f7H?KjGy# zx#7kFhMOXE<66TIK``dSZ1B%`X>vYk59pHaSYAUn)R*c;tYOMyM(I~?S}v~bV0ugo zmOv;RHx-e`12c>U!R%n*#V7zWOD>&6ZEl?$D;s=7!lA&f~W27&bCs8Akz9*U^G zaDfDUySzF@aZIcT<{m`MYmfrmbE%W+3bUp6pf5O!SRCNW@tCw0D$AGjN!#G4wQiH% zd*aojw6dv&7FCzfu&(lh;2mP>?*0*~E{_V{fj9hqW*GBxXcaSG2TnHAOvh>lN~9f& z^RsBv(rQ|_sDc`+3aO?zpRzJitkQoa5RXWD2Nii>*Q5-8bds+0j)=G%6={&OqwNu1 zg9`{Y1;#pIlm}335mj-;@Kj@<22nc-CVB_Qg<-7cV}Z}uD6? z`MkbVYGF_dBi6>sA`z>jl}kT^d-N;>5CouwIK7KOFeU(CC5!VjsVFy-hB#FX(Kv+c zNIPLa(GCW!ehPV?(s|!;tXLn!;x+ua0C;!ux?uJ>#`~298wh@E0;a1(;-d!tFNpfF zKYp9N0M3g{68IV%8K2~14T%sPf_#5K`8=Ve4$jZesE_w;Le`a*nndMDiIb{uL~%$z z?>kc8wVdvT;cO>=_9K((bHQf>!97ygs5T7%)WeuJteoxvoI{}?ADc>1gE`F>&0au+ z^XPe)!3$jc@tHs?2rsS&Zd^+B6@`i|<=3BX?WXRJuoggX{iE0;5h|-5?axqp)CI1v3Q7HJbNXforX{wW%edD22cgCaG`ft zX35xZ>>q^e1-#GDaA5$LlYYVDj} z?T7P~!{BElvxwW7rCPy&89na`bjne@t@wORP9f_O5gP_z1`P0pe0~5}lC(BWbZ5bc z*b^xCc^>2g0Rb?uB0#MGJ|k6{ufRq+1BjFqSR6#;?@4|=fCX4Fu^rI*9Dp4^KbM)7 zMwtxw01VKU2Pt3dKR#!?2KxoCEdZQI*dyDAPZKI5ajk#>k#on7`xxNClS__=&n4F7 z^(M(cj`jJ1K>-fQVm*-}sb5s>;e6n{0NAIbc;z*wU;#f-l?UL2ev2OTy_CX?kma3* z{Q(%m@nK)UNMb&am60Z(Vr()X+d*0x`vK7Bm1|q+00GXzk&t}WUcUQJP6h=Wg~O^? z1b$!uW&r$RexX|v;l0?V>UzLw_{o5PSg?=)QWLz*GzOMnsYWNK1W4km1pwT{-*ud4 z(+d&q^mlDsBIc>EZRi^wlkfQXwm!PZV0d_JN>^jXn&`%xtl_)((__tanbYIYW0vHk zi^Yq*?_!dC9%1QeIyf;+=ZTHHKn_b8H{4i=aM^G-7CP?Zi|WS_TdEMV_m6L1MLT#P z_J<<^B*bRH!O|#D<54DzlEPA2Eh*3EOBLxbWyGk;@eRX;fOs4!z?~O5=tTPfT^Sm+ zj6{7}FJ)3=0AnBCwp>6E29gvJeTPKH^~{+z>31l`kGvrDhCYGyMcD!no0ydWSd3_+ zQZOds$gYpUm^Pm88Kg&#Hq$8{F#MKd`OE><;O*-g#jw+2)GU)#HMFR7W@JWNFMu-) z(1%-l=qJ2wC+miR_5*L(CRK$n9F2t4IRS7iFUerV1oUTpDtgJNA`ZxV^!z5zRCo5o2hLec&5XwNhNvY_^?!t+Vm=oD`L_K6DYHeeso zKQc}|Lle}>Y4aXtG+}0XrFVGRH4~v&5quVdyW195Nq&kb(Q&j)+5th5mk3NMWR;kdMI&QfTo8V3W$2vwm`wltQB$X#v6BW%Kn zjsbe)*d?p!vxyO&slzJaWgJIxvS`MdIL*|^X(aR%1VdoAIS|0YKUXkVsmRTeUV_kF zLCg+)*#OVZk>2nL2vLoQ0o>M971I)Cvc|Pv6w@Ts!%SgrCbLSMq73LZLGX2g=g~vA zZ=|Pp9j86V&g+3aT2*+N2f?TIpQHM+0!h;ag7dc!k~IOmP$Z_hD?sxL=j$ze8E2nD z{_w1StL7-tQptrVCkxQbuDpqc$2KQ6t%G7Tgx0^&LW#uI8+~7dVvjlAK6`ABMr?np zee%S-$qC!=8BhA6O}%9y1tiM~vP8cpAl9qC_s1@E(~n*`POlzsv6^s4D0&^5eZ_b8 z$y1j_+lc<4xaW90iftBy(b(^cIn2j+d4k+<<2r;3u-sV4$l_(S^TGU#bkW_&Og4^| z$*?|#nKCR&$hfvApsSKPYaU30Vp^Ik&7KY0fg80)K#5ul0Ger0qp=&ob7w9?Pk3N~ zk-`FI1EA>FZ(d0yoD!P~*qhNRV1&xPt$F3@y$o8$m^Eq}7?U1;ewd8%e@L^SdJvi; zzYRDx^Njr*gBMgpAuX1dnJQI-sJw*l7ODqvd_IRh41Z^HiVKab&-Dx|hKHiF{I;%s z+V$F5dfTlVg)ItcRuJj2zTr_SVBlrg7oz*m(m{jFE?7;PHGA;L z&J(m|Q8{f{UMpIYcdl?|m-Q*sl%efvsrgMXR<{n@r&OD|}#YotKbah_bj9%UO}lQ%N6 zib)KbzKi%hb`Ov*{0T1|v3uo)8w(R|6`>o~63!HJH7|d_OP>9~m7Mmvxv4@}8K=po zVvv-nCVKOYh&D#ubRle(bfIn#i=eIchFQAr0)8IH|>E2KKlGO$95 z56l_ZFYFUs>T?Bh7KUoUX^KP{HQ8iv$&Q@7_j*@ zFvLLS6QOb{Wof;cewVyvF&}4RRT=F*dO?gx?Exkb?O1MFT_>MeaGs64-Ed>3kV@G= zNc3Gb%p9+0W@_WoDjMQc-$iDf5zcBs1ip8$Ey3gkm-36}+C}H3!&oz%;^nVM^=x;; zjRg-EV7YO%VV~{gkP?Z)rs2KhU`99Bm(#}D5&<{%)S%JLAdU9J!>T^!+C#>IL9x;d6GnYd}C=6|+v#I9Y=t z91qfo6`XqPLdBb-0rLdEzqBxqRy4x4A!M;tfuIsx=jCKsDN2)C#J;U;s*;r9UfrkA zZf}5oeoa+Hw7j-VbXbwA{EXai!ye`m!*@lRf%EsJ7%6oIT7~?xwQEQU7SwA!)9g*) zhlfdtLwkj_q7fmyuU^!@yO*mQ3mz`Oa^qU#t-SmrFCXLOeqM_0PlADZV0FFp)-yg% zCLrtvfco}<^R)9qy8r<&GiVmv#f%NSp%1r6`T>(=GDJ_s{uy7<+O@n6CJq%cT$6~9e z;Nm_oGDa(z#e#2BP&dXEdj%fA;O-6^0!wd2Bo+>3MQBc(LC@|zE~!H!P)5Kvs)Iq+ zy2X{8{>%`;ej*`h-LR^7!%ww@rFt;>cl0qOa*jkj&ona`wlqByGh{mcw8d3IxS#FY z+;D`Yx8(0s4c`lPtZfu)j^jKDGpYJn#8l>E_z^E(Ck42?~)iC%0=<$ba=AbD_ zGN=NGnNoRU_W-~I;AON~vkh`)f2B&1*MYKcSUI3I%_3&C)>W5?X!d4Km4T7VV1Nj* zx>VNbbx0YyM{OIzeo1X6dqoE(fXU;Tbstb{56}iS4H_-gY&s|aC|-c20+UvplWvLl z?dyW33N!d~J%iRU8hqEmkCiZkwRTBOOjQ@OSjpZb%FoW6CU! zoob~hr>`%|r*%ygR;qBekYa|A(%Zo}w15G45ij|?Anl3$&u6fQ1*`?kDgg*#{0Yyc zBwgz7;h%UM1rDlk!K2f`+P1(G{hMqy30Jjf5ErqDwBs){!|#zf9;X@KOZtqPVhH0^ z5Rrlqia9QGoZ+a)!o2Ch*DEN*ewkjNxOUd@gr_t=hmP@mW>QxI&*J6}@zy7KF{XJg zxN_sVgj+@E#^Vc9zIP*4=4J^X0ziQO6Vij7 zGqlMu>nY96q#NtYw1Fg9jLW{}UU7d1LzJXTyWui!WMht1Xu7>n&6xK2t>6W5|9U@RA|ufT#7eYwYZ6sM#FLWLaKGp z4jwc%)C4762-DI@Vfj1)E|Fe^`6<93&Ly;5c(W-62?j05}*{-csU) zhAK+179M5P3Uf~QSQ(>JY+Hc5Np~94CseG3L$YV)T!5kT>K;j^5W=H9ukh*O;s)>z z>h&kVJBvJWPJ*ISQyy0WaHNk$b@_d)6GVm}+F-l`j??Z&mnZM^(7FY983C4JSo+O&H#Ia9%XQLzGHbU-Mg|@25Y}|k6-986_IG+@zlu{>KvUUjIY>- z6lR2S(vyXiD$mK1{%NRi!ocj)19rUirNHnM#;jq*%n67ggkA86ELR@XvL$`0D4?J))P5J!deT4wnKq|1Qri{{P9erOxL=k|5t%x*Q3rl{TxAO){OWmsK#z8s6BS|bJ|M4|?p zj)9)!_@kx#CQTXEBf8FSnUV4L7c1xD1vXR~u%`d6Aa~_{RPDNF@C3=$Pe+nL(Q0wlA)v%KR(=1)}TG>AaT;qv4i~Lj|zCj{)ZQ4qdR?=$Oyw$z8{2NnJT@ zW+qDw%If^0S77hpC{;82W&b?!nE{N!5&rDuZu;kMJQXvD;oSb<<>Rz=Q3b8ylqx`g z$D@W=46P8=10Xb%7f^X&wtxX&U^<%@w>O=BD4cLs#}4c4znj0KD>c+B*6q=MMD)u; z=6SC^{~n8|6<~u;$G%TqYyGyIoF~q%MH~<46Bz|}HtL8!gMvtgjd}9&-{B}bL{WEn zvkp7%vpNoFz-JgGyCsLTDbmFjrfWbj7_w6Omb1MgI;B@3uFoW|YUmu)g)F$9lf{5kz|(v~B`}`o!KdGy{oM#LHde z0xUPKbGRBTH?AejJ#fqHIXfy4XIK(>^1O_^=fIZBXJ#peP9cK0n;Peu& z8!1_&B604)VgY1f+W>*~tzcjpotUH>S2ro(st3AASUJY$GQdquNr@4><2C7Nsg%#p z?L2ahhDRr2Qm;r2;`6y>O@n|xuqSARg7sGv=czLLf*cC-;H}uPpFf#y7%KTaq8IyP z_QxGdI7U0A8LUV||D@`F*Lp4@fzmY3rrlq4Aksim~qzUJ|P$al?(P2p3?v zu@F(gH}~J-pH=n;*H)L%Z|zu3>loB*sw<`S)y0x7!;Fzjft06=rNP2X_l~92w4$O=SSbk218_DEDUmsJQEfb^ zAGy*i=^niQ~SYq|~JHP*)+sHUX7tGBZzA8TDW&9G9WO zdT|^``@!=H!u>>Kkk+T+`-W>Q zm%l@(D9ubylWS;_uRq8EydIhpXvwn?l8nK zUfzv2LcE_Zq_0J66@+OFQhw{UHFQ&B1x*BkG3h0g*PrhhqJMeuD0T4-5WW164g??y zN{gGYyhlXPJOJOiq>75NGISMPY#2=w0MQh`*C*Pph*a<)J(kY_iKlXz!o% z^-k%JM?Zkawya)6AAk29l)=oRv2Fm1P{5#TacvnTGvg4l`CEBK@SaLPdhU=2-Y2Gf zY7mS>5@Mxl%L?c(e&sHD&pjI`6|ID%or%k5y_v)4urR@z;A^mJV2pau)nN+Ryu_*y z@$~|Ob+)aK{@_2K5Pvq$bcq{MgzqEOlh6$}7Cc-A%Z-JI5A(9k{s3$eptmZ|%ZvdH zxH+QY>G?CQwC7Tn-08;s0lGXeDxeFxB(#BnEdhG&o#b>|4+EQJ<%QCBFBnJojM*>P zF^GIq#P=#mTHgQ|0qg+G_=91K-N6k!odFcq|D|20=vaH7^!$S^3h6rZFuSDnQO6jt zyvFRyYs|J>>Km5F*VL3y0Ru57+hl-?%*gp?4sW}@IER*$O6{ zCoQsHxu{YE#s^Mbido-`+tWXX`JDfB&uQxA&wSg;ddexxrl2oCZoIie=`u+sI?!oh zK|jI?2H;?|<^ox!x)W`6kcK>P_KLl*#V${KoR_&poNl;r72yIbH|7)Z?rBB*;Ggo( z3j2e#JlNm2vQAhTpUoBlAPA!D3%x@k2u~!SuYyzgGG;p_b#vl)VHXMqbyMe{1_}0% ziYEqPdoFd-i)Y(s-L}$nMBk5!MfCYwQ&lWI1)XGzX^R7g`hf}MUXKbYXsp20L1`9fFstAO2)-y`O;1Z zX;oMVWatV&pMxPwPEw|xrrEOzqz3zkMy0uPHd@{Um2S=#3<^MotGW~D!B?r2!Ch5Z zA-#Cu6peE_)D!2t+Z*zl3(Bl&|LIl=^FH3Xev$J23WgFEH@or1j*zyMz$7Az+2bi+ zkS4W}?@ZT-nXKR%Kik?X=A(`Pu=*A+L*#}V*CAYh<;Hvh*URto^3S~7$;&;YT-*V! zEZsM5XwU?`da*-pgv-hcRO(Cv0{9jdLe!q3e4}Pcje+Sx3z!dVUdn)`SiG}{$P%a{ z)d+1>vb3W~QjXuG%o1lEmLuw>j1(fajZ|Tx;_~W(9D0ZW0KlFTn5lAk4-Jm{gz1_s zjbQ}75!-vgTA}&%{+1p-cb#;+qhEZ!j`DK0dyvzZKC9XiEn(8M0jWIz*sx7F7aTzst4QWw(6zp{M56k&nxv0u&LXDk;-2w)$GFW=LFz~&uY|aXF}T7Nwu=sf2N%p&}wI8Ek%MMO0Y)2jWJZ*!c^jce==(b?G3HB&cTR)&8N$H{D9|&%=sBB!uj0;kA0Bh2QB^0&> zb^EOt`@5Wh;;(L7K`Z#@RB(EqJ-i0#(7v%rWez$I_C*QRgY-NDjNO;I0l8)zK_jhO)`h8;3#&7e*j;(Z4gv^g2CEzC0h*4wvo>LgleC7+&6 zk5$E~+1j!q%N0GKo9v=bVKUCKjeV1p41b0IU@sp$BP^CZ@O(*52Hn1_Mn0d;k@1+8 zF;dOQ>+a|urF%Clrb15b1cPp>(2Zz_g$rNX8GPNN+7O@~AWLi?;(p&EQm`>NGC?mK zx#YA#L0RwxUS1|Q+_(+E}_!wbYX?e`>lP{`+&3?MCR*jO9j-lG8pNT^j=6;9Y!Q2SHw4SSzk_u z)l}N4X~OXW3;|@BmNkinIHxnibQb*rXQ6FyjzB~mX;V6R1J+yPzS%Z zw!ARPZt0lh?QK}zK)?Lp&Ggx?J*C~bt?Df?hFw)zL=Q7~#%J@y$<}C_ApN3a7dq$= z?@vu#33)kP8NLp#`)>SV2Y@~-9h!I|2C$AQL0tFF@0}9Mhed}~$@hm#odeEQfr$qr z@9MDJSnzNeEH~y87=g2RxtU_Yc|2RFm?yU~h$)7K%I46Fn_p0CknlSI6<92!290J2 zrl3}f4^sWcz#jeR$OU0?jLsaO1{HKL);!wYM~7Q`=o~XJ*{P{i!R(lEzjlUc9*pE- z7K5~^ylkoXTUuTy>9y6>yi^s5PnB3bz?A6 zB@(cdSj_v{+&3!6jqQW662C56rQ|E;JEj4w4NI!%y|=HGv>hq~Q38LMU#E6Cy3f#J z6&2*t(zsxTuqRfzM63er`JYpA88m(E}AirFqcYcO*+FRG+%O_kKr zuZ&Rb0p<5Hv%Fq0KOy~7+~c({-LD1b z_&Xkw^k`8!>=EE94;BlpNMLpfmM$gPh||5oz9Ajy)#jciuwKl>;UCsMJZAZ2%@mla z!{VWRc$}U-)k>$k2k9)Q4Zhd*l~h)kr-bgjZIdcQL+BKHs?ehZ?av7N2 zrrRbVeHn0k-ff)ry^Yzq+NxsL>D`SP1D72pWO>Yn0_X!|IYfO0SDZ}~* z<}x68gUyiiZ&OD2k4Yv%ana|)uQXAHzPI_d|0My5bAcvZJ0q7N^ccp0XrCQr0w5)e z#m!m1H3cJ}TdAfD6aO5tD-n*3&$yG)4i+S}Rlw@i)VT}|;$ zOlP)l^YSM;$1xilSqy19xib1}J5R|sD`zCZ(b<{58itP6>`bXg8=x8mCMMnY1=hQp z`~TTlWU1gokF7^#T&LK7hGNDi)zwhp{h5xaI&qMOgYR#quiP;oJh=hQ6=#xORyCh~ zIC0#vw3Ie$puHITe35?E_LvNg#z=r>Xzo^E20#exIYUbK?E z_&Qdr?wZvM<9g9EYZuJ)8X2NVF<(g!0`)8Pmjn$c)t*(y1sl@A2xC^>LAy{K>lKZ? zoIiQ#uQC7_9R0VVk=wlNP&c})$^i!;2Cv4#U`q0zDOE!FM3o;r_&Aij8_{IG*<@DpuVabN>?7wQ+z;o;SOwIQ22ya#qomK>3 z0z%9B4_Lk3KtRJ(i_ErdAxB>eGjb_(F)(Xc!ZlU~EP$1XDb~`}6Pl`rH=N8wJx7Hp z%ah^9G>&t}Ozm!BJyaR^^9C4+4oGS69Et zvmPuUzKu7SzDX*`s2riVC(-2v6738d5bh)_q}Z*V%`9CKU%^mwfn-;Yh(FNm%U8%& z%TkQK^;M0TB810XqcqfD_^8LR}xezQ494Had{oS+k0UZNnX{G^dD5s;*30sAEzV{RoD$)TOE%{x5 zQmjf-m&J0|D{UY2_k_u#FED3--O-wqYI$x0!bOAl1n=3GCZ)YI(#{(4Yn?>_Jk(R~ zEbXHnIitg>`9l9L2s2f}HYr6^be7#XbT~-)Qm|O#+FDM~*)bqgv13b|=ci^ci$Xb` zpGYr!!%#kwz^bvwtmbVz^c%#q@Wfc6{N04`l`7#4wwYt1_28YIOzTq&>ZWd!O4c2| z9P%B3rMQ3Ep^|pA4hfRO$%WM4Xv8F8KHjEgpJ&6@HtaRUIaM{ub`-@(;#Y>Fv?sd4 z;Q7}}eHFT#ujMtf({b(B{T}-s$Q$kpgiteP8_Ug!dUYj#9F^xRRr{@pk->b^g_8;K zlgB%PE=4ZFeFmLN(Jgk@@@G~ZY{#zo@Brrhz#E^UF86xEby?@6c2 z)G`SzwXe`k43_DxX#ZqkDv>j@=|7Y?IwJL_-sGf=Pjr{Xz?Hq6Z{HuZWzQzbSu~1x zZ^g>G57FHvhTYB0AMq=EXp17WZtMaR5Wey+>Q03Ub-)8rLm~dM1G3Ct1_hGmUpFDa z2kXC>=G`%JnqmWoJoKN)&P~CxtA98-X-4GXa{9NbK1ssZa#cu6k*)d|T9ao-*)cp? zXc#es4h6*4=eTUM(4d?)p_N(tOVe8Dk&n><*&j@jN7xg7KlZ;m$lr!hta(?k z9NrCzpn>pLn|cZeY89a@P4VC6`=a34tUsit2(e#5Eu^PsE1zZloNvB!@(5oDdVGUU zh40M*`FFel!2YIZNUJ?15g(x-E@RRmm^ZRgm(-QtWkx-Dp$-6w4BbW6Y?Y;zCF$ML zk6`$q@z^zlg?Kn*2*#CctE-IIvwwT*P^Q1H3nh){g`zceSVamxb*jRbNE9-3-0%bC zyHN#jN;dZWBgA`lHyRJ$1jIPsm|w=385|U{ufPi8%-5xo*cAOC>j32L?~^K&YZ=v=5&X)7z+S{oM(!)i);%**~;?A zn{v)yn#KM(c*;^MRv@6}{|(8!*tflm14x8ydSlpxszEzuA;4xG5a3lldOO;dIK?s^ zf8dnLpXvZVXCX0y%A8~J+%2swC7cko+$ZGfJ~$ryCHVfGLomFF2uwqzWS0ruUEP|l zpKAufbsc4Z(IhMR)%Mn}nDw+f8fP4?JMDokpT7Od%a-0o2c}XFmFV6oQ2gxs3Q(D3 zLK?nqE7VB0&E}k7;4m8$n%F}=*E63MXV>+gglw+b(7yX8 zZG}Dx_@Z!d;>(7cXK|sU-uSL}cSk#KHl-D;`J0a#feOcZd6|oebc&3WR~AjW*c|cSqqST0CVzVA%nU13aZGOLFaenWgstSV?M5rM+U1#bKUu1&8A<{d` zj)@l74>_Wy&DZ8{0EMu_O(Wqp z4bviu--aU0E>F$v7J>$8r!;1E#8Q(4XiR3mDlhJ=T*PJTbkdVm{|)C$G`aDplA%JHMM z|AbS;z_oRjX4`#so9c6wWkgw+?{&+ywfTgE!!UD=;xJIUyF7BX64*lXiB(`yXU5?T zyW`IL%T7rYF*QOR|xaUqQny?D_C_ z5zOn4PN#CkcgITLsH1;omR&88%VqxqBCs=!&TixQ9s}2c*-E}{jeqA=x_MPZQ{t;u zm@~8Oe`rl3cMY%drtG~*n;8}EL zH`mwAA+ghFoy56DQIFa(AWU%4vB3-fhJIGA5?oidelCu%HMqvWMOXT1qbGyZ_uX{; zO{VAVFujf7N5OM|YFt++M72pey0U_Yrmv50_hIHKiu$T8vlOKx<^O$ngyYru`&Qxa zf)VPjpC~cdB=*|cm{tDR&Bl~}o?;r!j%p(9IjTcn=%y*d*_pUh{EHeJd<3?wQe{U- zc2E+>4fI(3G798-K;h!f~-Q@auIgv>-lPQ7`nu{SOuQiiAPl+^spM1NoB)z zh~Q(4LUIfpqd{Vofwr=AP5}C7rMa$VKt9jyE%&Evh@y%fB5#zY49-O-9Ho%b{V}O6 z4^{`$6LZRP2B5{gdT15TLxsn~iCcDP=KBG!a_bw(F7sU#Wb)4M=Ts7nOA@!bt@R(s zjNz@zA-FJMjQhwq_7d84OGxb>{J)Yhj(NcZZN}kwOJQVq>OmJ0dtAe7vOnn@I{J&j z`5(LQ%I`=r#2Luxg09F4Ew6c*8-tyhsP$_hxi^dzuUQ)d=R)|Ex`4(=whVqeTJo^Q z-87aUWjOO0p7%j^8<4C2r@y{UI?b>0Jfz>Po56R#k0>AnV-&8ylo3pcvu)y&W4& z&qN9|l79(Kiuqdqk>A_Az{~Yo$HG>?N#*J+7g7s=?`tw{Si1D5`MAXdNFs=XE!Li9 zL0z28wb8XMR$a^>vA(g6JcMrQ)^~xdVXpwXgsMFxMV|M+bK^%g*(4wtO(=6+Pz1K}@z8K+G-o6Ihz4#>DB#iA?MmpXsBakz8@|GcT!FtwkAm8YLztjP( z@pz)g!`D=^2G{1Nwp3aHa}}z5@!t?sxEhX7uWecCh8xqRX>ass&=#pwm)?DXfwmfA ztG`B{E>OY4ARy=|&%X5Y`%bMo|FMwdmr0xJBWp#aA7iL=Pjc3q8H+lyMEOz9z<5o@ z`FH|b_VHK9TFS6Q3g981QmLcvs$BmLVh2QGEPB|5orhH zq+&IVtIqxX2;oPI1PUn?A-#EH{}NPKiBtloHcy&5e<(+x=wkplHk1}_`r_~7xHE&< zvWjjS4t|>fu8zB(hc38c^{=hgjsir&9V4aXafZr7FT_qWqjGZ;*wvbtQI>x9qQOF? zme=P7w3G^{57_PX)fjN44AUF%&&GP?z|V>Kjqj8>`Iqc{mS_v090-P$1o}uYEot< zPSoe0-8trrQ`!KuMj3mQUgWb_h|j8LJPJY$0zE|l4h$zaR0nxNt&4Bm6wTDOxIaBm zu&Drma88^pYilw`?h8*w1ZHrgG))xK=gcTfW*vd$TUHK!$66-2Y@)G%3mq3rRGB^uE&cZei4@* zYkAf=dwGnnbIA(O)3cv|amsbEe9;d8uqGUO&-Eg7Rp?TcjYmwt0N{YVA8d_;<3n+A zTX39I*OV%gF6Afm3FdMqZF@Xz-i0~^mn-9gI>B-LY1=RYcS3drSEn`ZX$8a@CKI4n z1t7M-fc*;Z;{sJ=ZYnm>$|#!ZY@pBLxH;`k;!)$!KW__lx7`y5scY-&m>^suH68g) z>c)4+dwv>SUTO{ZBl6w=t|0BQWT>-@(NFvWzx|8ZIdc>R^N$kqoQm-t3(F!eS1hBS zpjx_LBWbjBN6&722A5s|-2k&b zBIhaW-L1b%zJ!5S))Y;vdeZQpAE5R>Uqf zorMX#Vmi*lz#mo3z5)Xa>p9ciHASQL=4EATn71`jIa(@Ie}2_!Kn}Ry=g10~Ms@|l zaIN5w5ef><$8m=Dc5!ZEZF|PWjIWQ8GnxLPpK99zs2`gl^ui-$#N99e)InwQm`w(N zz5NA!Mw=Hb=B1odbN$l45!a;K7WVC!=|FiBQT?+K)#uLr(9r}bxVE~{S>a5 zOPy25?MTi&+=;^s@dpWMo1)cQQXp@(KzQ`by|r~~60OiO$=WN%5nA=2(-|(rH~}g? z>Qah3pl@DKEPINzBxIIvyjHU~v1MW5c<-t^RN2_Eved@l`*dMD=BP51*HL0iuK1JJ5` zjEDgL+f7cvCp&*4>GWGph@)O5Ck|x$!do)c00Mx6VTw2ic}hJ8MT*=07+zg-xpEs6 zNud(z6E*X{E2Zy-SiaC!0?D@AmsS(_zX3d-8tTdgtm{Vu80!&$n6F+7B}mj}srCa+Cc5ao46V#JQ_sAg~2hSX>HQEOOO(!lwoCM_bz)JphuXFitwo^rPp`$3@IzM$|nY(MQ53JQ_bgq53s6ZuW!k+0* z9sf`l=^-k$V1NmjzCTl#PmesV%(AU>q%ZY%)@3B;t_!AGsm&kDIVlWknlz6%xBEuK zhvOq*lk^UoG=$k{t5e1Kf z--08*HJ>@eRL3tbato1I@&|t?>1Y$1CSv5rtg0i#&n@~JbX!z*i|vLJi0+}o%bj7s zpyQA5`z{PFpxr(ev9*xT-PPmo$)t>}lm<>Z&i>r-a2dk7aDbghNcNO+ru6)y100Nr zgcjl(5~oR%GhpwaIl37D`|bMJ_+?G!MIrZZ!^~gqk}L$wN{3i=>YzK!G`Lnt5 zw~)9Dl^T@HjH87Xj(5EZLMn;2_#5oduHLHx%s3ds@+dJu%Jjfl`gqP%b`FB14A5^x zb^%=Q7xuLob|_CXv&wBCdj1CwvsqnnNm9_;>u#Coz zQ&dK9kJkPga5&ahQw+ZgS(@ZDZFz8yKI!vF=n8`kgbp97q*orn)@M5xwN((xmf@0; z#3k7`zZlXr_fd0VRI|+V3`G}{&M3ya*2;PXCsE3xwg&o4#!szfFB&n3LUVWAQ`@LC z*T-7;b6~u<(wR!IhR8RS6)8c^QH4OO=HK=dTE{4w0W9^*CFu$dStBu*nb3P`qbc++ zSZf~qQM~>>VeKIh3j}%$^?f56LVc|7=dh1{fD9nBfF=cZB*mUpbZhed( zRm?S#_C!>v|`Qg zE5MJ9)UM)h(5|T>1z6Vz^t;(mfwBs=LQaX5XZ_P!3upck&xQ19Q*PqY{JL8Hm~pv5Ra zs%uN67;zEm^!34?&JZRwQM$wj5%&a5dmOv5U(?ve@a%m$ovBb~KY3Q@c+jWm&9`;? z*G36j=QU0Y4GPQFn9Uk5IM>Fj$Kp|PT;m4#w6a!sLFP1kxN_g5j}xpGJ-*bB$>Zeg zUtx+XPf_5*7SJrUqx@Y{;`uBJ8NE9J? z$J0+MKaVGdmuo@i3N!0&+R?YXhWw)GcQyM9g0f`9Z#8LQR&b$BRzg0k2YcV8{wiSO ziCN$wd?_e*X+e+~RNse6vWk<6C&jcI%$u?UAU8*As?A_SdM2%ZJyR5(HG*GO=4Wae z`!NfE0Zaz>Bj|EDxR&S$0&%T|P4L^Jj2rO=*GOy*5t|1i**w|$Rp4jBMtVzqqdj8XV zGX4W0r?Ba@-X+uvKyEO+jJk~HySdAJ=$HH`C`GlL*6tA~Z-`F2e*QKsK5L7CJ7zS`!xIqd_61^}z%`x1L58OGGVz!#!VS zCqRcoxDDPU zUH6T2Y!s_sa9;nVJ>LG64X()oQ4Zrt_S;cjk)Q?}gkZP$kLm+ihWev?;YjVXbL+Fo zV!cn$)v`3e)0#D!v=8GSETXrXBW#xB=2c^>&2D3t|A>VvjL{E-J&lHCL?83HuKPI- z6NiOg0-<5XEY);-EA_FC4y!yqn0x2?AL?~lZsi@>bD}< zeU)(PW$(p`kIa0bVg>_7OZW-_Bx?lt;+^4JHdrI2IUl-H8u(7cvzSA^f45c*<9N9G z7LeBsG)FE47@H+FS9D9_xB7@^>_+`K!idXb-~k0c^|zjpoDo(By5=c+p4NPP6Y5`p zAeQqoTWXBfr4}d=YF!8xbD3!Q70&NMkoY6jt8E0-cx73C60m$DaM6omJ3zz#&~M1k zFU#)Fpvu$yLge(q-={u+oSf_A+OW@ik;f!Gq$mD_?6$%sjSRUSGq|8) zCWvFymxzD5Kh6;;bz=}2ZOgF4rVm?NCm;VL^LJcG5bVxZ6J|_GXpy9kA6L=YM&nt3 z?O9cPBv2W3m_P|c1G;>PzUoPu+P1Kr)+B>nkzM#jkPj}~PZ=S1*KlI+ydy^kg<|hX zS@Wqn;m`NaFqN-QoSpQgd$#md6Le%pH7NS(_#TcV0#r%5*Oi!h^+N3-a2$}XH2S%Sxjo?EGm@$%1)zT__4A# z(tZ8QOf)kSPZuyr3h|p+P%)msFpA0BzDmBPT^2n_kb;#AMT!CH!w6mIHA|X zc0-$Ua?O7I-IjkLCMwr7*W=&i@h*P0!`^FOUxoPecsttXDBD*6s??4g50?QQ182w# z>YrU-f;QXjnz>Bgr@W10LYQ(51l2QnuY(TCDx=PNIHxL##Y1iM_&D;VZjaNp<{m{> z*VYNVMdA70o-QgMC%Up+rXmeeIwZe;pN9f0k zVu(FW88_W{oFM2dKS^#xZ-j?_lv%&MGk7KLfT&q|$op zy80&Xz$g5Y;J>6H4}$zLL@sgt<+(Y#I3TCL%J3oLj5~5J-7YA0x}>JK-u;qkZ*Lxz@K??k3gFjA#%X1IT8&N0l&hO?OsB11}M-+ ztegNf5X)VZdw{_#&w&;8d<@brB0Zwf>+F85o+NE9ig_omlGn1JXyz;q_T)ip|3QCGk!aJT$Tt) z2(Pq7G-<6R*p7S_>NeJMnZiKtEu-$zHWhPNQ4IKe`&)qV;L0Gcind%zNfB@wL6q=c znF}YdQ0^uC@Gl>J?y6h(OgwnRb+?VL`-^6FyC{{Zp zl4axg*7p=o4B_Zw3=dupi3XzvZt~J<1r0B9g?w-GWX}5Hpcug5n_CVF8GBLV3)y(j z$3zw7HDe&B;Hz@yd{IM_rdHr-SNr0K2gIHWb3o#ex1kh!dGS#DZ-whIgS^(;IWVE&4mvfd zy{GSCS4MC!pZphV(8j4;ufEFu?_M4v8B#q@!@F~;S5<&Aky;lRu&P-}8v{D}KI0SX z#BzTW{Q+l^6!EduW=>Ld_a=6fN0KXv(@tK{mZJlBD=_vdvYwm`B@6myJ|?odd0FxHz3KzZ3v9`polQgMAjp3%eI(bNUmbPsg<_M)=$G zo~8w6C=Zx+#jq%;h>uR9A>Divw*h8>6p`lPOM)y+m8xWOmd3kj2)Qyramfi!hxb?u zp};byB)oq2L+jk{CFI6AK^Hx#wIw!6msyDhsU;%L>+B&H)MX3u5|qzSZAnNb`Whc; ze6xQF<7-&u4o=4R1jMhaYw$Ku{nTafNIO&zm}&?gj91-3Otgxz(?t$D(TJjh5m>$(a;yBqOFjiw#0eaHi+7-0GK@*vvrzR}%tua9o## zdX9-21*rwhj%q#;*jBnQc3daYo|XK-CMXBS*sx-Wc5PA@WM=%7LeuJ^+(E*H-@Ue- zsW~Z@_2^6pXZFgPN%A9AtZ+Sq%<*>-4hYM{l7ArdGx-Dh>Fj)`vrDq_&u=%R`rq2* z)o0$X*DKnb|LnqDl2v~{e(W!7t}mpa71R>Cp`XwKOb!e$r9$fmK@>N>|G}_KmpkQ4 z&*a2Ckh`&(%~yaf`(Vi7nckoy0NZK6*R@;;A5sZ4&t- z=waN11bC$^iZR&|6@WMromh|UjL+a%LV5UbZGT-7PS!L`ky+m=X_ZM_uIoC@vlrHFR|Lo=5g9eitV%gZ&DM<#*Vw?J2CZ#3i zar!CouGOSCa~WdPdPwk(R@u@2;0DjnOju-QYif;zGUlk|Jo^DIaDo)72q83xhwV{b zctQ2o`t|Tyv1fY7hYcQla#bv^d1Tr0@s7|Jf@{gezOShNM8RPNBf9jv6-p zEY^+jE2$PpBpt^*bh%d^b}Nm(4}g7g>x_K3(@yMoLiV>p&uis0x>;}sk$Q%(f&ynL zqA>z+E6~5jE`RJ7S!>93U)Ri&AzSTMiIVctqk!$?pzrj#U|bA4TfUR zi<=g~%Km_EJ6t#>SS4*joyl`9MebdI3ahMFIZ5`xxWDs4)fG$El`uoA9N^f^$uM@H zw(|#|^vE`v4HTg&VMxCBsV4pE0=1(j&cULe8W2s|u~%M;K^d`(f0&^Sx;nPp{e=jI zL7~*>TbcNXO;hy3N}Nry0-%Sc7uOY8HYWN({DNPx?_*`MbMDMcVEr=QnEi`WtFXfm z4fumSKL|(_Jh~}z1B|UmZ8ot(nQ}n-1e+%yn(3BzI<_WoUFW zxa-#8mXvbbtCSmX3~RmHLy>92Yg?WsbitFA*8&p$!P!JGXN4^K5DZ zei+Z3E9!WV5ooek-7E}NE($#+V`xQUTZuq9D_NW(&ON~$_@NwsL{1z7uqQg1EWT$P zSxYBvT4Lb3cZJY+A|q^lfhwo(gPGS2n2@fN)?_5s3L~&%xY>-srZ^}3;9w1TUIieP zGeX#PIk@%b=i5)b)n`=Ej(xiLU!*Gn{wc z0=4}@u5yf=CeH+RZLx}G|;4ogwT_$Ru4|yXEF9Y!-8R5a+Wml zCT}1@-3A9AL&-JTJj>Vv>B|+;7tmn5Fy9(tjAZzZn>PE}8ZI^STiW~33VT-kZ*C1F zFQi^-i9ypE%{H$4#U=C`!pRdYdM1<-HIYtP|H`$(%*r;I6e36)QMzhrFvj*~X2B8s zT~5MR1fn~<2q2=($#NA@fh_0~|Fsd*K3SQa0;H|dbv|M{nnwT#VW7kcC%mNVqmSo9 zZt^zYTg9%GS(7tDMQv7aF!}5DL4h8(F|F$6Hw(FRgfB3hO+poaOh!!{(C?>z1tG81 zU<-H4-*_wogacnVo}r^dWx&{3gcDcBE-38g>K`K1vRV4)0sByv5E?DstXF|XrK{4| z)#Dx2myk>mpczGEJ2dPuLrHe-+8^$hhVV#2uOFIIdw%W|EQ(JGIc#ORf-Jh|AorO2 zLd_IHHm)2J&0kb{tLVOic>a-smcR;c-)$REmbST%3mPQn3{IfVkdsh9^+BfrGY z{jw{1Y<~&{xZW=mYDadVZ~yl9M6@YSt#B9F!82% zUW$C|`D&rk8Mz0)`T>5qCq`Jl0rJAK+GBx8+C#Vo*E%BK!qefNq#21QX7aE%g}K-L z_L>cBFv7{3J8(Lhw?y5$$y!%UZ@kDQsV_0OAJ_!gPa^g3d9G?RQ;q<7xL@|c8RIev zvG;3R;7cf;m?S+)1q0>-OHTISz>JE`7Y_T3(33nfhQ=)&;e3m~u0&T>W1%>WE^=2h zd(J)dH*|xPa`<_Tk1*9xfd}uF&e9Rr;R|2K6u8Tu)AExUrO-S2`MR?H-yz*Fv&qQL z?Ix_La!p~tYVRLb$g{_W0coTEM%*0OS+^%m$m71H=)_SMf0kXSXYAJuCYjh75FLTI zsBPU8EKbF+91?AYJ+9t{rzPUbl@k$v>s4CCR6tN2u z9aF$z#IDB@Gp6!J3_Sv=nk-5_({0rOs}Uyg7ZYaqFX-9b<$cRC=?CtP{#rG5Ud*v% z!}TFwQ{mz5!vkcJ+Zn}kTBj7d-i}j&t#Wkzud$1JJWZb4iG#4gE2T@G<{JYisr3@5<85sffYr8h1 zxdM)iM@`!OQSN!KfLJ+uzT?)44R~(I?MdbLF{m>4iN|{`i!G_F!y#Ar`fSVd%%0J5 zlpWL?G8NA&D?5k4SZJ0~SI@^x00S@d5<_5GU&B@^aRia^NcCW+{M)Mkcd-6h>_-4L zvZDhWb>&fQfUcv>C{i{53U`sWA;J_AdI9~_x1YQp=>&ll?Sa`@#ViVAvsuT^vp)D! zajD@c_hx)v@E2$K4E<%rEv`o+XoNe3(ZDu53zWJZE}xUhcV!4WF3L({fCH1o`xN_F=l4(W{&zAJ4g62{K&xZa4d$3&8E*~qlS+IGuwxmqDLr<{xF zYLq|UgBpds`4U0rIa%|!%`_)7Vm17TB4$wCgQoc4XZ0Bn_gz5 zgHBayj6dOLz-{T>V5T8Mw%f2?Eo_*7?0&5@9`L$U^wPG|M4UYwTeaRn@WY zP)$jvYnld>b@TYTh)(Cxyk?F_;e&U|Oh%AS`}<2#8OBkFa&!Y>+uE_FSi3>-FwL6j zL5&ZtgSUC6^p9?Daz}E;HfN+zHxO}ji%?EBCj5O=W{Y~#hX?!4+b)>8#e;XuRYRSR zH#x?8?BLl=b`wKzd)h*-f3;dg8TaxFi+I(%k!Q)WVh$TC!AWD}J@ zi`1WEMV|9ymAmu6hM17MwOn^;zM>c9mtrNj3 zS1haH#VvkpUH|f2|9c|7*N*ROCxA1oqFxm zk5D!!bS7|Q#IE67)d;6G_mm1=Xw=;IlQ>f+JCVgt_6ymRutadUjd6)gn7_g9P}-=% z)WvS8r^*P|&zXp*Ws+DGzbNmMU}Np(1LQUx;2B00HS^w5HA9W9ZV8JXcih;0MuN~j z$)hjpZ>^*mewJ06Nkif}5rb(UUG0l`KjpnFM-dsV#>6x1)%!>&!YS*mKN01|DheE$ zvwtV$@g7|ta&;@J9of3uJxtz^{l{_~RiuF9orV|o!M3ood?ChwA({_6mV)O&QiRs8 z2(!5vtzjHbi4nBT7(PxOx0kWt_d1IjeufJTphx3?NsYKa$e*l%v-SKAs3F8IrNo#8 zOdrlF7u%QzSu-xO<4-E7H0yR{Q(_j;{YZMwf@}w{Oc$o6evXF~N>^;}>>mZH3KO{5 zH>uF@XSa2?Bx&FLAbCq4;w(}+*SQ+sZySJukq(ri(Avf4^AzMDC4m9NM9_Jza*FQ= z7(?SK5K8-No-W;^=Z)4G!BlZ5^)6n`jm4?ihIOv}uv>!U!41kn5RXr%E>q@k5`@P; za4J-2rcHP%yFTYGgcSPybxNCwD}&Z??$LwZp!^wVDo}X#6j?>jmO6p-DGz|GHi|rB zT9vx)N)`McNCWrsv*_;<$s;@kV8%)nnj};d8(A`JWd8CJz0R%(f`wxGcysIl_LPp@ zL9&$PVKrS9DG=ZR0R7~7vQ{~KQpF0Z0c1%kBbEz>mP7STXIwm{AHEdpv~>$%KUZ+` z0&cXHXy^4wU>-2Q0fA=OZ2iUe5Gi;a6d*`Rj;6o^eou0PBE;(TcPUSatms zT1F-oD%D7*aN#Cae8-O}mV@ARLu)g8`CBVj2=k|yNmz}}d*P0K_LxI~X3+12lYnm zg#>^)Q<~dGLLbDN(4`!Pp-q`z7+k_u*K*75CIPNsn$=GAv0*f|M1q(H$dfz5#;qP< z(<6;@)5Uc=|7OSX4^4gbG*w++SIW|1Yhe9=XRpg!R;ldrv6@bhFp#^*l zq=LTRXxq+Z3t19&c(FmJ^|GWSUjn&72kPxaJ`h9d4D`P`qqq``<4lcy^QrMpDwFSG z+4ERP3#729nnrbbAzRDeo111wd$Po2eB(25Q>LqpY*v4Ex~Z!2@otRB>ko+ zftWD~2~A7R-OYI6AXOB0TL{Ou3@vtmSGiHoxVhI=PO_Z0r(nk_*Bf$h&r3Y!%{3b9 z@RO#k%y($=Gssw$e^`JG&B&SWu{#^_4LtZLxY1sxEz}HM9PScDvZy4LII!Xn)*e)w zO`v33MH?Uv6+=R`H+aaR2>|0A+APc-!@>cmoygDRuRy&M%S*Rf4=4>o`acz&d%h1^ z5Q>a3Joi(XYR{2SDuba}X?WLVLH~v=#B~E#9ba^|Z)1~$pA5;q;AG1;Qz$Qg>pqQ~aA9bKO9DN=5@DVHVYZtE^|Cd_(K7@EhB^5hyjkYf}y6PnWd~Y|x z^K^?JDYW;;`NPho53}Zc1Dx~)C@Fm9CM*s%2|oZ9bEe^1F*MBP9-Xn2a#;1<$R82rG$^j5a%()%{e8V0r!PXz}%yFj?Nr$m$D!X6)b`yc&h z1JBQQjUqBkX9{*}5hK{7g^IG@4n~RS+j;;&ZZ|DMUr6T{QWzrj4hbkTA!TIXUm1D) z5}rA;fWnLUs%n`<1fqgubdO{UWtsA3`$!Ou9=WlM7O0qRhtcW8w0-Fk38hglmUZZO zj9;L?A_G&;d&=o&CEim5l4L5$A;bUv)iYP$s1n&4-_TKOb~bL*PH=pZba<*z*exG# z)w#R0nlWxVwq^7Qqk8iho>s(`_n6<6P2XJ$kA@~Wvf>;TodN84h_9shN6EA0k8W)? zw*&@<`IdpM&^)Q>PuK~c6+1y8#z=!c$Ca}HVljJp!1LnSYSU+ep2BRDBBU(t`bOr8 z(NlHCLw7?hZLxSQtF-)d+U+)aw%prK90#ofua6|(#Eb%mmi$~v`^^LITN7ugKEI3G z+cTF|vv-o8L*&VT?H4J+4Pyjz=oTo$wcjGs7z7Z=0`VWJ*EA*|?;2t4%TLYVAMOy& zgAaD+i$@sn7s{YDHeJK`-dN9&0ma3IE|w~bf3bwX$=+yuUbNhBkH{JBl&x7=#`_(_ za>}RLg}}L_N}Ylsk;dGCAntcLg|jK`f>Qe_8EOD?lU0|Q*hEOBUP+<0ss*G2AaM~D z5;#HLYPD;Al60pNZhoqRW4(q97yrD(y9y}u^>b9w&~CE)hfU^ zQYx?kOBd0Axz|~QrX83llyT}5o9L`!Y@&1yS|-MdWPaC>YCUl@z$PbD3;r#Sugj~M zK5j$aO{k3M^@NVR@WsoHr1z<>{=TbG6vwF9_$d02^<_c>{)A1$OSGKB=Mf%P#OQSN zjJL-xM6BRM?qeCcnj9a0>-&rnZ%D8=+FcM71u3&P=&Q?Epd43Zu}gHLi*i1% zPVQKsu1urfd-2Kq{p^1QbvW*|;avbd%RiP~a9KcXoWskOnixPmLY{(~&M+y*OQFcMWG(W%=(0=s!vRACQ z8w~XGv+lN;z6yML;8La6)Pm9aD{!>__c=UKRDPx>C}dN>O^y`Z`DEEdi1qR!G~iqo zZixg&1drfdh)yH>&y_gFz{j`rTm&rfgJ-9M1R1!D5BsPbET7(aIeI%+efMKAU4ugq zk2Wv0adNjlG}`>`Lhh&-~BP4o?*3~Z*O0>iu4kq_{G1Bf4pK64ai4BaW2(f;3_qa0xV)+>RuD4|U zo;o&8tD2GRxqfa-G0fGnlnT-L?68Vp%_ekKvswSR%)tGJY4-{!2Oys%9E1D zqm(sv^kMC%iI#;Hc$xU*Dn%sdMA0f4Q_|$ot9=$q`B#2}`qYkYw)F~oHJckW(ikab zy$rw6bM3c3ZZEs&n-xb}y{a8m1~JFH<9JAz?Ib-a+|jU}P4uEu)$6^1`9C){71vgC ztCHb3;!H6a`nNu3Qz2QnE8Hhj`TO{bihus=zLng~N|(p#dU{`nI@Q7kISJZHHV2eTFv33(bgmM|no*ni_hg zE~8+vk7CA{(@;u(pcV`folcFu=TR+0x(a;(3`@jbs6>uSYhrdPu=I}FG5iTL{fNmu zZG$+2!1nX4f4jB5F9FEMl6D%MkDD%Y~df#P;Ve*DlIxU?xAdiX%UM2Wur$`1POt7qu!@Wh7J9bx}O zP;umO_x4WeWf65~XAj-Cce|up@HcO==Cq#lI?P!c4_{`C`d0Ko{2|iz^B=yK#m_MP z$y0CUk2AUfy!!4r`tU=CsENfZB2su}DVd_N$$8o{P>5oQFprsjg-9c23#!r!ZZjPG zeWaG{3`ph903U*Q*mT!@zjDTq7B?-3lKQJ}NNPw?DntdA5+rz;Y39)rd=l3>6l0r4 z$gAaoYj^YX=^;9LWuoMp#7yU2s*fk=Cl2qT!)On)MigNq#Fg{pLevM_VD45;8yJ^n zL)65*7Ye6wB1Y?2F;$`B!GCZFy5Zf zMHF}@iUkAGj~*Hj@rvJ3Kt8>c7NG=l(AO`Hi4%N~dv^75x~(%%GNmAvE39F2h5xt!p*0W8J9lmGpaD+#?dWQzf!+>k z=I8LfY5O@5Lsveoh#JAMqf{z1W^3MN7n1aIAHI*CeC;%ivDkFN>Fv{(={qmHL!W;9 zFx9f4tK&UH`fzq}nU?rxBMWwjeiXuE#^EwYLbhP&>DN2Q?z*0(qD{j%%+?c2P`3q#Y?gidNS0@(oX|7UH zr<_$BOSu(#k<&DLx>`k?RAV?9thz5JzC0<|j*0jaAFr(O@InziqC@CMh_o0)%)xE# z(po22pMeuG>-MD0s-ZE@8xXqch)vTmRWQ;KW`X1Ji8?IO>m({(K|L4>wvWb zUK1Bjrx-`d8l>(6kt;O5M)5gBn@kty^}KOulv0^Y(f;F|(H5r}5uGw30e`QgSSfha z)7C_vdEX)Wz`eU@WO`0scV%prUOzcR7p_f-g##2H-WS`2bvN)aLORc-{ZLfj%aO-! zURRr>=W0c`rg{7DH=JS7X5QEwn@VfT0FLYI!jhyDfAPoer{Dd`i}_ibI9~tdsaNQ& z6Bp_5!9jZb$bRbUZkOYYqUG%}%TTKH=;Xy~^o8%dNPql|=RN#Q!k^WLSzLYifxWV2 z>VaqQj`i&fu)oJIv^E%04F9)=WnX36%isPqNs&JI?q}Z zK+M2W0MVXGE?d551VGdY&+{nqan}gi*alF^{(%rLh#JkZpn|W|G)IKc8xieUAh1?! zl*u&=#_YeJe{ipetYG~VmXipN-4x?@C0j55j(Ts@Ze5;QpfA07Rtz>{friNL|3RU8 zfYW@RxOq?81>ctC=H~hRl z%TcTsInws#nX9%Y&ZPX_J2ynf&JWS+$IjD(ckQMFJI$ombT&g1Q*-q0*&+I~@4rST z&kyHc7Zg`0w*PbQKSb><4dm+#!aEDklpvJys1u`Kdy^DFJ&*0AC>5dtO9>JTIw4$A zAAAx|Omg}L-b@fExabaaG#Q%)>t2rfs{8x9=>?8Vt!1={i}j;Bd&U1M*t*5cIEzX8 zEht_*dvY|h+g2of*2dA3UcOL@i+EsJGs7@PuU;9aw|SsRG9p&t6KBwF0p^%6PVH}R zUR9v(=l@rn;);sS#B4L;vt!dDt|amW5m&}#9ySfIp7?PVS-F^(x4po6mR441axt~) zH4sRL_w<>WepLt=EQxeo+D;OH@Jqzv)ZN-hJ?)KV8J}!)(5~6e{6rLKjs+F@)x6`{ z+apoGSfSMpOdt^@``f?xA^Kat^9`DuTP$kOi|spec~nMwOQX2B<1;usKFtxZ<<;rZ zfXMpvV+ZJce4MA|mI!th%AEyn4vnqH>YF!7-;@^B^Jj-EZq0)Fsb0QVS6Yu$5-UuQGPS=}gb`xyUw+a0qM~F62D@pXw~f;o zDOwM1jb>7T^96EsV*Z9tEC_@5&Dr`l2o99|#1;B}9k`$#v zRA4DVa60Md@i%$=B9C3cCvhQySXvI~m9t|r^j=#Nef;hL%GwB16N{er?e67BR+mIq z&`Je04Gox`rOEjNq0IhbUUN6Pbx$9X^=y%`GH@$Y(Hi#+|o40XVw5_?0 zhR5b;YGKJ-+{he9q%_f6ab|v*{@}^ir2rm8T>Q#z3;wFN>5Elv@gByqRoNkvsD6v!o~$q5?~C;A{e8(SOh5Crd351!lWRiITnQ zWu$o9lju@2i>MyXG)hxsS=#&`Ldo^0bEckn&0Ciz=v(hy3^}uxzW^?CG)+WWKw)4T z+UjfRgM0hvyT>m_8Vf?`^>QQ)y#rxzdN&IqR7Pd4!xPCxLm<-HIbHghdw0{DSH@T* zEz>j$MoAS~0R}QN|<)Gz9ZsSxV>bZXQL-+Fg)zcJ5yE>a1WcFuH#nl}HN+7c$+Ee+IbQshb zB9o6=#E4G@OqV-JQ7S|QmJ;NBJU(5`3vdDJ=L;Any9Fk>6?s_&YiHcFzwpTm(JNd` z-?=)mX4{)^N(X7i78GR%YMi8hUMxos4fe>aytg_OURFBje1d$B5MdyrL^tQG}`b_&Ci9o@f-p`pb+-~RN_Sbl~j_8G?!>w92dpV(4h2IO;Z zU!Zq5a(QiPo|Zx`?GR_!j)VNS|H%D&=#fJMv~NeJyboqL!kY`JRTS%It}`2~Y$0rC z2ncwj2xGB$#3I(FAvzt67c`NgREP>JCCCVmX$tvYIl{sQQ6~tP`*w7Rpeof;rIJ=F zNKGMv0im?^vIZDMJ}}tL>B#M}Etza_lm{Xw5#v-HUtITex8|cjuphw8I}Bt2QZ^k> zi9qpe17v4Z=u9&ajYE@aq$3d(fr+Zrr6I)$QOEn$5iF?ojHw}J1|o$jb`2~%L>LCl zG8fZWXS!&=!62s;2YT9A=&UaW6K+>zEI11FSLSE&@;Pgl009Gg3o-K~<9~*98w3j? zMEIGoh59gS6w!rcV_rUjC#jlHD%RuU2;sP}l%k6xQ*fp=Ge*f2*H5!J!n&Sd(e&WK zekoGFG&)0XoEoC56LYfO?VZL(0(KO;wztc1!kaBDrETih7w;|<(C1twzj6oR2PTk2 zQ>G>l{v+yxrBsXxEG5W0Jbs(UuTz9$F-|&2yTPgf;$}Zb0Z=zopGXwV0(7{@Rm>~Q z;_`#ht3dHFgi3-_M<3)U1ulf=alsTdMYeOw-8h5-qVfLiA-hgC@w!G|M6j>&DE=&px=9 zcCuiA$)+@Hrl{I3PlOesSFpBdvO)zri}Wgr{HG%<7<(f!Vi$AP8RSusMLr7jk#@yJZH-x_xbPuW2vLLx z!!SpuV4&*Kh2l=n#}UQpHT?J7!J-)^tB-PoY9*Ut!RAZxy39a@A#BbUv6i(FL;Qqq zSYKCbe!rDF3w)lkWj=>;qlX)T=+6dDAkCyG6`}%5ak$Ik@9;RwTt9p(aji z{nX*zlG<{G$Sb+k!T}KrTr3gcKuQdCO$mD;cKrp7=Zq%;d0JL75ap=jBX?pqmC|#bOFPqAQ=hitEiMv=} zm6-0GH$femUZ7`AUlqgCw#Hg%q`iY9FtCfbG_|m5|Ipj-1NZHgW09)T$4SI0AM?(Z zMv3-FBq<<3%mpSU!_=riIQg93t6@=HV?QN@C|h{tnd^)SAqt69;=!Fg(h}#&*bJut zSMnmuyp1mw6BvcmEN^d(nIMo%o7z5i%#FYz+ScAed;7ZNm|yoyQDw^^=c}A=i`S#$ zSNI&wb2@)=E^<~X1eVrdDHWpvOL4ff4$drp#^cL8{yvXiBU9odEbzt7n2B=wDwTl5LZ|gB0YHg(6)|3i?~4FKWTH} zn}}C(<>t-*f|!}${doG+RS|z{0WsFzP(Iy>G#Uib%NIuI%$RY%hrbw{@R15blrWiD zT?_J=$M2#?4(^c1nD1AA1n`O$jmj zXMAQM>S?ipBSonY6Ioq@sGdWSK*4x=EpS{hx zzsk)4vjdY-^B66$T$HOsP#n$gkCUQQhzcwv3U^IEPp0?QA@>9#Q)7Ig+s+q0lS-m| ziqCjic{DzsqPHVv14{IX5Pd2Ljx%Gk5`p;nBl~1_U#>*z=0=DsNmr7Y1sJ#P>rM!w zczh*Oh~ePzZJe6J1$!bwWSRbEB(C%i4EE6Dd$xpZZ$b;<X8sW&o@Wi}KvtON{G$ISB#0Fbz4+{v? z3u0ffrx~rex?}NdGkK(&kut7H#7i$*dNnW^)gpJLVpL!$VS)h@h%1P!yud<;{d*^` zNNblqPBGon*O_;5&qrC}g${z{B2Vr)q}(Pr1qAW3auaG}TLjzK|m|irTdP_C`r#CfBHs z!n%o_0#5*O?ymO=Kq~rmkB7U;aMW<^ev=?ff^GTS`ik zY8+#!^|P}RanVF$YS=K~C&U&mrbZ{LxVn>Z^`e+M6V$g_KwqM-TS(w$YoH$IG6~ z3U&)M(o-;7VLefPlA3M!EO)16QgK00+k{zr!?R1$D-a%25H{m1n5J3eC9~4hkRI6C zO+Rt(Zi&*lS!fxKYQYe+zoSWd3}Q>#VZYF1#FcXwhgoPPGt?wnX4i@6%IlZLXfBnO z`m&t0a0nlh{_Ymq&grh}E7INw&-a^))Xn6w9LcJsX8x??Z6mCWgQ*?!WcH&;N$0NH zkzcuu@J!@9Yr962Sj0r&`G#*X8QJ~eT{0p|Q7S?OmJ-E}^Y|Q@wmCpLbVV(Q*>rFk zM>GqC>kuOlF(265C($U_E#N|zu{HqCy1~^rA%gSa{e9HMk%_fLgXa~DJU#0Mkj|Py zW<;b+v?98I)x8#mpJ-nL$9NQ!mnsrJ%c;x711r>uM%H$-L=fdn)T&cFtxU}i9#7c_ zUbRK%QiRRo?7^KqbZC3K^ln7N2KEn#6izM_-!M1U*(S8>PVa3!apk~~H4Vf)=}#H6 zeS+{ekQu4IzvzgKDbx1(r2p{>iBUtaO;oE}1*^L#2puU;IL_qe~mn;zf2U24LjqDScIx17$>_fA|9 zK^R3a9XHV4EXCmAZ9+sTn z3aP{xU7eYuQ=`+7`w|pa@Z$Q|p#iDoL9J61l|-s=e6SCQZ9z1pYzoyYvAz`>A;U2` z#ZfkRX$5KHsM%moyJ_dKj+!EqRyB1d9%`^Og{IsA7}MFD6uA3*-tl75_lx*mO!IjL z_A(LRq@s`Vx=N@aPIXo^>Jhhdn4t1W{q@UZbb4$?8cE+j&@CtUvf|7$QLigAi>qcJ zcDFZ4E18v*)%Fb)AvH@eW0PTLeQMxGx!o}x0CLhueAN?v@vnUH$?;7PzF;*GtBQI| zQA(l$ONrv!JbsDCPw_a& zEeC!_)aSzFJUx4Qh|crRN(#my8hTqAC`x}hn}vanW;((m=e5hDQs)H0g_(g!2SOz7 z?rfpE`Z^^22s?`Pn0Ug}5~+r<#U#CS?wSZJY(FAzh{mC{&~k~WnC!o2TD*j#R{-s4 zV6l*k=oMHgp4~?2-AIaZ`{UW@I@(GVMUO*YAbc<8mQyq`F&FjKLg;&j6r~DOU@2jw zg&&V6?daq2bsmodmzh{h`im=>l1-jHiNwTgV`O6tiIZF|XJ&YLw)G5_K{_#Gi)!H3 zy})U|r%w$@fqbPrIM_o6x3!g=Iflp=Qg%=5?~|IZ+2u4f*CwPmzm-Lcn+1sF((5Qr z!fWfKH(!j7UKyt!o*bgd#iV`Cll8*$3jz;ez*42yDBy@7Ap%V+f!#FQpn_Q6Sb_mziVu=)`3RGYzQ3TI0P%Q88_;hfA726y*fr4qz z&(GpADZaQEhrV=vl&;RqOEd=2t|%J@2&)h6-@%ck9i|wSPi`>n+{x)Wh^*j-xb$Lc zeGQAUPVwFv;L+1uFUze|&|_{TTd;CikF7!&X+X`Q=={Vir!=q9MWi(SLLs|beQ!%6 z9p2GpT<=S*i%8nQjLa|7R47FV;f4Nz*SEPQDeG-rrv^o-3eWb@MD$7u+}#tQ?-6dc z(tIO0JUN%)b3&Yo%R+ItcZL+DN>pGeYXQ~rHjgxqeCV9hc%n^O>T5mBB3md zqyk@NarOG8wLSSDI9}uB;D+93%}S95d;gy8QsjMQdco93b+u4`d$ZU)z&U6 z(q(CD1X?M+zn(dGtHBuetuf36@P<-+4Xd<-tg3wt=rRmyS<&B6D z*vi_JA%JM=V=?kmNA}R%a$0J*kX~!5Gn06rFJRFyzmldKP0Tu);%D5Nl_GivVTkR8 zV0m|Fa+SCu`xD3J@x9yVuI(Mhj5I8cf<9loeu%~>dieMr7%-DM*By1^*SR8mU&WPj z2O$1ed{eW)=aJxS7z)~s!#hsWz(|})$gL>Bc z-pR{yv2JGJ0|9}kQ)g41w7%Kh)hdygwZOXe^|VS<$~EJ4j@>9iZ>_HtL4l}E(%B}I zh(H-FWJHiFTFsO(_$&t^Mc6kv(?V3SI6~?UwPd}`jnX&Kx6O#>lj;1v1mCK?v7SD8 z_ki?kbhTc^+gtGYIy<%~69Ui*ChXo0kRC*|Y`wC`w_Dg$&+Dm;Z();BMX3V*jxF-{ z6Or*)tVDr3neY3Bk(u1gVk&AOIR2^!mQpn;u#`&ZAXA4RxNCD(>aemBnR(&dHR;dy z(;Rg|E15@k_tG@~eqwaGD1C=eJWgg1E@(Fc->G|dbmm*#b!K z`yWq4r{8P%re>1A=O-_ZLtKT^gZM_iNs6)#RA4Ekz%?AU8@#;9mLo?i%8=rw$AeO+OoX(AxFSo#K#v`2CbhTkQR#Rp_OpR1Jn_F|HA9k;)wuW}JHOjuMMFTI} zTwk~D+9}fqGA1?KhZnrYW9{kCiV}ee zETtUtJl^AR4^g1QP2ePi;`E=mcNg`vHnLzyh|md&qns5X4w)&Jh>0+RK$(F7DGMN^ z_+Zqzt+k28P@^>gEmi`x92tUWd+zKoW-!WZ#i0LIlx{$(vH{ygYemI8ad*EmJzsKt z*ml%iee#|`>TPbIWFR6Y^=MTn7o;p;PWpkpeNuA;p%^@F;rHLx*-Y*Hp6d-li5SKV z!i3HsRBnGDsPG+|Ur5RXl~}wGoCODP9>)%*q- zi-toy_K+!f&jZJDL}Fm}iP?175+N3e5J?X_r0!BO!w}X6mnIkJGN0VixvWHWP+jOx2 z!uWJ2N3QPc@1~B%TH{l7ojL^#vU^$@=%)_vkvg^U`DGekSQgXLun*Rbu2yQOOROrE zuO2PHQf_|`F<;~xuo)Iu;m~{VYg=C%r{RhD+`H$lnU@uVkk*T&F<)h!sK8Q6;k3$8 z9=}4yqT#>h@!J&m+`y;mo#6@E-riWUW(nd7(I*J7cdky+(W?_ICYB=8dZ=4EGd4qy z?CN!WX3Q)>j=Y>1o#7PT7?lHS#4JC!ro;XLhNRh?zh=q~%QSGR;Nig@n&DKzTyjNx zw+{5Q(Sy5sq){|#$RN~~SJu}EdeyetnUYN`oRD&Ctf`T7=YM#4u}ENH|M&6!G_uf3 zrL%N>=(R$*{lRx;ad|~78iKP2!=d4N{?3ifjOWf=oy46>xCbFpgYS}}tP>SjN-2VN z-z#?fK967K@o;cK%p8Pxfx~X#F1j`Syn31;)r2jB5Loiv? zpirljmM13H@Fvke5?`*0U`veFz)LYfExJzYnw1VO> ze`6DD;?5-OO{2hXOJAUTtuRDkfLxeTWoWI}4$G-EDOD_6`w6IB`M{Wvfi5 z!9y#~FF~{l&h$7VXT&-ov*v4M7NoOK@cpXHO`Ulv_6veA+-w%d0T+uEYnGU;GD|2* z8E*7(Bd)XtOSzqq^k6zoQ~aIr(RVZ!?QK$nS;Mo7^uoIrV|=a%rw6e#>=|x+y9njB zMFo~pE&rLvU*oaeJplWI363s_le%?KcOb2nWMOc14Oe&6IXyJcLyzyYlG4Tzk^ zm&b)3PUe_-i06*%=%$@r%~I5!Tv;74!aAGl67;dV2E_S2!HT4h(|4Y&i;{>}g`x)U zjh=rHHR%X3w3Zi_7w8}8+ZH}&CgSx&9cQ0cOTuke^8MY#@CU@6stT;I2Oe4GN)njxf)UY?Ms z4NhPXL1={o(PY}>)Y8W}`h;i?B1rX#xCztqe=EF9GT?eH^+L{|)UyVq*1Qu%tC@85 zRt3g?%e1a}<>EDoHep+j40O{6IpT!v$*fVc1!0A4KXGlEj$fOi3mmnYOQu&vyK+>y z5X$#alrq?cxA( zgj2AvuxP8PC!*@D8q7>YBM?ZZT7@t zYG`^+>>SXt#tE!X-m{bL-riZY$Z{#cE7Obg+NDwP(F$&-siDf0ZGi0AqF6+&o;q=P zl?kY<$Ns#(zl-+wv_;mDh1ZFQ7>?7|-o8NB=E}FrIX^tbsgE>uwKPz*xNS2%9Al&? zw;!I|Kupdp&>Y{Sl=__GYmPe6SB?+SQD<0Od^*7w_bFP>U5uiHP=TdX3eHUke!#!r zV`L&&Wq}j>THZ|v<3n7{vUoUWMb+8yIa)#+9J>r6J1?BOMmsxNWVT^yjpA_^2}5fb zy!*RXC+Vr?&uP+v63y_xEU`#H9A8-IyWo@@L_j% z_a^hH6-x1XTGkK=5FoH@fHlPY!m>o2UOGRzDx&1xWp7Wb2&}TA4Kw^~aeEltDeviS zr4R4lATDz6_ruGgDeot{W9tN;UihkH1c9h%2P!KE#3prlDCsx=oF^qpz68 z&97Y?%?q5c$GH19nk=_+^r~DTy4W1RHGY0sQh6h@i?oo+$omI3I9MwMQ&U(zB+FPl zU>|C{7#-&H-&I~G(t3!HVJ0M6-0bUam&h7KT&Z4+?zq(08HiX8|K7*x#M3M=!^bFm z954}JA(@gQcQ@&<+Hy1qx|}#$tH@GrAMkNbarA1Ozn7Q{Q!;vG8_Ylb*4fyHRA4F9&_ac~zLKG&+IXDq+TJ1d3p2~B8_`2pNWuAuE30}7Mrnpa+dAlp z{e6;7TqzSBaweK5My5oxP+&vD;;Os3fj)W9AnopMk(rKbffOCQt$zCc-E@sb7VH!T zI-6x5Vezn-S}FNj6yMe*5@NkDI=3vs4)zofRrhS~lQYa91`lVNHFFYFO@8meA+Dk>R0E8Ec+Fv;_RBA=vBQt|g zQ8olU*DBS36VO`}YL#QjN3@nXl5=5VP6SC@TDugB0TjJonO>0ED5BDx@zD0>p1w|T zZ|`iZr{&DrEo<;r*^v0~gB!>vV65|F2m7hFr9q-el@!fm5$s2AOCxP>ZIrFX?~AaP zDDisKJRuE==Z|r!@{P-568+lM*+L&bG(eqAb<|iJmu-3fo^29gg7|ucBYNLGcDZP~ zk{pS2jC)p|+hguFQ)qs=GSsraiKDe)s zK6YqO#0~ltRx=|JOJaHKj?9QGldgjOOZWNmk>GQ{1YPc;P!UuJBYF} z%#=h_tFg9*CKs0JinXjY(f(J zy+N@$3_~%grc|c`F$d=F&>wyG&D3iruOP4xzV^Y}@Bb!iV@Haz0cZqEsT3DCfdSvk zJUV!c+vngN@XNu4$LE&l&C3(?V+VKS8#FsmtlrK7=TrCWqNh&}(KrhV2#Z0Ecpc<) zU>^&t4t^fdrgVv>%|Z9`U<3*4?rCk{RNh{C<-!PEE2IcH4ObZ^*w9Sb)#b4p| z!F?K0v1;$LQKXOP)dHs@C3@v~DzcO5cQ4cEyg5uRf&|(dOzJb6 zE1m#=e$wZ1PycYEtS2@!3<)i25KfQ5={^P5s=#5L`dAIdSsY}G@+Uyd2?d^-j;>x`j zUlcxiXn+M^^QtD(STLK*Qcp`gZEI;#q&|Bw+b8nm;l23j_&Ey~Qc#{-mgV6|cE6hB6{_L>KwnG$a;jRq5Z_l=(f_jMt zt)V2aWUY02M{mOKpSY4YBZc_+;mIr1*Vah8I$Mh(aHYWeqt4@%3)g6BF5b{RF37kL=&Et`r|;QOz$e(=4ZCyI9obGHO<>Y(+fj z>O9Jkt1130m+MY|qW77_WbPY3I+0mQuGB^>@-C0xBSpCZXaq~SUfflFgvTFKD9Uwp zc0pR^z{d#!Bxd^&!hqC8hSu!ahngn73q{K8Yj2;YZ@qg_#1%lw@9m)pN$n-7^mmdr zI=pVwYJK(S`BhHuXxlTx>B~}uvtbZjyE3^T1?sXMuW&HxIYs%iNA}PY`}!n`7HPif z+q-LiVP|v0s{O(1W*3v|Zg+$ISW2yMN;augyvmj(AvX#s8n(v7u@c`;#Cc&X`rJDg zk}sXOj4w^p7Y8?nYR#bB5Hy0NY$VT-nQ7RHUtEv{0tUWL^|dki9R=;^*SE8?Mb3ET zX7WK~K#-wL&3BGpqH8mSwlXd{)X3r`Zi~Y!MH)}DAcA!Q{J2&EwMmXHjv^T=hYBM- zq4#&&1G|aO9NtCu_H{`F4$(2M%qk$VnrckVSlCz<0t+({aZ$-;tFNNM`(oCWYrb7g zeVL-%YVf&-o*J84pas4mLU)2v)YT3)NU zt2fXufO11nfu(FT=vVlYRKi36XEI#W(W~%*!ER}7}1<9oNu{$%pP$FEpiUFJLP@1Go!HbU-#5Or()8ZR?3yGR2)ZB+{_ z=Z7`LX~sTIU*Z_4z*06diG(M0gJ(FUj~fGst5UI7e3wuz@XR~s>5D&jJ1neRdhlr; z&y%9uV00FtvQfA*+5gDnr^+qZ!;zCu-m{asn;Y{2!7o~oFEfC;0#*g?c{rqh;;upZ ziTiemhgLGwpAZom6urYP;R2@tC(xD#CuFa9Q^U1T5MCcT&`(e7-yvU$bQy7lsMf0& zM(Ojfo)U2d;NOL)SW=3|V{&3QgVsds9azx4d|^yPR`8+__44U?SS=*0^5+VImo25! z;tEf(^{qouZU(R{!8zh0N3*9mJ&1`Pr7l`Bfrg{vV>1i%$KQE_W)_x%PrDX4=q~@; zRB&xqlpBUdu#}DDeja~&t%YWjX@7iv$?s`th()ESkec)3zSKY=EpUuau81z}>S~eR zgqVSd*QEl{8)-8Wk4wrC!U{7NPmRoo$>`4<*+aX!TWJNYZ+P8U?tWhP7@x2qj8F{w zIHv?Z#A!Dq5FwJHWCv=aGAz8FIenF0;`O__F;^g;uO+KMRN+qBM5lba&smyYN>U}D z-OXEPuhHGRw~3e&!&0wu-%%Vpv;i99C|GM#-Kthbin1AyB6j|)4o}R{2;X=Z)8FW? z7apszX~VC+aE#u&I4b`R%p7#O?ALfaYyYjaSIP}V1(vdr?BLO5|BXAw$eT?THQoDufftp%cQKYZ&fed6JL)Y;lt(agjk zp1tafOw7t`#lFr~^1WQyVtz%rnRp_mU?w^*Ztb`U!}rFIkVGc$=TRGfpWox}=GR|1 zVb`(BV-I~)|HQooskl;ZFmY0pjRPmGUnEn9<#fu|d7xj;U~s{OeY)2&S9$)YedE3_3&o`&^$v_-s9S?2=eIgxA&4R4)-Lm$J**qWbMDPS6Cp ztFcZV%VbLnm4=!cx;QyU-+cE{QO%dzNr=Hu+&##mt-h>p7VIIgedoq!Inp*4@bQXK zdtM#`1OT|-qty>)GKP!oGrY`WckiUZ9@t!Ds9K~Jjm2a#m7(_LI;v-3s8K9s zGXSx~UlXY_JbQIg$^cv|tWr_hX8xYc%qQvp{pPE5^xUfZ?G&)!4 zaqZTFZTQ5YLAtB2L&Q+2qH!z-NBi%e8WQV?lYD1>p3@&_?hIkpR9nM>ua)lW>yoG) z(xLb6=n|`jvH7KkjFZqJ(I@ALqOAvC;pSdP2dBW?*b3b;dpFXg^&-6bmY3tyLph1M~M|_9v zL>Md_&<5y0Z+kusf`}aYE@EaPB6ldb$BfGS%tE^XLmUOWfA4l0=yp*okGzU2u?qXK zm`uy8$o7^7`CT)G%B><{>m1JwPtp|M%p_vr2`UBfF-5AqnIpenfAIwU{x@G+H6z*b zBALlI^Q0&@5fxa9MzeZ znyPjd7o~1DA;M5o^}_jUbZmHvg6nkp09Fn^d3cYAhjQt^ayx_9j_`^8>C;0ZhQi## z5vEUaY7hpaD`noJE|H+2nFV_C_+^PWgu64iV7^{W^#eY-yq{f13YO_ z12=a*xdw~JCl{8b2po#Z`8|ctvpE(YF6|dQYu>w~lYZgBeNrU8kSc9=gY_aMf!To& za&dbH?QmXJ-AxVDR$p86TAci!;I!b5wk9LsN}RT3h9jr?F7dvc8eLr@hV??+mQ~MR7@4MbFO0JIVj&uf(fXR_keD~lE-i`3N=q9O6X!GCto{Fzw_l6 zWj1VR5qz96mGCdwpDM1DTY?HKP~z|6bg3UH=` z7J(S*;|LNg4bcDZE*4}T+22ncO?6V|RBC1*PU0w#zr@1q%Ws_%2lg|gGg4H(yQ`(B zxZV|!^|Un5{_ZyEF$fQ>yZbsM@&>_GDuv;S^lK_;1rx^po;G^u;0{_&R&Uu5JTBPpx#bk)IOW;eR4>O^MV7MBn07Zl zjm;PdDyIn$Rl>}? zZ@+hmQ+H-!5!R1!xWCIZg!We(iMDbitD7S`sF}jDB6P}}y0M*$oc8?w$t#gHT#s_( z>d{@>Wai;o9#nW5K3Ry$Njn|;xM!X4hj~99WI+n!(M-0w{)9nre6hc?eBr`2wejCT zBU#Eu5wj^lG;F?bZCYC3AOf4<8;EkPpCocsgC3v>diw42^sm13BY$*y!OTcDufrnl zX6>Kw_}BK|8og3(87i=pTR|(CZuWPTT7X4|cX4V#yrBj=nmzHzvf>IUETpp%R%H1; z+4?D;vMhSaolTv0)_a_ic=`Nj(X?)~)xm7O+K8x)@#8Y@Wn88`(_*HK@W8gtEw6C& zYP6`W4}dS%kKa8gb_^?P%y4v~67~&vyEEgn`5umL+tHtJIhCQuc%8U_RqO5^em}eq zq627pEg~zXku2rl&Cfrb8<|>2n6<7_Pw$ekWEdqCSIQ4jV~N0&~H%y?*rGqaqcX^yTyDDCfVXQ5M5QFI0M9hi9pD~NZmPUbhn zaW6-&4sGv{Lhp`%hKZzbz)Hk>JTHSo$6z80xJiMgWEdj z!<@?N?`W3#t#X?kw^5_xoJRQ$r!(JVfrW<9Sa0pURa;wa7cN?yLMg$ETX6{PP7|cK z6n9$Or8pE1UfeCXLvb%oad&rj*Om9%d;f%ewsPexC&`%4%pBvkp~iz$XBh`$p9gZp zn<((`-YRm@z(RlSrT@wk_g4fV+35DrF?R|=0<)x9kXK6=ZG?#>EZeAZH8yd2nF71L<~5EM zkN?a|yI~Q&KM)=`N;8^3NJD|t#@`GxCRui;vN2)%pZ-vIzZoUkc&pNc>9lciAUoDB zdv|t}i%GgF&mTq7IM*_#jt+D#vJn&&qS^e@(ee%Ikdj{SqN*MsgC$aUvd5n=av(&M znI30ea*eCIg|M%hvx|l`B}>hD&H6TUQT{gV-}Lg9DzAv>eEt zR2seA=aKPcmLM~qcTAUi2p?^6g&($T*_`&xMkQVzX4?EwvtE|*+qZj#-kRhz7d!PG zozC@nfar`BLI1udU4`_705aOvMo^GCk7HL<8tqVjg*;wc4Ay5d@9PSm=4dds>26S6 z#E}#vQ3ygCWcHksh-R82?ib|JmGdIp{x>^>lF9-n-S|sO`ipQm-q?UuS#eI#HJa2v z{Hth6m-+4|3?~&g^9^r95$!4)Hc>PDSQRC&u~Yr1OYQ`l9aV;kb8o+?1b_pZVR>a~ z%wFy7i$dSoMs=jB#`a8#&K$K-<3tT$Q}`@$bg zM_3$jExy}2%s7pBH3h<2_KCvQ#{L8S1(FcPb_f} ziXhk%jYd!w=uHZ>DaOM$VooPps>uM#vy4N_F`ZsWFRnZ*v+`lVE0bfw4q5D)Bi~E0 zwIIXQnN*?#wLui@!JyogxTJn_DOV;NE4u<@e6RPc1$A1k?@WJY)75?BbdqW25co2M zKJ>78`zNdVo8v&-KPskY!nbG4)jUz3OZl$_=vhA)XYWyaUrk_xxi#E>*tt3xrBjl& z;x~W)OU$#gq*TUFwp2vZi%N786p*B!3glgk8{W)v*gT7BhyF_467bymQvCkhS$J78FC8nCizo#j2sEpQ(*4Z(at5Q2FoMOf6_Qi7?qFF#*JL?vPfXP%%bEXgQ*m zcSaRPdHY^(6JIykwIwzXR8Lf9H+JQHN`|}w33Ku{zb;I}Xpe<5x!(-NHVqwHpvTmg zmuzol(IGZzRXu+JEznHZk*5M8l8R$84c}iVsy5sKFGxFa&Ylrk_kt>x4k(IGGxkmAlcB=fkqM37feQV65Q9p>JVF3wWM6c#as&a?G+ zc|dX<8XIA3Nl@!h%L>Uw^H0kVY4lP>2 z(7*E;o;)&V#OKP?qO+`w!ceRTdpHMeWi)wX>)Mb1rb5vr%2uh z@G~G96?fgIw_%^GqmuS~NY#AW2s2dBYKN^uE>4nA-PhJP@c41kb@#Qxkng7>+-`M` za(nRu4SOP}w(KKvX?kBo?{U`GTy33?5Q>KnEQPSCmsyV}BN3AC_N~{%s<|2^X72p5 zeB@^P!ffM+m8{VBwRdkna!O+d`7e_fR>06b%5Y9^znK>0#eq0cRjJPB-o<>_ZlpFz})8ZZ)gkc3R)!A z8MG7Js}0XaroK>)9qSVTV{5MT0ErXAXjP^ zGjn>_NFm&t{C6VuMSb4rS(X;XE9bWc%U=n}-h@*i^`5$-%>Qp0dNZWiUF;Gg1|0t$ zys*YpJ3_oD#U3$(PGF&RG=qWRy@Dj6kEfwbT>qdum)ETjKNwjx5|jz;M1u3Tm|vt> zk9rA!k$it}6;?K)$POA{TGJ8YRQy?-LKN3qB|BM!azqu%f}b?BE*f^2^7!w`nCp^M zZHvgmu{HZZ+(3K)%+*(D#)r+;mL4WV+xROdW3-g665>Z4w$|Xpg%ORErXOOUfmR8c zADh_}<7c_(b&Ab(xA&JS!S1qicL(2+*I?l|u#&&TN<+ef2NWZJuDG>&T{*IvnnE@e z3Bo-&s^Q@uj%Q>P-e0;x>?AfPIZ8Yng2r5B1DawUcEjoRT{|AByw4(QRP69-<=t|meOwq@OxzVt4fere zAC!(*I>iXdd^%!4#mPHeq|1VPTG8m|`e}uFwG714H2`&H;a~wu7z6L$Za}PfM zUh}!!z|H>_)4BRyi=ChaW16cv8Ba#tJVz4!s2|*%I2BwK_3I3aq$SOL`m49L+~gly zk9LCaO2;%8_-XK8G(}vgZlR^Bj~yt{A$#R3wc4pGNV8U}bLC6db626Z9#==_^(`mP zoKgWx#A@PybT#o)#;srZHaUZ32pOU-M_FmWxMSy$s4D*eU6};AmN8@WZyD?tMuF6Z zzJpk$tio3cucnRWN!p1m%q+48DPf^)^*@@hVmp&Ijw3{d95m9`4Q-T z+JlcFb&b^CHpdB+{30KztE(%F6Nvum?c=3xS-b9XbKvPJlUKAVLV%~-Wi`?}XYG%} z)%e$_kJ1zg*{W%IcJDd8YzY%De(R=g(!inaQf45c8wLGMgYQN%+hSq_R$Uxs;qNwATm|KpOjXHK<;R;=mS4P{K8uj9CDtnWajZ%w2!lSK)Cf?xx=HCS@se*Kx6 zgXd$>$Rh@s!S;o3ECa-*=sUOypHGm7iSst1?H4QjcZP(Vy|Ir|Rs>Qz@UQ!)`Y97A zd(L@Z9chHh*oM%sD%w`cogKdm_`c-aRrI90r&_;HDdRy;&3J5@}D?-7VZZ_1r z^H}~lUNj!6f@)u&?Xf>VfW z?*c!X^<1ou`LISrrhx6X->nu&pV)n}56Mcf@JuP(rLiN;L;LWcX{!6WEl%Uo7`1o7 z_af%SS;L?(uVv7N1Zc0CZtT7_Ngp{%V_{%NOjgdIxl{b&QTte9Jm77eOx+L3p04`pz0(PvGp-06Sa3SQl?TDN)G`z%U`72A9}}wC z`<)6QY7eQs>Ap{vZl9Pn^Y>y4x4J{haA2b7=Pj2Ve45Gq|L;!lUx-tQruM&iN0@Vi zE~K5Sjw`Z}`>rjdcQ&Ee{lIGfFZh&zFY3MXU#YyHeK$DOP~< z`yA{fr6@(;%gf?c&bBQdeBqAsbsro*`jR$wiBp(L=J9cfAZ&Tc^=Iw))>D0`B0(S_ zu(^ol3)TzoT9cT`)e!iJ4A->EZXTABMYdzUQb;B!4GVbN=Fh_{E#Yl`ADU63ku_AR zsOq$xHjq$yH@LtB?a46y27}aDl2BXPGyYi}e#wv3wHxMYkQ8&wnzO z#4o{TfQzibP=&v}8uU(=WSbYb(Y$WU-MP`T@3^(L>}fEYTBsi#0((adrQ(|E7vOD^ z1|>cta&E(h!*S-d>fB20<@$Q@?PA@hPrAR9l%jbAzW8mrfo9ossw>;7e{+Pn^Kckj zMmMJsZF7j2dWU{=z&$ODJ}y9LBgJ-cI*Z(U)F#ePemgvdHukmz;3H0u4nKN^Q0v=e zZ9xdmWXTH&J>5HmiM_SPcFrW1|GCdZ{bdFx8Aqa+vh{u8BrMgh)R49kFyJKQ$tMuD z_SQ1r4u5XLUfND9HWuVX8yocq`qaPYWo;S?;|)SHJzltACK>%gv*^r1enA+oKYhuH z;Uh{}^omLL7B6@5IVOy0hvy3nG3hNi~l+`L5au zLO`2}ZvgmQf82tNBEjFDm9~zD{;yQ|j2?5vcv4m5nkON6sJ>TW`IfckEQ5LA=f&lI z8>(-)Zb}jE@aHu?rvwFEA{>MXo0AbAf-{`LkHW*Zaf5}#Y~iPpkZQqHGw* zXWBQu3JUC?O{QrfbQ(|+EE?@(iycdGZWq6}nwD}^;~lpEz?i=Ib)v~gKtzazw(vh5 zQ#Cb`D%SG&122R=MQb+=Ra1W1t5-Rqt=2d!bK@uo6*e>%jWiD*jMMukzH4keU7ARF z=rIZ5I1}Yknwpv2cB$j(p}?k`ovhFH5C>Fj-BU&ABf`(x+$>%-Bjk)N22TU%6dJ zF0YHjv-V=}+oAgQ>Hb6Z+xkO{jK|Y4YTLif!|5INR6RLmq?TW23z+WD0)$52dS@91 zXJI+5urkOBy+XpM>j4^XzhJVTf_!qyt&eArW2aAj`EFf_7yv!riwT6<%0ia2v2A9G zYy?=cZqNAttp(uO~R@Yj$HM-h1BJmf#5H5i>W9gv=628!78Jbq{v> z*`DPD2wI-5w5_bo%@H2i+F^@_P`#@4O@}}HoK0<|H;B;lVqtM{DL*VBF1om&;+bc< z^3-y`o`-NZ6i#BS<~dUNq8{%N`pTj@K%OoZkYpZU5U9+W?-fZ~FE38jJnCqko=L;# z`Td=cPgcLz4_4*)@@N?Aq_Dm>Fw9YgE_BP!(S{KN{dF!8Is4=$^p!SaUiKSyt1(3o zymElEuB%f(-m_BS%!=AraUb5}q2ONrEV@|4%jDB3)O)2oet6zIN%3KG7uo0Jd>G#Q zG-&HmyI(>7&cOXRd%CmHIeY2j^Hsi6F6{A^1XdESeHni7Jjl@#ruy|~K^NILoE7?h zngS-$on%U1_1ZgBn~>|~KGz{SdTw#O?Yq5p!IrAsKNViuH}q{dcrYi{36GB76YLm+ z{zXSRfl=c;+9KOA4QyFgy^&IXRP zMS>pVprGXsf4hDvwSOGEslm@G6Ju#xza>8;xhVZWGV$ z%y@?ublD_^J2@f{|AO7}K^0|}nXShxLX_1>)}on>1I%i__|%4O!qrKIj*-ckzr+oe znSmWPmT1vj33Blk7Z}aAL=NNFGv2BEl9qpVfwgRFw45z~$Tuj5+FNk{8@5|niQH5P zo-uwFivr9Uo`Y+USMX&5N{t{aG#nv33@Y1#OLAJ9)u)WSgJGS6!=V0KSiIhGz!PXd z9FkYFyScEe^o6cce#+P5N>2I99yPO<$kTayt1GT{j$tBoJpmP_GRJf*<)Mr5cWokh z4Rp&|WY4AI(7T$-8k!KFkdd3Zrq0#+@4kvUWw}kTT}M(8L0><_(NTUlauYSFFObr{ zb^Cmvl-xnZ5p3)|58e)MH|diadIm?B+hhG{O|Am(w%??7{ka#8;}-djO~b`HCN=wc zFq!kj|TT-v0_1Oi} z*7ag2lXK8i|0mU))DpCKtjp~^cdx7njg^Z-{R;X9Oc*D(MCz&%GVq8>XfjEDp3HQW znnh=#t&QWxY$7FWa~|xMT&H5sxoA%bx@cvHrNdG}rz}NeUW%sI(g@eBVvhsB=av@x z!LlH(G?_g_L6c;Q3)E);?U1XXf>m?^Yuvuv1k6mGbyR#gm4_RCD;DLp1Xp`eVb*@- zMmZex^1fh!R^I-aXKj2oGT>{`nP0Z}qKr0S^?5+{;fZ+x4FCTkOe)VM#6INzSs{{n z88BhBAx(jHVicCT$yJ&s?Y?G22X}#xbl3MNfQ5xl5?NdxTD@PIPbebE#=SDyUsheA zo~gJ<#L-H({OZ8m^3b^HZTK3-MVP*~r)A%jUhCJ8(+=$1?9C7Mb{B=z?x-aqndp8H zoruFWP66kmrdJ=hW7)VEtEa_<0`5QbXo*pghBRef4au-em)DHmx_ulHBk3e0?SD7K zd?{k&{wd;TRZ;JrF-=D9qBah9Pc=zzgxc%U&&&1!2Tg^c+c_HS)-0LTEG`Vi(Mcry ztvFa}(}dw7S(ZxH6vukNB_7#ZYQF`?NQ%=;Ih679y6M^%aM~Y+|9Hmt@k}?c#Y1qd zH2`gY-&x~)p zj{;9os3eG``3TkVlZ=O(gwitbLd~-o4tat5neq!wC*1O(BeH<&@P+skP}KlmCoGti z&@HUgW417TMF>Jnf6H-#Twibr*bqU zw-N+?sz;!ryB80WWg^3|iC8clW}{3{%UHp8NNqTZNE=V$hHx(;KtGPP*mNINRo+*s zN%^DsF`deOw=C1&xnOykkL*K_NZa!-rT+$Vr8VK8q8Hswi>8o-6>V=kSY-%g+xz+uX=WGq)}o`@xihvKd;R9{7ij0f4Y|u2QggTu9*X0 zN`=VKZ4mS;+e8w|KeS_->jjJn6jW{^BXEB+x49xqIfJuRKj)X~Bon!^cye2O&4lUB zzx;Q(J~ocAo4$wAD8!LMR5rHH0%M3PY^gO&y`_bjmws3tH=C%XZ+3x^>U7^QqB~^$ z@EwFi6bnB7NLwi&{f?-EX=6y51hru#f`&u6 zp=dSS$FTA2HI@{RccVs6_AxHk*pAJWWNQOKqCRnGxSo?+X&WY`58)}$%cTSz9r&ed}T19SiA?-xF~-n+J9ikJcPAlBl68#U+?yPzZ-lQ2genD=r4ZQThC zYv-_#LM`>!#6|6%L=elKx<{?arW=d^-4$;Uwu_M|pGK}nAoyf<^J*m|LadDm)dT#{ z7yz{aNFgYz2&Tn%A^J`+ayIkH-fKut?NS4^R0kheeB=9h-LuhIp%#tfls5tB2Euqz zN6}YqpRcub5n-LPbMC}zds4qa&TpL87X&@$vD?vB3_nfjY!}7Rd1^ibpo#4I-qO98 z(lj0RWpje=C~nDBBOiZ~yPX?j;=dh#W2AoEItOW;jeAApTezp-u})kk2ip9CxC6ko8qs}$cAT(o--@|L3ePo_@O`uBIT7QQF6Kfo*B*EhG zKkxhRUVf6gd+1Db6(tZEo~De1fl-*LXz^7h$FM~#8LT$LwnDTZRmak~>%vJjNXMpn ze2EoC+|8&UF2eryY^lAa+fPR1!N<2v#0lEkEb%3vibmJZRePY!G zd$*_pP`ikK^51C-MZLVJ^>}U>zkWyw&kc_kXYO1(G8;e*Gpt2K`u7%tcH*ZyP0L4Y z)myP8&KRYzvDQfOmZHacyJhm77Jgg2b{nQ}5N>xBgkht?M^uVG)%AuYO@%7&ADMhq zznji9HQfO|u$cvIJy&lqp!wmo4sWf+@Xr#qym6~ZcM4FmYG7(u-C=e#%vT%V^@3H- z39Yg>A9I}7HEE6?kBXJ-aC5tu)ilDlRR%OG{F(|W_PqS0xAuF#bnWfYd*>A1UQdZ97!sBI7sTGB} z#{R^cxmN5q;`;c}2Q;W_IDKy-AAJH`+>_Lfm#4uUwGW9;>_n0OMDA}3N9)H}^W$e+ zMnnuh!22=vpQnU*JuoaBZ;}&0$oEfx5|251Wmqj%#Sb8+eVP>)`kGCP-nG~9y9qFd zGK*wS0?-n-1d`l1!XMp?^Q?CTs;##PiHPmOUkh_UKORLV&GVEI6X-(P=>C1G5Xjtc z2#Y{lcLzwSFTp_x=#D&uxwE#PI1G5`uxFS6HV#6!;? z^%gh612-aDmAfD#5r~Z4Kg|UZNO)3x%Ir#L8;|NUNctCtV*w%UI_Hv@GZt(qo|ks5 z0hD#3EtO_#wczO9D~RcC*kR^GI3EiYUljcbh3RUSE$y*;!K4IV)35aeXyhB{fQNq0 z6d-)yCkPRX66MHVNMgUsZKEEoW@}V9`ssdm*GB?}JPhBo2!2LO2w7kW!8|@Fw0_X67d)i?f}!NO zqrGq4leT{TCB49@iN0>*^=DefIVi#KPBKZm=j{IDM!_(chW)%v6XxIlZwG}fjWisT z?UQQ((9x;Xs>GJdc1^c!^asb<7t|pY87|%5$_+Q)$lmbqfE=u`441YLH~Ba{UZ}~a zCDd<__V98kWR8mJoOR$?FY#pS)GqKwoc=gl3Ad;2Ar#AI-he%2)QwP-fV;(7HLXSR z7xiZALKvipET{=X!2C6p6YC;nsmP-=Ep#cz&~hT*`$E{FP@);17YNX{H4cC-tNr)9 zM+239j166Elpe_70k=ejy|y{@62Cu>n6fXOQf7--2c7{eBd=@g*~=2XKnoC`h7M9 zq3cdE5-h{5NW8PRdjy%e*8`Yo>&>l3l7ptBUS9I8L^iqIgLr4-v)$p-0H)-7W{4<= zwmwYEzJ^;Pe{b9E;T3sw<7y{+I!EjJP&zT~gM%RUbO|sYQiKoPfiy~(cP4gw|HR!1 zhTT0)3)!n^7;%&p>2HyLcz~9H;heya0A37dD|@8Vs|`9s(syaUj;Rpp};4XE>sh9%$oOeHi3qmjvUWCQqv zVx0V01Q^nDBIBIW5}tFiyS6doKWDGvU>Sh|HsF5Ncrq}${$~zNd#~Wg*GWl~8&i7d z0G@2nR%-Mp`=$(llm>(RAD2?iI~TaB#=ExuSr^btZ!KvBkK>9wJS^B%Cs%tG$@Z`Z z{7$vB0^l)y+l~s~RZj(Mc-4muXey`HWUAZ7{+g2PTuAoGn`ZP%Fx&w|ds2N>#w564 z30P0g_rGn!v9|ybxZBhq^j@$f_YUjJlQI(jPc7`fk7rrQlNOzzB0RcXhpM~0A+WkM zDLul(w9R1vu@2P{M6ziek)l8`ipg!o;kOLgWd2~uUoxes@dXy(Wx?KtO@qi--8*mH z$iog^Z_F7deOeiQidbYwVO|&aBJ?A=l4BPV>Wd{A>2|m}fs0%KQ0h;0RrasFgJ(+P ztP%LK4hIs?2$|0ayjpe4B3?eGghp{u9TM$HEAybj#^e!1_@Vr!_;Pfh0^ohk1|5gG z^o9Vk(YmECAf~u7RH}?tnvFvhV1R=@6t5jx_CsodV^UCu2E32B!RUvCH9fT~CV#^n z=+Aj?gr{#zrE{5fL&%9$nU2Z~DQ$$Xn~0&sd>fnS_#oQ!JM6h+LwuTQyQ6nt^j`N4Y-tDVmKd%avrdw zEhlBF=rBwX70|BY%2BcRS*0PQXxvvtxdQ!v6#!2pHwB>ANLJSaH{r#Q49bMqhRftX z=VhAbt`-kRfHeNLezSj-E2Ywe+){GvNabBWqY`^HNLOXD9IkvN5p*3^)5WttabtUL z#F~P8hz*OrsG50Ej4wy}@K&+ldt_#JL+#^WeZp+-*sxL`|LARdGtchMM((g!3MzL5VHt3klO zW9pCd$@F*~sGnP?LqO1}J#$GDH05?^iEwbL3MjNIb_OjlAKo;;4ap zH7auL_SG=M&+}pNmlX<@QN2}eETVr^xXGiRgJoy!mb|4Atd~d~M@m!Lt10?%R zq~W-5$Di0JBGd`P2Tg)dR^kN&AIiNmzbCf+BrJ{!8ScVVL~^q~G>@iA>?irzyI-M~XUuaQgt z_yOBRUu-Hbbw!z?`l*yz2qzBuKg9C((Ds3?PZrmWBo+BFP+fxm;H%H4Lzx_d1RBdTnp26tn{JE`!)Fll)@ucjj!X|? zrGfGB(3znW^_Dme2S&+qW(*l4lh-O?`fYq)?etQ9Lp}w9=^Nu}&$6=1FZeqtJI#o) zT=F!+Cl$xi$)I1nT2D`opm-Mzm*Hk;9+L6K^*N~Q_}NNQ^snS;dXD-=uQLn1#|sx) zZ!liQfMmmBrqew`4pDZ>AYVKXi+uLS{}Xofhwzu@r3?pFxjy6aPK&>)jZt-z{hUpn z8~ahozOlE1aiYjw7YR%uui~mI%so3jKJZKxIh>+(X6_r%pU6oVx@`EZ&LQrjoEazf zjrJdfod;eWmK;D3zpRcFed9uanH$U$w6PwS1PeR5GvHGo7amt!8Mz^8)7gl@eydbJ zLI~bMp$zJtRKiJux(WSsF;z|sQk=j1I&0!U+k1p1pE==shQ|@JsWa2>SQGHbx6EaT z{a4+xGtXLrYpl$g#~!Qe+832&xV{Ssm*4}0nV^1dV745EzzRg-8`qEE>?D3NS9#?Y zAZO{$w7|7ayUBWW@A+NTxsx8pat<&uB+Zf*D%LAcPU3Fe#;u)b=Jt%hF(Ddhh_{8v zHp&hu+2SnNBiw18x9rUsJi!da1+JR!_~HHzk6qv1JCyYI2F2?jypr87iLnVuNbWCx z0MtG9s{u50ilb1oG(gezSib30sXt_;jG6Lu*^Sk8&tHz(7Pro>?f#K;AWxnt0C*>m z89JxFLp8!2S70p^4--JU=o@SWfiXHamNd;~*Sj3N25S1o__PqgQ)%}Ne`i)OYA^<3 z*ER!LNADINp+g;>EXi+w|28Bl>9bs7Oy>nav~Ty87NeJIynj`fqTKvnGxsoU_`d_db;_6pm+Y*Qv<@L1C?U^iEbd&un ztxLq+Y8V3v39FZFHM!tPBUYo@T#_3sJT2)Tq*+@eY|D4lndq z%T?-qB;bd|kI@C9ucduQTdZ8TYt||rAjY_@$?Au7Wp4d@1e=AiPLA`1@B7I(5ge{I zOf`%r2i|kke{dTnOkXX|@^x+1t+81y6W`wCY=Wf>|Cg|{tgyZvijKFa>--x_|J0Uy z9=`_@W9QKxACc+j`WiqPpb0F2NKZFf7j|^>E)<^2 zLheZWu_dlKQpQrD%WVJkdrT1I^-cdVw+zz8Uv)|w(15*V@KQ=TcJY3*ex7<-M3H0v z!*g%N7dJ(ehh#jmOxVf2)csqS6K#q`U22&Dhl{(`$45R!Q#+^i1o2jh6 z1xgHeoLTVA)i20I!^Z+*Y#?e*E8+g|M$A?Do1YnjKLYzK3e7?^?=4Zr6O}Oy*4u=s z5LWOEJnV`&b1!ayG%@hd!u5ndpw>Sz&@`(>Fu20&hxS9Es=%2LKW#=(At1#OpQVCK zK38q90LEcKb;(Z!5tVn)BC&QHCs;vE+C}3}EN)ou!%I|RSfjb93aouvIieFD4@`xr z%9CmXj!j0*KTR<3z8C927)*qDZ=lP3%XjGD8-QMg+d>Moxk$ri{lZ?50syrAxK=GC z4D-pMMTeRBA%`Ca)P+?PEx$NZp;-unGGWGfvwk@7g!kpL%Js~QRk%B|Sm_FJ4Q_W!^JA1*(nyTYZp3T0-G}E6(2dUTpE= z6Y4AVLgvM6y-wsKx8T;wm=*59jPEfDPD=+kN-&xE0#(H+`0a|zUJfjC(#6o1(&-0D zL(rJPO~R<2@EcvbB*%JzLob5jy{ETYjCm|3YP!RUYSIP*n|^m_kL*ZOl0N23v)JZ7 z=KLMQMW)&lu7P)9**WH2@-^HZHeHr?-$CBmoMq};xO^WOa>s=unQmE3%`UdWZU^3z?h2r z0|$mx!pKDlzC}DaS>!2WaX-9-uHoQB*O-;Bu diff --git a/apps/code/src/renderer/assets/images/hedgehogs/detective-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/detective-hog.png deleted file mode 100644 index 4ba2b749946a82d93e46ee604854a6df2be911e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 100794 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 z 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

sZ#6=bl+tn|I zM3qKM@{`YxIf_D!2XZS9?K>mLU*_ds=>jLxqXy*C5rbe6Uir}QC?zLNe}L1B z1w(lzUF71319awdX*hyOg3O$VmAnREm=r$Vepce0y#sWzww0<%^F`s9pBNYerN|}& z6sa{1ZeF9fAjh{u+NLSwleLXH6*-9)jb0UhfpSb2$ivD&*u;3)?B_T7fJo`Bp8-Kr z=9TM~hyg)lcc!+5uHEXfHfE+I6!JBb&XJIb^jK-CBRP@YwKV1Dkzr9Ts%>hg=XV{Z zW9Pgq#+aGz4+V}-gzqCNFUpl73K$R}Bk*_|N`XeAw<0Yeo^n$odpK%3({YuCQ6Ztqa;fs3dAYE*&x4+A*h7yg88z1YbdkPK*qQ@wiZe zZgh|-VrWq@W5IcusbWB2Krvtn1_Z}%o>HhKVLiq^`yK>CB<{c@tv%Lz0vlU9Xmots zr-uXmEr46p_*%@MFR_5I;#m=;1~KYVc@+Urj0K-pGzp^1TnW7NZdR7aTcJ60-?18D1XhBHhPPd{E`Yv0Ap&|3 z(8CmPPUpy3?KNh8pfnay1C%&MLqnn4r`cm;k1#SsZsPhSB{KITm9IH|J0Zw<#Qt2q zu#k4uwadgqoEH!%)^zs^)Rk%K34t*|`EN#I`1{1o_qNNo;9%}}t@4fWk8Sv3ON}O-meeeh)v3I;nV<|M;_@UM>vnXyqgC2w@~n4 z9_p2ykwWjdb`vdLP$cKe96_Wc2QT4OMhg%lX3)RDkQ@YP(^b`x5z+e7`_Gik8a*8w z7vx2>E-JxFIiJ!u;*^L4b}f}1l=Q@zQ`orN9y38JEb}m31c@4uOGgZ?fHAfxH@y%7 zFWxoMKdUOMnTkLXI^=`ryt=H=7)(^l5dpl_VNSYFm?-imO!MeH0lfh_m5IL{G_A&- z-7v3*Mge2Yp^FVR*nySgGXCxlQ-RVWBSfD-OpL>r({a88G%}MR3K{^ttzWiKdNIa0 zS~zg*3|+BdRls`2k$L?74Ol@VxCHFf+}=Zvz5FJfZfKRr1Nkd|0#GzwE~_e~dv4r9 z*=ecTa4*v}lBUGi;3~MS{w!a1;(140ZGmXiBlX*z56u z;$C$jn+Xe3*uz#_O4dxOfxi)-i@?WeNh0#?{V!Z~HLahNZ$`2E&ei0@6NSzqEpG*}7H|yTG&abJAt#kP?rS>W))gONR_0YGY%O&WaED z5LEmC!oxavA483@OrF2FVJx6&P#OsX&x`pqy4`NOkzXP$r*OKi$#@MzVa33k$ObbQ zkCQYFnnuK)scoP~p4%=(5IAp5oF}L(DWojM@(-P;ky6&8{2XfQ?557{K1$%E{tzpC zDD4SONP|?+?Q%=bLCT!@z;IqXC}+iW(2o@dX4gxfwR7QNqpwAhFfzLj9H)D3y+$bC zQI3uN6huiL#;3prAW)cVT%w8E=5`un*sH0nSLW1%)DlT-;npH+L2??`0@UdfE_dR< z$cT%LhaOUrPyP<)E|+slv9XaA86fXfSz15`PSk1*c>Tjvm1|9lU&KyDZRdc5@QxUp zI60V+o*Zwd)up)`LP-xh0Z$*T7kDd+b0X#0X##tt^WVs^Am^f>Hat^*JXA{^ z13r-^tnT%sp%ka+0*#r@w!iF19?&iU+ja*Kb)V z3UHi_)Urx1yYRtNb@aavJx7O5)k~xUV^P4y2Ao+?cAY(1`6RfrR#q*b&R%Qpa>DIf zeyUXk`C1UzypbXAdYo$+;T(qnVWEm>+{E*QS?yMl4Gdv0Jl$^U_=dv${MUaV@X^JV z?Qb5U){ZVpab%O*IU+{obaNM-u5G51>|9_Lcvp8f=QEn<6z4SjE8eUmj+Jw

- ); -} diff --git a/apps/code/src/renderer/components/Providers.tsx b/apps/code/src/renderer/components/Providers.tsx index 6b8c75b31d..2dc1cf9a79 100644 --- a/apps/code/src/renderer/components/Providers.tsx +++ b/apps/code/src/renderer/components/Providers.tsx @@ -1,6 +1,12 @@ -import { ThemeWrapper } from "@components/ThemeWrapper"; +import { HostTRPCProvider } from "@posthog/host-router/react"; +import { ThemeWrapper } from "@posthog/ui/primitives/ThemeWrapper"; import { WorkspaceClientProvider } from "@posthog/workspace-client/provider"; -import { TRPCProvider, trpcClient, useTRPC } from "@renderer/trpc/client"; +import { + hostTrpcClient, + TRPCProvider, + trpcClient, + useTRPC, +} from "@renderer/trpc/client"; import { QueryClientProvider, useQuery, @@ -46,9 +52,14 @@ export const Providers: React.FC<{ children: React.ReactNode }> = ({ - - {children} - + + + {children} + + diff --git a/apps/code/src/renderer/components/ui/collapsible/collapsible.css b/apps/code/src/renderer/components/ui/collapsible/collapsible.css deleted file mode 100644 index 05e457912f..0000000000 --- a/apps/code/src/renderer/components/ui/collapsible/collapsible.css +++ /dev/null @@ -1,29 +0,0 @@ -.collapsible-content { - overflow: hidden; -} - -.collapsible-content[data-state="open"] { - animation: slideDown 200ms ease-out; -} - -.collapsible-content[data-state="closed"] { - animation: slideUp 200ms ease-out; -} - -@keyframes slideDown { - from { - height: 0; - } - to { - height: var(--radix-collapsible-content-height); - } -} - -@keyframes slideUp { - from { - height: var(--radix-collapsible-content-height); - } - to { - height: 0; - } -} diff --git a/apps/code/src/renderer/contributions/app-boot.contributions.ts b/apps/code/src/renderer/contributions/app-boot.contributions.ts new file mode 100644 index 0000000000..ef651541a5 --- /dev/null +++ b/apps/code/src/renderer/contributions/app-boot.contributions.ts @@ -0,0 +1,37 @@ +import type { WorkbenchContribution } from "@posthog/di/contribution"; +import { + initializePostHog, + registerAppVersion, +} from "@posthog/ui/workbench/posthogAnalyticsImpl"; +import { trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; +import { injectable } from "inversify"; + +const log = logger.scope("app-boot"); + +@injectable() +export class AnalyticsBootContribution implements WorkbenchContribution { + start(): void { + initializePostHog(); + trpcClient.os.getAppVersion + .query() + .then(registerAppVersion) + .catch((error) => { + log.warn("Failed to register app version super property", { error }); + }); + } +} + +@injectable() +export class InboxDemoDevContribution implements WorkbenchContribution { + start(): void { + if (import.meta.env.PROD) { + return; + } + void import("@posthog/ui/features/inbox/devtools/inboxDemoConsole").then( + ({ registerInboxDemoConsoleCommand }) => { + registerInboxDemoConsoleCommand(); + }, + ); + } +} diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts index a83ae6d487..d25e0469ef 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -1,6 +1,50 @@ +import { billingCoreModule } from "@posthog/core/billing/billing.module"; +import { inboxCoreModule } from "@posthog/core/inbox/inbox.module"; +import { onboardingModule } from "@posthog/core/onboarding/onboarding.module"; +import { setupCoreModule } from "@posthog/core/setup/setup.module"; +import { WORKBENCH_CONTRIBUTION } from "@posthog/di/contribution"; +import { agentUiModule } from "@posthog/ui/features/agent/agent.module"; +import { authUiModule } from "@posthog/ui/features/auth/auth.module"; +import { billingUiModule } from "@posthog/ui/features/billing/billing.module"; +import { cloneUiModule } from "@posthog/ui/features/clone/clone.module"; +import { connectivityUiModule } from "@posthog/ui/features/connectivity/connectivity.module"; +import { fileWatcherUiModule } from "@posthog/ui/features/file-watcher/file-watcher.module"; +import { focusUiModule } from "@posthog/ui/features/focus/focus.module"; +import { notificationsUiModule } from "@posthog/ui/features/notifications/notifications.module"; +import { provisioningUiModule } from "@posthog/ui/features/provisioning/provisioning.module"; +import { setupUiModule } from "@posthog/ui/features/setup/setup.module"; +import { workspaceUiModule } from "@posthog/ui/features/workspace/workspace.module"; +import { + AnalyticsBootContribution, + InboxDemoDevContribution, +} from "@renderer/contributions/app-boot.contributions"; import { container } from "@renderer/di/container"; export function registerDesktopContributions(): void { - // Feature modules will be loaded here as UI migrates to packages/ui. - void container; + container.load( + agentUiModule, + authUiModule, + billingUiModule, + billingCoreModule, + cloneUiModule, + connectivityUiModule, + fileWatcherUiModule, + focusUiModule, + inboxCoreModule, + notificationsUiModule, + onboardingModule, + provisioningUiModule, + setupCoreModule, + setupUiModule, + workspaceUiModule, + ); + + container + .bind(WORKBENCH_CONTRIBUTION) + .to(AnalyticsBootContribution) + .inSingletonScope(); + container + .bind(WORKBENCH_CONTRIBUTION) + .to(InboxDemoDevContribution) + .inSingletonScope(); } diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index cad5c501d9..d89fd7587c 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -1,3 +1,224 @@ // Desktop host service bindings live here as features move into packages. // Importing the renderer container performs today's existing bindings. import "@renderer/di/container"; +import { + setPosthogApiClientAppVersion, + setPosthogApiClientLogger, +} from "@posthog/api-client/posthog-client"; +import { archiveModule } from "@posthog/core/archive/archive.module"; +import { + ARCHIVE_CLIENT, + type ArchiveClient, +} from "@posthog/core/archive/identifiers"; +import { + LINEAR_OAUTH_FLOW, + type LinearOAuthFlow, + REPORT_MODEL_RESOLVER, + type ReportModelResolver, +} from "@posthog/core/inbox/identifiers"; +import { selectModelFromOptions } from "@posthog/core/inbox/reportTaskCreation"; +import { + REPOSITORIES_CLIENT, + REPOSITORIES_SERVICE, + type RepositoriesClient, +} from "@posthog/core/integrations/identifiers"; +import { RepositoriesService } from "@posthog/core/integrations/repositoriesService"; +import { + GITHUB_CONNECT_CLIENT, + type GithubConnectClient, +} from "@posthog/core/onboarding/identifiers"; +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { SETUP_STORE } from "@posthog/core/setup/identifiers"; +import { resolveService } from "@posthog/di/container"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { + type INotifications, + NOTIFICATIONS_SERVICE, +} from "@posthog/platform/notifications"; +import type { CloudRegion } from "@posthog/shared"; +import { + AUTH_SIDE_EFFECTS, + type IAuthSideEffects, +} from "@posthog/ui/features/auth/identifiers"; +import { + FEATURE_FLAGS, + type FeatureFlags, +} from "@posthog/ui/features/feature-flags/identifiers"; +import { + FILE_WATCHER_CLIENT, + type FileWatcherClient, +} from "@posthog/ui/features/file-watcher/identifiers"; +import { GIT_CACHE_KEY_PROVIDER } from "@posthog/ui/features/git-interaction/gitCacheProvider"; +import { UiRepositoriesClient } from "@posthog/ui/features/integrations/integrationsClientImpl"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { NAVIGATION_TASK_BINDER } from "@posthog/ui/features/navigation/taskBinder"; +import { navigationTaskBinder } from "@posthog/ui/features/navigation/taskBinderImpl"; +import { + ACTIVE_VIEW_PROVIDER, + type IActiveView, + type INotificationSettings, + NOTIFICATION_SETTINGS_PROVIDER, +} from "@posthog/ui/features/notifications/identifiers"; +import { OnboardingGithubConnectClient } from "@posthog/ui/features/onboarding/githubConnectClientImpl"; +import { + AGENT_PROMPT_SENDER, + type AgentPromptSender, +} from "@posthog/ui/features/sessions/agentPromptSender"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { + FILE_PATH_RESOLVER, + type FilePathResolver, +} from "@posthog/ui/utils/getFilePath"; +import { HEDGEHOG_MODE_HOST } from "@posthog/ui/workbench/hedgehogModeHost"; +import { posthogFeatureFlags } from "@posthog/ui/workbench/posthogAnalyticsImpl"; +import { IMPERATIVE_QUERY_CLIENT } from "@posthog/ui/workbench/queryClient"; +import { container } from "@renderer/di/container"; +import { RendererAuthSideEffects } from "@renderer/platform-adapters/auth-side-effects"; +import { gitCacheKeyProvider } from "@renderer/platform-adapters/git-cache-keys"; +import { RendererHedgehogModeHost } from "@renderer/platform-adapters/hedgehog-mode-host"; +import { setupStore } from "@renderer/platform-adapters/setup"; +import { initTours } from "@renderer/platform-adapters/tour"; +import { hostTrpcClient, trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; +import { queryClient } from "@utils/queryClient"; + +container.bind(IMPERATIVE_QUERY_CLIENT).toConstantValue(queryClient); +container.bind(GIT_CACHE_KEY_PROVIDER).toConstantValue(gitCacheKeyProvider); + +// archive +container.load(archiveModule); +container.bind(ARCHIVE_CLIENT).toConstantValue({ + unarchive: (input) => hostTrpcClient.archive.unarchive.mutate(input), + delete: (input) => hostTrpcClient.archive.delete.mutate(input), + showArchivedTaskContextMenu: (input) => + hostTrpcClient.contextMenu.showArchivedTaskContextMenu.mutate(input), +} satisfies ArchiveClient); + +// inbox host capabilities +const reportModelResolverLog = logger.scope("report-model-resolver"); +container.bind(REPORT_MODEL_RESOLVER).toConstantValue({ + async resolveDefaultModel( + apiHost: string, + adapter: "claude" | "codex", + ): Promise { + try { + const options = await hostTrpcClient.agent.getPreviewConfigOptions.query({ + apiHost, + adapter, + }); + return selectModelFromOptions(options); + } catch (error) { + reportModelResolverLog.warn("Failed to resolve default model", { + error, + adapter, + }); + return undefined; + } + }, +}); +container.bind(LINEAR_OAUTH_FLOW).toConstantValue({ + startFlow: async (region: string, projectId: number) => { + await hostTrpcClient.linearIntegration.startFlow.mutate({ + region: region as CloudRegion, + projectId, + }); + }, +} satisfies LinearOAuthFlow); + +// onboarding +container + .bind(GITHUB_CONNECT_CLIENT) + .toConstantValue(new OnboardingGithubConnectClient()); + +// integrations +container + .bind(REPOSITORIES_CLIENT) + .toConstantValue(new UiRepositoriesClient()); +container.bind(REPOSITORIES_SERVICE).to(RepositoriesService).inSingletonScope(); + +container + .bind(HEDGEHOG_MODE_HOST) + .toConstantValue(new RendererHedgehogModeHost()); +container + .bind(AGENT_PROMPT_SENDER) + .toConstantValue((taskId, prompt) => { + void resolveService(SESSION_SERVICE).sendPrompt( + taskId, + prompt, + ); + }); +container.bind(FILE_PATH_RESOLVER).toConstantValue({ + resolve: (file) => window.electronUtils?.getPathForFile?.(file), +}); +container.bind(NAVIGATION_TASK_BINDER).toConstantValue(navigationTaskBinder); +initTours(); +setPosthogApiClientLogger(logger.scope("posthog-client")); +setPosthogApiClientAppVersion( + typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "unknown", +); + +container.bind(WORKBENCH_LOGGER).toConstantValue(logger); + +const notificationsLog = logger.scope("notifications-adapter"); +container.bind(NOTIFICATIONS_SERVICE).toConstantValue({ + notify: (options) => { + hostTrpcClient.notification.send.mutate(options).catch((err) => { + notificationsLog.error("Failed to send notification", err); + }); + }, + showUnreadIndicator: () => { + hostTrpcClient.notification.showDockBadge.mutate().catch((err) => { + notificationsLog.error("Failed to show unread indicator", err); + }); + }, + requestAttention: () => { + hostTrpcClient.notification.bounceDock.mutate().catch((err) => { + notificationsLog.error("Failed to request attention", err); + }); + }, +}); + +container + .bind(NOTIFICATION_SETTINGS_PROVIDER) + .toConstantValue({ + get: () => { + const s = useSettingsStore.getState(); + return { + desktopNotifications: s.desktopNotifications, + dockBadgeNotifications: s.dockBadgeNotifications, + dockBounceNotifications: s.dockBounceNotifications, + completionSound: s.completionSound, + completionVolume: s.completionVolume, + }; + }, + }); + +container.bind(ACTIVE_VIEW_PROVIDER).toConstantValue({ + hasFocus: () => document.hasFocus(), + getActiveTaskId: () => { + const { view } = useNavigationStore.getState(); + return view.type === "task-detail" + ? (view.data?.id ?? view.taskId) + : undefined; + }, +}); + +container.bind(FILE_WATCHER_CLIENT).toConstantValue({ + start: (repoPath: string) => + trpcClient.fileWatcher.start.mutate({ repoPath }), + stop: (repoPath: string) => trpcClient.fileWatcher.stop.mutate({ repoPath }), +}); + +container + .bind(FEATURE_FLAGS) + .toConstantValue(posthogFeatureFlags); + +container + .bind(AUTH_SIDE_EFFECTS) + .to(RendererAuthSideEffects) + .inSingletonScope(); + +container.bind(SETUP_STORE).toConstantValue(setupStore); diff --git a/apps/code/src/renderer/di/container.ts b/apps/code/src/renderer/di/container.ts index b70cd4ed65..dec8fb3af2 100644 --- a/apps/code/src/renderer/di/container.ts +++ b/apps/code/src/renderer/di/container.ts @@ -1,9 +1,132 @@ import "reflect-metadata"; -import { SetupRunService } from "@features/setup/services/setupRunService"; -import { TaskService } from "@features/task-detail/service/service"; import type { TrpcRouter } from "@main/trpc/router"; +import { + CODE_REVIEW_WORKSPACE_CLIENT, + REVERT_HUNK_SERVICE, +} from "@posthog/core/code-review/identifiers"; +import type { CodeReviewWorkspaceClient } from "@posthog/core/code-review/revertHunkService"; +import { RevertHunkService } from "@posthog/core/code-review/revertHunkService"; +import { + GITHUB_ISSUE_CLIENT, + type GitHubIssueClient, + NEW_TASK_LINK_RESOLVER, +} from "@posthog/core/deep-links/identifiers"; +import { NewTaskLinkResolver } from "@posthog/core/deep-links/newTaskLinkResolver"; +import { ExternalAppService } from "@posthog/core/external-apps/externalAppService"; +import { + EXTERNAL_APPS_FOCUS_COORDINATOR, + EXTERNAL_APPS_SERVICE, + EXTERNAL_APPS_WORKSPACE_CLIENT, + type ExternalAppsWorkspaceClient, +} from "@posthog/core/external-apps/identifiers"; +import { GitInteractionService } from "@posthog/core/git-interaction/gitInteractionService"; +import { + GIT_INTERACTION_EFFECTS, + GIT_INTERACTION_SERVICE, + GIT_WRITE_CLIENT, +} from "@posthog/core/git-interaction/identifiers"; +import { LLM_GATEWAY_SERVICE } from "@posthog/core/llm-gateway/identifiers"; +import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway"; +import type { LlmMessage } from "@posthog/core/llm-gateway/schemas"; +import { CLOUD_ARTIFACT_READ_FILE_AS_BASE64 } from "@posthog/core/sessions/cloudArtifactIdentifiers"; +import { + LOCAL_HANDOFF_DIALOG, + LOCAL_HANDOFF_HOST, + LOCAL_HANDOFF_NOTIFIER, + LOCAL_HANDOFF_SERVICE, + type LocalHandoffHost, + LocalHandoffService, +} from "@posthog/core/sessions/localHandoffService"; +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { sessionsModule } from "@posthog/core/sessions/sessions.module"; +import { + TITLE_GENERATOR_FILE_READ_CLIENT, + TITLE_GENERATOR_LOGGER, +} from "@posthog/core/sessions/titleGeneratorIdentifiers"; +import { + TASK_CREATION_EFFECTS, + TASK_CREATION_HOST, +} from "@posthog/core/task-detail/identifiers"; +import type { ITaskCreationHost } from "@posthog/core/task-detail/taskCreationHost"; +import { + TASK_SERVICE, + TaskService, +} from "@posthog/core/task-detail/taskService"; +import { + TASK_DELETION_HOST, + TASK_DELETION_SERVICE, + TASK_DELETION_WORKSPACE_CLIENT, +} from "@posthog/core/tasks/identifiers"; +import { TaskDeletionService } from "@posthog/core/tasks/taskDeletionService"; +import { + SHELL_PROCESS_READER, + type ShellProcessReader, +} from "@posthog/core/terminal/identifiers"; +import { terminalCoreModule } from "@posthog/core/terminal/terminal.module"; +import { + WORKSPACE_SETUP_GIT_CLIENT, + WORKSPACE_SETUP_SERVICE, +} from "@posthog/core/workspace/identifiers"; +import { WorkspaceSetupService } from "@posthog/core/workspace/WorkspaceSetupService"; +import { setWorkbenchContainer } from "@posthog/di/container"; +import { HOST_TRPC_CLIENT } from "@posthog/host-router/client"; +import { + REVIEW_HOST, + type ReviewHost, +} from "@posthog/ui/features/code-review/reviewHost"; +import { + CONNECTIVITY_CLIENT, + type ConnectivityClient, +} from "@posthog/ui/features/connectivity/connectivityClient"; +import { FocusStoreCoordinator } from "@posthog/ui/features/external-apps/focusCoordinator"; +import { focusDeps } from "@posthog/ui/features/focus/focusAdapter"; +import { FOCUS_CONTROLLER_DEPS } from "@posthog/ui/features/focus/focusClient"; +import { + gitInteractionEffects, + gitWriteClient, +} from "@posthog/ui/features/git-interaction/gitInteractionAdapter"; +import { McpAppHost } from "@posthog/ui/features/mcp-apps/components/McpAppHost"; +import { McpToolBlock } from "@posthog/ui/features/mcp-apps/components/McpToolBlock"; +import { + MCP_APP_HOST_COMPONENT, + MCP_SANDBOX_PROXY_URL, +} from "@posthog/ui/features/mcp-apps/identifiers"; +import { MCP_TOOL_BLOCK_COMPONENT } from "@posthog/ui/features/sessions/components/session-update/identifiers"; +import { + localHandoffDialog, + localHandoffNotifier, +} from "@posthog/ui/features/sessions/localHandoffService"; +import { getSessionService } from "@posthog/ui/features/sessions/sessionServiceHost"; +import { taskCreationEffects } from "@posthog/ui/features/task-detail/taskCreationEffectsImpl"; +import { TrpcTaskCreationHost } from "@posthog/ui/features/task-detail/taskCreationHostImpl"; +import { + SHELL_CLIENT, + type ShellClient, +} from "@posthog/ui/features/terminal/shellClient"; +import { updatesClient } from "@posthog/ui/features/updates/updatesAdapter"; +import { UPDATES_CLIENT } from "@posthog/ui/features/updates/updatesClient"; +import { + ANALYTICS_TRACKER, + type AnalyticsTracker, +} from "@posthog/ui/workbench/analytics"; +import { DIFF_WORKER_FACTORY } from "@posthog/ui/workbench/diffWorkerHost"; +import { HOST_LOGGER } from "@posthog/ui/workbench/logger"; +import { posthogAnalyticsTracker } from "@posthog/ui/workbench/posthogAnalyticsImpl"; +import { + diffWorkerFactory, + reviewHost, +} from "@renderer/features/code-review/reviewHost"; +import { + taskDeletionHost, + taskDeletionWorkspaceClient, +} from "@renderer/platform-adapters/task-deletion"; import { trpcClient } from "@renderer/trpc"; +import { hostTrpcClient } from "@renderer/trpc/client"; import type { TRPCClient } from "@trpc/client"; +import { hostLog, logger } from "@utils/logger"; import { Container } from "inversify"; import { RENDERER_TOKENS } from "./tokens"; @@ -14,16 +137,212 @@ export const container = new Container({ defaultScope: "Singleton", }); +setWorkbenchContainer(container); + +container.bind(HOST_LOGGER).toConstantValue(hostLog); + // Bind infrastructure container .bind>(RENDERER_TOKENS.TRPCClient) .toConstantValue(trpcClient); +container.bind(HOST_TRPC_CLIENT).toConstantValue(hostTrpcClient); + +container.bind(UPDATES_CLIENT).toConstantValue(updatesClient); + +// connectivity client — passthrough over the renderer host client +const connectivityClient: ConnectivityClient = { + getStatus: () => trpcClient.connectivity.getStatus.query(), + onStatusChange: (sub) => + trpcClient.connectivity.onStatusChange.subscribe(undefined, sub), +}; +container.bind(CONNECTIVITY_CLIENT).toConstantValue(connectivityClient); + +// terminal shell client +const shellClient: ShellClient = { + write: async (input) => { + await trpcClient.shell.write.mutate(input); + }, + check: (input) => trpcClient.shell.check.query(input), + destroy: async (input) => { + await trpcClient.shell.destroy.mutate(input); + }, + create: async (input) => { + await trpcClient.shell.create.mutate(input); + }, + createCommand: async (input) => { + await trpcClient.shell.createCommand.mutate(input); + }, + resize: async (input) => { + await trpcClient.shell.resize.mutate(input); + }, + getProcess: async (input) => + (await trpcClient.shell.getProcess.query(input)) ?? null, + execute: (input) => trpcClient.shell.execute.mutate(input), + openExternal: async (input) => { + await trpcClient.os.openExternal.mutate(input); + }, + onData: (sessionId, onEvent) => + trpcClient.shell.onData.subscribe({ sessionId }, { onData: onEvent }), + onExit: (sessionId, onEvent) => + trpcClient.shell.onExit.subscribe({ sessionId }, { onData: onEvent }), +}; +container.bind(SHELL_CLIENT).toConstantValue(shellClient); + +// focus controller deps +container.bind(FOCUS_CONTROLLER_DEPS).toConstantValue(focusDeps); + +// code-review host (diff worker factory + expanded-review sidebar) +container.bind(DIFF_WORKER_FACTORY).toConstantValue(diffWorkerFactory); +container.bind(REVIEW_HOST).toConstantValue(reviewHost); + +// sessions MCP tool renderer slot +container.bind(MCP_TOOL_BLOCK_COMPONENT).toConstantValue(McpToolBlock); + +// interactive MCP App iframe host + its electron isolated-origin sandbox URL +container.bind(MCP_APP_HOST_COMPONENT).toConstantValue(McpAppHost); +container + .bind(MCP_SANDBOX_PROXY_URL) + .toConstantValue(() => "mcp-sandbox://proxy"); + +// terminal shell process reader + core module +container.bind(SHELL_PROCESS_READER).toConstantValue({ + getProcess: async (input) => + (await trpcClient.shell.getProcess.query(input)) ?? null, +}); +container.load(terminalCoreModule); + +// analytics tracker +container + .bind(ANALYTICS_TRACKER) + .toConstantValue(posthogAnalyticsTracker); + // Bind services +container.bind(TASK_CREATION_HOST).to(TrpcTaskCreationHost); +container.bind(TASK_CREATION_EFFECTS).toConstantValue(taskCreationEffects); container.bind(RENDERER_TOKENS.TaskService).to(TaskService); container - .bind(RENDERER_TOKENS.SetupRunService) - .to(SetupRunService); + .bind(TASK_SERVICE) + .toService(RENDERER_TOKENS.TaskService); +container + .bind(SESSION_SERVICE) + .toDynamicValue(() => getSessionService()) + .inSingletonScope(); +container.bind(LOCAL_HANDOFF_HOST).toConstantValue({ + getRepositoryByRemoteUrl: (input) => + trpcClient.folders.getRepositoryByRemoteUrl.query(input), + selectDirectory: () => trpcClient.os.selectDirectory.query(), + addFolder: (input) => trpcClient.folders.addFolder.mutate(input), +}); +container.bind(LOCAL_HANDOFF_DIALOG).toConstantValue(localHandoffDialog); +container.bind(LOCAL_HANDOFF_NOTIFIER).toConstantValue(localHandoffNotifier); +container + .bind(LOCAL_HANDOFF_SERVICE) + .to(LocalHandoffService) + .inSingletonScope(); + +// git-interaction +container.bind(GIT_WRITE_CLIENT).toConstantValue(gitWriteClient); +container.bind(GIT_INTERACTION_EFFECTS).toConstantValue(gitInteractionEffects); +container + .bind(GIT_INTERACTION_SERVICE) + .to(GitInteractionService) + .inSingletonScope(); + +// tasks (deletion) +container + .bind(TASK_DELETION_WORKSPACE_CLIENT) + .toConstantValue(taskDeletionWorkspaceClient); +container.bind(TASK_DELETION_HOST).toConstantValue(taskDeletionHost); +container + .bind(TASK_DELETION_SERVICE) + .to(TaskDeletionService) + .inSingletonScope(); + +// external-apps +container.bind(EXTERNAL_APPS_WORKSPACE_CLIENT).toConstantValue({ + openInApp: (appId: string, targetPath: string) => + hostTrpcClient.externalApps.openInApp.mutate({ appId, targetPath }), + setLastUsed: async (appId: string) => { + await hostTrpcClient.externalApps.setLastUsed.mutate({ appId }); + }, + getDetectedApps: () => hostTrpcClient.externalApps.getDetectedApps.query(), + copyPath: async (targetPath: string) => { + await hostTrpcClient.externalApps.copyPath.mutate({ targetPath }); + }, +} satisfies ExternalAppsWorkspaceClient); +container + .bind(EXTERNAL_APPS_FOCUS_COORDINATOR) + .to(FocusStoreCoordinator) + .inSingletonScope(); +container.bind(EXTERNAL_APPS_SERVICE).to(ExternalAppService).inSingletonScope(); + +// workspace setup +container.bind(WORKSPACE_SETUP_GIT_CLIENT).toConstantValue({ + detectRepo: (args: { directoryPath: string }) => + trpcClient.git.detectRepo.query(args), +}); +container + .bind(WORKSPACE_SETUP_SERVICE) + .to(WorkspaceSetupService) + .inSingletonScope(); + +// deep-links +container.bind(GITHUB_ISSUE_CLIENT).toConstantValue({ + getGithubIssue: (owner, repo, issueNumber) => + trpcClient.git.getGithubIssue.query({ + owner, + repo, + number: issueNumber, + }), +} satisfies GitHubIssueClient); +container + .bind(NEW_TASK_LINK_RESOLVER) + .to(NewTaskLinkResolver) + .inSingletonScope(); + +// code-review +container.bind(CODE_REVIEW_WORKSPACE_CLIENT).toConstantValue({ + getFileAtHead: (directoryPath: string, filePath: string) => + trpcClient.git.getFileAtHead.query({ directoryPath, filePath }), + readRepoFile: (repoPath: string, filePath: string) => + trpcClient.fs.readRepoFile.query({ repoPath, filePath }), + writeRepoFile: async ( + repoPath: string, + filePath: string, + content: string, + ) => { + await trpcClient.fs.writeRepoFile.mutate({ repoPath, filePath, content }); + }, +} satisfies CodeReviewWorkspaceClient); +container.bind(REVERT_HUNK_SERVICE).to(RevertHunkService).inSingletonScope(); + +// sessions (cloud-artifact + title-generator) +container.load(sessionsModule); +container + .bind(CLOUD_ARTIFACT_READ_FILE_AS_BASE64) + .toConstantValue((filePath: string) => + trpcClient.fs.readFileAsBase64.query({ filePath }), + ); +container.bind(LLM_GATEWAY_SERVICE).toConstantValue({ + prompt: ( + messages: LlmMessage[], + options: { system?: string; maxTokens?: number; model?: string } = {}, + ) => + trpcClient.llmGateway.prompt.mutate({ + messages, + system: options.system, + maxTokens: options.maxTokens, + model: options.model, + }), +} as unknown as LlmGatewayService); +container.bind(TITLE_GENERATOR_FILE_READ_CLIENT).toConstantValue({ + readAbsoluteFile: (filePath: string) => + trpcClient.fs.readAbsoluteFile.query({ filePath }), +}); +container + .bind(TITLE_GENERATOR_LOGGER) + .toConstantValue(logger.scope("title-generator")); export function get(token: symbol): T { return container.get(token); diff --git a/apps/code/src/renderer/di/tokens.ts b/apps/code/src/renderer/di/tokens.ts index 7b60ca586c..277d87f668 100644 --- a/apps/code/src/renderer/di/tokens.ts +++ b/apps/code/src/renderer/di/tokens.ts @@ -6,9 +6,8 @@ */ export const RENDERER_TOKENS = Object.freeze({ // Infrastructure - TRPCClient: Symbol.for("Renderer.TRPCClient"), + TRPCClient: Symbol.for("posthog.host.renderer.trpc-client"), // Services - TaskService: Symbol.for("Renderer.TaskService"), - SetupRunService: Symbol.for("Renderer.SetupRunService"), + TaskService: Symbol.for("posthog.host.renderer.task-service"), }); diff --git a/apps/code/src/renderer/features/auth/hooks/authClient.ts b/apps/code/src/renderer/features/auth/hooks/authClient.ts deleted file mode 100644 index 42d23a1990..0000000000 --- a/apps/code/src/renderer/features/auth/hooks/authClient.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { PostHogAPIClient } from "@renderer/api/posthogClient"; -import { trpcClient } from "@renderer/trpc/client"; -import { NotAuthenticatedError } from "@shared/errors"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { useMemo } from "react"; -import { - type AuthState, - fetchAuthState, - useAuthStateValue, -} from "./authQueries"; - -async function getValidAccessToken(): Promise { - const { accessToken } = await trpcClient.auth.getValidAccessToken.query(); - return accessToken; -} - -async function refreshAccessToken(): Promise { - const { accessToken } = await trpcClient.auth.refreshAccessToken.mutate(); - return accessToken; -} - -export function createAuthenticatedClient( - authState: AuthState | null | undefined, -): PostHogAPIClient | null { - if (authState?.status !== "authenticated" || !authState.cloudRegion) { - return null; - } - - const client = new PostHogAPIClient( - getCloudUrlFromRegion(authState.cloudRegion), - getValidAccessToken, - refreshAccessToken, - authState.projectId ?? undefined, - ); - - if (authState.projectId) { - client.setTeamId(authState.projectId); - } - - return client; -} - -export async function getAuthenticatedClient(): Promise { - return createAuthenticatedClient(await fetchAuthState()); -} - -export function useOptionalAuthenticatedClient(): PostHogAPIClient | null { - const authState = useAuthStateValue((state) => state); - - return useMemo( - () => createAuthenticatedClient(authState), - [authState.cloudRegion, authState.projectId, authState.status, authState], - ); -} - -export function useAuthenticatedClient(): PostHogAPIClient { - const client = useOptionalAuthenticatedClient(); - - if (!client) { - throw new NotAuthenticatedError(); - } - - return client; -} diff --git a/apps/code/src/renderer/features/auth/hooks/authMutations.ts b/apps/code/src/renderer/features/auth/hooks/authMutations.ts deleted file mode 100644 index a371710d5d..0000000000 --- a/apps/code/src/renderer/features/auth/hooks/authMutations.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - clearAuthScopedQueries, - fetchAuthState, - refreshAuthStateQuery, -} from "@features/auth/hooks/authQueries"; -import { useAuthUiStateStore } from "@features/auth/stores/authUiStateStore"; -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { resetSessionService } from "@features/sessions/service/service"; -import { trpcClient } from "@renderer/trpc/client"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import type { CloudRegion } from "@shared/types/regions"; -import { useNavigationStore } from "@stores/navigationStore"; -import { useMutation } from "@tanstack/react-query"; -import { track } from "@utils/analytics"; - -function useAuthFlowMutation( - mutateAuth: (region: CloudRegion) => Promise<{ - state: Awaited>; - }>, -) { - return useMutation({ - mutationFn: async (region: CloudRegion) => { - return await mutateAuth(region); - }, - onSuccess: async ({ state }, region) => { - await refreshAuthStateQuery(); - useAuthUiStateStore.getState().clearStaleRegion(); - track(ANALYTICS_EVENTS.USER_LOGGED_IN, { - project_id: state.projectId?.toString() ?? "", - region, - }); - }, - }); -} - -export function useLoginMutation() { - return useAuthFlowMutation(async (region) => { - return await trpcClient.auth.login.mutate({ region }); - }); -} - -export function useSignupMutation() { - return useAuthFlowMutation(async (region) => { - return await trpcClient.auth.signup.mutate({ region }); - }); -} - -export function useSelectProjectMutation() { - return useMutation({ - mutationFn: async (projectId: number) => { - resetSessionService(); - return await trpcClient.auth.selectProject.mutate({ projectId }); - }, - onSuccess: async () => { - clearAuthScopedQueries(); - await refreshAuthStateQuery(); - useNavigationStore.getState().navigateToTaskInput(); - }, - }); -} - -export function useRedeemInviteCodeMutation() { - return useMutation({ - mutationFn: async (code: string) => - await trpcClient.auth.redeemInviteCode.mutate({ code }), - onSuccess: async () => { - await refreshAuthStateQuery(); - }, - }); -} - -export function useLogoutMutation() { - return useMutation({ - mutationFn: async () => { - const previousState = await fetchAuthState(); - - track(ANALYTICS_EVENTS.USER_LOGGED_OUT); - resetSessionService(); - - return { previousState }; - }, - onSuccess: async ({ previousState }) => { - clearAuthScopedQueries(); - useAuthUiStateStore.getState().setStaleRegion(previousState.cloudRegion); - useNavigationStore.getState().navigateToTaskInput(); - useOnboardingStore.getState().resetSelections(); - - await trpcClient.auth.logout.mutate(); - await refreshAuthStateQuery(); - }, - }); -} diff --git a/apps/code/src/renderer/features/auth/hooks/authQueries.ts b/apps/code/src/renderer/features/auth/hooks/authQueries.ts deleted file mode 100644 index c7a7198c71..0000000000 --- a/apps/code/src/renderer/features/auth/hooks/authQueries.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { PostHogAPIClient } from "@renderer/api/posthogClient"; -import { trpc, trpcClient } from "@renderer/trpc/client"; -import { useQuery } from "@tanstack/react-query"; -import { queryClient } from "@utils/queryClient"; - -export type AuthState = Awaited< - ReturnType ->; - -export const AUTH_SCOPED_QUERY_META = { - authScoped: true, -} as const; - -export const ANONYMOUS_AUTH_STATE: AuthState = { - status: "anonymous", - bootstrapComplete: false, - cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], - hasCodeAccess: null, - needsScopeReauth: false, -}; - -export const authKeys = { - currentUsers: () => ["auth", "current-user"] as const, - currentUser: (identity: string | null) => - [...authKeys.currentUsers(), identity ?? "anonymous"] as const, -}; - -function getAuthStateQueryOptions() { - return trpc.auth.getState.queryOptions(); -} - -export async function fetchAuthState(): Promise { - return await trpcClient.auth.getState.query(); -} - -export function getCachedAuthState(): AuthState { - return ( - queryClient.getQueryData(trpc.auth.getState.queryKey()) ?? - ANONYMOUS_AUTH_STATE - ); -} - -export async function refreshAuthStateQuery(): Promise { - await queryClient.invalidateQueries(trpc.auth.getState.pathFilter()); -} - -export function clearAuthScopedQueries(): void { - queryClient.removeQueries({ - predicate: (query) => query.meta?.authScoped === true, - }); -} - -export function getAuthIdentity(authState: AuthState): string | null { - if (authState.status !== "authenticated" || !authState.cloudRegion) { - return null; - } - - return `${authState.cloudRegion}:${authState.projectId ?? "none"}`; -} - -export function useAuthState() { - return useQuery({ - ...getAuthStateQueryOptions(), - placeholderData: ANONYMOUS_AUTH_STATE, - refetchOnMount: true, - }); -} - -export function useAuthStateFetched(): boolean { - const { isFetched } = useAuthState(); - return isFetched; -} - -export function useAuthStateValue(selector: (state: AuthState) => T): T { - const { data } = useAuthState(); - return selector(data ?? ANONYMOUS_AUTH_STATE); -} - -export function useCurrentUser(options?: { - enabled?: boolean; - client?: PostHogAPIClient | null; - refetchOnWindowFocus?: boolean | "always"; -}) { - const authState = useAuthStateValue((state) => state); - const client = options?.client ?? null; - const authIdentity = getAuthIdentity(authState); - - return useQuery({ - queryKey: authKeys.currentUser(authIdentity), - queryFn: async () => { - if (!client) { - throw new Error("Not authenticated"); - } - - return await client.getCurrentUser(); - }, - enabled: !!client && !!authIdentity && (options?.enabled ?? true), - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: options?.refetchOnWindowFocus, - meta: AUTH_SCOPED_QUERY_META, - }); -} diff --git a/apps/code/src/renderer/features/auth/hooks/useOAuthFlow.ts b/apps/code/src/renderer/features/auth/hooks/useOAuthFlow.ts deleted file mode 100644 index 6b5518336a..0000000000 --- a/apps/code/src/renderer/features/auth/hooks/useOAuthFlow.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useAuthStore } from "@features/auth/stores/authStore"; -import { trpcClient } from "@renderer/trpc/client"; -import type { CloudRegion } from "@shared/types/regions"; -import { useMutation } from "@tanstack/react-query"; -import { useState } from "react"; - -export function getErrorMessage(error: unknown) { - if (!error) { - return null; - } - if (!(error instanceof Error)) { - return "Failed to authenticate"; - } - const message = error.message; - - if (message === "2FA_REQUIRED") { - return null; // 2FA dialog will handle this - } - - if (message.includes("access_denied")) { - return "Authorization cancelled."; - } - - if (message.includes("timed out")) { - return "Authorization timed out. Please try again."; - } - - if (message.includes("SSO login required")) { - return message; - } - - return message; -} - -export function useOAuthFlow() { - const staleRegion = useAuthStore((s) => s.staleCloudRegion); - const [region, setRegion] = useState(staleRegion ?? "us"); - const { loginWithOAuth } = useAuthStore(); - - const loginMutation = useMutation({ - mutationFn: async () => { - await loginWithOAuth(region); - }, - }); - - const handleAuth = () => { - loginMutation.mutate(); - }; - - const handleRegionChange = (value: CloudRegion) => { - setRegion(value); - loginMutation.reset(); - }; - - const handleCancel = async () => { - loginMutation.reset(); - await trpcClient.oauth.cancelFlow.mutate(); - }; - - return { - region, - handleAuth, - handleRegionChange, - handleCancel, - isPending: loginMutation.isPending, - errorMessage: getErrorMessage(loginMutation.error), - }; -} diff --git a/apps/code/src/renderer/features/auth/stores/authStore.test.ts b/apps/code/src/renderer/features/auth/stores/authStore.test.ts deleted file mode 100644 index f5d0ec9518..0000000000 --- a/apps/code/src/renderer/features/auth/stores/authStore.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockGetState = vi.hoisted(() => ({ query: vi.fn() })); -const mockGetValidAccessToken = vi.hoisted(() => ({ query: vi.fn() })); -const mockRefreshAccessToken = vi.hoisted(() => ({ mutate: vi.fn() })); -const mockLogin = vi.hoisted(() => ({ mutate: vi.fn() })); -const mockSignup = vi.hoisted(() => ({ mutate: vi.fn() })); -const mockSelectProject = vi.hoisted(() => ({ mutate: vi.fn() })); -const mockRedeemInviteCode = vi.hoisted(() => ({ mutate: vi.fn() })); -const mockLogout = vi.hoisted(() => ({ mutate: vi.fn() })); -const mockGetCurrentUser = vi.fn(); - -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - auth: { - getState: mockGetState, - getValidAccessToken: mockGetValidAccessToken, - refreshAccessToken: mockRefreshAccessToken, - login: mockLogin, - signup: mockSignup, - selectProject: mockSelectProject, - redeemInviteCode: mockRedeemInviteCode, - logout: mockLogout, - }, - analytics: { - setUserId: { mutate: vi.fn().mockResolvedValue(undefined) }, - resetUser: { mutate: vi.fn().mockResolvedValue(undefined) }, - }, - }, -})); - -vi.mock("@renderer/api/posthogClient", () => ({ - PostHogAPIClient: vi.fn().mockImplementation(function ( - this: Record, - ) { - this.getCurrentUser = mockGetCurrentUser; - this.setTeamId = vi.fn(); - }), - SeatSubscriptionRequiredError: class SeatSubscriptionRequiredError extends Error { - redirectUrl: string; - constructor(redirectUrl: string) { - super("Billing subscription required"); - this.name = "SeatSubscriptionRequiredError"; - this.redirectUrl = redirectUrl; - } - }, - SeatPaymentFailedError: class SeatPaymentFailedError extends Error { - constructor(message?: string) { - super(message ?? "Payment failed"); - this.name = "SeatPaymentFailedError"; - } - }, -})); - -vi.mock("@utils/analytics", () => ({ - identifyUser: vi.fn(), - resetUser: vi.fn(), - setUserGroups: vi.fn(), - track: vi.fn(), -})); - -vi.mock("@utils/logger", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -vi.mock("@utils/queryClient", () => ({ - queryClient: { - clear: vi.fn(), - setQueryData: vi.fn(), - removeQueries: vi.fn(), - }, -})); - -vi.mock("@stores/navigationStore", () => ({ - useNavigationStore: { - getState: () => ({ navigateToTaskInput: vi.fn() }), - }, -})); - -import { resetUser, setUserGroups } from "@utils/analytics"; -import { queryClient } from "@utils/queryClient"; -import { resetAuthStoreModuleStateForTest, useAuthStore } from "./authStore"; - -const authenticatedState = { - status: "authenticated" as const, - bootstrapComplete: true, - cloudRegion: "us" as const, - projectId: 1, - availableProjectIds: [1, 2], - availableOrgIds: ["org-1"], - hasCodeAccess: true, - needsScopeReauth: false, -}; - -describe("authStore", () => { - beforeEach(() => { - vi.clearAllMocks(); - resetAuthStoreModuleStateForTest(); - mockGetCurrentUser.mockResolvedValue({ - distinct_id: "user-123", - email: "test@example.com", - uuid: "uuid-123", - }); - mockGetValidAccessToken.query.mockResolvedValue({ - accessToken: "test-access-token", - apiHost: "https://us.posthog.com", - }); - mockRefreshAccessToken.mutate.mockResolvedValue({ - accessToken: "fresh-access-token", - apiHost: "https://us.posthog.com", - }); - mockGetState.query.mockResolvedValue({ - status: "anonymous", - bootstrapComplete: true, - cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], - hasCodeAccess: null, - needsScopeReauth: false, - }); - useAuthStore.setState({ - cloudRegion: null, - staleCloudRegion: null, - isAuthenticated: false, - client: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], - needsProjectSelection: false, - needsScopeReauth: false, - hasCodeAccess: null, - }); - }); - - it("syncs from main auth state", async () => { - mockGetState.query.mockResolvedValue(authenticatedState); - - await useAuthStore.getState().checkCodeAccess(); - - expect(useAuthStore.getState().isAuthenticated).toBe(true); - expect(useAuthStore.getState().projectId).toBe(1); - }); - - it("logs in through the main auth service", async () => { - mockLogin.mutate.mockResolvedValue({ state: authenticatedState }); - mockGetState.query.mockResolvedValue(authenticatedState); - - await useAuthStore.getState().loginWithOAuth("us"); - - expect(mockLogin.mutate).toHaveBeenCalledWith({ region: "us" }); - expect(useAuthStore.getState().isAuthenticated).toBe(true); - expect(useAuthStore.getState().needsScopeReauth).toBe(false); - }); - - it("deduplicates expensive renderer auth sync for repeated auth-state events", async () => { - mockGetState.query.mockResolvedValue(authenticatedState); - - await useAuthStore.getState().checkCodeAccess(); - await useAuthStore.getState().checkCodeAccess(); - - expect(mockGetCurrentUser).toHaveBeenCalledTimes(1); - expect(setUserGroups).toHaveBeenCalledTimes(1); - }); - - it("clears user identity and cached current user on implicit auth loss", async () => { - mockGetState.query - .mockResolvedValueOnce(authenticatedState) - .mockResolvedValueOnce({ - status: "anonymous", - bootstrapComplete: true, - cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], - hasCodeAccess: null, - needsScopeReauth: false, - }); - - await useAuthStore.getState().checkCodeAccess(); - await useAuthStore.getState().checkCodeAccess(); - - expect(resetUser).toHaveBeenCalledTimes(1); - expect(queryClient.removeQueries).toHaveBeenCalledWith({ - queryKey: ["currentUser"], - exact: true, - }); - }); - - it("clears auth state immediately on logout before the auth service responds", async () => { - mockGetState.query.mockResolvedValue(authenticatedState); - let resolveLogout!: () => void; - mockLogout.mutate.mockImplementation( - () => - new Promise((resolve) => { - resolveLogout = () => resolve(undefined); - }), - ); - - await useAuthStore.getState().checkCodeAccess(); - - const logoutPromise = useAuthStore.getState().logout(); - await Promise.resolve(); - - expect(useAuthStore.getState().isAuthenticated).toBe(false); - expect(useAuthStore.getState().client).toBeNull(); - expect(useAuthStore.getState().projectId).toBeNull(); - expect(useAuthStore.getState().needsScopeReauth).toBe(false); - - resolveLogout(); - await logoutPromise; - }); -}); diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts deleted file mode 100644 index 8de660445c..0000000000 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { useSeatStore } from "@features/billing/stores/seatStore"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { PostHogAPIClient } from "@renderer/api/posthogClient"; -import { trpcClient } from "@renderer/trpc/client"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import type { CloudRegion } from "@shared/types/regions"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { useNavigationStore } from "@stores/navigationStore"; -import { - identifyUser, - resetUser, - setUserGroups, - track, -} from "@utils/analytics"; -import { logger } from "@utils/logger"; -import { queryClient } from "@utils/queryClient"; -import { create } from "zustand"; - -const log = logger.scope("auth-store"); - -let sessionResetCallback: (() => void) | null = null; -let inFlightAuthSync: Promise | null = null; -let inFlightAuthSyncKey: string | null = null; -let lastCompletedAuthSyncKey: string | null = null; - -export function setSessionResetCallback(callback: () => void) { - sessionResetCallback = callback; -} - -export function resetAuthStoreModuleStateForTest(): void { - sessionResetCallback = null; - inFlightAuthSync = null; - inFlightAuthSyncKey = null; - lastCompletedAuthSyncKey = null; -} - -interface AuthStoreState { - cloudRegion: CloudRegion | null; - staleCloudRegion: CloudRegion | null; - isAuthenticated: boolean; - client: PostHogAPIClient | null; - projectId: number | null; - availableProjectIds: number[]; - availableOrgIds: string[]; - needsProjectSelection: boolean; - needsScopeReauth: boolean; - hasCodeAccess: boolean | null; - - checkCodeAccess: () => Promise; - redeemInviteCode: (code: string) => Promise; - loginWithOAuth: (region: CloudRegion) => Promise; - signupWithOAuth: (region: CloudRegion) => Promise; - selectProject: (projectId: number) => Promise; - logout: () => Promise; -} - -async function getValidAccessToken(): Promise { - const { accessToken } = await trpcClient.auth.getValidAccessToken.query(); - return accessToken; -} - -async function refreshAccessToken(): Promise { - const { accessToken } = await trpcClient.auth.refreshAccessToken.mutate(); - return accessToken; -} - -function createClient( - cloudRegion: CloudRegion, - projectId: number | null, -): PostHogAPIClient { - const client = new PostHogAPIClient( - getCloudUrlFromRegion(cloudRegion), - getValidAccessToken, - refreshAccessToken, - projectId ?? undefined, - ); - if (projectId) { - client.setTeamId(projectId); - } - return client; -} - -function clearAuthenticatedRendererState(options?: { - clearAllQueries?: boolean; -}): void { - resetUser(); - trpcClient.analytics.resetUser.mutate(); - - if (options?.clearAllQueries) { - queryClient.clear(); - return; - } - - queryClient.removeQueries({ queryKey: ["currentUser"], exact: true }); -} - -async function syncAuthState(): Promise { - const previousState = useAuthStore.getState(); - const authState = await trpcClient.auth.getState.query(); - const isAuthenticated = authState.status === "authenticated"; - - useAuthStore.setState((state) => { - const regionChanged = authState.cloudRegion !== state.cloudRegion; - const projectChanged = authState.projectId !== state.projectId; - const client = - isAuthenticated && authState.cloudRegion - ? regionChanged || projectChanged || !state.client - ? createClient(authState.cloudRegion, authState.projectId) - : state.client - : null; - - return { - ...state, - isAuthenticated, - cloudRegion: authState.cloudRegion, - staleCloudRegion: isAuthenticated - ? null - : (authState.cloudRegion ?? state.staleCloudRegion), - client, - projectId: authState.projectId, - availableProjectIds: authState.availableProjectIds, - availableOrgIds: authState.availableOrgIds, - needsProjectSelection: - isAuthenticated && - authState.availableProjectIds.length > 1 && - authState.projectId === null, - needsScopeReauth: authState.needsScopeReauth, - hasCodeAccess: authState.hasCodeAccess, - }; - }); - - const client = useAuthStore.getState().client; - - if (!isAuthenticated || !authState.cloudRegion || !client) { - if (previousState.isAuthenticated || lastCompletedAuthSyncKey !== null) { - clearAuthenticatedRendererState(); - } - inFlightAuthSync = null; - inFlightAuthSyncKey = null; - lastCompletedAuthSyncKey = null; - return; - } - - const authSyncKey = JSON.stringify({ - status: authState.status, - cloudRegion: authState.cloudRegion, - projectId: authState.projectId, - }); - - if (authSyncKey === lastCompletedAuthSyncKey) { - return; - } - - if (inFlightAuthSync && inFlightAuthSyncKey === authSyncKey) { - await inFlightAuthSync; - return; - } - - inFlightAuthSyncKey = authSyncKey; - inFlightAuthSync = (async () => { - try { - const user = await client.getCurrentUser(); - queryClient.setQueryData(["currentUser"], user); - - const distinctId = user.distinct_id || user.email; - identifyUser(distinctId, { - email: user.email, - uuid: user.uuid, - project_id: authState.projectId?.toString() ?? "", - region: authState.cloudRegion ?? "", - }); - - setUserGroups(user); - - trpcClient.analytics.setUserId.mutate({ - userId: distinctId, - properties: { - email: user.email, - uuid: user.uuid, - project_id: authState.projectId?.toString() ?? "", - region: authState.cloudRegion ?? "", - }, - }); - - lastCompletedAuthSyncKey = authSyncKey; - } catch (error) { - log.warn("Failed to synchronize authenticated renderer state", { error }); - } finally { - if (inFlightAuthSyncKey === authSyncKey) { - inFlightAuthSync = null; - inFlightAuthSyncKey = null; - } - } - })(); - - await inFlightAuthSync; -} - -export const useAuthStore = create((set) => ({ - cloudRegion: null, - staleCloudRegion: null, - - isAuthenticated: false, - client: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], - needsProjectSelection: false, - needsScopeReauth: false, - hasCodeAccess: null, - - checkCodeAccess: async () => { - await syncAuthState(); - }, - - redeemInviteCode: async (code: string) => { - await trpcClient.auth.redeemInviteCode.mutate({ code }); - await syncAuthState(); - }, - - loginWithOAuth: async (region: CloudRegion) => { - const result = await trpcClient.auth.login.mutate({ region }); - await syncAuthState(); - track(ANALYTICS_EVENTS.USER_LOGGED_IN, { - project_id: result.state.projectId?.toString() ?? "", - region, - }); - }, - - signupWithOAuth: async (region: CloudRegion) => { - const result = await trpcClient.auth.signup.mutate({ region }); - await syncAuthState(); - track(ANALYTICS_EVENTS.USER_LOGGED_IN, { - project_id: result.state.projectId?.toString() ?? "", - region, - }); - }, - - selectProject: async (projectId: number) => { - sessionResetCallback?.(); - await trpcClient.auth.selectProject.mutate({ projectId }); - await syncAuthState(); - useNavigationStore.getState().navigateToTaskInput(); - }, - - logout: async () => { - track(ANALYTICS_EVENTS.USER_LOGGED_OUT); - sessionResetCallback?.(); - useSeatStore.getState().reset(); - useSettingsDialogStore.getState().close(); - - set((state) => ({ - ...state, - cloudRegion: null, - staleCloudRegion: state.cloudRegion ?? null, - isAuthenticated: false, - client: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], - needsProjectSelection: false, - needsScopeReauth: false, - hasCodeAccess: null, - })); - inFlightAuthSync = null; - inFlightAuthSyncKey = null; - lastCompletedAuthSyncKey = null; - - clearAuthenticatedRendererState({ clearAllQueries: true }); - useNavigationStore.getState().navigateToTaskInput(); - await trpcClient.auth.logout.mutate(); - }, -})); diff --git a/apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts b/apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts deleted file mode 100644 index 99ad122675..0000000000 --- a/apps/code/src/renderer/features/billing/hooks/useSpendAnalysis.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; -import type { SpendAnalysisResponse } from "@features/billing/types/spend-analysis"; -import { logger } from "@utils/logger"; -import { useCallback, useState } from "react"; - -const log = logger.scope("spend-analysis"); - -interface RunOptions { - dateFrom?: string; - dateTo?: string; - product?: string; -} - -interface UseSpendAnalysisReturn { - data: SpendAnalysisResponse | null; - isLoading: boolean; - error: string | null; - run: (options?: RunOptions) => Promise; -} - -export function useSpendAnalysis(): UseSpendAnalysisReturn { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const run = useCallback(async (options: RunOptions = {}) => { - setIsLoading(true); - setError(null); - try { - const client = await getAuthenticatedClient(); - if (!client) { - throw new Error("Not authenticated"); - } - const result = await client.getPersonalSpendAnalysis(options); - setData(result); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - log.warn("Failed to fetch spend analysis", { error: message }); - setData(null); - setError(message); - } finally { - setIsLoading(false); - } - }, []); - - return { data, isLoading, error, run }; -} diff --git a/apps/code/src/renderer/features/billing/hooks/useUsage.ts b/apps/code/src/renderer/features/billing/hooks/useUsage.ts deleted file mode 100644 index 2b6af06c72..0000000000 --- a/apps/code/src/renderer/features/billing/hooks/useUsage.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useTRPC } from "@renderer/trpc"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { useCallback } from "react"; - -export function useUsage({ enabled = true }: { enabled?: boolean } = {}) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const query = useQuery({ - ...trpc.usageMonitor.getLatest.queryOptions(), - enabled, - }); - const { mutateAsync: refreshUsage } = useMutation( - trpc.usageMonitor.refresh.mutationOptions(), - ); - - useSubscription( - trpc.usageMonitor.onUsageUpdated.subscriptionOptions(undefined, { - enabled, - onData: (data) => { - queryClient.setQueryData(trpc.usageMonitor.getLatest.queryKey(), data); - }, - }), - ); - - const refetch = useCallback(async () => { - const fresh = await refreshUsage(); - if (fresh) { - queryClient.setQueryData(trpc.usageMonitor.getLatest.queryKey(), fresh); - } - return fresh; - }, [refreshUsage, queryClient, trpc.usageMonitor.getLatest]); - - return { - usage: query.data ?? null, - isLoading: query.isLoading, - refetch, - }; -} diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.test.ts b/apps/code/src/renderer/features/billing/stores/seatStore.test.ts deleted file mode 100644 index 464d4e97da..0000000000 --- a/apps/code/src/renderer/features/billing/stores/seatStore.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -import type { SeatData } from "@shared/types/seat"; -import { PLAN_FREE, PLAN_PRO, PLAN_PRO_ALPHA } from "@shared/types/seat"; - -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockGetAuthenticatedClient = vi.hoisted(() => vi.fn()); - -vi.mock("@features/auth/hooks/authClient", () => ({ - getAuthenticatedClient: mockGetAuthenticatedClient, -})); - -vi.mock("@renderer/api/posthogClient", () => ({ - SeatSubscriptionRequiredError: class SeatSubscriptionRequiredError extends Error { - redirectUrl: string; - constructor(redirectUrl: string) { - super("Billing subscription required"); - this.name = "SeatSubscriptionRequiredError"; - this.redirectUrl = redirectUrl; - } - }, - SeatPaymentFailedError: class SeatPaymentFailedError extends Error { - constructor(message?: string) { - super(message ?? "Payment failed"); - this.name = "SeatPaymentFailedError"; - } - }, -})); - -vi.mock("@utils/logger", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -vi.mock("@renderer/trpc", () => ({ - trpcClient: { - llmGateway: { - invalidatePlanCache: { mutate: vi.fn().mockResolvedValue(undefined) }, - }, - }, -})); - -vi.mock("@utils/analytics", () => ({ track: vi.fn() })); - -import { trpcClient } from "@renderer/trpc"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; -import { useSeatStore } from "./seatStore"; - -const mockInvalidatePlanCache = vi.mocked( - trpcClient.llmGateway.invalidatePlanCache.mutate, -); -const mockTrack = vi.mocked(track); - -function makeSeat(overrides: Partial = {}): SeatData { - return { - id: 1, - user_distinct_id: "user-123", - product_key: "posthog_code", - plan_key: PLAN_FREE, - status: "active", - end_reason: null, - created_at: Date.now(), - active_until: null, - active_from: Date.now(), - ...overrides, - }; -} - -function mockClient(overrides: Record = {}) { - const client = { - getMySeat: vi.fn().mockResolvedValue(null), - createSeat: vi.fn().mockResolvedValue(makeSeat()), - upgradeSeat: vi.fn().mockResolvedValue(makeSeat({ plan_key: PLAN_PRO })), - cancelSeat: vi.fn().mockResolvedValue(undefined), - reactivateSeat: vi.fn().mockResolvedValue(makeSeat()), - ...overrides, - }; - mockGetAuthenticatedClient.mockResolvedValue(client); - return client; -} - -describe("seatStore", () => { - beforeEach(() => { - vi.clearAllMocks(); - useSeatStore.setState({ - seat: null, - orgSeat: null, - isLoading: false, - error: null, - redirectUrl: null, - billingOrgId: null, - }); - }); - - describe("fetchSeat", () => { - it("fetches existing seat", async () => { - const seat = makeSeat(); - mockClient({ getMySeat: vi.fn().mockResolvedValue(seat) }); - - await useSeatStore.getState().fetchSeat(); - - const state = useSeatStore.getState(); - expect(state.seat).toEqual(seat); - expect(state.isLoading).toBe(false); - }); - - it("auto-provisions free seat when none exists", async () => { - const seat = makeSeat(); - const client = mockClient({ - getMySeat: vi.fn().mockResolvedValue(null), - createSeat: vi.fn().mockResolvedValue(seat), - }); - - await useSeatStore.getState().fetchSeat({ autoProvision: true }); - - expect(client.createSeat).toHaveBeenCalledWith(PLAN_FREE); - expect(useSeatStore.getState().seat).toEqual(seat); - }); - - it("does not auto-provision when option is false", async () => { - const client = mockClient(); - - await useSeatStore.getState().fetchSeat(); - - expect(client.createSeat).not.toHaveBeenCalled(); - expect(useSeatStore.getState().seat).toBeNull(); - }); - }); - - describe("provisionFreeSeat", () => { - it("creates free seat when none exists", async () => { - const seat = makeSeat(); - const client = mockClient({ - createSeat: vi.fn().mockResolvedValue(seat), - }); - - await useSeatStore.getState().provisionFreeSeat(); - - expect(client.createSeat).toHaveBeenCalledWith(PLAN_FREE); - expect(useSeatStore.getState().seat).toEqual(seat); - expect(mockInvalidatePlanCache).toHaveBeenCalled(); - }); - - it("uses existing seat instead of creating", async () => { - const existing = makeSeat(); - const client = mockClient({ - getMySeat: vi.fn().mockResolvedValue(existing), - }); - - await useSeatStore.getState().provisionFreeSeat(); - - expect(client.createSeat).not.toHaveBeenCalled(); - expect(useSeatStore.getState().seat).toEqual(existing); - expect(mockInvalidatePlanCache).not.toHaveBeenCalled(); - }); - }); - - describe("upgradeToPro", () => { - it("upgrades existing free seat to pro", async () => { - const freeSeat = makeSeat({ plan_key: PLAN_FREE }); - const proSeat = makeSeat({ plan_key: PLAN_PRO }); - const client = mockClient({ - getMySeat: vi.fn().mockResolvedValue(freeSeat), - upgradeSeat: vi.fn().mockResolvedValue(proSeat), - }); - - await useSeatStore.getState().upgradeToPro(); - - expect(client.upgradeSeat).toHaveBeenCalledWith(PLAN_PRO); - expect(useSeatStore.getState().seat).toEqual(proSeat); - expect(mockInvalidatePlanCache).toHaveBeenCalled(); - expect(mockTrack).toHaveBeenCalledWith( - ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, - { plan_key: PLAN_PRO, previous_plan_key: PLAN_FREE }, - ); - }); - - it("no-ops when already on pro", async () => { - const proSeat = makeSeat({ plan_key: PLAN_PRO }); - const client = mockClient({ - getMySeat: vi.fn().mockResolvedValue(proSeat), - }); - - await useSeatStore.getState().upgradeToPro(); - - expect(client.upgradeSeat).not.toHaveBeenCalled(); - expect(client.createSeat).not.toHaveBeenCalled(); - expect(useSeatStore.getState().seat).toEqual(proSeat); - expect(mockTrack).not.toHaveBeenCalled(); - }); - - it("upgrades alpha pro seat to paid pro", async () => { - const alphaSeat = makeSeat({ plan_key: PLAN_PRO_ALPHA }); - const proSeat = makeSeat({ plan_key: PLAN_PRO }); - const client = mockClient({ - getMySeat: vi.fn().mockResolvedValue(alphaSeat), - upgradeSeat: vi.fn().mockResolvedValue(proSeat), - }); - - await useSeatStore.getState().upgradeToPro(); - - expect(client.upgradeSeat).toHaveBeenCalledWith(PLAN_PRO); - expect(useSeatStore.getState().seat).toEqual(proSeat); - expect(mockTrack).toHaveBeenCalledWith( - ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, - { plan_key: PLAN_PRO, previous_plan_key: PLAN_PRO_ALPHA }, - ); - }); - - it("creates pro seat when none exists", async () => { - const proSeat = makeSeat({ plan_key: PLAN_PRO }); - const client = mockClient({ - createSeat: vi.fn().mockResolvedValue(proSeat), - }); - - await useSeatStore.getState().upgradeToPro(); - - expect(client.createSeat).toHaveBeenCalledWith(PLAN_PRO); - expect(mockInvalidatePlanCache).toHaveBeenCalled(); - expect(mockTrack).toHaveBeenCalledWith( - ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, - { plan_key: PLAN_PRO }, - ); - }); - }); - - describe("cancelSeat", () => { - it("cancels and re-fetches seat", async () => { - const proSeat = makeSeat({ plan_key: PLAN_PRO }); - const cancelingSeat = makeSeat({ - plan_key: PLAN_PRO, - status: "canceling", - }); - useSeatStore.setState({ seat: proSeat }); - const client = mockClient({ - getMySeat: vi.fn().mockResolvedValue(cancelingSeat), - }); - - await useSeatStore.getState().cancelSeat(); - - expect(client.cancelSeat).toHaveBeenCalled(); - expect(useSeatStore.getState().seat).toEqual(cancelingSeat); - expect(mockInvalidatePlanCache).toHaveBeenCalled(); - expect(mockTrack).toHaveBeenCalledWith( - ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED, - { plan_key: PLAN_PRO }, - ); - }); - - it("falls back to API response plan_key when store seat is null", async () => { - const cancelingSeat = makeSeat({ - plan_key: PLAN_PRO, - status: "canceling", - }); - const client = mockClient({ - getMySeat: vi.fn().mockResolvedValue(cancelingSeat), - }); - - await useSeatStore.getState().cancelSeat(); - - expect(client.cancelSeat).toHaveBeenCalled(); - expect(mockTrack).toHaveBeenCalledWith( - ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED, - { plan_key: PLAN_PRO }, - ); - }); - - it("skips tracking when no plan_key is available", async () => { - const client = mockClient({ - getMySeat: vi.fn().mockResolvedValue(null), - }); - - await useSeatStore.getState().cancelSeat(); - - expect(client.cancelSeat).toHaveBeenCalled(); - expect(mockTrack).not.toHaveBeenCalledWith( - ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED, - expect.anything(), - ); - }); - }); - - describe("reactivateSeat", () => { - it("reactivates seat", async () => { - const seat = makeSeat({ status: "active" }); - mockClient({ - reactivateSeat: vi.fn().mockResolvedValue(seat), - }); - - await useSeatStore.getState().reactivateSeat(); - - expect(useSeatStore.getState().seat).toEqual(seat); - expect(mockInvalidatePlanCache).toHaveBeenCalled(); - }); - }); - - describe("error handling", () => { - it("sets redirect URL on subscription required error", async () => { - const { SeatSubscriptionRequiredError } = await import( - "@renderer/api/posthogClient" - ); - mockClient({ - getMySeat: vi - .fn() - .mockRejectedValue( - new SeatSubscriptionRequiredError("/organization/billing"), - ), - }); - - await useSeatStore.getState().fetchSeat(); - - const state = useSeatStore.getState(); - expect(state.error).toBe("Billing subscription required"); - expect(state.redirectUrl).toBe("/organization/billing"); - }); - - it("sets error on payment failure", async () => { - const { SeatPaymentFailedError } = await import( - "@renderer/api/posthogClient" - ); - mockClient({ - getMySeat: vi - .fn() - .mockRejectedValue(new SeatPaymentFailedError("Card declined")), - }); - - await useSeatStore.getState().fetchSeat(); - - expect(useSeatStore.getState().error).toBe("Card declined"); - }); - - it("does not invalidate plan cache on failure", async () => { - mockClient({ - getMySeat: vi.fn().mockRejectedValue(new Error("Network error")), - }); - - await useSeatStore.getState().upgradeToPro(); - - expect(mockInvalidatePlanCache).not.toHaveBeenCalled(); - }); - }); - - describe("reset", () => { - it("clears all state", () => { - useSeatStore.setState({ - seat: makeSeat(), - isLoading: true, - error: "some error", - redirectUrl: "https://example.com", - }); - - useSeatStore.getState().reset(); - - const state = useSeatStore.getState(); - expect(state.seat).toBeNull(); - expect(state.isLoading).toBe(false); - expect(state.error).toBeNull(); - expect(state.redirectUrl).toBeNull(); - }); - }); -}); diff --git a/apps/code/src/renderer/features/billing/stores/seatStore.ts b/apps/code/src/renderer/features/billing/stores/seatStore.ts deleted file mode 100644 index e61399f833..0000000000 --- a/apps/code/src/renderer/features/billing/stores/seatStore.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { - SeatPaymentFailedError, - SeatSubscriptionRequiredError, -} from "@renderer/api/posthogClient"; -import { trpcClient } from "@renderer/trpc"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import type { SeatData } from "@shared/types/seat"; -import { PLAN_FREE, PLAN_PRO } from "@shared/types/seat"; -import { track } from "@utils/analytics"; -import { logger } from "@utils/logger"; -import { queryClient } from "@utils/queryClient"; -import { create } from "zustand"; - -const log = logger.scope("seat-store"); - -interface SeatStoreState { - seat: SeatData | null; - orgSeat: SeatData | null; - isLoading: boolean; - error: string | null; - redirectUrl: string | null; - billingOrgId: string | null; -} - -interface SeatStoreActions { - fetchSeat: (options?: { autoProvision?: boolean }) => Promise; - provisionFreeSeat: () => Promise; - upgradeToPro: () => Promise; - cancelSeat: () => Promise; - reactivateSeat: () => Promise; - clearError: () => void; - reset: () => void; -} - -type SeatStore = SeatStoreState & SeatStoreActions; - -async function getClient() { - const client = await getAuthenticatedClient(); - if (!client) { - throw new Error("Not authenticated"); - } - return client; -} - -async function fetchAndProvision( - client: Awaited>, - options: { best: boolean; autoProvision: boolean }, -): Promise { - let seat = await client.getMySeat({ best: options.best }); - if (!seat && options.autoProvision) { - log.info("No seat found, auto-provisioning free plan", { - best: options.best, - }); - try { - seat = await client.createSeat(PLAN_FREE); - } catch { - log.info("Auto-provision failed, re-fetching seat"); - seat = await client.getMySeat({ best: options.best }); - } - } - return seat; -} - -function handleSeatError( - error: unknown, - set: (state: Partial) => void, -): void { - if (!(error instanceof Error)) { - log.error("Seat operation failed", error); - set({ isLoading: false, error: "An unexpected error occurred" }); - return; - } - - if (error instanceof SeatSubscriptionRequiredError) { - set({ - isLoading: false, - error: "Billing subscription required", - redirectUrl: error.redirectUrl, - }); - return; - } - - if (error instanceof SeatPaymentFailedError) { - set({ isLoading: false, error: error.message }); - return; - } - - log.error("Seat operation failed", error); - set({ isLoading: false, error: error.message }); -} - -function invalidatePlanCache(): void { - trpcClient.llmGateway.invalidatePlanCache.mutate().catch((err) => { - log.warn("Failed to invalidate plan cache", err); - }); - void queryClient.invalidateQueries({ queryKey: [["llmGateway"]] }); -} - -const initialState: SeatStoreState = { - seat: null, - orgSeat: null, - isLoading: false, - error: null, - redirectUrl: null, - billingOrgId: null, -}; - -export const useSeatStore = create()((set, get) => ({ - ...initialState, - - fetchSeat: async (options?: { autoProvision?: boolean }) => { - set({ isLoading: true, error: null, redirectUrl: null }); - try { - const client = await getClient(); - const autoProvision = options?.autoProvision ?? false; - const [seat, orgSeat] = await Promise.all([ - fetchAndProvision(client, { best: true, autoProvision }), - fetchAndProvision(client, { best: false, autoProvision }), - ]); - set({ - seat, - orgSeat, - isLoading: false, - billingOrgId: seat?.organization_id ?? null, - }); - } catch (error) { - const { seat: existingSeat } = get(); - if (existingSeat) { - log.warn("fetchSeat failed but seat already loaded, keeping it", error); - set({ isLoading: false }); - return; - } - handleSeatError(error, set); - } - }, - - provisionFreeSeat: async () => { - log.info("Provisioning free seat"); - set({ isLoading: true, error: null, redirectUrl: null }); - try { - const client = await getClient(); - const existing = await client.getMySeat(); - if (existing) { - log.info("Seat already exists on server", { - plan: existing.plan_key, - status: existing.status, - }); - set({ - seat: existing, - isLoading: false, - billingOrgId: existing.organization_id ?? null, - }); - return; - } - const seat = await client.createSeat(PLAN_FREE); - log.info("Free seat created", { id: seat.id, plan: seat.plan_key }); - set({ - seat, - isLoading: false, - billingOrgId: seat.organization_id ?? null, - }); - invalidatePlanCache(); - } catch (error) { - log.error("provisionFreeSeat failed", error); - handleSeatError(error, set); - } - }, - - upgradeToPro: async () => { - set({ isLoading: true, error: null, redirectUrl: null }); - try { - const client = await getClient(); - const existing = await client.getMySeat(); - if (existing) { - if (existing.plan_key === PLAN_PRO) { - set({ - seat: existing, - isLoading: false, - billingOrgId: existing.organization_id ?? null, - }); - return; - } - const seat = await client.upgradeSeat(PLAN_PRO); - set({ - seat, - orgSeat: seat, - isLoading: false, - billingOrgId: seat.organization_id ?? null, - }); - track(ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, { - plan_key: seat.plan_key, - previous_plan_key: existing.plan_key, - }); - invalidatePlanCache(); - return; - } - const seat = await client.createSeat(PLAN_PRO); - set({ - seat, - orgSeat: seat, - isLoading: false, - billingOrgId: seat.organization_id ?? null, - }); - track(ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, { - plan_key: seat.plan_key, - }); - invalidatePlanCache(); - } catch (error) { - handleSeatError(error, set); - } - }, - - cancelSeat: async () => { - set({ isLoading: true, error: null, redirectUrl: null }); - try { - const client = await getClient(); - const previousPlanKey = get().seat?.plan_key; - await client.cancelSeat(); - const seat = await client.getMySeat(); - set({ - seat, - orgSeat: seat, - isLoading: false, - billingOrgId: seat?.organization_id ?? null, - }); - const cancelledPlanKey = previousPlanKey ?? seat?.plan_key; - if (cancelledPlanKey) { - track(ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED, { - plan_key: cancelledPlanKey, - }); - } - invalidatePlanCache(); - } catch (error) { - handleSeatError(error, set); - } - }, - - reactivateSeat: async () => { - set({ isLoading: true, error: null, redirectUrl: null }); - try { - const client = await getClient(); - const seat = await client.reactivateSeat(); - set({ - seat, - orgSeat: seat, - isLoading: false, - billingOrgId: seat.organization_id ?? null, - }); - invalidatePlanCache(); - } catch (error) { - handleSeatError(error, set); - } - }, - - clearError: () => set({ error: null, redirectUrl: null }), - - reset: () => set(initialState), -})); diff --git a/apps/code/src/renderer/features/code-editor/hooks/useFileEnrichment.ts b/apps/code/src/renderer/features/code-editor/hooks/useFileEnrichment.ts deleted file mode 100644 index 847aa73205..0000000000 --- a/apps/code/src/renderer/features/code-editor/hooks/useFileEnrichment.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import type { SerializedEnrichment } from "@posthog/enricher"; -import { useTRPC } from "@renderer/trpc/client"; -import { useQuery } from "@tanstack/react-query"; - -const SUPPORTED_EXT = /\.(?:ts|tsx|js|jsx|mjs|cjs|py|rb|go)$/i; - -interface UseFileEnrichmentOptions { - taskId: string; - filePath: string; - absolutePath?: string; - content: string | null | undefined; -} - -export function useFileEnrichment({ - taskId, - filePath, - absolutePath, - content, -}: UseFileEnrichmentOptions): SerializedEnrichment | null { - const trpc = useTRPC(); - const isAuthenticated = useAuthStateValue( - (s) => s.status === "authenticated", - ); - - // Wrapper helpers like `track(...)` don't mention `posthog` literally, so we - // only require the extension + supported size. The enrichment pipeline on - // the server bails out if there's no direct usage AND no resolvable wrapper. - const hasContent = - typeof content === "string" && - content.length > 0 && - content.length <= 1_000_000; - const extSupported = SUPPORTED_EXT.test(filePath); - - const query = useQuery( - trpc.enrichment.enrichFile.queryOptions( - { taskId, filePath, absolutePath, content: content ?? "" }, - { - enabled: hasContent && extSupported && isAuthenticated, - staleTime: Infinity, - }, - ), - ); - - return query.data ?? null; -} diff --git a/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx b/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx deleted file mode 100644 index 6e8b37e871..0000000000 --- a/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx +++ /dev/null @@ -1,593 +0,0 @@ -import { FileIcon } from "@components/ui/FileIcon"; -import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; -import { computeDiffStats } from "@features/git-interaction/utils/diffStats"; -import { ChangesPanel } from "@features/task-detail/components/ChangesPanel"; -import { ArrowSquareOut, CaretDown } from "@phosphor-icons/react"; -import type { FileDiffMetadata } from "@pierre/diffs/react"; -import { WorkerPoolContextProvider } from "@pierre/diffs/react"; -import WorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"; -import { Flex, Spinner, Text } from "@radix-ui/themes"; -import { useReviewDraftsStore } from "@renderer/features/code-review/stores/reviewDraftsStore"; -import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; -import type { ChangedFile, Task } from "@shared/types"; -import { useThemeStore } from "@stores/themeStore"; -import { - type ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { VList, type VListHandle } from "virtua"; -import { - REVIEW_LIST_BUFFER_PX, - REVIEW_LIST_ESTIMATED_ITEM_SIZE, -} from "../constants"; -import type { ResolvedDiffSource } from "../utils/resolveDiffSource"; -import { PendingReviewBar } from "./PendingReviewBar"; -import { ReviewToolbar } from "./ReviewToolbar"; - -export function splitFilePath(fullPath: string): { - dirPath: string; - fileName: string; -} { - const lastSlash = fullPath.lastIndexOf("/"); - return { - dirPath: lastSlash >= 0 ? fullPath.slice(0, lastSlash + 1) : "", - fileName: lastSlash >= 0 ? fullPath.slice(lastSlash + 1) : fullPath, - }; -} - -export function sumHunkStats(hunks: FileDiffMetadata["hunks"]): { - additions: number; - deletions: number; -} { - let additions = 0; - let deletions = 0; - for (const hunk of hunks) { - additions += hunk.additionLines; - deletions += hunk.deletionLines; - } - return { additions, deletions }; -} - -export function buildItemIndex( - items: { scrollKey?: string }[], -): Map { - const index = new Map(); - for (let i = 0; i < items.length; i++) { - const key = items[i].scrollKey; - if (key) index.set(key, i); - } - return index; -} - -function workerFactory(): Worker { - return new Worker(WorkerUrl, { type: "module" }); -} - -const STICKY_HEADER_CSS = `[data-diffs-header] { position: sticky; top: 0; z-index: 1; background: var(--gray-2); }`; - -export type DeferredReason = "line-limit" | "unavailable"; - -function useDiffOptions() { - const viewMode = useDiffViewerStore((s) => s.viewMode); - const wordWrap = useDiffViewerStore((s) => s.wordWrap); - const loadFullFiles = useDiffViewerStore((s) => s.loadFullFiles); - const wordDiffs = useDiffViewerStore((s) => s.wordDiffs); - const isDarkMode = useThemeStore((s) => s.isDarkMode); - - return useMemo( - () => ({ - diffStyle: viewMode as "split" | "unified", - overflow: (wordWrap ? "wrap" : "scroll") as "wrap" | "scroll", - expandUnchanged: loadFullFiles, - lineDiffType: (wordDiffs ? "word-alt" : "none") as "word-alt" | "none", - themeType: (isDarkMode ? "dark" : "light") as "dark" | "light", - theme: { dark: "github-dark" as const, light: "github-light" as const }, - unsafeCSS: STICKY_HEADER_CSS, - }), - [viewMode, wordWrap, loadFullFiles, wordDiffs, isDarkMode], - ); -} - -export function useReviewState( - changedFiles: ChangedFile[], - allPaths: string[], -) { - const diffOptions = useDiffOptions(); - - const { linesAdded, linesRemoved } = useMemo( - () => computeDiffStats(changedFiles), - [changedFiles], - ); - - const collapseState = useCollapseState(allPaths); - - return { diffOptions, linesAdded, linesRemoved, ...collapseState }; -} - -function useCollapseState(filePaths: string[]) { - const [collapsedFiles, setCollapsedFiles] = useState>( - () => new Set(), - ); - - const toggleFile = useCallback((filePath: string) => { - setCollapsedFiles((prev) => { - const next = new Set(prev); - if (next.has(filePath)) next.delete(filePath); - else next.add(filePath); - return next; - }); - }, []); - - const uncollapseFile = useCallback((filePath: string) => { - setCollapsedFiles((prev) => { - if (!prev.has(filePath)) return prev; - const next = new Set(prev); - next.delete(filePath); - return next; - }); - }, []); - - const expandAll = useCallback(() => setCollapsedFiles(new Set()), []); - - const collapseAll = useCallback( - () => setCollapsedFiles(new Set(filePaths)), - [filePaths], - ); - - return { - collapsedFiles, - toggleFile, - uncollapseFile, - expandAll, - collapseAll, - }; -} - -const SIDEBAR_MIN_WIDTH = 200; -const SIDEBAR_MAX_WIDTH = 500; -const SIDEBAR_DEFAULT_WIDTH = 280; - -function ExpandedSidebar({ task }: { task: Task }) { - const taskId = task.id; - const [width, setWidth] = useState(SIDEBAR_DEFAULT_WIDTH); - const isDragging = useRef(false); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - isDragging.current = true; - const startX = e.clientX; - const startWidth = width; - - const handleMouseMove = (e: MouseEvent) => { - if (!isDragging.current) return; - const delta = startX - e.clientX; - const newWidth = Math.min( - SIDEBAR_MAX_WIDTH, - Math.max(SIDEBAR_MIN_WIDTH, startWidth + delta), - ); - setWidth(newWidth); - }; - - const handleMouseUp = () => { - isDragging.current = false; - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - }, - [width], - ); - - return ( - - - ); -} - -export function DiffFileHeader({ - fileDiff, - collapsed, - onToggle, - onOpenFile, -}: { - fileDiff: FileDiffMetadata; - collapsed: boolean; - onToggle: () => void; - onOpenFile?: () => void; -}) { - const fullPath = - fileDiff.prevName && fileDiff.prevName !== fileDiff.name - ? `${fileDiff.prevName} \u2192 ${fileDiff.name}` - : fileDiff.name; - const { dirPath, fileName } = splitFilePath(fullPath ?? ""); - const { additions, deletions } = sumHunkStats(fileDiff.hunks); - - return ( - { - e.stopPropagation(); - onOpenFile(); - }} - className="ml-auto inline-flex cursor-pointer rounded-[3px] border-0 bg-transparent p-[2px] text-(--gray-9) hover:bg-gray-4" - > - - - ) - } - /> - ); -} - -function getDeferredMessage(reason: DeferredReason): string { - switch (reason) { - case "line-limit": - return "File exceeds the 5,000-line review limit."; - case "unavailable": - return "Unable to load diff."; - } -} - -export function DeferredDiffPlaceholder({ - filePath, - linesAdded, - linesRemoved, - reason, - collapsed, - onToggle, - onShow, - externalUrl, -}: { - filePath: string; - linesAdded: number; - linesRemoved: number; - reason: DeferredReason; - collapsed: boolean; - onToggle: () => void; - onShow?: () => void; - externalUrl?: string; -}) { - const { dirPath, fileName } = splitFilePath(filePath); - - return ( - - ); -} diff --git a/apps/code/src/renderer/features/code-review/hooks/useTaskDiffSummaryStats.ts b/apps/code/src/renderer/features/code-review/hooks/useTaskDiffSummaryStats.ts deleted file mode 100644 index 68f880e324..0000000000 --- a/apps/code/src/renderer/features/code-review/hooks/useTaskDiffSummaryStats.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - useLocalBranchChangedFiles, - usePrChangedFiles, -} from "@features/git-interaction/hooks/useGitQueries"; -import { - computeDiffStats, - type DiffStats, -} from "@features/git-interaction/utils/diffStats"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { useCloudChangedFiles } from "@features/task-detail/hooks/useCloudChangedFiles"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; -import type { Task } from "@shared/types"; -import { useMemo } from "react"; -import { useEffectiveDiffSource } from "./useEffectiveDiffSource"; - -const EMPTY_DIFF_STATS: DiffStats = { - filesChanged: 0, - linesAdded: 0, - linesRemoved: 0, -}; - -export function useTaskDiffSummaryStats(task: Task): DiffStats { - const taskId = task.id; - const workspace = useWorkspace(taskId); - const isCloud = - workspace?.mode === "cloud" || task.latest_run?.environment === "cloud"; - - const { reviewFiles } = useCloudChangedFiles(taskId, task, isCloud); - - const repoPath = useCwd(taskId); - const { - effectiveSource, - linkedBranch, - prUrl, - diffStats: localDiffStats, - } = useEffectiveDiffSource(taskId); - - const { data: branchFiles } = useLocalBranchChangedFiles( - !isCloud && effectiveSource === "branch" ? (repoPath ?? null) : null, - !isCloud && effectiveSource === "branch" ? linkedBranch : null, - ); - const { data: prFiles } = usePrChangedFiles( - !isCloud && effectiveSource === "pr" ? prUrl : null, - ); - - return useMemo(() => { - if (isCloud) return computeDiffStats(reviewFiles); - if (effectiveSource === "branch") { - return branchFiles ? computeDiffStats(branchFiles) : EMPTY_DIFF_STATS; - } - if (effectiveSource === "pr") { - return prFiles ? computeDiffStats(prFiles) : EMPTY_DIFF_STATS; - } - return localDiffStats; - }, [ - isCloud, - reviewFiles, - effectiveSource, - branchFiles, - prFiles, - localDiffStats, - ]); -} diff --git a/apps/code/src/renderer/features/code-review/reviewHost.tsx b/apps/code/src/renderer/features/code-review/reviewHost.tsx new file mode 100644 index 0000000000..01b0e49ad2 --- /dev/null +++ b/apps/code/src/renderer/features/code-review/reviewHost.tsx @@ -0,0 +1,13 @@ +import WorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"; +import type { ReviewHost } from "@posthog/ui/features/code-review/reviewHost"; +import { ChangesPanel } from "@posthog/ui/features/task-detail/components/ChangesPanel"; + +export const diffWorkerFactory = () => + new Worker(WorkerUrl, { type: "module" }); + +export const reviewHost: ReviewHost = { + diffWorkerFactory, + renderExpandedSidebar: (task) => ( + + ), +}; diff --git a/apps/code/src/renderer/features/command-center/hooks/useAvailableTasks.ts b/apps/code/src/renderer/features/command-center/hooks/useAvailableTasks.ts deleted file mode 100644 index a08339b9b9..0000000000 --- a/apps/code/src/renderer/features/command-center/hooks/useAvailableTasks.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; -import { useTasks } from "@features/tasks/hooks/useTasks"; -import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; -import type { Task } from "@shared/types"; -import { useMemo } from "react"; -import { useCommandCenterStore } from "../stores/commandCenterStore"; - -export function useAvailableTasks(): Task[] { - const { data: tasks = [] } = useTasks(); - const cells = useCommandCenterStore((s) => s.cells); - const archivedTaskIds = useArchivedTaskIds(); - const { data: workspaces } = useWorkspaces(); - - return useMemo(() => { - const assignedIds = new Set(cells.filter(Boolean)); - return tasks.filter( - (task) => - !assignedIds.has(task.id) && - !archivedTaskIds.has(task.id) && - workspaces?.[task.id], - ); - }, [tasks, cells, archivedTaskIds, workspaces]); -} diff --git a/apps/code/src/renderer/features/command-center/hooks/useCommandCenterData.ts b/apps/code/src/renderer/features/command-center/hooks/useCommandCenterData.ts deleted file mode 100644 index 85560c306a..0000000000 --- a/apps/code/src/renderer/features/command-center/hooks/useCommandCenterData.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { useSessions } from "@features/sessions/hooks/useSession"; -import type { AgentSession } from "@features/sessions/stores/sessionStore"; -import { useTasks } from "@features/tasks/hooks/useTasks"; -import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; -import type { WorkspaceMode } from "@main/services/workspace/schemas"; -import type { Task } from "@shared/types"; -import { getTaskRepository, parseRepository } from "@utils/repository"; -import { useMemo } from "react"; -import { useCommandCenterStore } from "../stores/commandCenterStore"; - -export type CellStatus = "running" | "waiting" | "idle" | "error" | "completed"; - -export interface CommandCenterCellData { - cellIndex: number; - taskId: string | null; - task: Task | undefined; - session: AgentSession | undefined; - status: CellStatus; - repoName: string | null; - workspaceMode: WorkspaceMode | null; -} - -export interface StatusSummary { - total: number; - running: number; - waiting: number; - idle: number; - error: number; - completed: number; -} - -export function deriveStatus(session: AgentSession | undefined): CellStatus { - if (!session) return "idle"; - - if (session.status === "error") return "error"; - if (session.cloudStatus === "failed" || session.cloudStatus === "cancelled") - return "error"; - if (session.cloudStatus === "completed") return "completed"; - - if (session.pendingPermissions.size > 0) return "waiting"; - - if (session.status === "connected" && session.isPromptPending) - return "running"; - - return "idle"; -} - -function getRepoName(task: Task): string | null { - const repository = getTaskRepository(task); - if (!repository) return null; - const parsed = parseRepository(repository); - return parsed?.repoName ?? repository; -} - -export function useCommandCenterData(): { - cells: CommandCenterCellData[]; - summary: StatusSummary; -} { - const storeCells = useCommandCenterStore((s) => s.cells); - const { data: tasks = [] } = useTasks(); - const sessions = useSessions(); - const { data: workspaces } = useWorkspaces(); - - const taskMap = useMemo(() => { - const map = new Map(); - for (const task of tasks) { - map.set(task.id, task); - } - return map; - }, [tasks]); - - const sessionByTaskId = useMemo(() => { - const map = new Map(); - for (const session of Object.values(sessions)) { - if (session.taskId) { - map.set(session.taskId, session); - } - } - return map; - }, [sessions]); - - const cells = useMemo(() => { - return storeCells.map((taskId, cellIndex) => { - const task = taskId ? taskMap.get(taskId) : undefined; - const session = taskId ? sessionByTaskId.get(taskId) : undefined; - const status = taskId ? deriveStatus(session) : "idle"; - const repoName = task ? getRepoName(task) : null; - const workspaceMode = - (taskId ? workspaces?.[taskId]?.mode : null) ?? null; - - return { - cellIndex, - taskId, - task, - session, - status, - repoName, - workspaceMode, - }; - }); - }, [storeCells, taskMap, sessionByTaskId, workspaces]); - - const summary = useMemo(() => { - const populated = cells.filter((c) => c.taskId && c.task); - return { - total: populated.length, - running: populated.filter((c) => c.status === "running").length, - waiting: populated.filter((c) => c.status === "waiting").length, - idle: populated.filter((c) => c.status === "idle").length, - error: populated.filter((c) => c.status === "error").length, - completed: populated.filter((c) => c.status === "completed").length, - }; - }, [cells]); - - return { cells, summary }; -} diff --git a/apps/code/src/renderer/features/connectivity/connectivityToast.ts b/apps/code/src/renderer/features/connectivity/connectivityToast.ts deleted file mode 100644 index e5d5ba781d..0000000000 --- a/apps/code/src/renderer/features/connectivity/connectivityToast.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useConnectivityStore } from "@stores/connectivityStore"; -import { toast } from "@utils/toast"; -import { toast as sonnerToast } from "sonner"; - -const TOAST_ID = "connectivity-offline"; -const OFFLINE_DEBOUNCE_MS = 5_000; - -export function showOfflineToast() { - toast.error("No internet connection", { - id: TOAST_ID, - duration: Number.POSITIVE_INFINITY, - description: - "PostHog Code features that need the network are paused until you reconnect.", - }); -} - -// Debounces flaky transitions: only surfaces a toast when the app has been -// continuously offline for OFFLINE_DEBOUNCE_MS. The stable id guarantees the -// toast never stacks; coming back online dismisses it automatically. -export function initializeConnectivityToast() { - let pendingTimer: ReturnType | null = null; - - const clearPending = () => { - if (pendingTimer) { - clearTimeout(pendingTimer); - pendingTimer = null; - } - }; - - const unsubscribe = useConnectivityStore.subscribe( - (state) => state.isOnline, - (isOnline, wasOnline) => { - if (isOnline === wasOnline) return; - - if (!isOnline) { - clearPending(); - pendingTimer = setTimeout(() => { - pendingTimer = null; - showOfflineToast(); - }, OFFLINE_DEBOUNCE_MS); - } else { - clearPending(); - sonnerToast.dismiss(TOAST_ID); - } - }, - ); - - return () => { - clearPending(); - unsubscribe(); - }; -} diff --git a/apps/code/src/renderer/features/external-apps/hooks/useExternalApps.ts b/apps/code/src/renderer/features/external-apps/hooks/useExternalApps.ts deleted file mode 100644 index 8e0a86cc85..0000000000 --- a/apps/code/src/renderer/features/external-apps/hooks/useExternalApps.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { trpcClient, useTRPC } from "@renderer/trpc"; -import type { DetectedApplication } from "@shared/types"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo } from "react"; - -export function useExternalApps() { - const trpcReact = useTRPC(); - const queryClient = useQueryClient(); - - const { data: detectedApps = [], isLoading: appsLoading } = useQuery( - trpcReact.externalApps.getDetectedApps.queryOptions(undefined, { - staleTime: 60_000, - }), - ); - - const { data: lastUsedData, isLoading: lastUsedLoading } = useQuery( - trpcReact.externalApps.getLastUsed.queryOptions(undefined, { - staleTime: 60_000, - }), - ); - - const setLastUsedMutation = useMutation( - trpcReact.externalApps.setLastUsed.mutationOptions({ - onSuccess: (_, { appId }) => { - queryClient.setQueryData( - trpcReact.externalApps.getLastUsed.queryKey(), - { lastUsedApp: appId }, - ); - }, - }), - ); - - const lastUsedAppId = lastUsedData?.lastUsedApp; - const isLoading = appsLoading || lastUsedLoading; - - const defaultApp = useMemo(() => { - if (lastUsedAppId) { - const app = detectedApps.find((a) => a.id === lastUsedAppId); - if (app) return app; - } - return detectedApps[0] || null; - }, [detectedApps, lastUsedAppId]); - - const setLastUsedApp = useCallback( - async (appId: string) => { - await setLastUsedMutation.mutateAsync({ appId }); - }, - [setLastUsedMutation], - ); - - return { - detectedApps, - lastUsedAppId, - defaultApp, - isLoading, - setLastUsedApp, - }; -} - -export const externalAppsApi = { - async getDetectedApps(): Promise { - return trpcClient.externalApps.getDetectedApps.query(); - }, - async getLastUsed(): Promise { - const result = await trpcClient.externalApps.getLastUsed.query(); - return result.lastUsedApp; - }, - async setLastUsed(appId: string): Promise { - await trpcClient.externalApps.setLastUsed.mutate({ appId }); - }, -}; diff --git a/apps/code/src/renderer/features/folders/hooks/useFolders.ts b/apps/code/src/renderer/features/folders/hooks/useFolders.ts deleted file mode 100644 index 7a6d56a72b..0000000000 --- a/apps/code/src/renderer/features/folders/hooks/useFolders.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { RegisteredFolder } from "@main/services/folders/schemas"; -import { trpc, trpcClient, useTRPC } from "@renderer/trpc"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { queryClient } from "@utils/queryClient"; -import { useCallback, useMemo } from "react"; - -export function useFolders() { - const trpcReact = useTRPC(); - const queryClient = useQueryClient(); - - const { data: folders = [], isLoading } = useQuery( - trpcReact.folders.getFolders.queryOptions(undefined, { - staleTime: 30_000, - }), - ); - - const existingFolders = useMemo( - () => folders.filter((f) => f.exists !== false), - [folders], - ); - - const addFolderMutation = useMutation( - trpcReact.folders.addFolder.mutationOptions({ - onSuccess: () => { - void queryClient.invalidateQueries( - trpcReact.folders.getFolders.pathFilter(), - ); - }, - }), - ); - - const removeFolderMutation = useMutation( - trpcReact.folders.removeFolder.mutationOptions({ - onSuccess: () => { - void queryClient.invalidateQueries( - trpcReact.folders.getFolders.pathFilter(), - ); - }, - }), - ); - - const updateAccessedMutation = useMutation( - trpcReact.folders.updateFolderAccessed.mutationOptions(), - ); - - const addFolder = useCallback( - async (folderPath: string) => { - return addFolderMutation.mutateAsync({ folderPath }); - }, - [addFolderMutation], - ); - - const removeFolder = useCallback( - async (folderId: string) => { - return removeFolderMutation.mutateAsync({ folderId }); - }, - [removeFolderMutation], - ); - - const updateLastAccessed = useCallback( - (folderId: string) => { - updateAccessedMutation.mutate({ folderId }); - }, - [updateAccessedMutation], - ); - - const getFolderByPath = useCallback( - (path: string) => existingFolders.find((f) => f.path === path), - [existingFolders], - ); - - const getRecentFolders = useCallback( - (limit = 5) => - [...existingFolders] - .sort( - (a, b) => - new Date(b.lastAccessed).getTime() - - new Date(a.lastAccessed).getTime(), - ) - .slice(0, limit), - [existingFolders], - ); - - const getFolderDisplayName = useCallback( - (path: string) => { - if (!path) return null; - const folder = existingFolders.find((f) => f.path === path); - return folder?.name ?? path.split("/").pop() ?? null; - }, - [existingFolders], - ); - - const loadFolders = useCallback(() => { - void queryClient.invalidateQueries( - trpcReact.folders.getFolders.pathFilter(), - ); - }, [queryClient, trpcReact]); - - return { - folders: existingFolders, - isLoaded: !isLoading, - addFolder, - removeFolder, - updateLastAccessed, - getFolderByPath, - getRecentFolders, - getFolderDisplayName, - loadFolders, - }; -} - -const invalidateFolders = () => { - void queryClient.invalidateQueries(trpc.folders.getFolders.pathFilter()); -}; - -export const foldersApi = { - async getFolders() { - return trpcClient.folders.getFolders.query(); - }, - async addFolder(folderPath: string) { - const newFolder = await trpcClient.folders.addFolder.mutate({ - folderPath, - }); - invalidateFolders(); - return newFolder; - }, - async removeFolder(folderId: string) { - const result = await trpcClient.folders.removeFolder.mutate({ folderId }); - invalidateFolders(); - return result; - }, - async updateFolderAccessed(folderId: string) { - return trpcClient.folders.updateFolderAccessed.mutate({ folderId }); - }, - getFolderByPath(folders: RegisteredFolder[], path: string) { - return folders.find((f) => f.path === path); - }, - getFolderDisplayName(folders: RegisteredFolder[], path: string) { - if (!path) return null; - const folder = folders.find((f) => f.path === path); - return folder?.name ?? path.split("/").pop() ?? null; - }, -}; diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts b/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts deleted file mode 100644 index c8a38f4161..0000000000 --- a/apps/code/src/renderer/features/git-interaction/hooks/useGitInteraction.ts +++ /dev/null @@ -1,663 +0,0 @@ -import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; -import { computeGitInteractionState } from "@features/git-interaction/state/gitInteractionLogic"; -import { - type GitInteractionStore, - useGitInteractionStore, -} from "@features/git-interaction/state/gitInteractionStore"; -import type { - CommitNextStep, - GitMenuAction, - GitMenuActionId, - PushMode, -} from "@features/git-interaction/types"; -import { - createBranch, - getBranchNameInputState, -} from "@features/git-interaction/utils/branchCreation"; -import { sanitizeBranchName } from "@features/git-interaction/utils/branchNameValidation"; -import type { DiffStats } from "@features/git-interaction/utils/diffStats"; -import { getSuggestedBranchName } from "@features/git-interaction/utils/getSuggestedBranchName"; -import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; -import { partitionByStaged } from "@features/git-interaction/utils/partitionByStaged"; -import { updateGitCacheFromSnapshot } from "@features/git-interaction/utils/updateGitCache"; -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { useSessionStore } from "@features/sessions/stores/sessionStore"; -import { trpc, trpcClient } from "@renderer/trpc"; -import type { ChangedFile } from "@shared/types"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useQueryClient } from "@tanstack/react-query"; -import { track } from "@utils/analytics"; -import { celebrate } from "@utils/confetti"; -import { logger } from "@utils/logger"; -import { useMemo, useRef } from "react"; - -const log = logger.scope("git-interaction"); - -export type { GitMenuAction, GitMenuActionId }; - -function getConversationContext(taskId: string): string | undefined { - const state = useSessionStore.getState(); - const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) return undefined; - return state.sessions[taskRunId]?.conversationSummary; -} - -interface GitInteractionState { - primaryAction: GitMenuAction; - actions: GitMenuAction[]; - hasChanges: boolean; - aheadOfRemote: number; - behind: number; - currentBranch: string | null; - defaultBranch: string | null; - isFeatureBranch: boolean; - prBaseBranch: string | null; - prHeadBranch: string | null; - diffStats: DiffStats; - prUrl: string | null; - pushDisabledReason: string | null; - isLoading: boolean; - stagedFiles: ChangedFile[]; - unstagedFiles: ChangedFile[]; -} - -interface GitInteractionActions { - openAction: (actionId: GitMenuActionId) => void; - closeCommit: () => void; - closePush: () => void; - closeBranch: () => void; - setCommitMessage: (value: string) => void; - setCommitNextStep: (value: CommitNextStep) => void; - setCommitAll: (value: boolean) => void; - setPrTitle: (value: string) => void; - setPrBody: (value: string) => void; - setBranchName: (value: string) => void; - runCommit: () => Promise; - runPush: (mode?: PushMode) => Promise; - runBranch: () => Promise; - runCreatePr: () => Promise; - generateCommitMessage: () => Promise; - generatePrTitleAndBody: () => Promise; - closeCreatePr: () => void; - setCreatePrBranchName: (value: string) => void; - setCreatePrDraft: (value: boolean) => void; -} - -function buildStagingContext( - stagedFiles: ChangedFile[], - unstagedFiles: ChangedFile[], - commitAll: boolean, -) { - const stagedOnly = - stagedFiles.length > 0 && unstagedFiles.length > 0 && !commitAll; - return { - stagedOnly, - analytics: { - staged_file_count: stagedFiles.length, - unstaged_file_count: unstagedFiles.length, - commit_all: commitAll, - staged_only: stagedOnly, - }, - }; -} - -function trackGitAction( - taskId: string, - actionType: string, - success: boolean, - stagingContext?: { - staged_file_count: number; - unstaged_file_count: number; - commit_all: boolean; - staged_only: boolean; - }, -) { - track(ANALYTICS_EVENTS.GIT_ACTION_EXECUTED, { - action_type: actionType as - | "commit" - | "push" - | "sync" - | "publish" - | "create-pr" - | "view-pr" - | "update-pr", - success, - task_id: taskId, - ...stagingContext, - }); -} - -function attachPrUrlToTask(taskId: string, prUrl: string) { - const taskRunId = useSessionStore.getState().taskIdIndex[taskId]; - if (!taskRunId) return; - - getAuthenticatedClient() - .then((client) => - client?.updateTaskRun(taskId, taskRunId, { - output: { pr_url: prUrl }, - }), - ) - .catch((err) => - log.warn("Failed to attach PR URL to task", { taskId, prUrl, err }), - ); -} - -export function useGitInteraction( - taskId: string, - repoPath?: string, -): { - state: GitInteractionState; - modals: GitInteractionStore; - actions: GitInteractionActions; -} { - const queryClient = useQueryClient(); - const store = useGitInteractionStore(); - const { actions: modal } = store; - const pushAbortRef = useRef(null); - - const git = useGitQueries(repoPath); - - const computed = useMemo( - () => - computeGitInteractionState({ - repoPath, - isRepo: git.isRepo, - isRepoLoading: git.isRepoLoading, - hasChanges: git.hasChanges, - aheadOfRemote: git.aheadOfRemote, - behind: git.behind, - aheadOfDefault: git.aheadOfDefault, - hasRemote: git.hasRemote, - isFeatureBranch: git.isFeatureBranch, - currentBranch: git.currentBranch, - defaultBranch: git.defaultBranch, - ghStatus: git.ghStatus ?? null, - repoInfo: git.repoInfo ?? null, - prStatus: git.prStatus ?? null, - }), - [ - repoPath, - git.isRepo, - git.isRepoLoading, - git.hasChanges, - git.aheadOfRemote, - git.behind, - git.aheadOfDefault, - git.hasRemote, - git.isFeatureBranch, - git.currentBranch, - git.defaultBranch, - git.ghStatus, - git.repoInfo, - git.prStatus, - ], - ); - - const { stagedFiles, unstagedFiles } = useMemo( - () => partitionByStaged(git.changedFiles), - [git.changedFiles], - ); - - const createPrDraftKey = `${taskId}:${repoPath ?? ""}`; - - const openCreatePr = () => { - const prExists = git.prStatus?.prExists ?? false; - const needsBranch = !git.isFeatureBranch || prExists; - const needsCommit = git.hasChanges; - modal.setCommitAll(!(stagedFiles.length > 0 && unstagedFiles.length > 0)); - modal.openCreatePr({ - needsBranch, - needsCommit, - baseBranch: git.currentBranch, - suggestedBranchName: needsBranch - ? getSuggestedBranchName(taskId, repoPath) - : undefined, - draftKey: createPrDraftKey, - }); - }; - - const runCreatePr = async () => { - if (!repoPath) return; - - if (store.createPrNeedsBranch && !store.branchName.trim()) { - modal.setCreatePrError("Branch name is required."); - return; - } - - modal.setIsSubmitting(true); - modal.setCreatePrError(null); - modal.setCreatePrStep("idle"); - modal.setCreatePrFailedStep(null); - - const flowId = crypto.randomUUID(); - - const subscription = trpcClient.git.onCreatePrProgress.subscribe( - undefined, - { - onData: (data) => { - if (data.flowId !== flowId) return; - if (useGitInteractionStore.getState().createPrStep === data.step) - return; - modal.setCreatePrStep(data.step); - }, - }, - ); - - try { - const { stagedOnly, analytics: prStagingContext } = buildStagingContext( - stagedFiles, - unstagedFiles, - store.commitAll, - ); - - const result = await trpcClient.git.createPr.mutate({ - directoryPath: repoPath, - flowId, - branchName: store.createPrNeedsBranch - ? store.branchName.trim() - : undefined, - commitMessage: store.commitMessage.trim() || undefined, - prTitle: store.prTitle.trim() || undefined, - prBody: store.prBody.trim() || undefined, - draft: store.createPrDraft || undefined, - stagedOnly: stagedOnly || undefined, - taskId, - conversationContext: getConversationContext(taskId), - }); - - if (!result.success) { - trackGitAction(taskId, "create-pr", false, prStagingContext); - useGitInteractionStore.setState({ - createPrError: result.message, - createPrFailedStep: result.failedStep ?? null, - createPrStep: "error", - }); - return; - } - - trackGitAction(taskId, "create-pr", true, prStagingContext); - track(ANALYTICS_EVENTS.PR_CREATED, { task_id: taskId, success: true }); - - const onboarding = useOnboardingStore.getState(); - if (!onboarding.hasShippedFirstPr) { - onboarding.markFirstPrShipped(); - celebrate(); - } - - if (result.state) { - updateGitCacheFromSnapshot(queryClient, repoPath, result.state); - } - if (store.createPrNeedsBranch) { - invalidateGitBranchQueries(repoPath); - } - - if (result.prUrl) { - const linkedBranchName = store.createPrNeedsBranch - ? store.branchName.trim() - : git.currentBranch; - if (linkedBranchName) { - queryClient.setQueryData( - trpc.git.getPrUrlForBranch.queryKey({ - directoryPath: repoPath, - branchName: linkedBranchName, - }), - result.prUrl, - ); - } - await trpcClient.os.openExternal.mutate({ url: result.prUrl }); - attachPrUrlToTask(taskId, result.prUrl); - } - - modal.clearCreatePrDraft(createPrDraftKey); - modal.closeCreatePr(); - } catch (error) { - log.error("Create PR flow failed", error); - useGitInteractionStore.setState({ - createPrFailedStep: useGitInteractionStore.getState().createPrStep, - createPrError: - error instanceof Error ? error.message : "Create PR flow failed.", - createPrStep: "error", - }); - } finally { - subscription.unsubscribe(); - modal.setIsSubmitting(false); - } - }; - - const openAction = (id: GitMenuActionId) => { - const actionMap: Record void> = { - commit: () => { - modal.setCommitAll( - !(stagedFiles.length > 0 && unstagedFiles.length > 0), - ); - modal.openCommit("commit"); - }, - push: () => modal.openPush("push"), - sync: () => modal.openPush("sync"), - publish: () => modal.openPush("publish"), - "view-pr": () => viewPr(), - "create-pr": () => openCreatePr(), - "branch-here": () => - modal.openBranch(getSuggestedBranchName(taskId, repoPath)), - }; - actionMap[id](); - }; - - const viewPr = async () => { - if (!repoPath) return; - const result = await trpcClient.git.openPr.mutate({ - directoryPath: repoPath, - }); - if (result.success && result.prUrl) { - await trpcClient.os.openExternal.mutate({ url: result.prUrl }); - } - }; - - const runCommit = async (): Promise => { - if (!repoPath) return false; - - if (store.commitNextStep === "commit-push" && computed.pushDisabledReason) { - modal.setCommitError(computed.pushDisabledReason); - return false; - } - - modal.setIsSubmitting(true); - modal.setCommitError(null); - - let message = store.commitMessage.trim(); - - if (!message) { - try { - const generated = await trpcClient.git.generateCommitMessage.mutate({ - directoryPath: repoPath, - conversationContext: getConversationContext(taskId), - }); - - if (!generated.message) { - modal.setCommitError( - "No changes detected to generate a commit message.", - ); - modal.setIsSubmitting(false); - return false; - } - - message = generated.message; - modal.setCommitMessage(message); - } catch (error) { - log.error("Failed to generate commit message", error); - modal.setCommitError( - error instanceof Error - ? error.message - : "Failed to generate commit message.", - ); - modal.setIsSubmitting(false); - return false; - } - } - - try { - const { stagedOnly, analytics: commitStagingContext } = - buildStagingContext(stagedFiles, unstagedFiles, store.commitAll); - - const result = await trpcClient.git.commit.mutate({ - directoryPath: repoPath, - message, - stagedOnly: stagedOnly || undefined, - taskId, - }); - - if (!result.success) { - trackGitAction(taskId, "commit", false, commitStagingContext); - modal.setCommitError(result.message || "Commit failed."); - return false; - } - - trackGitAction(taskId, "commit", true, commitStagingContext); - - if (result.state) { - updateGitCacheFromSnapshot(queryClient, repoPath, result.state); - } - - modal.setCommitMessage(""); - modal.closeCommit(); - - if (store.commitNextStep === "commit-push") { - const mode = git.hasRemote ? "push" : "publish"; - modal.openPush(mode); - await runPush(mode); - return true; - } - return true; - } finally { - modal.setIsSubmitting(false); - } - }; - - const runPush = async (mode?: PushMode) => { - if (!repoPath) return; - - const pushMode = mode ?? useGitInteractionStore.getState().pushMode; - - pushAbortRef.current?.abort(); - const controller = new AbortController(); - pushAbortRef.current = controller; - - modal.setIsSubmitting(true); - modal.setPushError(null); - - try { - const pushFn = - pushMode === "sync" - ? trpcClient.git.sync - : pushMode === "publish" - ? trpcClient.git.publish - : trpcClient.git.push; - - const result = await pushFn.mutate( - { directoryPath: repoPath }, - { signal: controller.signal }, - ); - - if (!result.success) { - const message = - "message" in result - ? result.message - : `Pull: ${result.pullMessage}, Push: ${result.pushMessage}`; - trackGitAction(taskId, pushMode, false); - modal.setPushError(message || "Push failed."); - modal.setPushState("error"); - return; - } - - trackGitAction(taskId, pushMode, true); - - if (result.state) { - updateGitCacheFromSnapshot(queryClient, repoPath, result.state); - } - - modal.setPushState("success"); - } catch (error) { - trackGitAction(taskId, pushMode, false); - if (controller.signal.aborted) { - return; - } - log.error("Push failed", error); - const message = error instanceof Error ? error.message : "Push failed."; - modal.setPushError(message); - modal.setPushState("error"); - } finally { - if (pushAbortRef.current === controller) { - pushAbortRef.current = null; - } - modal.setIsSubmitting(false); - } - }; - - const abortPush = () => { - pushAbortRef.current?.abort(); - pushAbortRef.current = null; - }; - - const closePush = () => { - abortPush(); - modal.closePush(); - }; - - const generateCommitMessage = async () => { - if (!repoPath) return; - - modal.setIsGeneratingCommitMessage(true); - modal.setCommitError(null); - - try { - const result = await trpcClient.git.generateCommitMessage.mutate({ - directoryPath: repoPath, - conversationContext: getConversationContext(taskId), - }); - - if (result.message) { - modal.setCommitMessage(result.message); - } else { - modal.setCommitError( - "No changes detected to generate a commit message.", - ); - } - } catch (error) { - log.error("Failed to generate commit message", error); - modal.setCommitError( - error instanceof Error - ? error.message - : "Failed to generate commit message.", - ); - } finally { - modal.setIsGeneratingCommitMessage(false); - } - }; - - const generatePrTitleAndBody = async () => { - if (!repoPath) return; - - modal.setIsGeneratingPr(true); - modal.setCreatePrError(null); - - try { - const result = await trpcClient.git.generatePrTitleAndBody.mutate({ - directoryPath: repoPath, - conversationContext: getConversationContext(taskId), - }); - - if (result.title || result.body) { - modal.setPrTitle(result.title); - modal.setPrBody(result.body); - } else { - modal.setCreatePrError( - "No changes detected to generate PR description.", - ); - } - } catch (error) { - log.error("Failed to generate PR title and body", error); - modal.setCreatePrError( - error instanceof Error - ? error.message - : "Failed to generate PR description.", - ); - } finally { - modal.setIsGeneratingPr(false); - } - }; - - const runBranch = async (): Promise => { - if (!repoPath) return false; - - modal.setIsSubmitting(true); - modal.setBranchError(null); - - try { - const result = await createBranch({ - repoPath, - rawBranchName: store.branchName, - }); - if (!result.success) { - if (result.reason === "request") { - log.error("Failed to create branch", result.rawError ?? result.error); - trackGitAction(taskId, "branch-here", false); - } - - modal.setBranchError(result.error); - return false; - } - - trackGitAction(taskId, "branch-here", true); - await queryClient.invalidateQueries(trpc.workspace.getAll.pathFilter()); - - trpcClient.workspace.linkBranch - .mutate({ taskId, branchName: store.branchName.trim() }) - .catch((err) => - log.warn("Failed to link branch to task", { taskId, err }), - ); - - modal.closeBranch(); - return true; - } catch (error) { - log.error("Failed to create branch", error); - trackGitAction(taskId, "branch-here", false); - modal.setBranchError( - error instanceof Error ? error.message : "Failed to create branch.", - ); - return false; - } finally { - modal.setIsSubmitting(false); - } - }; - - return { - state: { - primaryAction: computed.primaryAction, - actions: computed.actions, - hasChanges: git.hasChanges, - aheadOfRemote: git.aheadOfRemote, - behind: git.behind, - currentBranch: git.currentBranch, - defaultBranch: git.defaultBranch, - isFeatureBranch: git.isFeatureBranch, - prBaseBranch: computed.prBaseBranch, - prHeadBranch: computed.prHeadBranch, - diffStats: git.diffStats, - prUrl: computed.prUrl, - pushDisabledReason: computed.pushDisabledReason, - isLoading: git.isLoading, - stagedFiles, - unstagedFiles, - }, - modals: store, - actions: { - openAction, - closeCommit: modal.closeCommit, - closePush, - closeBranch: modal.closeBranch, - setCommitMessage: modal.setCommitMessage, - setCommitNextStep: modal.setCommitNextStep, - setCommitAll: modal.setCommitAll, - setPrTitle: modal.setPrTitle, - setPrBody: modal.setPrBody, - setBranchName: (value: string) => { - const { sanitized, error } = getBranchNameInputState(value); - modal.setBranchName(sanitized); - modal.setBranchError(error); - }, - runCommit, - runPush, - runBranch, - runCreatePr, - generateCommitMessage, - generatePrTitleAndBody, - closeCreatePr: modal.closeCreatePr, - setCreatePrBranchName: (value: string) => { - const sanitized = sanitizeBranchName(value); - modal.setBranchName(sanitized); - }, - setCreatePrDraft: modal.setCreatePrDraft, - }, - }; -} diff --git a/apps/code/src/renderer/features/git-interaction/hooks/usePrActions.ts b/apps/code/src/renderer/features/git-interaction/hooks/usePrActions.ts deleted file mode 100644 index f5b832cad6..0000000000 --- a/apps/code/src/renderer/features/git-interaction/hooks/usePrActions.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - getOptimisticPrState, - PR_ACTION_LABELS, -} from "@features/git-interaction/utils/prStatus"; -import type { PrActionType } from "@main/services/git/schemas"; -import { useTRPC } from "@renderer/trpc"; -import { toast } from "@renderer/utils/toast"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -export function usePrActions(prUrl: string | null) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const mutation = useMutation( - trpc.git.updatePrByUrl.mutationOptions({ - onSuccess: (data, variables) => { - if (data.success) { - toast.success(PR_ACTION_LABELS[variables.action]); - queryClient.setQueryData( - trpc.git.getPrDetailsByUrl.queryKey({ prUrl: variables.prUrl }), - getOptimisticPrState(variables.action), - ); - } else { - toast.error("Failed to update PR", { description: data.message }); - } - }, - onError: (error) => { - toast.error("Failed to update PR", { - description: error instanceof Error ? error.message : "Unknown error", - }); - }, - }), - ); - - return { - execute: (action: PrActionType) => { - if (!prUrl) return; - mutation.mutate({ prUrl, action }); - }, - isPending: mutation.isPending, - }; -} diff --git a/apps/code/src/renderer/features/git-interaction/utils/deriveBranchName.ts b/apps/code/src/renderer/features/git-interaction/utils/deriveBranchName.ts deleted file mode 100644 index 9177511c68..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/deriveBranchName.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { BRANCH_PREFIX } from "@shared/constants"; - -export function deriveBranchName(title: string, fallbackId: string): string { - const slug = title - .toLowerCase() - .trim() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/-{2,}/g, "-") - .replace(/^-|-$/g, "") - .slice(0, 60) - .replace(/-$/, ""); - - if (!slug) return `${BRANCH_PREFIX}task-${fallbackId}`; - return `${BRANCH_PREFIX}${slug}`; -} diff --git a/apps/code/src/renderer/features/git-interaction/utils/getSuggestedBranchName.ts b/apps/code/src/renderer/features/git-interaction/utils/getSuggestedBranchName.ts deleted file mode 100644 index 05cccb5399..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/getSuggestedBranchName.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { deriveBranchName } from "@features/git-interaction/utils/deriveBranchName"; -import { trpc } from "@renderer/trpc"; -import type { Task } from "@shared/types"; -import { queryClient } from "@utils/queryClient"; - -export function getSuggestedBranchName( - taskId: string, - repoPath?: string, -): string { - const queries = queryClient.getQueriesData({ - queryKey: ["tasks", "list"], - }); - let task: Task | undefined; - for (const [, tasks] of queries) { - task = tasks?.find((t) => t.id === taskId); - if (task) break; - } - const fallbackId = task?.task_number - ? String(task.task_number) - : (task?.slug ?? taskId); - const base = deriveBranchName(task?.title ?? "", fallbackId); - - if (!repoPath) return base; - - const cached = queryClient.getQueryData( - trpc.git.getAllBranches.queryKey({ directoryPath: repoPath }), - ); - if (!cached?.includes(base)) return base; - - let n = 2; - while (cached.includes(`${base}-${n}`)) n++; - return `${base}-${n}`; -} diff --git a/apps/code/src/renderer/features/git-interaction/utils/gitCacheKeys.ts b/apps/code/src/renderer/features/git-interaction/utils/gitCacheKeys.ts deleted file mode 100644 index 2d424fe6ca..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/gitCacheKeys.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { trpc } from "@renderer/trpc"; -import { queryClient } from "@utils/queryClient"; - -export function invalidateGitWorkingTreeQueries(repoPath: string) { - const input = { directoryPath: repoPath }; - queryClient.invalidateQueries( - trpc.git.getChangedFilesHead.queryFilter(input), - ); - queryClient.invalidateQueries(trpc.git.getDiffStats.queryFilter(input)); - queryClient.invalidateQueries(trpc.git.getDiffCached.pathFilter()); - queryClient.invalidateQueries(trpc.git.getDiffUnstaged.pathFilter()); -} - -export function invalidateGitBranchQueries(repoPath: string) { - const input = { directoryPath: repoPath }; - queryClient.invalidateQueries(trpc.git.getCurrentBranch.queryFilter(input)); - queryClient.invalidateQueries(trpc.git.getAllBranches.queryFilter(input)); - queryClient.invalidateQueries(trpc.git.getGitBusyState.queryFilter(input)); - queryClient.invalidateQueries(trpc.git.getGitSyncStatus.queryFilter(input)); - queryClient.invalidateQueries( - trpc.git.getChangedFilesHead.queryFilter(input), - ); - queryClient.invalidateQueries(trpc.git.getDiffStats.queryFilter(input)); - queryClient.invalidateQueries(trpc.git.getLatestCommit.queryFilter(input)); - queryClient.invalidateQueries(trpc.git.getPrStatus.queryFilter(input)); - queryClient.invalidateQueries(trpc.git.getFileAtHead.pathFilter()); - queryClient.invalidateQueries( - trpc.git.getLocalBranchChangedFiles.pathFilter(), - ); -} - -export function clearGitReviewQueries() { - queryClient.removeQueries(trpc.git.getDiffCached.pathFilter()); - queryClient.removeQueries(trpc.git.getDiffUnstaged.pathFilter()); - queryClient.removeQueries(trpc.git.getFileAtHead.pathFilter()); - queryClient.removeQueries(trpc.fs.readRepoFile.pathFilter()); - queryClient.removeQueries(trpc.fs.readRepoFiles.pathFilter()); - queryClient.removeQueries(trpc.fs.readRepoFileBounded.pathFilter()); - queryClient.removeQueries(trpc.fs.readRepoFilesBounded.pathFilter()); - queryClient.removeQueries(trpc.git.getLocalBranchChangedFiles.pathFilter()); - queryClient.removeQueries(trpc.git.getPrChangedFiles.pathFilter()); - queryClient.removeQueries(trpc.git.getPrDetailsByUrl.pathFilter()); - queryClient.removeQueries(trpc.git.getPrReviewComments.pathFilter()); -} diff --git a/apps/code/src/renderer/features/git-interaction/utils/partitionByStaged.ts b/apps/code/src/renderer/features/git-interaction/utils/partitionByStaged.ts deleted file mode 100644 index 58e5f55a75..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/partitionByStaged.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ChangedFile } from "@shared/types"; - -export function partitionByStaged(files: ChangedFile[]): { - stagedFiles: ChangedFile[]; - unstagedFiles: ChangedFile[]; -} { - const stagedFiles: ChangedFile[] = []; - const unstagedFiles: ChangedFile[] = []; - for (const f of files) { - if (f.staged) stagedFiles.push(f); - else unstagedFiles.push(f); - } - return { stagedFiles, unstagedFiles }; -} diff --git a/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts b/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts deleted file mode 100644 index 5ebf3f564c..0000000000 --- a/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useCreateTask } from "@features/tasks/hooks/useTasks"; -import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; -import { get } from "@renderer/di/container"; -import { RENDERER_TOKENS } from "@renderer/di/tokens"; -import { toast } from "@renderer/utils/toast"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { useNavigationStore } from "@stores/navigationStore"; -import { track } from "@utils/analytics"; -import { logger } from "@utils/logger"; -import { useCallback, useState } from "react"; -import { toast as sonnerToast } from "sonner"; -import type { - TaskCreationInput, - TaskService, -} from "../../task-detail/service/service"; -import { buildCreatePrReportPrompt } from "../utils/buildCreatePrReportPrompt"; -import { resolveDefaultModel } from "../utils/resolveDefaultModel"; - -const log = logger.scope("create-pr-report"); - -interface UseCreatePrReportOptions { - reportId: string; - reportTitle: string | null; - cloudRepository: string | null; -} - -interface UseCreatePrReportReturn { - /** Create an auto-mode implementation task for the report and navigate to it on success. */ - createPrReport: () => Promise; - /** True while the task is being created. */ - isCreatingPr: boolean; -} - -/** - * Create an implementation (PR) task directly from the inbox detail pane. - * - * Mirrors the Discuss flow: bypasses TaskInput so the user stays on the inbox - * until the task is ready, then jumps straight to the task detail page. The - * agent gets a short prompt that points it at the inbox MCP tools instead of - * inlining the entire report summary. - */ -export function useCreatePrReport({ - reportId, - reportTitle, - cloudRepository, -}: UseCreatePrReportOptions): UseCreatePrReportReturn { - const [isCreatingPr, setIsCreatingPr] = useState(false); - const { navigateToTask } = useNavigationStore(); - const { getUserIntegrationIdForRepo } = useUserRepositoryIntegration(); - const { invalidateTasks } = useCreateTask(); - const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - - const createPrReport = useCallback(async () => { - if (isCreatingPr) return; - if (!cloudRepository) { - toast.error("Pick a cloud repository before creating a PR"); - return; - } - - const githubUserIntegrationId = - getUserIntegrationIdForRepo(cloudRepository); - if (!githubUserIntegrationId) { - toast.error("Connect a GitHub integration to create a PR"); - return; - } - - if (!cloudRegion) { - toast.error("Sign in to create a PR"); - return; - } - - setIsCreatingPr(true); - const toastId = toast.loading( - "Starting PR task...", - reportTitle ?? undefined, - ); - - const prompt = buildCreatePrReportPrompt({ - reportId, - isDevBuild: import.meta.env.DEV, - }); - - const settings = useSettingsStore.getState(); - const adapter = settings.lastUsedAdapter ?? "claude"; - const apiHost = getCloudUrlFromRegion(cloudRegion); - - const model = - settings.lastUsedModel ?? (await resolveDefaultModel(apiHost, adapter)); - - if (!model) { - sonnerToast.dismiss(toastId); - toast.error("Failed to start PR task", { - description: - "Couldn't resolve a default model. Open the task page once and pick a model, then try again.", - }); - setIsCreatingPr(false); - return; - } - - const input: TaskCreationInput = { - content: prompt, - taskDescription: prompt, - repository: cloudRepository, - githubUserIntegrationId, - workspaceMode: "cloud", - executionMode: "auto", - adapter, - model, - reasoningLevel: settings.lastUsedReasoningEffort ?? undefined, - cloudPrAuthorshipMode: "user", - cloudRunSource: "signal_report", - signalReportId: reportId, - }; - - try { - const taskService = get(RENDERER_TOKENS.TaskService); - const result = await taskService.createTask(input, (output) => { - invalidateTasks(output.task); - navigateToTask(output.task); - }); - - if (result.success) { - sonnerToast.dismiss(toastId); - track(ANALYTICS_EVENTS.TASK_CREATED, { - auto_run: true, - created_from: "command-menu", - repository_provider: "github", - workspace_mode: "cloud", - has_branch: false, - cloud_run_source: "signal_report", - cloud_pr_authorship_mode: "user", - adapter, - }); - } else { - sonnerToast.dismiss(toastId); - toast.error("Failed to start PR task", { - description: result.error, - }); - log.error("Create PR task creation failed", { - failedStep: result.failedStep, - error: result.error, - reportId, - reportTitle, - }); - } - } catch (error) { - sonnerToast.dismiss(toastId); - const description = - error instanceof Error ? error.message : "Unknown error"; - toast.error("Failed to start PR task", { description }); - log.error("Unexpected error during Create PR task creation", { - error, - reportId, - }); - } finally { - setIsCreatingPr(false); - } - }, [ - isCreatingPr, - cloudRepository, - cloudRegion, - reportId, - reportTitle, - getUserIntegrationIdForRepo, - invalidateTasks, - navigateToTask, - ]); - - return { createPrReport, isCreatingPr }; -} diff --git a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts deleted file mode 100644 index 2b660a1681..0000000000 --- a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useCreateTask } from "@features/tasks/hooks/useTasks"; -import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; -import { get } from "@renderer/di/container"; -import { RENDERER_TOKENS } from "@renderer/di/tokens"; -import { toast } from "@renderer/utils/toast"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { useNavigationStore } from "@stores/navigationStore"; -import { track } from "@utils/analytics"; -import { logger } from "@utils/logger"; -import { useCallback, useState } from "react"; -import { toast as sonnerToast } from "sonner"; -import type { - TaskCreationInput, - TaskService, -} from "../../task-detail/service/service"; -import { buildDiscussReportPrompt } from "../utils/buildDiscussReportPrompt"; -import { resolveDefaultModel } from "../utils/resolveDefaultModel"; - -const log = logger.scope("discuss-report"); - -interface UseDiscussReportOptions { - reportId: string; - reportTitle: string | null; - cloudRepository: string | null; -} - -interface UseDiscussReportReturn { - /** Create a Discuss task for the report and navigate to it on success. */ - discussReport: (question?: string) => Promise; - /** True while a Discuss task is being created. */ - isDiscussing: boolean; -} - -/** - * Create a Discuss task directly from the inbox detail pane. - * - * Bypasses TaskInput entirely so the user stays on the inbox until the task is - * ready, then jumps straight to the task detail page. On failure we surface a - * toast and stay put. - */ -export function useDiscussReport({ - reportId, - reportTitle, - cloudRepository, -}: UseDiscussReportOptions): UseDiscussReportReturn { - const [isDiscussing, setIsDiscussing] = useState(false); - const { navigateToTask } = useNavigationStore(); - const { getUserIntegrationIdForRepo } = useUserRepositoryIntegration(); - const { invalidateTasks } = useCreateTask(); - const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - - const discussReport = useCallback( - async (question?: string) => { - if (isDiscussing) return; - if (!cloudRepository) { - toast.error("Pick a cloud repository before starting a discussion"); - return; - } - - const githubUserIntegrationId = - getUserIntegrationIdForRepo(cloudRepository); - if (!githubUserIntegrationId) { - toast.error("Connect a GitHub integration to start a discussion"); - return; - } - - if (!cloudRegion) { - toast.error("Sign in to start a discussion"); - return; - } - - setIsDiscussing(true); - const toastId = toast.loading( - "Starting discussion...", - reportTitle ?? undefined, - ); - - const prompt = buildDiscussReportPrompt({ - reportId, - reportTitle, - question, - isDevBuild: import.meta.env.DEV, - }); - - const settings = useSettingsStore.getState(); - const adapter = settings.lastUsedAdapter ?? "claude"; - const apiHost = getCloudUrlFromRegion(cloudRegion); - - const model = - settings.lastUsedModel ?? (await resolveDefaultModel(apiHost, adapter)); - - if (!model) { - sonnerToast.dismiss(toastId); - toast.error("Failed to start discussion", { - description: - "Couldn't resolve a default model. Open the task page once and pick a model, then try again.", - }); - setIsDiscussing(false); - return; - } - - const input: TaskCreationInput = { - content: prompt, - taskDescription: prompt, - repository: cloudRepository, - githubUserIntegrationId, - workspaceMode: "cloud", - executionMode: "auto", - adapter, - model, - reasoningLevel: settings.lastUsedReasoningEffort ?? undefined, - cloudPrAuthorshipMode: "user", - cloudRunSource: "signal_report", - signalReportId: reportId, - }; - - try { - const taskService = get(RENDERER_TOKENS.TaskService); - const result = await taskService.createTask(input, (output) => { - invalidateTasks(output.task); - navigateToTask(output.task); - }); - - if (result.success) { - sonnerToast.dismiss(toastId); - track(ANALYTICS_EVENTS.TASK_CREATED, { - auto_run: true, - created_from: "command-menu", - repository_provider: "github", - workspace_mode: "cloud", - has_branch: false, - cloud_run_source: "signal_report", - cloud_pr_authorship_mode: "user", - signal_report_id: reportId, - adapter, - }); - } else { - sonnerToast.dismiss(toastId); - toast.error("Failed to start discussion", { - description: result.error, - }); - log.error("Discuss task creation failed", { - failedStep: result.failedStep, - error: result.error, - reportId, - reportTitle, - }); - } - } catch (error) { - sonnerToast.dismiss(toastId); - const description = - error instanceof Error ? error.message : "Unknown error"; - toast.error("Failed to start discussion", { description }); - log.error("Unexpected error during Discuss task creation", { - error, - reportId, - }); - } finally { - setIsDiscussing(false); - } - }, - [ - isDiscussing, - cloudRepository, - cloudRegion, - reportId, - reportTitle, - getUserIntegrationIdForRepo, - invalidateTasks, - navigateToTask, - ], - ); - - return { discussReport, isDiscussing }; -} diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxBulkActions.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxBulkActions.ts deleted file mode 100644 index de9add0d12..0000000000 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxBulkActions.ts +++ /dev/null @@ -1,404 +0,0 @@ -import type { DismissReportDialogResult } from "@features/inbox/components/DismissReportDialog"; -import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; -import { inboxStatusLabel } from "@features/inbox/utils/inboxSort"; -import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; -import type { SignalReport } from "@shared/types"; -import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo } from "react"; -import { toast } from "sonner"; - -type BulkActionName = "suppress" | "snooze" | "delete" | "reingest"; - -interface BulkActionResult { - successCount: number; - failureCount: number; -} - -const inboxQueryKey = ["inbox", "signal-reports"] as const; - -/** Active workflow statuses for snooze and suppress. Terminal `suppressed` / `deleted` are excluded. */ -const suppressibleStatuses = new Set([ - "potential", - "candidate", - "in_progress", - "pending_input", - "ready", - "failed", -]); - -/** Clause after "Disabled because …" (see `@components/ui/Button`). */ -const DISABLED_NO_SELECTION = "you haven't selected a report"; - -/** Statuses that block suppression; labels match `inboxStatusLabel`. */ -const SUPPRESS_BLOCKED_STATUS_PHRASE = ( - ["suppressed", "deleted"] as const satisfies readonly SignalReport["status"][] -) - .map((status) => inboxStatusLabel(status)) - .join(" or "); - -type SelectedReportEligibility = { - selectedReports: SignalReport[]; - selectedIds: string[]; - selectedCount: number; - snoozeDisabledReason: string | null; - suppressDisabledReason: string | null; - deleteDisabledReason: string | null; - reingestDisabledReason: string | null; -}; - -function formatBulkActionSummary( - action: BulkActionName, - result: BulkActionResult, -): string { - const { successCount, failureCount } = result; - const pluralized = successCount === 1 ? "report" : "reports"; - const formulated = - action === "suppress" - ? `${pluralized} dismissed` - : action === "snooze" - ? `${pluralized} snoozed` - : action === "delete" - ? `${pluralized} deleted` - : `${pluralized} reingested`; - if (failureCount === 0) { - return `${successCount} ${formulated}`; - } - return `${successCount} ${formulated}, ${failureCount} failed`; -} - -function getSnoozeOrSuppressDisabledReason( - selectedCount: number, - selectedReports: SignalReport[], -): string | null { - if (selectedCount === 0) { - return DISABLED_NO_SELECTION; - } - const ok = selectedReports.every((report) => - suppressibleStatuses.has(report.status), - ); - if (ok) { - return null; - } - return `every selected report must not already be ${SUPPRESS_BLOCKED_STATUS_PHRASE}`; -} - -function getSelectedReportEligibility( - reports: SignalReport[], - selectedIds: string[], -): SelectedReportEligibility { - const selectedIdSet = new Set(selectedIds); - const selectedReports = reports.filter((report) => - selectedIdSet.has(report.id), - ); - const selectedCount = selectedReports.length; - - const snoozeOrSuppressDisabledReason = getSnoozeOrSuppressDisabledReason( - selectedCount, - selectedReports, - ); - - return { - selectedReports, - selectedIds: selectedReports.map((report) => report.id), - selectedCount, - snoozeDisabledReason: snoozeOrSuppressDisabledReason, - suppressDisabledReason: snoozeOrSuppressDisabledReason, - deleteDisabledReason: selectedCount === 0 ? DISABLED_NO_SELECTION : null, - reingestDisabledReason: selectedCount === 0 ? DISABLED_NO_SELECTION : null, - }; -} - -/** Toolbar: selected report ids. Dismiss dialog: that report's id, or null when closed. */ -export type InboxBulkSelection = string[] | string | null; - -const emptyBulkIds: string[] = []; - -function effectiveBulkIdsFromSelection( - selection: InboxBulkSelection, -): string[] { - if (selection == null) { - return emptyBulkIds; - } - if (Array.isArray(selection)) { - return selection; - } - return [selection]; -} - -function bulkSelectionKey(selection: InboxBulkSelection): string { - if (selection == null) { - return ""; - } - if (Array.isArray(selection)) { - return selection.join("\0"); - } - return selection; -} - -/** Snooze disabled reason when `selectedIds` are treated as the bulk selection (matches toolbar logic). */ -export function inboxBulkSnoozeDisabledReason( - reports: SignalReport[], - selectedIds: string[], -): string | null { - return getSelectedReportEligibility(reports, selectedIds) - .snoozeDisabledReason; -} - -/** Suppress/dismiss disabled reason when `selectedIds` are treated as the bulk selection. */ -export function inboxBulkSuppressDisabledReason( - reports: SignalReport[], - selectedIds: string[], -): string | null { - return getSelectedReportEligibility(reports, selectedIds) - .suppressDisabledReason; -} - -export function useInboxBulkActions( - reports: SignalReport[], - selection: InboxBulkSelection, -) { - const queryClient = useQueryClient(); - const clearSelection = useInboxReportSelectionStore( - (state) => state.clearSelection, - ); - - const effectiveBulkIds = effectiveBulkIdsFromSelection(selection); - - // biome-ignore lint/correctness/useExhaustiveDependencies: `bulkKeys` serializes selection so callers may pass fresh array literals (or a lone id) without busting this memo. - const eligibility = useMemo( - () => getSelectedReportEligibility(reports, effectiveBulkIds), - [reports, bulkSelectionKey(selection)], - ); - - const invalidateInboxQueries = useCallback(async () => { - await queryClient.invalidateQueries({ - queryKey: inboxQueryKey, - exact: false, - }); - }, [queryClient]); - - const suppressMutation = useAuthenticatedMutation( - async ( - client, - input: { reportIds: string[]; dismissal?: DismissReportDialogResult }, - ) => { - const results = await Promise.allSettled( - input.reportIds.map((reportId) => - client.updateSignalReportState(reportId, { - state: "suppressed", - ...(input.dismissal - ? { - dismissal_reason: input.dismissal.reason, - dismissal_note: input.dismissal.note.slice(0, 4000), - } - : {}), - }), - ), - ); - - const successCount = results.filter( - (result) => result.status === "fulfilled", - ).length; - - return { - successCount, - failureCount: results.length - successCount, - }; - }, - { - onSuccess: async (result) => { - await invalidateInboxQueries(); - clearSelection(); - - if (result.failureCount > 0) { - toast.error(formatBulkActionSummary("suppress", result)); - return; - } - - toast.success(formatBulkActionSummary("suppress", result)); - }, - onError: (error) => { - toast.error(error.message || "Failed to dismiss reports"); - }, - }, - ); - - const snoozeMutation = useAuthenticatedMutation( - async (client, reportIds: string[]) => { - const results = await Promise.allSettled( - reportIds.map((reportId) => - client.updateSignalReportState(reportId, { - state: "potential", - snooze_for: 1, - }), - ), - ); - - const successCount = results.filter( - (result) => result.status === "fulfilled", - ).length; - - return { - successCount, - failureCount: results.length - successCount, - }; - }, - { - onSuccess: async (result) => { - await invalidateInboxQueries(); - clearSelection(); - - if (result.failureCount > 0) { - toast.error(formatBulkActionSummary("snooze", result)); - return; - } - - toast.success(formatBulkActionSummary("snooze", result)); - }, - onError: (error) => { - toast.error(error.message || "Failed to snooze reports"); - }, - }, - ); - - const deleteMutation = useAuthenticatedMutation( - async (client, reportIds: string[]) => { - const results = await Promise.allSettled( - reportIds.map((reportId) => client.deleteSignalReport(reportId)), - ); - - const successCount = results.filter( - (result) => result.status === "fulfilled", - ).length; - - return { - successCount, - failureCount: results.length - successCount, - }; - }, - { - onSuccess: async (result) => { - await invalidateInboxQueries(); - clearSelection(); - - if (result.failureCount > 0) { - toast.error(formatBulkActionSummary("delete", result)); - return; - } - - toast.success(formatBulkActionSummary("delete", result)); - }, - onError: (error) => { - toast.error(error.message || "Failed to delete reports"); - }, - }, - ); - - const reingestMutation = useAuthenticatedMutation( - async (client, reportIds: string[]) => { - const results = await Promise.allSettled( - reportIds.map((reportId) => client.reingestSignalReport(reportId)), - ); - - const successCount = results.filter( - (result) => result.status === "fulfilled", - ).length; - - return { - successCount, - failureCount: results.length - successCount, - }; - }, - { - onSuccess: async (result) => { - await invalidateInboxQueries(); - clearSelection(); - - if (result.failureCount > 0) { - toast.error(formatBulkActionSummary("reingest", result)); - return; - } - - toast.success(formatBulkActionSummary("reingest", result)); - }, - onError: (error) => { - toast.error(error.message || "Failed to reingest reports"); - }, - }, - ); - - const suppressSelected = useCallback( - async (dismissal?: DismissReportDialogResult) => { - if (eligibility.suppressDisabledReason !== null) { - return false; - } - - await suppressMutation.mutateAsync({ - reportIds: eligibility.selectedIds, - ...(dismissal != null ? { dismissal } : {}), - }); - return true; - }, - [ - eligibility.suppressDisabledReason, - eligibility.selectedIds, - suppressMutation, - ], - ); - - const snoozeSelected = useCallback(async () => { - if (eligibility.snoozeDisabledReason !== null) { - return false; - } - - await snoozeMutation.mutateAsync(eligibility.selectedIds); - return true; - }, [ - eligibility.snoozeDisabledReason, - eligibility.selectedIds, - snoozeMutation, - ]); - - const deleteSelected = useCallback(async () => { - if (eligibility.deleteDisabledReason !== null) { - return false; - } - - await deleteMutation.mutateAsync(eligibility.selectedIds); - return true; - }, [ - deleteMutation, - eligibility.deleteDisabledReason, - eligibility.selectedIds, - ]); - - const reingestSelected = useCallback(async () => { - if (eligibility.reingestDisabledReason !== null) { - return false; - } - - await reingestMutation.mutateAsync(eligibility.selectedIds); - return true; - }, [ - eligibility.reingestDisabledReason, - eligibility.selectedIds, - reingestMutation, - ]); - - return { - selectedReports: eligibility.selectedReports, - selectedCount: eligibility.selectedCount, - snoozeDisabledReason: eligibility.snoozeDisabledReason, - suppressDisabledReason: eligibility.suppressDisabledReason, - deleteDisabledReason: eligibility.deleteDisabledReason, - reingestDisabledReason: eligibility.reingestDisabledReason, - isSuppressing: suppressMutation.isPending, - isSnoozing: snoozeMutation.isPending, - isDeleting: deleteMutation.isPending, - isReingesting: reingestMutation.isPending, - suppressSelected, - snoozeSelected, - deleteSelected, - reingestSelected, - }; -} diff --git a/apps/code/src/renderer/features/inbox/hooks/useReportTasks.ts b/apps/code/src/renderer/features/inbox/hooks/useReportTasks.ts deleted file mode 100644 index a37bc8583b..0000000000 --- a/apps/code/src/renderer/features/inbox/hooks/useReportTasks.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { SignalReportStatus, SignalReportTask, Task } from "@shared/types"; - -type Relationship = SignalReportTask["relationship"]; - -const DISPLAYED_RELATIONSHIPS: Relationship[] = ["implementation", "research"]; - -interface ReportTaskData { - task: Task; - relationship: Relationship; - startedAt: string; -} - -export function useReportTasks( - reportId: string, - reportStatus: SignalReportStatus, -) { - const isActive = - reportStatus === "candidate" || - reportStatus === "in_progress" || - reportStatus === "pending_input"; - - return useAuthenticatedQuery( - ["inbox", "report-tasks", reportId], - async (client) => { - const reportTasks = await client.getSignalReportTasks(reportId); - const relevant = reportTasks.filter((rt) => - DISPLAYED_RELATIONSHIPS.includes(rt.relationship), - ); - const tasks = await Promise.all( - relevant.map(async (rt) => { - const task = (await client.getTask(rt.task_id)) as unknown as Task; - return { - task, - relationship: rt.relationship, - startedAt: rt.created_at, - }; - }), - ); - return tasks.sort( - (a, b) => - DISPLAYED_RELATIONSHIPS.indexOf(a.relationship) - - DISPLAYED_RELATIONSHIPS.indexOf(b.relationship), - ); - }, - { - enabled: !!reportId, - staleTime: isActive ? 5_000 : 10_000, - refetchInterval: isActive ? 5_000 : false, - }, - ); -} - -export function getTaskPrUrl(task: Task): string | null { - const output = task.latest_run?.output; - if (output && typeof output === "object" && !Array.isArray(output)) { - const prUrl = (output as Record).pr_url; - if (typeof prUrl === "string" && prUrl.length > 0) { - return prUrl; - } - } - return null; -} diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts deleted file mode 100644 index d929c8ef19..0000000000 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts +++ /dev/null @@ -1,599 +0,0 @@ -import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import type { SignalSourceValues } from "@features/inbox/components/SignalSourceToggles"; -import type { - Evaluation, - SignalSourceConfig, -} from "@renderer/api/posthogClient"; -import type { - SignalReportPriority, - SignalUserAutonomyConfig, -} from "@shared/types"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { useQueryClient } from "@tanstack/react-query"; -import { track } from "@utils/analytics"; -import { useCallback, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { useEvaluations } from "./useEvaluations"; -import { useExternalDataSources } from "./useExternalDataSources"; -import { useSignalSourceConfigs } from "./useSignalSourceConfigs"; -import { useSignalTeamConfig } from "./useSignalTeamConfig"; -import { useSignalUserAutonomyConfig } from "./useSignalUserAutonomyConfig"; - -type SourceProduct = SignalSourceConfig["source_product"]; -type SourceType = SignalSourceConfig["source_type"]; - -const SOURCE_TYPE_MAP: Record< - Exclude, - SourceType -> = { - session_replay: "session_analysis_cluster", - github: "issue", - linear: "issue", - zendesk: "ticket", - conversations: "ticket", - pganalyze: "issue", -}; - -const ERROR_TRACKING_SOURCE_TYPES: SourceType[] = [ - "issue_created", - "issue_reopened", - "issue_spiking", -]; - -const SOURCE_LABELS: Record = { - session_replay: "Session replay", - error_tracking: "Error tracking", - github: "GitHub Issues", - linear: "Linear Issues", - zendesk: "Zendesk Tickets", - conversations: "PostHog Support", - pganalyze: "pganalyze", -}; - -const DATA_WAREHOUSE_SOURCES: Record< - string, - { dwSourceType: string; requiredTable: string } -> = { - github: { dwSourceType: "Github", requiredTable: "issues" }, - linear: { dwSourceType: "Linear", requiredTable: "issues" }, - zendesk: { dwSourceType: "Zendesk", requiredTable: "tickets" }, - pganalyze: { dwSourceType: "PgAnalyze", requiredTable: "issues" }, -}; - -const ALL_SOURCE_PRODUCTS: (keyof SignalSourceValues)[] = [ - "session_replay", - "error_tracking", - "github", - "linear", - "zendesk", - "conversations", - "pganalyze", -]; - -function computeValues( - configs: SignalSourceConfig[] | undefined, -): SignalSourceValues { - const result: SignalSourceValues = { - session_replay: false, - error_tracking: false, - github: false, - linear: false, - zendesk: false, - conversations: false, - pganalyze: false, - }; - if (!configs?.length) return result; - for (const product of ALL_SOURCE_PRODUCTS) { - if (product === "error_tracking") { - result.error_tracking = ERROR_TRACKING_SOURCE_TYPES.every((st) => - configs.some( - (c) => - c.source_product === "error_tracking" && - c.source_type === st && - c.enabled, - ), - ); - } else { - result[product] = configs.some( - (c) => c.source_product === product && c.enabled, - ); - } - } - return result; -} - -export function useSignalSourceManager() { - const projectId = useAuthStateValue((state) => state.projectId); - const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - const client = useAuthenticatedClient(); - const queryClient = useQueryClient(); - const { data: configs, isLoading: configsLoading } = useSignalSourceConfigs(); - const { data: externalSources, isLoading: sourcesLoading } = - useExternalDataSources(); - const { data: evaluations } = useEvaluations(); - const { data: teamConfig } = useSignalTeamConfig(); - const { data: userAutonomyConfig, isLoading: userAutonomyConfigLoading } = - useSignalUserAutonomyConfig(); - - // Optimistic overrides keyed by source product — only sources actively being - // toggled get an entry, so unrelated sources never see a prop change. - const [optimistic, setOptimistic] = useState< - Partial> - >({}); - const pendingRef = useRef(new Set()); - - const [setupSource, setSetupSource] = useState< - "github" | "linear" | "zendesk" | "pganalyze" | null - >(null); - const [loadingSources, setLoadingSources] = useState< - Partial> - >({}); - - const isLoading = configsLoading || sourcesLoading; - - const findExternalSource = useCallback( - (product: string) => { - const dwConfig = DATA_WAREHOUSE_SOURCES[product]; - if (!dwConfig || !externalSources) return null; - return externalSources.find( - (s) => - s.source_type.toLowerCase() === dwConfig.dwSourceType.toLowerCase(), - ); - }, - [externalSources], - ); - - const serverValues = useMemo( - () => computeValues(configs), - [configs], - ); - - // Merge: optimistic overrides take precedence over server values. - const displayValues = useMemo(() => { - if (Object.keys(optimistic).length === 0) return serverValues; - return { ...serverValues, ...optimistic }; - }, [serverValues, optimistic]); - - const sourceStates = useMemo(() => { - const states: Partial< - Record< - keyof SignalSourceValues, - { - requiresSetup: boolean; - loading: boolean; - syncStatus?: SignalSourceConfig["status"]; - } - > - > = {}; - for (const product of ALL_SOURCE_PRODUCTS) { - if ( - product === "github" || - product === "linear" || - product === "zendesk" || - product === "pganalyze" - ) { - const hasExternalSource = !!findExternalSource(product); - const isEnabled = serverValues[product]; - const config = configs?.find((c) => c.source_product === product); - states[product] = { - requiresSetup: !hasExternalSource && !isEnabled, - loading: !!loadingSources[product], - syncStatus: config?.status ?? null, - }; - } else { - const config = configs?.find((c) => c.source_product === product); - states[product] = { - requiresSetup: false, - loading: false, - syncStatus: config?.status ?? null, - }; - } - } - return states; - }, [findExternalSource, serverValues, loadingSources, configs]); - - const evaluationsUrl = useMemo(() => { - if (!cloudRegion) return ""; - return `${getCloudUrlFromRegion(cloudRegion)}/llm-analytics/evaluations`; - }, [cloudRegion]); - - // Optimistic evaluation state: map of evaluation ID to overridden enabled value - const [optimisticEvals, setOptimisticEvals] = useState< - Record - >({}); - - const displayEvaluations = useMemo(() => { - if (!evaluations) return []; - if (Object.keys(optimisticEvals).length === 0) return evaluations; - return evaluations.map((e) => - e.id in optimisticEvals ? { ...e, enabled: optimisticEvals[e.id] } : e, - ); - }, [evaluations, optimisticEvals]); - - const handleToggleEvaluation = useCallback( - async (evaluationId: string, enabled: boolean) => { - if (!client || !projectId) return; - - setOptimisticEvals((prev) => ({ ...prev, [evaluationId]: enabled })); - - try { - await client.updateEvaluation(projectId, evaluationId, { enabled }); - await queryClient.invalidateQueries({ queryKey: ["evaluations"] }); - } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : "Failed to toggle evaluation"; - toast.error(message); - } finally { - setOptimisticEvals((prev) => { - const next = { ...prev }; - delete next[evaluationId]; - return next; - }); - } - }, - [client, projectId, queryClient], - ); - - const ensureRequiredTableSyncing = useCallback( - async (product: string) => { - if (!projectId || !client) return; - const dwConfig = DATA_WAREHOUSE_SOURCES[product]; - if (!dwConfig) return; - - const source = findExternalSource(product); - if (!source?.schemas || !Array.isArray(source.schemas)) return; - - const requiredSchema = source.schemas.find( - (s) => s.name.toLowerCase() === dwConfig.requiredTable, - ); - if (!requiredSchema) return; - - const issuesFullReplication = - (product === "github" || product === "linear") && - dwConfig.requiredTable === "issues"; - - if (issuesFullReplication) { - const syncType = requiredSchema.sync_type; - const needsUpdate = - !requiredSchema.should_sync || syncType !== "full_refresh"; - - if (needsUpdate) { - await client.updateExternalDataSchema(projectId, requiredSchema.id, { - should_sync: true, - sync_type: "full_refresh", - }); - } - return; - } - - if (!requiredSchema.should_sync) { - await client.updateExternalDataSchema(projectId, requiredSchema.id, { - should_sync: true, - }); - } - }, - [projectId, client, findExternalSource], - ); - - const handleSetup = useCallback((source: keyof SignalSourceValues) => { - if ( - source === "github" || - source === "linear" || - source === "zendesk" || - source === "pganalyze" - ) { - setSetupSource(source); - } - }, []); - - const invalidateAfterToggle = useCallback(async () => { - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: ["signals", "source-configs"], - }), - queryClient.invalidateQueries({ - queryKey: ["inbox", "signal-reports"], - }), - ]); - }, [queryClient]); - - // Toggle a single source product. Calls the API directly (no react-query - // mutation tracking) so intermediate loading/success states don't cause - // cascading re-renders. - const handleToggle = useCallback( - async (product: keyof SignalSourceValues, enabled: boolean) => { - if (!client || !projectId) return; - if (pendingRef.current.has(product)) return; - - // Warehouse sources without a connected external data source need setup first - if (enabled && product in DATA_WAREHOUSE_SOURCES) { - const hasExternalSource = !!findExternalSource(product); - if (!hasExternalSource) { - setSetupSource( - product as "github" | "linear" | "zendesk" | "pganalyze", - ); - return; - } - - setLoadingSources((prev) => ({ ...prev, [product]: true })); - try { - await ensureRequiredTableSyncing(product); - } finally { - setLoadingSources((prev) => ({ ...prev, [product]: false })); - } - } - - // Optimistic update — only touches this one key - pendingRef.current.add(product); - setOptimistic((prev) => ({ ...prev, [product]: enabled })); - - const label = SOURCE_LABELS[product]; - - const hadExistingConfig = configs?.some( - (c) => c.source_product === product, - ); - try { - if (product === "error_tracking") { - for (const sourceType of ERROR_TRACKING_SOURCE_TYPES) { - const existing = configs?.find( - (c) => - c.source_product === "error_tracking" && - c.source_type === sourceType, - ); - if (existing) { - await client.updateSignalSourceConfig(projectId, existing.id, { - enabled, - }); - } else if (enabled) { - await client.createSignalSourceConfig(projectId, { - source_product: "error_tracking", - source_type: sourceType, - enabled: true, - }); - } - } - } else { - const existing = configs?.find((c) => c.source_product === product); - if (existing) { - await client.updateSignalSourceConfig(projectId, existing.id, { - enabled, - }); - } else if (enabled) { - await client.createSignalSourceConfig(projectId, { - source_product: product, - source_type: - SOURCE_TYPE_MAP[ - product as Exclude< - SourceProduct, - "error_tracking" | "llm_analytics" - > - ], - enabled: true, - }); - } - } - - if (enabled) { - track(ANALYTICS_EVENTS.SIGNAL_SOURCE_CONNECTED, { - source_product: product, - is_first_connection: !hadExistingConfig, - via_setup_wizard: false, - }); - } - - await invalidateAfterToggle(); - } catch (error: unknown) { - const message = - error instanceof Error ? error.message : `Failed to toggle ${label}`; - toast.error(message); - } finally { - pendingRef.current.delete(product); - setOptimistic((prev) => { - const next = { ...prev }; - delete next[product]; - return next; - }); - } - }, - [ - client, - projectId, - configs, - findExternalSource, - ensureRequiredTableSyncing, - invalidateAfterToggle, - ], - ); - - const handleSetupComplete = useCallback(async () => { - const completedSource = setupSource; - setSetupSource(null); - - if (completedSource && client && projectId) { - const existing = configs?.find( - (c) => c.source_product === completedSource, - ); - try { - if (!existing) { - await client.createSignalSourceConfig(projectId, { - source_product: completedSource, - source_type: SOURCE_TYPE_MAP[completedSource], - enabled: true, - }); - } else if (!existing.enabled) { - await client.updateSignalSourceConfig(projectId, existing.id, { - enabled: true, - }); - } - track(ANALYTICS_EVENTS.SIGNAL_SOURCE_CONNECTED, { - source_product: completedSource, - is_first_connection: !existing, - via_setup_wizard: true, - }); - } catch { - toast.error( - "Data source connected, but failed to enable signal source. Try toggling it on.", - ); - } - } - - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["external-data-sources"] }), - queryClient.invalidateQueries({ - queryKey: ["signals", "source-configs"], - }), - queryClient.invalidateQueries({ - queryKey: ["inbox", "signal-reports"], - }), - ]); - }, [queryClient, setupSource, configs, client, projectId]); - - const handleSetupCancel = useCallback(() => { - setSetupSource(null); - }, []); - - const handleUpdateAutostartPriority = useCallback( - async (priority: string) => { - if (!client) return; - try { - await client.updateSignalTeamConfig({ - default_autostart_priority: priority, - }); - await queryClient.invalidateQueries({ - queryKey: ["signals", "team-config"], - }); - } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : "Failed to update autostart priority"; - toast.error(message); - } - }, - [client, queryClient], - ); - - const handleUpdateUserAutonomyPriority = useCallback( - async (priority: string | null) => { - if (!client) return; - try { - if (priority === null) { - await client.deleteSignalUserAutonomyConfig(); - } else { - await client.updateSignalUserAutonomyConfig({ - autostart_priority: priority, - }); - } - await queryClient.invalidateQueries({ - queryKey: ["signals", "user-autonomy-config"], - }); - } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : "Failed to update autonomy setting"; - toast.error(message); - } - }, - [client, queryClient], - ); - - const handleUpdateSlackNotifications = useCallback( - async (updates: { - integrationId?: number | null; - channel?: string | null; - minPriority?: string | null; - }) => { - if (!client) return; - // Translate frontend camelCase to the API's snake_case body. Only include - // keys the caller passed in, so other settings (e.g. autostart_priority) - // are not wiped. - const body: Record = {}; - if ("integrationId" in updates) { - body.slack_notification_integration_id = updates.integrationId ?? null; - } - if ("channel" in updates) { - body.slack_notification_channel = updates.channel ?? null; - } - if ("minPriority" in updates) { - body.slack_notification_min_priority = updates.minPriority ?? null; - } - - const queryKey = ["signals", "user-autonomy-config"]; - const previous = - queryClient.getQueryData(queryKey); - - // Optimistic update: reflect the user's choice in the UI before the - // server responds. Build the next snapshot from the previous one so - // unrelated fields (autostart_priority, etc.) are preserved. - const optimisticNext: SignalUserAutonomyConfig = { - ...(previous ?? - ({ autostart_priority: null } as SignalUserAutonomyConfig)), - ...("integrationId" in updates - ? { slack_notification_integration_id: updates.integrationId ?? null } - : {}), - ...("channel" in updates - ? { slack_notification_channel: updates.channel ?? null } - : {}), - ...("minPriority" in updates - ? { - slack_notification_min_priority: - (updates.minPriority as - | SignalReportPriority - | null - | undefined) ?? null, - } - : {}), - }; - queryClient.setQueryData( - queryKey, - optimisticNext, - ); - - try { - const fresh = await client.updateSignalUserAutonomyConfig(body); - queryClient.setQueryData( - queryKey, - fresh, - ); - } catch (error: unknown) { - // Roll back to whatever was in the cache before this attempt. - queryClient.setQueryData( - queryKey, - previous ?? null, - ); - const message = - error instanceof Error - ? error.message - : "Failed to update Slack notification setting"; - toast.error(message); - } - }, - [client, queryClient], - ); - - return { - displayValues, - sourceStates, - - setupSource, - isLoading, - handleToggle, - handleSetup, - handleSetupComplete, - handleSetupCancel, - evaluations: displayEvaluations, - evaluationsUrl, - handleToggleEvaluation, - teamConfig, - handleUpdateAutostartPriority, - userAutonomyConfig, - userAutonomyConfigLoading, - handleUpdateUserAutonomyPriority, - handleUpdateSlackNotifications, - }; -} diff --git a/apps/code/src/renderer/features/inbox/stores/inboxCloudTaskStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxCloudTaskStore.ts deleted file mode 100644 index 2a931737a1..0000000000 --- a/apps/code/src/renderer/features/inbox/stores/inboxCloudTaskStore.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { TaskService } from "@features/task-detail/service/service"; -import { get as getFromContainer } from "@renderer/di/container"; -import { RENDERER_TOKENS } from "@renderer/di/tokens"; -import type { Task } from "@shared/types"; -import { logger } from "@utils/logger"; -import { create } from "zustand"; - -const log = logger.scope("inbox-cloud-task-store"); - -interface RunCloudTaskParams { - prompt: string; - githubIntegrationId?: number; - reportId?: string; -} - -interface RunCloudTaskResult { - success: boolean; - task?: Task; - error?: string; -} - -interface InboxCloudTaskStoreState { - isRunning: boolean; - showConfirm: boolean; - selectedRepo: string | null; -} - -interface InboxCloudTaskStoreActions { - openConfirm: (defaultRepo: string | null) => void; - closeConfirm: () => void; - setSelectedRepo: (repo: string | null) => void; - runCloudTask: (params: RunCloudTaskParams) => Promise; -} - -type InboxCloudTaskStore = InboxCloudTaskStoreState & - InboxCloudTaskStoreActions; - -export const useInboxCloudTaskStore = create()( - (set, get) => ({ - isRunning: false, - showConfirm: false, - selectedRepo: null, - - openConfirm: (defaultRepo) => - set({ showConfirm: true, selectedRepo: defaultRepo }), - - closeConfirm: () => set({ showConfirm: false }), - - setSelectedRepo: (repo) => set({ selectedRepo: repo }), - - runCloudTask: async (params) => { - const { selectedRepo } = get(); - set({ showConfirm: false, isRunning: true }); - - try { - const taskService = getFromContainer( - RENDERER_TOKENS.TaskService, - ); - const result = await taskService.createTask({ - content: params.prompt, - workspaceMode: "cloud", - githubIntegrationId: params.githubIntegrationId, - repository: selectedRepo, - cloudPrAuthorshipMode: "bot", - cloudRunSource: "signal_report", - signalReportId: params.reportId, - }); - - if (result.success) { - const { task } = result.data; - log.info("Cloud task created from signal report", { - taskId: task.id, - reportId: params.reportId, - repository: selectedRepo, - }); - return { success: true, task }; - } - - log.error("Cloud task creation failed", { - failedStep: result.failedStep, - error: result.error, - }); - return { - success: false, - error: result.error ?? "Failed to create cloud task", - }; - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error"; - log.error("Unexpected error during cloud task creation", { error }); - return { - success: false, - error: `Failed to run cloud task: ${message}`, - }; - } finally { - set({ isRunning: false }); - } - }, - }), -); diff --git a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts b/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts deleted file mode 100644 index e36118c4c3..0000000000 --- a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { buildDiscussReportPrompt as buildSharedDiscussReportPrompt } from "@posthog/shared"; -import { buildInboxDeeplink } from "@shared/deeplink"; - -interface BuildDiscussReportPromptOptions { - reportId: string; - reportTitle?: string | null; - question?: string; - isDevBuild: boolean; -} - -export function buildDiscussReportPrompt({ - reportId, - reportTitle, - question, - isDevBuild, -}: BuildDiscussReportPromptOptions): string { - const reportLink = buildInboxDeeplink(reportId, reportTitle, { isDevBuild }); - return buildSharedDiscussReportPrompt({ reportId, reportLink, question }); -} diff --git a/apps/code/src/renderer/features/inbox/utils/inboxSort.ts b/apps/code/src/renderer/features/inbox/utils/inboxSort.ts deleted file mode 100644 index 58c821a645..0000000000 --- a/apps/code/src/renderer/features/inbox/utils/inboxSort.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { SignalReportStatus } from "@shared/types"; - -export function inboxStatusLabel(status: SignalReportStatus): string { - switch (status) { - case "ready": - return "Ready"; - case "pending_input": - return "Needs input"; - case "in_progress": - return "Researching"; - case "candidate": - return "Queued"; - case "potential": - return "Gathering"; - case "failed": - return "Failed"; - case "suppressed": - return "Suppressed"; - case "deleted": - return "Deleted"; - default: - return status; - } -} - -export function inboxStatusAccentCss(status: SignalReportStatus): string { - switch (status) { - case "ready": - return "var(--green-9)"; - case "pending_input": - return "var(--violet-9)"; - case "in_progress": - return "var(--amber-9)"; - case "candidate": - return "var(--cyan-9)"; - case "potential": - return "var(--gray-9)"; - case "failed": - return "var(--red-9)"; - default: - return "var(--gray-8)"; - } -} diff --git a/apps/code/src/renderer/features/inbox/utils/resolveDefaultModel.ts b/apps/code/src/renderer/features/inbox/utils/resolveDefaultModel.ts deleted file mode 100644 index 21690f9765..0000000000 --- a/apps/code/src/renderer/features/inbox/utils/resolveDefaultModel.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { trpcClient } from "@renderer/trpc/client"; -import { logger } from "@utils/logger"; - -const log = logger.scope("resolve-default-model"); - -/** - * Resolve the default model for the given adapter via the preview-config - * tRPC query. Returns the server's `currentValue` for the `model` option, or - * undefined if the call fails or the option is missing. - * - * Used by inbox flows that create cloud tasks directly (Discuss, Create PR) - * without going through TaskInput — they need a model to pass to the saga - * and the user hasn't necessarily picked one yet. - */ -export async function resolveDefaultModel( - apiHost: string, - adapter: "claude" | "codex", -): Promise { - try { - const options = await trpcClient.agent.getPreviewConfigOptions.query({ - apiHost, - adapter, - }); - const modelOption = options.find( - (o) => o.id === "model" || o.category === "model", - ); - if (modelOption?.type === "select" && modelOption.currentValue) { - return modelOption.currentValue; - } - } catch (error) { - log.warn("Failed to resolve default model", { error, adapter }); - } - return undefined; -} diff --git a/apps/code/src/renderer/features/mcp-servers/components/parts/icons.tsx b/apps/code/src/renderer/features/mcp-servers/components/parts/icons.tsx deleted file mode 100644 index be4fb49c4c..0000000000 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/icons.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Plugs } from "@phosphor-icons/react"; -import { Flex } from "@radix-ui/themes"; -import IconAirOps from "@renderer/assets/services/airops.png"; -import IconAtlassian from "@renderer/assets/services/atlassian.svg"; -import IconAttio from "@renderer/assets/services/attio.png"; -import IconBox from "@renderer/assets/services/box.svg"; -import IconBrowserbase from "@renderer/assets/services/browserbase.svg"; -import IconCanva from "@renderer/assets/services/canva.svg"; -import IconCircle from "@renderer/assets/services/circle.png"; -import IconCiscoThousandEyes from "@renderer/assets/services/cisco_thousandeyes.png"; -import IconClerk from "@renderer/assets/services/clerk.svg"; -import IconClickHouse from "@renderer/assets/services/clickhouse.svg"; -import IconCloudflare from "@renderer/assets/services/cloudflare.svg"; -import IconContext7 from "@renderer/assets/services/context7.svg"; -import IconDatadog from "@renderer/assets/services/datadog.svg"; -import IconFigma from "@renderer/assets/services/figma.svg"; -import IconFiretiger from "@renderer/assets/services/firetiger.svg"; -import IconGitHub from "@renderer/assets/services/github.svg"; -import IconGitLab from "@renderer/assets/services/gitlab.svg"; -import IconHex from "@renderer/assets/services/hex.svg"; -import IconHubSpot from "@renderer/assets/services/hubspot.svg"; -import IconLaunchDarkly from "@renderer/assets/services/launchdarkly.png"; -import IconLinear from "@renderer/assets/services/linear.svg"; -import IconMonday from "@renderer/assets/services/monday.svg"; -import IconNeon from "@renderer/assets/services/neon.svg"; -import IconNotion from "@renderer/assets/services/notion.svg"; -import IconPagerDuty from "@renderer/assets/services/pagerduty.svg"; -import IconPlanetScale from "@renderer/assets/services/planetscale.svg"; -import IconPostman from "@renderer/assets/services/postman.svg"; -import IconPrisma from "@renderer/assets/services/prisma.svg"; -import IconRender from "@renderer/assets/services/render.svg"; -import IconSanity from "@renderer/assets/services/sanity.svg"; -import IconSentry from "@renderer/assets/services/sentry.svg"; -import IconSlack from "@renderer/assets/services/slack.png"; -import IconStripe from "@renderer/assets/services/stripe.png"; -import IconSupabase from "@renderer/assets/services/supabase.svg"; -import IconSvelte from "@renderer/assets/services/svelte.png"; -import IconWix from "@renderer/assets/services/wix.png"; - -const BRAND_ICONS: Record = { - airops: IconAirOps, - atlassian: IconAtlassian, - attio: IconAttio, - box: IconBox, - browserbase: IconBrowserbase, - canva: IconCanva, - circle: IconCircle, - cisco_thousandeyes: IconCiscoThousandEyes, - clerk: IconClerk, - clickhouse: IconClickHouse, - cloudflare: IconCloudflare, - context7: IconContext7, - datadog: IconDatadog, - figma: IconFigma, - firetiger: IconFiretiger, - github: IconGitHub, - gitlab: IconGitLab, - hex: IconHex, - hubspot: IconHubSpot, - launchdarkly: IconLaunchDarkly, - linear: IconLinear, - monday: IconMonday, - neon: IconNeon, - notion: IconNotion, - pagerduty: IconPagerDuty, - planetscale: IconPlanetScale, - postman: IconPostman, - prisma: IconPrisma, - render: IconRender, - sanity: IconSanity, - sentry: IconSentry, - slack: IconSlack, - stripe: IconStripe, - supabase: IconSupabase, - svelte: IconSvelte, - wix: IconWix, -}; - -export function resolveServerIcon( - iconKey: string | null | undefined, -): string | undefined { - return iconKey ? BRAND_ICONS[iconKey] : undefined; -} - -interface ServerIconProps { - iconKey?: string | null; - size?: number; - className?: string; -} - -export function ServerIcon({ iconKey, size = 32, className }: ServerIconProps) { - const src = resolveServerIcon(iconKey); - const dimension = `${size}px`; - const radius = 2; - return ( - - {src ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/code/src/renderer/features/mcp-servers/components/parts/statusBadge.ts b/apps/code/src/renderer/features/mcp-servers/components/parts/statusBadge.ts deleted file mode 100644 index 222c00c6fc..0000000000 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/statusBadge.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { McpServerInstallation } from "@renderer/api/posthogClient"; - -export type InstallationStatus = "connected" | "pending_oauth" | "needs_reauth"; - -export function getInstallationStatus( - installation: McpServerInstallation, -): InstallationStatus { - if (installation.pending_oauth) return "pending_oauth"; - if (installation.needs_reauth) return "needs_reauth"; - return "connected"; -} - -export const STATUS_LABELS: Record = { - connected: "Connected", - pending_oauth: "Finish connecting", - needs_reauth: "Reconnect required", -}; - -export const STATUS_COLORS: Record< - InstallationStatus, - "green" | "amber" | "red" -> = { - connected: "green", - pending_oauth: "amber", - needs_reauth: "red", -}; diff --git a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts deleted file mode 100644 index 14aded3710..0000000000 --- a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { AvailableCommand } from "@agentclientprotocol/sdk"; -import { CODE_COMMANDS } from "@features/message-editor/commands"; -import { getAvailableCommandsForTask } from "@features/sessions/stores/sessionStore"; -import { - fetchRepoFiles, - pathToFileItem, - searchFiles, -} from "@hooks/useRepoFiles"; -import { trpc } from "@renderer/trpc/client"; -import { isAbsolutePath } from "@utils/path"; -import { queryClient } from "@utils/queryClient"; -import Fuse, { type IFuseOptions } from "fuse.js"; -import { useDraftStore } from "../stores/draftStore"; -import type { - CommandSuggestionItem, - FileSuggestionItem, - IssueSuggestionItem, -} from "../types"; -import { - githubIssueToMentionChip, - githubPullRequestToMentionChip, -} from "../utils/githubIssueChip"; - -const COMMAND_FUSE_OPTIONS: IFuseOptions = { - keys: [ - { name: "name", weight: 0.7 }, - { name: "description", weight: 0.3 }, - ], - threshold: 0.3, - includeScore: true, -}; - -function searchCommands( - commands: AvailableCommand[], - query: string, -): AvailableCommand[] { - if (!query.trim()) { - return commands; - } - - const fuse = new Fuse(commands, COMMAND_FUSE_OPTIONS); - const results = fuse.search(query); - - const lowerQuery = query.toLowerCase(); - results.sort((a, b) => { - const aStartsWithQuery = a.item.name.toLowerCase().startsWith(lowerQuery); - const bStartsWithQuery = b.item.name.toLowerCase().startsWith(lowerQuery); - - if (aStartsWithQuery && !bStartsWithQuery) return -1; - if (!aStartsWithQuery && bStartsWithQuery) return 1; - return (a.score ?? 0) - (b.score ?? 0); - }); - - return results.map((result) => result.item); -} - -function parentDirLabel(dir: string, name: string): string { - const parent = dir.split("/").filter(Boolean).pop(); - return parent ? `${parent}/${name}` : name; -} - -function getAbsolutePathSuggestion(query: string): FileSuggestionItem | null { - if (!isAbsolutePath(query)) return null; - if (!/\.\w+$/.test(query)) return null; - - const fileItem = pathToFileItem(query); - return { - id: query, - label: parentDirLabel(fileItem.dir, fileItem.name), - description: fileItem.dir || undefined, - filename: fileItem.name, - path: query, - }; -} - -export async function getFileSuggestions( - sessionId: string, - query: string, -): Promise { - const repoPath = useDraftStore.getState().contexts[sessionId]?.repoPath; - const absoluteMatch = getAbsolutePathSuggestion(query); - - if (!repoPath) { - return absoluteMatch ? [absoluteMatch] : []; - } - - const { files, fzf } = await fetchRepoFiles(repoPath, { - includeDirectories: true, - }); - const matched = searchFiles(fzf, files, query); - - const results: FileSuggestionItem[] = matched.map((file) => { - const isDirectory = file.kind === "directory"; - return { - id: file.path, - label: parentDirLabel(file.dir, file.name), - description: file.dir || undefined, - filename: file.name, - path: file.path, - kind: file.kind, - chipType: isDirectory ? "folder" : "file", - }; - }); - - if ( - absoluteMatch && - !results.some((r) => `${repoPath}/${r.id}` === absoluteMatch.id) - ) { - results.unshift(absoluteMatch); - } - - return results; -} - -export async function getIssueSuggestions( - sessionId: string, - query: string, -): Promise { - const repoPath = useDraftStore.getState().contexts[sessionId]?.repoPath; - if (!repoPath) return []; - - try { - const refs = await queryClient.fetchQuery({ - ...trpc.git.searchGithubRefs.queryOptions({ - directoryPath: repoPath, - query: query || undefined, - limit: 25, - }), - staleTime: 30_000, - }); - - return refs.map((ref) => { - const chip = - ref.kind === "pr" - ? githubPullRequestToMentionChip(ref) - : githubIssueToMentionChip(ref); - return { - id: chip.id, - label: chip.label, - chipType: chip.type, - kind: ref.kind, - number: ref.number, - title: ref.title, - url: ref.url, - repo: ref.repo, - state: ref.state, - labels: ref.labels, - isDraft: ref.isDraft, - }; - }); - } catch { - return []; - } -} - -export function getCommandSuggestions( - sessionId: string, - query: string, -): CommandSuggestionItem[] { - const store = useDraftStore.getState(); - const taskId = store.contexts[sessionId]?.taskId; - const agentCommands = taskId - ? getAvailableCommandsForTask(taskId) - : (store.commands[sessionId] ?? []); - const merged = [...CODE_COMMANDS, ...agentCommands]; - const commands = [...new Map(merged.map((cmd) => [cmd.name, cmd])).values()]; - const filtered = searchCommands(commands, query); - - return filtered.map((cmd) => ({ - id: cmd.name, - label: cmd.name, - description: cmd.description, - command: cmd, - })); -} diff --git a/apps/code/src/renderer/features/onboarding/components/ProjectSelect.tsx b/apps/code/src/renderer/features/onboarding/components/ProjectSelect.tsx deleted file mode 100644 index 91a1807998..0000000000 --- a/apps/code/src/renderer/features/onboarding/components/ProjectSelect.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { Check } from "@phosphor-icons/react"; -import { - Autocomplete, - AutocompleteInput, - AutocompleteItem, - AutocompleteList, - AutocompleteStatus, -} from "@posthog/quill"; -import { Popover, Text } from "@radix-ui/themes"; -import { useState } from "react"; - -interface ProjectSelectProps { - projectId: number; - projectName: string; - projects: Array<{ id: number; name: string }>; - onProjectChange: (projectId: number) => void; - disabled?: boolean; - size?: "1" | "2"; -} - -type ProjectInfo = { id: number; name: string }; - -export function ProjectSelect({ - projectId, - projectName, - projects, - onProjectChange, - disabled = false, - size = "2", -}: ProjectSelectProps) { - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const sizeClass = size === "1" ? "text-[13px]" : "text-sm"; - - const handleOpenChange = (nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) setQuery(""); - }; - - const handleSelect = (id: string | null) => { - if (id === null) return; - const next = Number(id); - if (Number.isNaN(next)) return; - onProjectChange(next); - // Route through handleOpenChange so setQuery("") fires — calling - // setOpen(false) directly bypasses Popover's onOpenChange. - handleOpenChange(false); - }; - - if (projects.length <= 1) { - return ( - - {projectName} - - ); - } - - return ( - - - {projectName} - {" · "} - - - - - - - - inline - defaultOpen - items={projects} - value={query} - autoHighlight="always" - onValueChange={(val, eventDetails) => { - if (eventDetails.reason !== "input-change") return; - if (typeof val === "string") setQuery(val); - }} - filter={(project, q) => { - if (!q) return true; - return project.name.toLowerCase().includes(q.toLowerCase()); - }} - > - - - No projects match "{query}" - - ) : ( - No projects available - ) - } - /> - - {(project: ProjectInfo) => ( - handleSelect(String(project.id))} - className="flex items-center justify-between gap-3" - > - {project.name} - {project.id === projectId && ( - - )} - - )} - - - - - - ); -} diff --git a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts b/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts deleted file mode 100644 index 3c8932d97b..0000000000 --- a/apps/code/src/renderer/features/onboarding/hooks/useOnboardingFlow.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { trpcClient } from "@renderer/trpc/client"; -import { - ANALYTICS_EVENTS, - type RepositoryProvider, -} from "@shared/types/analytics"; -import { useActiveRepoStore } from "@stores/activeRepoStore"; -import { track } from "@utils/analytics"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ONBOARDING_STEPS, type OnboardingStep } from "../types"; - -function inferRepositoryProvider( - remote: string | undefined, -): RepositoryProvider { - if (!remote) return "local"; - const host = remote - .match(/^(?:[a-z]+:\/\/)?(?:[^@/]+@)?([a-z0-9.-]+)[:/]/i)?.[1] - ?.toLowerCase(); - if (host === "gitlab.com") return "gitlab"; - if (host === "github.com") return "github"; - return "none"; -} - -export interface DetectedRepo { - organization: string; - repository: string; - fullName: string; - remote?: string; - branch?: string; -} - -export function useOnboardingFlow() { - const currentStep = useOnboardingStore((state) => state.currentStep); - const setCurrentStep = useOnboardingStore((state) => state.setCurrentStep); - const selectedDirectory = useActiveRepoStore((state) => state.path); - const setSelectedDirectory = useActiveRepoStore((state) => state.setPath); - const directionRef = useRef<1 | -1>(1); - - const [detectedRepo, setDetectedRepo] = useState(null); - const [isDetectingRepo, setIsDetectingRepo] = useState(false); - const hasRehydrated = useRef(false); - - useEffect(() => { - if (hasRehydrated.current || !selectedDirectory) return; - hasRehydrated.current = true; - setIsDetectingRepo(true); - trpcClient.git.detectRepo - .query({ directoryPath: selectedDirectory }) - .then((result) => { - if (result) { - setDetectedRepo({ - organization: result.organization, - repository: result.repository, - fullName: `${result.organization}/${result.repository}`, - remote: result.remote ?? undefined, - branch: result.branch ?? undefined, - }); - } - }) - .catch(() => {}) - .finally(() => setIsDetectingRepo(false)); - }, [selectedDirectory]); - - const handleDirectoryChange = useCallback( - async (path: string) => { - setSelectedDirectory(path); - setDetectedRepo(null); - if (!path) return; - - setIsDetectingRepo(true); - try { - const result = await trpcClient.git.detectRepo.query({ - directoryPath: path, - }); - if (result) { - setDetectedRepo({ - organization: result.organization, - repository: result.repository, - fullName: `${result.organization}/${result.repository}`, - remote: result.remote ?? undefined, - branch: result.branch ?? undefined, - }); - track(ANALYTICS_EVENTS.ONBOARDING_FOLDER_SELECTED, { - has_git_remote: true, - repository_provider: inferRepositoryProvider( - result.remote ?? undefined, - ), - }); - } else { - track(ANALYTICS_EVENTS.ONBOARDING_FOLDER_SELECTED, { - has_git_remote: false, - repository_provider: "local", - }); - } - } catch { - track(ANALYTICS_EVENTS.ONBOARDING_FOLDER_SELECTED, { - has_git_remote: false, - repository_provider: "local", - }); - } finally { - setIsDetectingRepo(false); - } - }, - [setSelectedDirectory], - ); - - const hasCodeAccess = useAuthStateValue((state) => state.hasCodeAccess); - - const activeSteps = useMemo(() => { - if (hasCodeAccess === true) { - return ONBOARDING_STEPS.filter((s) => s !== "invite-code"); - } - return ONBOARDING_STEPS; - }, [hasCodeAccess]); - - useEffect(() => { - if (!activeSteps.includes(currentStep)) { - setCurrentStep(activeSteps[0]); - } - }, [activeSteps, currentStep, setCurrentStep]); - - const currentIndex = activeSteps.indexOf(currentStep); - const isFirstStep = currentIndex === 0; - const isLastStep = currentIndex === activeSteps.length - 1; - - const next = () => { - if (!isLastStep) { - directionRef.current = 1; - setCurrentStep(activeSteps[currentIndex + 1]); - } - }; - - const back = () => { - if (!isFirstStep) { - directionRef.current = -1; - setCurrentStep(activeSteps[currentIndex - 1]); - } - }; - - const goTo = (step: OnboardingStep) => { - const targetIndex = activeSteps.indexOf(step); - directionRef.current = targetIndex >= currentIndex ? 1 : -1; - setCurrentStep(step); - }; - - return { - currentStep, - currentIndex, - totalSteps: activeSteps.length, - activeSteps, - isFirstStep, - isLastStep, - direction: directionRef.current, - next, - back, - goTo, - selectedDirectory, - detectedRepo, - isDetectingRepo, - handleDirectoryChange, - }; -} diff --git a/apps/code/src/renderer/features/onboarding/types.ts b/apps/code/src/renderer/features/onboarding/types.ts deleted file mode 100644 index 66a118598a..0000000000 --- a/apps/code/src/renderer/features/onboarding/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type OnboardingStep = - | "welcome" - | "project-select" - | "invite-code" - | "connect-github" - | "install-cli" - | "select-repo"; - -export const ONBOARDING_STEPS: OnboardingStep[] = [ - "welcome", - "project-select", - "invite-code", - "connect-github", - "install-cli", - "select-repo", -]; diff --git a/apps/code/src/renderer/features/panels/index.ts b/apps/code/src/renderer/features/panels/index.ts deleted file mode 100644 index 0c09a6ec2b..0000000000 --- a/apps/code/src/renderer/features/panels/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -export { PanelLayout } from "./components/PanelLayout"; -export { - PanelGroupTree, - PanelLeaf, - PanelTab, -} from "./components/PanelTree"; -export { useDragDropHandlers } from "./hooks/useDragDropHandlers"; -export { usePanelLayoutStore } from "./store/panelLayoutStore"; -export { usePanelStore } from "./store/panelStore"; -export { isFileTabActiveInTree } from "./store/panelStoreHelpers"; - -export type { - GroupId, - GroupPanel, - LeafPanel, - PanelContent, - PanelId, - PanelNode, - SplitDirection, - Tab, - TabId, -} from "./store/panelTypes"; diff --git a/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts b/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts deleted file mode 100644 index 47c08f67c2..0000000000 --- a/apps/code/src/renderer/features/panels/store/panelLayoutStore.ts +++ /dev/null @@ -1,913 +0,0 @@ -import { getFileExtension } from "@renderer/utils/path"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; -import { persist } from "zustand/middleware"; -import { createWithEqualityFn } from "zustand/traditional"; -import { - DEFAULT_PANEL_IDS, - DEFAULT_TAB_IDS, -} from "../constants/panelConstants"; -import { - addNewTabToPanel, - applyCleanupWithFallback, - createFileTabId, - generatePanelId, - getLeafPanel, - getSplitConfig, - selectNextTabAfterClose, - updateMetadataForTab, - updateTaskLayout, -} from "./panelStoreHelpers"; -import { - addTabToPanel, - cleanupNode, - findTabInPanel, - findTabInTree, - removeTabFromPanel, - setActiveTabInPanel, - updateTreeNode, -} from "./panelTree"; -import type { PanelNode, Tab } from "./panelTypes"; - -const MAX_RECENT_FILES = 10; - -export interface TaskLayout { - panelTree: PanelNode; - openFiles: string[]; - recentFiles: string[]; - draggingTabId: string | null; - draggingTabPanelId: string | null; - focusedPanelId: string | null; -} - -export type SplitDirection = "left" | "right" | "top" | "bottom"; - -export interface PanelLayoutStore { - taskLayouts: Record; - - getLayout: (taskId: string) => TaskLayout | null; - initializeTask: (taskId: string) => void; - openFile: (taskId: string, filePath: string, asPreview?: boolean) => void; - openFileInSplit: ( - taskId: string, - filePath: string, - asPreview?: boolean, - ) => void; - keepTab: (taskId: string, panelId: string, tabId: string) => void; - closeTab: (taskId: string, panelId: string, tabId: string) => void; - closeOtherTabs: (taskId: string, panelId: string, tabId: string) => void; - closeTabsToRight: (taskId: string, panelId: string, tabId: string) => void; - closeTabsForFile: (taskId: string, filePath: string) => void; - - setActiveTab: (taskId: string, panelId: string, tabId: string) => void; - setDraggingTab: ( - taskId: string, - tabId: string | null, - panelId: string | null, - ) => void; - clearDraggingTab: (taskId: string) => void; - reorderTabs: ( - taskId: string, - panelId: string, - sourceIndex: number, - targetIndex: number, - ) => void; - moveTab: ( - taskId: string, - tabId: string, - sourcePanelId: string, - targetPanelId: string, - ) => void; - splitPanel: ( - taskId: string, - tabId: string, - sourcePanelId: string, - targetPanelId: string, - direction: SplitDirection, - ) => void; - updateSizes: (taskId: string, groupId: string, sizes: number[]) => void; - updateTabMetadata: ( - taskId: string, - tabId: string, - metadata: Partial>, - ) => void; - updateTabLabel: (taskId: string, tabId: string, label: string) => void; - setFocusedPanel: (taskId: string, panelId: string) => void; - addTerminalTab: (taskId: string, panelId: string) => void; - addActionTab: ( - taskId: string, - panelId: string, - action: { - actionId: string; - command: string; - cwd: string; - label: string; - }, - ) => void; - clearAllLayouts: () => void; -} - -function createDefaultPanelTree(): PanelNode { - return { - type: "leaf", - id: DEFAULT_PANEL_IDS.MAIN_PANEL, - content: { - id: DEFAULT_PANEL_IDS.MAIN_PANEL, - tabs: [ - { - id: DEFAULT_TAB_IDS.LOGS, - label: "Chat", - data: { type: "logs" }, - component: null, - closeable: false, - draggable: true, - }, - { - id: DEFAULT_TAB_IDS.SHELL, - label: "Terminal", - data: { - type: "terminal", - terminalId: DEFAULT_TAB_IDS.SHELL, - cwd: "", - }, - component: null, - closeable: true, - draggable: true, - }, - ], - activeTabId: DEFAULT_TAB_IDS.LOGS, - showTabs: true, - droppable: true, - }, - }; -} - -function openTab( - state: { taskLayouts: Record }, - taskId: string, - tabId: string, - asPreview = true, - targetPanelId?: string, -): { taskLayouts: Record } { - return updateTaskLayout(state, taskId, (layout) => { - // Check if tab already exists in tree - const existingTab = findTabInTree(layout.panelTree, tabId); - - if (existingTab) { - // Tab exists - activate it, only pin if explicitly requested (asPreview=false) - const updatedTree = updateTreeNode( - layout.panelTree, - existingTab.panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - return { - ...panel, - content: { - ...panel.content, - tabs: asPreview - ? panel.content.tabs - : panel.content.tabs.map((tab) => - tab.id === tabId ? { ...tab, isPreview: false } : tab, - ), - activeTabId: tabId, - }, - }; - }, - ); - - return { panelTree: updatedTree }; - } - - // Tab doesn't exist, add it to the specified panel, focused panel, or main panel as fallback - const resolvedPanelId = - targetPanelId ?? layout.focusedPanelId ?? DEFAULT_PANEL_IDS.MAIN_PANEL; - let targetPanel = getLeafPanel(layout.panelTree, resolvedPanelId); - - // Fall back to main panel if the focused panel doesn't exist or isn't a leaf - if (!targetPanel) { - targetPanel = getLeafPanel( - layout.panelTree, - DEFAULT_PANEL_IDS.MAIN_PANEL, - ); - } - if (!targetPanel) return {}; - - const panelId = targetPanel.id; - const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => - addNewTabToPanel(panel, tabId, true, asPreview), - ); - - const metadata = updateMetadataForTab(layout, tabId, "add"); - - return { - panelTree: updatedTree, - ...metadata, - }; - }); -} - -function findNonMainLeafPanel(node: PanelNode): PanelNode | null { - if (node.type === "leaf") { - return node.id !== DEFAULT_PANEL_IDS.MAIN_PANEL ? node : null; - } - if (node.type === "group") { - for (const child of node.children) { - const found = findNonMainLeafPanel(child); - if (found) return found; - } - } - return null; -} - -function openTabInSplit( - state: { taskLayouts: Record }, - taskId: string, - tabId: string, - asPreview = true, -): { taskLayouts: Record } { - return updateTaskLayout(state, taskId, (layout) => { - const existingTab = findTabInTree(layout.panelTree, tabId); - - if (existingTab) { - const updatedTree = updateTreeNode( - layout.panelTree, - existingTab.panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - return { - ...panel, - content: { - ...panel.content, - tabs: asPreview - ? panel.content.tabs - : panel.content.tabs.map((tab) => - tab.id === tabId ? { ...tab, isPreview: false } : tab, - ), - activeTabId: tabId, - }, - }; - }, - ); - - return { panelTree: updatedTree }; - } - - const nonMainPanel = findNonMainLeafPanel(layout.panelTree); - - if (nonMainPanel) { - const updatedTree = updateTreeNode( - layout.panelTree, - nonMainPanel.id, - (panel) => addNewTabToPanel(panel, tabId, true, asPreview), - ); - - const metadata = updateMetadataForTab(layout, tabId, "add"); - return { panelTree: updatedTree, ...metadata }; - } - - const newPanelId = generatePanelId(); - const newPanel: PanelNode = { - type: "leaf", - id: newPanelId, - content: { - id: newPanelId, - tabs: [], - activeTabId: "", - showTabs: true, - droppable: true, - }, - }; - - const mainPanel = getLeafPanel( - layout.panelTree, - DEFAULT_PANEL_IDS.MAIN_PANEL, - ); - if (!mainPanel) return {}; - - const splitTree = updateTreeNode( - layout.panelTree, - DEFAULT_PANEL_IDS.MAIN_PANEL, - (panel) => ({ - type: "group" as const, - id: generatePanelId(), - direction: "horizontal" as const, - sizes: [50, 50], - children: [panel, newPanel], - }), - ); - - const finalTree = updateTreeNode(splitTree, newPanelId, (panel) => - addNewTabToPanel(panel, tabId, true, asPreview), - ); - - const metadata = updateMetadataForTab(layout, tabId, "add"); - return { panelTree: finalTree, focusedPanelId: newPanelId, ...metadata }; - }); -} - -export const usePanelLayoutStore = createWithEqualityFn()( - persist( - (set, get) => ({ - taskLayouts: {}, - - getLayout: (taskId) => { - return get().taskLayouts[taskId] || null; - }, - - initializeTask: (taskId) => { - set((state) => ({ - taskLayouts: { - ...state.taskLayouts, - [taskId]: { - panelTree: createDefaultPanelTree(), - openFiles: [], - recentFiles: [], - openArtifacts: [], - draggingTabId: null, - draggingTabPanelId: null, - focusedPanelId: DEFAULT_PANEL_IDS.MAIN_PANEL, - }, - }, - })); - }, - - openFile: (taskId, filePath, asPreview = true) => { - const tabId = createFileTabId(filePath); - set((state) => { - const afterOpenTab = openTab(state, taskId, tabId, asPreview); - const layout = afterOpenTab.taskLayouts[taskId]; - if (!layout) return afterOpenTab; - - const recentFiles = [ - filePath, - ...(layout.recentFiles || []).filter((f) => f !== filePath), - ].slice(0, MAX_RECENT_FILES); - - return { - ...afterOpenTab, - taskLayouts: { - ...afterOpenTab.taskLayouts, - [taskId]: { ...layout, recentFiles }, - }, - }; - }); - - track(ANALYTICS_EVENTS.FILE_OPENED, { - file_extension: getFileExtension(filePath), - source: "sidebar", - task_id: taskId, - }); - }, - - openFileInSplit: (taskId, filePath, asPreview = true) => { - const tabId = createFileTabId(filePath); - set((state) => { - const afterOpenTab = openTabInSplit(state, taskId, tabId, asPreview); - const layout = afterOpenTab.taskLayouts[taskId]; - if (!layout) return afterOpenTab; - - const recentFiles = [ - filePath, - ...(layout.recentFiles || []).filter((f) => f !== filePath), - ].slice(0, MAX_RECENT_FILES); - - return { - ...afterOpenTab, - taskLayouts: { - ...afterOpenTab.taskLayouts, - [taskId]: { ...layout, recentFiles }, - }, - }; - }); - - track(ANALYTICS_EVENTS.FILE_OPENED, { - file_extension: getFileExtension(filePath), - source: "sidebar", - task_id: taskId, - }); - }, - - keepTab: (taskId, panelId, tabId) => { - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const updatedTree = updateTreeNode( - layout.panelTree, - panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - return { - ...panel, - content: { - ...panel.content, - tabs: panel.content.tabs.map((tab) => - tab.id === tabId ? { ...tab, isPreview: false } : tab, - ), - }, - }; - }, - ); - return { panelTree: updatedTree }; - }), - ); - }, - - closeTab: (taskId, panelId, tabId) => { - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const updatedTree = updateTreeNode( - layout.panelTree, - panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const tabIndex = panel.content.tabs.findIndex( - (t) => t.id === tabId, - ); - const remainingTabs = panel.content.tabs.filter( - (t) => t.id !== tabId, - ); - - const newActiveTabId = selectNextTabAfterClose( - remainingTabs, - tabIndex, - panel.content.activeTabId, - tabId, - ); - - return { - ...panel, - content: { - ...panel.content, - tabs: remainingTabs, - activeTabId: newActiveTabId, - }, - }; - }, - ); - - const cleanedTree = applyCleanupWithFallback( - cleanupNode(updatedTree), - layout.panelTree, - ); - const metadata = updateMetadataForTab(layout, tabId, "remove"); - - return { - panelTree: cleanedTree, - ...metadata, - }; - }), - ); - }, - - closeOtherTabs: (taskId, panelId, tabId) => { - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const updatedTree = updateTreeNode( - layout.panelTree, - panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const remainingTabs = panel.content.tabs.filter( - (t) => t.id === tabId || t.closeable === false, - ); - - return { - ...panel, - content: { - ...panel.content, - tabs: remainingTabs, - activeTabId: tabId, - }, - }; - }, - ); - - return { panelTree: updatedTree }; - }), - ); - }, - - closeTabsToRight: (taskId, panelId, tabId) => { - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const updatedTree = updateTreeNode( - layout.panelTree, - panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const tabIndex = panel.content.tabs.findIndex( - (t) => t.id === tabId, - ); - if (tabIndex === -1) return panel; - - const remainingTabs = panel.content.tabs.filter( - (t, index) => index <= tabIndex || t.closeable === false, - ); - - return { - ...panel, - content: { - ...panel.content, - tabs: remainingTabs, - activeTabId: tabId, - }, - }; - }, - ); - - return { panelTree: updatedTree }; - }), - ); - }, - - closeTabsForFile: (taskId, filePath) => { - const layout = get().taskLayouts[taskId]; - if (!layout) return; - - const tabId = createFileTabId(filePath); - const tabLocation = findTabInTree(layout.panelTree, tabId); - if (tabLocation) { - get().closeTab(taskId, tabLocation.panelId, tabId); - } - }, - - setActiveTab: (taskId, panelId, tabId) => { - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const updatedTree = updateTreeNode( - layout.panelTree, - panelId, - (panel) => setActiveTabInPanel(panel, tabId), - ); - - return { panelTree: updatedTree }; - }), - ); - }, - - setDraggingTab: (taskId, tabId, panelId) => { - set((state) => - updateTaskLayout(state, taskId, () => ({ - draggingTabId: tabId, - draggingTabPanelId: panelId, - })), - ); - }, - - clearDraggingTab: (taskId) => { - set((state) => - updateTaskLayout(state, taskId, () => ({ - draggingTabId: null, - draggingTabPanelId: null, - })), - ); - }, - - reorderTabs: (taskId, panelId, sourceIndex, targetIndex) => { - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const updatedTree = updateTreeNode( - layout.panelTree, - panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const tabs = [...panel.content.tabs]; - const [removed] = tabs.splice(sourceIndex, 1); - tabs.splice(targetIndex, 0, removed); - - return { - ...panel, - content: { - ...panel.content, - tabs, - }, - }; - }, - ); - - return { panelTree: updatedTree }; - }), - ); - }, - - moveTab: (taskId, tabId, sourcePanelId, targetPanelId) => { - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const sourcePanel = getLeafPanel(layout.panelTree, sourcePanelId); - if (!sourcePanel) return {}; - - const tab = findTabInPanel(sourcePanel, tabId); - if (!tab) return {}; - - const treeAfterRemove = updateTreeNode( - layout.panelTree, - sourcePanelId, - (panel) => removeTabFromPanel(panel, tabId), - ); - - const treeAfterAdd = updateTreeNode( - treeAfterRemove, - targetPanelId, - (panel) => addTabToPanel(panel, tab), - ); - - const cleanedTree = applyCleanupWithFallback( - cleanupNode(treeAfterAdd), - layout.panelTree, - ); - - const focusedPanelId = - layout.focusedPanelId === sourcePanelId - ? targetPanelId - : layout.focusedPanelId; - - return { panelTree: cleanedTree, focusedPanelId }; - }), - ); - }, - - splitPanel: (taskId, tabId, sourcePanelId, targetPanelId, direction) => { - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const sourcePanel = getLeafPanel(layout.panelTree, sourcePanelId); - if (!sourcePanel) return {}; - - const targetPanel = getLeafPanel(layout.panelTree, targetPanelId); - if (!targetPanel) return {}; - - const tab = findTabInPanel(sourcePanel, tabId); - if (!tab) return {}; - - // For same-panel splits with only 1 tab, create a split with a new terminal - // (keep the tab in source, add a new terminal tab to the new panel) - if ( - sourcePanelId === targetPanelId && - targetPanel.content.tabs.length <= 1 - ) { - const singleTabConfig = getSplitConfig(direction); - const newPanelId = generatePanelId(); - const terminalTabId = `shell-${Date.now()}`; - const newPanel: PanelNode = { - type: "leaf", - id: newPanelId, - content: { - id: newPanelId, - tabs: [ - { - id: terminalTabId, - label: "Terminal", - data: { - type: "terminal", - terminalId: terminalTabId, - cwd: "", - }, - component: null, - draggable: true, - closeable: true, - }, - ], - activeTabId: terminalTabId, - showTabs: true, - droppable: true, - }, - }; - - const updatedTree = updateTreeNode( - layout.panelTree, - targetPanelId, - (panel) => ({ - type: "group" as const, - id: generatePanelId(), - direction: singleTabConfig.splitDirection, - sizes: [50, 50], - children: singleTabConfig.isAfter - ? [panel, newPanel] - : [newPanel, panel], - }), - ); - - return { panelTree: updatedTree, focusedPanelId: newPanelId }; - } - - const config = getSplitConfig(direction); - const newPanelId = generatePanelId(); - const newPanel: PanelNode = { - type: "leaf", - id: newPanelId, - content: { - id: newPanelId, - tabs: [tab], - activeTabId: tab.id, - showTabs: true, - droppable: true, - }, - }; - - // Remove tab from source panel - const treeAfterRemove = updateTreeNode( - layout.panelTree, - sourcePanelId, - (panel) => removeTabFromPanel(panel, tabId), - ); - - // Split the target panel - const updatedTree = updateTreeNode( - treeAfterRemove, - targetPanelId, - (panel) => { - const newGroup: PanelNode = { - type: "group", - id: generatePanelId(), - direction: config.splitDirection, - sizes: [50, 50], - children: config.isAfter - ? [panel, newPanel] - : [newPanel, panel], - }; - return newGroup; - }, - ); - - const cleanedTree = applyCleanupWithFallback( - cleanupNode(updatedTree), - layout.panelTree, - ); - - return { panelTree: cleanedTree }; - }), - ); - }, - - updateSizes: (taskId, groupId, sizes) => { - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const updatedTree = updateTreeNode( - layout.panelTree, - groupId, - (node) => { - if (node.type !== "group") return node; - return { ...node, sizes }; - }, - ); - - return { panelTree: updatedTree }; - }), - ); - }, - - updateTabMetadata: (taskId, tabId, metadata) => { - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const tabLocation = findTabInTree(layout.panelTree, tabId); - if (!tabLocation) return {}; - - const updatedTree = updateTreeNode( - layout.panelTree, - tabLocation.panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const updatedTabs = panel.content.tabs.map((tab) => - tab.id === tabId ? { ...tab, ...metadata } : tab, - ); - - return { - ...panel, - content: { - ...panel.content, - tabs: updatedTabs, - }, - }; - }, - ); - - return { panelTree: updatedTree }; - }), - ); - }, - - updateTabLabel: (taskId, tabId, label) => { - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const tabLocation = findTabInTree(layout.panelTree, tabId); - if (!tabLocation) return {}; - - const updatedTree = updateTreeNode( - layout.panelTree, - tabLocation.panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const updatedTabs = panel.content.tabs.map((tab) => - tab.id === tabId ? { ...tab, label } : tab, - ); - - return { - ...panel, - content: { - ...panel.content, - tabs: updatedTabs, - }, - }; - }, - ); - - return { panelTree: updatedTree }; - }), - ); - }, - - setFocusedPanel: (taskId, panelId) => { - set((state) => - updateTaskLayout(state, taskId, () => ({ - focusedPanelId: panelId, - })), - ); - }, - - addTerminalTab: (taskId, panelId) => { - const tabId = `shell-${Date.now()}`; - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const updatedTree = updateTreeNode( - layout.panelTree, - panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - return addTabToPanel(panel, { - id: tabId, - label: "Terminal", - data: { type: "terminal", terminalId: tabId, cwd: "" }, - component: null, - draggable: true, - closeable: true, - }); - }, - ); - - return { panelTree: updatedTree }; - }), - ); - }, - - addActionTab: (taskId, panelId, action) => { - const tabId = `action-${action.actionId}`; - set((state) => - updateTaskLayout(state, taskId, (layout) => { - const existingTab = findTabInTree(layout.panelTree, tabId); - if (existingTab) return {}; - - const targetPanel = getLeafPanel(layout.panelTree, panelId); - if (!targetPanel) return {}; - - const updatedTree = updateTreeNode( - layout.panelTree, - panelId, - (panel) => { - if (panel.type !== "leaf") return panel; - - const newTab: Tab = { - id: tabId, - label: action.label, - data: { - type: "action", - actionId: action.actionId, - command: action.command, - cwd: action.cwd, - label: action.label, - }, - component: null, - draggable: true, - closeable: true, - }; - - return { - ...panel, - content: { - ...panel.content, - tabs: [...panel.content.tabs, newTab], - }, - }; - }, - ); - - return { panelTree: updatedTree }; - }), - ); - }, - - clearAllLayouts: () => { - set({ taskLayouts: {} }); - }, - }), - { - name: "panel-layout-store", - // Bump this version when the default panel structure changes to reset all layouts - version: 10, - migrate: () => ({ taskLayouts: {} }), - }, - ), -); diff --git a/apps/code/src/renderer/features/panels/store/panelStore.ts b/apps/code/src/renderer/features/panels/store/panelStore.ts deleted file mode 100644 index 4c23d17113..0000000000 --- a/apps/code/src/renderer/features/panels/store/panelStore.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { create } from "zustand"; -import { - addTabToPanel, - cleanupNode, - findTabInPanel, - isLeaf, - removeTabFromPanel, - setActiveTabInPanel, - updateTreeNode, -} from "./panelTree"; -import type { - GroupId, - PanelId, - PanelNode, - SplitDirection, - TabId, -} from "./panelTypes"; -import { calculateSplitSizes } from "./panelUtils"; - -interface DragState { - draggingTabId: TabId | null; - draggingTabPanelId: PanelId | null; -} - -interface TreeState { - root: PanelNode | null; - idCounter: number; -} - -interface TreeActions { - setRoot: (root: PanelNode) => void; - findPanel: (id: PanelId, node?: PanelNode) => PanelNode | null; - cleanupTree: () => void; -} - -interface TabActions { - setDraggingTab: (tabId: TabId | null, panelId: PanelId | null) => void; - moveTab: ( - tabId: TabId, - sourcePanelId: PanelId, - targetPanelId: PanelId, - ) => void; - setActiveTab: (panelId: PanelId, tabId: TabId) => void; - closeTab: (panelId: PanelId, tabId: TabId) => void; - reorderTabs: ( - panelId: PanelId, - sourceIndex: number, - targetIndex: number, - ) => void; -} - -interface PanelActions { - splitPanel: ( - tabId: TabId, - sourcePanelId: PanelId, - targetPanelId: PanelId, - direction: SplitDirection, - ) => void; - updateSizes: (groupId: GroupId, sizes: number[]) => void; -} - -type PanelStore = TreeState & - DragState & - TreeActions & - TabActions & - PanelActions; - -export const usePanelStore = create((set, get) => { - const generateId = (prefix: string): string => { - const id = `${prefix}-gen-${get().idCounter}`; - set((state) => ({ idCounter: state.idCounter + 1 })); - return id; - }; - - const setRootWithCleanup = (root: PanelNode | null) => { - set({ root: root ? cleanupNode(root) : null }); - }; - - const getLeafPanel = ( - panelId: PanelId, - ): Extract | null => { - const panel = get().findPanel(panelId); - return isLeaf(panel) ? panel : null; - }; - - return { - root: null, - draggingTabId: null, - draggingTabPanelId: null, - idCounter: 0, - - setRoot: (root) => set({ root }), - - setDraggingTab: (tabId, panelId) => - set({ draggingTabId: tabId, draggingTabPanelId: panelId }), - - findPanel: (id, node) => { - const searchNode = node ?? get().root; - if (!searchNode) return null; - if (searchNode.id === id) return searchNode; - - if (searchNode.type === "group") { - for (const child of searchNode.children) { - const found = get().findPanel(id, child); - if (found) return found; - } - } - - return null; - }, - - moveTab: (tabId, sourcePanelId, targetPanelId) => { - const { root } = get(); - if (!root || sourcePanelId === targetPanelId) return; - - const sourcePanel = getLeafPanel(sourcePanelId); - const targetPanel = getLeafPanel(targetPanelId); - if (!sourcePanel || !targetPanel) return; - - const tabToMove = findTabInPanel(sourcePanel, tabId); - if (!tabToMove) return; - - const updatedRoot = updateTreeNode( - updateTreeNode(root, sourcePanelId, (node) => - removeTabFromPanel(node, tabId), - ), - targetPanelId, - (node) => addTabToPanel(node, tabToMove), - ); - - setRootWithCleanup(updatedRoot); - }, - - setActiveTab: (panelId, tabId) => { - const { root } = get(); - if (!root) return; - - set({ - root: updateTreeNode(root, panelId, (node) => - setActiveTabInPanel(node, tabId), - ), - }); - }, - - splitPanel: (tabId, sourcePanelId, targetPanelId, direction) => { - const { root } = get(); - if (!root) return; - - const sourcePanel = getLeafPanel(sourcePanelId); - if (!sourcePanel) return; - - const tabToMove = findTabInPanel(sourcePanel, tabId); - if (!tabToMove) return; - - const isVerticalSplit = direction === "top" || direction === "bottom"; - const newPanelFirst = direction === "top" || direction === "left"; - const splitSizes = calculateSplitSizes(); - - const newPanel: PanelNode = { - type: "leaf", - id: generateId("panel"), - content: { - id: generateId("panel"), - tabs: [tabToMove], - activeTabId: tabToMove.id, - showTabs: true, - droppable: true, - }, - }; - - const updateInNode = (node: PanelNode): PanelNode => { - if (node.id === targetPanelId && isLeaf(node)) { - const targetNode = - sourcePanelId === targetPanelId - ? removeTabFromPanel(node, tabId) - : node; - - const children = newPanelFirst - ? [newPanel, targetNode] - : [targetNode, newPanel]; - - const sizes = newPanelFirst - ? splitSizes - : [splitSizes[1], splitSizes[0]]; - - return { - type: "group", - id: generateId("group"), - direction: isVerticalSplit ? "vertical" : "horizontal", - children, - sizes, - }; - } - - if ( - node.id === sourcePanelId && - isLeaf(node) && - sourcePanelId !== targetPanelId - ) { - return removeTabFromPanel(node, tabId); - } - - if (node.type === "group") { - return { ...node, children: node.children.map(updateInNode) }; - } - - return node; - }; - - setRootWithCleanup(updateInNode(root)); - }, - - closeTab: (panelId, tabId) => { - const { root } = get(); - if (!root) return; - - setRootWithCleanup( - updateTreeNode(root, panelId, (node) => - removeTabFromPanel(node, tabId), - ), - ); - }, - - cleanupTree: () => { - const { root } = get(); - if (!root) return; - - set({ root: cleanupNode(root) }); - }, - - updateSizes: (groupId, sizes) => { - const { root } = get(); - if (!root) return; - - set({ - root: updateTreeNode(root, groupId, (node) => { - if (node.type !== "group") return node; - return { ...node, sizes }; - }), - }); - }, - - reorderTabs: (panelId, sourceIndex, targetIndex) => { - const { root } = get(); - if (!root) return; - - set({ - root: updateTreeNode(root, panelId, (node) => { - if (!isLeaf(node)) return node; - - const newTabs = [...node.content.tabs]; - const [movedTab] = newTabs.splice(sourceIndex, 1); - newTabs.splice(targetIndex, 0, movedTab); - - return { - ...node, - content: { ...node.content, tabs: newTabs }, - }; - }), - }); - }, - }; -}); - -export type { - PanelContent, - PanelNode, - SplitDirection, - Tab, -} from "./panelTypes"; diff --git a/apps/code/src/renderer/features/panels/utils/panelLayoutUtils.ts b/apps/code/src/renderer/features/panels/utils/panelLayoutUtils.ts deleted file mode 100644 index 3f45bbacd7..0000000000 --- a/apps/code/src/renderer/features/panels/utils/panelLayoutUtils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { PANEL_SIZES } from "../constants/panelConstants"; -import type { GroupPanel } from "../store/panelTypes"; - -export function calculateDefaultSize(node: GroupPanel, index: number): number { - return node.sizes?.[index] ?? 100 / node.children.length; -} - -export function shouldUpdateSizes( - currentSizes: number[], - storeSizes: number[], -): boolean { - if (currentSizes.length !== storeSizes.length) { - return false; - } - - return currentSizes.some( - (size, i) => - Math.abs(size - storeSizes[i]) > PANEL_SIZES.SIZE_DIFF_THRESHOLD, - ); -} diff --git a/apps/code/src/renderer/features/provisioning/components/ProvisioningView.tsx b/apps/code/src/renderer/features/provisioning/components/ProvisioningView.tsx deleted file mode 100644 index ba6c418fd4..0000000000 --- a/apps/code/src/renderer/features/provisioning/components/ProvisioningView.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { BackgroundWrapper } from "@components/BackgroundWrapper"; -import { Box, Flex, Spinner, Text } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc/client"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { useEffect, useRef, useState } from "react"; - -interface ProvisioningViewProps { - taskId: string; -} - -// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is required to strip ANSI sequences -const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g; - -function stripAnsi(text: string): string { - return text.replace(ANSI_RE, ""); -} - -function processOutput(lines: string[], chunk: string): string[] { - const next = [...lines]; - const parts = chunk.split("\n"); - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const crSegments = part.split("\r"); - const lastSegment = crSegments[crSegments.length - 1]; - - if (i === 0 && next.length > 0) { - if (crSegments.length > 1) { - next[next.length - 1] = lastSegment; - } else { - next[next.length - 1] += lastSegment; - } - } else { - next.push(lastSegment); - } - } - - return next; -} - -export function ProvisioningView({ taskId }: ProvisioningViewProps) { - const trpc = useTRPC(); - const [lines, setLines] = useState([]); - const scrollRef = useRef(null); - - useSubscription( - trpc.provisioning.onOutput.subscriptionOptions(undefined, { - onData: (data) => { - if (data.taskId !== taskId) return; - setLines((prev) => processOutput(prev, stripAnsi(data.data))); - }, - }), - ); - - useEffect(() => { - const el = scrollRef.current; - if (el) { - el.scrollTop = el.scrollHeight; - } - }, []); - - return ( - - - - - - Setting up worktree... - - - -
-            {lines.join("\n")}
-          
-
-
-
- ); -} diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts deleted file mode 100644 index 764c2af788..0000000000 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useConnectivity } from "@hooks/useConnectivity"; -import { trpcClient } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; -import { useEffect } from "react"; -import { getSessionService } from "../service/service"; -import { type AgentSession, sessionStoreSetters } from "../stores/sessionStore"; -import { useChatTitleGenerator } from "./useChatTitleGenerator"; - -const log = logger.scope("session-connection"); - -const connectingTasks = new Set(); -const activityRecorded = new Set(); - -interface UseSessionConnectionOptions { - taskId: string; - task: Task; - session: AgentSession | undefined; - repoPath: string | null; - isCloud: boolean; - isSuspended?: boolean; -} - -export function useSessionConnection({ - taskId, - task, - session, - repoPath, - isCloud, - isSuspended, -}: UseSessionConnectionOptions) { - const queryClient = useQueryClient(); - const { isOnline } = useConnectivity(); - const cloudAuthState = useAuthStateValue((state) => state); - - useChatTitleGenerator(task); - - useEffect(() => { - const taskRunId = session?.taskRunId; - if (!taskRunId) return; - if (!activityRecorded.has(taskRunId)) { - activityRecorded.add(taskRunId); - trpcClient.agent.recordActivity.mutate({ taskRunId }).catch(() => {}); - } - const heartbeat = setInterval( - () => { - trpcClient.agent.recordActivity.mutate({ taskRunId }).catch(() => {}); - }, - 5 * 60 * 1000, - ); - return () => { - clearInterval(heartbeat); - activityRecorded.delete(taskRunId); - }; - }, [session?.taskRunId]); - - useEffect(() => { - if (!isCloud) return; - getSessionService().updateSessionTaskTitle( - task.id, - task.title || task.description || "Cloud Task", - ); - }, [isCloud, task.id, task.title, task.description]); - - useEffect(() => { - if (!isCloud || !task.latest_run?.id) return; - if (cloudAuthState.status !== "authenticated") return; - if (!cloudAuthState.bootstrapComplete) return; - if (!cloudAuthState.projectId || !cloudAuthState.cloudRegion) return; - - const runId = task.latest_run.id; - const initialMode = - typeof task.latest_run.state?.initial_permission_mode === "string" - ? task.latest_run.state.initial_permission_mode - : undefined; - const adapter = - task.latest_run.runtime_adapter === "codex" ? "codex" : "claude"; - const initialModel = task.latest_run.model ?? undefined; - const cleanup = getSessionService().watchCloudTask( - task.id, - runId, - getCloudUrlFromRegion(cloudAuthState.cloudRegion), - cloudAuthState.projectId, - () => { - queryClient.invalidateQueries({ queryKey: ["tasks"] }); - }, - task.latest_run?.log_url, - initialMode, - adapter, - initialModel, - task.description ?? undefined, - ); - return cleanup; - }, [ - cloudAuthState.bootstrapComplete, - cloudAuthState.cloudRegion, - cloudAuthState.projectId, - cloudAuthState.status, - isCloud, - queryClient, - task.id, - task.latest_run?.id, - task.latest_run?.log_url, - task.latest_run?.model, - task.latest_run?.runtime_adapter, - task.latest_run?.state?.initial_permission_mode, - task.description, - ]); - - useEffect(() => { - if (!repoPath) return; - if (connectingTasks.has(taskId)) return; - if (!isOnline) return; - if (isCloud || session?.isCloud) return; - if (isSuspended) return; - - if (session?.status === "error" && session?.idleKilled) { - const taskRunId = session.taskRunId; - connectingTasks.add(taskId); - getSessionService() - .clearSessionError(taskId, repoPath) - .catch((error) => { - log.error("Auto-reconnect after idle kill failed", error); - sessionStoreSetters.updateSession(taskRunId, { - idleKilled: false, - errorMessage: - "Session disconnected due to inactivity. Click Retry to reconnect.", - }); - }) - .finally(() => { - connectingTasks.delete(taskId); - }); - return () => { - connectingTasks.delete(taskId); - }; - } - - if ( - session?.status === "connected" || - session?.status === "connecting" || - session?.status === "error" - ) { - return; - } - - // New sessions (no latest_run) are handled by the task creation saga, - // which passes model/adapter/executionMode. Only reconnect existing ones here. - if (!task.latest_run?.id) return; - - connectingTasks.add(taskId); - - getSessionService() - .connectToTask({ - task, - repoPath, - }) - .finally(() => { - connectingTasks.delete(taskId); - }); - - return () => { - connectingTasks.delete(taskId); - }; - }, [task, taskId, repoPath, session, isOnline, isCloud, isSuspended]); - - const cannotConnect = !repoPath && !isCloud; - useEffect(() => { - if (!cannotConnect) return; - if (session && session.events.length > 0) return; - if (!task.latest_run?.id || !task.latest_run?.log_url) return; - - getSessionService().loadLogsOnly({ - taskId: task.id, - taskRunId: task.latest_run.id, - taskTitle: task.title || task.description || "Task", - logUrl: task.latest_run.log_url, - }); - }, [ - cannotConnect, - task.id, - task.latest_run?.id, - task.latest_run?.log_url, - task.title, - task.description, - session, - ]); -} diff --git a/apps/code/src/renderer/features/sessions/service/localHandoffService.ts b/apps/code/src/renderer/features/sessions/service/localHandoffService.ts deleted file mode 100644 index e7307bbed8..0000000000 --- a/apps/code/src/renderer/features/sessions/service/localHandoffService.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { trpcClient } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; -import { logger } from "@utils/logger"; -import { toast } from "@utils/toast"; -import { useHandoffDialogStore } from "../stores/handoffDialogStore"; -import { getSessionService } from "./service"; - -const log = logger.scope("local-handoff-service"); - -async function resolveRepoPathFromRemote( - remoteUrl: string | undefined | null, -): Promise { - if (!remoteUrl) return null; - const repo = await trpcClient.folders.getRepositoryByRemoteUrl.query({ - remoteUrl, - }); - return repo?.path ?? null; -} - -async function resolveRepoPathFromPicker( - remoteUrl: string | null | undefined, -): Promise { - const selectedPath = await trpcClient.os.selectDirectory.query(); - if (!selectedPath) return null; - - await trpcClient.folders.addFolder.mutate({ - folderPath: selectedPath, - remoteUrl: remoteUrl ?? undefined, - }); - - return selectedPath; -} - -let serviceInstance: LocalHandoffService | null = null; - -export function getLocalHandoffService(): LocalHandoffService { - if (!serviceInstance) { - serviceInstance = new LocalHandoffService(); - } - return serviceInstance; -} - -export class LocalHandoffService { - public openConfirm(taskId: string, branchName: string | null): void { - useHandoffDialogStore - .getState() - .openConfirm(taskId, "to-local", branchName); - } - - public closeConfirm(): void { - useHandoffDialogStore.getState().closeConfirm(); - } - - public cancelPendingFlow(): void { - useHandoffDialogStore.getState().cancelPendingHandoff(); - } - - public hideDirtyTree(): void { - useHandoffDialogStore.getState().hideDirtyTree(); - } - - public getPendingAfterCommit() { - return useHandoffDialogStore.getState().pendingAfterCommit; - } - - public async start(taskId: string, task: Task): Promise { - try { - const targetPath = - (await resolveRepoPathFromRemote(task.repository)) ?? - (await resolveRepoPathFromPicker(task.repository)); - - if (!targetPath) return; - - const preflight = await getSessionService().preflightToLocal( - taskId, - targetPath, - ); - - if (preflight.canHandoff) { - this.closeConfirm(); - await getSessionService().handoffToLocal(taskId, targetPath); - return; - } - - if (preflight.localTreeDirty && preflight.changedFiles) { - useHandoffDialogStore - .getState() - .openDirtyTreeForPendingHandoff(preflight.changedFiles, { - taskId, - repoPath: targetPath, - branchName: preflight.localGitState?.branch ?? null, - }); - return; - } - - toast.error(preflight.reason ?? "Cannot continue locally"); - this.closeConfirm(); - } catch (error) { - log.error("Failed to hand off to local", error); - const message = error instanceof Error ? error.message : "Unknown error"; - toast.error(`Failed to continue locally: ${message}`); - this.closeConfirm(); - } - } - - public async resumePending(): Promise { - const pending = this.getPendingAfterCommit(); - if (!pending) return; - - useHandoffDialogStore.getState().clearPendingAfterCommit(); - - try { - await getSessionService().handoffToLocal( - pending.taskId, - pending.repoPath, - ); - } catch (error) { - log.error("Failed to resume handoff to local", error); - const message = error instanceof Error ? error.message : "Unknown error"; - toast.error(`Failed to continue locally: ${message}`); - } - } -} diff --git a/apps/code/src/renderer/features/sessions/utils/cloudArtifacts.test.ts b/apps/code/src/renderer/features/sessions/utils/cloudArtifacts.test.ts deleted file mode 100644 index 32804bda50..0000000000 --- a/apps/code/src/renderer/features/sessions/utils/cloudArtifacts.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - fs: { - readFileAsBase64: { - query: vi.fn(), - }, - }, - }, -})); - -import { trpcClient } from "@renderer/trpc/client"; - -import { - CLOUD_ATTACHMENT_MAX_SIZE_BYTES, - CLOUD_PDF_ATTACHMENT_MAX_SIZE_BYTES, - combineQueuedCloudPrompts, - promptToQueuedEditorContent, - uploadRunAttachments, -} from "./cloudArtifacts"; - -describe("cloudArtifacts", () => { - it("preserves attachment blocks when combining queued cloud prompts", () => { - const prompt: ContentBlock[] = [ - { type: "text", text: "read this" }, - { - type: "resource_link", - uri: "file:///tmp/test.txt", - name: "test.txt", - mimeType: "text/plain", - }, - ]; - - expect( - combineQueuedCloudPrompts([ - { - content: "read this\n\nAttached files: test.txt", - rawPrompt: prompt, - }, - ]), - ).toEqual(prompt); - }); - - it("rejects attachments that exceed the max size", async () => { - const oversizedByteLength = CLOUD_ATTACHMENT_MAX_SIZE_BYTES + 1; - const base64 = btoa("a".repeat(oversizedByteLength)); - vi.mocked(trpcClient.fs.readFileAsBase64.query).mockResolvedValueOnce( - base64, - ); - - const client = { - prepareTaskRunArtifactUploads: vi.fn(), - finalizeTaskRunArtifactUploads: vi.fn(), - } as never; - - await expect( - uploadRunAttachments(client, "task-1", "run-1", ["/tmp/huge.bin"]), - ).rejects.toThrow(/exceeds the 30MB attachment limit/); - }); - - it("rejects PDFs that exceed the stricter cloud limit", async () => { - const oversizedByteLength = CLOUD_PDF_ATTACHMENT_MAX_SIZE_BYTES + 1; - const base64 = btoa("a".repeat(oversizedByteLength)); - vi.mocked(trpcClient.fs.readFileAsBase64.query).mockResolvedValueOnce( - base64, - ); - - const client = { - prepareTaskRunArtifactUploads: vi.fn(), - finalizeTaskRunArtifactUploads: vi.fn(), - } as never; - - await expect( - uploadRunAttachments(client, "task-1", "run-1", ["/tmp/large.pdf"]), - ).rejects.toThrow( - /exceeds the 10MB attachment limit for PDFs in cloud runs/, - ); - }); - - it("restores queued editor content with attachments from prompt blocks", () => { - const prompt: ContentBlock[] = [ - { type: "text", text: "read this" }, - { - type: "resource_link", - uri: "file:///tmp/test.txt", - name: "test.txt", - mimeType: "text/plain", - }, - ]; - - expect(promptToQueuedEditorContent(prompt)).toEqual({ - segments: [{ type: "text", text: "read this" }], - attachments: [{ id: "/tmp/test.txt", label: "test.txt" }], - }); - }); -}); diff --git a/apps/code/src/renderer/features/sessions/utils/cloudArtifacts.ts b/apps/code/src/renderer/features/sessions/utils/cloudArtifacts.ts deleted file mode 100644 index 2f23c59b7b..0000000000 --- a/apps/code/src/renderer/features/sessions/utils/cloudArtifacts.ts +++ /dev/null @@ -1,409 +0,0 @@ -import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { - buildCloudTaskDescription, - getAbsoluteAttachmentPaths, - stripAbsoluteFileTags, -} from "@features/editor/utils/cloud-prompt"; -import type { - PostHogAPIClient, - PreparedTaskArtifactUpload, - TaskArtifactUploadRequest, -} from "@renderer/api/posthogClient"; -import { trpcClient } from "@renderer/trpc/client"; -import { getFileName, pathToFileUri } from "@utils/path"; -import type { EditorContent } from "../../message-editor/utils/content"; - -const FILE_URI_PREFIX = "file://"; -const ATTACHMENT_SOURCE = "posthog_code"; -const DEFAULT_CONTENT_TYPE = "application/octet-stream"; -export const CLOUD_ATTACHMENT_MAX_SIZE_BYTES = 30 * 1024 * 1024; -export const CLOUD_PDF_ATTACHMENT_MAX_SIZE_BYTES = 10 * 1024 * 1024; - -const CONTENT_TYPE_BY_EXTENSION: Record = { - bmp: "image/bmp", - c: "text/plain", - cc: "text/plain", - conf: "text/plain", - cpp: "text/plain", - css: "text/css", - csv: "text/csv", - gif: "image/gif", - go: "text/plain", - h: "text/plain", - html: "text/html", - ini: "text/plain", - java: "text/plain", - jpeg: "image/jpeg", - jpg: "image/jpeg", - js: "text/javascript", - json: "application/json", - jsx: "text/javascript", - log: "text/plain", - md: "text/markdown", - pdf: "application/pdf", - png: "image/png", - py: "text/x-python", - rb: "text/plain", - rs: "text/plain", - sh: "text/x-shellscript", - sql: "application/sql", - svg: "image/svg+xml", - toml: "application/toml", - ts: "text/typescript", - tsx: "text/typescript", - txt: "text/plain", - webp: "image/webp", - xml: "application/xml", - yaml: "application/yaml", - yml: "application/yaml", - zip: "application/zip", -}; - -interface LoadedCloudAttachment { - filePath: string; - bytes: Uint8Array; - upload: TaskArtifactUploadRequest; -} - -export interface CloudPromptTransport { - filePaths: string[]; - messageText?: string; - promptText: string; -} - -export type QueuedCloudPrompt = string | ContentBlock[]; - -function base64ToUint8Array(base64: string): Uint8Array { - const binary = atob(base64); - const bytes = new Uint8Array(new ArrayBuffer(binary.length)); - - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - - return bytes; -} - -function getFileExtension(filePath: string): string { - const parts = getFileName(filePath).split("."); - return parts.length > 1 ? (parts.at(-1)?.toLowerCase() ?? "") : ""; -} - -function inferContentType(filePath: string): string { - return ( - CONTENT_TYPE_BY_EXTENSION[getFileExtension(filePath)] ?? - DEFAULT_CONTENT_TYPE - ); -} - -function getCloudAttachmentMaxSizeBytes( - filePath: string, - contentType: string, -): number { - const extension = getFileExtension(filePath); - const normalizedContentType = - contentType.split(";")[0]?.trim().toLowerCase() ?? ""; - - if (extension === "pdf" || normalizedContentType === "application/pdf") { - return CLOUD_PDF_ATTACHMENT_MAX_SIZE_BYTES; - } - - return CLOUD_ATTACHMENT_MAX_SIZE_BYTES; -} - -function getCloudAttachmentSizeError( - filePath: string, - maxSizeBytes: number, -): string { - const maxMb = Math.floor(maxSizeBytes / (1024 * 1024)); - - if (getFileExtension(filePath) === "pdf") { - return `${getFileName(filePath)} exceeds the ${maxMb}MB attachment limit for PDFs in cloud runs`; - } - - return `${getFileName(filePath)} exceeds the ${maxMb}MB attachment limit`; -} - -function decodeFileUri(uri: string): string | null { - if (!uri.startsWith(FILE_URI_PREFIX)) { - return null; - } - - const encodedPath = uri.slice(FILE_URI_PREFIX.length); - const normalizedPath = encodedPath.startsWith("/") - ? encodedPath - : `/${encodedPath}`; - - try { - return normalizedPath - .split("/") - .map((segment, index) => - index === 0 && segment === "" ? segment : decodeURIComponent(segment), - ) - .join("/"); - } catch { - return null; - } -} - -function collectBlockAttachmentPaths(prompt: ContentBlock[]): string[] { - const filePaths = prompt - .map((block) => { - if (block.type === "resource_link") { - return decodeFileUri(block.uri); - } - - if (block.type === "resource") { - return block.resource.uri ? decodeFileUri(block.resource.uri) : null; - } - - if (block.type === "image") { - return block.uri ? decodeFileUri(block.uri) : null; - } - - return null; - }) - .filter((value): value is string => Boolean(value)); - - return Array.from(new Set(filePaths)); -} - -function summarizePrompt(text: string, filePaths: string[]): string { - if (filePaths.length === 0) { - return text.trim(); - } - - const attachmentSummary = `Attached files: ${filePaths.map(getFileName).join(", ")}`; - return text.trim() - ? `${text.trim()}\n\n${attachmentSummary}` - : attachmentSummary; -} - -async function loadCloudAttachments( - filePaths: string[], -): Promise { - return Promise.all( - filePaths.map(async (filePath) => { - const base64 = await trpcClient.fs.readFileAsBase64.query({ filePath }); - if (!base64) { - throw new Error( - `Unable to read attached file ${getFileName(filePath)}`, - ); - } - - const bytes = base64ToUint8Array(base64); - const contentType = inferContentType(filePath); - const maxSizeBytes = getCloudAttachmentMaxSizeBytes( - filePath, - contentType, - ); - if (bytes.byteLength > maxSizeBytes) { - throw new Error(getCloudAttachmentSizeError(filePath, maxSizeBytes)); - } - return { - filePath, - bytes, - upload: { - name: getFileName(filePath), - type: "user_attachment", - source: ATTACHMENT_SOURCE, - size: bytes.byteLength, - content_type: contentType, - }, - }; - }), - ); -} - -async function uploadPreparedArtifacts( - attachments: LoadedCloudAttachment[], - preparedArtifacts: PreparedTaskArtifactUpload[], -): Promise { - if (attachments.length !== preparedArtifacts.length) { - throw new Error("Prepared uploads do not match the selected attachments"); - } - - await Promise.all( - preparedArtifacts.map(async (preparedArtifact, index) => { - const attachment = attachments[index]; - const formData = new FormData(); - - for (const [key, value] of Object.entries( - preparedArtifact.presigned_post.fields, - )) { - formData.append(key, value); - } - - formData.append( - "file", - new Blob([attachment.bytes], { - type: attachment.upload.content_type || DEFAULT_CONTENT_TYPE, - }), - attachment.upload.name, - ); - - const response = await fetch(preparedArtifact.presigned_post.url, { - method: "POST", - body: formData, - }); - - if (!response.ok) { - throw new Error(`Failed to upload ${attachment.upload.name}`); - } - }), - ); -} - -export function getCloudPromptTransport( - prompt: string | ContentBlock[], - filePaths: string[] = [], -): CloudPromptTransport { - if (typeof prompt === "string") { - const attachmentPaths = getAbsoluteAttachmentPaths(prompt, filePaths); - const messageText = stripAbsoluteFileTags(prompt).trim(); - - return { - filePaths: attachmentPaths, - messageText: messageText || undefined, - promptText: buildCloudTaskDescription(prompt, filePaths).trim(), - }; - } - - const promptText = prompt - .filter( - (block): block is Extract => - block.type === "text", - ) - .map((block) => block.text) - .join("") - .trim(); - const attachmentPaths = collectBlockAttachmentPaths(prompt); - - return { - filePaths: attachmentPaths, - messageText: promptText || undefined, - promptText: summarizePrompt(promptText, attachmentPaths), - }; -} - -export function cloudPromptToBlocks(prompt: QueuedCloudPrompt): ContentBlock[] { - if (typeof prompt !== "string") { - return prompt; - } - - const transport = getCloudPromptTransport(prompt); - const blocks: ContentBlock[] = []; - - if (transport.messageText) { - blocks.push({ type: "text", text: transport.messageText }); - } - - for (const filePath of transport.filePaths) { - blocks.push({ - type: "resource_link", - uri: pathToFileUri(filePath), - name: getFileName(filePath), - }); - } - - return blocks; -} - -export async function uploadTaskStagedAttachments( - client: PostHogAPIClient, - taskId: string, - filePaths: string[], -): Promise { - if (!filePaths.length) { - return []; - } - - const attachments = await loadCloudAttachments(filePaths); - const preparedArtifacts = await client.prepareTaskStagedArtifactUploads( - taskId, - attachments.map((attachment) => attachment.upload), - ); - - await uploadPreparedArtifacts(attachments, preparedArtifacts); - - const finalizedArtifacts = await client.finalizeTaskStagedArtifactUploads( - taskId, - preparedArtifacts, - ); - - return finalizedArtifacts.map((artifact) => artifact.id); -} - -export async function uploadRunAttachments( - client: PostHogAPIClient, - taskId: string, - runId: string, - filePaths: string[], -): Promise { - if (!filePaths.length) { - return []; - } - - const attachments = await loadCloudAttachments(filePaths); - const preparedArtifacts = await client.prepareTaskRunArtifactUploads( - taskId, - runId, - attachments.map((attachment) => attachment.upload), - ); - - await uploadPreparedArtifacts(attachments, preparedArtifacts); - - const finalizedArtifacts = await client.finalizeTaskRunArtifactUploads( - taskId, - runId, - preparedArtifacts, - ); - - return finalizedArtifacts.map((artifact) => artifact.id); -} - -export function promptToQueuedEditorContent( - prompt: QueuedCloudPrompt, -): EditorContent { - const transport = getCloudPromptTransport(prompt); - const attachments = transport.filePaths.map((filePath) => ({ - id: filePath, - label: getFileName(filePath), - })); - const text = - typeof prompt === "string" - ? stripAbsoluteFileTags(prompt) - : (transport.messageText ?? ""); - - return { - segments: [{ type: "text", text }], - ...(attachments.length > 0 ? { attachments } : {}), - }; -} - -export function combineQueuedCloudPrompts( - queuedPrompts: Array<{ content: string; rawPrompt?: QueuedCloudPrompt }>, -): QueuedCloudPrompt | null { - if (queuedPrompts.length === 0) { - return null; - } - - const blocks: ContentBlock[] = []; - - for (const [index, queuedPrompt] of queuedPrompts.entries()) { - const promptBlocks = cloudPromptToBlocks( - queuedPrompt.rawPrompt ?? queuedPrompt.content, - ); - if (promptBlocks.length === 0) { - continue; - } - - if (index > 0 && blocks.length > 0) { - blocks.push({ type: "text", text: "\n\n" }); - } - - blocks.push(...promptBlocks); - } - - return blocks.length > 0 ? blocks : null; -} diff --git a/apps/code/src/renderer/features/sessions/utils/parseSessionLogs.ts b/apps/code/src/renderer/features/sessions/utils/parseSessionLogs.ts deleted file mode 100644 index 91d88bbf7d..0000000000 --- a/apps/code/src/renderer/features/sessions/utils/parseSessionLogs.ts +++ /dev/null @@ -1,224 +0,0 @@ -/// - -import { - CLIENT_METHODS, - type RequestPermissionRequest, - type SessionConfigOption, - type SessionNotification, -} from "@agentclientprotocol/sdk"; -import { trpcClient } from "@renderer/trpc"; -import type { StoredLogEntry as BaseStoredLogEntry } from "@shared/types/session-events"; - -export interface StoredLogEntry extends BaseStoredLogEntry { - direction?: "client" | "agent"; -} - -export interface ParsedSessionLogs { - notifications: SessionNotification[]; - rawEntries: StoredLogEntry[]; - sessionId?: string; - adapter?: "claude" | "codex"; - configOptions?: SessionConfigOption[]; -} - -/** - * Fetch and parse session logs from S3. - * Returns both parsed SessionNotifications and raw log entries. - */ -export async function fetchSessionLogs( - logUrl: string, -): Promise { - if (!logUrl) { - return { notifications: [], rawEntries: [] }; - } - - try { - const content = await trpcClient.logs.fetchS3Logs.query({ logUrl }); - if (!content?.trim()) { - return { notifications: [], rawEntries: [] }; - } - - const notifications: SessionNotification[] = []; - const rawEntries: StoredLogEntry[] = []; - let sessionId: string | undefined; - let adapter: "claude" | "codex" | undefined; - let configOptions: SessionConfigOption[] | undefined; - - for (const line of content.trim().split("\n")) { - try { - const stored = JSON.parse(line) as StoredLogEntry; - if (!stored.notification) { - const maybeMsg = stored as unknown as { - id?: number; - method?: string; - params?: unknown; - result?: unknown; - error?: unknown; - }; - if ( - typeof maybeMsg === "object" && - maybeMsg !== null && - ("method" in maybeMsg || - "result" in maybeMsg || - "error" in maybeMsg || - "id" in maybeMsg) - ) { - stored.notification = maybeMsg; - } - } - - const msg = stored.notification; - if (msg) { - const hasId = msg.id !== undefined; - const hasMethod = msg.method !== undefined; - const hasResult = msg.result !== undefined || msg.error !== undefined; - - if (hasId && hasMethod) { - stored.direction = "client"; - } else if (hasId && hasResult) { - stored.direction = "agent"; - } else if (hasMethod && !hasId) { - stored.direction = "agent"; - } - } - - rawEntries.push(stored); - - if ( - stored.type === "notification" && - stored.notification?.method === "session/update" && - stored.notification?.params - ) { - notifications.push(stored.notification.params as SessionNotification); - - const params = stored.notification.params as { - update?: { - sessionUpdate?: string; - configOptions?: SessionConfigOption[]; - }; - }; - if (params.update?.sessionUpdate === "config_option_update") { - configOptions = params.update.configOptions; - } - } - - if ( - stored.type === "notification" && - stored.notification?.method?.endsWith("posthog/sdk_session") && - stored.notification?.params - ) { - const params = stored.notification.params as { - sessionId?: string; - sdkSessionId?: string; - adapter?: "claude" | "codex"; - }; - if (params.sessionId) { - sessionId = params.sessionId; - } else if (params.sdkSessionId) { - sessionId = params.sdkSessionId; - } - if (params.adapter) { - adapter = params.adapter; - } - } - } catch { - // Skip malformed lines - } - } - - return { notifications, rawEntries, sessionId, adapter, configOptions }; - } catch { - return { notifications: [], rawEntries: [] }; - } -} - -export type PermissionRequest = Omit & { - taskRunId: string; - receivedAt: number; -}; - -type SessionUpdate = { - sessionUpdate?: string; - toolCallId?: string; - status?: string; -}; - -type NotificationMsg = StoredLogEntry["notification"]; - -function getSessionUpdate(msg: NotificationMsg): SessionUpdate | null { - if (msg?.method !== "session/update") return null; - return (msg.params as { update?: SessionUpdate })?.update ?? null; -} - -function getPermissionToolCallId(msg: NotificationMsg): string | null { - if (msg?.method !== CLIENT_METHODS.session_request_permission) return null; - return (msg.params as RequestPermissionRequest)?.toolCall?.toolCallId ?? null; -} - -function isTerminalStatus(status?: string): boolean { - return ( - status === "in_progress" || status === "completed" || status === "failed" - ); -} - -/** - * Scan log entries to find pending permission requests. - * A permission is pending if: - * 1. We have a session/request_permission for a toolCallId - * 2. No subsequent tool_call_update - * 3. No assistant messages after the permission request (conversation hasn't moved on) - */ -export function findPendingPermissions( - entries: StoredLogEntry[], -): Map { - const permissionRequests = new Map< - string, - { entry: StoredLogEntry; index: number } - >(); - const resolvedToolCalls = new Set(); - let lastAssistantMessageIndex = -1; - - entries.forEach((entry, i) => { - const msg = entry.notification; - - const permissionToolCallId = getPermissionToolCallId(msg); - if (permissionToolCallId) { - permissionRequests.set(permissionToolCallId, { entry, index: i }); - } - - const update = getSessionUpdate(msg); - if (!update) return; - - const isResolvedToolCall = - update.sessionUpdate === "tool_call_update" && - update.toolCallId && - isTerminalStatus(update.status); - - if (isResolvedToolCall && update.toolCallId) { - resolvedToolCalls.add(update.toolCallId); - } - - if (update.sessionUpdate === "assistant_message") { - lastAssistantMessageIndex = i; - } - }); - - const pending = new Map(); - for (const [toolCallId, { entry, index }] of permissionRequests) { - const isResolved = resolvedToolCalls.has(toolCallId); - const isStale = lastAssistantMessageIndex > index; - if (isResolved || isStale) continue; - - const params = entry.notification?.params as RequestPermissionRequest; - const { sessionId, ...rest } = params; - pending.set(toolCallId, { - ...rest, - taskRunId: sessionId, - receivedAt: entry.timestamp - ? new Date(entry.timestamp).getTime() - : Date.now(), - }); - } - - return pending; -} diff --git a/apps/code/src/renderer/features/settings/components/sections/ShortcutsSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/ShortcutsSettings.tsx deleted file mode 100644 index 4dd853c992..0000000000 --- a/apps/code/src/renderer/features/settings/components/sections/ShortcutsSettings.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { KeyboardShortcutsList } from "@components/KeyboardShortcutsSheet"; - -export function ShortcutsSettings() { - return ; -} diff --git a/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts b/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts deleted file mode 100644 index 4919da33ab..0000000000 --- a/apps/code/src/renderer/features/setup/hooks/useSetupDiscovery.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { SetupRunService } from "@features/setup/services/setupRunService"; -import { useSetupStore } from "@features/setup/stores/setupStore"; -import { get } from "@renderer/di/container"; -import { RENDERER_TOKENS } from "@renderer/di/tokens"; -import { useActiveRepoStore } from "@stores/activeRepoStore"; -import { useEffect } from "react"; - -export function useSetupDiscovery() { - const selectedDirectory = useActiveRepoStore((s) => s.path); - - // Discovery is a one-time-per-user agent run; once any repo has triggered - // it we never auto-launch another one from this hook. Errored/interrupted - // runs require explicit user retry (see setupStore partialize and #2257). - // Enricher runs per repo on every selection (gated on per-repo status - // inside the service). - useEffect(() => { - if (!selectedDirectory) return; - const service = get(RENDERER_TOKENS.SetupRunService); - const discoveryEverStarted = Object.values( - useSetupStore.getState().discoveryByRepo, - ).some((d) => d.status !== "idle"); - if (discoveryEverStarted) { - service.startEnricherForRepo(selectedDirectory); - } else { - service.startSetup(selectedDirectory); - } - }, [selectedDirectory]); -} diff --git a/apps/code/src/renderer/features/setup/services/setupRunService.ts b/apps/code/src/renderer/features/setup/services/setupRunService.ts deleted file mode 100644 index eda36203ea..0000000000 --- a/apps/code/src/renderer/features/setup/services/setupRunService.ts +++ /dev/null @@ -1,656 +0,0 @@ -import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { fetchAuthState } from "@features/auth/hooks/authQueries"; -import { buildDiscoveryPrompt } from "@features/setup/prompts"; -import { - type ActivityEntry, - selectRepoDiscovery, - selectRepoEnricher, - useSetupStore, -} from "@features/setup/stores/setupStore"; -import { - buildTaskDiscoverySchema, - type DiscoveredTask, -} from "@features/setup/types"; -import { trpcClient } from "@renderer/trpc/client"; -import { EXPERIMENT_SUGGESTIONS_FLAG } from "@shared/constants"; -import { isTerminalStatus, type Task } from "@shared/types"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { - captureException, - isFeatureFlagEnabled, - track, -} from "@utils/analytics"; -import { logger } from "@utils/logger"; -import { injectable } from "inversify"; - -const log = logger.scope("setup-run-service"); - -let activityIdCounter = 0; - -function extractPathFromRawInput( - tool: string, - rawInput: Record | undefined, -): string | null { - if (!rawInput) return null; - - switch (tool) { - case "Read": - case "Edit": - case "Write": - return (rawInput.file_path as string) ?? null; - case "Grep": - return (rawInput.pattern as string) - ? `"${rawInput.pattern}"${rawInput.path ? ` in ${rawInput.path}` : ""}` - : ((rawInput.path as string) ?? null); - case "Glob": - return (rawInput.pattern as string) ?? null; - case "Bash": { - const cmd = rawInput.command as string | undefined; - if (!cmd) return null; - return cmd.length > 80 ? `${cmd.slice(0, 77)}...` : cmd; - } - default: { - const filePath = - rawInput.file_path ?? rawInput.path ?? rawInput.notebook_path; - if (typeof filePath === "string") return filePath; - const pattern = rawInput.pattern; - if (typeof pattern === "string") return `"${pattern}"`; - const command = rawInput.command; - if (typeof command === "string") - return command.length > 80 ? `${command.slice(0, 77)}...` : command; - const url = rawInput.url; - if (typeof url === "string") return url; - const query = rawInput.query; - if (typeof query === "string") return query; - return null; - } - } -} - -function extractToolCall( - update: Record, -): ActivityEntry | null { - const sessionUpdate = update.sessionUpdate as string | undefined; - if (sessionUpdate !== "tool_call" && sessionUpdate !== "tool_call_update") - return null; - - const meta = update._meta as - | { claudeCode?: { toolName?: string } } - | undefined; - const tool = meta?.claudeCode?.toolName ?? "Working"; - const locations = update.locations as - | { path?: string; line?: number }[] - | undefined; - const rawInput = (update.rawInput ?? update.input) as - | Record - | undefined; - const filePath = - locations?.[0]?.path ?? extractPathFromRawInput(tool, rawInput); - const title = (update.title as string) ?? ""; - const toolCallId = (update.toolCallId as string) ?? ""; - - activityIdCounter += 1; - return { id: activityIdCounter, toolCallId, tool, filePath, title }; -} - -function extractAgentMessageText( - update: Record, -): string | null { - if (update.sessionUpdate !== "agent_message_chunk") return null; - const content = update.content as - | { type?: string; text?: string } - | undefined; - if (content?.type !== "text" || !content.text) return null; - return content.text; -} - -function handleSessionUpdate( - payload: unknown, - pushActivity: (entry: ActivityEntry) => void, - pushAssistantText?: (text: string) => void, -) { - const acpMsg = payload as { message?: Record }; - const inner = acpMsg.message; - if (!inner) return; - - if ("method" in inner && inner.method === "session/update") { - const params = inner.params as Record | undefined; - if (!params) return; - - const update = (params.update as Record) ?? params; - - const entry = extractToolCall(update); - if (entry) { - pushActivity(entry); - return; - } - - if (pushAssistantText) { - const text = extractAgentMessageText(update); - if (text) pushAssistantText(text); - } - } -} - -function sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new DOMException("Aborted", "AbortError")); - return; - } - const onAbort = () => { - clearTimeout(timer); - reject(new DOMException("Aborted", "AbortError")); - }; - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, ms); - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} - -interface StaleFlagPayload { - flagKey: string; - references: { file: string; line: number; method: string }[]; - referenceCount: number; -} - -function buildStaleFlagSuggestion(flag: StaleFlagPayload): DiscoveredTask { - const refs = flag.references; - const first = refs[0]; - const moreCount = Math.max(0, flag.referenceCount - refs.length); - const referencesBlock = refs - .map((r) => `- ${r.file}:${r.line} (${r.method})`) - .join("\n"); - const recommendation = `Remove the flag check and inline the winning branch. Code references:\n${referencesBlock}${moreCount > 0 ? `\n…and ${moreCount} more.` : ""}`; - return { - // Stable id keyed off the flag key so dismissal sticks across re-runs. - id: `posthog-stale-flag-${flag.flagKey}`, - source: "enricher", - category: "stale_feature_flag", - title: `Clean up stale flag "${flag.flagKey}"`, - description: `\`${flag.flagKey}\` hasn't been evaluated in 30+ days but is still referenced in ${flag.referenceCount} place${flag.referenceCount === 1 ? "" : "s"} in this codebase.`, - impact: - "Stale flags accumulate dead code paths and conditional branches that nobody is exercising any more — they make refactors riskier and obscure what's actually live in production.", - recommendation, - file: first?.file, - lineHint: first?.line, - prompt: `/cleaning-up-stale-feature-flags Clean up stale flag "${flag.flagKey}"\n\n${recommendation}`, - }; -} - -function buildSdkHealthSuggestion(): DiscoveredTask { - return { - id: "posthog-sdk-health", - source: "enricher", - category: "posthog_setup", - title: "Check PostHog SDK health", - description: - "Run a quick health check on the PostHog SDKs installed in this repo: confirm they're on supported versions, flag anything outdated or deprecated, and bump the safely-upgradable ones.", - impact: - "Outdated SDKs miss bug fixes, security patches, and new features (newer event types, recording APIs, flag evaluation behavior). Catching version drift early avoids surprise breakage when you eventually upgrade.", - recommendation: - 'Click "Implement as new task" — the agent uses the bundled diagnosing-sdk-health skill to inspect each PostHog SDK\'s version, compare it against the latest, and open a PR with safe bumps. Breaking-change upgrades are flagged for your review rather than applied automatically.', - prompt: "/diagnosing-sdk-health", - }; -} - -function buildPosthogSetupSuggestion( - state: "not_installed" | "installed_no_init", -): DiscoveredTask { - if (state === "not_installed") { - return { - id: "posthog-setup", - source: "enricher", - category: "posthog_setup", - title: "Set up PostHog", - description: - "PostHog isn't installed in this repo yet. Run this task to detect your framework, install the SDK, instrument analytics + error tracking + replay, and open a PR with the changes.", - impact: - "Without PostHog wired in, you have no visibility into how users interact with the product, no error or session-replay coverage, and no way to gate releases behind feature flags.", - recommendation: - 'Click "Implement as new task" — the agent runs the bundled instrument-integration skill, sets up env vars, installs the SDK with your project\'s package manager, and opens a PR.', - prompt: "/instrument-integration", - }; - } - return { - id: "posthog-finish-init", - source: "enricher", - category: "posthog_setup", - title: "Finish wiring PostHog", - description: - "The PostHog SDK is declared in this repo but `posthog.init(...)` (or the framework-equivalent provider) isn't called. Events won't be captured until that's wired up.", - impact: - "Until init runs, all PostHog calls are no-ops — you'll see no events in the project, no error reports, and no session replays despite the SDK being installed.", - recommendation: - 'Click "Implement as new task" — the agent adds the init call and provider component for your framework, sets up the public-token + host env vars, and opens a PR. The SDK package itself is left alone.', - prompt: - "/instrument-integration\n\nThe SDK is already declared in this repo — skip install steps and focus on adding the init call, provider, and env vars.", - }; -} - -@injectable() -export class SetupRunService { - private anyDiscoveryEverLaunched = false; - private discoveryStartingByRepo = new Set(); - private enricherSuggestionsRunningByRepo = new Set(); - - startSetup(directory: string): void { - // Defense in depth: never auto-run from a non-idle persisted state. - // The hook (useSetupDiscovery) is the primary gate, but a direct call - // path could otherwise re-enter the loop that wedged users on boot — - // creating fresh cloud tasks and a tree-sitter parse storm against the - // user's repo on every launch. - const status = selectRepoDiscovery( - useSetupStore.getState(), - directory, - ).status; - if (status !== "idle") return; - this.injectEnricherSuggestions(directory); - this.startDiscovery(directory); - } - - startEnricherForRepo(directory: string): void { - this.injectEnricherSuggestions(directory); - } - - startDiscovery(directory: string): void { - if (!directory) return; - if (this.anyDiscoveryEverLaunched) return; - if (this.discoveryStartingByRepo.has(directory)) return; - const status = selectRepoDiscovery( - useSetupStore.getState(), - directory, - ).status; - if (status === "running" || status === "done") return; - this.anyDiscoveryEverLaunched = true; - this.discoveryStartingByRepo.add(directory); - this.runDiscovery(directory) - .catch((err) => { - log.error("Discovery startup failed", { error: err }); - }) - .finally(() => { - this.discoveryStartingByRepo.delete(directory); - }); - } - - injectEnricherSuggestions(directory: string): void { - if (!directory) return; - if (this.enricherSuggestionsRunningByRepo.has(directory)) return; - // Once per repo per success. "done" survives across boots via partialize - // so re-selecting a previously-enriched repo doesn't re-hit the PostHog - // install-state and stale-flag APIs. "error" and "idle" fall through so - // a transient failure can retry on the next selection. - const enricherStatus = selectRepoEnricher( - useSetupStore.getState(), - directory, - ).status; - if (enricherStatus === "done" || enricherStatus === "running") return; - this.enricherSuggestionsRunningByRepo.add(directory); - useSetupStore.getState().startEnrichment(directory); - this.runEnricher(directory).catch((err) => { - log.warn("Enricher run failed", { error: err }); - }); - } - - private async runEnricher(directory: string): Promise { - try { - const installState = - await trpcClient.enrichment.detectPosthogInstallState.query({ - repoPath: directory, - }); - - if (installState === "initialized") { - useSetupStore.getState().addEnricherSuggestionIfMissing({ - ...buildSdkHealthSuggestion(), - repoPath: directory, - }); - await this.injectStaleFlagSuggestions(directory); - } else { - const suggestion = buildPosthogSetupSuggestion(installState); - useSetupStore.getState().addEnricherSuggestionIfMissing({ - ...suggestion, - repoPath: directory, - }); - } - useSetupStore.getState().completeEnrichment(directory); - } catch (err) { - log.warn("Enricher run failed", { error: err }); - useSetupStore.getState().failEnrichment(directory); - } finally { - this.enricherSuggestionsRunningByRepo.delete(directory); - } - } - - private async injectStaleFlagSuggestions(directory: string): Promise { - try { - const flags = await trpcClient.enrichment.findStaleFlagSuggestions.query({ - repoPath: directory, - }); - const store = useSetupStore.getState(); - for (const flag of flags) { - store.addEnricherSuggestionIfMissing({ - ...buildStaleFlagSuggestion(flag), - repoPath: directory, - }); - } - } catch (err) { - log.warn("Failed to find stale flag suggestions", { error: err }); - } - } - - private async runDiscovery(directory: string): Promise { - const abort = new AbortController(); - const discoveryStartedAt = Date.now(); - - try { - const authState = await fetchAuthState(); - if (abort.signal.aborted) return; - const apiHost = authState.cloudRegion - ? getCloudUrlFromRegion(authState.cloudRegion) - : null; - const projectId = authState.projectId; - - if (!apiHost || !projectId) { - log.error("Missing auth for discovery", { apiHost, projectId }); - useSetupStore - .getState() - .failDiscovery(directory, "Authentication required."); - track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { - reason: "startup_error", - error_message: "missing_auth", - }); - return; - } - - const client = await getAuthenticatedClient(); - if (abort.signal.aborted) return; - if (!client) { - useSetupStore - .getState() - .failDiscovery(directory, "Authentication required."); - track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { - reason: "startup_error", - error_message: "unauthenticated_client", - }); - return; - } - - if (!directory) { - useSetupStore - .getState() - .failDiscovery(directory, "No directory selected."); - track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { - reason: "startup_error", - error_message: "missing_directory", - }); - return; - } - - const includeExperiments = - isFeatureFlagEnabled(EXPERIMENT_SUGGESTIONS_FLAG) || - import.meta.env.DEV; - const discoveryPrompt = buildDiscoveryPrompt({ includeExperiments }); - const discoverySchema = buildTaskDiscoverySchema({ includeExperiments }); - - const task = (await client.createTask({ - title: "Discover first tasks", - description: discoveryPrompt, - json_schema: discoverySchema, - })) as unknown as Task; - if (abort.signal.aborted) return; - - const taskRun = await client.createTaskRun(task.id); - if (abort.signal.aborted) return; - if (!taskRun?.id) { - throw new Error("Failed to create discovery task run"); - } - - useSetupStore.getState().startDiscovery(directory, task.id, taskRun.id); - track(ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED, { - discovery_task_id: task.id, - discovery_task_run_id: taskRun.id, - }); - - await trpcClient.agent.start.mutate({ - taskId: task.id, - taskRunId: taskRun.id, - repoPath: directory, - apiHost, - projectId, - permissionMode: "bypassPermissions", - jsonSchema: discoverySchema, - }); - if (abort.signal.aborted) return; - - trpcClient.agent.prompt - .mutate({ - sessionId: taskRun.id, - prompt: [{ type: "text", text: discoveryPrompt }], - }) - .catch((err) => { - log.error("Failed to send discovery prompt", { error: err }); - }); - - let completed = false; - let subscription: { unsubscribe: () => void } | null = null; - - type CompletionSource = - | "structured_output" - | "terminal_status" - | "missing_output"; - - const finishSuccess = ( - tasks: DiscoveredTask[], - signalSource: CompletionSource, - ) => { - if (completed || abort.signal.aborted) return; - completed = true; - subscription?.unsubscribe(); - - const durationSeconds = Math.round( - (Date.now() - discoveryStartedAt) / 1000, - ); - - log.info("Discovery completed", { - taskCount: tasks.length, - signalSource, - }); - useSetupStore.getState().completeDiscovery(directory, tasks); - track(ANALYTICS_EVENTS.SETUP_DISCOVERY_COMPLETED, { - discovery_task_id: task.id, - discovery_task_run_id: taskRun.id, - task_count: tasks.length, - duration_seconds: durationSeconds, - signal_source: signalSource, - }); - }; - - const finishFailure = ( - reason: "failed" | "cancelled" | "timeout", - message: string, - ) => { - if (completed || abort.signal.aborted) return; - completed = true; - subscription?.unsubscribe(); - - log.error("Discovery failed", { reason }); - useSetupStore.getState().failDiscovery(directory, message); - track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { - discovery_task_id: task.id, - discovery_task_run_id: taskRun.id, - reason, - }); - }; - - let signalRetryStarted = false; - const handleStructuredOutputSignal = async () => { - if (signalRetryStarted) return; - signalRetryStarted = true; - const startedAt = Date.now(); - const TIMEOUT_MS = 8000; - const MAX_DELAY_MS = 4000; - let delay = 500; - while (Date.now() - startedAt < TIMEOUT_MS) { - try { - await sleep(delay, abort.signal); - } catch { - return; // aborted - } - if (completed) return; - try { - const run = await client.getTaskRun(task.id, taskRun.id); - if (completed || abort.signal.aborted) return; - const output = run.output as { tasks?: DiscoveredTask[] } | null; - if (output?.tasks) { - finishSuccess(output.tasks, "structured_output"); - return; - } - } catch (err) { - log.warn("Failed to fetch run after StructuredOutput signal", { - error: err, - }); - } - delay = Math.min(delay * 2, MAX_DELAY_MS); - } - }; - - let structuredOutputSeen = false; - let wrapupBuffer = ""; - const WRAPUP_TOOL_CALL_ID = "discovery-wrapup"; - const pushWrapupActivity = (text: string) => { - if (!structuredOutputSeen) return; - wrapupBuffer = (wrapupBuffer + text).slice(-200); - activityIdCounter += 1; - useSetupStore.getState().pushDiscoveryActivity(directory, { - id: activityIdCounter, - toolCallId: WRAPUP_TOOL_CALL_ID, - tool: "WrappingUp", - filePath: null, - title: wrapupBuffer.trim(), - }); - }; - - subscription = trpcClient.agent.onSessionEvent.subscribe( - { taskRunId: taskRun.id }, - { - onData: (payload: unknown) => { - handleSessionUpdate( - payload, - (entry) => { - useSetupStore - .getState() - .pushDiscoveryActivity(directory, entry); - if (entry.tool === "StructuredOutput") { - structuredOutputSeen = true; - handleStructuredOutputSignal().catch((err) => - log.warn("StructuredOutput handler failed", { error: err }), - ); - } - }, - pushWrapupActivity, - ); - }, - onError: (err) => { - log.error("Discovery subscription error", { error: err }); - }, - }, - ); - const subscriptionAtAbort = subscription; - abort.signal.addEventListener( - "abort", - () => { - subscriptionAtAbort.unsubscribe(); - }, - { once: true }, - ); - - const pollForCompletion = async () => { - const maxAttempts = 120; - const intervalMs = 5000; - - for (let i = 0; i < maxAttempts; i++) { - try { - await sleep(intervalMs, abort.signal); - } catch { - return; // aborted - } - if (completed) return; - - try { - const run = await client.getTaskRun(task.id, taskRun.id); - if (completed || abort.signal.aborted) return; - - const output = run.output as { tasks?: DiscoveredTask[] } | null; - - if (isTerminalStatus(run.status)) { - if (run.status === "completed" && output?.tasks) { - finishSuccess(output.tasks, "terminal_status"); - } else if ( - run.status === "failed" || - run.status === "cancelled" - ) { - finishFailure( - run.status, - "Discovery failed. You can skip or retry.", - ); - } else { - finishSuccess([], "missing_output"); - } - return; - } - - if (output?.tasks) { - finishSuccess(output.tasks, "missing_output"); - return; - } - } catch (err) { - log.warn("Failed to poll discovery", { - attempt: i + 1, - error: err, - }); - } - } - - finishFailure("timeout", "Discovery timed out. You can skip or retry."); - }; - - pollForCompletion().catch((err) => { - if (abort.signal.aborted) return; - log.error("Discovery poll failed", { error: err }); - if (!completed) { - completed = true; - subscription?.unsubscribe(); - useSetupStore - .getState() - .failDiscovery(directory, "Discovery failed unexpectedly."); - track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { - discovery_task_id: task.id, - discovery_task_run_id: taskRun.id, - reason: "failed", - error_message: - err instanceof Error ? err.message : "discovery_poll_error", - }); - if (err instanceof Error) { - captureException(err, { scope: "setup.discovery_poll" }); - } - } - }); - } catch (err) { - if (abort.signal.aborted) return; - log.error("Failed to start discovery", { error: err }); - const message = - err instanceof Error ? err.message : "Failed to start discovery."; - useSetupStore.getState().failDiscovery(directory, message); - track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { - reason: "startup_error", - error_message: message, - }); - if (err instanceof Error) { - captureException(err, { scope: "setup.start_discovery" }); - } - } - } -} diff --git a/apps/code/src/renderer/features/setup/stores/setupStore.ts b/apps/code/src/renderer/features/setup/stores/setupStore.ts deleted file mode 100644 index 4614575e2a..0000000000 --- a/apps/code/src/renderer/features/setup/stores/setupStore.ts +++ /dev/null @@ -1,387 +0,0 @@ -import type { DiscoveredTask } from "@features/setup/types"; -import { logger } from "@utils/logger"; -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -const log = logger.scope("setup-store"); - -type DiscoveryStatus = "idle" | "running" | "done" | "error"; -type EnricherStatus = "idle" | "running" | "done" | "error"; - -export interface ActivityEntry { - id: number; - toolCallId: string; - tool: string; - filePath: string | null; - title: string; -} - -export interface AgentFeedState { - currentTool: string | null; - currentFilePath: string | null; - recentEntries: ActivityEntry[]; -} - -export interface RepoDiscoveryState { - status: DiscoveryStatus; - taskId: string | null; - taskRunId: string | null; - feed: AgentFeedState; - error: string | null; -} - -export interface RepoEnricherState { - status: EnricherStatus; -} - -const EMPTY_FEED: AgentFeedState = { - currentTool: null, - currentFilePath: null, - recentEntries: [], -}; - -const DEFAULT_DISCOVERY: RepoDiscoveryState = { - status: "idle", - taskId: null, - taskRunId: null, - feed: EMPTY_FEED, - error: null, -}; - -const DEFAULT_ENRICHER: RepoEnricherState = { status: "idle" }; - -interface SetupStoreState { - discoveredTasks: DiscoveredTask[]; - discoveryByRepo: Record; - enricherByRepo: Record; -} - -interface SetupStoreActions { - startDiscovery: (repoPath: string, taskId: string, taskRunId: string) => void; - completeDiscovery: (repoPath: string, tasks: DiscoveredTask[]) => void; - failDiscovery: (repoPath: string, message?: string) => void; - resetDiscovery: (repoPath: string) => void; - startEnrichment: (repoPath: string) => void; - completeEnrichment: (repoPath: string) => void; - failEnrichment: (repoPath: string) => void; - removeDiscoveredTask: (taskId: string, repoPath: string | null) => void; - addEnricherSuggestionIfMissing: (task: DiscoveredTask) => void; - pushDiscoveryActivity: (repoPath: string, entry: ActivityEntry) => void; - resetSetup: () => void; -} - -type SetupStore = SetupStoreState & SetupStoreActions; - -const initialState: SetupStoreState = { - discoveredTasks: [], - discoveryByRepo: {}, - enricherByRepo: {}, -}; - -export function selectRepoDiscovery( - state: SetupStoreState, - repoPath: string | null, -): RepoDiscoveryState { - if (!repoPath) return DEFAULT_DISCOVERY; - return state.discoveryByRepo[repoPath] ?? DEFAULT_DISCOVERY; -} - -export function selectRepoEnricher( - state: SetupStoreState, - repoPath: string | null, -): RepoEnricherState { - if (!repoPath) return DEFAULT_ENRICHER; - return state.enricherByRepo[repoPath] ?? DEFAULT_ENRICHER; -} - -export function isTaskForRepo( - task: DiscoveredTask, - repoPath: string | null, -): boolean { - if (!repoPath) return !task.repoPath; - return task.repoPath === repoPath; -} - -// Discovery resets only clear agent-source suggestions for the affected repo; -// enricher-source suggestions are deterministic and survive across runs. -function dropAgentTasksForRepo( - tasks: DiscoveredTask[], - repoPath: string, -): DiscoveredTask[] { - return tasks.filter( - (t) => !(t.source === "agent" && isTaskForRepo(t, repoPath)), - ); -} - -function pushEntry(prev: AgentFeedState, entry: ActivityEntry): AgentFeedState { - const existingIdx = entry.toolCallId - ? prev.recentEntries.findIndex((e) => e.toolCallId === entry.toolCallId) - : -1; - - let newEntries: ActivityEntry[]; - if (existingIdx >= 0) { - newEntries = [...prev.recentEntries]; - const old = newEntries[existingIdx]; - newEntries[existingIdx] = { - ...old, - tool: entry.tool || old.tool, - filePath: entry.filePath || old.filePath, - title: entry.title || old.title, - }; - } else { - newEntries = [...prev.recentEntries.slice(-4), entry]; - } - - return { - currentTool: entry.tool, - currentFilePath: entry.filePath ?? prev.currentFilePath, - recentEntries: newEntries, - }; -} - -function updateDiscovery( - state: SetupStoreState, - repoPath: string, - patch: Partial, -): Record { - const prev = state.discoveryByRepo[repoPath] ?? DEFAULT_DISCOVERY; - return { ...state.discoveryByRepo, [repoPath]: { ...prev, ...patch } }; -} - -function updateEnricher( - state: SetupStoreState, - repoPath: string, - patch: Partial, -): Record { - const prev = state.enricherByRepo[repoPath] ?? DEFAULT_ENRICHER; - return { ...state.enricherByRepo, [repoPath]: { ...prev, ...patch } }; -} - -export const useSetupStore = create()( - persist( - (set) => ({ - ...initialState, - - // Starts a fresh agent run for `repoPath`. Clears agent-source - // suggestions only for that repo — enricher and other repos stay put. - startDiscovery: (repoPath, taskId, taskRunId) => { - log.info("Discovery started", { repoPath, taskId, taskRunId }); - set((state) => ({ - discoveredTasks: dropAgentTasksForRepo( - state.discoveredTasks, - repoPath, - ), - discoveryByRepo: updateDiscovery(state, repoPath, { - status: "running", - taskId, - taskRunId, - feed: EMPTY_FEED, - error: null, - }), - })); - }, - - // Replaces agent-source entries for `repoPath` with the new findings. - // Other repos' tasks and enricher entries are untouched. - completeDiscovery: (repoPath, tasks) => { - log.info("Discovery completed", { - repoPath, - taskCount: tasks.length, - }); - set((state) => { - const cleaned = dropAgentTasksForRepo( - state.discoveredTasks, - repoPath, - ); - const agent = tasks.map((t) => ({ - ...t, - source: "agent" as const, - repoPath: t.repoPath ?? repoPath, - })); - return { - discoveredTasks: [...cleaned, ...agent], - discoveryByRepo: updateDiscovery(state, repoPath, { - status: "done", - error: null, - }), - }; - }); - }, - - failDiscovery: (repoPath, message) => { - log.warn("Discovery failed", { repoPath, message }); - set((state) => ({ - discoveryByRepo: updateDiscovery(state, repoPath, { - status: "error", - error: message ?? null, - }), - })); - }, - - resetDiscovery: (repoPath) => { - log.info("Discovery reset", { repoPath }); - set((state) => ({ - discoveredTasks: dropAgentTasksForRepo( - state.discoveredTasks, - repoPath, - ), - discoveryByRepo: updateDiscovery(state, repoPath, { - status: "idle", - taskId: null, - taskRunId: null, - feed: EMPTY_FEED, - error: null, - }), - })); - }, - - startEnrichment: (repoPath) => { - set((state) => ({ - enricherByRepo: updateEnricher(state, repoPath, { - status: "running", - }), - })); - }, - - completeEnrichment: (repoPath) => { - set((state) => ({ - enricherByRepo: updateEnricher(state, repoPath, { status: "done" }), - })); - }, - - failEnrichment: (repoPath) => { - set((state) => ({ - enricherByRepo: updateEnricher(state, repoPath, { status: "error" }), - })); - }, - - removeDiscoveredTask: (taskId, repoPath) => { - set((state) => ({ - discoveredTasks: state.discoveredTasks.filter( - (t) => !(t.id === taskId && isTaskForRepo(t, repoPath)), - ), - })); - }, - - // Adds an enricher-source suggestion if there isn't already one with - // the same id+repoPath. Idempotent — safe to call repeatedly on every - // detection run. Dismissed suggestions stay dismissed until `resetSetup`. - addEnricherSuggestionIfMissing: (task) => { - set((state) => { - const repoTask = { ...task, source: "enricher" as const }; - if ( - state.discoveredTasks.some( - (t) => t.id === repoTask.id && t.repoPath === repoTask.repoPath, - ) - ) { - return state; - } - return { - discoveredTasks: [repoTask, ...state.discoveredTasks], - }; - }); - }, - - pushDiscoveryActivity: (repoPath, entry) => { - set((state) => { - const prev = state.discoveryByRepo[repoPath] ?? DEFAULT_DISCOVERY; - return { - discoveryByRepo: updateDiscovery(state, repoPath, { - feed: pushEntry(prev.feed, entry), - }), - }; - }); - }, - - resetSetup: () => { - log.info("Setup state reset"); - set({ ...initialState }); - }, - }), - { - name: "setup-store", - version: 2, - migrate: (persistedState, version): SetupStoreState => { - if (version < 2) { - // v1 stored a single global discoveryStatus, not a per-repo map. - // We can't recover which repo it belonged to, so for v1 users who - // had already finished (or interrupted) a discovery run we plant a - // sentinel entry under a synthetic key. That keeps - // `discoveryEverStarted` true on first boot post-upgrade, - // suppressing an automatic fresh agent launch — without it, every - // upgraded user would create a new cloud task and re-trigger the - // parse storm we fixed in #2257. - // - // Pre-v2 tasks are dropped: they have no repoPath, so the new - // per-repo filter would never render them anyway. - const oldState = (persistedState ?? {}) as { - discoveryStatus?: string; - error?: unknown; - }; - let sentinel: Record = {}; - if (oldState.discoveryStatus === "done") { - sentinel = { - __migrated_v1__: { ...DEFAULT_DISCOVERY, status: "done" }, - }; - } else if ( - oldState.discoveryStatus === "error" || - oldState.discoveryStatus === "running" - ) { - sentinel = { - __migrated_v1__: { - ...DEFAULT_DISCOVERY, - status: "error", - error: - typeof oldState.error === "string" - ? oldState.error - : "Discovery was interrupted. You can skip or retry.", - }, - }; - } - return { - discoveredTasks: [], - discoveryByRepo: sentinel, - enricherByRepo: {}, - }; - } - return persistedState as SetupStoreState; - }, - // Persist non-idle discovery status per repo so a known-done repo - // doesn't trigger another full agent run on reload. Persist "running" - // as "error" so an interrupted run (crash, force-quit, freeze) doesn't - // auto-restart on next boot — otherwise discovery loops forever, - // creating new cloud tasks and spawning agents on every launch (#2257). - // - // Enricher only persists "done" — it's cheap to rerun on error/idle, - // and we never want to skip an in-flight "running" across boots. - partialize: (state): SetupStoreState => ({ - discoveredTasks: state.discoveredTasks, - discoveryByRepo: Object.fromEntries( - Object.entries(state.discoveryByRepo) - .filter(([, d]) => d.status !== "idle") - .map(([repo, d]) => { - if (d.status === "running") { - return [ - repo, - { - ...DEFAULT_DISCOVERY, - status: "error", - error: "Discovery was interrupted. You can skip or retry.", - }, - ]; - } - return [ - repo, - { ...DEFAULT_DISCOVERY, status: d.status, error: d.error }, - ]; - }), - ), - enricherByRepo: Object.fromEntries( - Object.entries(state.enricherByRepo).filter( - ([, e]) => e.status === "done", - ), - ), - }), - }, - ), -); diff --git a/apps/code/src/renderer/features/sidebar/components/index.tsx b/apps/code/src/renderer/features/sidebar/components/index.tsx deleted file mode 100644 index c4e4dbc736..0000000000 --- a/apps/code/src/renderer/features/sidebar/components/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { Sidebar } from "./Sidebar"; -export { SidebarContent } from "./SidebarContent"; diff --git a/apps/code/src/renderer/features/sidebar/hooks/usePinnedTasks.ts b/apps/code/src/renderer/features/sidebar/hooks/usePinnedTasks.ts deleted file mode 100644 index fb18e5cde9..0000000000 --- a/apps/code/src/renderer/features/sidebar/hooks/usePinnedTasks.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { trpcClient, useTRPC } from "@renderer/trpc"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo, useRef } from "react"; - -export function usePinnedTasks() { - const trpcReact = useTRPC(); - const queryClient = useQueryClient(); - const pinnedQueryKey = trpcReact.workspace.getPinnedTaskIds.queryKey(); - - const { data: pinnedTaskIds = [], isLoading } = useQuery( - trpcReact.workspace.getPinnedTaskIds.queryOptions(undefined, { - staleTime: 30_000, - }), - ); - - const pinnedSet = useMemo(() => new Set(pinnedTaskIds), [pinnedTaskIds]); - - const togglePinMutation = useMutation( - trpcReact.workspace.togglePin.mutationOptions({ - onMutate: async ({ taskId }) => { - await queryClient.cancelQueries({ queryKey: pinnedQueryKey }); - const previous = queryClient.getQueryData(pinnedQueryKey); - const wasPinned = previous?.includes(taskId); - queryClient.setQueryData(pinnedQueryKey, (old) => { - if (!old) return wasPinned ? [] : [taskId]; - return wasPinned - ? old.filter((id) => id !== taskId) - : [...old, taskId]; - }); - return { previous, wasPinned, taskId }; - }, - onError: (_, __, context) => { - if (context?.previous) { - queryClient.setQueryData(pinnedQueryKey, context.previous); - } - }, - onSuccess: (result, _, context) => { - const taskId = context?.taskId; - if (!taskId) return; - queryClient.setQueryData(pinnedQueryKey, (old) => { - if (!old) return result.isPinned ? [taskId] : []; - const filtered = old.filter((id) => id !== taskId); - return result.isPinned ? [...filtered, taskId] : filtered; - }); - }, - }), - ); - - const togglePinMutationRef = useRef(togglePinMutation); - togglePinMutationRef.current = togglePinMutation; - - const pinnedSetRef = useRef(pinnedSet); - pinnedSetRef.current = pinnedSet; - - const togglePin = useCallback(async (taskId: string) => { - await togglePinMutationRef.current.mutateAsync({ taskId }); - }, []); - - const unpin = useCallback(async (taskId: string) => { - if (!pinnedSetRef.current.has(taskId)) return; - const result = await togglePinMutationRef.current.mutateAsync({ taskId }); - if (result.isPinned) { - await togglePinMutationRef.current.mutateAsync({ taskId }); - } - }, []); - - const isPinned = useCallback( - (taskId: string) => pinnedSet.has(taskId), - [pinnedSet], - ); - - return { - pinnedTaskIds: pinnedSet, - isLoading, - togglePin, - unpin, - isPinned, - }; -} - -export const pinnedTasksApi = { - async getPinnedTaskIds(): Promise { - return trpcClient.workspace.getPinnedTaskIds.query(); - }, - async togglePin( - taskId: string, - ): Promise<{ taskId: string; isPinned: boolean }> { - const result = await trpcClient.workspace.togglePin.mutate({ taskId }); - return { taskId, isPinned: result.isPinned }; - }, - async unpin(taskId: string): Promise { - const result = await trpcClient.workspace.togglePin.mutate({ taskId }); - if (result.isPinned) { - await trpcClient.workspace.togglePin.mutate({ taskId }); - } - }, - isPinned(pinnedTaskIds: Set, taskId: string): boolean { - return pinnedTaskIds.has(taskId); - }, -}; diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts deleted file mode 100644 index 2323121d7c..0000000000 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; -import { useProvisioningStore } from "@features/provisioning/stores/provisioningStore"; -import { useSessions } from "@features/sessions/stores/sessionStore"; -import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds"; -import { - useSlackTasks, - useTaskSummaries, - useTasks, -} from "@features/tasks/hooks/useTasks"; -import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; -import type { Schemas } from "@posthog/api-client"; -import type { Task, TaskRunStatus } from "@shared/types"; -import { useEffect, useMemo, useRef } from "react"; -import { useSidebarStore } from "../stores/sidebarStore"; -import type { SortMode } from "../types"; -import { - type TaskGroup as GenericTaskGroup, - getRepositoryInfo, - groupByRepository, - type TaskRepositoryInfo, -} from "../utils/groupTasks"; -import { computeSummaryIds } from "../utils/summaryIds"; -import { usePinnedTasks } from "./usePinnedTasks"; -import { useTaskViewed } from "./useTaskViewed"; - -export interface TaskData { - id: string; - title: string; - createdAt: number; - lastActivityAt: number; - isGenerating: boolean; - isUnread: boolean; - isPinned: boolean; - needsPermission: boolean; - repository: TaskRepositoryInfo | null; - isSuspended: boolean; - folderId?: string; - taskRunStatus?: TaskRunStatus; - taskRunEnvironment?: "local" | "cloud"; - originProduct?: string; - slackThreadUrl?: string; - folderPath: string | null; - cloudPrUrl: string | null; - branchName: string | null; - linkedBranch: string | null; -} - -export type TaskGroup = GenericTaskGroup; - -export interface SidebarData { - isHomeActive: boolean; - isInboxActive: boolean; - isCommandCenterActive: boolean; - isSkillsActive: boolean; - isMcpServersActive: boolean; - isLoading: boolean; - activeTaskId: string | null; - pinnedTasks: TaskData[]; - flatTasks: TaskData[]; - groupedTasks: TaskGroup[]; - totalCount: number; - hasMore: boolean; -} - -interface ViewState { - type: - | "task-detail" - | "task-pending" - | "task-input" - | "settings" - | "folder-settings" - | "inbox" - | "archived" - | "command-center" - | "skills" - | "mcp-servers" - | "setup"; - data?: Task; -} - -interface UseSidebarDataProps { - activeView: ViewState; -} - -function getSortValue(task: TaskData, sortMode: SortMode): number { - return sortMode === "updated" ? task.lastActivityAt : task.createdAt; -} - -function sortTasks(tasks: TaskData[], sortMode: SortMode): TaskData[] { - return tasks.sort( - (a, b) => getSortValue(b, sortMode) - getSortValue(a, sortMode), - ); -} - -export function useSidebarData({ - activeView, -}: UseSidebarDataProps): SidebarData { - const showAllUsers = useSidebarStore((state) => state.showAllUsers); - const showInternal = useSidebarStore((state) => state.showInternal); - const { data: workspaces, isFetched: isWorkspacesFetched } = useWorkspaces(); - const archivedTaskIds = useArchivedTaskIds(); - const suspendedTaskIds = useSuspendedTaskIds(); - const provisioningTaskIds = useProvisioningStore((s) => s.activeTasks); - const sessions = useSessions(); - const { timestamps } = useTaskViewed(); - const historyVisibleCount = useSidebarStore( - (state) => state.historyVisibleCount, - ); - const { pinnedTaskIds } = usePinnedTasks(); - - const summaryIds = useMemo( - () => - showAllUsers - ? [] - : computeSummaryIds({ - workspaceIds: workspaces ? Object.keys(workspaces) : [], - pinnedTaskIds, - provisioningTaskIds, - archivedTaskIds, - }), - [ - showAllUsers, - workspaces, - pinnedTaskIds, - provisioningTaskIds, - archivedTaskIds, - ], - ); - - const { data: summaryTasks = [], isLoading: isSummariesLoading } = - useTaskSummaries(summaryIds, { enabled: !showAllUsers }); - // showAllUsers stays on the heavy /tasks/ list endpoint until that path gets - // its own optimization (e.g. server-side recency pagination). The mapping - // below narrows full Task → TaskSummary so downstream sidebar code stays uniform. - const { data: fullTasks = [], isLoading: isTasksLoading } = useTasks( - { showAllUsers, showInternal }, - { enabled: showAllUsers }, - ); - // Skip the slack fetch when showAllUsers is on — fullTasks already carries - // origin_product through the rawTasks mapping below. - const { data: slackTasks = [] } = useSlackTasks({ - enabled: !showAllUsers, - showInternal, - }); - const slackTaskIds = useMemo( - () => new Set(slackTasks.map((t) => t.id)), - [slackTasks], - ); - // task.latest_run.state is Record — the backend writes the - // full thread URL there. /tasks/summaries/ doesn't return state, so for the - // summaries path we read the URL out of the full slack-task payload here. - const slackThreadUrlByTaskId = useMemo(() => { - const map = new Map(); - for (const t of slackTasks) { - const url = t.latest_run?.state?.slack_thread_url; - if (typeof url === "string") map.set(t.id, url); - } - return map; - }, [slackTasks]); - - type SidebarTask = Schemas.TaskSummary & { - latest_run: - | (Schemas.TaskSummary["latest_run"] & { - output?: { pr_url?: unknown } | null; - }) - | null; - origin_product?: string; - slack_thread_url?: string; - }; - - const rawTasks: SidebarTask[] = useMemo(() => { - if (!showAllUsers) return summaryTasks; - return fullTasks.map((t) => { - const slackThreadUrl = t.latest_run?.state?.slack_thread_url; - return { - id: t.id, - title: t.title, - repository: t.repository ?? null, - created_at: t.created_at, - updated_at: t.updated_at, - latest_run: t.latest_run - ? { - status: t.latest_run.status, - environment: t.latest_run.environment ?? null, - output: t.latest_run.output ?? null, - } - : null, - origin_product: t.origin_product, - slack_thread_url: - typeof slackThreadUrl === "string" ? slackThreadUrl : undefined, - }; - }); - }, [showAllUsers, summaryTasks, fullTasks]); - - const isPrimaryLoading = showAllUsers ? isTasksLoading : isSummariesLoading; - const isLoading = isPrimaryLoading || !isWorkspacesFetched; - - const allTasks = useMemo( - () => - rawTasks.filter( - (task) => - !archivedTaskIds.has(task.id) && - (showAllUsers || - showInternal || - !!workspaces?.[task.id] || - provisioningTaskIds.has(task.id)), - ), - [ - rawTasks, - archivedTaskIds, - workspaces, - showAllUsers, - showInternal, - provisioningTaskIds, - ], - ); - const organizeMode = useSidebarStore((state) => state.organizeMode); - const sortMode = useSidebarStore((state) => state.sortMode); - const folderOrder = useSidebarStore((state) => state.folderOrder); - - const isHomeActive = - activeView.type === "task-input" || activeView.type === "task-pending"; - const isInboxActive = activeView.type === "inbox"; - const isCommandCenterActive = activeView.type === "command-center"; - const isSkillsActive = activeView.type === "skills"; - const isMcpServersActive = activeView.type === "mcp-servers"; - - const activeTaskId = - activeView.type === "task-detail" && activeView.data - ? activeView.data.id - : null; - - const sessionByTaskId = useMemo(() => { - const map = new Map(); - for (const session of Object.values(sessions)) { - if (session.taskId) { - map.set(session.taskId, session); - } - } - return map; - }, [sessions]); - - const taskData = useMemo(() => { - return allTasks.map((task) => { - const session = sessionByTaskId.get(task.id); - const workspace = workspaces?.[task.id]; - const apiUpdatedAt = new Date(task.updated_at).getTime(); - const taskTimestamps = timestamps[task.id]; - const localActivity = taskTimestamps?.lastActivityAt; - const lastActivityAt = localActivity - ? Math.max(apiUpdatedAt, localActivity) - : apiUpdatedAt; - const createdAt = new Date(task.created_at).getTime(); - - const taskLastViewedAt = taskTimestamps?.lastViewedAt; - const isUnread = - taskLastViewedAt != null && lastActivityAt > taskLastViewedAt; - - const cloudPrUrl = - typeof task.latest_run?.output?.pr_url === "string" - ? task.latest_run.output.pr_url - : ((session?.cloudOutput?.pr_url as string | undefined) ?? null); - - const originProduct = - task.origin_product ?? - (slackTaskIds.has(task.id) ? "slack" : undefined); - const slackThreadUrl = - task.slack_thread_url ?? slackThreadUrlByTaskId.get(task.id); - - return { - id: task.id, - title: task.title, - createdAt, - lastActivityAt, - isGenerating: session?.isPromptPending ?? false, - isUnread, - isPinned: pinnedTaskIds.has(task.id), - isSuspended: suspendedTaskIds.has(task.id), - needsPermission: (session?.pendingPermissions?.size ?? 0) > 0, - repository: getRepositoryInfo(task, workspace?.folderPath), - folderId: workspace?.folderId || undefined, - taskRunStatus: - session?.cloudStatus ?? task.latest_run?.status ?? undefined, - taskRunEnvironment: task.latest_run?.environment ?? undefined, - originProduct, - slackThreadUrl, - folderPath: workspace?.folderPath ?? null, - cloudPrUrl, - branchName: workspace?.branchName ?? null, - linkedBranch: workspace?.linkedBranch ?? null, - }; - }); - }, [ - allTasks, - timestamps, - pinnedTaskIds, - suspendedTaskIds, - sessionByTaskId, - workspaces, - slackTaskIds, - slackThreadUrlByTaskId, - ]); - - const pinnedTasks = useMemo(() => { - const pinned = taskData.filter((task) => task.isPinned); - return sortTasks(pinned, sortMode); - }, [taskData, sortMode]); - - const unpinnedTasks = useMemo( - () => taskData.filter((task) => !task.isPinned), - [taskData], - ); - - const sortedUnpinnedTasks = useMemo( - () => sortTasks([...unpinnedTasks], sortMode), - [unpinnedTasks, sortMode], - ); - - const totalCount = unpinnedTasks.length; - const hasMore = - organizeMode === "chronological" && - sortedUnpinnedTasks.length > historyVisibleCount; - - const flatTasks = useMemo(() => { - if (organizeMode !== "chronological") { - return sortedUnpinnedTasks; - } - return sortedUnpinnedTasks.slice(0, historyVisibleCount); - }, [organizeMode, sortedUnpinnedTasks, historyVisibleCount]); - - const groupedTasks = useMemo( - () => groupByRepository(sortedUnpinnedTasks, folderOrder), - [sortedUnpinnedTasks, folderOrder], - ); - - const groupIdsRef = useRef([]); - useEffect(() => { - if (groupedTasks.length === 0) return; - const groupIds = groupedTasks.map((g) => g.id); - const prev = groupIdsRef.current; - if ( - groupIds.length === prev.length && - groupIds.every((id, i) => id === prev[i]) - ) { - return; - } - groupIdsRef.current = groupIds; - useSidebarStore.getState().syncFolderOrder(groupIds); - }, [groupedTasks]); - - return { - isHomeActive, - isInboxActive, - isCommandCenterActive, - isSkillsActive, - isMcpServersActive, - isLoading, - activeTaskId, - pinnedTasks, - flatTasks, - groupedTasks, - totalCount, - hasMore, - }; -} diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts deleted file mode 100644 index bf8688bc56..0000000000 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useTRPC } from "@renderer/trpc"; -import { useQuery } from "@tanstack/react-query"; -import type { TaskData } from "./useSidebarData"; - -export type SidebarPrState = "merged" | "open" | "draft" | "closed" | null; - -export interface TaskPrStatus { - prState: SidebarPrState; - hasDiff: boolean; -} - -const SIDEBAR_STALE_TIME = 60_000; -const EMPTY: TaskPrStatus = { prState: null, hasDiff: false }; - -export function useTaskPrStatus( - task: Pick, -): TaskPrStatus { - const trpc = useTRPC(); - - // Cloud tasks without a PR URL have nothing for the main process to look up - // — it returns EMPTY immediately. Skip the tRPC roundtrip so a sidebar full - // of cloud tasks doesn't fire one IPC per task on mount. - const skipQuery = task.taskRunEnvironment === "cloud" && !task.cloudPrUrl; - - const { data } = useQuery( - trpc.workspace.getTaskPrStatus.queryOptions( - { taskId: task.id, cloudPrUrl: task.cloudPrUrl }, - { staleTime: SIDEBAR_STALE_TIME, enabled: !skipQuery }, - ), - ); - - if (!data || (!data.prState && !data.hasDiff)) return EMPTY; - return data; -} diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskViewed.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskViewed.ts deleted file mode 100644 index 2633de31e7..0000000000 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskViewed.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { trpcClient, useTRPC } from "@renderer/trpc"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo, useRef } from "react"; - -interface TaskTimestamps { - lastViewedAt: number | null; - lastActivityAt: number | null; -} - -function parseTimestamps( - raw: Record< - string, - { - pinnedAt: string | null; - lastViewedAt: string | null; - lastActivityAt: string | null; - } - >, -): Record { - const result: Record = {}; - for (const [taskId, ts] of Object.entries(raw)) { - result[taskId] = { - lastViewedAt: ts.lastViewedAt - ? new Date(ts.lastViewedAt).getTime() - : null, - lastActivityAt: ts.lastActivityAt - ? new Date(ts.lastActivityAt).getTime() - : null, - }; - } - return result; -} - -export function useTaskViewed() { - const trpcReact = useTRPC(); - const queryClient = useQueryClient(); - const timestampsQueryKey = - trpcReact.workspace.getAllTaskTimestamps.queryKey(); - - const { data: rawTimestamps = {}, isLoading } = useQuery( - trpcReact.workspace.getAllTaskTimestamps.queryOptions(undefined, { - staleTime: 30_000, - }), - ); - - const timestamps = useMemo( - () => parseTimestamps(rawTimestamps), - [rawTimestamps], - ); - - const markViewedMutation = useMutation( - trpcReact.workspace.markViewed.mutationOptions({ - onMutate: async ({ taskId }) => { - await queryClient.cancelQueries({ queryKey: timestampsQueryKey }); - const previous = - queryClient.getQueryData(timestampsQueryKey); - const now = new Date().toISOString(); - queryClient.setQueryData( - timestampsQueryKey, - (old) => { - if (!old) - return { - [taskId]: { - pinnedAt: null, - lastViewedAt: now, - lastActivityAt: null, - }, - }; - return { - ...old, - [taskId]: { ...old[taskId], lastViewedAt: now }, - }; - }, - ); - return { previous }; - }, - onError: (_, __, context) => { - if (context?.previous) { - queryClient.setQueryData(timestampsQueryKey, context.previous); - } - }, - }), - ); - - const markActivityMutation = useMutation( - trpcReact.workspace.markActivity.mutationOptions({ - onMutate: async ({ taskId }) => { - await queryClient.cancelQueries({ queryKey: timestampsQueryKey }); - const previous = - queryClient.getQueryData(timestampsQueryKey); - const existing = previous?.[taskId]; - const lastViewedAt = existing?.lastViewedAt - ? new Date(existing.lastViewedAt).getTime() - : 0; - const now = Date.now(); - const activityTime = Math.max(now, lastViewedAt + 1); - const activityIso = new Date(activityTime).toISOString(); - queryClient.setQueryData( - timestampsQueryKey, - (old) => { - if (!old) - return { - [taskId]: { - pinnedAt: null, - lastViewedAt: null, - lastActivityAt: activityIso, - }, - }; - return { - ...old, - [taskId]: { ...old[taskId], lastActivityAt: activityIso }, - }; - }, - ); - return { previous }; - }, - onError: (_, __, context) => { - if (context?.previous) { - queryClient.setQueryData(timestampsQueryKey, context.previous); - } - }, - }), - ); - - const markViewedMutationRef = useRef(markViewedMutation); - markViewedMutationRef.current = markViewedMutation; - - const markActivityMutationRef = useRef(markActivityMutation); - markActivityMutationRef.current = markActivityMutation; - - const markAsViewed = useCallback((taskId: string) => { - markViewedMutationRef.current.mutate({ taskId }); - }, []); - - const markActivity = useCallback((taskId: string) => { - markActivityMutationRef.current.mutate({ taskId }); - }, []); - - const getLastViewedAt = useCallback( - (taskId: string) => timestamps[taskId]?.lastViewedAt ?? undefined, - [timestamps], - ); - - const getLastActivityAt = useCallback( - (taskId: string) => timestamps[taskId]?.lastActivityAt ?? undefined, - [timestamps], - ); - - return { - timestamps, - isLoading, - markAsViewed, - markActivity, - getLastViewedAt, - getLastActivityAt, - }; -} - -export const taskViewedApi = { - async loadTimestamps(): Promise> { - const raw = await trpcClient.workspace.getAllTaskTimestamps.query(); - return parseTimestamps(raw); - }, - - markAsViewed(taskId: string): void { - trpcClient.workspace.markViewed.mutate({ taskId }); - }, - - markActivity(taskId: string): void { - trpcClient.workspace.markActivity.mutate({ taskId }); - }, -}; diff --git a/apps/code/src/renderer/features/suspension/hooks/useSuspendTask.ts b/apps/code/src/renderer/features/suspension/hooks/useSuspendTask.ts deleted file mode 100644 index 90fb159d1b..0000000000 --- a/apps/code/src/renderer/features/suspension/hooks/useSuspendTask.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useTerminalStore } from "@features/terminal/stores/terminalStore"; -import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; -import { trpc, trpcClient } from "@renderer/trpc"; -import { useFocusStore } from "@stores/focusStore"; -import { useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; - -const log = logger.scope("suspend-task"); - -interface SuspendTaskInput { - taskId: string; - reason?: "manual" | "max_worktrees" | "inactivity"; -} - -export function useSuspendTask() { - const queryClient = useQueryClient(); - - const suspendTask = async (input: SuspendTaskInput) => { - const { taskId, reason = "manual" } = input; - const focusStore = useFocusStore.getState(); - const workspace = await workspaceApi.get(taskId); - - useTerminalStore.getState().clearTerminalStatesForTask(taskId); - - queryClient.setQueryData( - trpc.suspension.suspendedTaskIds.queryKey(), - (old) => (old ? [...old, taskId] : [taskId]), - ); - - if ( - workspace?.worktreePath && - focusStore.session?.worktreePath === workspace.worktreePath - ) { - log.info("Unfocusing workspace before suspending"); - await focusStore.disableFocus(); - } - - try { - await trpcClient.suspension.suspend.mutate({ - taskId, - reason, - }); - - queryClient.invalidateQueries(trpc.suspension.pathFilter()); - queryClient.invalidateQueries(trpc.workspace.pathFilter()); - } catch (error) { - log.error("Failed to suspend task", error); - - queryClient.setQueryData( - trpc.suspension.suspendedTaskIds.queryKey(), - (old) => (old ? old.filter((id) => id !== taskId) : []), - ); - - throw error; - } - }; - - return { suspendTask }; -} diff --git a/apps/code/src/renderer/features/suspension/hooks/useSuspensionSettings.ts b/apps/code/src/renderer/features/suspension/hooks/useSuspensionSettings.ts deleted file mode 100644 index 5aa3ad3b26..0000000000 --- a/apps/code/src/renderer/features/suspension/hooks/useSuspensionSettings.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { trpcClient, useTRPC } from "@renderer/trpc"; -import type { SuspensionSettings } from "@shared/types/suspension"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; - -export function useSuspensionSettings() { - const trpcReact = useTRPC(); - const queryClient = useQueryClient(); - - const { data: settings } = useQuery( - trpcReact.suspension.settings.queryOptions(), - ); - - const updateSettings = async (update: Partial) => { - await trpcClient.suspension.updateSettings.mutate(update); - queryClient.invalidateQueries(trpcReact.suspension.settings.queryFilter()); - }; - - return { - settings: settings ?? { - autoSuspendEnabled: true, - maxActiveWorktrees: 5, - autoSuspendAfterDays: 7, - }, - updateSettings, - }; -} diff --git a/apps/code/src/renderer/features/task-detail/components/RunModeSelect.tsx b/apps/code/src/renderer/features/task-detail/components/RunModeSelect.tsx deleted file mode 100644 index b618313ed0..0000000000 --- a/apps/code/src/renderer/features/task-detail/components/RunModeSelect.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Cloud, Desktop } from "@phosphor-icons/react"; -import { ChevronDownIcon } from "@radix-ui/react-icons"; -import { Button, DropdownMenu, Flex, Text } from "@radix-ui/themes"; -import type { Responsive } from "@radix-ui/themes/dist/esm/props/prop-def.js"; - -export type RunMode = "local" | "cloud"; - -interface RunModeSelectProps { - value: RunMode; - onChange: (mode: RunMode) => void; - size?: Responsive<"1" | "2">; -} - -const MODE_CONFIG: Record = { - local: { - label: "Local", - icon: , - }, - cloud: { - label: "Cloud", - icon: , - }, -}; - -export function RunModeSelect({ - value, - onChange, - size = "1", -}: RunModeSelectProps) { - const textSizeClass = size === "1" ? "text-[13px]" : "text-sm"; - return ( - - - - - - - onChange("local")}> - - - Local - - - onChange("cloud")}> - - - Cloud - - - - - ); -} diff --git a/apps/code/src/renderer/features/task-detail/hooks/useCloudRunState.ts b/apps/code/src/renderer/features/task-detail/hooks/useCloudRunState.ts deleted file mode 100644 index d02bb211c5..0000000000 --- a/apps/code/src/renderer/features/task-detail/hooks/useCloudRunState.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { resolveCloudPrUrl } from "@features/git-interaction/hooks/useCloudPrUrl"; -import { useSessionForTask } from "@features/sessions/hooks/useSession"; -import { useCloudEventSummary } from "@features/task-detail/hooks/useCloudEventSummary"; -import { extractCloudToolChangedFiles } from "@features/task-detail/utils/cloudToolChanges"; -import { useTasks } from "@features/tasks/hooks/useTasks"; -import type { Task } from "@shared/types"; -import { useMemo } from "react"; - -export function useCloudRunState(taskId: string, task: Task) { - const { data: tasks = [] } = useTasks(); - const freshTask = useMemo( - () => tasks.find((t) => t.id === taskId) ?? task, - [tasks, taskId, task], - ); - - const session = useSessionForTask(taskId); - - const prUrl = resolveCloudPrUrl(freshTask, session); - const branch = freshTask.latest_run?.branch ?? null; - const cloudBranch = session?.cloudBranch ?? null; - const effectiveBranch = branch ?? cloudBranch; - const repo = freshTask.repository ?? null; - - const cloudStatus = - session?.cloudStatus ?? freshTask.latest_run?.status ?? null; - const isRunActive = - cloudStatus === "queued" || - cloudStatus === "in_progress" || - (cloudStatus === null && session != null); - - const summary = useCloudEventSummary(taskId); - const fallbackFiles = useMemo( - () => extractCloudToolChangedFiles(summary.toolCalls), - [summary], - ); - - return { - freshTask, - session, - prUrl, - effectiveBranch, - repo, - cloudStatus, - isRunActive, - fallbackFiles, - toolCalls: summary.toolCalls, - }; -} diff --git a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts deleted file mode 100644 index 1c7c950c35..0000000000 --- a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts +++ /dev/null @@ -1,272 +0,0 @@ -import type { SessionConfigOption } from "@agentclientprotocol/sdk"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; -import { trpcClient } from "@renderer/trpc/client"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { logger } from "@utils/logger"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { flattenConfigValues } from "../utils/configOptions"; - -const log = logger.scope("preview-config"); - -interface PreviewConfigResult { - configOptions: SessionConfigOption[]; - modeOption: SessionConfigOption | undefined; - modelOption: SessionConfigOption | undefined; - thoughtOption: SessionConfigOption | undefined; - isLoading: boolean; - setConfigOption: (configId: string, value: string) => void; -} - -function getOptionByCategory( - options: SessionConfigOption[], - category: string, -): SessionConfigOption | undefined { - return options.find( - (opt) => opt.category === category || opt.id === category, - ); -} - -const EFFORT_RANK: Record = { - low: 0, - medium: 1, - high: 2, - xhigh: 3, - max: 4, -}; - -/** - * Clamp a desired effort to the nearest level the current model supports. - * Falls back to the highest supported level when the desired level has no - * known rank (e.g. unrecognized value from older settings). - */ -function clampEffortToAvailable( - desired: string, - available: string[], -): string | null { - if (available.length === 0) return null; - if (available.includes(desired)) return desired; - - const desiredRank = EFFORT_RANK[desired]; - if (desiredRank === undefined) { - return available[available.length - 1]; - } - - const ranked = available - .map((value) => ({ value, rank: EFFORT_RANK[value] })) - .filter((entry): entry is { value: string; rank: number } => - Number.isFinite(entry.rank), - ); - if (ranked.length === 0) return available[0]; - - return ranked.reduce((closest, entry) => - Math.abs(entry.rank - desiredRank) < Math.abs(closest.rank - desiredRank) - ? entry - : closest, - ).value; -} - -/** - * Fetches config options (models, modes, effort levels) for the task input - * page via a lightweight tRPC query. No agent session is created. - * - * Returns config options as local state with a setter for local updates. - */ -export function usePreviewConfig( - adapter: "claude" | "codex", -): PreviewConfigResult { - const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - const apiHost = useMemo( - () => (cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null), - [cloudRegion], - ); - const [configOptions, setConfigOptions] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const abortRef = useRef(null); - - useEffect(() => { - if (!apiHost) return; - - abortRef.current?.abort(); - const abort = new AbortController(); - abortRef.current = abort; - - setIsLoading(true); - - trpcClient.agent.getPreviewConfigOptions - .query({ apiHost, adapter }) - .then((options) => { - if (abort.signal.aborted) return; - - const { - defaultInitialTaskMode, - lastUsedInitialTaskMode, - defaultReasoningEffort, - lastUsedReasoningEffort, - } = useSettingsStore.getState(); - - // Use the mode option's existing currentValue (set by the server - // based on the adapter) when the user hasn't chosen a preference, - // or when their last-used mode doesn't match the current adapter's - // available modes. - const modeOpt = options.find((o) => o.id === "mode"); - const serverDefault = modeOpt?.currentValue; - const availableValues: string[] = modeOpt - ? flattenConfigValues(modeOpt) - : []; - - let initialMode: string; - if ( - defaultInitialTaskMode === "last_used" && - lastUsedInitialTaskMode && - availableValues.includes(lastUsedInitialTaskMode) - ) { - initialMode = lastUsedInitialTaskMode; - } else { - const fallbackDefault = adapter === "codex" ? "auto" : "plan"; - initialMode = - typeof serverDefault === "string" && - availableValues.includes(serverDefault) - ? serverDefault - : fallbackDefault; - } - - const withMode = options.map((opt) => - opt.id === "mode" - ? ({ ...opt, currentValue: initialMode } as SessionConfigOption) - : opt, - ); - - const withEffort = withMode.map((opt) => { - if (opt.category !== "thought_level" || opt.type !== "select") { - return opt; - } - const validValues = flattenConfigValues(opt); - if (defaultReasoningEffort === "last_used") { - if ( - lastUsedReasoningEffort && - validValues.includes(lastUsedReasoningEffort) - ) { - return { - ...opt, - currentValue: lastUsedReasoningEffort, - } as SessionConfigOption; - } - return opt; - } - const clamped = clampEffortToAvailable( - defaultReasoningEffort, - validValues, - ); - if (clamped) { - return { - ...opt, - currentValue: clamped, - } as SessionConfigOption; - } - return opt; - }); - - setConfigOptions(withEffort); - setIsLoading(false); - }) - .catch((error) => { - if (abort.signal.aborted) return; - log.error("Failed to fetch preview config options", { error }); - setIsLoading(false); - }); - - return () => { - abort.abort(); - }; - }, [adapter, apiHost]); - - const setConfigOption = useCallback( - (configId: string, value: string) => { - setConfigOptions((prev) => { - let updated = prev.map((opt) => - opt.id === configId - ? ({ ...opt, currentValue: value } as SessionConfigOption) - : opt, - ); - - if (configId === "model") { - const effortOpts = getReasoningEffortOptions(adapter, value); - const existingIdx = updated.findIndex( - (o) => o.category === "thought_level", - ); - const effortOptionId = - existingIdx >= 0 - ? updated[existingIdx].id - : adapter === "codex" - ? "reasoning_effort" - : "effort"; - - const { lastUsedReasoningEffort, defaultReasoningEffort } = - useSettingsStore.getState(); - const isValidEffort = (effort: unknown): effort is string => - typeof effort === "string" && - !!effortOpts?.some((e) => e.value === effort); - const resolveEffortFallback = (): string => { - if ( - defaultReasoningEffort !== "last_used" && - isValidEffort(defaultReasoningEffort) - ) { - return defaultReasoningEffort; - } - return isValidEffort(lastUsedReasoningEffort) - ? lastUsedReasoningEffort - : "high"; - }; - if (effortOpts && existingIdx >= 0) { - const currentEffort = updated[existingIdx].currentValue; - const nextEffort = isValidEffort(currentEffort) - ? currentEffort - : resolveEffortFallback(); - updated[existingIdx] = { - ...updated[existingIdx], - currentValue: nextEffort, - options: effortOpts, - } as SessionConfigOption; - } else if (effortOpts && existingIdx === -1) { - const nextEffort = resolveEffortFallback(); - updated = [ - ...updated, - { - id: effortOptionId, - name: adapter === "codex" ? "Reasoning Level" : "Effort", - type: "select", - currentValue: nextEffort, - options: effortOpts, - category: "thought_level", - description: - adapter === "codex" - ? "Controls how much reasoning effort the model uses" - : "Controls how much effort Claude puts into its response", - } as SessionConfigOption, - ]; - } else if (!effortOpts && existingIdx >= 0) { - updated = updated.filter((o) => o.category !== "thought_level"); - } - } - - return updated; - }); - }, - [adapter], - ); - - const modeOption = getOptionByCategory(configOptions, "mode"); - const modelOption = getOptionByCategory(configOptions, "model"); - const thoughtOption = getOptionByCategory(configOptions, "thought_level"); - - return { - configOptions, - modeOption, - modelOption, - thoughtOption, - isLoading, - setConfigOption, - }; -} diff --git a/apps/code/src/renderer/features/task-detail/service/service.ts b/apps/code/src/renderer/features/task-detail/service/service.ts deleted file mode 100644 index 11adeb8711..0000000000 --- a/apps/code/src/renderer/features/task-detail/service/service.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useDraftStore } from "@features/message-editor/stores/draftStore"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; -import type { Workspace } from "@main/services/workspace/schemas"; -import type { SagaResult } from "@posthog/shared"; -import { - type TaskCreationInput, - type TaskCreationOutput, - TaskCreationSaga, -} from "@renderer/sagas/task/task-creation"; -import { trpc } from "@renderer/trpc"; -import { logger } from "@utils/logger"; -import { queryClient } from "@utils/queryClient"; -import { injectable } from "inversify"; - -export type { TaskCreationInput, TaskCreationOutput }; - -const log = logger.scope("task-service"); - -export type CreateTaskResult = SagaResult; - -@injectable() -export class TaskService { - /** - * Create a task with workspace provisioning. - * - * This method: - * 2. Executes the TaskCreationSaga (with automatic rollback on failure) - * 3. Updates renderer stores on success - * 4. Returns a typed result for the hook to handle UI effects - */ - public async createTask( - input: TaskCreationInput, - onTaskReady?: (output: TaskCreationOutput) => void, - ): Promise { - log.info("Creating task", { - workspaceMode: input.workspaceMode, - hasContent: !!input.content, - hasRepo: !!input.repository, - }); - - if (!input.content?.trim()) { - return { - success: false, - error: "Task description cannot be empty", - failedStep: "validation", - }; - } - - const posthogClient = await getAuthenticatedClient(); - if (!posthogClient) { - return { - success: false, - error: "Not authenticated", - failedStep: "validation", - }; - } - - const saga = new TaskCreationSaga({ - posthogClient, - onTaskReady: onTaskReady - ? (output) => { - this.optimisticallyUpdateWorkspaceCache(output); - this.updateStoresOnSuccess(output, input); - void queryClient.invalidateQueries( - trpc.workspace.getAll.pathFilter(), - ); - onTaskReady(output); - } - : undefined, - }); - - const result = await saga.run(input); - - if (result.success) { - this.optimisticallyUpdateWorkspaceCache(result.data); - if (!onTaskReady) { - this.updateStoresOnSuccess(result.data, input); - } - void queryClient.invalidateQueries(trpc.workspace.getAll.pathFilter()); - } - - return result; - } - - /** - * Open an existing task by ID, optionally loading a specific run. - * If the workspace already exists, just fetches task data. - * Otherwise runs the full saga to set up the workspace. - */ - public async openTask( - taskId: string, - taskRunId?: string, - ): Promise { - log.info("Opening existing task", { taskId, taskRunId }); - - const posthogClient = await getAuthenticatedClient(); - if (!posthogClient) { - return { - success: false, - error: "Not authenticated", - failedStep: "validation", - }; - } - - const existingWorkspace = await workspaceApi.get(taskId); - if (existingWorkspace) { - log.info("Workspace already exists, fetching task only", { taskId }); - try { - const task = await posthogClient.getTask(taskId); - - // If a specific run was requested, fetch and use it - if (taskRunId) { - log.info("Fetching specific task run", { taskId, taskRunId }); - const run = await posthogClient.getTaskRun(taskId, taskRunId); - task.latest_run = run; - } - - return { - success: true, - data: { - task: task as unknown as import("@shared/types").Task, - workspace: existingWorkspace, - }, - }; - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : "Failed to fetch task", - failedStep: "fetch_task", - }; - } - } - - // No existing workspace - run full saga to set it up - const saga = new TaskCreationSaga({ posthogClient }); - const result = await saga.run({ taskId }); - - if (result.success) { - this.optimisticallyUpdateWorkspaceCache(result.data); - this.updateStoresOnSuccess(result.data); - void queryClient.invalidateQueries(trpc.workspace.getAll.pathFilter()); - - // If a specific run was requested, update the task with that run - if (taskRunId && result.data.task) { - try { - log.info("Fetching specific task run for new workspace", { - taskId, - taskRunId, - }); - const run = await posthogClient.getTaskRun(taskId, taskRunId); - result.data.task.latest_run = run; - } catch (error) { - log.warn("Failed to fetch specific task run, using latest", { - taskId, - taskRunId, - error, - }); - } - } - } - - return result; - } - - private optimisticallyUpdateWorkspaceCache(output: TaskCreationOutput): void { - if (!output.workspace) return; - const workspace = output.workspace; - queryClient.setQueriesData>( - trpc.workspace.getAll.pathFilter(), - (old) => ({ ...old, [output.task.id]: workspace }), - ); - } - - /** - * Batch update stores after successful task creation/open. - */ - private updateStoresOnSuccess( - output: TaskCreationOutput, - input?: TaskCreationInput, - ): void { - const settings = useSettingsStore.getState(); - const draftStore = useDraftStore.getState(); - - const workspaceMode = - input?.workspaceMode ?? output.workspace?.mode ?? "local"; - - if (input) { - settings.setLastUsedWorkspaceMode(workspaceMode); - - if (workspaceMode === "cloud") { - settings.setLastUsedRunMode("cloud"); - } else { - settings.setLastUsedRunMode("local"); - settings.setLastUsedLocalWorkspaceMode( - workspaceMode as "worktree" | "local", - ); - } - - draftStore.actions.setDraft("task-input", null); - } - } -} diff --git a/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts b/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts deleted file mode 100644 index b36240e773..0000000000 --- a/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { useCommandCenterStore } from "@features/command-center/stores/commandCenterStore"; -import { getSessionService } from "@features/sessions/service/service"; -import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; -import { useTerminalStore } from "@features/terminal/stores/terminalStore"; -import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; -import { trpc, trpcClient } from "@renderer/trpc"; -import type { ArchivedTask } from "@shared/types/archive"; -import { useFocusStore } from "@stores/focusStore"; -import { useNavigationStore } from "@stores/navigationStore"; -import { type QueryClient, useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; -import { toast } from "@utils/toast"; - -const log = logger.scope("archive-task"); - -interface ArchiveTaskOptions { - skipNavigate?: boolean; -} - -export async function archiveTaskImperative( - taskId: string, - queryClient: QueryClient, - options?: ArchiveTaskOptions, -): Promise { - const focusStore = useFocusStore.getState(); - const workspace = await workspaceApi.get(taskId); - const pinnedTaskIds = await pinnedTasksApi.getPinnedTaskIds(); - const wasPinned = pinnedTaskIds.includes(taskId); - - if (!options?.skipNavigate) { - const nav = useNavigationStore.getState(); - if (nav.view.type === "task-detail" && nav.view.data?.id === taskId) { - nav.navigateToTaskInput(); - } - } - - const terminalStatesSnapshot = Object.fromEntries( - Object.entries(useTerminalStore.getState().terminalStates).filter( - ([key]) => key === taskId || key.startsWith(`${taskId}-`), - ), - ); - const commandCenterState = useCommandCenterStore.getState(); - const commandCenterIndex = commandCenterState.cells.indexOf(taskId); - const wasActiveInCommandCenter = commandCenterState.activeTaskId === taskId; - - pinnedTasksApi.unpin(taskId); - useTerminalStore.getState().clearTerminalStatesForTask(taskId); - useCommandCenterStore.getState().removeTaskById(taskId); - - await queryClient.cancelQueries(trpc.archive.pathFilter()); - - queryClient.setQueryData( - trpc.archive.archivedTaskIds.queryKey(), - (old) => (old ? [...old, taskId] : [taskId]), - ); - - const optimisticArchived: ArchivedTask = { - taskId, - archivedAt: new Date().toISOString(), - folderId: workspace?.folderId ?? "", - mode: workspace?.mode ?? "worktree", - worktreeName: workspace?.worktreeName ?? null, - branchName: workspace?.branchName ?? null, - checkpointId: null, - }; - queryClient.setQueryData( - trpc.archive.list.queryKey(), - (old) => (old ? [...old, optimisticArchived] : [optimisticArchived]), - ); - - if ( - workspace?.worktreePath && - focusStore.session?.worktreePath === workspace.worktreePath - ) { - log.info("Unfocusing workspace before archiving"); - await focusStore.disableFocus(); - } - - try { - await getSessionService().disconnectFromTask(taskId); - - await trpcClient.archive.archive.mutate({ - taskId, - }); - - queryClient.invalidateQueries(trpc.archive.pathFilter()); - } catch (error) { - log.error("Failed to archive task", error); - - queryClient.setQueryData( - trpc.archive.archivedTaskIds.queryKey(), - (old) => (old ? old.filter((id) => id !== taskId) : []), - ); - queryClient.setQueryData( - trpc.archive.list.queryKey(), - (old) => (old ? old.filter((a) => a.taskId !== taskId) : []), - ); - if (wasPinned) { - pinnedTasksApi.togglePin(taskId); - } - if (Object.keys(terminalStatesSnapshot).length > 0) { - useTerminalStore.setState((s) => ({ - terminalStates: { ...s.terminalStates, ...terminalStatesSnapshot }, - })); - } - if (commandCenterIndex !== -1) { - useCommandCenterStore.setState((s) => { - const cells = [...s.cells]; - cells[commandCenterIndex] = taskId; - return wasActiveInCommandCenter - ? { cells, activeTaskId: taskId } - : { cells }; - }); - } - - throw error; - } -} - -export async function archiveTasksImperative( - taskIds: string[], - queryClient: QueryClient, -): Promise<{ archived: number; failed: number }> { - if (taskIds.length === 0) return { archived: 0, failed: 0 }; - - const nav = useNavigationStore.getState(); - const idSet = new Set(taskIds); - if ( - nav.view.type === "task-detail" && - nav.view.data && - idSet.has(nav.view.data.id) - ) { - nav.navigateToTaskInput(); - } - - let archived = 0; - let failed = 0; - for (const id of taskIds) { - try { - await archiveTaskImperative(id, queryClient, { skipNavigate: true }); - archived++; - } catch { - failed++; - } - } - return { archived, failed }; -} - -export function useArchiveTask() { - const queryClient = useQueryClient(); - - const archiveTask = async ({ taskId }: { taskId: string }) => { - await archiveTaskImperative(taskId, queryClient); - toast.success("Task archived"); - }; - - return { archiveTask }; -} diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts deleted file mode 100644 index 3bf0f73a8d..0000000000 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { getSessionService } from "@features/sessions/service/service"; -import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; -import { taskKeys } from "@features/tasks/hooks/taskKeys"; -import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; -import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import { useMeQuery } from "@hooks/useMeQuery"; -import type { Schemas } from "@posthog/api-client"; -import { useFocusStore } from "@renderer/stores/focusStore"; -import { useNavigationStore } from "@renderer/stores/navigationStore"; -import { trpcClient } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; -import { keepPreviousData, useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; -import { useCallback } from "react"; - -const log = logger.scope("tasks"); - -const TASK_LIST_POLL_INTERVAL_MS = 30_000; - -function getTaskTitle( - tasks: Task[] | undefined, - taskId: string, -): string | undefined { - return tasks?.find((task) => task.id === taskId)?.title; -} - -function getTaskSummaryTitle( - summaries: Schemas.TaskSummary[] | undefined, - taskId: string, -): string | undefined { - return summaries?.find((summary) => summary.id === taskId)?.title; -} - -export function useTasks( - filters?: { - repository?: string; - showAllUsers?: boolean; - showInternal?: boolean; - }, - options?: { enabled?: boolean }, -) { - const { data: currentUser } = useMeQuery(); - const createdBy = filters?.showAllUsers ? undefined : currentUser?.id; - const internal = filters?.showInternal ? true : undefined; - - return useAuthenticatedQuery( - taskKeys.list({ repository: filters?.repository, createdBy, internal }), - (client) => - client.getTasks({ - repository: filters?.repository, - createdBy, - internal, - }) as unknown as Promise, - { - enabled: (options?.enabled ?? true) && !!currentUser?.id, - refetchInterval: TASK_LIST_POLL_INTERVAL_MS, - }, - ); -} - -export function useTaskSummaries( - ids: string[], - options?: { enabled?: boolean }, -) { - return useAuthenticatedQuery( - taskKeys.summaries(ids), - (client) => client.getTaskSummaries(ids), - { - enabled: (options?.enabled ?? true) && ids.length > 0, - refetchInterval: TASK_LIST_POLL_INTERVAL_MS, - placeholderData: keepPreviousData, - }, - ); -} - -// The /tasks/summaries/ endpoint doesn't include origin_product, so fetch the -// slack-origin subset separately and intersect by id in the sidebar. The -// `internal` filter mirrors the sidebar's task-visibility scope so staff -// toggling the internal view still see slack icons on internal tasks. -export function useSlackTasks(options?: { - enabled?: boolean; - showInternal?: boolean; -}) { - const internal = options?.showInternal ? true : undefined; - return useAuthenticatedQuery( - taskKeys.list({ originProduct: "slack", internal }), - (client) => - client.getTasks({ - originProduct: "slack", - internal, - }) as unknown as Promise, - { - enabled: options?.enabled ?? true, - refetchInterval: TASK_LIST_POLL_INTERVAL_MS, - }, - ); -} - -export function useCreateTask() { - const queryClient = useQueryClient(); - - const invalidateTasks = (newTask?: Task) => { - if (newTask) { - queryClient.setQueriesData( - { queryKey: taskKeys.lists() }, - (old) => { - if (!old) return old; - if (old.some((task) => task.id === newTask.id)) return old; - return [newTask, ...old]; - }, - ); - } - queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }; - - const mutation = useAuthenticatedMutation( - ( - client, - { - description, - repository, - github_integration, - }: { - description: string; - repository?: string; - github_integration?: number; - createdFrom?: "cli" | "command-menu"; - }, - ) => - client.createTask({ - description, - repository, - github_integration, - }) as unknown as Promise, - ); - - return { ...mutation, invalidateTasks }; -} - -export function useUpdateTask() { - const queryClient = useQueryClient(); - - return useAuthenticatedMutation( - ( - client, - { - taskId, - updates, - }: { - taskId: string; - updates: Partial; - }, - ) => - client.updateTask( - taskId, - updates as Parameters[1], - ), - { - onSuccess: (_, { taskId }) => { - queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - queryClient.invalidateQueries({ queryKey: taskKeys.detail(taskId) }); - queryClient.invalidateQueries({ queryKey: taskKeys.allSummaries() }); - }, - }, - ); -} - -export function useRenameTask() { - const queryClient = useQueryClient(); - const updateTask = useUpdateTask(); - - const renameTask = useCallback( - async ({ - taskId, - currentTitle, - newTitle, - }: { - taskId: string; - currentTitle: string; - newTitle: string; - }) => { - const previousListQueries = queryClient.getQueriesData({ - queryKey: taskKeys.lists(), - }); - const previousSummaryQueries = queryClient.getQueriesData< - Schemas.TaskSummary[] - >({ - queryKey: taskKeys.allSummaries(), - }); - const previousDetail = queryClient.getQueryData( - taskKeys.detail(taskId), - ); - - queryClient.setQueriesData( - { queryKey: taskKeys.lists() }, - (old) => - old?.map((task) => - task.id === taskId - ? { ...task, title: newTitle, title_manually_set: true } - : task, - ), - ); - queryClient.setQueriesData( - { queryKey: taskKeys.allSummaries() }, - (old) => - old?.map((task) => - task.id === taskId ? { ...task, title: newTitle } : task, - ), - ); - - if (previousDetail) { - queryClient.setQueryData(taskKeys.detail(taskId), { - ...previousDetail, - title: newTitle, - title_manually_set: true, - }); - } - - getSessionService().updateSessionTaskTitle(taskId, newTitle); - - try { - await updateTask.mutateAsync({ - taskId, - updates: { title: newTitle, title_manually_set: true }, - }); - } catch (error) { - const shouldRollbackSessionTitle = - queryClient.getQueryData(taskKeys.detail(taskId))?.title === - newTitle || - queryClient - .getQueriesData({ - queryKey: taskKeys.lists(), - }) - .some(([, tasks]) => getTaskTitle(tasks, taskId) === newTitle); - - for (const [queryKey, data] of previousListQueries) { - queryClient.setQueryData(queryKey, (current) => { - if (!current) { - return data; - } - - return getTaskTitle(current, taskId) === newTitle ? data : current; - }); - } - for (const [queryKey, data] of previousSummaryQueries) { - queryClient.setQueryData( - queryKey, - (current) => { - if (!current) { - return data; - } - - return getTaskSummaryTitle(current, taskId) === newTitle - ? data - : current; - }, - ); - } - if (previousDetail) { - queryClient.setQueryData( - taskKeys.detail(taskId), - (current) => { - if (!current) { - return previousDetail; - } - - return current.title === newTitle ? previousDetail : current; - }, - ); - } - if (shouldRollbackSessionTitle) { - getSessionService().updateSessionTaskTitle(taskId, currentTitle); - } - throw error; - } - }, - [queryClient, updateTask], - ); - - return { - renameTask, - isPending: updateTask.isPending, - }; -} - -interface DeleteTaskOptions { - taskId: string; - taskTitle: string; - hasWorktree: boolean; -} - -export function useDeleteTask() { - const queryClient = useQueryClient(); - const { view, navigateToTaskInput } = useNavigationStore(); - - const mutation = useAuthenticatedMutation( - async (client, taskId: string) => { - const focusStore = useFocusStore.getState(); - const workspace = await workspaceApi.get(taskId); - - if (workspace) { - if ( - focusStore.session?.worktreePath === workspace.worktreePath && - workspace.worktreePath - ) { - log.info("Unfocusing workspace before deletion"); - await focusStore.disableFocus(); - } - - try { - await workspaceApi.delete(taskId, workspace.folderPath); - } catch (error) { - log.error("Failed to delete workspace:", error); - } - } - - return client.deleteTask(taskId); - }, - { - onMutate: async (taskId) => { - // Cancel outgoing refetches to avoid overwriting optimistic update - await queryClient.cancelQueries({ queryKey: taskKeys.lists() }); - - // Snapshot all task list queries for rollback - const previousQueries: Array<{ queryKey: unknown; data: Task[] }> = []; - const queries = queryClient.getQueriesData({ - queryKey: taskKeys.lists(), - }); - for (const [queryKey, data] of queries) { - if (data) { - previousQueries.push({ queryKey, data }); - } - } - - // Optimistically remove the task from all list queries - queryClient.setQueriesData( - { queryKey: taskKeys.lists() }, - (old) => old?.filter((task) => task.id !== taskId), - ); - - return { previousQueries }; - }, - onError: (_err, _taskId, context) => { - // Rollback all queries on error - const ctx = context as - | { - previousQueries: Array<{ - queryKey: readonly unknown[]; - data: Task[]; - }>; - } - | undefined; - if (ctx?.previousQueries) { - for (const { queryKey, data } of ctx.previousQueries) { - queryClient.setQueryData(queryKey, data); - } - } - }, - onSettled: () => { - // Always refetch to ensure sync with server - queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - }, - }, - ); - - const deleteWithConfirm = useCallback( - async ({ taskId, taskTitle, hasWorktree }: DeleteTaskOptions) => { - const result = await trpcClient.contextMenu.confirmDeleteTask.mutate({ - taskTitle, - hasWorktree, - }); - - if (!result.confirmed) { - return false; - } - - // Navigate away if viewing the deleted task - if (view.type === "task-detail" && view.data?.id === taskId) { - navigateToTaskInput(); - } - - pinnedTasksApi.unpin(taskId); - - await mutation.mutateAsync(taskId); - - return true; - }, - [mutation, view, navigateToTaskInput], - ); - - return { ...mutation, deleteWithConfirm }; -} diff --git a/apps/code/src/renderer/features/tasks/stores/taskStore.ts b/apps/code/src/renderer/features/tasks/stores/taskStore.ts deleted file mode 100644 index 5c53130bc9..0000000000 --- a/apps/code/src/renderer/features/tasks/stores/taskStore.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import type { - FilterCategory, - FilterOperator, - TaskState, -} from "./taskStore.types"; - -function getDefaultOperator(category: FilterCategory): FilterOperator { - return category === "created_at" ? "after" : "is"; -} - -function toggleOperator( - category: FilterCategory, - operator: FilterOperator, -): FilterOperator { - if (category === "created_at") { - return operator === "before" ? "after" : "before"; - } - return operator === "is" ? "is_not" : "is"; -} - -export const useTaskStore = create()( - persist( - (set) => ({ - selectedIndex: null, - hoveredIndex: null, - contextMenuIndex: null, - filter: "", - orderBy: "created_at", - orderDirection: "desc", - groupBy: "none", - expandedGroups: {}, - activeFilters: {}, - filterMatchMode: "all", - filterSearchQuery: "", - filterMenuSelectedIndex: -1, - isFilterDropdownOpen: false, - editingFilterBadgeKey: null, - - setSelectedIndex: (index) => set({ selectedIndex: index }), - setHoveredIndex: (index) => set({ hoveredIndex: index }), - setContextMenuIndex: (index) => set({ contextMenuIndex: index }), - - setFilter: (filter) => set({ filter }), - setOrderBy: (orderBy) => set({ orderBy }), - setOrderDirection: (orderDirection) => set({ orderDirection }), - setGroupBy: (groupBy) => set({ groupBy }), - - toggleGroupExpanded: (groupName) => - set((state) => ({ - expandedGroups: { - ...state.expandedGroups, - [groupName]: !(state.expandedGroups[groupName] ?? true), - }, - })), - - setActiveFilters: (filters) => set({ activeFilters: filters }), - clearActiveFilters: () => set({ activeFilters: {} }), - - toggleFilter: (category, value, operator) => - set((state) => { - const currentFilters = state.activeFilters[category] || []; - const existingFilter = currentFilters.find((f) => f.value === value); - - if (existingFilter) { - const newFilters = currentFilters.filter((f) => f.value !== value); - return { - activeFilters: { - ...state.activeFilters, - [category]: newFilters.length > 0 ? newFilters : undefined, - }, - }; - } - - return { - activeFilters: { - ...state.activeFilters, - [category]: [ - ...currentFilters, - { value, operator: operator ?? getDefaultOperator(category) }, - ], - }, - }; - }), - - addFilter: (category, value, operator) => - set((state) => ({ - activeFilters: { - ...state.activeFilters, - [category]: [ - ...(state.activeFilters[category] || []), - { value, operator: operator ?? getDefaultOperator(category) }, - ], - }, - })), - - updateFilter: (category, oldValue, newValue) => - set((state) => { - const currentFilters = state.activeFilters[category] || []; - const filterIndex = currentFilters.findIndex( - (f) => f.value === oldValue, - ); - - if (filterIndex === -1) return state; - - const updatedFilters = [...currentFilters]; - updatedFilters[filterIndex] = { - ...updatedFilters[filterIndex], - value: newValue, - }; - - return { - activeFilters: { - ...state.activeFilters, - [category]: updatedFilters, - }, - }; - }), - - toggleFilterOperator: (category, value) => - set((state) => { - const currentFilters = state.activeFilters[category] || []; - const filterIndex = currentFilters.findIndex( - (f) => f.value === value, - ); - - if (filterIndex === -1) return state; - - const updatedFilters = [...currentFilters]; - const currentOperator = updatedFilters[filterIndex].operator; - - updatedFilters[filterIndex] = { - ...updatedFilters[filterIndex], - operator: toggleOperator(category, currentOperator), - }; - - return { - activeFilters: { - ...state.activeFilters, - [category]: updatedFilters, - }, - }; - }), - - setFilterMatchMode: (mode) => set({ filterMatchMode: mode }), - setFilterSearchQuery: (query) => set({ filterSearchQuery: query }), - setFilterMenuSelectedIndex: (index) => - set({ filterMenuSelectedIndex: index }), - setIsFilterDropdownOpen: (open) => set({ isFilterDropdownOpen: open }), - setEditingFilterBadgeKey: (key) => set({ editingFilterBadgeKey: key }), - }), - { - name: "task-store", - partialize: (state) => ({ - orderBy: state.orderBy, - orderDirection: state.orderDirection, - groupBy: state.groupBy, - expandedGroups: state.expandedGroups, - activeFilters: state.activeFilters, - filterMatchMode: state.filterMatchMode, - }), - }, - ), -); diff --git a/apps/code/src/renderer/features/tour/stores/tourStore.ts b/apps/code/src/renderer/features/tour/stores/tourStore.ts deleted file mode 100644 index ee8a2ad7bc..0000000000 --- a/apps/code/src/renderer/features/tour/stores/tourStore.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import { createFirstTaskTour } from "../tours/createFirstTaskTour"; -import { TOUR_REGISTRY } from "../tours/tourRegistry"; - -interface TourStoreState { - completedTourIds: string[]; - activeTourId: string | null; - activeStepIndex: number; -} - -interface TourStoreActions { - startTour: (tourId: string) => void; - advance: (tourId: string, stepId: string) => void; - completeTour: (tourId: string) => void; - dismiss: () => void; - resetTours: () => void; -} - -type TourStore = TourStoreState & TourStoreActions; - -export const useTourStore = create()( - persist( - (set, get) => ({ - completedTourIds: [], - activeTourId: null, - activeStepIndex: 0, - - startTour: (tourId) => { - const { completedTourIds, activeTourId } = get(); - if (completedTourIds.includes(tourId) || activeTourId === tourId) - return; - const tour = TOUR_REGISTRY[tourId]; - set({ activeTourId: tourId, activeStepIndex: 0 }); - track(ANALYTICS_EVENTS.TOUR_EVENT, { - tour_id: tourId, - action: "started", - step_id: tour?.steps[0]?.id, - step_index: 0, - total_steps: tour?.steps.length, - }); - }, - - advance: (tourId, stepId) => { - const { activeTourId, activeStepIndex } = get(); - if (activeTourId !== tourId) return; - - const tour = TOUR_REGISTRY[activeTourId]; - if (!tour) return; - - const currentStep = tour.steps[activeStepIndex]; - if (!currentStep || currentStep.id !== stepId) return; - - track(ANALYTICS_EVENTS.TOUR_EVENT, { - tour_id: tourId, - action: "step_advanced", - step_id: stepId, - step_index: activeStepIndex, - total_steps: tour.steps.length, - }); - - if (activeStepIndex >= tour.steps.length - 1) { - set((state) => { - if (!state.activeTourId) return state; - return { - completedTourIds: [...state.completedTourIds, state.activeTourId], - activeTourId: null, - activeStepIndex: 0, - }; - }); - track(ANALYTICS_EVENTS.TOUR_EVENT, { - tour_id: tourId, - action: "completed", - total_steps: tour.steps.length, - }); - } else { - set({ activeStepIndex: activeStepIndex + 1 }); - } - }, - - completeTour: (tourId) => { - const { completedTourIds } = get(); - if (completedTourIds.includes(tourId)) return; - const tour = TOUR_REGISTRY[tourId]; - set({ - completedTourIds: [...completedTourIds, tourId], - activeTourId: null, - activeStepIndex: 0, - }); - track(ANALYTICS_EVENTS.TOUR_EVENT, { - tour_id: tourId, - action: "completed", - total_steps: tour?.steps.length, - }); - }, - - dismiss: () => { - const { activeTourId, activeStepIndex } = get(); - if (!activeTourId) return; - const tour = TOUR_REGISTRY[activeTourId]; - track(ANALYTICS_EVENTS.TOUR_EVENT, { - tour_id: activeTourId, - action: "dismissed", - step_id: tour?.steps[activeStepIndex]?.id, - step_index: activeStepIndex, - total_steps: tour?.steps.length, - }); - set((state) => ({ - completedTourIds: [...state.completedTourIds, activeTourId], - activeTourId: null, - activeStepIndex: 0, - })); - }, - - resetTours: () => { - set({ completedTourIds: [], activeTourId: null, activeStepIndex: 0 }); - }, - }), - { - name: "tour-store", - partialize: (state) => ({ - completedTourIds: state.completedTourIds, - }), - onRehydrateStorage: () => () => { - const migrationKey = "tour-store-v1-migrated"; - if (localStorage.getItem(migrationKey)) return; - localStorage.setItem(migrationKey, "1"); - - const { hasCompletedOnboarding } = useOnboardingStore.getState(); - if (hasCompletedOnboarding) { - useTourStore.getState().completeTour(createFirstTaskTour.id); - } - }, - }, - ), -); diff --git a/apps/code/src/renderer/features/tour/tours/tourRegistry.ts b/apps/code/src/renderer/features/tour/tours/tourRegistry.ts deleted file mode 100644 index c5c4b0f944..0000000000 --- a/apps/code/src/renderer/features/tour/tours/tourRegistry.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { TourDefinition } from "../types"; -import { createFirstTaskTour } from "./createFirstTaskTour"; - -export const TOUR_REGISTRY: Record = { - [createFirstTaskTour.id]: createFirstTaskTour, -}; diff --git a/apps/code/src/renderer/features/workspace/hooks/index.ts b/apps/code/src/renderer/features/workspace/hooks/index.ts deleted file mode 100644 index dc278d9024..0000000000 --- a/apps/code/src/renderer/features/workspace/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useWorkspaceEvents } from "./useWorkspaceEvents"; diff --git a/apps/code/src/renderer/features/workspace/hooks/useLocalRepoPath.ts b/apps/code/src/renderer/features/workspace/hooks/useLocalRepoPath.ts deleted file mode 100644 index 13bf3e476a..0000000000 --- a/apps/code/src/renderer/features/workspace/hooks/useLocalRepoPath.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; -import { selectIsFocusedOnWorktree, useFocusStore } from "@stores/focusStore"; - -/** - * Resolves the local repo path to run git commands against for a task. - * When the user is focused on the worktree, commands target the main repo - * (`folderPath`); otherwise they target the worktree itself. - */ -export function useLocalRepoPath(taskId: string): string | undefined { - const workspace = useWorkspace(taskId); - const isFocused = useFocusStore( - selectIsFocusedOnWorktree(workspace?.worktreePath ?? ""), - ); - return isFocused - ? workspace?.folderPath - : (workspace?.worktreePath ?? workspace?.folderPath); -} diff --git a/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts b/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts deleted file mode 100644 index f0932a1ee9..0000000000 --- a/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { - Workspace, - WorkspaceMode, -} from "@main/services/workspace/schemas"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo } from "react"; - -function useWorkspacesQuery() { - const trpcReact = useTRPC(); - return useQuery( - trpcReact.workspace.getAll.queryOptions(undefined, { - staleTime: 1000 * 60, - }), - ); -} - -function useInvalidateWorkspaceCaches() { - const trpcReact = useTRPC(); - const queryClient = useQueryClient(); - return useCallback( - async (mainRepoPath?: string) => { - const tasks: Promise[] = [ - queryClient.invalidateQueries(trpcReact.workspace.getAll.pathFilter()), - ]; - if (mainRepoPath) { - tasks.push( - queryClient.invalidateQueries( - trpcReact.workspace.listGitWorktrees.queryFilter({ mainRepoPath }), - ), - ); - } - await Promise.all(tasks); - }, - [queryClient, trpcReact], - ); -} - -export function useWorkspaces(): { - data: Record | undefined; - isFetched: boolean; -} { - const query = useWorkspacesQuery(); - return { data: query.data, isFetched: query.isFetched }; -} - -export function useWorkspace(taskId: string | undefined): Workspace | null { - const { data: workspaces } = useWorkspacesQuery(); - return useMemo( - () => workspaces?.[taskId ?? ""] ?? null, - [workspaces, taskId], - ); -} - -export function useIsWorkspaceCloudRun(taskId: string | undefined): boolean { - const workspace = useWorkspace(taskId); - return workspace?.mode === "cloud"; -} - -export function useWorkspaceLoaded(): boolean { - const { isFetched } = useWorkspacesQuery(); - return isFetched; -} - -export function useCreateWorkspace(): { isPending: boolean } { - const trpcReact = useTRPC(); - const invalidateCaches = useInvalidateWorkspaceCaches(); - - const mutation = useMutation( - trpcReact.workspace.create.mutationOptions({ - onSuccess: (_data, variables) => { - void invalidateCaches(variables.mainRepoPath); - }, - }), - ); - - return { isPending: mutation.isPending }; -} - -export function useDeleteWorkspace(): { isPending: boolean } { - const trpcReact = useTRPC(); - const invalidateCaches = useInvalidateWorkspaceCaches(); - - const mutation = useMutation( - trpcReact.workspace.delete.mutationOptions({ - onSuccess: (_data, variables) => { - void invalidateCaches(variables.mainRepoPath); - }, - }), - ); - - return { isPending: mutation.isPending }; -} - -export function useEnsureWorkspace(): { - ensureWorkspace: ( - taskId: string, - repoPath: string, - mode?: WorkspaceMode, - branch?: string | null, - ) => Promise; - isCreating: boolean; -} { - const trpcReact = useTRPC(); - const queryClient = useQueryClient(); - const invalidateCaches = useInvalidateWorkspaceCaches(); - - const createMutation = useMutation( - trpcReact.workspace.create.mutationOptions({ - onSuccess: (_data, variables) => { - void invalidateCaches(variables.mainRepoPath); - }, - }), - ); - - const ensureWorkspace = useCallback( - async ( - taskId: string, - repoPath: string, - mode: WorkspaceMode = "worktree", - branch?: string | null, - ): Promise => { - const existing = queryClient.getQueryData( - trpcReact.workspace.getAll.queryKey(), - )?.[taskId]; - if (existing) { - return existing; - } - - const result = await createMutation.mutateAsync({ - taskId, - mainRepoPath: repoPath, - folderId: "", - folderPath: repoPath, - mode, - branch: branch ?? undefined, - }); - - if (!result) { - throw new Error("Failed to create workspace"); - } - - await invalidateCaches(repoPath); - return ( - queryClient.getQueryData(trpcReact.workspace.getAll.queryKey())?.[ - taskId - ] ?? null - ); - }, - [createMutation, queryClient, trpcReact, invalidateCaches], - ); - - return { - ensureWorkspace, - isCreating: createMutation.isPending, - }; -} - -export const workspaceApi = { - async getAll(): Promise> { - return (await trpcClient.workspace.getAll.query()) ?? {}; - }, - - async get(taskId: string): Promise { - const workspaces = await trpcClient.workspace.getAll.query(); - return workspaces?.[taskId] ?? null; - }, - - async create(options: { - taskId: string; - mainRepoPath: string; - folderId: string; - folderPath: string; - mode: WorkspaceMode; - branch?: string; - }) { - return trpcClient.workspace.create.mutate(options); - }, - - async reconcileCloudWorkspaces( - taskIds: string[], - ): Promise<{ created: string[] }> { - return trpcClient.workspace.reconcileCloudWorkspaces.mutate({ taskIds }); - }, - - async delete(taskId: string, mainRepoPath: string) { - return trpcClient.workspace.delete.mutate({ taskId, mainRepoPath }); - }, - - async verify(taskId: string) { - return trpcClient.workspace.verify.query({ taskId }); - }, -}; diff --git a/apps/code/src/renderer/hooks/useAuthenticatedClient.ts b/apps/code/src/renderer/hooks/useAuthenticatedClient.ts deleted file mode 100644 index b22d29a563..0000000000 --- a/apps/code/src/renderer/hooks/useAuthenticatedClient.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useAuthenticatedClient as useClient } from "@features/auth/hooks/authClient"; - -export function useAuthenticatedClient() { - return useClient(); -} diff --git a/apps/code/src/renderer/hooks/useConnectivity.ts b/apps/code/src/renderer/hooks/useConnectivity.ts deleted file mode 100644 index 29f91d8100..0000000000 --- a/apps/code/src/renderer/hooks/useConnectivity.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useConnectivityStore } from "@stores/connectivityStore"; - -export function useConnectivity() { - const isOnline = useConnectivityStore((s) => s.isOnline); - const isChecking = useConnectivityStore((s) => s.isChecking); - const check = useConnectivityStore((s) => s.check); - - return { isOnline, isChecking, check }; -} diff --git a/apps/code/src/renderer/hooks/useFeatureFlag.ts b/apps/code/src/renderer/hooks/useFeatureFlag.ts deleted file mode 100644 index de841080a2..0000000000 --- a/apps/code/src/renderer/hooks/useFeatureFlag.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { isFeatureFlagEnabled, onFeatureFlagsLoaded } from "@utils/analytics"; -import { useEffect, useState } from "react"; - -export function useFeatureFlag( - flagKey: string, - defaultValue: boolean = false, -): boolean { - const [enabled, setEnabled] = useState( - () => isFeatureFlagEnabled(flagKey) || defaultValue, - ); - - useEffect(() => { - // Update immediately in case flags loaded between render and effect - setEnabled(isFeatureFlagEnabled(flagKey) || defaultValue); - - // Subscribe to flag reloads (e.g. after identify, or periodic refresh) - return onFeatureFlagsLoaded(() => { - setEnabled(isFeatureFlagEnabled(flagKey) || defaultValue); - }); - }, [flagKey, defaultValue]); - - return enabled; -} diff --git a/apps/code/src/renderer/hooks/useFileWatcher.ts b/apps/code/src/renderer/hooks/useFileWatcher.ts deleted file mode 100644 index 15c3513773..0000000000 --- a/apps/code/src/renderer/hooks/useFileWatcher.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - invalidateGitBranchQueries, - invalidateGitWorkingTreeQueries, -} from "@features/git-interaction/utils/gitCacheKeys"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { useFileWatcher as useFileWatcherUI } from "@posthog/ui/features/file-watcher/useFileWatcher"; -import type { FileWatcherEvent } from "@posthog/workspace-client/types"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; -import { toRelativePath } from "@utils/path"; -import { useCallback, useEffect } from "react"; - -const log = logger.scope("file-watcher"); - -export function useFileWatcher(repoPath: string | null, taskId?: string) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const closeTabsForFile = usePanelLayoutStore((s) => s.closeTabsForFile); - - useEffect(() => { - if (!repoPath) return; - trpcClient.fileWatcher.start.mutate({ repoPath }).catch((error) => { - log.error("Failed to start main-side file watcher:", error); - }); - return () => { - trpcClient.fileWatcher.stop.mutate({ repoPath }); - }; - }, [repoPath]); - - const onEvent = useCallback( - (event: FileWatcherEvent) => { - if (!repoPath) return; - switch (event.kind) { - case "file-changed": { - const relativePath = toRelativePath(event.filePath, repoPath); - queryClient.invalidateQueries( - trpc.fs.readRepoFile.queryFilter({ - repoPath, - filePath: relativePath, - }), - ); - queryClient.invalidateQueries( - trpc.fs.readRepoFileBounded.queryFilter({ - repoPath, - filePath: relativePath, - }), - ); - return; - } - case "file-deleted": { - if (!taskId) return; - closeTabsForFile(taskId, toRelativePath(event.filePath, repoPath)); - return; - } - case "git-state-changed": - invalidateGitBranchQueries(repoPath); - return; - case "working-tree-changed": - invalidateGitWorkingTreeQueries(repoPath); - return; - } - }, - [repoPath, taskId, queryClient, trpc, closeTabsForFile], - ); - - useFileWatcherUI(repoPath, onEvent); -} diff --git a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts deleted file mode 100644 index bfc614286a..0000000000 --- a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { trpcClient, useTRPC } from "@renderer/trpc"; -import type { NewTaskLinkPayload } from "@shared/types"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { - type TaskInputNavigationOptions, - useNavigationStore, -} from "@stores/navigationStore"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { track } from "@utils/analytics"; -import { logger } from "@utils/logger"; -import { useCallback, useEffect, useRef } from "react"; -import { toast } from "sonner"; - -const log = logger.scope("new-task-deep-link"); - -type NavigateToTaskInput = (options?: TaskInputNavigationOptions) => void; - -export function useNewTaskDeepLink() { - const trpcReact = useTRPC(); - const navigateToTaskInput = useNavigationStore( - (state) => state.navigateToTaskInput, - ); - const clearTaskInputReportAssociation = useNavigationStore( - (state) => state.clearTaskInputReportAssociation, - ); - const isAuthenticated = useAuthStateValue( - (state) => state.status === "authenticated", - ); - const hasFetchedPending = useRef(false); - - const handleAction = useCallback( - async (payload: NewTaskLinkPayload) => { - log.info(`Handling deep link action: ${payload.action}`); - clearTaskInputReportAssociation(); - - switch (payload.action) { - case "new": - return handleNew(payload, navigateToTaskInput); - case "plan": - return handlePlan(payload, navigateToTaskInput); - case "issue": - return handleIssue(payload, navigateToTaskInput); - } - }, - [navigateToTaskInput, clearTaskInputReportAssociation], - ); - - useEffect(() => { - if (!isAuthenticated) { - hasFetchedPending.current = false; - return; - } - if (hasFetchedPending.current) return; - - const fetchPending = async () => { - hasFetchedPending.current = true; - try { - const pending = await trpcClient.deepLink.getPendingNewTaskLink.query(); - if (pending) { - log.info(`Found pending new task link: action=${pending.action}`); - handleAction(pending).catch((error) => { - log.error("Failed to handle pending new task link:", error); - }); - } - } catch (error) { - hasFetchedPending.current = false; - log.error("Failed to check for pending new task link:", error); - } - }; - - fetchPending(); - }, [isAuthenticated, handleAction]); - - useSubscription( - trpcReact.deepLink.onNewTaskAction.subscriptionOptions(undefined, { - onData: (data) => { - log.info(`Received new task link event: action=${data.action}`); - handleAction(data).catch((error) => { - log.error("Failed to handle new task link action:", error); - }); - }, - }), - ); -} - -function handleNew( - payload: Extract, - navigateToTaskInput: NavigateToTaskInput, -) { - navigateToTaskInput({ - initialPrompt: payload.prompt, - initialCloudRepository: payload.repo, - initialModel: payload.model, - initialMode: payload.mode, - }); - - track(ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK, { - has_prompt: !!payload.prompt, - has_repo: !!payload.repo, - mode: payload.mode, - model: payload.model, - }); - - log.info("Navigated to task input from new deep link"); -} - -function handlePlan( - payload: Extract, - navigateToTaskInput: NavigateToTaskInput, -) { - navigateToTaskInput({ - initialPrompt: payload.plan, - initialCloudRepository: payload.repo, - initialModel: payload.model, - initialMode: payload.mode, - }); - - track(ANALYTICS_EVENTS.DEEP_LINK_PLAN, { - has_repo: !!payload.repo, - mode: payload.mode, - model: payload.model, - plan_length_chars: payload.plan.length, - }); - - log.info("Navigated to task input from plan deep link"); -} - -async function handleIssue( - payload: Extract, - navigateToTaskInput: NavigateToTaskInput, -) { - try { - const issue = await trpcClient.git.getGithubIssue.query({ - owner: payload.owner, - repo: payload.issueRepo, - number: payload.issueNumber, - }); - - if (!issue) { - toast.error("GitHub issue not found", { - description: `${payload.owner}/${payload.issueRepo}#${payload.issueNumber} could not be opened.`, - }); - log.warn("GitHub issue not found", { - owner: payload.owner, - repo: payload.issueRepo, - number: payload.issueNumber, - }); - track(ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED, { - owner: payload.owner, - repo: payload.issueRepo, - issue_number: payload.issueNumber, - reason: "not_found", - }); - return; - } - - const labelsText = - issue.labels.length > 0 ? `\nLabels: ${issue.labels.join(", ")}` : ""; - const prompt = `GitHub Issue: ${issue.title}\n${issue.url}${labelsText}`; - - const cloudRepo = payload.repo ?? `${payload.owner}/${payload.issueRepo}`; - - navigateToTaskInput({ - initialPrompt: prompt, - initialCloudRepository: cloudRepo, - initialModel: payload.model, - initialMode: payload.mode, - }); - - track(ANALYTICS_EVENTS.DEEP_LINK_ISSUE, { - owner: payload.owner, - repo: payload.issueRepo, - issue_number: payload.issueNumber, - mode: payload.mode, - model: payload.model, - }); - - log.info("Navigated to task input from issue deep link", { - issue: issue.title, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log.error("Failed to fetch GitHub issue:", error); - toast.error("Failed to fetch GitHub issue", { description: message }); - track(ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED, { - owner: payload.owner, - repo: payload.issueRepo, - issue_number: payload.issueNumber, - reason: "fetch_failed", - error_message: message, - }); - } -} diff --git a/apps/code/src/renderer/hooks/useRepositoryDirectory.ts b/apps/code/src/renderer/hooks/useRepositoryDirectory.ts deleted file mode 100644 index 728b67f7c7..0000000000 --- a/apps/code/src/renderer/hooks/useRepositoryDirectory.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { workspaceApi } from "@renderer/features/workspace/hooks/useWorkspace"; -import { trpcClient } from "@renderer/trpc/client"; -import { expandTildePath } from "@utils/path"; - -export async function getTaskDirectory( - taskId: string, - repoKey?: string, -): Promise { - const workspace = await workspaceApi.get(taskId); - if (workspace?.folderPath) { - return expandTildePath(workspace.folderPath); - } - - if (repoKey) { - const repo = await trpcClient.folders.getRepositoryByRemoteUrl.query({ - remoteUrl: repoKey, - }); - if (repo) { - return expandTildePath(repo.path); - } - } - - return null; -} - -export async function getLastUsedDirectory(): Promise { - const repo = - await trpcClient.folders.getMostRecentlyAccessedRepository.query(); - return repo?.path ?? null; -} diff --git a/apps/code/src/renderer/main.tsx b/apps/code/src/renderer/main.tsx index 05caaf5565..9f7e15ab87 100644 --- a/apps/code/src/renderer/main.tsx +++ b/apps/code/src/renderer/main.tsx @@ -1,17 +1,24 @@ import "reflect-metadata"; +// Side effect: registers the host (electron-trpc-backed) storage with @posthog/ui +// before any persisted store hydrates. +import "@utils/electronStorage"; +// Side effect: drives the updates subscription + toast via the core update store. +// Resolves UPDATES_CLIENT, which renderer/di/container.ts binds (loaded via the +// electronStorage import above). +import "@renderer/platform-adapters/updates"; // Side effect: attaches window focus/visibility listeners so `focused` is accurate before inbox queries mount. -import "@stores/rendererWindowFocusStore"; +import "@posthog/ui/workbench/rendererWindowFocusStore"; import { Providers } from "@components/Providers"; import { preloadHighlighter } from "@pierre/diffs"; -import { ServiceProvider } from "@posthog/ui/workbench/service-context"; -import App from "@renderer/App"; +import { startWorkbench } from "@posthog/di/contribution"; +import { ServiceProvider } from "@posthog/di/react"; +import App from "@posthog/ui/workbench/App"; import { registerDesktopContributions } from "@renderer/desktop-contributions"; import { container } from "@renderer/di/container"; import "@renderer/desktop-services"; -import { startWorkbenchContributions } from "@posthog/ui/workbench/contribution"; import React from "react"; import ReactDOM from "react-dom/client"; -import "./styles/globals.css"; +import "@posthog/ui/styles/globals.css"; void preloadHighlighter({ themes: ["github-dark", "github-light"], @@ -65,7 +72,7 @@ document.title = import.meta.env.DEV : "PostHog Code"; registerDesktopContributions(); -void startWorkbenchContributions(container); +void startWorkbench(container); const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("Root element not found"); diff --git a/apps/code/src/renderer/platform-adapters/auth-side-effects.ts b/apps/code/src/renderer/platform-adapters/auth-side-effects.ts new file mode 100644 index 0000000000..817c5f956b --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/auth-side-effects.ts @@ -0,0 +1,46 @@ +import type { CloudRegion } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { + clearAuthScopedQueries, + refreshAuthStateQuery, +} from "@posthog/ui/features/auth/authQueries"; +import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore"; +import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { resetSessionService } from "@posthog/ui/features/sessions/sessionServiceHost"; +import { track } from "@posthog/ui/workbench/analytics"; +import { injectable } from "inversify"; + +@injectable() +export class RendererAuthSideEffects implements IAuthSideEffects { + onAuthSuccess(region: CloudRegion, projectId: number | null): void { + void refreshAuthStateQuery(); + useAuthUiStateStore.getState().clearStaleRegion(); + track(ANALYTICS_EVENTS.USER_LOGGED_IN, { + project_id: projectId?.toString() ?? "", + region, + }); + } + + beforeProjectSwitch(): void { + resetSessionService(); + } + + onProjectSelected(): void { + clearAuthScopedQueries(); + void refreshAuthStateQuery(); + useNavigationStore.getState().navigateToTaskInput(); + } + + onLogout(previousRegion: CloudRegion | null): void { + track(ANALYTICS_EVENTS.USER_LOGGED_OUT); + resetSessionService(); + clearAuthScopedQueries(); + if (previousRegion) { + useAuthUiStateStore.getState().setStaleRegion(previousRegion); + } + useNavigationStore.getState().navigateToTaskInput(); + useOnboardingStore.getState().resetSelections(); + } +} diff --git a/apps/code/src/renderer/platform-adapters/git-cache-keys.ts b/apps/code/src/renderer/platform-adapters/git-cache-keys.ts new file mode 100644 index 0000000000..471307b0bd --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/git-cache-keys.ts @@ -0,0 +1,24 @@ +import type { GitCacheKeyProvider } from "@posthog/ui/features/git-interaction/gitCacheProvider"; +import { trpc } from "@renderer/trpc"; +import type { QueryFilters } from "@tanstack/react-query"; + +// Desktop adapter: maps the host-agnostic proc-name lookups used by +// @posthog/ui/features/git-interaction/gitCacheKeys onto the real tRPC options +// proxy, so the produced query keys/filters are byte-identical to those used by +// the renderer's read queries. +interface ProcHelpers { + queryFilter: (input: unknown) => QueryFilters; + pathFilter: () => QueryFilters; + queryKey: (input: unknown) => readonly unknown[]; +} + +const gitProcs = trpc.git as unknown as Record; +const fsProcs = trpc.fs as unknown as Record; + +export const gitCacheKeyProvider: GitCacheKeyProvider = { + gitQueryFilter: (proc, input) => gitProcs[proc].queryFilter(input), + gitPathFilter: (proc) => gitProcs[proc].pathFilter(), + fsPathFilter: (proc) => fsProcs[proc].pathFilter(), + gitQueryKey: (proc, input) => gitProcs[proc].queryKey(input), + fsQueryKey: (proc, input) => fsProcs[proc].queryKey(input), +}; diff --git a/apps/code/src/renderer/platform-adapters/hedgehog-mode-host.ts b/apps/code/src/renderer/platform-adapters/hedgehog-mode-host.ts new file mode 100644 index 0000000000..d0a2fb5143 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/hedgehog-mode-host.ts @@ -0,0 +1,35 @@ +import type { HedgehogActorOptions } from "@posthog/hedgehog-mode"; +import type { + HedgehogModeHandle, + HedgehogModeHost, + HedgehogModeMountOptions, +} from "@posthog/ui/workbench/hedgehogModeHost"; + +export class RendererHedgehogModeHost implements HedgehogModeHost { + async mount( + container: HTMLDivElement, + options: HedgehogModeMountOptions, + ): Promise { + const { HedgeHogMode } = await import("@posthog/hedgehog-mode"); + const actorOptions = options.actorOptions as + | HedgehogActorOptions + | undefined; + + const game = new HedgeHogMode({ + assetsUrl: "./hedgehog-mode", + state: actorOptions ? { options: actorOptions } : undefined, + onQuit: (g) => { + g.getAllHedgehogs().forEach((hedgehog) => { + hedgehog.updateSprite("wave", { reset: true, loop: false }); + }); + setTimeout(() => options.onQuit(), 1000); + }, + }); + + await game.render(container); + + return { + destroy: () => game.destroy(), + }; + } +} diff --git a/apps/code/src/renderer/platform-adapters/setup.ts b/apps/code/src/renderer/platform-adapters/setup.ts new file mode 100644 index 0000000000..b869bd3128 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/setup.ts @@ -0,0 +1,41 @@ +import type { ISetupStore } from "@posthog/core/setup/identifiers"; +import type { ActivityEntry } from "@posthog/core/setup/setupState"; +import type { DiscoveredTask } from "@posthog/core/setup/types"; +import { + selectRepoDiscovery, + selectRepoEnricher, + useSetupStore, +} from "@posthog/ui/features/setup/setupStore"; + +/** + * Host delegate exposing the setup zustand store to the core + * `SetupRunService`. Inverts the store coupling (the connectivity getValue() + * pattern): core writes UI state through this narrow interface instead of + * importing `@posthog/ui`. + */ +export const setupStore: ISetupStore = { + getDiscoveryStatus: (repoPath) => + selectRepoDiscovery(useSetupStore.getState(), repoPath).status, + getEnricherStatus: (repoPath) => + selectRepoEnricher(useSetupStore.getState(), repoPath).status, + anyDiscoveryStarted: () => + Object.values(useSetupStore.getState().discoveryByRepo).some( + (d) => d.status !== "idle", + ), + startDiscovery: (repoPath, taskId, taskRunId) => + useSetupStore.getState().startDiscovery(repoPath, taskId, taskRunId), + completeDiscovery: (repoPath, tasks: DiscoveredTask[]) => + useSetupStore.getState().completeDiscovery(repoPath, tasks), + failDiscovery: (repoPath, message) => + useSetupStore.getState().failDiscovery(repoPath, message), + pushDiscoveryActivity: (repoPath, entry: ActivityEntry) => + useSetupStore.getState().pushDiscoveryActivity(repoPath, entry), + startEnrichment: (repoPath) => + useSetupStore.getState().startEnrichment(repoPath), + completeEnrichment: (repoPath) => + useSetupStore.getState().completeEnrichment(repoPath), + failEnrichment: (repoPath) => + useSetupStore.getState().failEnrichment(repoPath), + addEnricherSuggestionIfMissing: (task: DiscoveredTask) => + useSetupStore.getState().addEnricherSuggestionIfMissing(task), +}; diff --git a/apps/code/src/renderer/platform-adapters/task-deletion.ts b/apps/code/src/renderer/platform-adapters/task-deletion.ts new file mode 100644 index 0000000000..73b550881f --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/task-deletion.ts @@ -0,0 +1,43 @@ +import type { + ITaskDeletionHost, + ITaskDeletionWorkspaceClient, + TaskDeletionFocusSession, + TaskDeletionView, + TaskWorkspace, +} from "@posthog/core/tasks/identifiers"; +import { useFocusStore } from "@posthog/ui/features/focus/focusStore"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { pinnedTasksApi } from "@posthog/ui/features/sidebar/taskMetaApi"; +import { trpcClient } from "@renderer/trpc/client"; + +export const taskDeletionWorkspaceClient: ITaskDeletionWorkspaceClient = { + getAll() { + return trpcClient.workspace.getAll.query() as Promise< + Record + >; + }, + delete(input) { + return trpcClient.workspace.delete.mutate(input); + }, +}; + +export const taskDeletionHost: ITaskDeletionHost = { + getSession(): TaskDeletionFocusSession | null { + return useFocusStore.getState().session; + }, + disableFocus() { + return useFocusStore.getState().disableFocus(); + }, + confirmDeleteTask(input) { + return trpcClient.contextMenu.confirmDeleteTask.mutate(input); + }, + unpin(taskId) { + return pinnedTasksApi.unpin(taskId); + }, + getCurrentView(): TaskDeletionView | undefined { + return useNavigationStore.getState().view as TaskDeletionView; + }, + navigateToTaskInput() { + useNavigationStore.getState().navigateToTaskInput(); + }, +}; diff --git a/apps/code/src/renderer/platform-adapters/tour.ts b/apps/code/src/renderer/platform-adapters/tour.ts new file mode 100644 index 0000000000..81b2395ece --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/tour.ts @@ -0,0 +1,10 @@ +import { registerTour } from "@posthog/core/tour/tourRegistry"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { useTourStore } from "@posthog/ui/features/tour/tourStore"; +import { createFirstTaskTour } from "@posthog/ui/features/tour/tours/createFirstTaskTour"; + +export function initTours(): void { + registerTour(createFirstTaskTour); + const { hasCompletedOnboarding } = useOnboardingStore.getState(); + useTourStore.getState().applyReturningUserMigration(hasCompletedOnboarding); +} diff --git a/apps/code/src/renderer/platform-adapters/updates.ts b/apps/code/src/renderer/platform-adapters/updates.ts new file mode 100644 index 0000000000..362e9fc394 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/updates.ts @@ -0,0 +1,121 @@ +import { + deriveUpdateUiStatus, + type MenuCheckToast, + resolveMenuCheckFromStatus, + resolveMenuCheckResult, + updateStore, +} from "@posthog/core/updates/updateStore"; +import { resolveService } from "@posthog/di/container"; +import { + UPDATES_CLIENT, + type UpdatesClient, +} from "@posthog/ui/features/updates/updatesClient"; +import { toast } from "@posthog/ui/primitives/toast"; +import { logger } from "@posthog/ui/workbench/logger"; + +const log = logger.scope("updates-host"); + +const client = resolveService(UPDATES_CLIENT); +const store = updateStore.getState; + +function showToast(menuToast: MenuCheckToast): void { + if (menuToast.kind === "success") { + toast.success(menuToast.message); + return; + } + toast.error( + menuToast.message, + menuToast.description + ? { + description: menuToast.description, + } + : undefined, + ); +} + +void client + .isEnabled() + .then((result) => store().setEnabled(result.enabled)) + .catch((error: unknown) => { + log.error("Failed to get update enabled status", { error }); + }); + +void client + .getStatus() + .then((status) => { + const update = deriveUpdateUiStatus(status, store().status); + if (update?.status) { + store().setStatus(update.status); + } + if (update && "version" in update) { + store().setVersion(update.version ?? null); + } + }) + .catch((error: unknown) => { + log.error("Failed to get update status", { error }); + }); + +client.onStatus({ + onData: (status) => { + const update = deriveUpdateUiStatus(status, store().status); + if (update?.status) { + store().setStatus(update.status); + } + if (update && "version" in update) { + store().setVersion(update.version ?? null); + } + + const outcome = resolveMenuCheckFromStatus( + status, + store().menuCheckPending, + ); + if (outcome) { + if (outcome.clearPending) { + store().setMenuCheckPending(false); + } + if (outcome.toast) { + showToast(outcome.toast); + } + } + }, + onError: (error) => { + log.error("Update status subscription error", { error }); + store().setMenuCheckPending(false); + }, +}); + +client.onReady({ + onData: (data) => { + store().setReady(data.version); + }, + onError: (error) => { + log.error("Update ready subscription error", { error }); + }, +}); + +client.onCheckFromMenu({ + onData: () => { + store().setMenuCheckPending(true); + void client + .check() + .then((result) => { + const outcome = resolveMenuCheckResult(result); + if (outcome) { + if (outcome.clearPending) { + store().setMenuCheckPending(false); + } + if (outcome.toast) { + showToast(outcome.toast); + } + } + }) + .catch((error: unknown) => { + store().setMenuCheckPending(false); + log.error("Failed to check for updates", { error }); + toast.error("Failed to check for updates"); + }); + }, + onError: (error) => { + log.error("Update menu check subscription error", { error }); + }, +}); diff --git a/apps/code/src/renderer/services/.gitkeep b/apps/code/src/renderer/services/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apps/code/src/renderer/stores/cloneStore.ts b/apps/code/src/renderer/stores/cloneStore.ts deleted file mode 100644 index 73e9e86f99..0000000000 --- a/apps/code/src/renderer/stores/cloneStore.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { trpcClient } from "@renderer/trpc/client"; -import { create } from "zustand"; - -type CloneStatus = "cloning" | "complete" | "error"; - -interface CloneOperation { - cloneId: string; - repository: string; - targetPath: string; - status: CloneStatus; - latestMessage?: string; - error?: string; - unsubscribe?: () => void; -} - -interface CloneStore { - operations: Record; - startClone: (cloneId: string, repository: string, targetPath: string) => void; - updateClone: (cloneId: string, status: CloneStatus, message: string) => void; - removeClone: (cloneId: string) => void; - isCloning: (repoKey: string) => boolean; - getCloneForRepo: (repoKey: string) => CloneOperation | null; -} - -const REMOVE_DELAY_SUCCESS_MS = 3000; -const REMOVE_DELAY_ERROR_MS = 5000; - -let globalSubscription: { unsubscribe: () => void } | null = null; -let subscriptionRefCount = 0; - -const ensureGlobalSubscription = (store: CloneStore) => { - if (globalSubscription) { - subscriptionRefCount++; - return; - } - - subscriptionRefCount = 1; - globalSubscription = trpcClient.git.onCloneProgress.subscribe(undefined, { - onData: (event) => { - store.updateClone(event.cloneId, event.status, event.message); - }, - }); -}; - -const releaseGlobalSubscription = () => { - subscriptionRefCount--; - if (subscriptionRefCount <= 0 && globalSubscription) { - globalSubscription.unsubscribe(); - globalSubscription = null; - subscriptionRefCount = 0; - } -}; - -export const cloneStore = create((set, get) => { - const handleComplete = (cloneId: string) => { - window.setTimeout( - () => get().removeClone(cloneId), - REMOVE_DELAY_SUCCESS_MS, - ); - }; - - const handleError = (cloneId: string) => { - window.setTimeout(() => get().removeClone(cloneId), REMOVE_DELAY_ERROR_MS); - }; - - const store: CloneStore = { - operations: {}, - - startClone: (cloneId, repository, targetPath) => { - // Ensure global subscription is active - ensureGlobalSubscription(store); - - // Set up clone operation with progress handler - set((state) => ({ - operations: { - ...state.operations, - [cloneId]: { - cloneId, - repository, - targetPath, - status: "cloning", - latestMessage: `Cloning ${repository}...`, - unsubscribe: releaseGlobalSubscription, - }, - }, - })); - - // Start the clone operation via tRPC mutation - trpcClient.git.cloneRepository - .mutate({ repoUrl: repository, targetPath, cloneId }) - .then(() => { - handleComplete(cloneId); - }) - .catch((err) => { - const message = err instanceof Error ? err.message : "Clone failed"; - get().updateClone(cloneId, "error", message); - handleError(cloneId); - }); - }, - - updateClone: (cloneId, status, message) => { - set((state) => { - const operation = state.operations[cloneId]; - if (!operation) return state; - - return { - operations: { - ...state.operations, - [cloneId]: { - ...operation, - status, - latestMessage: message, - error: status === "error" ? message : operation.error, - }, - }, - }; - }); - }, - - removeClone: (cloneId) => { - set((state) => { - const operation = state.operations[cloneId]; - operation?.unsubscribe?.(); - - const { [cloneId]: _, ...remainingOps } = state.operations; - return { operations: remainingOps }; - }); - }, - - isCloning: (repository) => - Object.values(get().operations).some( - (op) => op.status === "cloning" && op.repository === repository, - ), - - getCloneForRepo: (repository) => - Object.values(get().operations).find( - (op) => op.repository === repository, - ) ?? null, - }; - - return store; -}); diff --git a/apps/code/src/renderer/stores/connectivityStore.ts b/apps/code/src/renderer/stores/connectivityStore.ts deleted file mode 100644 index 0b4145400f..0000000000 --- a/apps/code/src/renderer/stores/connectivityStore.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { trpcClient } from "@renderer/trpc/client"; -import { logger } from "@utils/logger"; -import { create } from "zustand"; -import { subscribeWithSelector } from "zustand/middleware"; - -const log = logger.scope("connectivity-store"); - -interface ConnectivityState { - isOnline: boolean; - isChecking: boolean; - - // Actions - setOnline: (isOnline: boolean) => void; - check: () => Promise; -} - -export const useConnectivityStore = create()( - subscribeWithSelector((set) => ({ - isOnline: true, // Assume online initially - isChecking: false, - - setOnline: (isOnline: boolean) => { - set({ isOnline }); - }, - - check: async () => { - set({ isChecking: true }); - try { - const result = await trpcClient.connectivity.checkNow.mutate(); - set({ isOnline: result.isOnline, isChecking: false }); - } catch (error) { - log.error("Failed to check connectivity", { error }); - set({ isChecking: false }); - } - }, - })), -); - -// Initialize: fetch initial status and subscribe to changes -export function initializeConnectivityStore() { - // Get initial status - trpcClient.connectivity.getStatus - .query() - .then((status) => { - useConnectivityStore.getState().setOnline(status.isOnline); - }) - .catch((error) => { - log.error("Failed to get initial connectivity status", { error }); - }); - - // Subscribe to status changes - const subscription = trpcClient.connectivity.onStatusChange.subscribe( - undefined, - { - onData: (status) => { - useConnectivityStore.getState().setOnline(status.isOnline); - }, - onError: (error) => { - log.error("Connectivity subscription error", { error }); - }, - }, - ); - - return () => { - subscription.unsubscribe(); - }; -} - -// Convenience selectors -export const getIsOnline = () => useConnectivityStore.getState().isOnline; diff --git a/apps/code/src/renderer/stores/focusStore.ts b/apps/code/src/renderer/stores/focusStore.ts deleted file mode 100644 index 2cd61697fd..0000000000 --- a/apps/code/src/renderer/stores/focusStore.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; -import { - type EnableFocusParams, - FocusController, - type FocusSagaResult, -} from "@posthog/core/focus/service"; -import type { SagaLogger } from "@posthog/shared"; -import type { - FocusResult, - FocusSession, -} from "@posthog/workspace-client/types"; -import { trpcClient } from "@renderer/trpc"; -import { logger } from "@utils/logger"; -import { create } from "zustand"; - -const log = logger.scope("focus-store"); - -const sagaLogger: SagaLogger = { - info: (message, data) => log.info(message, data), - debug: (message, data) => log.debug(message, data), - error: (message, data) => log.error(message, data), - warn: (message, data) => log.warn(message, data), -}; - -const focusController = new FocusController( - { - cancelSessionPrompt: async (sessionId, reason) => { - await trpcClient.agent.cancelPrompt.mutate({ sessionId, reason }); - }, - checkout: (repoPath, branch) => - trpcClient.focus.checkout.mutate({ repoPath, branch }), - cleanWorkingTree: (repoPath) => - trpcClient.focus.cleanWorkingTree.mutate({ repoPath }), - deleteSession: (mainRepoPath) => - trpcClient.focus.deleteSession.mutate({ mainRepoPath }), - detachWorktree: (worktreePath) => - trpcClient.focus.detachWorktree.mutate({ worktreePath }), - getCommitSha: (repoPath) => - trpcClient.focus.getCommitSha.query({ repoPath }), - getCurrentBranch: async (mainRepoPath) => - await trpcClient.git.getCurrentBranch.query({ - directoryPath: mainRepoPath, - }), - getSession: (mainRepoPath) => - trpcClient.focus.getSession.query({ mainRepoPath }), - isDirty: (repoPath) => trpcClient.focus.isDirty.query({ repoPath }), - listLocalTaskIds: async (mainRepoPath) => - ( - await trpcClient.workspace.getLocalTasks.query({ - mainRepoPath, - }) - ).map(({ taskId }) => taskId), - listSessionIds: async (taskId) => - ( - await trpcClient.agent.listSessions.query({ - taskId, - }) - ).map(({ taskRunId }) => taskRunId), - listWorktreeTaskIds: async (worktreePath) => - ( - await trpcClient.workspace.getWorktreeTasks.query({ - worktreePath, - }) - ).map(({ taskId }) => taskId), - notifySessionContext: (sessionId, context) => - trpcClient.agent.notifySessionContext.mutate({ sessionId, context }), - reattachWorktree: (worktreePath, branch) => - trpcClient.focus.reattachWorktree.mutate({ worktreePath, branch }), - saveSession: (session) => trpcClient.focus.saveSession.mutate(session), - stash: (repoPath, message) => - trpcClient.focus.stash.mutate({ repoPath, message }), - stashApply: (repoPath, stashRef) => - trpcClient.focus.stashApply.mutate({ repoPath, stashRef }), - startSync: (mainRepoPath, worktreePath) => - trpcClient.focus.startSync.mutate({ mainRepoPath, worktreePath }), - startWatchingMainRepo: (mainRepoPath) => - trpcClient.focus.startWatchingMainRepo.mutate({ mainRepoPath }), - stopSync: () => trpcClient.focus.stopSync.mutate(), - stopWatchingMainRepo: () => trpcClient.focus.stopWatchingMainRepo.mutate(), - toRelativeWorktreePath: (absolutePath, mainRepoPath) => - trpcClient.focus.toRelativeWorktreePath.query({ - absolutePath, - mainRepoPath, - }), - worktreeExistsAtPath: (relativePath) => - trpcClient.focus.worktreeExistsAtPath.query({ relativePath }), - }, - sagaLogger, -); - -export type { FocusSagaResult }; - -interface FocusState { - session: FocusSession | null; - isLoading: boolean; - enableFocus: (params: EnableFocusParams) => Promise; - disableFocus: () => Promise; - restore: (mainRepoPath: string) => Promise; - updateSessionBranch: (worktreePath: string, newBranch: string) => void; -} - -export const useFocusStore = create()((set, get) => ({ - session: null, - isLoading: false, - - enableFocus: async (params) => { - set({ isLoading: true }); - const result = await focusController.enableFocus(params, get().session); - set({ - isLoading: false, - session: result.success ? result.session : get().session, - }); - if (result.success) invalidateGitBranchQueries(params.mainRepoPath); - return result; - }, - - disableFocus: async () => { - const { session } = get(); - if (!session) return { success: false, error: "No active focus session" }; - - set({ isLoading: true }); - const result = await focusController.disableFocus(session); - set({ isLoading: false, session: result.success ? null : session }); - if (result.success) invalidateGitBranchQueries(session.mainRepoPath); - return result; - }, - - restore: async (mainRepoPath) => { - const session = await focusController.restore(mainRepoPath); - if (session) set({ session }); - }, - - updateSessionBranch: (worktreePath, newBranch) => { - const { session } = get(); - if (session?.worktreePath === worktreePath) { - set({ session: { ...session, branch: newBranch } }); - } - }, -})); - -export const selectIsLoading = (state: FocusState) => state.isLoading; - -export const selectIsFocusedOnWorktree = - (worktreePath: string) => (state: FocusState) => - state.session?.worktreePath === worktreePath; diff --git a/apps/code/src/renderer/stores/settingsStore.test.ts b/apps/code/src/renderer/stores/settingsStore.test.ts deleted file mode 100644 index 769f1653da..0000000000 --- a/apps/code/src/renderer/stores/settingsStore.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { getItem, setItem } = vi.hoisted(() => ({ - getItem: vi.fn(), - setItem: vi.fn(), -})); - -vi.mock("../trpc", () => ({ - trpcClient: { - secureStore: { - getItem: { query: getItem }, - setItem: { query: setItem }, - }, - }, -})); - -import { useSettingsStore } from "./settingsStore"; - -describe("settingsStore sendMessagesWith", () => { - beforeEach(() => { - getItem.mockReset(); - setItem.mockReset(); - useSettingsStore.setState({ - sendMessagesWith: "enter", - }); - }); - - it("loads sendMessagesWith from secure store", async () => { - getItem.mockResolvedValue("cmd+enter"); - - await useSettingsStore.getState().loadSendMessagesWith(); - - expect(getItem).toHaveBeenCalledWith({ key: "sendMessagesWith" }); - expect(useSettingsStore.getState().sendMessagesWith).toBe("cmd+enter"); - }); - - it("keeps default when no value is stored", async () => { - getItem.mockResolvedValue(null); - - await useSettingsStore.getState().loadSendMessagesWith(); - - expect(useSettingsStore.getState().sendMessagesWith).toBe("enter"); - }); - - it("persists sendMessagesWith updates", async () => { - setItem.mockResolvedValue(undefined); - - await useSettingsStore.getState().setSendMessagesWith("cmd+enter"); - - expect(setItem).toHaveBeenCalledWith({ - key: "sendMessagesWith", - value: "cmd+enter", - }); - expect(useSettingsStore.getState().sendMessagesWith).toBe("cmd+enter"); - }); -}); diff --git a/apps/code/src/renderer/stores/settingsStore.ts b/apps/code/src/renderer/stores/settingsStore.ts deleted file mode 100644 index 7eac67e585..0000000000 --- a/apps/code/src/renderer/stores/settingsStore.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { logger } from "@utils/logger"; -import { create } from "zustand"; -import { trpcClient } from "../trpc"; - -const log = logger.scope("settings-store"); - -export type SendMessagesWith = "enter" | "cmd+enter"; - -interface SettingsState { - sendMessagesWith: SendMessagesWith; - loadSendMessagesWith: () => Promise; - setSendMessagesWith: (mode: SendMessagesWith) => Promise; -} - -export const useSettingsStore = create()((set) => ({ - sendMessagesWith: "enter", - - loadSendMessagesWith: async () => { - try { - const mode = await trpcClient.secureStore.getItem.query({ - key: "sendMessagesWith", - }); - if (mode === "enter" || mode === "cmd+enter") { - set({ sendMessagesWith: mode }); - } - } catch (error) { - log.warn("Failed to load sendMessagesWith preference", { error }); - } - }, - - setSendMessagesWith: async (mode: SendMessagesWith) => { - try { - await trpcClient.secureStore.setItem.query({ - key: "sendMessagesWith", - value: mode, - }); - set({ sendMessagesWith: mode }); - } catch (error) { - log.warn("Failed to persist sendMessagesWith preference", { error }); - } - }, -})); diff --git a/apps/code/src/renderer/stores/updateStore.test.ts b/apps/code/src/renderer/stores/updateStore.test.ts deleted file mode 100644 index f556a86c17..0000000000 --- a/apps/code/src/renderer/stores/updateStore.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { - checkMutate, - getStatusQuery, - installMutate, - isEnabledQuery, - subscriptions, - toast, -} = vi.hoisted(() => ({ - checkMutate: vi.fn(), - getStatusQuery: vi.fn(), - installMutate: vi.fn(), - isEnabledQuery: vi.fn(), - subscriptions: { - onStatus: null as - | null - | ((status: { - checking: boolean; - downloading?: boolean; - upToDate?: boolean; - updateReady?: boolean; - version?: string; - error?: string; - }) => void), - onReady: null as null | ((data: { version: string | null }) => void), - onCheckFromMenu: null as null | (() => void), - }, - toast: { - error: vi.fn(), - success: vi.fn(), - }, -})); - -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - updates: { - isEnabled: { query: isEnabledQuery }, - getStatus: { query: getStatusQuery }, - check: { mutate: checkMutate }, - install: { mutate: installMutate }, - onStatus: { - subscribe: vi.fn((_input, handlers) => { - subscriptions.onStatus = handlers.onData; - return { unsubscribe: vi.fn() }; - }), - }, - onReady: { - subscribe: vi.fn((_input, handlers) => { - subscriptions.onReady = handlers.onData; - return { unsubscribe: vi.fn() }; - }), - }, - onCheckFromMenu: { - subscribe: vi.fn((_input, handlers) => { - subscriptions.onCheckFromMenu = handlers.onData; - return { unsubscribe: vi.fn() }; - }), - }, - }, - }, -})); - -vi.mock("@utils/logger", () => ({ - logger: { - scope: () => ({ - error: vi.fn(), - }), - }, -})); - -vi.mock("@utils/toast", () => ({ - toast, -})); - -import { initializeUpdateStore, useUpdateStore } from "./updateStore"; - -async function flushPromises(): Promise { - await Promise.resolve(); - await Promise.resolve(); -} - -describe("updateStore", () => { - beforeEach(() => { - vi.clearAllMocks(); - subscriptions.onStatus = null; - subscriptions.onReady = null; - subscriptions.onCheckFromMenu = null; - isEnabledQuery.mockResolvedValue({ enabled: true }); - getStatusQuery.mockResolvedValue({ checking: false }); - checkMutate.mockResolvedValue({ success: true }); - installMutate.mockResolvedValue({ installed: true }); - useUpdateStore.setState({ - status: "idle", - version: null, - isEnabled: false, - menuCheckPending: false, - }); - }); - - it("hydrates an already-ready update from the main status snapshot", async () => { - getStatusQuery.mockResolvedValue({ - checking: false, - updateReady: true, - version: "v2.0.0", - }); - - const dispose = initializeUpdateStore(); - await flushPromises(); - - expect(getStatusQuery).toHaveBeenCalled(); - expect(useUpdateStore.getState()).toMatchObject({ - isEnabled: true, - status: "ready", - version: "v2.0.0", - }); - - dispose(); - }); - - it("surfaces an already-staged update from a menu check replay", async () => { - const dispose = initializeUpdateStore(); - await flushPromises(); - - subscriptions.onCheckFromMenu?.(); - await flushPromises(); - - expect(checkMutate).toHaveBeenCalled(); - - subscriptions.onReady?.({ version: "v2.0.0" }); - expect(useUpdateStore.getState()).toMatchObject({ - status: "ready", - version: "v2.0.0", - }); - - subscriptions.onStatus?.({ checking: false }); - dispose(); - }); - - it("hydrates an installing update so the renderer keeps the restart spinner", async () => { - getStatusQuery.mockResolvedValue({ - checking: false, - updateReady: true, - installing: true, - version: "v2.0.0", - }); - - const dispose = initializeUpdateStore(); - await flushPromises(); - - expect(useUpdateStore.getState()).toMatchObject({ - status: "installing", - version: "v2.0.0", - }); - - dispose(); - }); - - it("does not reset a ready update when a stale upToDate status arrives", async () => { - getStatusQuery.mockResolvedValue({ - checking: false, - updateReady: true, - version: "v2.0.0", - }); - - const dispose = initializeUpdateStore(); - await flushPromises(); - - subscriptions.onStatus?.({ checking: false, upToDate: true }); - - expect(useUpdateStore.getState().status).toBe("ready"); - dispose(); - }); - - it("shows the success toast when a menu check resolves with upToDate", async () => { - const dispose = initializeUpdateStore(); - await flushPromises(); - - subscriptions.onCheckFromMenu?.(); - await flushPromises(); - expect(useUpdateStore.getState().menuCheckPending).toBe(true); - - subscriptions.onStatus?.({ checking: false, upToDate: true }); - - expect(toast.success).toHaveBeenCalledWith("You're on the latest version"); - expect(useUpdateStore.getState().menuCheckPending).toBe(false); - dispose(); - }); - - it("clears the menu-check flag on disabled errors and shows the error toast", async () => { - checkMutate.mockResolvedValue({ - success: false, - errorCode: "disabled", - errorMessage: "Updates only available in packaged builds", - }); - - const dispose = initializeUpdateStore(); - await flushPromises(); - - subscriptions.onCheckFromMenu?.(); - await flushPromises(); - - expect(useUpdateStore.getState().menuCheckPending).toBe(false); - expect(toast.error).toHaveBeenCalledWith( - "Updates only available in packaged builds", - ); - dispose(); - }); - - it("keeps the menu-check flag when an in-flight check is already running", async () => { - checkMutate.mockResolvedValue({ - success: false, - errorCode: "already_checking", - }); - - const dispose = initializeUpdateStore(); - await flushPromises(); - - subscriptions.onCheckFromMenu?.(); - await flushPromises(); - - expect(useUpdateStore.getState().menuCheckPending).toBe(true); - dispose(); - }); -}); diff --git a/apps/code/src/renderer/stores/updateStore.ts b/apps/code/src/renderer/stores/updateStore.ts deleted file mode 100644 index 145b49544e..0000000000 --- a/apps/code/src/renderer/stores/updateStore.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { trpcClient } from "@renderer/trpc/client"; -import { logger } from "@utils/logger"; -import { toast } from "@utils/toast"; -import { create } from "zustand"; - -const log = logger.scope("update-store"); - -type UpdateStatus = - | "idle" - | "checking" - | "downloading" - | "ready" - | "installing"; - -interface StatusPayload { - checking: boolean; - downloading?: boolean; - upToDate?: boolean; - updateReady?: boolean; - installing?: boolean; - version?: string; - error?: string; -} - -interface UpdateState { - status: UpdateStatus; - version: string | null; - isEnabled: boolean; - menuCheckPending: boolean; - - installUpdate: () => Promise; - checkForUpdates: () => void; -} - -export const useUpdateStore = create()((set, get) => ({ - status: "idle", - version: null, - isEnabled: false, - menuCheckPending: false, - - installUpdate: async () => { - if (get().status === "installing") return; - - set({ status: "installing" }); - - try { - const result = await trpcClient.updates.install.mutate(); - if (!result.installed) { - log.error("Update install returned not installed"); - set({ status: "ready" }); - } - } catch (error) { - log.error("Failed to install update", { error }); - set({ status: "ready" }); - } - }, - - checkForUpdates: () => { - trpcClient.updates.check.mutate().catch((error: unknown) => { - log.error("Failed to check for updates", { error }); - }); - }, -})); - -export function initializeUpdateStore() { - trpcClient.updates.isEnabled - .query() - .then((result) => { - useUpdateStore.setState({ isEnabled: result.enabled }); - }) - .catch((error: unknown) => { - log.error("Failed to get update enabled status", { error }); - }); - - trpcClient.updates.getStatus - .query() - .then((status) => { - applyStatus(status); - }) - .catch((error: unknown) => { - log.error("Failed to get update status", { error }); - }); - - const statusSub = trpcClient.updates.onStatus.subscribe(undefined, { - onData: (status) => { - applyStatus(status); - - if (status.upToDate) { - if (useUpdateStore.getState().menuCheckPending) { - useUpdateStore.setState({ menuCheckPending: false }); - toast.success("You're on the latest version"); - } - } else if (status.error) { - log.error("Update check failed", { error: status.error }); - if (useUpdateStore.getState().menuCheckPending) { - useUpdateStore.setState({ menuCheckPending: false }); - toast.error("Failed to check for updates", { - description: status.error, - }); - } - } else if ( - status.checking === false && - useUpdateStore.getState().menuCheckPending - ) { - // Check finished and an update was found (download in progress / ready) - // — the UpdateBanner will surface it, so suppress the menu-check toast. - useUpdateStore.setState({ menuCheckPending: false }); - } - }, - onError: (error) => { - log.error("Update status subscription error", { error }); - useUpdateStore.setState({ menuCheckPending: false }); - }, - }); - - const readySub = trpcClient.updates.onReady.subscribe(undefined, { - onData: (data) => { - useUpdateStore.setState({ - status: "ready", - version: data.version, - }); - }, - onError: (error) => { - log.error("Update ready subscription error", { error }); - }, - }); - - const menuCheckSub = trpcClient.updates.onCheckFromMenu.subscribe(undefined, { - onData: () => { - useUpdateStore.setState({ menuCheckPending: true }); - trpcClient.updates.check - .mutate() - .then((result) => { - if (!result.success) { - if (result.errorCode === "disabled") { - useUpdateStore.setState({ menuCheckPending: false }); - toast.error(result.errorMessage ?? "Updates not available"); - } else if (result.errorCode !== "already_checking") { - // Unknown/future error code — reset the flag so it never gets stuck. - useUpdateStore.setState({ menuCheckPending: false }); - } - // For "already_checking", keep the flag so the in-flight check - // surfaces the toast when it resolves. - } - }) - .catch((error: unknown) => { - useUpdateStore.setState({ menuCheckPending: false }); - log.error("Failed to check for updates", { error }); - toast.error("Failed to check for updates"); - }); - }, - onError: (error) => { - log.error("Update menu check subscription error", { error }); - }, - }); - - return () => { - statusSub.unsubscribe(); - readySub.unsubscribe(); - menuCheckSub.unsubscribe(); - }; -} - -function applyStatus(status: StatusPayload): void { - if (status.installing) { - useUpdateStore.setState({ - status: "installing", - version: status.version ?? null, - }); - return; - } - - if (status.updateReady) { - useUpdateStore.setState({ - status: "ready", - version: status.version ?? null, - }); - return; - } - - if (status.checking && status.downloading) { - useUpdateStore.setState({ status: "downloading" }); - return; - } - - if (status.checking) { - useUpdateStore.setState({ status: "checking" }); - return; - } - - if (status.upToDate || status.error) { - const current = useUpdateStore.getState().status; - if (current !== "ready" && current !== "installing") { - useUpdateStore.setState({ status: "idle" }); - } - } -} diff --git a/apps/code/src/renderer/trpc/client.ts b/apps/code/src/renderer/trpc/client.ts index 3cd3152c8d..70f3c8e3c6 100644 --- a/apps/code/src/renderer/trpc/client.ts +++ b/apps/code/src/renderer/trpc/client.ts @@ -1,4 +1,5 @@ import { ipcLink } from "@posthog/electron-trpc/renderer"; +import type { HostRouter } from "@posthog/host-router/router"; import { createTRPCClient } from "@trpc/client"; import { createTRPCContext, @@ -11,6 +12,10 @@ export const trpcClient = createTRPCClient({ links: [ipcLink()], }); +export const hostTrpcClient = createTRPCClient({ + links: [ipcLink()], +}); + const context = createTRPCContext(); export const TRPCProvider = context.TRPCProvider; export const useTRPC = context.useTRPC; diff --git a/apps/code/src/renderer/types/rehype.d.ts b/apps/code/src/renderer/types/rehype.d.ts deleted file mode 100644 index b09108753f..0000000000 --- a/apps/code/src/renderer/types/rehype.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -declare module "rehype-raw" { - import type { Plugin } from "unified"; - const rehypeRaw: Plugin; - export default rehypeRaw; -} - -declare module "rehype-sanitize" { - import type { Plugin } from "unified"; - import type { Schema } from "hast-util-sanitize"; - const rehypeSanitize: Plugin<[Schema?]>; - export default rehypeSanitize; - export const defaultSchema: Schema; -} diff --git a/apps/code/src/renderer/utils/clearStorage.ts b/apps/code/src/renderer/utils/clearStorage.ts deleted file mode 100644 index fcfd8a5c72..0000000000 --- a/apps/code/src/renderer/utils/clearStorage.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { trpcClient } from "@renderer/trpc"; -import { logger } from "./logger"; - -const log = logger.scope("clear-storage"); - -export function clearApplicationStorage(): void { - const confirmed = window.confirm( - "Are you sure you want to clear all application storage?\n\nThis will remove:\n• All registered folders\n• UI state (sidebar preferences, etc.)\n• Task directory mappings\n\nYour files will not be deleted from your computer.", - ); - - if (confirmed) { - trpcClient.folders.clearAllData - .mutate() - .then(() => { - localStorage.clear(); - window.location.reload(); - }) - .catch((error: unknown) => { - log.error("Failed to clear storage:", error); - alert("Failed to clear storage. Please try again."); - }); - } -} diff --git a/apps/code/src/renderer/utils/electronStorage.ts b/apps/code/src/renderer/utils/electronStorage.ts index 0e992bbc3e..12b979296f 100644 --- a/apps/code/src/renderer/utils/electronStorage.ts +++ b/apps/code/src/renderer/utils/electronStorage.ts @@ -1,10 +1,12 @@ -import { createJSONStorage, type StateStorage } from "zustand/middleware"; +import { + electronStorage, + RENDERER_STATE_STORAGE, + type RendererStateStorage, +} from "@posthog/ui/workbench/rendererStorage"; +import { container } from "@renderer/di/container"; import { trpcClient } from "../trpc"; -/** - * Raw storage adapter that uses electron to persist state. - */ -const electronStorageRaw: StateStorage = { +const electronStorageRaw: RendererStateStorage = { getItem: async (key: string): Promise => { return await trpcClient.secureStore.getItem.query({ key }); }, @@ -16,4 +18,8 @@ const electronStorageRaw: StateStorage = { }, }; -export const electronStorage = createJSONStorage(() => electronStorageRaw); +container + .bind(RENDERER_STATE_STORAGE) + .toConstantValue(electronStorageRaw); + +export { electronStorage }; diff --git a/apps/code/src/renderer/utils/generateTitle.ts b/apps/code/src/renderer/utils/generateTitle.ts deleted file mode 100644 index 46cc1fffcf..0000000000 --- a/apps/code/src/renderer/utils/generateTitle.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { fetchAuthState } from "@features/auth/hooks/authQueries"; -import { xmlToContent } from "@features/message-editor/utils/content"; -import { isBinaryFile } from "@posthog/shared"; -import { trpcClient } from "@renderer/trpc"; -import { logger } from "@utils/logger"; -import { getFileName } from "@utils/path"; - -const log = logger.scope("title-generator"); - -const ATTACHED_FILES_REGEX = /^\[?Attached files:.*]?$/gm; -const PASTED_TEXT_SNIPPET_LIMIT = 500; - -export async function enrichDescriptionWithFileContent( - description: string, - filePaths: string[] = [], -): Promise { - const parsed = xmlToContent(description); - const stripped = parsed.segments - .flatMap((seg) => (seg.type === "text" ? [seg.text] : [])) - .join("") - .replace(ATTACHED_FILES_REGEX, "") - .replace(/^\d+\.\s*$/gm, "") - .trim(); - - if (stripped.length > 0) return description; - - const chipFilePaths = parsed.segments.flatMap((seg) => - seg.type === "chip" && seg.chip.type === "file" ? [seg.chip.id] : [], - ); - const paths = filePaths.length > 0 ? filePaths : chipFilePaths; - - if (paths.length === 0) return description; - - const parts = await Promise.all( - paths.map(async (filePath) => { - if (isBinaryFile(filePath)) { - return `[Attached: ${getFileName(filePath)}]`; - } - try { - const fileContent = await trpcClient.fs.readAbsoluteFile.query({ - filePath, - }); - if (fileContent) { - return fileContent.length > PASTED_TEXT_SNIPPET_LIMIT - ? fileContent.slice(0, PASTED_TEXT_SNIPPET_LIMIT) - : fileContent; - } - return `[Attached: ${getFileName(filePath)}]`; - } catch { - return `[Attached: ${getFileName(filePath)}]`; - } - }), - ); - - return parts.length > 0 ? parts.join("\n\n") : description; -} - -const SYSTEM_PROMPT = `You are a title and summary generator. Output using exactly this format: - -TITLE: -SUMMARY: <summary here> - -Convert the task description into a concise task title and a brief conversation summary. - -Title rules: -- The title should be clear, concise, and accurately reflect the content of the task. -- You should keep it short and simple, ideally no more than 6 words. -- Avoid using jargon or overly technical terms unless absolutely necessary. -- The title should be easy to understand for anyone reading it. -- Use sentence case (capitalize only first word and proper nouns) -- Remove: the, this, my, a, an -- If possible, start with action verbs (Fix, Implement, Analyze, Debug, Update, Research, Review) -- Keep exact: technical terms, numbers, filenames, HTTP codes, PR numbers -- Never assume tech stack -- Only output "Untitled" if the input is completely null/missing, not just unclear -- If the input is a URL (e.g. a GitHub issue link, PR link, or any web URL), generate a title based on what you can infer from the URL structure (repo name, issue/PR number, etc.). Never say you cannot access URLs or ask the user for more information. -- Never wrap the title in quotes - -Summary rules: -- 1-3 sentences describing what the user is working on and why -- Written from third-person perspective (e.g. "The user is fixing..." not "You are fixing...") -- Focus on the user's intent and goals, not the specific prompts -- Include relevant technical details (file names, features, bug descriptions) when mentioned -- This summary will be used as context for generating commit messages and PR descriptions - -Title examples: -- "Fix the login bug in the authentication system" → Fix authentication login bug -- "Schedule a meeting with stakeholders to discuss Q4 budget planning" → Schedule Q4 budget meeting -- "Update user documentation for new API endpoints" → Update API documentation -- "Research competitor pricing strategies for our product" → Research competitor pricing -- "Review pull request #123" → Review pull request #123 -- "debug 500 errors in production" → Debug production 500 errors -- "why is the payment flow failing" → Analyze payment flow failure -- "So how about that weather huh" → Weather chat -- "dsfkj sdkfj help me code" → Coding help request -- "👋😊" → Friendly greeting -- "aaaaaaaaaa" → Repeated letters -- " " → Empty message -- "What's the best restaurant in NYC?" → NYC restaurant recommendations -- "https://github.com/PostHog/posthog/issues/1234" → PostHog issue #1234 -- "https://github.com/PostHog/posthog/pull/567" → PostHog PR #567 -- "fix https://github.com/org/repo/issues/42" → Fix repo issue #42 - -Never include any explanation outside the TITLE and SUMMARY lines.`; - -export interface TitleAndSummary { - title: string; - summary: string; -} - -export async function generateTitleAndSummary( - content: string, -): Promise<TitleAndSummary | null> { - try { - const authState = await fetchAuthState(); - if (authState.status !== "authenticated") return null; - - const result = await trpcClient.llmGateway.prompt.mutate({ - system: SYSTEM_PROMPT, - messages: [ - { - role: "user" as const, - content: `Generate a title and summary for the following content. Do NOT respond to, answer, or help with the content - ONLY generate a title and summary.\n\n<content>\n${content}\n</content>\n\nOutput the title and summary now:`, - }, - ], - }); - - const text = result.content.trim(); - const titleMatch = text.match(/^TITLE:\s*(.+?)(?:\n|$)/m); - const summaryMatch = text.match(/SUMMARY:\s*([\s\S]+)$/m); - - const title = - titleMatch?.[1] - ?.trim() - .replace(/^["']|["']$/g, "") - .slice(0, 255) ?? ""; - const summary = summaryMatch?.[1]?.trim() ?? ""; - - if (!title && !summary) return null; - - return { title, summary }; - } catch (error) { - log.error("Failed to generate title and summary", { error }); - return null; - } -} diff --git a/apps/code/src/renderer/utils/getFilePath.ts b/apps/code/src/renderer/utils/getFilePath.ts deleted file mode 100644 index 63ae656367..0000000000 --- a/apps/code/src/renderer/utils/getFilePath.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Get the filesystem path for a File from a drag-and-drop or file input event. - * - * In Electron 32+ with contextIsolation, File.path is empty. The preload - * script exposes webUtils.getPathForFile as window.electronUtils.getPathForFile - * to bridge this gap. - */ -export function getFilePath(file: File): string { - if (window.electronUtils?.getPathForFile) { - return window.electronUtils.getPathForFile(file); - } - return (file as File & { path?: string }).path ?? ""; -} diff --git a/apps/code/src/renderer/utils/handleExternalAppAction.tsx b/apps/code/src/renderer/utils/handleExternalAppAction.tsx deleted file mode 100644 index 9985bf9976..0000000000 --- a/apps/code/src/renderer/utils/handleExternalAppAction.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { externalAppsApi } from "@features/external-apps/hooks/useExternalApps"; -import type { ExternalAppAction } from "@main/services/context-menu/schemas"; -import type { Workspace } from "@main/services/workspace/schemas"; -import { trpcClient } from "@renderer/trpc/client"; -import { useFocusStore } from "@stores/focusStore"; -import { logger } from "@utils/logger"; -import { toast } from "@utils/toast"; -import { showFocusSuccessToast } from "./focusToast"; - -const log = logger.scope("external-app-action"); - -interface WorkspaceContext { - workspace: Workspace | null; - mainRepoPath?: string; -} - -/** - * Ensures the workspace is focused before opening files. - * If not focused, automatically focuses the workspace first. - * Returns the effective path to use (main repo path if focused, original path otherwise). - */ -async function ensureWorkspaceFocused( - filePath: string, - workspaceContext?: WorkspaceContext, -): Promise<{ effectivePath: string; didFocus: boolean }> { - if (!workspaceContext?.workspace) { - return { effectivePath: filePath, didFocus: false }; - } - - const { workspace, mainRepoPath } = workspaceContext; - - // Only applies to worktree mode workspaces - if ( - workspace.mode !== "worktree" || - !workspace.branchName || - !workspace.worktreePath - ) { - return { effectivePath: filePath, didFocus: false }; - } - - const focusStore = useFocusStore.getState(); - const isAlreadyFocused = - focusStore.session?.worktreePath === workspace.worktreePath; - - if (isAlreadyFocused && mainRepoPath) { - // Already focused - convert worktree path to main repo path - const relativePath = filePath.replace(workspace.worktreePath, ""); - const effectivePath = `${mainRepoPath}${relativePath}`; - return { effectivePath, didFocus: false }; - } - - if (!isAlreadyFocused && mainRepoPath) { - // Need to focus first - log.info("Auto-focusing workspace before opening file", { - branch: workspace.branchName, - }); - - const result = await focusStore.enableFocus({ - mainRepoPath: workspace.folderPath, - worktreePath: workspace.worktreePath, - branch: workspace.branchName, - }); - - if (result.success) { - showFocusSuccessToast(workspace.branchName, result); - - // Convert worktree path to main repo path - const relativePath = filePath.replace(workspace.worktreePath, ""); - const effectivePath = `${mainRepoPath}${relativePath}`; - return { effectivePath, didFocus: true }; - } - - // Focus failed - fall back to original path - toast.error("Could not edit workspace", { - description: result.error, - }); - return { effectivePath: filePath, didFocus: false }; - } - - return { effectivePath: filePath, didFocus: false }; -} - -export async function handleExternalAppAction( - action: ExternalAppAction, - filePath: string, - displayName: string, - workspaceContext?: WorkspaceContext, -): Promise<void> { - if (action.type === "open-in-app") { - // Ensure workspace is focused before opening - const { effectivePath } = await ensureWorkspaceFocused( - filePath, - workspaceContext, - ); - - log.info("Opening file in app", { - appId: action.appId, - filePath: effectivePath, - displayName, - }); - const openResult = await trpcClient.externalApps.openInApp.mutate({ - appId: action.appId, - targetPath: effectivePath, - }); - if (openResult.success) { - await externalAppsApi.setLastUsed(action.appId); - - const apps = await externalAppsApi.getDetectedApps(); - const app = apps.find((a) => a.id === action.appId); - toast.success(`Opening in ${app?.name || "external app"}`, { - description: displayName, - }); - } else { - toast.error("Failed to open in external app", { - description: openResult.error || "Unknown error", - }); - } - } else if (action.type === "copy-path") { - await trpcClient.externalApps.copyPath.mutate({ targetPath: filePath }); - toast.success("Path copied to clipboard", { - description: filePath, - }); - } -} diff --git a/apps/code/src/renderer/utils/logger.ts b/apps/code/src/renderer/utils/logger.ts index 8ea0685d27..9b3520bc8a 100644 --- a/apps/code/src/renderer/utils/logger.ts +++ b/apps/code/src/renderer/utils/logger.ts @@ -1,5 +1,11 @@ +import { + type HostLogger, + logger as uiLogger, +} from "@posthog/ui/workbench/logger"; import log from "electron-log/renderer"; log.transports.console.level = "debug"; -export const logger = log; +export const hostLog = log as unknown as HostLogger; + +export const logger = uiLogger; diff --git a/apps/code/src/renderer/utils/notifications.test.ts b/apps/code/src/renderer/utils/notifications.test.ts deleted file mode 100644 index 98546573fa..0000000000 --- a/apps/code/src/renderer/utils/notifications.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useNavigationStore } from "@stores/navigationStore"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { sendMutate, showDockBadgeMutate, bounceDockMutate, playSound } = - vi.hoisted(() => ({ - sendMutate: vi.fn().mockResolvedValue(undefined), - showDockBadgeMutate: vi.fn().mockResolvedValue(undefined), - bounceDockMutate: vi.fn().mockResolvedValue(undefined), - playSound: vi.fn(), - })); - -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - notification: { - send: { mutate: sendMutate }, - showDockBadge: { mutate: showDockBadgeMutate }, - bounceDock: { mutate: bounceDockMutate }, - }, - secureStore: { - getItem: { query: vi.fn().mockResolvedValue(null) }, - setItem: { query: vi.fn().mockResolvedValue(undefined) }, - removeItem: { query: vi.fn().mockResolvedValue(undefined) }, - }, - }, -})); - -vi.mock("@utils/logger", () => ({ - logger: { scope: () => ({ info: vi.fn(), error: vi.fn(), debug: vi.fn() }) }, -})); - -vi.mock("@utils/analytics", () => ({ track: vi.fn() })); - -vi.mock("@utils/sounds", () => ({ - playCompletionSound: playSound, -})); - -import { notifyPermissionRequest, notifyPromptComplete } from "./notifications"; - -const TASK_ID = "task-123"; -const OTHER_TASK_ID = "task-999"; - -type View = { type: string; data?: { id: string }; taskId?: string }; - -function setView(view: View) { - useNavigationStore.setState({ - // biome-ignore lint/suspicious/noExplicitAny: test-only narrow cast - view: view as any, - }); -} - -function setFocus(focused: boolean) { - vi.spyOn(document, "hasFocus").mockReturnValue(focused); -} - -describe("notifications", () => { - beforeEach(() => { - sendMutate.mockClear(); - showDockBadgeMutate.mockClear(); - bounceDockMutate.mockClear(); - playSound.mockClear(); - useSettingsStore.setState({ - desktopNotifications: true, - dockBadgeNotifications: true, - dockBounceNotifications: true, - completionSound: "meep", - completionVolume: 80, - }); - setView({ type: "task-input" }); - }); - - describe("shouldNotifyForTask gating (via notifyPermissionRequest)", () => { - const cases: ReadonlyArray<{ - name: string; - focused: boolean; - view: View; - taskId?: string; - shouldNotify: boolean; - }> = [ - { - name: "window unfocused → notifies", - focused: false, - view: { type: "task-detail", data: { id: TASK_ID }, taskId: TASK_ID }, - taskId: TASK_ID, - shouldNotify: true, - }, - { - name: "focused on the same task → does not notify", - focused: true, - view: { type: "task-detail", data: { id: TASK_ID }, taskId: TASK_ID }, - taskId: TASK_ID, - shouldNotify: false, - }, - { - name: "focused on a different task → notifies", - focused: true, - view: { - type: "task-detail", - data: { id: OTHER_TASK_ID }, - taskId: OTHER_TASK_ID, - }, - taskId: TASK_ID, - shouldNotify: true, - }, - { - name: "focused but view is not task-detail → notifies", - focused: true, - view: { type: "inbox" }, - taskId: TASK_ID, - shouldNotify: true, - }, - { - name: "focused with no taskId supplied → does not notify", - focused: true, - view: { type: "inbox" }, - taskId: undefined, - shouldNotify: false, - }, - { - name: "focused, view.data missing, falls back to view.taskId → does not notify", - focused: true, - view: { type: "task-detail", taskId: TASK_ID }, - taskId: TASK_ID, - shouldNotify: false, - }, - ]; - - it.each(cases)("$name", ({ focused, view, taskId, shouldNotify }) => { - setFocus(focused); - setView(view); - - notifyPermissionRequest("My task", taskId); - - expect(sendMutate).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); - expect(playSound).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); - }); - }); - - describe("notifyPromptComplete", () => { - it.each([ - { stopReason: "tool_use", shouldNotify: false }, - { stopReason: "max_tokens", shouldNotify: false }, - { stopReason: "end_turn", shouldNotify: true }, - ])( - "stop reason '$stopReason' → notifies=$shouldNotify", - ({ stopReason, shouldNotify }) => { - setFocus(false); - notifyPromptComplete("My task", stopReason, TASK_ID); - expect(sendMutate).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); - }, - ); - - it.each([ - { - name: "focused on same task → does not notify", - view: { type: "task-detail", data: { id: TASK_ID }, taskId: TASK_ID }, - shouldNotify: false, - }, - { - name: "focused on different task → notifies", - view: { - type: "task-detail", - data: { id: OTHER_TASK_ID }, - taskId: OTHER_TASK_ID, - }, - shouldNotify: true, - }, - ])("$name", ({ view, shouldNotify }) => { - setFocus(true); - setView(view); - notifyPromptComplete("My task", "end_turn", TASK_ID); - expect(sendMutate).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); - }); - }); -}); diff --git a/apps/code/src/renderer/utils/notifications.ts b/apps/code/src/renderer/utils/notifications.ts deleted file mode 100644 index f29b278786..0000000000 --- a/apps/code/src/renderer/utils/notifications.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { trpcClient } from "@renderer/trpc/client"; -import { useNavigationStore } from "@stores/navigationStore"; -import { logger } from "@utils/logger"; -import { playCompletionSound } from "@utils/sounds"; - -const log = logger.scope("notifications"); - -const MAX_TITLE_LENGTH = 50; - -function truncateTitle(title: string): string { - if (title.length <= MAX_TITLE_LENGTH) return title; - return `${title.slice(0, MAX_TITLE_LENGTH)}...`; -} - -function shouldNotifyForTask(taskId?: string): boolean { - if (!document.hasFocus()) return true; - if (!taskId) return false; - const view = useNavigationStore.getState().view; - const viewedTaskId = - view.type === "task-detail" ? (view.data?.id ?? view.taskId) : undefined; - return viewedTaskId !== taskId; -} - -function sendDesktopNotification( - title: string, - body: string, - silent: boolean, - taskId?: string, -): void { - trpcClient.notification.send - .mutate({ title, body, silent, taskId }) - .catch((err) => { - log.error("Failed to send notification", err); - }); -} - -function showDockBadge(): void { - trpcClient.notification.showDockBadge.mutate().catch((err) => { - log.error("Failed to show dock badge", err); - }); -} - -function bounceDock(): void { - trpcClient.notification.bounceDock.mutate().catch((err) => { - log.error("Failed to bounce dock", err); - }); -} - -export function notifyPromptComplete( - taskTitle: string, - stopReason: string, - taskId?: string, -): void { - if (stopReason !== "end_turn") return; - - const { - completionSound, - completionVolume, - desktopNotifications, - dockBadgeNotifications, - dockBounceNotifications, - } = useSettingsStore.getState(); - - if (!shouldNotifyForTask(taskId)) return; - - const willPlayCustomSound = completionSound !== "none"; - playCompletionSound(completionSound, completionVolume); - - if (desktopNotifications) { - sendDesktopNotification( - "PostHog Code", - `"${truncateTitle(taskTitle)}" finished`, - willPlayCustomSound, - taskId, - ); - } - if (dockBadgeNotifications) { - showDockBadge(); - } - if (dockBounceNotifications) { - bounceDock(); - } -} - -export function notifyPermissionRequest( - taskTitle: string, - taskId?: string, -): void { - const { - completionSound, - completionVolume, - desktopNotifications, - dockBadgeNotifications, - dockBounceNotifications, - } = useSettingsStore.getState(); - - if (!shouldNotifyForTask(taskId)) return; - - const willPlayCustomSound = completionSound !== "none"; - playCompletionSound(completionSound, completionVolume); - - if (desktopNotifications) { - sendDesktopNotification( - "PostHog Code", - `"${truncateTitle(taskTitle)}" needs your input`, - willPlayCustomSound, - taskId, - ); - } - if (dockBadgeNotifications) { - showDockBadge(); - } - if (dockBounceNotifications) { - bounceDock(); - } -} diff --git a/apps/code/src/renderer/utils/object.ts b/apps/code/src/renderer/utils/object.ts deleted file mode 100644 index a6bdb335e4..0000000000 --- a/apps/code/src/renderer/utils/object.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function omitKey<T extends Record<string, unknown>>( - obj: T, - key: keyof T, -): Omit<T, typeof key> { - const { [key]: _, ...rest } = obj; - return rest; -} diff --git a/apps/code/src/renderer/utils/queryClient.test.ts b/apps/code/src/renderer/utils/queryClient.test.ts index 135112ce3c..91a1353c2f 100644 --- a/apps/code/src/renderer/utils/queryClient.test.ts +++ b/apps/code/src/renderer/utils/queryClient.test.ts @@ -1,4 +1,4 @@ -import type { Task } from "@shared/types"; +import type { Task } from "@posthog/shared/domain-types"; import { focusManager } from "@tanstack/react-query"; import { beforeEach, describe, expect, it } from "vitest"; import { getCachedTask, queryClient } from "./queryClient"; diff --git a/apps/code/src/renderer/utils/queryClient.ts b/apps/code/src/renderer/utils/queryClient.ts index 409348c4d0..aa32898722 100644 --- a/apps/code/src/renderer/utils/queryClient.ts +++ b/apps/code/src/renderer/utils/queryClient.ts @@ -1,4 +1,4 @@ -import type { Task } from "@shared/types"; +import type { Task } from "@posthog/shared/domain-types"; import { focusManager, QueryClient } from "@tanstack/react-query"; export const queryClient = new QueryClient({ diff --git a/apps/code/src/shared/constants.ts b/apps/code/src/shared/constants.ts index 73b6097d8f..280a0c34cd 100644 --- a/apps/code/src/shared/constants.ts +++ b/apps/code/src/shared/constants.ts @@ -1,10 +1,3 @@ -export const BILLING_FLAG = "posthog-code-billing"; -export const INBOX_GATED_DUE_TO_SCALE_FLAG = "inbox-gated-due-to-scale"; -export const EXPERIMENT_SUGGESTIONS_FLAG = - "posthog-code-experiment-suggestions"; -export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; -export const BRANCH_PREFIX = "posthog-code/"; -export const DATA_DIR = ".posthog-code"; export const WORKTREES_DIR = ".posthog-code/worktrees"; export const LEGACY_DATA_DIRS = [ ".twig", diff --git a/apps/code/src/shared/constants/environment.ts b/apps/code/src/shared/constants/environment.ts deleted file mode 100644 index 6444586003..0000000000 --- a/apps/code/src/shared/constants/environment.ts +++ /dev/null @@ -1 +0,0 @@ -export const IS_DEV = import.meta.env.DEV as boolean; diff --git a/apps/code/src/shared/deeplink.ts b/apps/code/src/shared/deeplink.ts deleted file mode 100644 index 9b0787f8d2..0000000000 --- a/apps/code/src/shared/deeplink.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** Custom URL scheme for PostHog Code deep links (without `://`). */ -export const DEEPLINK_PROTOCOL_PRODUCTION = "posthog-code"; -export const DEEPLINK_PROTOCOL_DEVELOPMENT = "posthog-code-dev"; - -export function getDeeplinkProtocol(isDevBuild: boolean): string { - return isDevBuild - ? DEEPLINK_PROTOCOL_DEVELOPMENT - : DEEPLINK_PROTOCOL_PRODUCTION; -} - -/** True when `href` parses as a PostHog Code deep link (production or dev scheme). */ -export function isPostHogCodeDeeplink( - href: string | undefined, -): href is string { - if (!href) return false; - try { - const protocol = new URL(href).protocol; - return ( - protocol === `${DEEPLINK_PROTOCOL_PRODUCTION}:` || - protocol === `${DEEPLINK_PROTOCOL_DEVELOPMENT}:` - ); - } catch { - return false; - } -} - -/** - * Build the deep link URL for an inbox report. The optional title is slugified - * and appended as a trailing path segment for human-readable sharing; the - * receiver only reads the UUID, so the slug is purely cosmetic. - * - * Slug rules: - * - Accented Latin letters are folded to their ASCII base (`café` → `cafe`) - * via NFD decomposition + combining-mark stripping. - * - Letters, digits, and the URL-unreserved punctuation `_ . ~` are kept - * verbatim (case preserved). - * - Any run of other characters collapses to a single `-`, except runs that - * mix a colon with other unsafe chars collapse to `--`. This preserves the - * title-like break in `fix(inbox): Add foo` → `fix-inbox--Add-foo` while - * keeping standalone colons compact (`feat:bar` → `feat-bar`) and unrelated - * runs single (`Cost $5, 50% off` → `Cost-5-50-off`). - * - Leading and trailing hyphens are stripped. - */ -export function buildInboxDeeplink( - reportId: string, - title: string | null | undefined, - { isDevBuild }: { isDevBuild: boolean }, -): string { - const base = `${getDeeplinkProtocol(isDevBuild)}://inbox/${reportId}`; - const slug = title - ? title - .normalize("NFD") - .replace(/\p{M}/gu, "") - .replace(/[^a-zA-Z0-9_.~]+/g, (run) => - run.includes(":") && /[^:]/.test(run) ? "--" : "-", - ) - .replace(/^-+|-+$/g, "") - : ""; - return slug ? `${base}/${slug}` : base; -} diff --git a/apps/code/src/shared/mcp-sandbox-proxy.ts b/apps/code/src/shared/mcp-sandbox-proxy.ts deleted file mode 100644 index 02f0e8865f..0000000000 --- a/apps/code/src/shared/mcp-sandbox-proxy.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Sandbox proxy HTML for MCP Apps. - * - * This is the intermediate layer in the double-iframe architecture: - * - * Host (renderer) → Outer iframe (sandbox proxy) → Inner iframe (MCP App) - * - * The outer iframe is served from the `mcp-sandbox:` custom protocol, giving it - * an isolated origin separate from the renderer. The inner iframe uses - * allow-same-origin so the proxy can write HTML via document.write() — srcdoc - * creates an opaque origin that breaks WebGL canvas operations (toDataURL) and - * cross-origin resource access. - * - * Because the proxy's origin (`mcp-sandbox://proxy`) differs from the - * renderer's origin, the app cannot traverse `window.parent.parent` to access - * the host's DOM, storage, or cookies. - * - * @see https://modelcontextprotocol.io/specification/2025-03-26/extensions/mcp-apps - */ - -import html from "./mcp-sandbox-proxy.html?raw"; - -export const sandboxProxyHtml: string = html; diff --git a/apps/code/src/shared/test/loggerMock.ts b/apps/code/src/shared/test/loggerMock.ts deleted file mode 100644 index c04623dc25..0000000000 --- a/apps/code/src/shared/test/loggerMock.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { vi } from "vitest"; - -export function makeLoggerMock() { - return { - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, - }; -} diff --git a/apps/code/src/shared/types/posthog.ts b/apps/code/src/shared/types/posthog.ts deleted file mode 100644 index ed83b11875..0000000000 --- a/apps/code/src/shared/types/posthog.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type PosthogInstallState = - | "not_installed" - | "installed_no_init" - | "initialized"; diff --git a/apps/code/src/shared/types/suspension.ts b/apps/code/src/shared/types/suspension.ts deleted file mode 100644 index fb6ab7c79f..0000000000 --- a/apps/code/src/shared/types/suspension.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from "zod"; - -export const suspensionReasonSchema = z.enum([ - "max_worktrees", - "inactivity", - "manual", -]); - -export type SuspensionReason = z.infer<typeof suspensionReasonSchema>; - -export const suspendedTaskSchema = z.object({ - taskId: z.string(), - suspendedAt: z.string(), - reason: suspensionReasonSchema, - folderId: z.string(), - mode: z.enum(["worktree", "local", "cloud"]), - worktreeName: z.string().nullable(), - branchName: z.string().nullable(), - checkpointId: z.string().nullable(), -}); - -export type SuspendedTask = z.infer<typeof suspendedTaskSchema>; - -export const suspensionSettingsSchema = z.object({ - autoSuspendEnabled: z.boolean(), - maxActiveWorktrees: z.number().min(1), - autoSuspendAfterDays: z.number().min(1), -}); - -export type SuspensionSettings = z.infer<typeof suspensionSettingsSchema>; diff --git a/apps/code/src/shared/utils/id.ts b/apps/code/src/shared/utils/id.ts deleted file mode 100644 index a9b6b9e710..0000000000 --- a/apps/code/src/shared/utils/id.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function randomSuffix(length = 8): string { - const array = new Uint8Array(length); - crypto.getRandomValues(array); - return Array.from(array, (b) => b.toString(16).padStart(2, "0")) - .join("") - .substring(0, length); -} - -export function generateId(prefix: string, length = 8): string { - return `${prefix}_${Date.now()}_${randomSuffix(length)}`; -} diff --git a/apps/code/vite.main.config.mts b/apps/code/vite.main.config.mts index 4e0f4b0368..5dfe558118 100644 --- a/apps/code/vite.main.config.mts +++ b/apps/code/vite.main.config.mts @@ -478,7 +478,10 @@ function copyPosthogPlugin(isDev: boolean): Plugin { } function copyDrizzleMigrations(): Plugin { - const migrationsDir = join(__dirname, "src/main/db/migrations"); + const migrationsDir = join( + __dirname, + "../../packages/workspace-server/src/db/migrations", + ); return { name: "copy-drizzle-migrations", buildStart() { diff --git a/apps/code/vite.shared.mts b/apps/code/vite.shared.mts index bc1ae0c82b..4b853b1547 100644 --- a/apps/code/vite.shared.mts +++ b/apps/code/vite.shared.mts @@ -49,6 +49,10 @@ const workspaceAliases: Alias[] = [ find: "@posthog/agent", replacement: path.resolve(__dirname, "../../packages/agent/src/index.ts"), }, + { + find: /^@posthog\/shared\/(.+)$/, + replacement: path.resolve(__dirname, "../../packages/shared/src/$1"), + }, { find: "@posthog/shared", replacement: path.resolve(__dirname, "../../packages/shared/src/index.ts"), @@ -64,6 +68,10 @@ const workspaceAliases: Alias[] = [ find: /^@posthog\/core\/(.+)$/, replacement: path.resolve(__dirname, "../../packages/core/src/$1"), }, + { + find: /^@posthog\/di\/(.+)$/, + replacement: path.resolve(__dirname, "../../packages/di/src/$1"), + }, { find: /^@posthog\/api-client\/(.+)$/, replacement: path.resolve(__dirname, "../../packages/api-client/src/$1"), @@ -72,6 +80,14 @@ const workspaceAliases: Alias[] = [ find: /^@posthog\/ui\/(.+)$/, replacement: path.resolve(__dirname, "../../packages/ui/src/$1"), }, + { + find: /^@posthog\/host-trpc\/(.+)$/, + replacement: path.resolve(__dirname, "../../packages/host-trpc/src/$1"), + }, + { + find: /^@posthog\/host-router\/(.+)$/, + replacement: path.resolve(__dirname, "../../packages/host-router/src/$1"), + }, { find: /^@posthog\/workspace-client\/(.+)$/, replacement: path.resolve( diff --git a/apps/code/vitest.config.ts b/apps/code/vitest.config.ts index 1edf6fc539..2203fb613b 100644 --- a/apps/code/vitest.config.ts +++ b/apps/code/vitest.config.ts @@ -1,6 +1,7 @@ import path from "node:path"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; +import { rendererAliases } from "./vite.shared.mjs"; export default defineConfig({ plugins: [react()], @@ -25,16 +26,12 @@ export default defineConfig({ }, }, resolve: { - alias: { - "@main": path.resolve(__dirname, "./src/main"), - "@renderer": path.resolve(__dirname, "./src/renderer"), - "@shared": path.resolve(__dirname, "./src/shared"), - "@features": path.resolve(__dirname, "./src/renderer/features"), - "@components": path.resolve(__dirname, "./src/renderer/components"), - "@stores": path.resolve(__dirname, "./src/renderer/stores"), - "@hooks": path.resolve(__dirname, "./src/renderer/hooks"), - "@utils": path.resolve(__dirname, "./src/renderer/utils"), - "@test": path.resolve(__dirname, "./src/shared/test"), - }, + alias: [ + ...rendererAliases, + { + find: "@test", + replacement: path.resolve(__dirname, "./src/shared/test"), + }, + ], }, }); diff --git a/apps/web/dev-server.mjs b/apps/web/dev-server.mjs new file mode 100644 index 0000000000..5b5b8a63cb --- /dev/null +++ b/apps/web/dev-server.mjs @@ -0,0 +1,68 @@ +// Minimal dev host backend for apps/web. +// +// The web app's HOST_TRPC_CLIENT points at this over HTTP (the electron +// equivalent is the IPC-served host router). A real cloud backend is a future +// workstream; this serves just the boot-path procedures so the @posthog/ui app +// renders its real first screen (the login/auth screen) in a browser instead of +// hanging on the auth-bootstrap spinner. +// +// Plain JS so it runs under bare `node` with no TS toolchain — it depends only +// on @trpc/server, never on the workspace TS sources. + +import { initTRPC } from "@trpc/server"; +import { createHTTPServer } from "@trpc/server/adapters/standalone"; + +const PORT = 8787; + +const t = initTRPC.create(); +const router = t.router; +const publicProcedure = t.procedure; + +// A bootstrapped, logged-out state. `bootstrapComplete: true` is what releases +// the app's Loading gate; `status: "anonymous"` makes it render the login screen. +const ANONYMOUS_BOOTSTRAPPED = { + status: "anonymous", + bootstrapComplete: true, + cloudRegion: null, + projectId: null, + availableProjectIds: [], + availableOrgIds: [], + hasCodeAccess: null, + needsScopeReauth: false, +}; + +const appRouter = router({ + auth: router({ + getState: publicProcedure.query(() => ANONYMOUS_BOOTSTRAPPED), + onStateChanged: publicProcedure.subscription(async function* (opts) { + yield ANONYMOUS_BOOTSTRAPPED; + // Hold the stream open until the client disconnects; tRPC aborts the + // generator via opts.signal so this doesn't leak. + await new Promise((resolve) => { + opts.signal?.addEventListener("abort", () => resolve(undefined)); + }); + }), + }), + analytics: router({ + resetUser: publicProcedure.mutation(() => undefined), + setUserId: publicProcedure.mutation(() => undefined), + }), +}); + +createHTTPServer({ + router: appRouter, + // Cross-origin: the Vite dev app is on :5273, this backend on :8787. + middleware: (req, res, next) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "*"); + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + next(); + }, +}).listen(PORT); + +console.log(`[web dev-server] listening on http://localhost:${PORT}`); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000000..98d51e4e75 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>PostHog Code (Web) + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000000..fde0441fbe --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,37 @@ +{ + "name": "@posthog/web", + "version": "0.0.0-dev", + "private": true, + "description": "PostHog Code", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@posthog/core": "workspace:*", + "@posthog/di": "workspace:*", + "@posthog/host-router": "workspace:*", + "@posthog/platform": "workspace:*", + "@posthog/shared": "workspace:*", + "@posthog/ui": "workspace:*", + "@posthog/workspace-client": "workspace:*", + "@tanstack/react-query": "^5.90.2", + "@trpc/client": "^11.12.0", + "@trpc/tanstack-react-query": "^11.12.0", + "inversify": "^7.10.6", + "react": "19.1.0", + "react-dom": "19.1.0", + "reflect-metadata": "^0.2.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.2.1", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", + "vite": "^6.0.7" + } +} diff --git a/apps/web/src/Providers.tsx b/apps/web/src/Providers.tsx new file mode 100644 index 0000000000..207a20a293 --- /dev/null +++ b/apps/web/src/Providers.tsx @@ -0,0 +1,25 @@ +import { HostTRPCProvider } from "@posthog/host-router/react"; +import { ThemeWrapper } from "@posthog/ui/primitives/ThemeWrapper"; +import { QueryClientProvider } from "@tanstack/react-query"; +import type React from "react"; +import { HotkeysProvider } from "react-hotkeys-hook"; +import { queryClient } from "./web-container"; +import { hostTrpcClient } from "./web-trpc"; + +// Web transport wiring — the per-host counterpart of apps/code's Providers.tsx. +// @posthog/ui consumes the HOST router context (useHostTRPCClient), so web only +// needs HostTRPCProvider over the HTTP client. No electron TrpcRouter context. + +export const Providers: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + return ( + + + + {children} + + + + ); +}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000000..126eb98ecb --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,24 @@ +import "reflect-metadata"; +import "@posthog/ui/styles/globals.css"; +import { startWorkbench } from "@posthog/di/contribution"; +import { ServiceProvider } from "@posthog/di/react"; +import App from "@posthog/ui/workbench/App"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import { Providers } from "./Providers"; +import { container } from "./web-container"; + +void startWorkbench(container); + +const rootElement = document.getElementById("root"); +if (!rootElement) throw new Error("Root element not found"); + +ReactDOM.createRoot(rootElement).render( + + + + + + + , +); diff --git a/apps/web/src/web-auth-side-effects.ts b/apps/web/src/web-auth-side-effects.ts new file mode 100644 index 0000000000..bae7a4f49c --- /dev/null +++ b/apps/web/src/web-auth-side-effects.ts @@ -0,0 +1,38 @@ +import type { CloudRegion } from "@posthog/shared"; +import { + clearAuthScopedQueries, + refreshAuthStateQuery, +} from "@posthog/ui/features/auth/authQueries"; +import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore"; +import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { injectable } from "inversify"; + +// Web counterpart of the desktop RendererAuthSideEffects. Identical store/query +// coordination, minus the desktop SessionService reset — web cloud sessions are +// owned by the core SessionService, not a renderer singleton. +@injectable() +export class WebAuthSideEffects implements IAuthSideEffects { + onAuthSuccess(_region: CloudRegion, _projectId: number | null): void { + void refreshAuthStateQuery(); + useAuthUiStateStore.getState().clearStaleRegion(); + } + + beforeProjectSwitch(): void {} + + onProjectSelected(): void { + clearAuthScopedQueries(); + void refreshAuthStateQuery(); + useNavigationStore.getState().navigateToTaskInput(); + } + + onLogout(previousRegion: CloudRegion | null): void { + clearAuthScopedQueries(); + if (previousRegion) { + useAuthUiStateStore.getState().setStaleRegion(previousRegion); + } + useNavigationStore.getState().navigateToTaskInput(); + useOnboardingStore.getState().resetSelections(); + } +} diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts new file mode 100644 index 0000000000..ea28a5ee88 --- /dev/null +++ b/apps/web/src/web-container.ts @@ -0,0 +1,82 @@ +import "reflect-metadata"; +import { setWorkbenchContainer } from "@posthog/di/container"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { HOST_TRPC_CLIENT } from "@posthog/host-router/client"; +import { sandboxProxyHtml } from "@posthog/shared/mcp-sandbox-proxy"; +import { + AUTH_SIDE_EFFECTS, + type IAuthSideEffects, +} from "@posthog/ui/features/auth/identifiers"; +import { + FEATURE_FLAGS, + type FeatureFlags, +} from "@posthog/ui/features/feature-flags/identifiers"; +import { McpAppHost } from "@posthog/ui/features/mcp-apps/components/McpAppHost"; +import { + MCP_APP_HOST_COMPONENT, + MCP_SANDBOX_PROXY_URL, +} from "@posthog/ui/features/mcp-apps/identifiers"; +import { + ANALYTICS_TRACKER, + type AnalyticsTracker, +} from "@posthog/ui/workbench/analytics"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "@posthog/ui/workbench/queryClient"; +import { QueryClient } from "@tanstack/react-query"; +import { Container } from "inversify"; +import { WebAuthSideEffects } from "./web-auth-side-effects"; +import { hostTrpcClient } from "./web-trpc"; + +export const queryClient = new QueryClient(); + +export const container = new Container({ defaultScope: "Singleton" }); + +// Keystone: the same typed host client the renderer binds, over HTTP not IPC. +container.bind(HOST_TRPC_CLIENT).toConstantValue(hostTrpcClient); + +// Logger: web uses console; electron uses electron-log. Same WorkbenchLogger shape. +const scoped = (name?: string): WorkbenchLogger => ({ + debug: (...a) => console.debug(name ? `[${name}]` : "", ...a), + info: (...a) => console.info(name ? `[${name}]` : "", ...a), + warn: (...a) => console.warn(name ? `[${name}]` : "", ...a), + error: (...a) => console.error(name ? `[${name}]` : "", ...a), + scope: (n: string) => scoped(n), +}); +container.bind(WORKBENCH_LOGGER).toConstantValue(scoped()); + +// ── Stubbed web ports (TODO: real web adapters — posthog-js, localStorage, etc.) ── +container.bind(FEATURE_FLAGS).toConstantValue({ + isEnabled: () => false, + onFlagsLoaded: () => () => {}, +}); +container.bind(ANALYTICS_TRACKER).toConstantValue({ + track: () => {}, + setActiveTaskContext: () => {}, + captureException: () => {}, + identifyUser: () => {}, + setUserGroups: () => {}, + resetUser: () => {}, +}); +container + .bind(IMPERATIVE_QUERY_CLIENT) + .toConstantValue(queryClient); + +// Interactive MCP App iframe host. Electron isolates the proxy with a custom +// privileged scheme; web gets a separate origin for free via a blob URL of the +// same (host-agnostic) proxy HTML. The blob is created once, lazily. +container.bind(AUTH_SIDE_EFFECTS).to(WebAuthSideEffects); + +container.bind(MCP_APP_HOST_COMPONENT).toConstantValue(McpAppHost); +let sandboxProxyUrl: string | null = null; +container.bind(MCP_SANDBOX_PROXY_URL).toConstantValue(() => { + if (!sandboxProxyUrl) { + sandboxProxyUrl = URL.createObjectURL( + new Blob([sandboxProxyHtml], { type: "text/html" }), + ); + } + return sandboxProxyUrl; +}); + +setWorkbenchContainer(container); diff --git a/apps/web/src/web-trpc.ts b/apps/web/src/web-trpc.ts new file mode 100644 index 0000000000..c5ffce58ac --- /dev/null +++ b/apps/web/src/web-trpc.ts @@ -0,0 +1,23 @@ +import type { HostRouter } from "@posthog/host-router/router"; +import { + createTRPCClient, + httpBatchLink, + httpSubscriptionLink, + splitLink, +} from "@trpc/client"; + +// The ENTIRE electron->web transport difference. The renderer builds the same +// client with `links: [ipcLink()]`; web swaps in HTTP: httpBatchLink for +// queries/mutations, httpSubscriptionLink (SSE) for subscriptions. Everything +// downstream (HOST_TRPC_CLIENT, every *_CLIENT port derived from it) is identical. +const API_URL = import.meta.env.VITE_WEB_API_URL ?? "http://localhost:8787"; + +export const hostTrpcClient = createTRPCClient({ + links: [ + splitLink({ + condition: (op) => op.type === "subscription", + true: httpSubscriptionLink({ url: API_URL }), + false: httpBatchLink({ url: API_URL }), + }), + ], +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000000..5f52a1b6df --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "noImplicitAny": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "esnext", + "types": ["vite/client"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000000..05f2f11d01 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,35 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const src = (name: string) => path.resolve(dir, `../../packages/${name}/src`); + +// Mirror apps/code's vite.shared.mts: resolve @posthog// to package src, +// since the packages' array-fallback `exports` don't resolve under Rollup. +const subpath = (name: string) => ({ + find: new RegExp(`^@posthog/${name}/(.+)$`), + replacement: `${src(name)}/$1`, +}); + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: [ + subpath("di"), + subpath("ui"), + subpath("core"), + subpath("shared"), + subpath("host-router"), + subpath("host-trpc"), + subpath("platform"), + subpath("workspace-client"), + subpath("api-client"), + subpath("agent"), + subpath("enricher"), + ], + }, + server: { port: 5273 }, +}); diff --git a/biome.jsonc b/biome.jsonc index 2526157d43..5326e0ccde 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -101,7 +101,7 @@ { // Files using unknownAtRules "includes": [ - "apps/code/src/renderer/styles/globals.css", + "packages/ui/src/styles/globals.css", "apps/mobile/global.css" ], "linter": { @@ -212,7 +212,9 @@ "electron", "node:*", "@posthog/*", - "!@posthog/core" + "!@posthog/core", + "!@posthog/shared", + "!@posthog/shared/*" ], "message": "api-client must run in any JS environment." } @@ -323,6 +325,8 @@ "@posthog/*", "!@posthog/core", "!@posthog/api-client", + "!@posthog/shared", + "!@posthog/shared/*", "!@posthog/workspace-client", "!@posthog/workspace-client/client", "!@posthog/platform", diff --git a/knip.json b/knip.json index 955847b6df..03dfdecbfc 100644 --- a/knip.json +++ b/knip.json @@ -1,13 +1,9 @@ { "$schema": "https://unpkg.com/knip@latest/schema.json", - "ignoreWorkspaces": ["apps/mobile"], + "ignoreWorkspaces": ["apps/mobile", "apps/twig", "apps/web"], "workspaces": { ".": { - "entry": [ - "mprocs.yaml", - "scripts/pnpm-run.mjs", - "scripts/test-access-token.js" - ] + "entry": ["mprocs.yaml", "scripts/*.{mjs,js,ts}"] }, "apps/code": { "entry": [ @@ -15,21 +11,25 @@ "src/main/index.ts", "src/main/preload.ts", "src/renderer/main.tsx", + "src/renderer/desktop-services.ts", + "src/renderer/desktop-contributions.ts", "forge.config.ts", "vite.main.config.mts", "vite.preload.config.mts", "vite.renderer.config.mts", - "scripts/*.ts" + "vite.shared.mts", + "vite.workspace-server.config.mts", + "scripts/*.{ts,mjs}" ], - "project": ["src/**/*.{ts,tsx}", "scripts/**/*.ts"], - "ignore": ["src/api/generated.ts"], + "project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,mjs}"], "ignoreDependencies": [ "typed-openapi", "chokidar", "detect-libc", "is-glob", "micromatch", - "node-addon-api" + "node-addon-api", + "@vitest/coverage-v8" ] }, "apps/cli": { @@ -40,13 +40,22 @@ "packages/agent": { "project": ["src/**/*.ts"], "ignore": ["src/templates/**"], - "ignoreDependencies": ["minimatch", "yoga-wasm-web"], + "ignoreDependencies": ["yoga-wasm-web", "@vitest/coverage-v8"], + "includeEntryExports": true + }, + "packages/api-client": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.{ts,tsx}"], + "ignore": ["src/generated.ts", "src/generated.augment.ts"], "includeEntryExports": true }, "packages/core": { - "entry": ["src/*.ts"], - "project": ["src/**/*.ts"], - "ignore": ["tests/**"], + "project": ["src/**/*.{ts,tsx}"], + "ignoreDependencies": ["reflect-metadata"], + "includeEntryExports": true + }, + "packages/di": { + "project": ["src/**/*.{ts,tsx}"], "includeEntryExports": true }, "packages/electron-trpc": { @@ -59,10 +68,49 @@ "project": ["src/**/*.ts"], "includeEntryExports": true }, + "packages/enricher": { + "entry": ["src/**/*.test.ts", "scripts/*.cjs"], + "project": ["src/**/*.ts"], + "ignoreDependencies": ["tree-sitter-cli"], + "includeEntryExports": true + }, + "packages/git": { + "entry": ["src/*.ts", "src/**/*.test.ts"], + "project": ["src/**/*.ts"], + "includeEntryExports": true + }, + "packages/host-router": { + "project": ["src/**/*.{ts,tsx}"], + "includeEntryExports": true + }, + "packages/host-trpc": { + "project": ["src/**/*.{ts,tsx}"], + "includeEntryExports": true + }, + "packages/platform": { + "entry": ["src/*.ts"], + "project": ["src/**/*.ts"], + "includeEntryExports": true + }, "packages/shared": { - "entry": ["src/index.ts", "src/**/*.test.ts"], + "entry": ["src/**/*.test.ts"], "project": ["src/**/*.ts"], "includeEntryExports": true + }, + "packages/ui": { + "project": ["src/**/*.{ts,tsx}"], + "ignore": ["src/test/**", "src/**/*.stories.tsx"], + "ignoreDependencies": ["vite"], + "includeEntryExports": true + }, + "packages/workspace-client": { + "project": ["src/**/*.{ts,tsx}"], + "includeEntryExports": true + }, + "packages/workspace-server": { + "entry": ["tsup.config.ts"], + "project": ["src/**/*.{ts,tsx}"], + "includeEntryExports": true } } } diff --git a/package.json b/package.json index fb3fe4d048..1cd881070b 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:e2e": "pnpm --filter code test:e2e", "test:e2e:headed": "pnpm --filter code test:e2e:headed", "typecheck": "turbo typecheck", + "boundaries": "node scripts/check-host-boundaries.mjs", "lint": "biome check --write --unsafe", "format": "biome format --write", "clean": "pnpm -r clean", diff --git a/packages/agent/package.json b/packages/agent/package.json index 0230e1d742..b43d2022db 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -8,6 +8,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./acp-extensions": { + "types": "./dist/acp-extensions.d.ts", + "import": "./dist/acp-extensions.js" + }, "./agent": { "types": "./dist/agent.d.ts", "import": "./dist/agent.js" diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 0b707b70c9..6b75426ca3 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -1,7 +1,18 @@ import type { GitHandoffCheckpoint, HandoffLocalGitState as GitHandoffLocalGitState, -} from "@posthog/git/handoff"; + PostHogAPIConfig, +} from "@posthog/shared"; + +export type { + ArtifactType, + PostHogAPIConfig, + Task, + TaskRun, + TaskRunArtifact, + TaskRunEnvironment, + TaskRunStatus, +} from "@posthog/shared"; /** * Stored custom notification following ACP extensibility model. @@ -25,88 +36,6 @@ export interface StoredNotification { */ export type StoredEntry = StoredNotification; -// PostHog Task model (matches PostHog Code's OpenAPI schema) -export interface Task { - id: string; - task_number?: number; - slug?: string; - title: string; - description: string; - origin_product: - | "error_tracking" - | "eval_clusters" - | "user_created" - | "support_queue" - | "session_summaries" - | "signal_report" - | "slack"; - signal_report?: string | null; // Inbox report UUID when origin_product is "signal_report" - github_integration?: number | null; - repository: string; // Format: "organization/repository" (e.g., "posthog/posthog-js") - json_schema?: Record | null; // JSON schema for task output validation - internal?: boolean; - created_at: string; - updated_at: string; - created_by?: { - id: number; - uuid: string; - distinct_id: string; - first_name: string; - email: string; - }; - latest_run?: TaskRun; -} - -// Log entry structure for TaskRun.log - -export type ArtifactType = - | "plan" - | "context" - | "reference" - | "output" - | "artifact" - | "user_attachment"; - -export interface TaskRunArtifact { - id?: string; - name: string; - type: ArtifactType; - source?: string; - size?: number; - content_type?: string; - storage_path?: string; - uploaded_at?: string; -} - -export type TaskRunStatus = - | "not_started" - | "queued" - | "in_progress" - | "completed" - | "failed" - | "cancelled"; - -export type TaskRunEnvironment = "local" | "cloud"; - -// TaskRun model - represents individual execution runs of tasks -export interface TaskRun { - id: string; - task: string; // Task ID - team: number; - branch: string | null; - stage: string | null; // Current stage (e.g., 'research', 'plan', 'build') - environment: TaskRunEnvironment; - status: TaskRunStatus; - log_url: string; - error_message: string | null; - output: Record | null; // Structured output (PR URL, commit SHA, etc.) - state: Record; // Intermediate run state (defaults to {}, never null) - artifacts?: TaskRunArtifact[]; - created_at: string; - updated_at: string; - completed_at: string | null; -} - export interface ProcessSpawnedCallback { onProcessSpawned?: (info: { pid: number; @@ -140,14 +69,6 @@ export type OnLogCallback = ( data?: unknown, ) => void; -export interface PostHogAPIConfig { - apiUrl: string; - getApiKey: () => string | Promise; - refreshApiKey?: () => string | Promise; - projectId: number; - userAgent?: string; -} - export interface OtelTransportConfig { /** PostHog ingest host, e.g., "https://us.i.posthog.com" */ host: string; diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index e704a62e9b..a7c04fd29f 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -100,6 +100,7 @@ export default defineConfig([ { entry: [ "src/index.ts", + "src/acp-extensions.ts", "src/agent.ts", "src/gateway-models.ts", "src/handoff-checkpoint.ts", diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 6e4102513e..0f1dbd58e0 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -23,5 +23,9 @@ }, "files": [ "src/**/*" - ] + ], + "dependencies": { + "@posthog/agent": "workspace:*", + "@posthog/shared": "workspace:*" + } } diff --git a/apps/code/src/renderer/api/posthogClient.test.ts b/packages/api-client/src/posthog-client.test.ts similarity index 99% rename from apps/code/src/renderer/api/posthogClient.test.ts rename to packages/api-client/src/posthog-client.test.ts index 2e0f299643..cd15f99d8c 100644 --- a/apps/code/src/renderer/api/posthogClient.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { PostHogAPIClient } from "./posthogClient"; +import { PostHogAPIClient } from "./posthog-client"; describe("PostHogAPIClient", () => { it("sends supported reasoning effort for cloud Codex runs", async () => { diff --git a/apps/code/src/renderer/api/posthogClient.ts b/packages/api-client/src/posthog-client.ts similarity index 94% rename from apps/code/src/renderer/api/posthogClient.ts rename to packages/api-client/src/posthog-client.ts index 505f04b600..b25808b3d2 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/packages/api-client/src/posthog-client.ts @@ -1,15 +1,17 @@ -import type { SpendAnalysisResponse } from "@features/billing/types/spend-analysis"; +import "./generated.augment"; import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; import type { PermissionMode } from "@posthog/agent/execution-mode"; -import { - buildApiFetcher, - createApiClient, - type Schemas, -} from "@posthog/api-client"; +import type { + CloudRunSource, + PrAuthorshipMode, + SeatData, + StoredLogEntry, +} from "@posthog/shared"; import { DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, -} from "@shared/dismissalReasons"; + SEAT_PRODUCT_KEY, +} from "@posthog/shared"; import type { ActionabilityJudgmentArtefact, AvailableSuggestedReviewer, @@ -18,6 +20,7 @@ import type { PriorityJudgmentArtefact, SandboxEnvironment, SandboxEnvironmentInput, + Signal, SignalFindingArtefact, SignalProcessingStateResponse, SignalReport, @@ -35,12 +38,27 @@ import type { SuggestedReviewersArtefact, Task, TaskRun, -} from "@shared/types"; -import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud"; -import type { SeatData } from "@shared/types/seat"; -import { SEAT_PRODUCT_KEY } from "@shared/types/seat"; -import type { StoredLogEntry } from "@shared/types/session-events"; -import { logger } from "@utils/logger"; +} from "@posthog/shared/domain-types"; +import { buildApiFetcher } from "./fetcher"; +import { createApiClient, type Schemas } from "./generated"; +import type { SpendAnalysisResponse } from "./spend-analysis"; +export interface ApiClientLogger { + warn(...args: unknown[]): void; +} + +let log: ApiClientLogger = { warn: () => {} }; + +export function setPosthogApiClientLogger(logger: ApiClientLogger): void { + log = logger; +} + +// Host build version, set by the host at boot (default "unknown"); avoids a +// build-time global so the package typechecks standalone and across importers. +let clientAppVersion = "unknown"; + +export function setPosthogApiClientAppVersion(version: string): void { + clientAppVersion = version; +} export class SeatSubscriptionRequiredError extends Error { redirectUrl: string; @@ -58,8 +76,6 @@ export class SeatPaymentFailedError extends Error { } } -const log = logger.scope("posthog-client"); - export const MCP_CATEGORIES = [ { id: "all", label: "All" }, { id: "business", label: "Business Operations" }, @@ -70,13 +86,22 @@ export const MCP_CATEGORIES = [ { id: "productivity", label: "Productivity & Collaboration" }, ] as const; -export type McpCategory = Schemas.CategoryEnum; -export type McpApprovalState = - Schemas.MCPServerInstallationToolApprovalStateEnum; -export type McpAuthType = Schemas.MCPAuthTypeEnum; -export type McpRecommendedServer = Schemas.MCPServerTemplate; -export type McpServerInstallation = Schemas.MCPServerInstallation; -export type McpInstallationTool = Schemas.MCPServerInstallationTool; +import type { + McpApprovalState, + McpAuthType, + McpCategory, + McpInstallationTool, + McpRecommendedServer, + McpServerInstallation, +} from "./types"; +export type { + McpApprovalState, + McpAuthType, + McpCategory, + McpInstallationTool, + McpRecommendedServer, + McpServerInstallation, +}; export type Evaluation = Schemas.Evaluation; @@ -576,8 +601,7 @@ export class PostHogAPIClient { buildApiFetcher({ getAccessToken, refreshAccessToken, - appVersion: - typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "unknown", + appVersion: clientAppVersion, }), baseUrl, ); @@ -1062,7 +1086,10 @@ export class PostHogAPIClient { return { success: false, error: errorMessage }; } - const data = await response.json(); + const data = (await response.json()) as { + error?: { message?: string }; + result?: unknown; + }; if (data.error) { return { success: false, @@ -1287,8 +1314,8 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch task runs: ${response.statusText}`); } - const data = await response.json(); - return data.results ?? data ?? []; + const data = (await response.json()) as { results?: TaskRun[] }; + return data.results ?? []; } async getTaskRun(taskId: string, runId: string): Promise { @@ -1306,7 +1333,7 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch task run: ${response.statusText}`); } - return await response.json(); + return (await response.json()) as TaskRun; } async createTaskRun( @@ -1501,8 +1528,10 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch integrations: ${response.statusText}`); } - const data = await response.json(); - return data.results ?? data ?? []; + const data = (await response.json()) as { + results?: { kind: string; id: number | string; [key: string]: unknown }[]; + }; + return data.results ?? []; } async getGithubBranches( @@ -1526,9 +1555,13 @@ export class PostHogAPIClient { ); } - const data = await response.json(); + const data = (await response.json()) as { + branches?: string[]; + results?: string[]; + default_branch?: string | null; + }; return { - branches: data.branches ?? data.results ?? data ?? [], + branches: data.branches ?? data.results ?? [], defaultBranch: data.default_branch ?? null, }; } @@ -1566,9 +1599,14 @@ export class PostHogAPIClient { ); } - const data = await response.json(); + const data = (await response.json()) as { + branches?: string[]; + results?: string[]; + default_branch?: string | null; + has_more?: boolean; + }; return { - branches: data.branches ?? data.results ?? data ?? [], + branches: data.branches ?? data.results ?? [], defaultBranch: data.default_branch ?? null, hasMore: data.has_more ?? false, }; @@ -1605,9 +1643,14 @@ export class PostHogAPIClient { ); } - const data = await response.json(); + const data = (await response.json()) as { + branches?: string[]; + results?: string[]; + default_branch?: string | null; + has_more?: boolean; + }; return { - branches: data.branches ?? data.results ?? data ?? [], + branches: data.branches ?? data.results ?? [], defaultBranch: data.default_branch ?? null, hasMore: data.has_more ?? false, }; @@ -1665,7 +1708,7 @@ export class PostHogAPIClient { ); } - const data = await response.json(); + const data = (await response.json()) as { has_more?: boolean }; return { repositories: this.normalizeGithubRepositories(data), hasMore: data.has_more ?? false, @@ -1722,7 +1765,7 @@ export class PostHogAPIClient { ); } - const data = await response.json(); + const data = (await response.json()) as { has_more?: boolean }; return { repositories: this.normalizeGithubRepositories(data), hasMore: data.has_more ?? false, @@ -1748,7 +1791,7 @@ export class PostHogAPIClient { ); } - const data = await response.json(); + const data: unknown = await response.json(); return this.normalizeGithubRepositories(data); } @@ -1769,7 +1812,7 @@ export class PostHogAPIClient { ); } - const data = await response.json(); + const data: unknown = await response.json(); return this.normalizeGithubRepositories(data); } @@ -1800,8 +1843,8 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch agents: ${response.statusText}`); } - const data = await response.json(); - return data.results ?? data ?? []; + const data = (await response.json()) as { results?: unknown[] }; + return data.results ?? []; } async getUsers() { @@ -1854,7 +1897,7 @@ export class PostHogAPIClient { ); } - return await response.json(); + return (await response.json()) as Schemas.Team; } async getSignalReport(reportId: string): Promise { @@ -1918,10 +1961,13 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch signal reports: ${response.statusText}`); } - const data = await response.json(); + const data = (await response.json()) as { + results?: SignalReport[]; + count?: number; + }; return { - results: data.results ?? data ?? [], - count: data.count ?? data.results?.length ?? data?.length ?? 0, + results: data.results ?? [], + count: data.count ?? data.results?.length ?? 0, }; } @@ -1944,7 +1990,7 @@ export class PostHogAPIClient { ); } - const data = await response.json(); + const data = (await response.json()) as { paused_until?: string | null }; return { paused_until: typeof data?.paused_until === "string" ? data.paused_until : null, @@ -2001,7 +2047,10 @@ export class PostHogAPIClient { return { report: null, signals: [] }; } - const data = await response.json(); + const data = (await response.json()) as { + report?: SignalReport | null; + signals?: Signal[]; + }; return { report: data.report ?? null, signals: data.signals ?? [], @@ -2193,7 +2242,7 @@ export class PostHogAPIClient { ); } - const data = await response.json(); + const data = (await response.json()) as { results?: SignalReportTask[] }; return data.results ?? []; } @@ -2354,8 +2403,10 @@ export class PostHogAPIClient { throw new Error(`Failed to fetch MCP servers: ${response.statusText}`); } - const data = await response.json(); - return data.results ?? data ?? []; + const data = (await response.json()) as { + results?: McpRecommendedServer[]; + }; + return data.results ?? []; } async getMcpServerInstallations(): Promise { @@ -2375,8 +2426,10 @@ export class PostHogAPIClient { ); } - const data = await response.json(); - return data.results ?? data ?? []; + const data = (await response.json()) as { + results?: McpServerInstallation[]; + }; + return data.results ?? []; } async installCustomMcpServer(options: { @@ -2411,7 +2464,9 @@ export class PostHogAPIClient { ); } - return await response.json(); + return (await response.json()) as + | McpServerInstallation + | Schemas.OAuthRedirectResponse; } async updateMcpServerInstallation( @@ -2443,7 +2498,7 @@ export class PostHogAPIClient { ); } - return await response.json(); + return (await response.json()) as McpServerInstallation; } async uninstallMcpServer(installationId: string): Promise { @@ -2485,7 +2540,9 @@ export class PostHogAPIClient { ); } - return await response.json(); + return (await response.json()) as + | McpServerInstallation + | Schemas.OAuthRedirectResponse; } async authorizeMcpInstallation(options: { @@ -2520,7 +2577,7 @@ export class PostHogAPIClient { ); } - return await response.json(); + return (await response.json()) as Schemas.OAuthRedirectResponse; } async getMcpInstallationTools( @@ -2545,8 +2602,10 @@ export class PostHogAPIClient { ); } - const data = await response.json(); - return data.results ?? data ?? []; + const data = (await response.json()) as { + results?: McpInstallationTool[]; + }; + return data.results ?? []; } async updateMcpToolApproval( @@ -2571,7 +2630,7 @@ export class PostHogAPIClient { ); } - return await response.json(); + return (await response.json()) as McpInstallationTool; } async refreshMcpInstallationTools( @@ -2593,8 +2652,10 @@ export class PostHogAPIClient { ); } - const data = await response.json(); - return data.results ?? data ?? []; + const data = (await response.json()) as { + results?: McpInstallationTool[]; + }; + return data.results ?? []; } async getMySeat( @@ -2764,8 +2825,10 @@ export class PostHogAPIClient { return false; } - const data = await response.json(); - const flags = data.results ?? data ?? []; + const data = (await response.json()) as { + results?: { key: string; active: boolean }[]; + }; + const flags = data.results ?? []; const flag = flags.find( (f: { key: string; active: boolean }) => f.key === flagKey, ); @@ -2794,8 +2857,10 @@ export class PostHogAPIClient { `Failed to fetch sandbox environments: ${response.statusText}`, ); } - const data = await response.json(); - return (data.results ?? data) as SandboxEnvironment[]; + const data = (await response.json()) as { + results?: SandboxEnvironment[]; + }; + return data.results ?? []; } async createSandboxEnvironment( diff --git a/apps/code/src/renderer/features/billing/types/spend-analysis.ts b/packages/api-client/src/spend-analysis.ts similarity index 100% rename from apps/code/src/renderer/features/billing/types/spend-analysis.ts rename to packages/api-client/src/spend-analysis.ts diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts new file mode 100644 index 0000000000..a17f035ad1 --- /dev/null +++ b/packages/api-client/src/types.ts @@ -0,0 +1,10 @@ +import "./generated.augment"; +import type { Schemas } from "./generated"; + +export type McpCategory = Schemas.CategoryEnum; +export type McpApprovalState = + Schemas.MCPServerInstallationToolApprovalStateEnum; +export type McpAuthType = Schemas.MCPAuthTypeEnum; +export type McpRecommendedServer = Schemas.MCPServerTemplate; +export type McpServerInstallation = Schemas.MCPServerInstallation; +export type McpInstallationTool = Schemas.MCPServerInstallationTool; diff --git a/packages/api-client/tsconfig.json b/packages/api-client/tsconfig.json index 703bc8a1d2..7c28f018ac 100644 --- a/packages/api-client/tsconfig.json +++ b/packages/api-client/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "@posthog/tsconfig/base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"] + }, "include": ["src/**/*"] } diff --git a/packages/core/package.json b/packages/core/package.json index 348afb7d87..99d84fffee 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@posthog/core", "version": "1.0.0", - "description": "Zero-dependency pure domain layer. Types, schemas, pure functions. Runs in any JS environment (Node, Bun, browser, RN, edge). No I/O, no platform calls, no framework deps.", + "description": "Host-agnostic business layer. Domain types, schemas, pure functions, and orchestration services that consume @posthog/platform capability interfaces via Inversify constructor injection. No I/O implementation, no Electron/Node host syscalls, no UI. Runs anywhere the platform interfaces are bound (desktop, web, mobile, cloud).", "private": true, "type": "module", "exports": { @@ -12,15 +12,29 @@ }, "scripts": { "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "node ../../scripts/rimraf.mjs .turbo" }, "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.1.2", + "@modelcontextprotocol/sdk": "^1.12.1", + "@pierre/diffs": "^1.1.21", + "@posthog/api-client": "workspace:*", + "@posthog/di": "workspace:*", + "@posthog/platform": "workspace:*", "@posthog/shared": "workspace:*", - "@posthog/workspace-client": "workspace:*" + "@posthog/workspace-client": "workspace:*", + "fuse.js": "^7.1.0", + "inversify": "catalog:", + "reflect-metadata": "catalog:", + "zod": "^4.1.12", + "zustand": "^4.5.0" }, "devDependencies": { + "@posthog/git": "workspace:*", "@posthog/tsconfig": "workspace:*", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^4.0.10" }, "files": [ "src/**/*" diff --git a/packages/core/src/archive/archive.module.ts b/packages/core/src/archive/archive.module.ts new file mode 100644 index 0000000000..9f80f53d91 --- /dev/null +++ b/packages/core/src/archive/archive.module.ts @@ -0,0 +1,11 @@ +import { ContainerModule } from "inversify"; +import { ArchivedTasksController } from "./archivedTasksController"; +import { ARCHIVED_TASKS_CONTROLLER, UNARCHIVE_SERVICE } from "./identifiers"; +import { UnarchiveService } from "./unarchiveService"; + +export const archiveModule = new ContainerModule(({ bind }) => { + bind(UNARCHIVE_SERVICE).to(UnarchiveService).inSingletonScope(); + bind(ARCHIVED_TASKS_CONTROLLER) + .to(ArchivedTasksController) + .inSingletonScope(); +}); diff --git a/packages/core/src/archive/archiveListView.test.ts b/packages/core/src/archive/archiveListView.test.ts new file mode 100644 index 0000000000..f7b6d83dd7 --- /dev/null +++ b/packages/core/src/archive/archiveListView.test.ts @@ -0,0 +1,108 @@ +import type { ArchivedTask } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + type ArchivedTaskWithDetails, + deriveUniqueRepos, + filterAndSortArchivedTasks, + getRepoName, + withRepoNames, +} from "./archiveListView"; + +function makeArchived(taskId: string, archivedAt: string): ArchivedTask { + return { + taskId, + archivedAt, + folderId: "", + mode: "worktree", + worktreeName: null, + branchName: null, + checkpointId: null, + }; +} + +function makeTask(id: string, partial: Partial = {}): Task { + return { + id, + title: id, + created_at: "2024-01-01T00:00:00.000Z", + repository: null, + ...partial, + } as Task; +} + +describe("getRepoName", () => { + it("returns the last path segment of an org/repo string", () => { + expect(getRepoName("posthog/posthog-js")).toBe("posthog-js"); + }); + + it("returns an em dash for nullish input", () => { + expect(getRepoName(null)).toBe("—"); + }); +}); + +describe("deriveUniqueRepos", () => { + it("returns sorted unique repo names excluding the em-dash placeholder", () => { + const items = withRepoNames([ + { + archived: makeArchived("a", "2024-01-02T00:00:00.000Z"), + task: makeTask("a", { repository: "o/zed" }), + }, + { + archived: makeArchived("b", "2024-01-03T00:00:00.000Z"), + task: makeTask("b", { repository: "o/alpha" }), + }, + { archived: makeArchived("c", "2024-01-04T00:00:00.000Z"), task: null }, + ]); + expect(deriveUniqueRepos(items)).toEqual(["alpha", "zed"]); + }); +}); + +describe("filterAndSortArchivedTasks", () => { + const items: ArchivedTaskWithDetails[] = [ + { + archived: makeArchived("a", "2024-01-02T00:00:00.000Z"), + task: makeTask("a", { title: "Apple", repository: "o/one" }), + }, + { + archived: makeArchived("b", "2024-01-04T00:00:00.000Z"), + task: makeTask("b", { title: "Banana", repository: "o/two" }), + }, + ]; + + it("filters by search query against task title", () => { + const result = filterAndSortArchivedTasks(withRepoNames(items), { + searchQuery: "ban", + repoFilter: null, + sort: { column: "archived", direction: "desc" }, + }); + expect(result.map((i) => i.archived.taskId)).toEqual(["b"]); + }); + + it("filters by repo name", () => { + const result = filterAndSortArchivedTasks(withRepoNames(items), { + searchQuery: "", + repoFilter: "one", + sort: { column: "archived", direction: "desc" }, + }); + expect(result.map((i) => i.archived.taskId)).toEqual(["a"]); + }); + + it("sorts by archivedAt descending", () => { + const result = filterAndSortArchivedTasks(withRepoNames(items), { + searchQuery: "", + repoFilter: null, + sort: { column: "archived", direction: "desc" }, + }); + expect(result.map((i) => i.archived.taskId)).toEqual(["b", "a"]); + }); + + it("sorts by archivedAt ascending", () => { + const result = filterAndSortArchivedTasks(withRepoNames(items), { + searchQuery: "", + repoFilter: null, + sort: { column: "archived", direction: "asc" }, + }); + expect(result.map((i) => i.archived.taskId)).toEqual(["a", "b"]); + }); +}); diff --git a/packages/core/src/archive/archiveListView.ts b/packages/core/src/archive/archiveListView.ts new file mode 100644 index 0000000000..83a58f8736 --- /dev/null +++ b/packages/core/src/archive/archiveListView.ts @@ -0,0 +1,98 @@ +import type { ArchivedTask } from "@posthog/shared"; +import { formatRelativeTimeLong } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; + +export interface ArchivedTaskWithDetails { + archived: ArchivedTask; + task: Task | null; +} + +export interface ArchivedTaskWithRepo extends ArchivedTaskWithDetails { + repoName: string; +} + +export type ArchiveSortColumn = "created" | "archived"; +export type ArchiveSortDirection = "asc" | "desc"; + +export interface ArchiveSortState { + column: ArchiveSortColumn; + direction: ArchiveSortDirection; +} + +export interface ArchiveFilterSortInput { + searchQuery: string; + repoFilter: string | null; + sort: ArchiveSortState; +} + +export function mergeArchivedWithTasks( + archivedTasks: ArchivedTask[], + tasks: Task[], +): ArchivedTaskWithDetails[] { + const taskMap = new Map(tasks.map((task) => [task.id, task])); + return archivedTasks.map((archived) => ({ + archived, + task: taskMap.get(archived.taskId) ?? null, + })); +} + +export function formatRelativeDate(isoDate: string | undefined): string { + if (!isoDate) return "—"; + return formatRelativeTimeLong(isoDate); +} + +export function getRepoName(repository: string | null | undefined): string { + return repository?.split("/").pop() ?? "—"; +} + +export function withRepoNames( + items: ArchivedTaskWithDetails[], +): ArchivedTaskWithRepo[] { + return items.map((item) => ({ + ...item, + repoName: getRepoName(item.task?.repository), + })); +} + +export function deriveUniqueRepos(items: ArchivedTaskWithRepo[]): string[] { + const repos = new Set(); + for (const item of items) { + if (item.repoName !== "—") repos.add(item.repoName); + } + return [...repos].sort((a, b) => a.localeCompare(b)); +} + +function sortTimestamp( + item: ArchivedTaskWithRepo, + column: ArchiveSortColumn, +): number { + if (column === "created") { + return item.task?.created_at ? new Date(item.task.created_at).getTime() : 0; + } + return new Date(item.archived.archivedAt).getTime(); +} + +export function filterAndSortArchivedTasks( + items: ArchivedTaskWithRepo[], + { searchQuery, repoFilter, sort }: ArchiveFilterSortInput, +): ArchivedTaskWithRepo[] { + let result = items; + + const query = searchQuery.trim().toLowerCase(); + if (query) { + result = result.filter((item) => + (item.task?.title?.toLowerCase() ?? "").includes(query), + ); + } + + if (repoFilter) { + result = result.filter((item) => item.repoName === repoFilter); + } + + const dir = sort.direction === "asc" ? 1 : -1; + + return [...result].sort( + (a, b) => + dir * (sortTimestamp(a, sort.column) - sortTimestamp(b, sort.column)), + ); +} diff --git a/packages/core/src/archive/archiveOrchestration.test.ts b/packages/core/src/archive/archiveOrchestration.test.ts new file mode 100644 index 0000000000..0725252d3d --- /dev/null +++ b/packages/core/src/archive/archiveOrchestration.test.ts @@ -0,0 +1,113 @@ +import type { ArchivedTask } from "@posthog/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type ArchiveOrchestrationDeps, + archiveTask, + archiveTasks, + shouldNavigateAwayForBulkArchive, +} from "./archiveOrchestration"; + +const TASK_ID = "task-1"; + +class Harness { + ids: string[] = []; + list: ArchivedTask[] = []; + deps: ArchiveOrchestrationDeps = { + getWorkspace: vi.fn().mockResolvedValue(null), + getPinnedTaskIds: vi.fn().mockResolvedValue([]), + unpin: vi.fn().mockResolvedValue(undefined), + togglePin: vi.fn().mockResolvedValue(undefined), + navigateAwayFromTaskIfActive: vi.fn(), + snapshotTerminalStates: vi.fn().mockReturnValue({}), + clearTerminalStates: vi.fn(), + restoreTerminalStates: vi.fn(), + snapshotCommandCenter: vi + .fn() + .mockReturnValue({ index: -1, wasActive: false }), + removeFromCommandCenter: vi.fn(), + restoreCommandCenter: vi.fn(), + getFocusedWorktreePath: vi.fn().mockReturnValue(null), + disableFocus: vi.fn().mockResolvedValue(undefined), + disconnectFromTask: vi.fn().mockResolvedValue(undefined), + archive: vi.fn().mockResolvedValue(undefined), + logError: vi.fn(), + cache: { + cancelPathFilter: vi.fn().mockResolvedValue(undefined), + invalidatePathFilter: vi.fn(), + setArchivedTaskIds: (updater) => { + this.ids = updater(this.ids); + }, + setArchiveList: (updater) => { + this.list = updater(this.list); + }, + }, + }; +} + +function makeDeps(): Harness { + return new Harness(); +} + +describe("archiveTask", () => { + let harness: ReturnType; + + beforeEach(() => { + harness = makeDeps(); + }); + + it("optimistically adds the task to both archive caches and calls archive", async () => { + await archiveTask(TASK_ID, harness.deps); + + expect(harness.deps.archive).toHaveBeenCalledWith(TASK_ID); + expect(harness.deps.disconnectFromTask).toHaveBeenCalledWith(TASK_ID); + expect(harness.ids).toContain(TASK_ID); + expect(harness.list.some((a) => a.taskId === TASK_ID)).toBe(true); + }); + + it("rolls back caches and re-pins when archive fails", async () => { + harness.deps.getPinnedTaskIds = vi.fn().mockResolvedValue([TASK_ID]); + harness.deps.archive = vi.fn().mockRejectedValue(new Error("boom")); + + await expect(archiveTask(TASK_ID, harness.deps)).rejects.toThrow("boom"); + + expect(harness.ids).not.toContain(TASK_ID); + expect(harness.list).toEqual([]); + expect(harness.deps.togglePin).toHaveBeenCalledWith(TASK_ID); + }); +}); + +describe("archiveTasks", () => { + it("tallies archived and failed counts", async () => { + const harness = makeDeps(); + harness.deps.archive = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("boom")); + + const result = await archiveTasks(["a", "b"], harness.deps); + + expect(result).toEqual({ archived: 1, failed: 1 }); + }); + + it("returns zeros for an empty list", async () => { + const harness = makeDeps(); + expect(await archiveTasks([], harness.deps)).toEqual({ + archived: 0, + failed: 0, + }); + }); +}); + +describe("shouldNavigateAwayForBulkArchive", () => { + it("is true when the active task is in the archive set", () => { + expect(shouldNavigateAwayForBulkArchive(["a", "b"], "b")).toBe(true); + }); + + it("is false when the active task is absent", () => { + expect(shouldNavigateAwayForBulkArchive(["a"], "z")).toBe(false); + }); + + it("is false when there is no active task", () => { + expect(shouldNavigateAwayForBulkArchive(["a"], null)).toBe(false); + }); +}); diff --git a/packages/core/src/archive/archiveOrchestration.ts b/packages/core/src/archive/archiveOrchestration.ts new file mode 100644 index 0000000000..302bd2e733 --- /dev/null +++ b/packages/core/src/archive/archiveOrchestration.ts @@ -0,0 +1,140 @@ +import type { ArchivedTask } from "@posthog/shared"; +import { + appendArchivedTaskId, + appendOptimisticArchivedTask, + buildOptimisticArchivedTask, + type OptimisticWorkspaceInfo, + removeArchivedTask, + removeArchivedTaskId, +} from "./optimisticArchive"; + +export interface ArchiveWorkspaceInfo extends OptimisticWorkspaceInfo { + worktreePath?: string | null; +} + +export interface ArchiveCacheWriter { + cancelPathFilter(): Promise; + invalidatePathFilter(): void; + setArchivedTaskIds(updater: (old: string[] | undefined) => string[]): void; + setArchiveList( + updater: (old: ArchivedTask[] | undefined) => ArchivedTask[], + ): void; +} + +export interface ArchiveOrchestrationDeps { + getWorkspace(taskId: string): Promise; + getPinnedTaskIds(): Promise; + unpin(taskId: string): Promise; + togglePin(taskId: string): Promise; + navigateAwayFromTaskIfActive(taskId: string): void; + snapshotTerminalStates(taskId: string): Record; + clearTerminalStates(taskId: string): void; + restoreTerminalStates(states: Record): void; + snapshotCommandCenter(taskId: string): { index: number; wasActive: boolean }; + removeFromCommandCenter(taskId: string): void; + restoreCommandCenter( + taskId: string, + snapshot: { index: number; wasActive: boolean }, + ): void; + getFocusedWorktreePath(): string | null | undefined; + disableFocus(): Promise; + disconnectFromTask(taskId: string): Promise; + archive(taskId: string): Promise; + logError(message: string, error: unknown): void; + cache: ArchiveCacheWriter; +} + +export interface ArchiveTaskOptions { + skipNavigate?: boolean; +} + +export async function archiveTask( + taskId: string, + deps: ArchiveOrchestrationDeps, + options?: ArchiveTaskOptions, +): Promise { + const workspace = await deps.getWorkspace(taskId); + const pinnedTaskIds = await deps.getPinnedTaskIds(); + const wasPinned = pinnedTaskIds.includes(taskId); + + if (!options?.skipNavigate) { + deps.navigateAwayFromTaskIfActive(taskId); + } + + const terminalStatesSnapshot = deps.snapshotTerminalStates(taskId); + const commandCenterSnapshot = deps.snapshotCommandCenter(taskId); + + await deps.unpin(taskId); + deps.clearTerminalStates(taskId); + deps.removeFromCommandCenter(taskId); + + await deps.cache.cancelPathFilter(); + + deps.cache.setArchivedTaskIds((old) => appendArchivedTaskId(old, taskId)); + + const optimisticArchived = buildOptimisticArchivedTask(taskId, workspace); + deps.cache.setArchiveList((old) => + appendOptimisticArchivedTask(old, optimisticArchived), + ); + + if ( + workspace?.worktreePath && + deps.getFocusedWorktreePath() === workspace.worktreePath + ) { + await deps.disableFocus(); + } + + try { + await deps.disconnectFromTask(taskId); + await deps.archive(taskId); + deps.cache.invalidatePathFilter(); + } catch (error) { + deps.logError("Failed to archive task", error); + + deps.cache.setArchivedTaskIds((old) => removeArchivedTaskId(old, taskId)); + deps.cache.setArchiveList((old) => removeArchivedTask(old, taskId)); + if (wasPinned) { + await deps.togglePin(taskId); + } + if (Object.keys(terminalStatesSnapshot).length > 0) { + deps.restoreTerminalStates(terminalStatesSnapshot); + } + if (commandCenterSnapshot.index !== -1) { + deps.restoreCommandCenter(taskId, commandCenterSnapshot); + } + + throw error; + } +} + +export interface ArchiveTasksResult { + archived: number; + failed: number; +} + +export async function archiveTasks( + taskIds: string[], + deps: ArchiveOrchestrationDeps, +): Promise { + if (taskIds.length === 0) return { archived: 0, failed: 0 }; + + let archived = 0; + let failed = 0; + for (const id of taskIds) { + try { + await archiveTask(id, deps, { skipNavigate: true }); + archived++; + } catch { + failed++; + } + } + return { archived, failed }; +} + +export function shouldNavigateAwayForBulkArchive( + taskIds: string[], + activeTaskId: string | null | undefined, +): boolean { + if (taskIds.length === 0 || !activeTaskId) return false; + return new Set(taskIds).has(activeTaskId); +} diff --git a/packages/core/src/archive/archivedTasksController.test.ts b/packages/core/src/archive/archivedTasksController.test.ts new file mode 100644 index 0000000000..299119ba9f --- /dev/null +++ b/packages/core/src/archive/archivedTasksController.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ArchivedTasksController } from "./archivedTasksController"; +import type { UnarchiveService } from "./unarchiveService"; + +const TASK_ID = "task-1"; + +function makeUnarchive(): UnarchiveService { + return { + unarchiveTask: vi.fn().mockResolvedValue({ ok: true }), + deleteArchivedTask: vi.fn().mockResolvedValue({ ok: true }), + requestContextMenuAction: vi.fn().mockResolvedValue({ action: null }), + } as unknown as UnarchiveService; +} + +describe("ArchivedTasksController.restore", () => { + let unarchive: UnarchiveService; + let controller: ArchivedTasksController; + + beforeEach(() => { + unarchive = makeUnarchive(); + controller = new ArchivedTasksController(unarchive); + }); + + it("returns the task id to navigate to when a task exists", async () => { + const outcome = await controller.restore(TASK_ID, true); + + expect(outcome).toEqual({ kind: "restored", navigateToTaskId: TASK_ID }); + }); + + it("returns a null navigation target when no task exists", async () => { + const outcome = await controller.restore(TASK_ID, false); + + expect(outcome).toEqual({ kind: "restored", navigateToTaskId: null }); + }); + + it("forwards recreateBranch to the service", async () => { + await controller.restore(TASK_ID, true, { recreateBranch: true }); + + expect(unarchive.unarchiveTask).toHaveBeenCalledWith(TASK_ID, { + recreateBranch: true, + }); + }); + + it("surfaces a branch-not-found outcome", async () => { + unarchive.unarchiveTask = vi.fn().mockResolvedValue({ + ok: false, + kind: "branch-not-found", + branchName: "feature/x", + }); + + const outcome = await controller.restore(TASK_ID, true); + + expect(outcome).toEqual({ + kind: "branch-not-found", + taskId: TASK_ID, + branchName: "feature/x", + }); + }); + + it("surfaces an error outcome on other failures", async () => { + unarchive.unarchiveTask = vi + .fn() + .mockResolvedValue({ ok: false, kind: "other", message: "boom" }); + + const outcome = await controller.restore(TASK_ID, true); + + expect(outcome).toEqual({ kind: "error", message: "boom" }); + }); +}); + +describe("ArchivedTasksController.remove", () => { + it("returns a deleted outcome on success", async () => { + const controller = new ArchivedTasksController(makeUnarchive()); + + const outcome = await controller.remove(TASK_ID); + + expect(outcome).toEqual({ kind: "deleted" }); + }); + + it("returns an error outcome on failure", async () => { + const unarchive = makeUnarchive(); + unarchive.deleteArchivedTask = vi + .fn() + .mockResolvedValue({ ok: false, message: "nope" }); + const controller = new ArchivedTasksController(unarchive); + + const outcome = await controller.remove(TASK_ID); + + expect(outcome).toEqual({ kind: "error", message: "nope" }); + }); +}); + +describe("ArchivedTasksController.runContextMenuAction", () => { + it("returns a menu-error when the menu call fails", async () => { + const unarchive = makeUnarchive(); + unarchive.requestContextMenuAction = vi + .fn() + .mockResolvedValue({ error: "menu broke" }); + const controller = new ArchivedTasksController(unarchive); + + const outcome = await controller.runContextMenuAction( + TASK_ID, + "Title", + true, + ); + + expect(outcome).toEqual({ kind: "menu-error", message: "menu broke" }); + }); + + it("dispatches restore and wraps the outcome", async () => { + const unarchive = makeUnarchive(); + unarchive.requestContextMenuAction = vi + .fn() + .mockResolvedValue({ action: "restore" }); + const controller = new ArchivedTasksController(unarchive); + + const outcome = await controller.runContextMenuAction( + TASK_ID, + "Title", + true, + ); + + expect(outcome).toEqual({ + kind: "restore", + outcome: { kind: "restored", navigateToTaskId: TASK_ID }, + }); + }); + + it("dispatches delete and wraps the outcome", async () => { + const unarchive = makeUnarchive(); + unarchive.requestContextMenuAction = vi + .fn() + .mockResolvedValue({ action: "delete" }); + const controller = new ArchivedTasksController(unarchive); + + const outcome = await controller.runContextMenuAction( + TASK_ID, + "Title", + true, + ); + + expect(outcome).toEqual({ kind: "delete", outcome: { kind: "deleted" } }); + }); + + it("returns a noop when the menu is dismissed", async () => { + const controller = new ArchivedTasksController(makeUnarchive()); + + const outcome = await controller.runContextMenuAction( + TASK_ID, + "Title", + true, + ); + + expect(outcome).toEqual({ kind: "noop" }); + }); +}); diff --git a/packages/core/src/archive/archivedTasksController.ts b/packages/core/src/archive/archivedTasksController.ts new file mode 100644 index 0000000000..a2e69153c9 --- /dev/null +++ b/packages/core/src/archive/archivedTasksController.ts @@ -0,0 +1,73 @@ +import { inject, injectable } from "inversify"; +import { ARCHIVED_TASKS_CONTROLLER, UNARCHIVE_SERVICE } from "./identifiers"; +import type { UnarchiveService } from "./unarchiveService"; + +export { ARCHIVED_TASKS_CONTROLLER }; + +export type RestoreOutcome = + | { kind: "restored"; navigateToTaskId: string | null } + | { kind: "branch-not-found"; taskId: string; branchName: string } + | { kind: "error"; message: string }; + +export type DeleteOutcome = + | { kind: "deleted" } + | { kind: "error"; message: string }; + +export type ContextMenuOutcome = + | { kind: "noop" } + | { kind: "menu-error"; message: string } + | { kind: "restore"; outcome: RestoreOutcome } + | { kind: "delete"; outcome: DeleteOutcome }; + +@injectable() +export class ArchivedTasksController { + constructor( + @inject(UNARCHIVE_SERVICE) + private readonly unarchive: UnarchiveService, + ) {} + + async restore( + taskId: string, + hasTask: boolean, + options?: { recreateBranch?: boolean }, + ): Promise { + const result = await this.unarchive.unarchiveTask(taskId, options); + if (result.ok) { + return { kind: "restored", navigateToTaskId: hasTask ? taskId : null }; + } + if (result.kind === "branch-not-found") { + return { + kind: "branch-not-found", + taskId, + branchName: result.branchName, + }; + } + return { kind: "error", message: result.message }; + } + + async remove(taskId: string): Promise { + const result = await this.unarchive.deleteArchivedTask(taskId); + if (result.ok) { + return { kind: "deleted" }; + } + return { kind: "error", message: result.message }; + } + + async runContextMenuAction( + taskId: string, + taskTitle: string, + hasTask: boolean, + ): Promise { + const result = await this.unarchive.requestContextMenuAction(taskTitle); + if ("error" in result) { + return { kind: "menu-error", message: result.error }; + } + if (result.action === "restore") { + return { kind: "restore", outcome: await this.restore(taskId, hasTask) }; + } + if (result.action === "delete") { + return { kind: "delete", outcome: await this.remove(taskId) }; + } + return { kind: "noop" }; + } +} diff --git a/packages/core/src/archive/identifiers.ts b/packages/core/src/archive/identifiers.ts new file mode 100644 index 0000000000..664565388a --- /dev/null +++ b/packages/core/src/archive/identifiers.ts @@ -0,0 +1,18 @@ +export const UNARCHIVE_SERVICE = Symbol.for("posthog.core.unarchiveService"); +export const ARCHIVED_TASKS_CONTROLLER = Symbol.for( + "posthog.core.archivedTasksController", +); +export const ARCHIVE_CLIENT = Symbol.for("posthog.core.archiveClient"); + +export type ArchivedTaskContextMenuAction = "restore" | "delete"; + +export interface ArchiveClient { + unarchive(input: { + taskId: string; + recreateBranch?: boolean; + }): Promise; + delete(input: { taskId: string }): Promise; + showArchivedTaskContextMenu(input: { taskTitle: string }): Promise<{ + action: { type: ArchivedTaskContextMenuAction } | null; + }>; +} diff --git a/packages/core/src/archive/optimisticArchive.ts b/packages/core/src/archive/optimisticArchive.ts new file mode 100644 index 0000000000..2494f9da84 --- /dev/null +++ b/packages/core/src/archive/optimisticArchive.ts @@ -0,0 +1,52 @@ +import type { ArchivedTask } from "@posthog/shared"; + +export interface OptimisticWorkspaceInfo { + folderId?: string; + mode?: ArchivedTask["mode"]; + worktreeName?: string | null; + branchName?: string | null; +} + +export function buildOptimisticArchivedTask( + taskId: string, + workspace: OptimisticWorkspaceInfo | null, + archivedAt: string = new Date().toISOString(), +): ArchivedTask { + return { + taskId, + archivedAt, + folderId: workspace?.folderId ?? "", + mode: workspace?.mode ?? "worktree", + worktreeName: workspace?.worktreeName ?? null, + branchName: workspace?.branchName ?? null, + checkpointId: null, + }; +} + +export function appendArchivedTaskId( + old: string[] | undefined, + taskId: string, +): string[] { + return old ? [...old, taskId] : [taskId]; +} + +export function removeArchivedTaskId( + old: string[] | undefined, + taskId: string, +): string[] { + return old ? old.filter((id) => id !== taskId) : []; +} + +export function appendOptimisticArchivedTask( + old: ArchivedTask[] | undefined, + optimistic: ArchivedTask, +): ArchivedTask[] { + return old ? [...old, optimistic] : [optimistic]; +} + +export function removeArchivedTask( + old: ArchivedTask[] | undefined, + taskId: string, +): ArchivedTask[] { + return old ? old.filter((a) => a.taskId !== taskId) : []; +} diff --git a/packages/core/src/archive/parseUnarchiveError.test.ts b/packages/core/src/archive/parseUnarchiveError.test.ts new file mode 100644 index 0000000000..5765f461e5 --- /dev/null +++ b/packages/core/src/archive/parseUnarchiveError.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { parseUnarchiveError } from "./parseUnarchiveError"; + +describe("parseUnarchiveError", () => { + it("extracts the branch name when the branch is missing", () => { + const result = parseUnarchiveError( + new Error("Branch 'feature/x' does not exist"), + ); + expect(result).toEqual({ + kind: "branch-not-found", + branchName: "feature/x", + }); + }); + + it("returns the raw message for other errors", () => { + const result = parseUnarchiveError(new Error("network down")); + expect(result).toEqual({ kind: "other", message: "network down" }); + }); + + it("coerces non-error values to a string message", () => { + const result = parseUnarchiveError("boom"); + expect(result).toEqual({ kind: "other", message: "boom" }); + }); +}); diff --git a/packages/core/src/archive/parseUnarchiveError.ts b/packages/core/src/archive/parseUnarchiveError.ts new file mode 100644 index 0000000000..afb130cff8 --- /dev/null +++ b/packages/core/src/archive/parseUnarchiveError.ts @@ -0,0 +1,14 @@ +const BRANCH_NOT_FOUND_PATTERN = /Branch '(.+)' does not exist/; + +export type UnarchiveErrorResult = + | { kind: "branch-not-found"; branchName: string } + | { kind: "other"; message: string }; + +export function parseUnarchiveError(error: unknown): UnarchiveErrorResult { + const message = error instanceof Error ? error.message : String(error); + const match = message.match(BRANCH_NOT_FOUND_PATTERN); + if (match) { + return { kind: "branch-not-found", branchName: match[1] }; + } + return { kind: "other", message }; +} diff --git a/packages/core/src/archive/unarchiveService.test.ts b/packages/core/src/archive/unarchiveService.test.ts new file mode 100644 index 0000000000..8bf5417e8e --- /dev/null +++ b/packages/core/src/archive/unarchiveService.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ArchiveClient } from "./identifiers"; +import { UnarchiveService } from "./unarchiveService"; + +const TASK_ID = "task-1"; + +function makeClient(): ArchiveClient { + return { + unarchive: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + showArchivedTaskContextMenu: vi.fn().mockResolvedValue({ action: null }), + }; +} + +describe("UnarchiveService.unarchiveTask", () => { + let client: ArchiveClient; + let service: UnarchiveService; + + beforeEach(() => { + client = makeClient(); + service = new UnarchiveService(client); + }); + + it("returns ok and calls the client on success", async () => { + const result = await service.unarchiveTask(TASK_ID); + + expect(result).toEqual({ ok: true }); + expect(client.unarchive).toHaveBeenCalledWith({ + taskId: TASK_ID, + recreateBranch: undefined, + }); + }); + + it("forwards recreateBranch to the client", async () => { + await service.unarchiveTask(TASK_ID, { recreateBranch: true }); + + expect(client.unarchive).toHaveBeenCalledWith({ + taskId: TASK_ID, + recreateBranch: true, + }); + }); + + it("classifies a missing branch as branch-not-found", async () => { + client.unarchive = vi + .fn() + .mockRejectedValue(new Error("Branch 'feature/x' does not exist")); + + const result = await service.unarchiveTask(TASK_ID); + + expect(result).toEqual({ + ok: false, + kind: "branch-not-found", + branchName: "feature/x", + }); + }); + + it("classifies any other failure as other", async () => { + client.unarchive = vi.fn().mockRejectedValue(new Error("boom")); + + const result = await service.unarchiveTask(TASK_ID); + + expect(result).toEqual({ ok: false, kind: "other", message: "boom" }); + }); +}); + +describe("UnarchiveService.deleteArchivedTask", () => { + it("returns ok on success", async () => { + const client = makeClient(); + const service = new UnarchiveService(client); + + const result = await service.deleteArchivedTask(TASK_ID); + + expect(result).toEqual({ ok: true }); + expect(client.delete).toHaveBeenCalledWith({ taskId: TASK_ID }); + }); + + it("returns the error message on failure", async () => { + const client = makeClient(); + client.delete = vi.fn().mockRejectedValue(new Error("nope")); + const service = new UnarchiveService(client); + + const result = await service.deleteArchivedTask(TASK_ID); + + expect(result).toEqual({ ok: false, message: "nope" }); + }); +}); + +describe("UnarchiveService.requestContextMenuAction", () => { + it("maps the chosen menu action type", async () => { + const client = makeClient(); + client.showArchivedTaskContextMenu = vi + .fn() + .mockResolvedValue({ action: { type: "restore" } }); + const service = new UnarchiveService(client); + + const result = await service.requestContextMenuAction("Title"); + + expect(result).toEqual({ action: "restore" }); + }); + + it("returns a null action when the menu is dismissed", async () => { + const client = makeClient(); + const service = new UnarchiveService(client); + + const result = await service.requestContextMenuAction("Title"); + + expect(result).toEqual({ action: null }); + }); + + it("returns an error when the menu call throws", async () => { + const client = makeClient(); + client.showArchivedTaskContextMenu = vi + .fn() + .mockRejectedValue(new Error("menu broke")); + const service = new UnarchiveService(client); + + const result = await service.requestContextMenuAction("Title"); + + expect(result).toEqual({ error: "menu broke" }); + }); +}); diff --git a/packages/core/src/archive/unarchiveService.ts b/packages/core/src/archive/unarchiveService.ts new file mode 100644 index 0000000000..9943909254 --- /dev/null +++ b/packages/core/src/archive/unarchiveService.ts @@ -0,0 +1,77 @@ +import { inject, injectable } from "inversify"; +import { + ARCHIVE_CLIENT, + type ArchiveClient, + type ArchivedTaskContextMenuAction, + UNARCHIVE_SERVICE, +} from "./identifiers"; +import { parseUnarchiveError } from "./parseUnarchiveError"; + +export { UNARCHIVE_SERVICE }; + +export type UnarchiveResult = + | { ok: true } + | { ok: false; kind: "branch-not-found"; branchName: string } + | { ok: false; kind: "other"; message: string }; + +export type DeleteArchivedTaskResult = + | { ok: true } + | { ok: false; message: string }; + +export type ContextMenuActionResult = + | { action: ArchivedTaskContextMenuAction | null } + | { error: string }; + +@injectable() +export class UnarchiveService { + constructor( + @inject(ARCHIVE_CLIENT) private readonly archive: ArchiveClient, + ) {} + + async unarchiveTask( + taskId: string, + options?: { recreateBranch?: boolean }, + ): Promise { + try { + await this.archive.unarchive({ + taskId, + recreateBranch: options?.recreateBranch, + }); + return { ok: true }; + } catch (error) { + const parsed = parseUnarchiveError(error); + if (parsed.kind === "branch-not-found") { + return { + ok: false, + kind: "branch-not-found", + branchName: parsed.branchName, + }; + } + return { ok: false, kind: "other", message: parsed.message }; + } + } + + async deleteArchivedTask(taskId: string): Promise { + try { + await this.archive.delete({ taskId }); + return { ok: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, message }; + } + } + + async requestContextMenuAction( + taskTitle: string, + ): Promise { + try { + const result = await this.archive.showArchivedTaskContextMenu({ + taskTitle, + }); + return { action: result.action?.type ?? null }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { error: message }; + } + } +} diff --git a/packages/core/src/auth/auth.module.ts b/packages/core/src/auth/auth.module.ts new file mode 100644 index 0000000000..6501241330 --- /dev/null +++ b/packages/core/src/auth/auth.module.ts @@ -0,0 +1,9 @@ +import { ContainerModule } from "inversify"; +import { AuthService } from "./auth"; + +export const AUTH_SERVICE = Symbol.for("posthog.core.auth.service"); + +export const authCoreModule = new ContainerModule(({ bind }) => { + bind(AuthService).toSelf().inSingletonScope(); + bind(AUTH_SERVICE).toService(AuthService); +}); diff --git a/apps/code/src/main/services/auth/service.test.ts b/packages/core/src/auth/auth.test.ts similarity index 72% rename from apps/code/src/main/services/auth/service.test.ts rename to packages/core/src/auth/auth.test.ts index 8733ebd258..926ee51fc9 100644 --- a/apps/code/src/main/services/auth/service.test.ts +++ b/packages/core/src/auth/auth.test.ts @@ -1,34 +1,69 @@ -import { EventEmitter } from "node:events"; +import type { WorkbenchLogger } from "@posthog/di/logger"; import type { IPowerManager } from "@posthog/platform/power-manager"; -import { OAUTH_SCOPE_VERSION } from "@shared/constants/oauth"; +import { OAUTH_SCOPE_VERSION } from "@posthog/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createMockAuthPreferenceRepository } from "../../db/repositories/auth-preference-repository.mock"; -import { createMockAuthSessionRepository } from "../../db/repositories/auth-session-repository.mock"; -import { decrypt, encrypt } from "../../utils/encryption"; -import { ConnectivityEvent } from "../connectivity/schemas"; -import type { ConnectivityService } from "../connectivity/service"; -import type { OAuthService } from "../oauth/service"; -import { AuthService } from "./service"; +import { AuthService } from "./auth"; +import type { + AuthPreferenceRecord, + AuthSessionRecord, + ConnectivityStatus, + IAuthConnectivity, + IAuthOAuthFlowService, + IAuthPreferenceStore, + IAuthSessionStore, + IAuthTokenCipher, + PersistAuthSessionRecord, +} from "./identifiers"; + +vi.mock("@posthog/shared", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sleepWithBackoff: vi.fn().mockResolvedValue(undefined), + }; +}); const mockPowerManager = vi.hoisted(() => ({ onResume: vi.fn(() => () => {}), preventSleep: vi.fn(() => () => {}), })); -vi.mock("@shared/utils/backoff", () => ({ - sleepWithBackoff: vi.fn().mockResolvedValue(undefined), -})); +function createSessionPort(): IAuthSessionStore { + let current: AuthSessionRecord | null = null; + return { + getCurrent: () => (current ? { ...current } : null), + saveCurrent: (input: PersistAuthSessionRecord) => { + current = { ...input }; + }, + clearCurrent: () => { + current = null; + }, + }; +} -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), - }, -})); +function createPreferencePort(): IAuthPreferenceStore { + const store = new Map(); + return { + get: (accountKey, cloudRegion) => + store.get(`${accountKey}:${cloudRegion}`) ?? null, + save: (input) => { + store.set(`${input.accountKey}:${input.cloudRegion}`, { ...input }); + }, + }; +} + +const identityCipher: IAuthTokenCipher = { + encrypt: (plaintext) => plaintext, + decrypt: (encrypted) => encrypted, +}; + +const mockLogger: WorkbenchLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + scope: vi.fn(() => mockLogger), +}; function mockTokenResponse( overrides: { @@ -53,20 +88,26 @@ function mockTokenResponse( } describe("AuthService", () => { - const preferenceRepository = createMockAuthPreferenceRepository(); - const repository = createMockAuthSessionRepository(); + let sessionPort: IAuthSessionStore; + let preferencePort: IAuthPreferenceStore; - const oauthService = { + const oauthFlow = { refreshToken: vi.fn(), startFlow: vi.fn(), startSignupFlow: vi.fn(), - } as unknown as OAuthService; + cancelFlow: vi.fn(), + }; - const connectivityEmitter = new EventEmitter(); - const connectivityService = Object.assign(connectivityEmitter, { + let connectivityHandler: ((status: ConnectivityStatus) => void) | null = null; + const connectivity: IAuthConnectivity = { getStatus: vi.fn(() => ({ isOnline: true })), - checkNow: vi.fn(), - }) as unknown as ConnectivityService; + onStatusChange: vi.fn((handler) => { + connectivityHandler = handler; + return () => { + connectivityHandler = null; + }; + }), + }; let service: AuthService; @@ -77,20 +118,16 @@ describe("AuthService", () => { scopeVersion?: number; } = {}, ) { - repository.saveCurrent({ - refreshTokenEncrypted: encrypt( - overrides.refreshToken ?? "stored-refresh-token", - ), + sessionPort.saveCurrent({ + refreshTokenEncrypted: overrides.refreshToken ?? "stored-refresh-token", cloudRegion: "us", selectedProjectId: overrides.selectedProjectId ?? null, scopeVersion: overrides.scopeVersion ?? OAUTH_SCOPE_VERSION, }); } - function emitOnline() { - connectivityEmitter.emit(ConnectivityEvent.StatusChange, { - isOnline: true, - }); + function emitStatus(isOnline: boolean) { + connectivityHandler?.({ isOnline }); } function getResumeHandler(): () => void { @@ -115,22 +152,30 @@ describe("AuthService", () => { ok: true, json: vi.fn().mockResolvedValue({ has_access: true }), } as unknown as Response; - }) as typeof fetch, + }) as unknown as typeof fetch, ); }; - beforeEach(() => { - preferenceRepository._preferences = []; - repository.clearCurrent(); - vi.clearAllMocks(); - connectivityEmitter.removeAllListeners(); - service = new AuthService( - preferenceRepository, - repository, - oauthService, - connectivityService, + function createService(): AuthService { + return new AuthService( + preferencePort, + sessionPort, + oauthFlow as unknown as IAuthOAuthFlowService, + connectivity, + identityCipher, mockPowerManager as unknown as IPowerManager, + mockLogger, + null, ); + } + + beforeEach(() => { + sessionPort = createSessionPort(); + preferencePort = createPreferencePort(); + vi.clearAllMocks(); + connectivityHandler = null; + vi.mocked(connectivity.getStatus).mockReturnValue({ isOnline: true }); + service = createService(); service.init(); }); @@ -178,7 +223,7 @@ describe("AuthService", () => { it("restores an authenticated session by refreshing the stored refresh token", async () => { seedStoredSession({ selectedProjectId: 42 }); - vi.mocked(oauthService.refreshToken).mockResolvedValue( + oauthFlow.refreshToken.mockResolvedValue( mockTokenResponse({ accessToken: "new-access-token", refreshToken: "rotated-refresh-token", @@ -200,19 +245,19 @@ describe("AuthService", () => { needsScopeReauth: false, }); - expect(decrypt(repository.getCurrent()?.refreshTokenEncrypted ?? "")).toBe( + expect(sessionPort.getCurrent()?.refreshTokenEncrypted).toBe( "rotated-refresh-token", ); }); it("forces a token refresh when explicitly requested", async () => { - vi.mocked(oauthService.startFlow).mockResolvedValue( + oauthFlow.startFlow.mockResolvedValue( mockTokenResponse({ accessToken: "initial-access-token", refreshToken: "initial-refresh-token", }), ); - vi.mocked(oauthService.refreshToken).mockResolvedValue( + oauthFlow.refreshToken.mockResolvedValue( mockTokenResponse({ accessToken: "refreshed-access-token", refreshToken: "rotated-refresh-token", @@ -224,17 +269,17 @@ describe("AuthService", () => { const token = await service.refreshAccessToken(); expect(token.accessToken).toBe("refreshed-access-token"); - expect(oauthService.refreshToken).toHaveBeenCalledWith( + expect(oauthFlow.refreshToken).toHaveBeenCalledWith( "initial-refresh-token", "us", ); - expect(decrypt(repository.getCurrent()?.refreshTokenEncrypted ?? "")).toBe( + expect(sessionPort.getCurrent()?.refreshTokenEncrypted).toBe( "rotated-refresh-token", ); }); it("preserves the selected project across logout and re-login for the same account", async () => { - vi.mocked(oauthService.startFlow) + oauthFlow.startFlow .mockResolvedValueOnce( mockTokenResponse({ accessToken: "initial-access-token", @@ -249,7 +294,7 @@ describe("AuthService", () => { scopedTeams: [42, 84], }), ); - vi.mocked(oauthService.refreshToken).mockResolvedValue( + oauthFlow.refreshToken.mockResolvedValue( mockTokenResponse({ accessToken: "refreshed-access-token", refreshToken: "refreshed-refresh-token", @@ -279,7 +324,7 @@ describe("AuthService", () => { }); it("restores the selected project after app restart while logged out", async () => { - vi.mocked(oauthService.startFlow) + oauthFlow.startFlow .mockResolvedValueOnce( mockTokenResponse({ accessToken: "initial-access-token", @@ -294,7 +339,7 @@ describe("AuthService", () => { scopedTeams: [42, 84], }), ); - vi.mocked(oauthService.refreshToken).mockResolvedValue( + oauthFlow.refreshToken.mockResolvedValue( mockTokenResponse({ accessToken: "refreshed-access-token", refreshToken: "refreshed-refresh-token", @@ -307,13 +352,7 @@ describe("AuthService", () => { await service.selectProject(84); await service.logout(); - service = new AuthService( - preferenceRepository, - repository, - oauthService, - connectivityService, - mockPowerManager as unknown as IPowerManager, - ); + service = createService(); await service.login("us"); @@ -328,21 +367,15 @@ describe("AuthService", () => { describe("lifecycle: connectivity recovery", () => { it("recovers session when connectivity changes to online", async () => { seedStoredSession({ selectedProjectId: 42 }); - vi.mocked(connectivityService.getStatus).mockReturnValue({ - isOnline: false, - }); + vi.mocked(connectivity.getStatus).mockReturnValue({ isOnline: false }); await service.initialize(); expect(service.getState().status).toBe("anonymous"); - vi.mocked(connectivityService.getStatus).mockReturnValue({ - isOnline: true, - }); - vi.mocked(oauthService.refreshToken).mockResolvedValue( - mockTokenResponse(), - ); + vi.mocked(connectivity.getStatus).mockReturnValue({ isOnline: true }); + oauthFlow.refreshToken.mockResolvedValue(mockTokenResponse()); stubAuthFetch(); - emitOnline(); + emitStatus(true); await vi.waitFor(() => { expect(service.getState().status).toBe("authenticated"); @@ -350,44 +383,42 @@ describe("AuthService", () => { }); it("does nothing when session already exists", async () => { - vi.mocked(oauthService.startFlow).mockResolvedValue(mockTokenResponse()); + oauthFlow.startFlow.mockResolvedValue(mockTokenResponse()); stubAuthFetch(); await service.login("us"); - vi.mocked(oauthService.refreshToken).mockClear(); + oauthFlow.refreshToken.mockClear(); - emitOnline(); + emitStatus(true); await new Promise((r) => setTimeout(r, 10)); - expect(oauthService.refreshToken).not.toHaveBeenCalled(); + expect(oauthFlow.refreshToken).not.toHaveBeenCalled(); }); it("ignores offline events", async () => { seedStoredSession(); - connectivityEmitter.emit(ConnectivityEvent.StatusChange, { - isOnline: false, - }); + emitStatus(false); await new Promise((r) => setTimeout(r, 10)); - expect(oauthService.refreshToken).not.toHaveBeenCalled(); + expect(oauthFlow.refreshToken).not.toHaveBeenCalled(); }); it("deduplicates concurrent recovery attempts", async () => { seedStoredSession(); let resolveRefresh!: () => void; - vi.mocked(oauthService.refreshToken).mockReturnValue( + oauthFlow.refreshToken.mockReturnValue( new Promise((resolve) => { resolveRefresh = () => resolve(mockTokenResponse()); }), ); stubAuthFetch(); - emitOnline(); - emitOnline(); + emitStatus(true); + emitStatus(true); await new Promise((r) => setTimeout(r, 10)); - expect(oauthService.refreshToken).toHaveBeenCalledTimes(1); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1); resolveRefresh(); @@ -405,8 +436,6 @@ describe("AuthService", () => { const unsubscribe = mockPowerManager.onResume.mock.results[0]?.value as | (() => void) | undefined; - const unsubscribeSpy = vi.fn(); - mockPowerManager.onResume.mockReturnValueOnce(unsubscribeSpy); service.shutdown(); expect(unsubscribe).toBeDefined(); @@ -414,9 +443,7 @@ describe("AuthService", () => { it("attempts session recovery on resume", async () => { seedStoredSession(); - vi.mocked(oauthService.refreshToken).mockResolvedValue( - mockTokenResponse(), - ); + oauthFlow.refreshToken.mockResolvedValue(mockTokenResponse()); stubAuthFetch(); getResumeHandler()(); @@ -435,7 +462,7 @@ describe("AuthService", () => { "retries on $label and succeeds on second attempt", async ({ errorCode }) => { seedStoredSession(); - vi.mocked(oauthService.refreshToken) + oauthFlow.refreshToken .mockResolvedValueOnce({ success: false, error: "Transient failure", @@ -447,13 +474,13 @@ describe("AuthService", () => { await service.initialize(); expect(service.getState().status).toBe("authenticated"); - expect(oauthService.refreshToken).toHaveBeenCalledTimes(2); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(2); }, ); it("does not retry on auth_error and forces logout", async () => { seedStoredSession({ selectedProjectId: 42 }); - vi.mocked(oauthService.refreshToken).mockResolvedValue({ + oauthFlow.refreshToken.mockResolvedValue({ success: false, error: "Token revoked", errorCode: "auth_error", @@ -466,13 +493,13 @@ describe("AuthService", () => { cloudRegion: "us", projectId: 42, }); - expect(oauthService.refreshToken).toHaveBeenCalledTimes(1); - expect(repository.getCurrent()).toBeNull(); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1); + expect(sessionPort.getCurrent()).toBeNull(); }); it("does not retry on unknown_error", async () => { seedStoredSession(); - vi.mocked(oauthService.refreshToken).mockResolvedValue({ + oauthFlow.refreshToken.mockResolvedValue({ success: false, error: "Something weird", errorCode: "unknown_error", @@ -481,12 +508,12 @@ describe("AuthService", () => { await service.initialize(); expect(service.getState().status).toBe("anonymous"); - expect(oauthService.refreshToken).toHaveBeenCalledTimes(1); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1); }); it("gives up after all retry attempts are exhausted", async () => { seedStoredSession(); - vi.mocked(oauthService.refreshToken).mockResolvedValue({ + oauthFlow.refreshToken.mockResolvedValue({ success: false, error: "Network error", errorCode: "network_error", @@ -495,19 +522,19 @@ describe("AuthService", () => { await service.initialize(); expect(service.getState().status).toBe("anonymous"); - expect(oauthService.refreshToken).toHaveBeenCalledTimes(3); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(3); }); }); describe("redeemInviteCode uses authenticatedFetch", () => { it("retries on 401 via authenticatedFetch", async () => { - vi.mocked(oauthService.startFlow).mockResolvedValue( + oauthFlow.startFlow.mockResolvedValue( mockTokenResponse({ accessToken: "initial-token", refreshToken: "refresh-token", }), ); - vi.mocked(oauthService.refreshToken).mockResolvedValue( + oauthFlow.refreshToken.mockResolvedValue( mockTokenResponse({ accessToken: "refreshed-token", refreshToken: "new-refresh-token", @@ -547,7 +574,7 @@ describe("AuthService", () => { ok: true, json: vi.fn().mockResolvedValue({ has_access: true }), } as unknown as Response; - }) as typeof fetch, + }) as unknown as typeof fetch, ); await service.login("us"); diff --git a/apps/code/src/main/services/auth/service.ts b/packages/core/src/auth/auth.ts similarity index 81% rename from apps/code/src/main/services/auth/service.ts rename to packages/core/src/auth/auth.ts index e59051aa16..faca6b10d0 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/packages/core/src/auth/auth.ts @@ -1,25 +1,31 @@ -import type { IPowerManager } from "@posthog/platform/power-manager"; -import { OAUTH_SCOPE_VERSION } from "@shared/constants/oauth"; -import { NotAuthenticatedError } from "@shared/errors"; -import type { CloudRegion } from "@shared/types/regions"; -import { type BackoffOptions, sleepWithBackoff } from "@shared/utils/backoff"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { + type IPowerManager, + POWER_MANAGER_SERVICE, +} from "@posthog/platform/power-manager"; +import { + type BackoffOptions, + type CloudRegion, + getCloudUrlFromRegion, + NotAuthenticatedError, + OAUTH_SCOPE_VERSION, + sleepWithBackoff, + TypedEventEmitter, +} from "@posthog/shared"; import { inject, injectable, postConstruct, preDestroy } from "inversify"; -import type { IAuthPreferenceRepository } from "../../db/repositories/auth-preference-repository"; -import type { - IAuthSessionRepository, - PersistAuthSessionInput, -} from "../../db/repositories/auth-session-repository"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { decrypt, encrypt } from "../../utils/encryption"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; import { - ConnectivityEvent, - type ConnectivityStatusOutput, -} from "../connectivity/schemas"; -import type { ConnectivityService } from "../connectivity/service"; -import type { OAuthService } from "../oauth/service"; + AUTH_CONNECTIVITY, + AUTH_OAUTH_FLOW_SERVICE, + AUTH_PREFERENCE_STORE, + AUTH_SESSION_STORE, + AUTH_TOKEN_CIPHER, + AUTH_TOKEN_OVERRIDE, + type IAuthConnectivity, + type IAuthOAuthFlowService, + type IAuthPreferenceStore, + type IAuthSessionStore, + type IAuthTokenCipher, +} from "./identifiers"; import { AuthServiceEvent, type AuthServiceEvents, @@ -28,7 +34,6 @@ import { type ValidAccessTokenOutput, } from "./schemas"; -const log = logger.scope("auth-service"); const TOKEN_EXPIRY_SKEW_MS = 60_000; type FetchLike = ( input: string | Request, @@ -73,16 +78,22 @@ export class AuthService extends TypedEventEmitter { private initializePromise: Promise | null = null; private refreshPromise: Promise | null = null; constructor( - @inject(MAIN_TOKENS.AuthPreferenceRepository) - private readonly authPreferenceRepository: IAuthPreferenceRepository, - @inject(MAIN_TOKENS.AuthSessionRepository) - private readonly authSessionRepository: IAuthSessionRepository, - @inject(MAIN_TOKENS.OAuthService) - private readonly oauthService: OAuthService, - @inject(MAIN_TOKENS.ConnectivityService) - private readonly connectivityService: ConnectivityService, - @inject(MAIN_TOKENS.PowerManager) + @inject(AUTH_PREFERENCE_STORE) + private readonly authPreference: IAuthPreferenceStore, + @inject(AUTH_SESSION_STORE) + private readonly authSession: IAuthSessionStore, + @inject(AUTH_OAUTH_FLOW_SERVICE) + private readonly oauthFlow: IAuthOAuthFlowService, + @inject(AUTH_CONNECTIVITY) + private readonly connectivity: IAuthConnectivity, + @inject(AUTH_TOKEN_CIPHER) + private readonly cipher: IAuthTokenCipher, + @inject(POWER_MANAGER_SERVICE) private readonly powerManager: IPowerManager, + @inject(WORKBENCH_LOGGER) + private readonly logger: WorkbenchLogger, + @inject(AUTH_TOKEN_OVERRIDE) + private readonly tokenOverride: string | null, ) { super(); } @@ -99,7 +110,7 @@ export class AuthService extends TypedEventEmitter { } async login(region: CloudRegion): Promise { await this.authenticateWithFlow( - () => this.oauthService.startFlow(region), + () => this.oauthFlow.startFlow(region), region, "OAuth flow failed", ); @@ -107,14 +118,14 @@ export class AuthService extends TypedEventEmitter { } async signup(region: CloudRegion): Promise { await this.authenticateWithFlow( - () => this.oauthService.startSignupFlow(region), + () => this.oauthFlow.startSignupFlow(region), region, "Signup failed", ); return this.getState(); } async getValidAccessToken(): Promise { - const override = process.env.VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE; + const override = this.tokenOverride; if (override) { await this.initialize(); const region = this.session?.cloudRegion ?? "us"; @@ -133,7 +144,7 @@ export class AuthService extends TypedEventEmitter { }; } async refreshAccessToken(): Promise { - const override = process.env.VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE; + const override = this.tokenOverride; if (override) { await this.initialize(); const region = this.session?.cloudRegion ?? "us"; @@ -161,8 +172,6 @@ export class AuthService extends TypedEventEmitter { this.session = { ...this.session, accessToken: `${this.session.accessToken}_invalid`, - // Keep the token apparently fresh so the next authenticated request - // exercises the 401 -> refresh retry path instead of preemptive refresh. accessTokenExpiresAt: Date.now() + 5 * 60 * 1000, }; } @@ -242,7 +251,7 @@ export class AuthService extends TypedEventEmitter { async logout(): Promise { const { cloudRegion, projectId } = this.state; - this.authSessionRepository.clearCurrent(); + this.authSession.clearCurrent(); this.session = null; this.setAnonymousState({ cloudRegion, projectId }); return this.getState(); @@ -262,7 +271,7 @@ export class AuthService extends TypedEventEmitter { }); } private async doInitialize(): Promise { - const stored = this.authSessionRepository.getCurrent(); + const stored = this.authSession.getCurrent(); if (!stored) { this.setAnonymousState({ bootstrapComplete: true }); @@ -282,8 +291,8 @@ export class AuthService extends TypedEventEmitter { const storedSession = this.resolveStoredSession(); if (!storedSession) { - log.warn("Stored auth session could not be decrypted"); - this.authSessionRepository.clearCurrent(); + this.logger.warn("Stored auth session could not be decrypted"); + this.authSession.clearCurrent(); this.setAnonymousState({ bootstrapComplete: true }); return; } @@ -291,7 +300,7 @@ export class AuthService extends TypedEventEmitter { try { await this.refreshAndSyncSession(storedSession); } catch (error) { - log.warn("Failed to restore stored auth session", { error }); + this.logger.warn("Failed to restore stored auth session", { error }); this.session = null; this.setAnonymousState({ bootstrapComplete: true, @@ -345,7 +354,7 @@ export class AuthService extends TypedEventEmitter { private async refreshSession( input: StoredSessionInput, ): Promise { - if (!this.connectivityService.getStatus().isOnline) { + if (!this.connectivity.getStatus().isOnline) { throw new Error("Offline"); } @@ -356,7 +365,7 @@ export class AuthService extends TypedEventEmitter { attempt < AuthService.REFRESH_MAX_ATTEMPTS; attempt++ ) { - const result = await this.oauthService.refreshToken( + const result = await this.oauthFlow.refreshToken( input.refreshToken, input.cloudRegion, ); @@ -368,8 +377,8 @@ export class AuthService extends TypedEventEmitter { lastError = result.error || "Token refresh failed"; if (result.errorCode === "auth_error") { - log.warn("Refresh token rejected by server, forcing logout"); - this.authSessionRepository.clearCurrent(); + this.logger.warn("Refresh token rejected by server, forcing logout"); + this.authSession.clearCurrent(); this.session = null; this.setAnonymousState({ cloudRegion: input.cloudRegion, @@ -389,7 +398,7 @@ export class AuthService extends TypedEventEmitter { const isLastAttempt = attempt === AuthService.REFRESH_MAX_ATTEMPTS - 1; if (isLastAttempt) break; - log.warn("Transient refresh failure, retrying", { + this.logger.warn("Transient refresh failure, retrying", { attempt, errorCode: result.errorCode, }); @@ -411,7 +420,7 @@ export class AuthService extends TypedEventEmitter { const preferredProjectId = options.selectedProjectId ?? (accountKey - ? (this.authPreferenceRepository.get(accountKey, options.cloudRegion) + ? (this.authPreference.get(accountKey, options.cloudRegion) ?.lastSelectedProjectId ?? null) : null); const projectId = @@ -485,21 +494,19 @@ export class AuthService extends TypedEventEmitter { cloudRegion: CloudRegion; selectedProjectId: number | null; }): void { - const row: PersistAuthSessionInput = { - refreshTokenEncrypted: encrypt(input.refreshToken), + this.authSession.saveCurrent({ + refreshTokenEncrypted: this.cipher.encrypt(input.refreshToken), cloudRegion: input.cloudRegion, selectedProjectId: input.selectedProjectId, scopeVersion: OAUTH_SCOPE_VERSION, - }; - - this.authSessionRepository.saveCurrent(row); + }); } private persistProjectPreference(session: InMemorySession): void { if (!session.accountKey) { return; } - this.authPreferenceRepository.save({ + this.authPreference.save({ accountKey: session.accountKey, cloudRegion: session.cloudRegion, lastSelectedProjectId: session.projectId, @@ -510,7 +517,7 @@ export class AuthService extends TypedEventEmitter { } private async fetchAccountKey( accessToken: string, - cloudRegion: "us" | "eu" | "dev", + cloudRegion: CloudRegion, ): Promise { try { const response = await fetch( @@ -544,7 +551,7 @@ export class AuthService extends TypedEventEmitter { return null; } catch (error) { - log.warn("Failed to resolve auth account key", { error }); + this.logger.warn("Failed to resolve auth account key", { error }); return null; } } @@ -591,7 +598,7 @@ export class AuthService extends TypedEventEmitter { this.updateState({ hasCodeAccess: data.has_access === true }); } catch (error) { - log.warn("Failed to update code access state", { error }); + this.logger.warn("Failed to update code access state", { error }); this.updateState({ hasCodeAccess: false }); } } @@ -606,15 +613,13 @@ export class AuthService extends TypedEventEmitter { private resumeUnsubscribe: (() => void) | null = null; @postConstruct() init(): void { - const handler = (status: ConnectivityStatusOutput) => { - if (status.isOnline) { - this.attemptSessionRecovery(); - } - }; - this.connectivityService.on(ConnectivityEvent.StatusChange, handler); - this.connectivityUnsubscribe = () => { - this.connectivityService.off(ConnectivityEvent.StatusChange, handler); - }; + this.connectivityUnsubscribe = this.connectivity.onStatusChange( + (status) => { + if (status.isOnline) { + this.attemptSessionRecovery(); + } + }, + ); this.resumeUnsubscribe = this.powerManager.onResume(this.handleResume); } @@ -629,10 +634,10 @@ export class AuthService extends TypedEventEmitter { this.attemptSessionRecovery(); }; private resolveStoredSession(): StoredSessionInput | null { - const stored = this.authSessionRepository.getCurrent(); + const stored = this.authSession.getCurrent(); if (!stored) return null; - const refreshToken = decrypt(stored.refreshTokenEncrypted); + const refreshToken = this.cipher.decrypt(stored.refreshTokenEncrypted); if (!refreshToken) return null; return { @@ -645,7 +650,7 @@ export class AuthService extends TypedEventEmitter { if (this.session) return; if (this.recoveryPromise) return; - const stored = this.authSessionRepository.getCurrent(); + const stored = this.authSession.getCurrent(); if (!stored) return; if (stored.scopeVersion < OAUTH_SCOPE_VERSION) return; @@ -654,7 +659,7 @@ export class AuthService extends TypedEventEmitter { this.recoveryPromise = this.refreshAndSyncSession(storedSession) .catch((error) => { - log.warn("Session recovery failed", { error }); + this.logger.warn("Session recovery failed", { error }); }) .finally(() => { this.recoveryPromise = null; diff --git a/packages/core/src/auth/authErrors.ts b/packages/core/src/auth/authErrors.ts new file mode 100644 index 0000000000..464585ee7e --- /dev/null +++ b/packages/core/src/auth/authErrors.ts @@ -0,0 +1,27 @@ +export function mapAuthErrorMessage(error: unknown): string | null { + if (!error) { + return null; + } + if (!(error instanceof Error)) { + return "Failed to authenticate"; + } + const message = error.message; + + if (message === "2FA_REQUIRED") { + return null; + } + + if (message.includes("access_denied")) { + return "Authorization cancelled."; + } + + if (message.includes("timed out")) { + return "Authorization timed out. Please try again."; + } + + if (message.includes("SSO login required")) { + return message; + } + + return message; +} diff --git a/packages/core/src/auth/authIdentity.ts b/packages/core/src/auth/authIdentity.ts new file mode 100644 index 0000000000..8096e19aae --- /dev/null +++ b/packages/core/src/auth/authIdentity.ts @@ -0,0 +1,8 @@ +import type { AuthState } from "./schemas"; + +export function getAuthIdentity(authState: AuthState): string | null { + if (authState.status !== "authenticated" || !authState.cloudRegion) { + return null; + } + return `${authState.cloudRegion}:${authState.projectId ?? "none"}`; +} diff --git a/packages/core/src/auth/identifiers.ts b/packages/core/src/auth/identifiers.ts new file mode 100644 index 0000000000..66a48f8861 --- /dev/null +++ b/packages/core/src/auth/identifiers.ts @@ -0,0 +1,110 @@ +import type { CloudRegion } from "@posthog/shared"; +import type { + CancelFlowOutput, + RefreshTokenOutput, + StartFlowOutput, +} from "./oauth.schemas"; + +export interface AuthSessionRecord { + refreshTokenEncrypted: string; + cloudRegion: CloudRegion; + selectedProjectId: number | null; + scopeVersion: number; +} + +export interface PersistAuthSessionRecord { + refreshTokenEncrypted: string; + cloudRegion: CloudRegion; + selectedProjectId: number | null; + scopeVersion: number; +} + +export interface AuthPreferenceRecord { + accountKey: string; + cloudRegion: CloudRegion; + lastSelectedProjectId: number | null; +} + +/** + * Persists the encrypted auth session. Desktop adapter wraps the + * workspace-server AuthSessionRepository (drizzle rows mapped to the domain + * record above so core never imports workspace-server). + */ +export interface IAuthSessionStore { + getCurrent(): AuthSessionRecord | null; + saveCurrent(input: PersistAuthSessionRecord): void; + clearCurrent(): void; +} + +export const AUTH_SESSION_STORE = Symbol.for("posthog.core.auth.sessionStore"); + +/** + * Persists per-account project preference. Desktop adapter wraps the + * workspace-server AuthPreferenceRepository. + */ +export interface IAuthPreferenceStore { + get( + accountKey: string, + cloudRegion: CloudRegion, + ): AuthPreferenceRecord | null; + save(input: AuthPreferenceRecord): void; +} + +export const AUTH_PREFERENCE_STORE = Symbol.for( + "posthog.core.auth.preferenceStore", +); + +/** + * Drives the host OAuth login/refresh flow. Desktop adapter wraps the + * Electron-coupled OAuthService (loopback callback server, deep links, + * browser launch, window focus). + */ +export interface IAuthOAuthFlowService { + startFlow(region: CloudRegion): Promise; + startSignupFlow(region: CloudRegion): Promise; + refreshToken( + refreshToken: string, + region: CloudRegion, + ): Promise; + cancelFlow(): CancelFlowOutput; +} + +export const AUTH_OAUTH_FLOW_SERVICE = Symbol.for( + "posthog.core.auth.oauthFlow", +); + +/** + * Machine-bound symmetric cipher for the refresh token at rest. Desktop adapter + * wraps the existing encryption util (node:crypto + machine id). + */ +export interface IAuthTokenCipher { + encrypt(plaintext: string): string; + decrypt(encrypted: string): string | null; +} + +export const AUTH_TOKEN_CIPHER = Symbol.for("posthog.core.auth.tokenCipher"); + +export interface ConnectivityStatus { + isOnline: boolean; +} + +/** + * Reports network connectivity so the session refresh can avoid pointless + * offline attempts and recover when the network returns. Desktop adapter wraps + * the ConnectivityService (workspace-server connectivity stream). + */ +export interface IAuthConnectivity { + getStatus(): ConnectivityStatus; + onStatusChange(handler: (status: ConnectivityStatus) => void): () => void; +} + +export const AUTH_CONNECTIVITY = Symbol.for("posthog.core.auth.connectivity"); + +/** + * Optional dev/test access-token override (host build env, e.g. Vite + * VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE). Injected as a value so core stays pure + * (no process.env). Bind to null when unset. + */ +export const AUTH_TOKEN_OVERRIDE = Symbol.for( + "posthog.core.auth.tokenOverride", +); diff --git a/apps/code/src/main/services/oauth/schemas.ts b/packages/core/src/auth/oauth.schemas.ts similarity index 98% rename from apps/code/src/main/services/oauth/schemas.ts rename to packages/core/src/auth/oauth.schemas.ts index aef4a0280a..e909c4471b 100644 --- a/apps/code/src/main/services/oauth/schemas.ts +++ b/packages/core/src/auth/oauth.schemas.ts @@ -66,6 +66,6 @@ export const cancelFlowOutput = z.object({ export type CancelFlowOutput = z.infer; export const openExternalUrlInput = z.object({ - url: z.url(), + url: z.string().url(), }); export type OpenExternalUrlInput = z.infer; diff --git a/apps/code/src/main/services/auth/schemas.ts b/packages/core/src/auth/schemas.ts similarity index 94% rename from apps/code/src/main/services/auth/schemas.ts rename to packages/core/src/auth/schemas.ts index f165e6a22a..9f2e7fd26a 100644 --- a/apps/code/src/main/services/auth/schemas.ts +++ b/packages/core/src/auth/schemas.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { cloudRegion, type oAuthTokenResponse } from "../oauth/schemas"; +import { cloudRegion, type oAuthTokenResponse } from "./oauth.schemas"; export const authStatusSchema = z.enum(["anonymous", "authenticated"]); export type AuthStatus = z.infer; diff --git a/apps/code/src/renderer/features/auth/utils/userInitials.test.ts b/packages/core/src/auth/userInitials.test.ts similarity index 100% rename from apps/code/src/renderer/features/auth/utils/userInitials.test.ts rename to packages/core/src/auth/userInitials.test.ts diff --git a/apps/code/src/renderer/features/auth/utils/userInitials.ts b/packages/core/src/auth/userInitials.ts similarity index 100% rename from apps/code/src/renderer/features/auth/utils/userInitials.ts rename to packages/core/src/auth/userInitials.ts diff --git a/packages/core/src/billing/billing.module.ts b/packages/core/src/billing/billing.module.ts new file mode 100644 index 0000000000..cb4283c994 --- /dev/null +++ b/packages/core/src/billing/billing.module.ts @@ -0,0 +1,8 @@ +import { ContainerModule } from "inversify"; +import { SEAT_SERVICE } from "./identifiers"; +import { SeatService } from "./seatService"; + +export const billingCoreModule = new ContainerModule(({ bind }) => { + bind(SeatService).toSelf().inSingletonScope(); + bind(SEAT_SERVICE).toService(SeatService); +}); diff --git a/packages/core/src/billing/identifiers.ts b/packages/core/src/billing/identifiers.ts new file mode 100644 index 0000000000..b5c8bdc0b8 --- /dev/null +++ b/packages/core/src/billing/identifiers.ts @@ -0,0 +1,27 @@ +import type { SeatData } from "@posthog/shared"; + +export interface SubscriptionEventProps { + plan_key: string; + previous_plan_key?: string; +} + +export interface SeatClient { + getMySeat(options?: { best?: boolean }): Promise; + createSeat(planKey: string): Promise; + upgradeSeat(planKey: string): Promise; + cancelSeat(): Promise; + reactivateSeat(): Promise; + invalidatePlanCache(): void; + trackSubscriptionStarted(props: SubscriptionEventProps): void; + trackSubscriptionCancelled(props: SubscriptionEventProps): void; +} + +export interface SeatLogger { + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; + debug(message: string, ...args: unknown[]): void; +} + +export const SEAT_CLIENT = Symbol.for("posthog.core.seatClient"); +export const SEAT_SERVICE = Symbol.for("posthog.core.seatService"); diff --git a/packages/core/src/billing/seatErrors.ts b/packages/core/src/billing/seatErrors.ts new file mode 100644 index 0000000000..ca7ddabe12 --- /dev/null +++ b/packages/core/src/billing/seatErrors.ts @@ -0,0 +1,24 @@ +export interface ClassifiedSeatError { + error: string; + redirectUrl: string | null; +} + +export function classifySeatError(error: unknown): ClassifiedSeatError { + if (!(error instanceof Error)) { + return { error: "An unexpected error occurred", redirectUrl: null }; + } + + if (error.name === "SeatSubscriptionRequiredError") { + const redirectUrl = + "redirectUrl" in error && typeof error.redirectUrl === "string" + ? error.redirectUrl + : null; + return { error: "Billing subscription required", redirectUrl }; + } + + if (error.name === "SeatPaymentFailedError") { + return { error: error.message, redirectUrl: null }; + } + + return { error: error.message, redirectUrl: null }; +} diff --git a/packages/core/src/billing/seatService.test.ts b/packages/core/src/billing/seatService.test.ts new file mode 100644 index 0000000000..c81d0fca1c --- /dev/null +++ b/packages/core/src/billing/seatService.test.ts @@ -0,0 +1,280 @@ +import type { WorkbenchLogger } from "@posthog/di/logger"; +import { + PLAN_FREE, + PLAN_PRO, + PLAN_PRO_ALPHA, + type SeatData, +} from "@posthog/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SeatClient } from "./identifiers"; +import { SeatService } from "./seatService"; + +function makeSeat(overrides: Partial = {}): SeatData { + return { + id: 1, + user_distinct_id: "user-123", + product_key: "posthog_code", + plan_key: PLAN_FREE, + status: "active", + end_reason: null, + created_at: 1_700_000_000_000, + active_until: null, + active_from: 1_700_000_000_000, + ...overrides, + }; +} + +function makeClient(overrides: Partial = {}): SeatClient { + return { + getMySeat: vi.fn().mockResolvedValue(null), + createSeat: vi.fn().mockResolvedValue(makeSeat()), + upgradeSeat: vi.fn().mockResolvedValue(makeSeat({ plan_key: PLAN_PRO })), + cancelSeat: vi.fn().mockResolvedValue(undefined), + reactivateSeat: vi.fn().mockResolvedValue(makeSeat()), + invalidatePlanCache: vi.fn(), + trackSubscriptionStarted: vi.fn(), + trackSubscriptionCancelled: vi.fn(), + ...overrides, + }; +} + +const logger: WorkbenchLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + scope: () => logger, +}; + +class SeatSubscriptionRequiredError extends Error { + redirectUrl: string; + constructor(redirectUrl: string) { + super("subscription required"); + this.name = "SeatSubscriptionRequiredError"; + this.redirectUrl = redirectUrl; + } +} + +class SeatPaymentFailedError extends Error { + constructor(message: string) { + super(message); + this.name = "SeatPaymentFailedError"; + } +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("fetchSeat", () => { + it("fetches existing seat", async () => { + const seat = makeSeat(); + const client = makeClient({ getMySeat: vi.fn().mockResolvedValue(seat) }); + const result = await new SeatService(client, logger).fetchSeat(); + expect(result.seat).toEqual(seat); + expect(result.error).toBeNull(); + }); + + it("auto-provisions free seat when none exists", async () => { + const seat = makeSeat(); + const client = makeClient({ + getMySeat: vi.fn().mockResolvedValue(null), + createSeat: vi.fn().mockResolvedValue(seat), + }); + const result = await new SeatService(client, logger).fetchSeat({ + autoProvision: true, + }); + expect(client.createSeat).toHaveBeenCalledWith(PLAN_FREE); + expect(result.seat).toEqual(seat); + }); + + it("does not auto-provision when option is false", async () => { + const client = makeClient(); + const result = await new SeatService(client, logger).fetchSeat(); + expect(client.createSeat).not.toHaveBeenCalled(); + expect(result.seat).toBeNull(); + }); + + it("keeps existing seat when fetch fails", async () => { + const existing = makeSeat(); + const client = makeClient({ + getMySeat: vi.fn().mockRejectedValue(new Error("Network error")), + }); + const result = await new SeatService(client, logger).fetchSeat({ + currentSeat: existing, + }); + expect(result.keepExisting).toBe(true); + expect(result.seat).toEqual(existing); + expect(result.error).toBeNull(); + }); +}); + +describe("provisionFreeSeat", () => { + it("creates free seat when none exists", async () => { + const seat = makeSeat(); + const client = makeClient({ createSeat: vi.fn().mockResolvedValue(seat) }); + const result = await new SeatService(client, logger).provisionFreeSeat(); + expect(client.createSeat).toHaveBeenCalledWith(PLAN_FREE); + expect(result.seat).toEqual(seat); + expect(result.orgSeatUnchanged).toBe(true); + expect(client.invalidatePlanCache).toHaveBeenCalled(); + }); + + it("uses existing seat instead of creating", async () => { + const existing = makeSeat(); + const client = makeClient({ + getMySeat: vi.fn().mockResolvedValue(existing), + }); + const result = await new SeatService(client, logger).provisionFreeSeat(); + expect(client.createSeat).not.toHaveBeenCalled(); + expect(result.seat).toEqual(existing); + expect(client.invalidatePlanCache).not.toHaveBeenCalled(); + }); +}); + +describe("upgradeToPro", () => { + it("upgrades existing free seat to pro", async () => { + const freeSeat = makeSeat({ plan_key: PLAN_FREE }); + const proSeat = makeSeat({ plan_key: PLAN_PRO }); + const client = makeClient({ + getMySeat: vi.fn().mockResolvedValue(freeSeat), + upgradeSeat: vi.fn().mockResolvedValue(proSeat), + }); + const result = await new SeatService(client, logger).upgradeToPro(); + expect(client.upgradeSeat).toHaveBeenCalledWith(PLAN_PRO); + expect(result.seat).toEqual(proSeat); + expect(client.invalidatePlanCache).toHaveBeenCalled(); + expect(client.trackSubscriptionStarted).toHaveBeenCalledWith({ + plan_key: PLAN_PRO, + previous_plan_key: PLAN_FREE, + }); + }); + + it("no-ops when already on pro", async () => { + const proSeat = makeSeat({ plan_key: PLAN_PRO }); + const client = makeClient({ + getMySeat: vi.fn().mockResolvedValue(proSeat), + }); + const result = await new SeatService(client, logger).upgradeToPro(); + expect(client.upgradeSeat).not.toHaveBeenCalled(); + expect(client.createSeat).not.toHaveBeenCalled(); + expect(result.seat).toEqual(proSeat); + expect(client.trackSubscriptionStarted).not.toHaveBeenCalled(); + }); + + it("upgrades alpha pro seat to paid pro", async () => { + const alphaSeat = makeSeat({ plan_key: PLAN_PRO_ALPHA }); + const proSeat = makeSeat({ plan_key: PLAN_PRO }); + const client = makeClient({ + getMySeat: vi.fn().mockResolvedValue(alphaSeat), + upgradeSeat: vi.fn().mockResolvedValue(proSeat), + }); + const result = await new SeatService(client, logger).upgradeToPro(); + expect(client.upgradeSeat).toHaveBeenCalledWith(PLAN_PRO); + expect(result.seat).toEqual(proSeat); + expect(client.trackSubscriptionStarted).toHaveBeenCalledWith({ + plan_key: PLAN_PRO, + previous_plan_key: PLAN_PRO_ALPHA, + }); + }); + + it("creates pro seat when none exists", async () => { + const proSeat = makeSeat({ plan_key: PLAN_PRO }); + const client = makeClient({ + createSeat: vi.fn().mockResolvedValue(proSeat), + }); + await new SeatService(client, logger).upgradeToPro(); + expect(client.createSeat).toHaveBeenCalledWith(PLAN_PRO); + expect(client.invalidatePlanCache).toHaveBeenCalled(); + expect(client.trackSubscriptionStarted).toHaveBeenCalledWith({ + plan_key: PLAN_PRO, + }); + }); + + it("does not invalidate plan cache on failure", async () => { + const client = makeClient({ + getMySeat: vi.fn().mockRejectedValue(new Error("Network error")), + }); + await new SeatService(client, logger).upgradeToPro(); + expect(client.invalidatePlanCache).not.toHaveBeenCalled(); + }); +}); + +describe("cancelSeat", () => { + it("cancels and re-fetches seat", async () => { + const cancelingSeat = makeSeat({ + plan_key: PLAN_PRO, + status: "canceling", + }); + const client = makeClient({ + getMySeat: vi.fn().mockResolvedValue(cancelingSeat), + }); + const result = await new SeatService(client, logger).cancelSeat(PLAN_PRO); + expect(client.cancelSeat).toHaveBeenCalled(); + expect(result.seat).toEqual(cancelingSeat); + expect(client.invalidatePlanCache).toHaveBeenCalled(); + expect(client.trackSubscriptionCancelled).toHaveBeenCalledWith({ + plan_key: PLAN_PRO, + }); + }); + + it("falls back to API response plan_key when previous is undefined", async () => { + const cancelingSeat = makeSeat({ + plan_key: PLAN_PRO, + status: "canceling", + }); + const client = makeClient({ + getMySeat: vi.fn().mockResolvedValue(cancelingSeat), + }); + await new SeatService(client, logger).cancelSeat(); + expect(client.trackSubscriptionCancelled).toHaveBeenCalledWith({ + plan_key: PLAN_PRO, + }); + }); + + it("skips tracking when no plan_key is available", async () => { + const client = makeClient({ + getMySeat: vi.fn().mockResolvedValue(null), + }); + await new SeatService(client, logger).cancelSeat(); + expect(client.cancelSeat).toHaveBeenCalled(); + expect(client.trackSubscriptionCancelled).not.toHaveBeenCalled(); + }); +}); + +describe("reactivateSeat", () => { + it("reactivates seat", async () => { + const seat = makeSeat({ status: "active" }); + const client = makeClient({ + reactivateSeat: vi.fn().mockResolvedValue(seat), + }); + const result = await new SeatService(client, logger).reactivateSeat(); + expect(result.seat).toEqual(seat); + expect(client.invalidatePlanCache).toHaveBeenCalled(); + }); +}); + +describe("error classification", () => { + it("sets redirect URL on subscription required error", async () => { + const client = makeClient({ + getMySeat: vi + .fn() + .mockRejectedValue( + new SeatSubscriptionRequiredError("/organization/billing"), + ), + }); + const result = await new SeatService(client, logger).fetchSeat(); + expect(result.error).toBe("Billing subscription required"); + expect(result.redirectUrl).toBe("/organization/billing"); + }); + + it("sets error on payment failure", async () => { + const client = makeClient({ + getMySeat: vi + .fn() + .mockRejectedValue(new SeatPaymentFailedError("Card declined")), + }); + const result = await new SeatService(client, logger).fetchSeat(); + expect(result.error).toBe("Card declined"); + }); +}); diff --git a/packages/core/src/billing/seatService.ts b/packages/core/src/billing/seatService.ts new file mode 100644 index 0000000000..c93cb8414d --- /dev/null +++ b/packages/core/src/billing/seatService.ts @@ -0,0 +1,174 @@ +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { PLAN_FREE, PLAN_PRO, type SeatData } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import { SEAT_CLIENT, type SeatClient, type SeatLogger } from "./identifiers"; +import { type ClassifiedSeatError, classifySeatError } from "./seatErrors"; + +export interface SeatOperationResult { + seat: SeatData | null; + orgSeat: SeatData | null; + billingOrgId: string | null; + error: string | null; + redirectUrl: string | null; + keepExisting?: boolean; + orgSeatUnchanged?: boolean; +} + +function ok( + seat: SeatData | null, + orgSeat: SeatData | null, + orgSeatUnchanged = false, +): SeatOperationResult { + return { + seat, + orgSeat, + billingOrgId: seat?.organization_id ?? null, + error: null, + redirectUrl: null, + orgSeatUnchanged, + }; +} + +function fail(classified: ClassifiedSeatError): SeatOperationResult { + return { + seat: null, + orgSeat: null, + billingOrgId: null, + error: classified.error, + redirectUrl: classified.redirectUrl, + }; +} + +@injectable() +export class SeatService { + private readonly logger: SeatLogger; + + constructor( + @inject(SEAT_CLIENT) private readonly client: SeatClient, + @inject(WORKBENCH_LOGGER) logger: WorkbenchLogger, + ) { + this.logger = logger.scope("seat-service"); + } + + private async fetchAndProvision(options: { + best: boolean; + autoProvision: boolean; + }): Promise { + let seat = await this.client.getMySeat({ best: options.best }); + if (!seat && options.autoProvision) { + this.logger.info("No seat found, auto-provisioning free plan", { + best: options.best, + }); + try { + seat = await this.client.createSeat(PLAN_FREE); + } catch { + this.logger.info("Auto-provision failed, re-fetching seat"); + seat = await this.client.getMySeat({ best: options.best }); + } + } + return seat; + } + + async fetchSeat(options?: { + autoProvision?: boolean; + currentSeat?: SeatData | null; + }): Promise { + try { + const autoProvision = options?.autoProvision ?? false; + const [seat, orgSeat] = await Promise.all([ + this.fetchAndProvision({ best: true, autoProvision }), + this.fetchAndProvision({ best: false, autoProvision }), + ]); + return ok(seat, orgSeat); + } catch (error) { + if (options?.currentSeat) { + this.logger.warn( + "fetchSeat failed but seat already loaded, keeping it", + error, + ); + return { + seat: options.currentSeat, + orgSeat: null, + billingOrgId: options.currentSeat.organization_id ?? null, + error: null, + redirectUrl: null, + keepExisting: true, + }; + } + return fail(classifySeatError(error)); + } + } + + async provisionFreeSeat(): Promise { + this.logger.info("Provisioning free seat"); + try { + const existing = await this.client.getMySeat(); + if (existing) { + this.logger.info("Seat already exists on server", { + plan: existing.plan_key, + status: existing.status, + }); + return ok(existing, null, true); + } + const seat = await this.client.createSeat(PLAN_FREE); + this.logger.info("Free seat created", { + id: seat.id, + plan: seat.plan_key, + }); + this.client.invalidatePlanCache(); + return ok(seat, null, true); + } catch (error) { + this.logger.error("provisionFreeSeat failed", error); + return fail(classifySeatError(error)); + } + } + + async upgradeToPro(): Promise { + try { + const existing = await this.client.getMySeat(); + if (existing) { + if (existing.plan_key === PLAN_PRO) { + return ok(existing, null, true); + } + const seat = await this.client.upgradeSeat(PLAN_PRO); + this.client.trackSubscriptionStarted({ + plan_key: seat.plan_key, + previous_plan_key: existing.plan_key, + }); + this.client.invalidatePlanCache(); + return ok(seat, seat); + } + const seat = await this.client.createSeat(PLAN_PRO); + this.client.trackSubscriptionStarted({ plan_key: seat.plan_key }); + this.client.invalidatePlanCache(); + return ok(seat, seat); + } catch (error) { + return fail(classifySeatError(error)); + } + } + + async cancelSeat(previousPlanKey?: string): Promise { + try { + await this.client.cancelSeat(); + const seat = await this.client.getMySeat(); + const cancelledPlanKey = previousPlanKey ?? seat?.plan_key; + if (cancelledPlanKey) { + this.client.trackSubscriptionCancelled({ plan_key: cancelledPlanKey }); + } + this.client.invalidatePlanCache(); + return ok(seat, seat); + } catch (error) { + return fail(classifySeatError(error)); + } + } + + async reactivateSeat(): Promise { + try { + const seat = await this.client.reactivateSeat(); + this.client.invalidatePlanCache(); + return ok(seat, seat); + } catch (error) { + return fail(classifySeatError(error)); + } + } +} diff --git a/packages/core/src/billing/seatView.test.ts b/packages/core/src/billing/seatView.test.ts new file mode 100644 index 0000000000..dfc36ada6a --- /dev/null +++ b/packages/core/src/billing/seatView.test.ts @@ -0,0 +1,55 @@ +import { PLAN_FREE, PLAN_PRO, type SeatData } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { deriveSeatView } from "./seatView"; + +function makeSeat(overrides: Partial = {}): SeatData { + return { + id: 1, + user_distinct_id: "user-123", + product_key: "posthog_code", + plan_key: PLAN_FREE, + status: "active", + end_reason: null, + created_at: 1_700_000_000_000, + active_until: null, + active_from: 1_700_000_000_000, + ...overrides, + }; +} + +describe("deriveSeatView", () => { + it("returns defaults when no seat", () => { + const view = deriveSeatView(null, null); + expect(view.isPro).toBe(false); + expect(view.hasAccess).toBe(false); + expect(view.planLabel).toBe("Free"); + expect(view.activeUntil).toBeNull(); + expect(view.hasBetterPlanElsewhere).toBe(false); + }); + + it("labels a pro seat with access", () => { + const seat = makeSeat({ plan_key: PLAN_PRO }); + const view = deriveSeatView(seat, seat); + expect(view.isPro).toBe(true); + expect(view.isOrgPro).toBe(true); + expect(view.hasAccess).toBe(true); + expect(view.planLabel).toBe("Pro"); + }); + + it("flags a pro personal seat against a free org seat", () => { + const personal = makeSeat({ plan_key: PLAN_PRO }); + const org = makeSeat({ plan_key: PLAN_FREE }); + expect(deriveSeatView(personal, org).hasBetterPlanElsewhere).toBe(true); + }); + + it("detects canceling org seat and active_until", () => { + const org = makeSeat({ + plan_key: PLAN_PRO, + status: "canceling", + active_until: 1_800_000_000, + }); + const view = deriveSeatView(org, org); + expect(view.isCanceling).toBe(true); + expect(view.activeUntil).toEqual(new Date(1_800_000_000 * 1000)); + }); +}); diff --git a/apps/code/src/renderer/hooks/useSeat.ts b/packages/core/src/billing/seatView.ts similarity index 53% rename from apps/code/src/renderer/hooks/useSeat.ts rename to packages/core/src/billing/seatView.ts index 063df469e0..f85a79283e 100644 --- a/apps/code/src/renderer/hooks/useSeat.ts +++ b/packages/core/src/billing/seatView.ts @@ -1,14 +1,19 @@ -import { useSeatStore } from "@features/billing/stores/seatStore"; -import { isProPlan, seatHasAccess } from "@shared/types/seat"; +import { isProPlan, type SeatData, seatHasAccess } from "@posthog/shared"; -export function useSeat() { - const seat = useSeatStore((s) => s.seat); - const orgSeat = useSeatStore((s) => s.orgSeat); - const isLoading = useSeatStore((s) => s.isLoading); - const error = useSeatStore((s) => s.error); - const redirectUrl = useSeatStore((s) => s.redirectUrl); - const billingOrgId = useSeatStore((s) => s.billingOrgId); +export interface SeatView { + isPro: boolean; + isOrgPro: boolean; + hasAccess: boolean; + isCanceling: boolean; + planLabel: string; + activeUntil: Date | null; + hasBetterPlanElsewhere: boolean; +} +export function deriveSeatView( + seat: SeatData | null, + orgSeat: SeatData | null, +): SeatView { const isPro = isProPlan(seat?.plan_key); const isOrgPro = isProPlan(orgSeat?.plan_key); const hasAccess = seat ? seatHasAccess(seat.status) : false; @@ -25,12 +30,6 @@ export function useSeat() { !isProPlan(orgSeat.plan_key); return { - seat, - orgSeat, - isLoading, - error, - redirectUrl, - billingOrgId, isPro, isOrgPro, hasAccess, diff --git a/apps/code/src/renderer/features/billing/utils/spendAnalysisFormat.ts b/packages/core/src/billing/spendAnalysisFormat.ts similarity index 64% rename from apps/code/src/renderer/features/billing/utils/spendAnalysisFormat.ts rename to packages/core/src/billing/spendAnalysisFormat.ts index 051963b7e9..2cc26238db 100644 --- a/apps/code/src/renderer/features/billing/utils/spendAnalysisFormat.ts +++ b/packages/core/src/billing/spendAnalysisFormat.ts @@ -1,8 +1,3 @@ -/** Display helpers shared between the React rendering of the spend banner and the - * markdown prompt that gets fed to a new agent task. - * - * Single source of truth so the agent sees the same shape the user sees. */ - export function formatUsd(amount: number): string { if (amount === 0) return "$0"; if (amount < 0.01) return "<$0.01"; @@ -16,9 +11,12 @@ export function formatTokens(n: number): string { return n.toString(); } -export function formatWindow(fromIso: string, toIso: string): string { +export function windowDays(fromIso: string, toIso: string): number { const fromMs = new Date(fromIso).getTime(); const toMs = new Date(toIso).getTime(); - const days = Math.max(1, Math.round((toMs - fromMs) / (1000 * 60 * 60 * 24))); - return `${days} days`; + return Math.max(1, Math.round((toMs - fromMs) / (1000 * 60 * 60 * 24))); +} + +export function formatWindow(fromIso: string, toIso: string): string { + return `${windowDays(fromIso, toIso)} days`; } diff --git a/apps/code/src/renderer/features/billing/utils/spendAnalysisPrompt.test.ts b/packages/core/src/billing/spendAnalysisPrompt.test.ts similarity index 98% rename from apps/code/src/renderer/features/billing/utils/spendAnalysisPrompt.test.ts rename to packages/core/src/billing/spendAnalysisPrompt.test.ts index 81750ce789..9221b44a10 100644 --- a/apps/code/src/renderer/features/billing/utils/spendAnalysisPrompt.test.ts +++ b/packages/core/src/billing/spendAnalysisPrompt.test.ts @@ -1,6 +1,6 @@ -import type { SpendAnalysisResponse } from "@features/billing/types/spend-analysis"; import { describe, expect, it } from "vitest"; import { buildAnalysisPrompt, escapeTableCell } from "./spendAnalysisPrompt"; +import type { SpendAnalysisResponse } from "./spendAnalysisTypes"; describe("escapeTableCell", () => { it.each([ diff --git a/apps/code/src/renderer/features/billing/utils/spendAnalysisPrompt.ts b/packages/core/src/billing/spendAnalysisPrompt.ts similarity index 95% rename from apps/code/src/renderer/features/billing/utils/spendAnalysisPrompt.ts rename to packages/core/src/billing/spendAnalysisPrompt.ts index 0918eaeda6..743da6c317 100644 --- a/apps/code/src/renderer/features/billing/utils/spendAnalysisPrompt.ts +++ b/packages/core/src/billing/spendAnalysisPrompt.ts @@ -1,5 +1,5 @@ -import type { SpendAnalysisResponse } from "@features/billing/types/spend-analysis"; import { formatTokens, formatUsd, formatWindow } from "./spendAnalysisFormat"; +import type { SpendAnalysisResponse } from "./spendAnalysisTypes"; /** Sanitises a value for safe inclusion in a markdown-table cell whose contents are then * fed to an LLM as a prompt. @@ -63,7 +63,7 @@ Give me a ranked list of recommendations. For each: what to do, the data point f * in its prompt context without a second API round-trip. */ export function buildAnalysisPrompt(data: SpendAnalysisResponse): string { const { summary } = data; - const windowDays = formatWindow(summary.date_from, summary.date_to); + const windowLabel = formatWindow(summary.date_from, summary.date_to); const codeShare = summary.total_cost_usd > 0 ? Math.round((summary.scoped_cost_usd / summary.total_cost_usd) * 100) @@ -91,7 +91,7 @@ export function buildAnalysisPrompt(data: SpendAnalysisResponse): string { ) .join("\n"); - return `Here is my PostHog Code LLM spend for the last ${windowDays}. Help me understand what's driving the cost and what concrete changes I should make to reduce it. + return `Here is my PostHog Code LLM spend for the last ${windowLabel}. Help me understand what's driving the cost and what concrete changes I should make to reduce it. Work only from the tables below — do **not** try to query PostHog LLM analytics or any external data source. The numbers here are everything you have. Rank advice by impact, lead with the biggest lever, and keep each suggestion concrete and actionable. @@ -101,7 +101,7 @@ Work only from the tables below — do **not** try to query PostHog LLM analytic - Total spend: ${formatUsd(summary.total_cost_usd)} - PostHog Code spend: ${formatUsd(summary.scoped_cost_usd)} (${codeShare}% of total) - Generations: ${summary.scoped_event_count.toLocaleString()} -- Window: ${windowDays} +- Window: ${windowLabel} ### By product | Product | Events | Cost | diff --git a/packages/core/src/billing/spendAnalysisTypes.ts b/packages/core/src/billing/spendAnalysisTypes.ts new file mode 100644 index 0000000000..e8220111bf --- /dev/null +++ b/packages/core/src/billing/spendAnalysisTypes.ts @@ -0,0 +1,43 @@ +export interface SpendAnalysisSummary { + date_from: string; + date_to: string; + product: string | null; + total_cost_usd: number; + event_count: number; + scoped_cost_usd: number; + scoped_event_count: number; +} + +export interface SpendAnalysisProductRow { + product: string | null; + event_count: number; + cost_usd: number; +} + +export interface SpendAnalysisToolRow { + tool: string | null; + generation_count: number; + cost_usd: number; + share_of_scoped: number; + avg_input_tokens: number; +} + +export interface SpendAnalysisModelRow { + model: string | null; + generation_count: number; + cost_usd: number; + input_tokens: number; + output_tokens: number; +} + +export interface SpendAnalysisBreakdown { + items: TRow[]; + truncated: boolean; +} + +export interface SpendAnalysisResponse { + summary: SpendAnalysisSummary; + by_product: SpendAnalysisBreakdown; + by_tool: SpendAnalysisBreakdown; + by_model: SpendAnalysisBreakdown; +} diff --git a/packages/core/src/billing/spendSuggestions.ts b/packages/core/src/billing/spendSuggestions.ts new file mode 100644 index 0000000000..4857855450 --- /dev/null +++ b/packages/core/src/billing/spendSuggestions.ts @@ -0,0 +1,44 @@ +import { formatTokens } from "./spendAnalysisFormat"; +import type { SpendAnalysisResponse } from "./spendAnalysisTypes"; + +export function deriveSpendSuggestions(data: SpendAnalysisResponse): string[] { + const suggestions: string[] = []; + const { summary } = data; + const toolItems = data.by_tool.items; + + if (summary.total_cost_usd === 0) { + return ["No LLM spend in the selected window."]; + } + + const codeShare = + summary.scoped_cost_usd / Math.max(summary.total_cost_usd, 0.0001); + if (codeShare > 0.7) { + suggestions.push( + `PostHog Code is ${Math.round(codeShare * 100)}% of your spend. Other AI products (background agents, posthog_ai) are minor here.`, + ); + } + + const codeTotal = summary.scoped_cost_usd; + if (codeTotal > 0 && toolItems.length > 0) { + const top = toolItems[0]; + if (top.share_of_scoped > 0.35 && top.tool) { + suggestions.push( + `${top.tool} drives ${Math.round(top.share_of_scoped * 100)}% of your PostHog Code spend — averaging ${formatTokens(top.avg_input_tokens)} input tokens per call.`, + ); + } + const noToolRow = toolItems.find((r) => r.tool === null); + if (noToolRow && noToolRow.share_of_scoped > 0.1) { + suggestions.push( + `${Math.round(noToolRow.share_of_scoped * 100)}% is spent on generations that take no tool action — pure text replies. Consider tighter prompts or stopping the agent earlier.`, + ); + } + } + + if (suggestions.length === 0) { + suggestions.push( + "Your spend is fairly evenly distributed across tools — no single hotspot stands out.", + ); + } + + return suggestions; +} diff --git a/apps/code/src/renderer/features/billing/utils.test.ts b/packages/core/src/billing/usageDisplay.test.ts similarity index 95% rename from apps/code/src/renderer/features/billing/utils.test.ts rename to packages/core/src/billing/usageDisplay.test.ts index 0b9ed02d71..b9af92bd72 100644 --- a/apps/code/src/renderer/features/billing/utils.test.ts +++ b/packages/core/src/billing/usageDisplay.test.ts @@ -1,6 +1,6 @@ -import type { UsageOutput } from "@main/services/llm-gateway/schemas"; import { describe, expect, it } from "vitest"; -import { formatResetTime, isUsageExceeded } from "./utils"; +import type { UsageOutput } from "../usage/schemas"; +import { formatResetTime, isUsageExceeded } from "./usageDisplay"; function makeUsage( overrides: Partial<{ diff --git a/apps/code/src/renderer/features/billing/utils.ts b/packages/core/src/billing/usageDisplay.ts similarity index 93% rename from apps/code/src/renderer/features/billing/utils.ts rename to packages/core/src/billing/usageDisplay.ts index 7db7af0415..6d6d83b981 100644 --- a/apps/code/src/renderer/features/billing/utils.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -1,4 +1,4 @@ -import type { UsageOutput } from "@main/services/llm-gateway/schemas"; +import type { UsageOutput } from "../usage/schemas"; export function isUsageExceeded(usage: UsageOutput): boolean { return ( diff --git a/packages/core/src/clone/cloneProgress.test.ts b/packages/core/src/clone/cloneProgress.test.ts new file mode 100644 index 0000000000..ea326abad9 --- /dev/null +++ b/packages/core/src/clone/cloneProgress.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { parseCloneProgress } from "./cloneProgress"; +import type { CloneOperation } from "./cloneTypes"; + +const operation = (latestMessage?: string): CloneOperation => ({ + cloneId: "c1", + repository: "owner/repo", + targetPath: "/tmp/repo", + status: "cloning", + latestMessage, +}); + +describe("parseCloneProgress", () => { + it("returns null for a null operation", () => { + expect(parseCloneProgress(null)).toBeNull(); + }); + + it("returns null when there is no latest message", () => { + expect(parseCloneProgress(operation(undefined))).toBeNull(); + }); + + it("extracts the percent integer from the message", () => { + expect(parseCloneProgress(operation("Receiving objects: 42%"))).toEqual({ + message: "Receiving objects: 42%", + percent: 42, + }); + }); + + it("defaults percent to 0 when no percent is present", () => { + expect(parseCloneProgress(operation("Cloning owner/repo..."))).toEqual({ + message: "Cloning owner/repo...", + percent: 0, + }); + }); +}); diff --git a/packages/core/src/clone/cloneProgress.ts b/packages/core/src/clone/cloneProgress.ts new file mode 100644 index 0000000000..a2ab86f374 --- /dev/null +++ b/packages/core/src/clone/cloneProgress.ts @@ -0,0 +1,20 @@ +import type { CloneOperation } from "./cloneTypes"; + +export interface CloneProgress { + message: string; + percent: number; +} + +export function parseCloneProgress( + operation: CloneOperation | null, +): CloneProgress | null { + if (!operation?.latestMessage) return null; + + const percentMatch = operation.latestMessage.match(/(\d+)%/); + const percent = percentMatch ? Number.parseInt(percentMatch[1], 10) : 0; + + return { + message: operation.latestMessage, + percent, + }; +} diff --git a/packages/core/src/clone/cloneRemovalDelay.test.ts b/packages/core/src/clone/cloneRemovalDelay.test.ts new file mode 100644 index 0000000000..45ef40adca --- /dev/null +++ b/packages/core/src/clone/cloneRemovalDelay.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { removalDelayMsForStatus } from "./cloneRemovalDelay"; + +describe("removalDelayMsForStatus", () => { + it("removes a completed clone after 3000ms", () => { + expect(removalDelayMsForStatus("complete")).toBe(3000); + }); + + it("removes an errored clone after 5000ms", () => { + expect(removalDelayMsForStatus("error")).toBe(5000); + }); + + it("never removes a clone that is still cloning", () => { + expect(removalDelayMsForStatus("cloning")).toBeNull(); + }); +}); diff --git a/packages/core/src/clone/cloneRemovalDelay.ts b/packages/core/src/clone/cloneRemovalDelay.ts new file mode 100644 index 0000000000..89895a142a --- /dev/null +++ b/packages/core/src/clone/cloneRemovalDelay.ts @@ -0,0 +1,10 @@ +import type { CloneStatus } from "./cloneTypes"; + +const REMOVE_DELAY_SUCCESS_MS = 3000; +const REMOVE_DELAY_ERROR_MS = 5000; + +export function removalDelayMsForStatus(status: CloneStatus): number | null { + if (status === "complete") return REMOVE_DELAY_SUCCESS_MS; + if (status === "error") return REMOVE_DELAY_ERROR_MS; + return null; +} diff --git a/packages/core/src/clone/cloneSelectors.test.ts b/packages/core/src/clone/cloneSelectors.test.ts new file mode 100644 index 0000000000..9a6304c3e3 --- /dev/null +++ b/packages/core/src/clone/cloneSelectors.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { findCloneForRepo, isRepoCloning } from "./cloneSelectors"; +import type { CloneOperation } from "./cloneTypes"; + +const op = (overrides: Partial): CloneOperation => ({ + cloneId: "c1", + repository: "owner/repo", + targetPath: "/tmp/repo", + status: "cloning", + ...overrides, +}); + +describe("isRepoCloning", () => { + it("is false when no operation matches the repo", () => { + expect(isRepoCloning({}, "owner/repo")).toBe(false); + }); + + it("is true while a matching operation is cloning", () => { + const ops = { c1: op({}) }; + expect(isRepoCloning(ops, "owner/repo")).toBe(true); + }); + + it("is false once the matching operation is complete", () => { + const ops = { c1: op({ status: "complete" }) }; + expect(isRepoCloning(ops, "owner/repo")).toBe(false); + }); +}); + +describe("findCloneForRepo", () => { + it("returns null when no operation matches", () => { + expect(findCloneForRepo({}, "owner/repo")).toBeNull(); + }); + + it("returns the operation for the repo", () => { + const ops = { c1: op({}) }; + expect(findCloneForRepo(ops, "owner/repo")?.cloneId).toBe("c1"); + }); +}); diff --git a/packages/core/src/clone/cloneSelectors.ts b/packages/core/src/clone/cloneSelectors.ts new file mode 100644 index 0000000000..729be3637e --- /dev/null +++ b/packages/core/src/clone/cloneSelectors.ts @@ -0,0 +1,19 @@ +import type { CloneOperation } from "./cloneTypes"; + +export function isRepoCloning( + operations: Record, + repository: string, +): boolean { + return Object.values(operations).some( + (op) => op.status === "cloning" && op.repository === repository, + ); +} + +export function findCloneForRepo( + operations: Record, + repository: string, +): CloneOperation | null { + return ( + Object.values(operations).find((op) => op.repository === repository) ?? null + ); +} diff --git a/packages/core/src/clone/cloneTypes.ts b/packages/core/src/clone/cloneTypes.ts new file mode 100644 index 0000000000..68c1409b3b --- /dev/null +++ b/packages/core/src/clone/cloneTypes.ts @@ -0,0 +1,22 @@ +export type CloneStatus = "cloning" | "complete" | "error"; + +export interface CloneProgressEvent { + cloneId: string; + status: CloneStatus; + message: string; +} + +export interface CloneRepositoryInput { + repoUrl: string; + targetPath: string; + cloneId: string; +} + +export interface CloneOperation { + cloneId: string; + repository: string; + targetPath: string; + status: CloneStatus; + latestMessage?: string; + error?: string; +} diff --git a/packages/core/src/cloud-task/cloud-task-types.ts b/packages/core/src/cloud-task/cloud-task-types.ts new file mode 100644 index 0000000000..03f3ce4eef --- /dev/null +++ b/packages/core/src/cloud-task/cloud-task-types.ts @@ -0,0 +1,67 @@ +import type { StoredLogEntry, TaskRunStatus } from "@posthog/shared"; + +interface CloudTaskUpdateBase { + taskId: string; + runId: string; +} + +export interface CloudTaskLogsUpdate extends CloudTaskUpdateBase { + kind: "logs"; + newEntries: StoredLogEntry[]; + totalEntryCount: number; +} + +export interface CloudTaskStatusUpdate extends CloudTaskUpdateBase { + kind: "status"; + status?: TaskRunStatus; + stage?: string | null; + output?: Record | null; + errorMessage?: string | null; + branch?: string | null; +} + +export interface CloudTaskSnapshotUpdate extends CloudTaskUpdateBase { + kind: "snapshot"; + newEntries: StoredLogEntry[]; + totalEntryCount: number; + status?: TaskRunStatus; + stage?: string | null; + output?: Record | null; + errorMessage?: string | null; + branch?: string | null; +} + +export interface CloudTaskErrorUpdate extends CloudTaskUpdateBase { + kind: "error"; + errorTitle: string; + errorMessage: string; + retryable: boolean; +} + +export interface CloudPermissionOption { + kind: string; + optionId: string; + name: string; + _meta?: Record; +} + +export interface CloudTaskPermissionRequestUpdate extends CloudTaskUpdateBase { + kind: "permission_request"; + requestId: string; + toolCall: { + toolCallId: string; + title: string; + kind: string; + content?: unknown[]; + rawInput?: Record; + _meta?: Record; + }; + options: CloudPermissionOption[]; +} + +export type CloudTaskUpdatePayload = + | CloudTaskLogsUpdate + | CloudTaskStatusUpdate + | CloudTaskSnapshotUpdate + | CloudTaskErrorUpdate + | CloudTaskPermissionRequestUpdate; diff --git a/packages/core/src/cloud-task/cloud-task.module.ts b/packages/core/src/cloud-task/cloud-task.module.ts new file mode 100644 index 0000000000..02f0cc91db --- /dev/null +++ b/packages/core/src/cloud-task/cloud-task.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { CloudTaskService } from "./cloud-task"; +import { CLOUD_TASK_SERVICE } from "./identifiers"; + +export const cloudTaskModule = new ContainerModule(({ bind }) => { + bind(CLOUD_TASK_SERVICE).to(CloudTaskService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/cloud-task/service.test.ts b/packages/core/src/cloud-task/cloud-task.test.ts similarity index 99% rename from apps/code/src/main/services/cloud-task/service.test.ts rename to packages/core/src/cloud-task/cloud-task.test.ts index ab9000a167..223a3eb213 100644 --- a/apps/code/src/main/services/cloud-task/service.test.ts +++ b/packages/core/src/cloud-task/cloud-task.test.ts @@ -16,18 +16,7 @@ const fetchRouter = vi.hoisted(() => }), ); -vi.mock("../../utils/logger", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -import { CloudTaskService } from "./service"; +import { CloudTaskService } from "./cloud-task"; const mockAuthService = { authenticatedFetch: vi.fn(), @@ -94,19 +83,22 @@ describe("CloudTaskService", () => { let service: CloudTaskService; beforeEach(() => { - service = new CloudTaskService(mockAuthService as never); + const scopedLog = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const loggerMock = { ...scopedLog, scope: vi.fn(() => scopedLog) }; + service = new CloudTaskService(mockAuthService as never, loggerMock); mockNetFetch.mockReset(); mockStreamFetch.mockReset(); mockAuthService.authenticatedFetch.mockReset(); vi.stubGlobal("fetch", fetchRouter); mockAuthService.authenticatedFetch.mockImplementation( - async ( - fetchImpl: typeof fetch, - input: string | Request, - init?: RequestInit, - ) => { - return fetchImpl(input, { + async (input: string | Request, init?: RequestInit) => { + return fetchRouter(input, { ...init, headers: { ...(init?.headers ?? {}), diff --git a/apps/code/src/main/services/cloud-task/service.ts b/packages/core/src/cloud-task/cloud-task.ts similarity index 94% rename from apps/code/src/main/services/cloud-task/service.ts rename to packages/core/src/cloud-task/cloud-task.ts index 59716b068c..dd55cbdcdf 100644 --- a/apps/code/src/main/services/cloud-task/service.ts +++ b/packages/core/src/cloud-task/cloud-task.ts @@ -1,10 +1,13 @@ -import type { CloudTaskPermissionRequestUpdate } from "@shared/types"; -import type { StoredLogEntry } from "@shared/types/session-events"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import type { StoredLogEntry } from "@posthog/shared"; +import { TypedEventEmitter } from "@posthog/shared"; import { inject, injectable, preDestroy } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import type { AuthService } from "../auth/service"; +import type { CloudTaskPermissionRequestUpdate } from "./cloud-task-types"; +import { CLOUD_TASK_AUTH, type ICloudTaskAuth } from "./identifiers"; import { CloudTaskEvent, type CloudTaskEvents, @@ -16,8 +19,6 @@ import { } from "./schemas"; import { type SseEvent, SseEventParser } from "./sse-parser"; -const log = logger.scope("cloud-task"); - const MAX_SSE_RECONNECT_ATTEMPTS = 5; const MAX_CUMULATIVE_RECONNECT_ATTEMPTS = 30; const SSE_RECONNECT_BASE_DELAY_MS = 2_000; @@ -222,12 +223,16 @@ function shouldFailWatcherForFetchStatus(status: number): boolean { @injectable() export class CloudTaskService extends TypedEventEmitter { private watchers = new Map(); + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, + @inject(CLOUD_TASK_AUTH) + private readonly auth: ICloudTaskAuth, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, ) { super(); + this.log = logger.scope("cloud-task"); } watch(input: WatchInput): void { @@ -236,7 +241,7 @@ export class CloudTaskService extends TypedEventEmitter { const existing = this.watchers.get(key); if (existing) { existing.subscriberCount++; - log.info("Cloud task watcher subscriber added", { + this.log.info("Cloud task watcher subscriber added", { key, subscribers: existing.subscriberCount, }); @@ -258,7 +263,7 @@ export class CloudTaskService extends TypedEventEmitter { if (watcher.subscriberCount <= 0) { this.stopWatcher(key); } else { - log.info("Cloud task watcher subscriber removed", { + this.log.info("Cloud task watcher subscriber removed", { key, subscribers: watcher.subscriberCount, }); @@ -292,7 +297,7 @@ export class CloudTaskService extends TypedEventEmitter { watcher.needsPostBootstrapReconnect = false; watcher.needsStopAfterBootstrap = false; - log.info("Retrying cloud task watcher", { + this.log.info("Retrying cloud task watcher", { key, hasSnapshot: watcher.hasEmittedSnapshot, }); @@ -318,7 +323,7 @@ export class CloudTaskService extends TypedEventEmitter { }; try { - const response = await this.authService.authenticatedFetch(fetch, url, { + const response = await this.auth.authenticatedFetch(url, { method: "POST", headers: { "Content-Type": "application/json", @@ -343,7 +348,7 @@ export class CloudTaskService extends TypedEventEmitter { if (errorText) errorMessage = errorText; } - log.warn("Cloud task command failed", { + this.log.warn("Cloud task command failed", { taskId: input.taskId, runId: input.runId, method: input.method, @@ -353,10 +358,13 @@ export class CloudTaskService extends TypedEventEmitter { return { success: false, error: errorMessage }; } - const data = await response.json(); + const data = (await response.json()) as { + error?: { message?: string }; + result?: unknown; + }; if (data.error) { - log.warn("Cloud task command returned error", { + this.log.warn("Cloud task command returned error", { taskId: input.taskId, method: input.method, error: data.error, @@ -367,7 +375,7 @@ export class CloudTaskService extends TypedEventEmitter { }; } - log.info("Cloud task command sent", { + this.log.info("Cloud task command sent", { taskId: input.taskId, runId: input.runId, method: input.method, @@ -377,7 +385,7 @@ export class CloudTaskService extends TypedEventEmitter { } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; - log.error("Cloud task command error", { + this.log.error("Cloud task command error", { taskId: input.taskId, method: input.method, error: errorMessage, @@ -427,7 +435,7 @@ export class CloudTaskService extends TypedEventEmitter { }; this.watchers.set(key, watcher); - log.info("Cloud task watcher started", { key }); + this.log.info("Cloud task watcher started", { key }); void this.bootstrapWatcher(key); } @@ -449,7 +457,7 @@ export class CloudTaskService extends TypedEventEmitter { this.flushLogBatch(key); this.watchers.delete(key); - log.info("Cloud task watcher stopped", { key }); + this.log.info("Cloud task watcher stopped", { key }); } private async bootstrapWatcher(key: string): Promise { @@ -618,7 +626,9 @@ export class CloudTaskService extends TypedEventEmitter { headers["Last-Event-ID"] = watcher.lastEventId; } - const parser = new SseEventParser(); + const parser = new SseEventParser((message, data) => + this.log.warn(message, data), + ); const decoder = new TextDecoder(); // Tracks whether the response body was opened and how long it stayed open, @@ -628,15 +638,11 @@ export class CloudTaskService extends TypedEventEmitter { let streamWasEstablished = false; try { - const response = await this.authService.authenticatedFetch( - fetch, - url.toString(), - { - method: "GET", - headers, - signal: controller.signal, - }, - ); + const response = await this.auth.authenticatedFetch(url.toString(), { + method: "GET", + headers, + signal: controller.signal, + }); if (!response.ok) { throw createStreamStatusError(response.status); @@ -713,7 +719,7 @@ export class CloudTaskService extends TypedEventEmitter { } } - log.warn("Cloud task stream error", { + this.log.warn("Cloud task stream error", { key, error: errorMessage, wasHealthyStream, @@ -944,7 +950,7 @@ export class CloudTaskService extends TypedEventEmitter { } if (!historicalEntries) { - log.warn("Cloud task snapshot replay failed", { + this.log.warn("Cloud task snapshot replay failed", { taskId: watcher.taskId, runId: watcher.runId, }); @@ -1145,7 +1151,7 @@ export class CloudTaskService extends TypedEventEmitter { branch: watcher.lastBranch, }); } - log.warn("Cloud task stream ended before terminal status", { + this.log.warn("Cloud task stream ended before terminal status", { key, status: watcher.lastStatus, }); @@ -1231,8 +1237,7 @@ export class CloudTaskService extends TypedEventEmitter { url.searchParams.set("offset", offset.toString()); try { - const authedResponse = await this.authService.authenticatedFetch( - fetch, + const authedResponse = await this.auth.authenticatedFetch( url.toString(), { method: "GET", @@ -1240,7 +1245,7 @@ export class CloudTaskService extends TypedEventEmitter { ); if (!authedResponse.ok) { - log.warn("Cloud task session logs fetch failed", { + this.log.warn("Cloud task session logs fetch failed", { status: authedResponse.status, taskId: watcher.taskId, runId: watcher.runId, @@ -1261,7 +1266,7 @@ export class CloudTaskService extends TypedEventEmitter { hasMore: authedResponse.headers.get("X-Has-More") === "true", }; } catch (error) { - log.warn("Cloud task session logs fetch error", { + this.log.warn("Cloud task session logs fetch error", { taskId: watcher.taskId, runId: watcher.runId, offset, @@ -1300,16 +1305,12 @@ export class CloudTaskService extends TypedEventEmitter { const url = `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/`; try { - const authedResponse = await this.authService.authenticatedFetch( - fetch, - url, - { - method: "GET", - }, - ); + const authedResponse = await this.auth.authenticatedFetch(url, { + method: "GET", + }); if (!authedResponse.ok) { - log.warn("Cloud task status fetch failed", { + this.log.warn("Cloud task status fetch failed", { status: authedResponse.status, taskId: watcher.taskId, runId: watcher.runId, @@ -1325,7 +1326,7 @@ export class CloudTaskService extends TypedEventEmitter { return (await authedResponse.json()) as TaskRunResponse; } catch (error) { - log.warn("Cloud task status fetch error", { + this.log.warn("Cloud task status fetch error", { taskId: watcher.taskId, runId: watcher.runId, error, diff --git a/packages/core/src/cloud-task/identifiers.ts b/packages/core/src/cloud-task/identifiers.ts new file mode 100644 index 0000000000..5693714913 --- /dev/null +++ b/packages/core/src/cloud-task/identifiers.ts @@ -0,0 +1,6 @@ +export const CLOUD_TASK_SERVICE = Symbol.for("posthog.core.cloudTaskService"); +export const CLOUD_TASK_AUTH = Symbol.for("posthog.core.cloudTaskAuth"); + +export interface ICloudTaskAuth { + authenticatedFetch(url: string, init?: RequestInit): Promise; +} diff --git a/apps/code/src/main/services/cloud-task/schemas.ts b/packages/core/src/cloud-task/schemas.ts similarity index 74% rename from apps/code/src/main/services/cloud-task/schemas.ts rename to packages/core/src/cloud-task/schemas.ts index 69512afb7c..4b11754fba 100644 --- a/apps/code/src/main/services/cloud-task/schemas.ts +++ b/packages/core/src/cloud-task/schemas.ts @@ -1,13 +1,20 @@ -import { - type CloudTaskUpdatePayload, - isTerminalStatus, - type TaskRunStatus, - TERMINAL_STATUSES, -} from "@shared/types"; +import type { TaskRunStatus } from "@posthog/shared"; import { z } from "zod"; +import type { CloudTaskUpdatePayload } from "./cloud-task-types"; export type { CloudTaskUpdatePayload, TaskRunStatus }; -export { TERMINAL_STATUSES, isTerminalStatus }; + +export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; + +export function isTerminalStatus( + status: TaskRunStatus | string | null | undefined, +): boolean { + return ( + status !== null && + status !== undefined && + TERMINAL_STATUSES.includes(status as (typeof TERMINAL_STATUSES)[number]) + ); +} // --- Events --- diff --git a/apps/code/src/main/services/cloud-task/sse-parser.test.ts b/packages/core/src/cloud-task/sse-parser.test.ts similarity index 100% rename from apps/code/src/main/services/cloud-task/sse-parser.test.ts rename to packages/core/src/cloud-task/sse-parser.test.ts diff --git a/apps/code/src/main/services/cloud-task/sse-parser.ts b/packages/core/src/cloud-task/sse-parser.ts similarity index 90% rename from apps/code/src/main/services/cloud-task/sse-parser.ts rename to packages/core/src/cloud-task/sse-parser.ts index 5bc0a957b6..12e0dfcc0e 100644 --- a/apps/code/src/main/services/cloud-task/sse-parser.ts +++ b/packages/core/src/cloud-task/sse-parser.ts @@ -1,7 +1,3 @@ -import { logger } from "../../utils/logger"; - -const log = logger.scope("sse-parser"); - export interface SseEvent { event?: string; id?: string; @@ -14,6 +10,13 @@ export class SseEventParser { private currentEventId: string | null = null; private currentData: string[] = []; + constructor( + private readonly onWarn?: ( + message: string, + data?: Record, + ) => void, + ) {} + parse(chunk: string): SseEvent[] { this.buffer += chunk; const lines = this.buffer.split("\n"); @@ -79,7 +82,7 @@ export class SseEventParser { data, }; } catch { - log.warn("SSE event JSON parse failure", { rawData }); + this.onWarn?.("SSE event JSON parse failure", { rawData }); return null; } finally { this.currentEventName = null; diff --git a/packages/core/src/code-editor/buildEnrichmentOccurrences.test.ts b/packages/core/src/code-editor/buildEnrichmentOccurrences.test.ts new file mode 100644 index 0000000000..323da35bd9 --- /dev/null +++ b/packages/core/src/code-editor/buildEnrichmentOccurrences.test.ts @@ -0,0 +1,68 @@ +import type { SerializedEnrichment } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { buildEnrichmentOccurrences } from "./buildEnrichmentOccurrences"; + +function emptyEnrichment(): SerializedEnrichment { + return { flags: [], events: [] }; +} + +describe("buildEnrichmentOccurrences", () => { + it("returns empty array for null", () => { + expect(buildEnrichmentOccurrences(null)).toEqual([]); + }); + + it("offsets line numbers by 1 and tags entries", () => { + const data: SerializedEnrichment = { + ...emptyEnrichment(), + flags: [ + { + flagKey: "my-flag", + flagId: null, + flagType: "boolean", + staleness: null, + rollout: null, + active: true, + variants: [], + experiment: null, + occurrences: [ + { method: "isFeatureEnabled", line: 4, startCol: 2, endCol: 8 }, + ], + }, + ], + }; + const out = buildEnrichmentOccurrences(data); + expect(out).toHaveLength(1); + expect(out[0].line).toBe(5); + expect(out[0].entry.kind).toBe("flag"); + expect(out[0].summary).toBe("Flag: my-flag"); + }); + + it("sorts occurrences into document order", () => { + const data: SerializedEnrichment = { + ...emptyEnrichment(), + flags: [ + { + flagKey: "f", + flagId: null, + flagType: "boolean", + staleness: null, + rollout: null, + active: true, + variants: [], + experiment: null, + occurrences: [ + { method: "isFeatureEnabled", line: 9, startCol: 0, endCol: 1 }, + { method: "isFeatureEnabled", line: 1, startCol: 5, endCol: 6 }, + { method: "isFeatureEnabled", line: 1, startCol: 1, endCol: 2 }, + ], + }, + ], + }; + const out = buildEnrichmentOccurrences(data); + expect(out.map((o) => [o.line, o.startCol])).toEqual([ + [2, 1], + [2, 5], + [10, 0], + ]); + }); +}); diff --git a/packages/core/src/code-editor/buildEnrichmentOccurrences.ts b/packages/core/src/code-editor/buildEnrichmentOccurrences.ts new file mode 100644 index 0000000000..a9f8ec0b8b --- /dev/null +++ b/packages/core/src/code-editor/buildEnrichmentOccurrences.ts @@ -0,0 +1,52 @@ +import type { + SerializedEnrichment, + SerializedEvent, + SerializedFlag, +} from "@posthog/shared"; + +export type EnrichmentPopoverEntry = + | { kind: "flag"; data: SerializedFlag } + | { kind: "event"; data: SerializedEvent }; + +export interface EnrichmentOccurrence { + line: number; + startCol: number; + endCol: number; + entry: EnrichmentPopoverEntry; + summary: string; +} + +export function buildEnrichmentOccurrences( + data: SerializedEnrichment | null, +): EnrichmentOccurrence[] { + if (!data) return []; + const out: EnrichmentOccurrence[] = []; + + for (const flag of data.flags) { + for (const occ of flag.occurrences) { + out.push({ + line: occ.line + 1, + startCol: occ.startCol, + endCol: occ.endCol, + entry: { kind: "flag", data: flag }, + summary: `Flag: ${flag.flagKey}`, + }); + } + } + for (const event of data.events) { + for (const occ of event.occurrences) { + out.push({ + line: occ.line + 1, + startCol: occ.startCol, + endCol: occ.endCol, + entry: { kind: "event", data: event }, + summary: `Event: ${event.eventName}`, + }); + } + } + + out.sort((a, b) => + a.line !== b.line ? a.line - b.line : a.startCol - b.startCol, + ); + return out; +} diff --git a/packages/core/src/code-editor/enrichmentEligibility.test.ts b/packages/core/src/code-editor/enrichmentEligibility.test.ts new file mode 100644 index 0000000000..4dbde7568d --- /dev/null +++ b/packages/core/src/code-editor/enrichmentEligibility.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { isEnrichmentEligible } from "./enrichmentEligibility"; + +describe("isEnrichmentEligible", () => { + it("accepts supported extensions with content", () => { + expect(isEnrichmentEligible("src/a.ts", "code")).toBe(true); + expect(isEnrichmentEligible("src/a.py", "code")).toBe(true); + }); + + it("rejects unsupported extensions", () => { + expect(isEnrichmentEligible("README.md", "code")).toBe(false); + expect(isEnrichmentEligible("a.txt", "code")).toBe(false); + }); + + it("rejects empty or missing content", () => { + expect(isEnrichmentEligible("a.ts", "")).toBe(false); + expect(isEnrichmentEligible("a.ts", null)).toBe(false); + expect(isEnrichmentEligible("a.ts", undefined)).toBe(false); + }); + + it("rejects content over the size bound", () => { + expect(isEnrichmentEligible("a.ts", "x".repeat(1_000_001))).toBe(false); + expect(isEnrichmentEligible("a.ts", "x".repeat(1_000_000))).toBe(true); + }); +}); diff --git a/packages/core/src/code-editor/enrichmentEligibility.ts b/packages/core/src/code-editor/enrichmentEligibility.ts new file mode 100644 index 0000000000..af0bd4bf12 --- /dev/null +++ b/packages/core/src/code-editor/enrichmentEligibility.ts @@ -0,0 +1,13 @@ +const SUPPORTED_EXT = /\.(?:ts|tsx|js|jsx|mjs|cjs|py|rb|go)$/i; +const MAX_CONTENT_BYTES = 1_000_000; + +export function isEnrichmentEligible( + filePath: string, + content: string | null | undefined, +): boolean { + const hasContent = + typeof content === "string" && + content.length > 0 && + content.length <= MAX_CONTENT_BYTES; + return hasContent && SUPPORTED_EXT.test(filePath); +} diff --git a/packages/core/src/code-editor/enrichmentPresenters.ts b/packages/core/src/code-editor/enrichmentPresenters.ts new file mode 100644 index 0000000000..55544cfd31 --- /dev/null +++ b/packages/core/src/code-editor/enrichmentPresenters.ts @@ -0,0 +1,37 @@ +import type { SerializedFlag } from "@posthog/shared"; + +export function compactNumber(n: number): string { + if (n < 1000) return `${n}`; + if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; +} + +export function relativeTime(iso: string | null): string | null { + if (!iso) return null; + const then = Date.parse(iso); + if (Number.isNaN(then)) return null; + const diffSec = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.round(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.round(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.round(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + const diffMon = Math.round(diffDay / 30); + if (diffMon < 12) return `${diffMon}mo ago`; + return `${Math.round(diffMon / 12)}y ago`; +} + +type Staleness = NonNullable; + +const STALENESS_LABELS: Record = { + fully_rolled_out: "Fully rolled out", + inactive: "Inactive", + not_in_posthog: "Not in PostHog", + experiment_complete: "Experiment complete", +}; + +export function stalenessLabel(staleness: Staleness): string { + return STALENESS_LABELS[staleness]; +} diff --git a/apps/code/src/renderer/features/code-editor/utils/markdownUtils.ts b/packages/core/src/code-editor/fileKind.ts similarity index 100% rename from apps/code/src/renderer/features/code-editor/utils/markdownUtils.ts rename to packages/core/src/code-editor/fileKind.ts diff --git a/packages/core/src/code-editor/fileSource.test.ts b/packages/core/src/code-editor/fileSource.test.ts new file mode 100644 index 0000000000..a8ee483c51 --- /dev/null +++ b/packages/core/src/code-editor/fileSource.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + collapseFileState, + resolveMarkdownLink, + selectFileSource, +} from "./fileSource"; + +describe("selectFileSource", () => { + it("enables repo source inside repo for non-image local file", () => { + expect( + selectFileSource({ + isInsideRepo: true, + isCloudRun: false, + isImage: false, + }), + ).toEqual({ + cloudEnabled: false, + repoEnabled: true, + absoluteEnabled: false, + imageEnabled: false, + }); + }); + + it("enables cloud source for cloud run", () => { + const flags = selectFileSource({ + isInsideRepo: true, + isCloudRun: true, + isImage: false, + }); + expect(flags.cloudEnabled).toBe(true); + expect(flags.repoEnabled).toBe(false); + }); + + it("enables image source only when not cloud", () => { + expect( + selectFileSource({ + isInsideRepo: false, + isCloudRun: false, + isImage: true, + }).imageEnabled, + ).toBe(true); + expect( + selectFileSource({ isInsideRepo: false, isCloudRun: true, isImage: true }) + .imageEnabled, + ).toBe(false); + }); +}); + +describe("collapseFileState", () => { + it("uses cloud file when cloud run", () => { + expect( + collapseFileState({ + cloudFile: { content: "cloud", isLoading: false }, + localQuery: { content: "local", isLoading: true, error: new Error() }, + isCloudRun: true, + }), + ).toEqual({ content: "cloud", isLoading: false, error: null }); + }); + + it("uses local query when not cloud run", () => { + const err = new Error("boom"); + expect( + collapseFileState({ + cloudFile: { content: "cloud", isLoading: true }, + localQuery: { content: "local", isLoading: false, error: err }, + isCloudRun: false, + }), + ).toEqual({ content: "local", isLoading: false, error: err }); + }); +}); + +describe("resolveMarkdownLink", () => { + it("classifies http links as external", () => { + const link = resolveMarkdownLink("https://x.com", "docs/a.md", "/repo"); + expect(link.kind).toBe("external"); + expect(link.relativePath).toBeNull(); + }); + + it("resolves relative link against file dir", () => { + const link = resolveMarkdownLink("./b.md", "docs/a.md", "/repo"); + expect(link.kind).toBe("internal"); + expect(link.relativePath).toBe("docs/b.md"); + expect(link.absolutePath).toBe("/repo/docs/b.md"); + }); + + it("resolves link at repo root when file has no dir", () => { + const link = resolveMarkdownLink("b.md", "a.md", null); + expect(link.relativePath).toBe("b.md"); + expect(link.absolutePath).toBeNull(); + }); +}); diff --git a/packages/core/src/code-editor/fileSource.ts b/packages/core/src/code-editor/fileSource.ts new file mode 100644 index 0000000000..35d0231d8c --- /dev/null +++ b/packages/core/src/code-editor/fileSource.ts @@ -0,0 +1,93 @@ +export interface FileSourceInput { + isInsideRepo: boolean; + isCloudRun: boolean; + isImage: boolean; +} + +export interface FileSourceFlags { + cloudEnabled: boolean; + repoEnabled: boolean; + absoluteEnabled: boolean; + imageEnabled: boolean; +} + +export function selectFileSource({ + isInsideRepo, + isCloudRun, + isImage, +}: FileSourceInput): FileSourceFlags { + return { + cloudEnabled: isCloudRun && !isImage, + repoEnabled: isInsideRepo && !isImage && !isCloudRun, + absoluteEnabled: !isInsideRepo && !isImage && !isCloudRun, + imageEnabled: isImage && !isCloudRun, + }; +} + +export interface CloudFileState { + content: string | null | undefined; + isLoading: boolean; +} + +export interface LocalQueryState { + content: string | null | undefined; + isLoading: boolean; + error: unknown; +} + +export interface CollapsedFileState { + content: string | null | undefined; + isLoading: boolean; + error: unknown; +} + +export function collapseFileState({ + cloudFile, + localQuery, + isCloudRun, +}: { + cloudFile: CloudFileState; + localQuery: LocalQueryState; + isCloudRun: boolean; +}): CollapsedFileState { + if (isCloudRun) { + return { + content: cloudFile.content, + isLoading: cloudFile.isLoading, + error: null, + }; + } + return { + content: localQuery.content, + isLoading: localQuery.isLoading, + error: localQuery.error, + }; +} + +export interface ResolvedMarkdownLink { + kind: "external" | "internal"; + href: string; + relativePath: string | null; + absolutePath: string | null; +} + +export function resolveMarkdownLink( + href: string, + filePath: string, + repoPath: string | null | undefined, +): ResolvedMarkdownLink { + if (href.startsWith("http://") || href.startsWith("https://")) { + return { kind: "external", href, relativePath: null, absolutePath: null }; + } + const cleanHref = href.replace(/^\.\//, ""); + const dir = filePath.includes("/") + ? filePath.slice(0, filePath.lastIndexOf("/")) + : ""; + const resolved = dir ? `${dir}/${cleanHref}` : cleanHref; + return { + kind: "internal", + href, + relativePath: resolved, + absolutePath: repoPath ? `${repoPath}/${resolved}` : null, + }; +} diff --git a/apps/code/src/renderer/features/code-editor/utils/pathUtils.ts b/packages/core/src/code-editor/pathUtils.ts similarity index 100% rename from apps/code/src/renderer/features/code-editor/utils/pathUtils.ts rename to packages/core/src/code-editor/pathUtils.ts diff --git a/packages/core/src/code-review/buildToolCallFallbacks.test.ts b/packages/core/src/code-review/buildToolCallFallbacks.test.ts new file mode 100644 index 0000000000..fb68ef69f5 --- /dev/null +++ b/packages/core/src/code-review/buildToolCallFallbacks.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { buildToolCallFallbacks } from "./buildToolCallFallbacks"; + +describe("buildToolCallFallbacks", () => { + it("returns undefined when remote files exist", () => { + expect( + buildToolCallFallbacks(true, ["a"], () => undefined), + ).toBeUndefined(); + }); + + it("collects only paths that resolve a truthy diff", () => { + const result = buildToolCallFallbacks(false, ["a", "b", "c"], (path) => + path === "b" ? undefined : { oldText: path, newText: `${path}!` }, + ); + expect(result?.size).toBe(2); + expect(result?.get("a")).toEqual({ oldText: "a", newText: "a!" }); + expect(result?.has("b")).toBe(false); + expect(result?.has("c")).toBe(true); + }); + + it("skips paths with no diff", () => { + const result = buildToolCallFallbacks(false, ["a", "b"], (path) => + path === "a" ? { oldText: null, newText: "x" } : undefined, + ); + expect(result?.size).toBe(1); + expect(result?.has("a")).toBe(true); + expect(result?.has("b")).toBe(false); + }); +}); diff --git a/packages/core/src/code-review/buildToolCallFallbacks.ts b/packages/core/src/code-review/buildToolCallFallbacks.ts new file mode 100644 index 0000000000..de4f4e841f --- /dev/null +++ b/packages/core/src/code-review/buildToolCallFallbacks.ts @@ -0,0 +1,18 @@ +export interface ToolCallFileDiff { + oldText: string | null; + newText: string | null; +} + +export function buildToolCallFallbacks( + hasRemoteFiles: boolean, + reviewFilePaths: string[], + extractFileDiff: (filePath: string) => ToolCallFileDiff | undefined, +): Map | undefined { + if (hasRemoteFiles) return undefined; + const diffs = new Map(); + for (const filePath of reviewFilePaths) { + const diff = extractFileDiff(filePath); + if (diff) diffs.set(filePath, diff); + } + return diffs; +} diff --git a/packages/core/src/code-review/code-review.module.ts b/packages/core/src/code-review/code-review.module.ts new file mode 100644 index 0000000000..ca72ac7451 --- /dev/null +++ b/packages/core/src/code-review/code-review.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { REVERT_HUNK_SERVICE } from "./identifiers"; +import { RevertHunkService } from "./revertHunkService"; + +export const codeReviewModule = new ContainerModule(({ bind }) => { + bind(REVERT_HUNK_SERVICE).to(RevertHunkService).inSingletonScope(); +}); diff --git a/apps/code/src/renderer/features/code-review/utils/contentHash.ts b/packages/core/src/code-review/contentHash.ts similarity index 100% rename from apps/code/src/renderer/features/code-review/utils/contentHash.ts rename to packages/core/src/code-review/contentHash.ts diff --git a/packages/core/src/code-review/diffAnnotations.test.ts b/packages/core/src/code-review/diffAnnotations.test.ts new file mode 100644 index 0000000000..df5f8f3478 --- /dev/null +++ b/packages/core/src/code-review/diffAnnotations.test.ts @@ -0,0 +1,82 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import { describe, expect, it } from "vitest"; +import { + buildDraftAnnotations, + buildHunkAnnotations, + getLastChangeLineNumber, +} from "./diffAnnotations"; +import type { DraftComment } from "./types"; + +type Hunk = FileDiffMetadata["hunks"][number]; + +function makeHunk(overrides: Partial): Hunk { + return { + additionStart: 1, + additionLines: 0, + deletionLines: 0, + hunkContent: [], + ...overrides, + } as Hunk; +} + +describe("getLastChangeLineNumber", () => { + it("computes the last changed line accounting for context offset", () => { + const hunk = makeHunk({ + additionStart: 10, + hunkContent: [ + { type: "context", lines: 2 }, + { type: "change", additions: 3 }, + ] as Hunk["hunkContent"], + }); + expect(getLastChangeLineNumber(hunk)).toBe(14); + }); +}); + +describe("buildHunkAnnotations", () => { + it("skips empty hunks and emits a revert annotation per changed hunk", () => { + const fileDiff = { + hunks: [ + makeHunk({ additionLines: 0, deletionLines: 0 }), + makeHunk({ + additionStart: 5, + additionLines: 1, + hunkContent: [ + { type: "change", additions: 1 }, + ] as Hunk["hunkContent"], + }), + ], + } as FileDiffMetadata; + + const annotations = buildHunkAnnotations(fileDiff); + expect(annotations).toHaveLength(1); + expect(annotations[0].metadata).toEqual({ + kind: "hunk-revert", + hunkIndex: 1, + }); + expect(annotations[0].side).toBe("additions"); + }); +}); + +describe("buildDraftAnnotations", () => { + it("maps drafts to draft-comment annotations on their side/endLine", () => { + const drafts: DraftComment[] = [ + { + id: "d1", + taskId: "t", + filePath: "a.ts", + startLine: 2, + endLine: 4, + side: "deletions", + text: "x", + createdAt: 0, + }, + ]; + const [annotation] = buildDraftAnnotations(drafts); + expect(annotation.side).toBe("deletions"); + expect(annotation.lineNumber).toBe(4); + expect(annotation.metadata).toMatchObject({ + kind: "draft-comment", + draftId: "d1", + }); + }); +}); diff --git a/apps/code/src/renderer/features/code-review/utils/diffAnnotations.ts b/packages/core/src/code-review/diffAnnotations.ts similarity index 93% rename from apps/code/src/renderer/features/code-review/utils/diffAnnotations.ts rename to packages/core/src/code-review/diffAnnotations.ts index c918be9902..5f49817a81 100644 --- a/apps/code/src/renderer/features/code-review/utils/diffAnnotations.ts +++ b/packages/core/src/code-review/diffAnnotations.ts @@ -3,8 +3,7 @@ import type { FileDiffMetadata, SelectedLineRange, } from "@pierre/diffs"; -import type { DraftComment } from "../stores/reviewDraftsStore"; -import type { AnnotationMetadata, DiffOptions } from "../types"; +import type { AnnotationMetadata, DiffOptions, DraftComment } from "./types"; export function getLastChangeLineNumber( hunk: FileDiffMetadata["hunks"][number], diff --git a/apps/code/src/renderer/features/code-review/utils/fileDiffExpansion.test.ts b/packages/core/src/code-review/fileDiffExpansion.test.ts similarity index 100% rename from apps/code/src/renderer/features/code-review/utils/fileDiffExpansion.test.ts rename to packages/core/src/code-review/fileDiffExpansion.test.ts diff --git a/apps/code/src/renderer/features/code-review/utils/fileDiffExpansion.ts b/packages/core/src/code-review/fileDiffExpansion.ts similarity index 100% rename from apps/code/src/renderer/features/code-review/utils/fileDiffExpansion.ts rename to packages/core/src/code-review/fileDiffExpansion.ts diff --git a/packages/core/src/code-review/identifiers.ts b/packages/core/src/code-review/identifiers.ts new file mode 100644 index 0000000000..9e51ea9097 --- /dev/null +++ b/packages/core/src/code-review/identifiers.ts @@ -0,0 +1,4 @@ +export const REVERT_HUNK_SERVICE = Symbol.for("posthog.core.revertHunkService"); +export const CODE_REVIEW_WORKSPACE_CLIENT = Symbol.for( + "posthog.core.codeReviewWorkspaceClient", +); diff --git a/packages/core/src/code-review/prCommentAnnotations.test.ts b/packages/core/src/code-review/prCommentAnnotations.test.ts new file mode 100644 index 0000000000..b50a17b8b6 --- /dev/null +++ b/packages/core/src/code-review/prCommentAnnotations.test.ts @@ -0,0 +1,56 @@ +import type { PrReviewComment } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { buildFileAnnotations } from "./prCommentAnnotations"; +import type { PrCommentMetadata, PrCommentThread } from "./types"; + +function makeThread( + rootComment: Partial, + overrides: Partial = {}, +): PrCommentThread { + return { + rootId: 1, + nodeId: "n1", + isResolved: false, + filePath: "a.ts", + comments: [rootComment as PrReviewComment], + ...overrides, + }; +} + +describe("buildFileAnnotations", () => { + it("filters threads to the requested file path", () => { + const threads = new Map([ + [1, makeThread({ line: 5 })], + [2, makeThread({ line: 6 }, { rootId: 2, filePath: "b.ts" })], + ]); + expect(buildFileAnnotations(threads, "a.ts")).toHaveLength(1); + }); + + it("derives a deletions side for LEFT comments", () => { + const threads = new Map([ + [1, makeThread({ line: 5, side: "LEFT" })], + ]); + const [annotation] = buildFileAnnotations(threads, "a.ts"); + expect(annotation.side).toBe("deletions"); + }); + + it("treats a comment with no line/original_line as file-level", () => { + const threads = new Map([ + [1, makeThread({ line: null, original_line: null })], + ]); + const [annotation] = buildFileAnnotations(threads, "a.ts"); + const meta = annotation.metadata as PrCommentMetadata; + expect(meta.isFileLevel).toBe(true); + expect(annotation.lineNumber).toBe(1); + }); + + it("marks an outdated comment when only original_line is present", () => { + const threads = new Map([ + [1, makeThread({ line: null, original_line: 12 })], + ]); + const [annotation] = buildFileAnnotations(threads, "a.ts"); + const meta = annotation.metadata as PrCommentMetadata; + expect(meta.isOutdated).toBe(true); + expect(annotation.lineNumber).toBe(12); + }); +}); diff --git a/apps/code/src/renderer/features/code-review/utils/prCommentAnnotations.ts b/packages/core/src/code-review/prCommentAnnotations.ts similarity index 83% rename from apps/code/src/renderer/features/code-review/utils/prCommentAnnotations.ts rename to packages/core/src/code-review/prCommentAnnotations.ts index e704f9e567..691ee445fd 100644 --- a/apps/code/src/renderer/features/code-review/utils/prCommentAnnotations.ts +++ b/packages/core/src/code-review/prCommentAnnotations.ts @@ -1,14 +1,7 @@ -import type { PrReviewComment } from "@main/services/git/schemas"; import type { DiffLineAnnotation } from "@pierre/diffs"; -import type { AnnotationMetadata } from "../types"; +import type { AnnotationMetadata, PrCommentThread } from "./types"; -export interface PrCommentThread { - rootId: number; - nodeId: string; - isResolved: boolean; - comments: PrReviewComment[]; - filePath: string; -} +export type { PrCommentThread } from "./types"; function buildAnnotation( thread: PrCommentThread, diff --git a/apps/code/src/renderer/features/code-review/utils/resolveDiffSource.test.ts b/packages/core/src/code-review/resolveDiffSource.test.ts similarity index 100% rename from apps/code/src/renderer/features/code-review/utils/resolveDiffSource.test.ts rename to packages/core/src/code-review/resolveDiffSource.test.ts diff --git a/apps/code/src/renderer/features/code-review/utils/resolveDiffSource.ts b/packages/core/src/code-review/resolveDiffSource.ts similarity index 87% rename from apps/code/src/renderer/features/code-review/utils/resolveDiffSource.ts rename to packages/core/src/code-review/resolveDiffSource.ts index a82e80af23..11679edfe9 100644 --- a/apps/code/src/renderer/features/code-review/utils/resolveDiffSource.ts +++ b/packages/core/src/code-review/resolveDiffSource.ts @@ -1,6 +1,6 @@ -import type { DiffSource } from "@features/code-editor/stores/diffViewerStore"; +import type { DiffSource, ResolvedDiffSource } from "./types"; -export type ResolvedDiffSource = DiffSource; +export type { ResolvedDiffSource } from "./types"; export interface ResolveDiffSourceInput { configured: DiffSource | null; diff --git a/packages/core/src/code-review/revertHunk.ts b/packages/core/src/code-review/revertHunk.ts new file mode 100644 index 0000000000..fc55799f95 --- /dev/null +++ b/packages/core/src/code-review/revertHunk.ts @@ -0,0 +1,15 @@ +import { diffAcceptRejectHunk, parseDiffFromFile } from "@pierre/diffs"; + +export function revertHunkContent( + filePath: string, + originalContent: string, + modifiedContent: string, + hunkIndex: number, +): string { + const fullDiff = parseDiffFromFile( + { name: filePath, contents: originalContent }, + { name: filePath, contents: modifiedContent }, + ); + const reverted = diffAcceptRejectHunk(fullDiff, hunkIndex, "reject"); + return reverted.additionLines.join(""); +} diff --git a/packages/core/src/code-review/revertHunkService.test.ts b/packages/core/src/code-review/revertHunkService.test.ts new file mode 100644 index 0000000000..2b5f632af6 --- /dev/null +++ b/packages/core/src/code-review/revertHunkService.test.ts @@ -0,0 +1,183 @@ +import { parseDiffFromFile } from "@pierre/diffs"; +import { describe, expect, it, vi } from "vitest"; +import { + type CodeReviewWorkspaceClient, + RevertHunkService, +} from "./revertHunkService"; + +const FILE_PATH = "src/app.ts"; +const REPO_PATH = "/repo"; + +const HEAD = "line one\nline two\nline three\n"; +const WORKING = "line one\nline two changed\nline three\nline four\n"; + +function makeClient( + overrides: Partial = {}, +): CodeReviewWorkspaceClient { + return { + getFileAtHead: vi.fn(async () => HEAD), + readRepoFile: vi.fn(async () => WORKING), + writeRepoFile: vi.fn(async () => {}), + ...overrides, + }; +} + +describe("RevertHunkService", () => { + it("reads head and working content then writes reverted content back", async () => { + const client = makeClient(); + const service = new RevertHunkService(client); + + await service.revertHunk({ + repoPath: REPO_PATH, + filePath: FILE_PATH, + hunkIndex: 0, + }); + + expect(client.getFileAtHead).toHaveBeenCalledWith(REPO_PATH, FILE_PATH); + expect(client.readRepoFile).toHaveBeenCalledWith(REPO_PATH, FILE_PATH); + + const writeMock = client.writeRepoFile as ReturnType; + expect(writeMock).toHaveBeenCalledTimes(1); + const [repoPath, filePath, content] = writeMock.mock.calls[0]; + expect(repoPath).toBe(REPO_PATH); + expect(filePath).toBe(FILE_PATH); + expect(content).toBe(HEAD); + }); + + it("reads head and working tree in parallel", async () => { + const order: string[] = []; + const client = makeClient({ + getFileAtHead: vi.fn(async () => { + order.push("head:start"); + await Promise.resolve(); + order.push("head:end"); + return HEAD; + }), + readRepoFile: vi.fn(async () => { + order.push("working:start"); + await Promise.resolve(); + order.push("working:end"); + return WORKING; + }), + }); + const service = new RevertHunkService(client); + + await service.revertHunk({ + repoPath: REPO_PATH, + filePath: FILE_PATH, + hunkIndex: 0, + }); + + expect(order.indexOf("working:start")).toBeLessThan( + order.indexOf("head:end"), + ); + }); + + it("treats a missing head (newly added file) as empty when reverting", async () => { + const client = makeClient({ + getFileAtHead: vi.fn(async () => null), + readRepoFile: vi.fn(async () => "added line\n"), + }); + const service = new RevertHunkService(client); + + await service.revertHunk({ + repoPath: REPO_PATH, + filePath: FILE_PATH, + hunkIndex: 0, + }); + + expect(client.getFileAtHead).toHaveBeenCalledWith(REPO_PATH, FILE_PATH); + const writeMock = client.writeRepoFile as ReturnType; + expect(writeMock.mock.calls[0][2]).toBe(""); + }); + + it("propagates a write failure to the caller", async () => { + const client = makeClient({ + writeRepoFile: vi.fn(async () => { + throw new Error("disk full"); + }), + }); + const service = new RevertHunkService(client); + + await expect( + service.revertHunk({ + repoPath: REPO_PATH, + filePath: FILE_PATH, + hunkIndex: 0, + }), + ).rejects.toThrow("disk full"); + }); +}); + +const SAMPLE_DIFF = parseDiffFromFile( + { name: FILE_PATH, contents: HEAD }, + { name: FILE_PATH, contents: WORKING }, +); + +describe("RevertHunkService.revertHunkOptimistic", () => { + it("applies the optimistic diff before awaiting the backend revert", async () => { + const order: string[] = []; + const client = makeClient({ + writeRepoFile: vi.fn(async () => { + order.push("write"); + }), + }); + const service = new RevertHunkService(client); + + await service.revertHunkOptimistic( + { + repoPath: REPO_PATH, + filePath: FILE_PATH, + hunkIndex: 0, + fileDiff: SAMPLE_DIFF, + }, + { + onOptimisticApply: () => order.push("apply"), + onRollback: () => order.push("rollback"), + }, + ); + + expect(order).toEqual(["apply", "write"]); + }); + + it("returns true and never rolls back when the revert succeeds", async () => { + const service = new RevertHunkService(makeClient()); + const onRollback = vi.fn(); + + const result = await service.revertHunkOptimistic( + { + repoPath: REPO_PATH, + filePath: FILE_PATH, + hunkIndex: 0, + fileDiff: SAMPLE_DIFF, + }, + { onOptimisticApply: vi.fn(), onRollback }, + ); + + expect(result).toBe(true); + expect(onRollback).not.toHaveBeenCalled(); + }); + + it("rolls back and returns false when the backend revert fails", async () => { + const client = makeClient({ + writeRepoFile: vi.fn(async () => { + throw new Error("disk full"); + }), + }); + const service = new RevertHunkService(client); + const onRollback = vi.fn(); + + const result = await service.revertHunkOptimistic( + { + repoPath: REPO_PATH, + filePath: FILE_PATH, + hunkIndex: 0, + fileDiff: SAMPLE_DIFF, + }, + { onOptimisticApply: vi.fn(), onRollback }, + ); + + expect(result).toBe(false); + expect(onRollback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/code-review/revertHunkService.ts b/packages/core/src/code-review/revertHunkService.ts new file mode 100644 index 0000000000..0332764d0c --- /dev/null +++ b/packages/core/src/code-review/revertHunkService.ts @@ -0,0 +1,86 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import { diffAcceptRejectHunk } from "@pierre/diffs"; +import { inject, injectable } from "inversify"; +import { + CODE_REVIEW_WORKSPACE_CLIENT, + REVERT_HUNK_SERVICE, +} from "./identifiers"; +import { revertHunkContent } from "./revertHunk"; + +export { REVERT_HUNK_SERVICE }; + +export interface CodeReviewWorkspaceClient { + getFileAtHead( + directoryPath: string, + filePath: string, + ): Promise; + readRepoFile(repoPath: string, filePath: string): Promise; + writeRepoFile( + repoPath: string, + filePath: string, + content: string, + ): Promise; +} + +export interface RevertHunkInput { + repoPath: string; + filePath: string; + hunkIndex: number; +} + +export interface OptimisticRevertInput { + repoPath: string; + filePath: string; + hunkIndex: number; + fileDiff: FileDiffMetadata; +} + +export interface OptimisticRevertCallbacks { + onOptimisticApply(fileDiff: FileDiffMetadata): void; + onRollback(): void; +} + +@injectable() +export class RevertHunkService { + constructor( + @inject(CODE_REVIEW_WORKSPACE_CLIENT) + private readonly workspace: CodeReviewWorkspaceClient, + ) {} + + async revertHunk(input: RevertHunkInput): Promise { + const { repoPath, filePath, hunkIndex } = input; + + const [originalContent, modifiedContent] = await Promise.all([ + this.workspace.getFileAtHead(repoPath, filePath), + this.workspace.readRepoFile(repoPath, filePath), + ]); + + const newContent = revertHunkContent( + filePath, + originalContent ?? "", + modifiedContent ?? "", + hunkIndex, + ); + + await this.workspace.writeRepoFile(repoPath, filePath, newContent); + } + + async revertHunkOptimistic( + input: OptimisticRevertInput, + callbacks: OptimisticRevertCallbacks, + ): Promise { + const { repoPath, filePath, hunkIndex, fileDiff } = input; + + callbacks.onOptimisticApply( + diffAcceptRejectHunk(fileDiff, hunkIndex, "reject"), + ); + + try { + await this.revertHunk({ repoPath, filePath, hunkIndex }); + return true; + } catch { + callbacks.onRollback(); + return false; + } + } +} diff --git a/packages/core/src/code-review/reviewItemKeys.test.ts b/packages/core/src/code-review/reviewItemKeys.test.ts new file mode 100644 index 0000000000..70ac8c519e --- /dev/null +++ b/packages/core/src/code-review/reviewItemKeys.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { buildGithubFileUrl, computeSkipExpansion } from "./reviewItemKeys"; + +describe("computeSkipExpansion", () => { + it("skips when staged", () => { + expect(computeSkipExpansion(true, "a.ts", undefined)).toBe(true); + }); + + it("skips when the path is also staged elsewhere", () => { + expect(computeSkipExpansion(false, "a.ts", new Set(["a.ts"]))).toBe(true); + }); + + it("does not skip an unstaged path with no overlap", () => { + expect(computeSkipExpansion(false, "a.ts", new Set(["b.ts"]))).toBe(false); + }); + + it("does not skip when alsoStagedPaths is undefined", () => { + expect(computeSkipExpansion(false, "a.ts", undefined)).toBe(false); + }); +}); + +describe("buildGithubFileUrl", () => { + it("returns undefined without a prUrl", () => { + expect(buildGithubFileUrl(null, "src/a.ts")).toBeUndefined(); + }); + + it("builds an anchored files URL with slashes replaced by dashes", () => { + expect(buildGithubFileUrl("https://gh/pr/1", "src/a/b.ts")).toBe( + "https://gh/pr/1/files#diff-src-a-b.ts", + ); + }); +}); diff --git a/packages/core/src/code-review/reviewItemKeys.ts b/packages/core/src/code-review/reviewItemKeys.ts new file mode 100644 index 0000000000..e565c0e469 --- /dev/null +++ b/packages/core/src/code-review/reviewItemKeys.ts @@ -0,0 +1,15 @@ +export function computeSkipExpansion( + staged: boolean, + filePath: string, + alsoStagedPaths: Set | undefined, +): boolean { + return staged || (alsoStagedPaths?.has(filePath) ?? false); +} + +export function buildGithubFileUrl( + prUrl: string | null | undefined, + filePath: string, +): string | undefined { + if (!prUrl) return undefined; + return `${prUrl}/files#diff-${filePath.replaceAll("/", "-")}`; +} diff --git a/packages/core/src/code-review/reviewPrompts.test.ts b/packages/core/src/code-review/reviewPrompts.test.ts new file mode 100644 index 0000000000..376940691f --- /dev/null +++ b/packages/core/src/code-review/reviewPrompts.test.ts @@ -0,0 +1,84 @@ +import type { PrReviewComment } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { + buildAskAboutPrCommentPrompt, + buildBatchedInlineCommentsPrompt, + buildFixPrCommentPrompt, + buildInlineCommentPrompt, +} from "./reviewPrompts"; +import type { DraftComment } from "./types"; + +function makeDraft(overrides: Partial = {}): DraftComment { + return { + id: "d1", + taskId: "t1", + filePath: "src/a.ts", + startLine: 10, + endLine: 10, + side: "additions", + text: "fix this", + createdAt: 0, + ...overrides, + }; +} + +function makeComment(body: string, login = "alice"): PrReviewComment { + return { user: { login }, body } as PrReviewComment; +} + +describe("buildInlineCommentPrompt", () => { + it("escapes the file path and renders a single line ref", () => { + const out = buildInlineCommentPrompt('a"&<>.ts', 5, 5, "deletions", "hi"); + expect(out).toContain("""); + expect(out).toContain("&"); + expect(out).toContain("line 5"); + expect(out).toContain("(old)"); + }); + + it("renders a range and new side", () => { + const out = buildInlineCommentPrompt("a.ts", 3, 7, "additions", "hi"); + expect(out).toContain("lines 3-7"); + expect(out).toContain("(new)"); + }); +}); + +describe("buildBatchedInlineCommentsPrompt", () => { + it("returns empty for no drafts", () => { + expect(buildBatchedInlineCommentsPrompt([])).toBe(""); + }); + + it("delegates to the single-comment prompt for one draft", () => { + const out = buildBatchedInlineCommentsPrompt([makeDraft()]); + expect(out).toBe( + buildInlineCommentPrompt("src/a.ts", 10, 10, "additions", "fix this"), + ); + }); + + it("renders a bulleted, indented list for multiple drafts", () => { + const out = buildBatchedInlineCommentsPrompt([ + makeDraft({ id: "d1", text: "one" }), + makeDraft({ id: "d2", filePath: "b.ts", text: "two\nlines" }), + ]); + expect(out).toContain("Please address these review comments:"); + expect(out).toContain("- In file"); + expect(out).toContain(" lines"); + }); +}); + +describe("buildFixPrCommentPrompt / buildAskAboutPrCommentPrompt", () => { + it("includes the thread body and side", () => { + const out = buildFixPrCommentPrompt("a.ts", 4, "new", [ + makeComment("please rename"), + ]); + expect(out).toContain("line 4 (new)"); + expect(out).toContain("@alice"); + expect(out).toContain("please rename"); + }); + + it("ask prompt asks for understanding without changes", () => { + const out = buildAskAboutPrCommentPrompt("a.ts", 4, "old", [ + makeComment("why?"), + ]); + expect(out).toContain("Do not make any changes"); + }); +}); diff --git a/apps/code/src/renderer/features/code-review/utils/reviewPrompts.ts b/packages/core/src/code-review/reviewPrompts.ts similarity index 95% rename from apps/code/src/renderer/features/code-review/utils/reviewPrompts.ts rename to packages/core/src/code-review/reviewPrompts.ts index e842de7874..d5b7a9aeaf 100644 --- a/apps/code/src/renderer/features/code-review/utils/reviewPrompts.ts +++ b/packages/core/src/code-review/reviewPrompts.ts @@ -1,6 +1,6 @@ -import type { PrReviewComment } from "@main/services/git/schemas"; import type { AnnotationSide } from "@pierre/diffs"; -import type { DraftComment } from "../stores/reviewDraftsStore"; +import type { PrReviewComment } from "@posthog/shared"; +import type { DraftComment } from "./types"; function escapeXmlAttr(value: string): string { return value diff --git a/packages/core/src/code-review/reviewShellGeometry.test.ts b/packages/core/src/code-review/reviewShellGeometry.test.ts new file mode 100644 index 0000000000..a81cab7745 --- /dev/null +++ b/packages/core/src/code-review/reviewShellGeometry.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + buildItemIndex, + getDeferredMessage, + splitFilePath, + sumHunkStats, +} from "./reviewShellGeometry"; + +describe("splitFilePath", () => { + it("splits a nested path into dir and file", () => { + expect(splitFilePath("src/a/b/File.ts")).toEqual({ + dirPath: "src/a/b/", + fileName: "File.ts", + }); + }); + + it("returns empty dir for a bare filename", () => { + expect(splitFilePath("File.ts")).toEqual({ + dirPath: "", + fileName: "File.ts", + }); + }); +}); + +describe("sumHunkStats", () => { + it("sums addition and deletion lines across hunks", () => { + const hunks = [ + { additionLines: 3, deletionLines: 1 }, + { additionLines: 2, deletionLines: 4 }, + ] as Parameters[0]; + expect(sumHunkStats(hunks)).toEqual({ additions: 5, deletions: 5 }); + }); + + it("returns zeros for no hunks", () => { + expect(sumHunkStats([])).toEqual({ additions: 0, deletions: 0 }); + }); +}); + +describe("buildItemIndex", () => { + it("maps scrollKey to index, skipping items without a key", () => { + const index = buildItemIndex([{ scrollKey: "a" }, {}, { scrollKey: "b" }]); + expect(index.get("a")).toBe(0); + expect(index.get("b")).toBe(2); + expect(index.size).toBe(2); + }); +}); + +describe("getDeferredMessage", () => { + it("returns the line-limit message", () => { + expect(getDeferredMessage("line-limit")).toContain("5,000-line"); + }); + + it("returns the unavailable message", () => { + expect(getDeferredMessage("unavailable")).toBe("Unable to load diff."); + }); +}); diff --git a/packages/core/src/code-review/reviewShellGeometry.ts b/packages/core/src/code-review/reviewShellGeometry.ts new file mode 100644 index 0000000000..58cb2551f8 --- /dev/null +++ b/packages/core/src/code-review/reviewShellGeometry.ts @@ -0,0 +1,47 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; + +export type DeferredReason = "line-limit" | "unavailable"; + +export function splitFilePath(fullPath: string): { + dirPath: string; + fileName: string; +} { + const lastSlash = fullPath.lastIndexOf("/"); + return { + dirPath: lastSlash >= 0 ? fullPath.slice(0, lastSlash + 1) : "", + fileName: lastSlash >= 0 ? fullPath.slice(lastSlash + 1) : fullPath, + }; +} + +export function sumHunkStats(hunks: FileDiffMetadata["hunks"]): { + additions: number; + deletions: number; +} { + let additions = 0; + let deletions = 0; + for (const hunk of hunks) { + additions += hunk.additionLines; + deletions += hunk.deletionLines; + } + return { additions, deletions }; +} + +export function buildItemIndex( + items: { scrollKey?: string }[], +): Map { + const index = new Map(); + for (let i = 0; i < items.length; i++) { + const key = items[i].scrollKey; + if (key) index.set(key, i); + } + return index; +} + +export function getDeferredMessage(reason: DeferredReason): string { + switch (reason) { + case "line-limit": + return "File exceeds the 5,000-line review limit."; + case "unavailable": + return "Unable to load diff."; + } +} diff --git a/packages/core/src/code-review/selectTaskDiffStats.test.ts b/packages/core/src/code-review/selectTaskDiffStats.test.ts new file mode 100644 index 0000000000..5aaf3695a2 --- /dev/null +++ b/packages/core/src/code-review/selectTaskDiffStats.test.ts @@ -0,0 +1,86 @@ +import type { ChangedFile } from "@posthog/shared/domain-types"; +import { describe, expect, it, vi } from "vitest"; +import { + deriveIsCloud, + EMPTY_DIFF_STATS, + selectTaskDiffStats, +} from "./selectTaskDiffStats"; + +function makeFiles(paths: string[]): ChangedFile[] { + return paths.map((path) => ({ path }) as ChangedFile); +} + +const compute = vi.fn((files: ChangedFile[]) => ({ + filesChanged: files.length, + linesAdded: files.length, + linesRemoved: 0, +})); + +describe("deriveIsCloud", () => { + it("is true when workspace mode is cloud", () => { + expect(deriveIsCloud("cloud", undefined)).toBe(true); + }); + + it("is true when latest run environment is cloud", () => { + expect(deriveIsCloud("local", "cloud")).toBe(true); + }); + + it("is false otherwise", () => { + expect(deriveIsCloud("local", "local")).toBe(false); + }); +}); + +describe("selectTaskDiffStats", () => { + const base = { + reviewFiles: makeFiles(["r1", "r2"]), + branchFiles: makeFiles(["b1"]), + prFiles: makeFiles(["p1", "p2", "p3"]), + localDiffStats: { filesChanged: 9, linesAdded: 9, linesRemoved: 9 }, + computeStats: compute, + }; + + it("uses reviewFiles when cloud", () => { + expect( + selectTaskDiffStats({ ...base, isCloud: true, effectiveSource: "pr" }) + .filesChanged, + ).toBe(2); + }); + + it("uses branchFiles for branch source", () => { + expect( + selectTaskDiffStats({ + ...base, + isCloud: false, + effectiveSource: "branch", + }).filesChanged, + ).toBe(1); + }); + + it("falls back to empty stats when branch files missing", () => { + expect( + selectTaskDiffStats({ + ...base, + isCloud: false, + effectiveSource: "branch", + branchFiles: undefined, + }), + ).toBe(EMPTY_DIFF_STATS); + }); + + it("uses prFiles for pr source", () => { + expect( + selectTaskDiffStats({ ...base, isCloud: false, effectiveSource: "pr" }) + .filesChanged, + ).toBe(3); + }); + + it("returns localDiffStats for local source", () => { + expect( + selectTaskDiffStats({ + ...base, + isCloud: false, + effectiveSource: "local", + }), + ).toBe(base.localDiffStats); + }); +}); diff --git a/packages/core/src/code-review/selectTaskDiffStats.ts b/packages/core/src/code-review/selectTaskDiffStats.ts new file mode 100644 index 0000000000..74448f8110 --- /dev/null +++ b/packages/core/src/code-review/selectTaskDiffStats.ts @@ -0,0 +1,50 @@ +import type { ChangedFile } from "@posthog/shared/domain-types"; +import type { ResolvedDiffSource } from "./types"; + +export interface DiffStats { + filesChanged: number; + linesAdded: number; + linesRemoved: number; +} + +export const EMPTY_DIFF_STATS: DiffStats = { + filesChanged: 0, + linesAdded: 0, + linesRemoved: 0, +}; + +export interface SelectTaskDiffStatsInput { + isCloud: boolean; + effectiveSource: ResolvedDiffSource; + reviewFiles: ChangedFile[]; + branchFiles: ChangedFile[] | undefined; + prFiles: ChangedFile[] | undefined; + localDiffStats: DiffStats; + computeStats: (files: ChangedFile[]) => DiffStats; +} + +export function selectTaskDiffStats({ + isCloud, + effectiveSource, + reviewFiles, + branchFiles, + prFiles, + localDiffStats, + computeStats, +}: SelectTaskDiffStatsInput): DiffStats { + if (isCloud) return computeStats(reviewFiles); + if (effectiveSource === "branch") { + return branchFiles ? computeStats(branchFiles) : EMPTY_DIFF_STATS; + } + if (effectiveSource === "pr") { + return prFiles ? computeStats(prFiles) : EMPTY_DIFF_STATS; + } + return localDiffStats; +} + +export function deriveIsCloud( + workspaceMode: string | undefined, + latestRunEnvironment: string | undefined, +): boolean { + return workspaceMode === "cloud" || latestRunEnvironment === "cloud"; +} diff --git a/apps/code/src/renderer/features/code-review/types.ts b/packages/core/src/code-review/types.ts similarity index 58% rename from apps/code/src/renderer/features/code-review/types.ts rename to packages/core/src/code-review/types.ts index ca77ca73d2..07a2fe25a7 100644 --- a/apps/code/src/renderer/features/code-review/types.ts +++ b/packages/core/src/code-review/types.ts @@ -1,7 +1,28 @@ -import type { PrReviewComment } from "@main/services/git/schemas"; import type { AnnotationSide, FileDiffOptions } from "@pierre/diffs"; -import type { FileDiffProps, MultiFileDiffProps } from "@pierre/diffs/react"; -import type { PrCommentThread } from "./utils/prCommentAnnotations"; +import type { PrReviewComment } from "@posthog/shared"; + +export type DiffSource = "local" | "branch" | "pr"; + +export type ResolvedDiffSource = DiffSource; + +export interface DraftComment { + id: string; + taskId: string; + filePath: string; + startLine: number; + endLine: number; + side: AnnotationSide; + text: string; + createdAt: number; +} + +export interface PrCommentThread { + rootId: number; + nodeId: string; + isResolved: boolean; + comments: PrReviewComment[]; + filePath: string; +} export interface HunkRevertMetadata { kind: "hunk-revert"; @@ -43,20 +64,3 @@ export type AnnotationMetadata = | PrCommentMetadata; export type DiffOptions = FileDiffOptions; - -interface PrCommentProps { - taskId?: string; - prUrl?: string | null; - commentThreads?: Map; -} - -export type PatchDiffProps = FileDiffProps & - PrCommentProps & { - repoPath?: string; - skipExpansion?: boolean; - }; - -export type FilesDiffProps = MultiFileDiffProps & - PrCommentProps; - -export type InteractiveFileDiffProps = PatchDiffProps | FilesDiffProps; diff --git a/packages/core/src/command-center/autofill.test.ts b/packages/core/src/command-center/autofill.test.ts new file mode 100644 index 0000000000..a8186ff9f8 --- /dev/null +++ b/packages/core/src/command-center/autofill.test.ts @@ -0,0 +1,151 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { selectAutofillCandidates } from "./autofill"; +import { workspaceIdSet } from "./eligibility"; + +const NOW = new Date("2026-02-27T12:00:00Z").getTime(); +const ONE_HOUR_MS = 60 * 60 * 1000; + +function makeTask(overrides: Partial = {}): Task { + return { + id: "task-1", + task_number: 1, + slug: "task-1", + title: "Task 1", + description: "", + created_at: new Date(NOW).toISOString(), + updated_at: new Date(NOW).toISOString(), + origin_product: "code", + ...overrides, + } as Task; +} + +function candidates(opts: { + tasks: Task[]; + workspaceIds?: string[]; + assigned?: string[]; + archived?: string[]; + emptySlots: number; +}): string[] { + const workspaces = Object.fromEntries( + (opts.workspaceIds ?? opts.tasks.map((t) => t.id)).map((id) => [id, {}]), + ); + return selectAutofillCandidates(opts.tasks, { + assignedIds: new Set(opts.assigned ?? []), + archivedIds: new Set(opts.archived ?? []), + workspaceIds: workspaceIdSet(workspaces), + emptySlots: opts.emptySlots, + nowMs: NOW, + }); +} + +describe("selectAutofillCandidates", () => { + it("returns recent tasks that have workspaces", () => { + const result = candidates({ + tasks: [ + makeTask({ id: "t1", updated_at: new Date(NOW - 100).toISOString() }), + makeTask({ id: "t2", updated_at: new Date(NOW - 200).toISOString() }), + ], + emptySlots: 4, + }); + expect(result).toEqual(["t1", "t2"]); + }); + + it("excludes already-assigned tasks", () => { + const result = candidates({ + tasks: [ + makeTask({ id: "t1", updated_at: new Date(NOW - 100).toISOString() }), + makeTask({ id: "t2", updated_at: new Date(NOW - 200).toISOString() }), + ], + assigned: ["t1"], + emptySlots: 4, + }); + expect(result).toEqual(["t2"]); + }); + + it("excludes archived tasks", () => { + const result = candidates({ + tasks: [makeTask({ id: "t1" }), makeTask({ id: "t2" })], + archived: ["t1"], + emptySlots: 4, + }); + expect(result).toEqual(["t2"]); + }); + + it("excludes tasks without a workspace", () => { + const result = candidates({ + tasks: [makeTask({ id: "t1" }), makeTask({ id: "t2" })], + workspaceIds: ["t2"], + emptySlots: 4, + }); + expect(result).toEqual(["t2"]); + }); + + it("excludes tasks older than the recent window", () => { + const result = candidates({ + tasks: [ + makeTask({ + id: "fresh", + updated_at: new Date(NOW - 100).toISOString(), + }), + makeTask({ + id: "stale", + updated_at: new Date(NOW - 3 * ONE_HOUR_MS).toISOString(), + }), + ], + emptySlots: 4, + }); + expect(result).toEqual(["fresh"]); + }); + + it("uses latest_run.updated_at when newer than task.updated_at", () => { + const result = candidates({ + tasks: [ + makeTask({ + id: "stale", + updated_at: new Date(NOW - 3 * ONE_HOUR_MS).toISOString(), + latest_run: { + id: "run-1", + task: "stale", + team: 1, + branch: null, + status: "in_progress", + log_url: "", + error_message: null, + output: null, + state: {}, + created_at: new Date(NOW - 3 * ONE_HOUR_MS).toISOString(), + updated_at: new Date(NOW - 100).toISOString(), + completed_at: null, + }, + } as Task), + ], + emptySlots: 4, + }); + expect(result).toEqual(["stale"]); + }); + + it("sorts by most recent activity descending", () => { + const result = candidates({ + tasks: [ + makeTask({ id: "old", updated_at: new Date(NOW - 1000).toISOString() }), + makeTask({ id: "new", updated_at: new Date(NOW - 100).toISOString() }), + makeTask({ id: "mid", updated_at: new Date(NOW - 500).toISOString() }), + ], + emptySlots: 4, + }); + expect(result).toEqual(["new", "mid", "old"]); + }); + + it("caps candidates at emptySlots", () => { + const tasks = Array.from({ length: 10 }, (_, i) => + makeTask({ id: `t${i}`, updated_at: new Date(NOW - i).toISOString() }), + ); + const result = candidates({ tasks, emptySlots: 4 }); + expect(result).toEqual(["t0", "t1", "t2", "t3"]); + }); + + it("returns empty when no tasks are eligible", () => { + expect(candidates({ tasks: [], emptySlots: 4 })).toEqual([]); + }); +}); diff --git a/packages/core/src/command-center/autofill.ts b/packages/core/src/command-center/autofill.ts new file mode 100644 index 0000000000..65282a2698 --- /dev/null +++ b/packages/core/src/command-center/autofill.ts @@ -0,0 +1,37 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { + type CellEligibilityInput, + isTaskEligibleForCell, +} from "./eligibility"; + +export const RECENT_WINDOW_MS = 2 * 60 * 60 * 1000; + +export function getLastActivity(task: Task): number { + const taskTime = new Date(task.updated_at).getTime(); + const runTime = task.latest_run?.updated_at + ? new Date(task.latest_run.updated_at).getTime() + : 0; + return Math.max(taskTime, runTime); +} + +export interface AutofillInput extends CellEligibilityInput { + emptySlots: number; + nowMs: number; + recentWindowMs?: number; +} + +export function selectAutofillCandidates( + tasks: Task[], + input: AutofillInput, +): string[] { + const recentWindowMs = input.recentWindowMs ?? RECENT_WINDOW_MS; + const cutoff = input.nowMs - recentWindowMs; + return tasks + .filter( + (task) => + isTaskEligibleForCell(task, input) && getLastActivity(task) >= cutoff, + ) + .sort((a, b) => getLastActivity(b) - getLastActivity(a)) + .slice(0, input.emptySlots) + .map((task) => task.id); +} diff --git a/packages/core/src/command-center/cells.ts b/packages/core/src/command-center/cells.ts new file mode 100644 index 0000000000..091a8dbaa5 --- /dev/null +++ b/packages/core/src/command-center/cells.ts @@ -0,0 +1,43 @@ +import type { AgentSession, WorkspaceMode } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import { type CellStatus, deriveStatus, getRepoName } from "./status"; + +export interface CommandCenterCellData { + cellIndex: number; + taskId: string | null; + task: Task | undefined; + session: AgentSession | undefined; + status: CellStatus; + repoName: string | null; + workspaceMode: WorkspaceMode | null; +} + +export interface BuildCellsInput { + taskById: Map; + sessionByTaskId: Map; + workspaces: Record | undefined; +} + +export function buildCommandCenterCells( + storeCells: (string | null)[], + input: BuildCellsInput, +): CommandCenterCellData[] { + const { taskById, sessionByTaskId, workspaces } = input; + return storeCells.map((taskId, cellIndex) => { + const task = taskId ? taskById.get(taskId) : undefined; + const session = taskId ? sessionByTaskId.get(taskId) : undefined; + const status = taskId ? deriveStatus(session) : "idle"; + const repoName = task ? getRepoName(task) : null; + const workspaceMode = (taskId ? workspaces?.[taskId]?.mode : null) ?? null; + + return { + cellIndex, + taskId, + task, + session, + status, + repoName, + workspaceMode, + }; + }); +} diff --git a/packages/core/src/command-center/eligibility.test.ts b/packages/core/src/command-center/eligibility.test.ts new file mode 100644 index 0000000000..5880594723 --- /dev/null +++ b/packages/core/src/command-center/eligibility.test.ts @@ -0,0 +1,28 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { selectAvailableTasks, workspaceIdSet } from "./eligibility"; + +function makeTask(id: string): Task { + return { + id, + task_number: 1, + slug: id, + title: id, + description: "", + created_at: "", + updated_at: "", + origin_product: "code", + } as Task; +} + +describe("selectAvailableTasks", () => { + it("keeps tasks that are unassigned, unarchived, and have a workspace", () => { + const tasks = [makeTask("a"), makeTask("b"), makeTask("c"), makeTask("d")]; + const result = selectAvailableTasks(tasks, { + assignedIds: new Set(["a"]), + archivedIds: new Set(["b"]), + workspaceIds: workspaceIdSet({ a: {}, b: {}, c: {} }), + }); + expect(result.map((t) => t.id)).toEqual(["c"]); + }); +}); diff --git a/packages/core/src/command-center/eligibility.ts b/packages/core/src/command-center/eligibility.ts new file mode 100644 index 0000000000..ee60695d9f --- /dev/null +++ b/packages/core/src/command-center/eligibility.ts @@ -0,0 +1,33 @@ +import type { Task } from "@posthog/shared/domain-types"; + +export interface CellEligibilityInput { + assignedIds: Set; + archivedIds: Set; + workspaceIds: { has(id: string): boolean }; +} + +export function isTaskEligibleForCell( + task: Task, + input: CellEligibilityInput, +): boolean { + return ( + !input.assignedIds.has(task.id) && + !input.archivedIds.has(task.id) && + input.workspaceIds.has(task.id) + ); +} + +export function workspaceIdSet( + workspaces: Record | undefined, +): { has(id: string): boolean } { + return { + has: (id: string) => Boolean(workspaces?.[id]), + }; +} + +export function selectAvailableTasks( + tasks: Task[], + input: CellEligibilityInput, +): Task[] { + return tasks.filter((task) => isTaskEligibleForCell(task, input)); +} diff --git a/packages/core/src/command-center/grid.test.ts b/packages/core/src/command-center/grid.test.ts new file mode 100644 index 0000000000..f770342df1 --- /dev/null +++ b/packages/core/src/command-center/grid.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + clampZoom, + getCellCount, + getCellSessionId, + getGridDimensions, + resizeCells, +} from "./grid"; + +describe("getGridDimensions / getCellCount", () => { + it.each([ + { preset: "1x1", cols: 1, rows: 1, count: 1 }, + { preset: "2x1", cols: 2, rows: 1, count: 2 }, + { preset: "1x2", cols: 1, rows: 2, count: 2 }, + { preset: "2x2", cols: 2, rows: 2, count: 4 }, + { preset: "3x2", cols: 3, rows: 2, count: 6 }, + { preset: "3x3", cols: 3, rows: 3, count: 9 }, + ] as const)( + "$preset -> $cols x $rows = $count", + ({ preset, cols, rows, count }) => { + expect(getGridDimensions(preset)).toEqual({ cols, rows }); + expect(getCellCount(preset)).toBe(count); + }, + ); +}); + +describe("resizeCells", () => { + it("returns same array when count matches", () => { + const cells = ["a", null, "b"]; + expect(resizeCells(cells, 3)).toBe(cells); + }); + + it("truncates when shrinking", () => { + expect(resizeCells(["a", "b", "c", "d"], 2)).toEqual(["a", "b"]); + }); + + it("pads with null when growing", () => { + expect(resizeCells(["a"], 4)).toEqual(["a", null, null, null]); + }); +}); + +describe("clampZoom", () => { + it.each([ + { input: 0.1, expected: 0.5 }, + { input: 2, expected: 1.5 }, + { input: 1.0, expected: 1 }, + { input: 1.04, expected: 1 }, + { input: 1.06, expected: 1.1 }, + ])("clamps and rounds $input -> $expected", ({ input, expected }) => { + expect(clampZoom(input)).toBe(expected); + }); +}); + +describe("getCellSessionId", () => { + it("formats the cell session id", () => { + expect(getCellSessionId(2)).toBe("cc-cell-2"); + }); +}); diff --git a/packages/core/src/command-center/grid.ts b/packages/core/src/command-center/grid.ts new file mode 100644 index 0000000000..16e02b17d8 --- /dev/null +++ b/packages/core/src/command-center/grid.ts @@ -0,0 +1,37 @@ +export type LayoutPreset = "1x1" | "2x1" | "1x2" | "2x2" | "3x2" | "3x3"; + +export interface GridDimensions { + cols: number; + rows: number; +} + +export const ZOOM_MIN = 0.5; +export const ZOOM_MAX = 1.5; +export const ZOOM_STEP = 0.1; + +export function getGridDimensions(preset: LayoutPreset): GridDimensions { + const [cols, rows] = preset.split("x").map(Number); + return { cols, rows }; +} + +export function getCellCount(preset: LayoutPreset): number { + const { cols, rows } = getGridDimensions(preset); + return cols * rows; +} + +export function resizeCells( + current: (string | null)[], + newCount: number, +): (string | null)[] { + if (current.length === newCount) return current; + if (current.length > newCount) return current.slice(0, newCount); + return [...current, ...Array(newCount - current.length).fill(null)]; +} + +export function clampZoom(value: number): number { + return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, value)) * 10) / 10; +} + +export function getCellSessionId(cellIndex: number): string { + return `cc-cell-${cellIndex}`; +} diff --git a/packages/core/src/command-center/status.test.ts b/packages/core/src/command-center/status.test.ts new file mode 100644 index 0000000000..aa1389c1ed --- /dev/null +++ b/packages/core/src/command-center/status.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + buildStatusSummary, + type CellStatus, + deriveStatus, + type SessionStatusInput, +} from "./status"; + +function makeSession( + overrides: Partial = {}, +): SessionStatusInput { + return { + status: "connected", + pendingPermissions: { size: 0 }, + isPromptPending: false, + ...overrides, + }; +} + +describe("deriveStatus", () => { + it("returns idle for no session", () => { + expect(deriveStatus(undefined)).toBe("idle"); + }); + + it("returns error for session status error", () => { + expect(deriveStatus(makeSession({ status: "error" }))).toBe("error"); + }); + + it.each(["failed", "cancelled"])( + "returns error for cloudStatus %s", + (cloudStatus) => { + expect(deriveStatus(makeSession({ cloudStatus }))).toBe("error"); + }, + ); + + it("returns completed for cloudStatus completed", () => { + expect(deriveStatus(makeSession({ cloudStatus: "completed" }))).toBe( + "completed", + ); + }); + + it("returns waiting when permissions pending", () => { + expect(deriveStatus(makeSession({ pendingPermissions: { size: 1 } }))).toBe( + "waiting", + ); + }); + + it("returns running when connected and prompt pending", () => { + expect(deriveStatus(makeSession({ isPromptPending: true }))).toBe( + "running", + ); + }); + + it("returns idle otherwise", () => { + expect(deriveStatus(makeSession())).toBe("idle"); + }); +}); + +describe("buildStatusSummary", () => { + function cell(taskId: string | null, status: CellStatus) { + return { taskId, task: taskId ? {} : undefined, status }; + } + + it("tallies populated cells by status", () => { + const summary = buildStatusSummary([ + cell("a", "running"), + cell("b", "waiting"), + cell("c", "idle"), + cell("d", "error"), + cell("e", "completed"), + cell(null, "idle"), + ]); + expect(summary).toEqual({ + total: 5, + running: 1, + waiting: 1, + idle: 1, + error: 1, + completed: 1, + }); + }); + + it("ignores cells without a task", () => { + const summary = buildStatusSummary([ + cell(null, "idle"), + { taskId: "x", task: undefined, status: "running" }, + ]); + expect(summary.total).toBe(0); + }); +}); diff --git a/packages/core/src/command-center/status.ts b/packages/core/src/command-center/status.ts new file mode 100644 index 0000000000..4d33b32ac8 --- /dev/null +++ b/packages/core/src/command-center/status.ts @@ -0,0 +1,59 @@ +import { getTaskRepository, parseRepository } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; + +export type CellStatus = "running" | "waiting" | "idle" | "error" | "completed"; + +export interface SessionStatusInput { + status: string; + cloudStatus?: string; + pendingPermissions: { size: number }; + isPromptPending: boolean; +} + +export function deriveStatus( + session: SessionStatusInput | undefined, +): CellStatus { + if (!session) return "idle"; + + if (session.status === "error") return "error"; + if (session.cloudStatus === "failed" || session.cloudStatus === "cancelled") + return "error"; + if (session.cloudStatus === "completed") return "completed"; + + if (session.pendingPermissions.size > 0) return "waiting"; + + if (session.status === "connected" && session.isPromptPending) + return "running"; + + return "idle"; +} + +export function getRepoName(task: Task): string | null { + const repository = getTaskRepository(task); + if (!repository) return null; + const parsed = parseRepository(repository); + return parsed?.repoName ?? repository; +} + +export interface StatusSummary { + total: number; + running: number; + waiting: number; + idle: number; + error: number; + completed: number; +} + +export function buildStatusSummary( + cells: { taskId: string | null; task?: unknown; status: CellStatus }[], +): StatusSummary { + const populated = cells.filter((c) => c.taskId && c.task); + return { + total: populated.length, + running: populated.filter((c) => c.status === "running").length, + waiting: populated.filter((c) => c.status === "waiting").length, + idle: populated.filter((c) => c.status === "idle").length, + error: populated.filter((c) => c.status === "error").length, + completed: populated.filter((c) => c.status === "completed").length, + }; +} diff --git a/packages/core/src/command-center/stopAll.test.ts b/packages/core/src/command-center/stopAll.test.ts new file mode 100644 index 0000000000..b8fa2aa1d5 --- /dev/null +++ b/packages/core/src/command-center/stopAll.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import type { CellStatus } from "./status"; +import { selectStoppableTaskIds } from "./stopAll"; + +function cell(taskId: string | null, status: CellStatus) { + return { taskId, status }; +} + +describe("selectStoppableTaskIds", () => { + it("returns task ids of running and waiting cells", () => { + const ids = selectStoppableTaskIds([ + cell("a", "running"), + cell("b", "waiting"), + cell("c", "idle"), + cell("d", "error"), + cell("e", "completed"), + ]); + expect(ids).toEqual(["a", "b"]); + }); + + it("ignores cells without a task id", () => { + const ids = selectStoppableTaskIds([cell(null, "running")]); + expect(ids).toEqual([]); + }); +}); diff --git a/packages/core/src/command-center/stopAll.ts b/packages/core/src/command-center/stopAll.ts new file mode 100644 index 0000000000..27164147e1 --- /dev/null +++ b/packages/core/src/command-center/stopAll.ts @@ -0,0 +1,16 @@ +import type { CellStatus } from "./status"; + +export function selectStoppableTaskIds( + cells: { taskId: string | null; status: CellStatus }[], +): string[] { + const ids: string[] = []; + for (const cell of cells) { + if ( + cell.taskId && + (cell.status === "running" || cell.status === "waiting") + ) { + ids.push(cell.taskId); + } + } + return ids; +} diff --git a/packages/core/src/connectivity/connectivityStore.ts b/packages/core/src/connectivity/connectivityStore.ts new file mode 100644 index 0000000000..169b147d9d --- /dev/null +++ b/packages/core/src/connectivity/connectivityStore.ts @@ -0,0 +1,13 @@ +import { createStore } from "zustand/vanilla"; + +interface ConnectivityState { + isOnline: boolean; + setOnline: (isOnline: boolean) => void; +} + +export const connectivityStore = createStore((set) => ({ + isOnline: true, + setOnline: (isOnline) => set({ isOnline }), +})); + +export const getIsOnline = () => connectivityStore.getState().isOnline; diff --git a/packages/core/src/context-menu/context-menu.module.ts b/packages/core/src/context-menu/context-menu.module.ts new file mode 100644 index 0000000000..96ad134b58 --- /dev/null +++ b/packages/core/src/context-menu/context-menu.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { ContextMenuService } from "./context-menu"; +import { CONTEXT_MENU_CONTROLLER } from "./identifiers"; + +export const contextMenuCoreModule = new ContainerModule(({ bind }) => { + bind(CONTEXT_MENU_CONTROLLER).to(ContextMenuService).inSingletonScope(); +}); diff --git a/packages/core/src/context-menu/context-menu.test.ts b/packages/core/src/context-menu/context-menu.test.ts new file mode 100644 index 0000000000..036e4cffc4 --- /dev/null +++ b/packages/core/src/context-menu/context-menu.test.ts @@ -0,0 +1,188 @@ +import type { + ContextMenuAction, + ContextMenuItem, + IContextMenu, + ShowContextMenuOptions, +} from "@posthog/platform/context-menu"; +import type { ConfirmOptions, IDialog } from "@posthog/platform/dialog"; +import { describe, expect, it } from "vitest"; +import { ContextMenuService } from "./context-menu"; +import type { IContextMenuExternalApps } from "./identifiers"; +import type { TaskContextMenuInput } from "./schemas"; + +class FakeContextMenu implements IContextMenu { + lastItems: ContextMenuItem[] = []; + lastOptions?: ShowContextMenuOptions; + private shownResolve!: () => void; + readonly shown = new Promise((resolve) => { + this.shownResolve = resolve; + }); + + show(items: ContextMenuItem[], options?: ShowContextMenuOptions): void { + this.lastItems = items; + this.lastOptions = options; + this.shownResolve(); + } +} + +const noExternalApps: IContextMenuExternalApps = { + getDetectedApps: async () => [], + getLastUsed: async () => ({}), +}; + +function dialogReturning(response: number): IDialog { + return { + confirm: async (_options: ConfirmOptions) => response, + } as IDialog; +} + +function labels(items: ContextMenuItem[]): string[] { + return items + .filter((i): i is ContextMenuAction => !("separator" in i)) + .map((i) => i.label); +} + +function findItem(items: ContextMenuItem[], label: string): ContextMenuAction { + const item = items.find( + (i): i is ContextMenuAction => !("separator" in i) && i.label === label, + ); + if (!item) throw new Error(`menu item "${label}" not found`); + return item; +} + +function makeService(menu: IContextMenu, dialog: IDialog = dialogReturning(1)) { + return new ContextMenuService(noExternalApps, dialog, menu); +} + +const baseTask: TaskContextMenuInput = { + taskTitle: "Task", + isPinned: false, + isSuspended: false, + isInCommandCenter: false, + hasEmptyCommandCenterCell: true, +}; + +describe("ContextMenuService.showTaskContextMenu", () => { + it("shows Pin/Unpin based on isPinned", async () => { + const menu = new FakeContextMenu(); + const pinned = makeService(menu).showTaskContextMenu({ + ...baseTask, + isPinned: true, + }); + await menu.shown; + expect(labels(menu.lastItems)).toContain("Unpin"); + expect(labels(menu.lastItems)).not.toContain("Pin"); + findItem(menu.lastItems, "Unpin").click(); + expect(await pinned).toEqual({ action: { type: "pin" } }); + }); + + it("only offers Suspend when the task has a worktree", async () => { + const withWt = new FakeContextMenu(); + makeService(withWt).showTaskContextMenu({ + ...baseTask, + worktreePath: "/wt", + }); + await withWt.shown; + expect(labels(withWt.lastItems)).toContain("Suspend"); + + const noWt = new FakeContextMenu(); + makeService(noWt).showTaskContextMenu({ ...baseTask, folderPath: "/f" }); + await noWt.shown; + expect(labels(noWt.lastItems)).not.toContain("Suspend"); + }); + + it("labels Suspend as Unsuspend when already suspended", async () => { + const menu = new FakeContextMenu(); + makeService(menu).showTaskContextMenu({ + ...baseTask, + worktreePath: "/wt", + isSuspended: true, + }); + await menu.shown; + expect(labels(menu.lastItems)).toContain("Unsuspend"); + expect(labels(menu.lastItems)).not.toContain("Suspend"); + }); + + it("hides Add to Command Center when already in it", async () => { + const inCc = new FakeContextMenu(); + makeService(inCc).showTaskContextMenu({ + ...baseTask, + isInCommandCenter: true, + }); + await inCc.shown; + expect(labels(inCc.lastItems)).not.toContain("Add to Command Center"); + }); + + it("disables Add to Command Center when there is no empty cell", async () => { + const menu = new FakeContextMenu(); + makeService(menu).showTaskContextMenu({ + ...baseTask, + isInCommandCenter: false, + hasEmptyCommandCenterCell: false, + }); + await menu.shown; + expect(findItem(menu.lastItems, "Add to Command Center").enabled).toBe( + false, + ); + }); + + it("resolves to null when the menu is dismissed", async () => { + const menu = new FakeContextMenu(); + const result = makeService(menu).showTaskContextMenu(baseTask); + await menu.shown; + menu.lastOptions?.onDismiss?.(); + expect(await result).toEqual({ action: null }); + }); + + it("gates a confirm-protected item on dialog confirmation", async () => { + const confirmed = new FakeContextMenu(); + const okResult = makeService( + confirmed, + dialogReturning(1), + ).showTaskContextMenu(baseTask); + await confirmed.shown; + findItem(confirmed.lastItems, "Archive prior tasks").click(); + expect(await okResult).toEqual({ action: { type: "archive-prior" } }); + + const cancelled = new FakeContextMenu(); + const cancelResult = makeService( + cancelled, + dialogReturning(0), + ).showTaskContextMenu(baseTask); + await cancelled.shown; + findItem(cancelled.lastItems, "Archive prior tasks").click(); + expect(await cancelResult).toEqual({ action: null }); + }); +}); + +describe("ContextMenuService.showBulkTaskContextMenu", () => { + it("labels the archive action with the task count and gates on confirm", async () => { + const menu = new FakeContextMenu(); + const result = makeService( + menu, + dialogReturning(1), + ).showBulkTaskContextMenu({ taskCount: 3 }); + await menu.shown; + expect(labels(menu.lastItems)).toEqual(["Archive 3 tasks"]); + findItem(menu.lastItems, "Archive 3 tasks").click(); + expect(await result).toEqual({ action: { type: "archive" } }); + }); +}); + +describe("ContextMenuService.confirmDeleteTask", () => { + it("returns confirmed=true/false from the dialog response", async () => { + const menu = new FakeContextMenu(); + expect( + await makeService(menu, dialogReturning(1)).confirmDeleteTask({ + taskTitle: "x", + hasWorktree: true, + }), + ).toEqual({ confirmed: true }); + expect( + await makeService(menu, dialogReturning(0)).confirmDeleteTask({ + taskTitle: "x", + hasWorktree: false, + }), + ).toEqual({ confirmed: false }); + }); +}); diff --git a/apps/code/src/main/services/context-menu/service.ts b/packages/core/src/context-menu/context-menu.ts similarity index 94% rename from apps/code/src/main/services/context-menu/service.ts rename to packages/core/src/context-menu/context-menu.ts index 93376654c7..67ad39cfbc 100644 --- a/apps/code/src/main/services/context-menu/service.ts +++ b/packages/core/src/context-menu/context-menu.ts @@ -1,12 +1,15 @@ -import type { - ContextMenuItem, - IContextMenu, +import { + CONTEXT_MENU_SERVICE, + type ContextMenuItem, + type IContextMenu, } from "@posthog/platform/context-menu"; -import type { IDialog } from "@posthog/platform/dialog"; -import type { DetectedApplication } from "@shared/types"; +import { DIALOG_SERVICE, type IDialog } from "@posthog/platform/dialog"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { ExternalAppsService } from "../external-apps/service"; +import { + CONTEXT_MENU_EXTERNAL_APPS_SERVICE, + type ContextMenuExternalApp, + type IContextMenuExternalApps, +} from "./identifiers"; import type { ArchivedTaskAction, ArchivedTaskContextMenuInput, @@ -42,18 +45,18 @@ import type { @injectable() export class ContextMenuService { constructor( - @inject(MAIN_TOKENS.ExternalAppsService) - private readonly externalAppsService: ExternalAppsService, - @inject(MAIN_TOKENS.Dialog) + @inject(CONTEXT_MENU_EXTERNAL_APPS_SERVICE) + private readonly externalApps: IContextMenuExternalApps, + @inject(DIALOG_SERVICE) private readonly dialog: IDialog, - @inject(MAIN_TOKENS.ContextMenu) + @inject(CONTEXT_MENU_SERVICE) private readonly contextMenu: IContextMenu, ) {} private async getExternalAppsData() { const [apps, lastUsed] = await Promise.all([ - this.externalAppsService.getDetectedApps(), - this.externalAppsService.getLastUsed(), + this.externalApps.getDetectedApps(), + this.externalApps.getLastUsed(), ]); return { apps, lastUsedAppId: lastUsed.lastUsedApp }; } @@ -285,7 +288,7 @@ export class ContextMenuService { } private externalAppItems( - apps: DetectedApplication[], + apps: ContextMenuExternalApp[], lastUsedAppId?: string, ): MenuItemDef[] { if (apps.length === 0) { diff --git a/packages/core/src/context-menu/identifiers.ts b/packages/core/src/context-menu/identifiers.ts new file mode 100644 index 0000000000..c04200673e --- /dev/null +++ b/packages/core/src/context-menu/identifiers.ts @@ -0,0 +1,18 @@ +export interface ContextMenuExternalApp { + id: string; + name: string; + icon?: string; +} + +export interface IContextMenuExternalApps { + getDetectedApps(): Promise; + getLastUsed(): Promise<{ lastUsedApp?: string }>; +} + +export const CONTEXT_MENU_EXTERNAL_APPS_SERVICE = Symbol.for( + "posthog.core.contextMenuExternalAppsService", +); + +export const CONTEXT_MENU_CONTROLLER = Symbol.for( + "posthog.core.contextMenuController", +); diff --git a/apps/code/src/main/services/context-menu/schemas.ts b/packages/core/src/context-menu/schemas.ts similarity index 98% rename from apps/code/src/main/services/context-menu/schemas.ts rename to packages/core/src/context-menu/schemas.ts index 9620d3ba87..7bb23275d2 100644 --- a/apps/code/src/main/services/context-menu/schemas.ts +++ b/packages/core/src/context-menu/schemas.ts @@ -157,6 +157,9 @@ export type ConfirmDeleteWorktreeResult = z.infer< >; export type TaskContextMenuResult = z.infer; +export type BulkTaskContextMenuResult = z.infer< + typeof bulkTaskContextMenuOutput +>; export type ArchivedTaskContextMenuResult = z.infer< typeof archivedTaskContextMenuOutput >; diff --git a/apps/code/src/main/services/context-menu/types.ts b/packages/core/src/context-menu/types.ts similarity index 100% rename from apps/code/src/main/services/context-menu/types.ts rename to packages/core/src/context-menu/types.ts diff --git a/packages/core/src/deep-links/deep-links.module.ts b/packages/core/src/deep-links/deep-links.module.ts new file mode 100644 index 0000000000..de7ebb6abd --- /dev/null +++ b/packages/core/src/deep-links/deep-links.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { NEW_TASK_LINK_RESOLVER } from "./identifiers"; +import { NewTaskLinkResolver } from "./newTaskLinkResolver"; + +export const deepLinksCoreModule = new ContainerModule(({ bind }) => { + bind(NEW_TASK_LINK_RESOLVER).to(NewTaskLinkResolver).inSingletonScope(); +}); diff --git a/packages/core/src/deep-links/identifiers.ts b/packages/core/src/deep-links/identifiers.ts new file mode 100644 index 0000000000..24b32c6fdb --- /dev/null +++ b/packages/core/src/deep-links/identifiers.ts @@ -0,0 +1,66 @@ +import type { GithubRef } from "@posthog/shared"; +import type { + ANALYTICS_EVENTS, + DeepLinkIssueFailedProperties, + DeepLinkIssueProperties, + DeepLinkNewTaskProperties, + DeepLinkPlanProperties, +} from "@posthog/shared/analytics-events"; + +export const NEW_TASK_LINK_RESOLVER = Symbol.for( + "posthog.core.newTaskLinkResolver", +); + +export const GITHUB_ISSUE_CLIENT = Symbol.for("posthog.core.githubIssueClient"); + +export interface GitHubIssueClient { + getGithubIssue( + owner: string, + repo: string, + issueNumber: number, + ): Promise; +} + +export interface TaskInputNavigation { + initialPrompt?: string; + initialCloudRepository?: string; + initialModel?: string; + initialMode?: string; +} + +export type NewTaskLinkAnalytics = + | { + event: typeof ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK; + properties: DeepLinkNewTaskProperties; + } + | { + event: typeof ANALYTICS_EVENTS.DEEP_LINK_PLAN; + properties: DeepLinkPlanProperties; + } + | { + event: typeof ANALYTICS_EVENTS.DEEP_LINK_ISSUE; + properties: DeepLinkIssueProperties; + } + | { + event: typeof ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED; + properties: DeepLinkIssueFailedProperties; + }; + +export type NewTaskLinkResolution = + | { + kind: "navigate"; + navigation: TaskInputNavigation; + analytics: NewTaskLinkAnalytics; + } + | { + kind: "not_found"; + title: string; + description: string; + analytics: NewTaskLinkAnalytics; + } + | { + kind: "fetch_failed"; + title: string; + description: string; + analytics: NewTaskLinkAnalytics; + }; diff --git a/packages/core/src/deep-links/newTaskLinkResolver.test.ts b/packages/core/src/deep-links/newTaskLinkResolver.test.ts new file mode 100644 index 0000000000..b417fde5ff --- /dev/null +++ b/packages/core/src/deep-links/newTaskLinkResolver.test.ts @@ -0,0 +1,147 @@ +import type { GithubRef, NewTaskLinkPayload } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { describe, expect, it, vi } from "vitest"; +import type { GitHubIssueClient } from "./identifiers"; +import { NewTaskLinkResolver } from "./newTaskLinkResolver"; + +function makeIssue(overrides: Partial = {}): GithubRef { + return { + kind: "issue", + number: 7, + title: "Fix the bug", + state: "OPEN", + labels: [], + url: "https://github.com/acme/web/issues/7", + repo: "acme/web", + ...overrides, + }; +} + +function makeResolver( + getGithubIssue: GitHubIssueClient["getGithubIssue"], +): NewTaskLinkResolver { + return new NewTaskLinkResolver({ getGithubIssue }); +} + +describe("NewTaskLinkResolver", () => { + it("maps a new-action payload to navigation options", async () => { + const resolver = makeResolver(vi.fn()); + const payload: NewTaskLinkPayload = { + action: "new", + prompt: "do a thing", + repo: "acme/web", + model: "sonnet", + mode: "plan", + }; + + const result = await resolver.resolve(payload); + + expect(result.kind).toBe("navigate"); + if (result.kind !== "navigate") return; + expect(result.navigation).toEqual({ + initialPrompt: "do a thing", + initialCloudRepository: "acme/web", + initialModel: "sonnet", + initialMode: "plan", + }); + expect(result.analytics.event).toBe(ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK); + if (result.analytics.event !== ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK) return; + expect(result.analytics.properties.has_prompt).toBe(true); + }); + + it("uses the decoded plan as the prompt for a plan-action payload", async () => { + const resolver = makeResolver(vi.fn()); + const payload: NewTaskLinkPayload = { action: "plan", plan: "step one" }; + + const result = await resolver.resolve(payload); + + expect(result.kind).toBe("navigate"); + if (result.kind !== "navigate") return; + expect(result.navigation.initialPrompt).toBe("step one"); + expect(result.analytics.event).toBe(ANALYTICS_EVENTS.DEEP_LINK_PLAN); + if (result.analytics.event !== ANALYTICS_EVENTS.DEEP_LINK_PLAN) return; + expect(result.analytics.properties.plan_length_chars).toBe(8); + }); + + it("derives prompt, repo and labels for a found issue", async () => { + const getGithubIssue = vi + .fn() + .mockResolvedValue(makeIssue({ labels: ["bug", "p1"] })); + const resolver = makeResolver(getGithubIssue); + const payload: NewTaskLinkPayload = { + action: "issue", + url: "https://github.com/acme/web/issues/7", + owner: "acme", + issueRepo: "web", + issueNumber: 7, + }; + + const result = await resolver.resolve(payload); + + expect(getGithubIssue).toHaveBeenCalledWith("acme", "web", 7); + expect(result.kind).toBe("navigate"); + if (result.kind !== "navigate") return; + expect(result.navigation.initialPrompt).toBe( + "GitHub Issue: Fix the bug\nhttps://github.com/acme/web/issues/7\nLabels: bug, p1", + ); + expect(result.navigation.initialCloudRepository).toBe("acme/web"); + expect(result.analytics.event).toBe(ANALYTICS_EVENTS.DEEP_LINK_ISSUE); + }); + + it("prefers an explicit repo over the issue owner/repo default", async () => { + const resolver = makeResolver(vi.fn().mockResolvedValue(makeIssue())); + const payload: NewTaskLinkPayload = { + action: "issue", + url: "https://github.com/acme/web/issues/7", + owner: "acme", + issueRepo: "web", + issueNumber: 7, + repo: "acme/override", + }; + + const result = await resolver.resolve(payload); + + if (result.kind !== "navigate") throw new Error("expected navigate"); + expect(result.navigation.initialCloudRepository).toBe("acme/override"); + }); + + it("classifies a missing issue as not_found", async () => { + const resolver = makeResolver(vi.fn().mockResolvedValue(null)); + const payload: NewTaskLinkPayload = { + action: "issue", + url: "https://github.com/acme/web/issues/7", + owner: "acme", + issueRepo: "web", + issueNumber: 7, + }; + + const result = await resolver.resolve(payload); + + expect(result.kind).toBe("not_found"); + if (result.analytics.event !== ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED) + return; + expect(result.analytics.properties.reason).toBe("not_found"); + }); + + it("classifies a thrown fetch as fetch_failed and carries the message", async () => { + const resolver = makeResolver( + vi.fn().mockRejectedValue(new Error("network down")), + ); + const payload: NewTaskLinkPayload = { + action: "issue", + url: "https://github.com/acme/web/issues/7", + owner: "acme", + issueRepo: "web", + issueNumber: 7, + }; + + const result = await resolver.resolve(payload); + + expect(result.kind).toBe("fetch_failed"); + if (result.kind !== "fetch_failed") return; + expect(result.description).toBe("network down"); + if (result.analytics.event !== ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED) + return; + expect(result.analytics.properties.error_message).toBe("network down"); + }); +}); diff --git a/packages/core/src/deep-links/newTaskLinkResolver.ts b/packages/core/src/deep-links/newTaskLinkResolver.ts new file mode 100644 index 0000000000..25d0c7b7e8 --- /dev/null +++ b/packages/core/src/deep-links/newTaskLinkResolver.ts @@ -0,0 +1,148 @@ +import type { NewTaskLinkPayload } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { inject, injectable } from "inversify"; +import { + GITHUB_ISSUE_CLIENT, + type GitHubIssueClient, + NEW_TASK_LINK_RESOLVER, + type NewTaskLinkResolution, +} from "./identifiers"; + +export { NEW_TASK_LINK_RESOLVER }; + +@injectable() +export class NewTaskLinkResolver { + constructor( + @inject(GITHUB_ISSUE_CLIENT) + private readonly github: GitHubIssueClient, + ) {} + + async resolve(payload: NewTaskLinkPayload): Promise { + switch (payload.action) { + case "new": + return this.resolveNew(payload); + case "plan": + return this.resolvePlan(payload); + case "issue": + return this.resolveIssue(payload); + } + } + + private resolveNew( + payload: Extract, + ): NewTaskLinkResolution { + return { + kind: "navigate", + navigation: { + initialPrompt: payload.prompt, + initialCloudRepository: payload.repo, + initialModel: payload.model, + initialMode: payload.mode, + }, + analytics: { + event: ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK, + properties: { + has_prompt: !!payload.prompt, + has_repo: !!payload.repo, + mode: payload.mode, + model: payload.model, + }, + }, + }; + } + + private resolvePlan( + payload: Extract, + ): NewTaskLinkResolution { + return { + kind: "navigate", + navigation: { + initialPrompt: payload.plan, + initialCloudRepository: payload.repo, + initialModel: payload.model, + initialMode: payload.mode, + }, + analytics: { + event: ANALYTICS_EVENTS.DEEP_LINK_PLAN, + properties: { + has_repo: !!payload.repo, + mode: payload.mode, + model: payload.model, + plan_length_chars: payload.plan.length, + }, + }, + }; + } + + private async resolveIssue( + payload: Extract, + ): Promise { + let issue: Awaited>; + try { + issue = await this.github.getGithubIssue( + payload.owner, + payload.issueRepo, + payload.issueNumber, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + kind: "fetch_failed", + title: "Failed to fetch GitHub issue", + description: message, + analytics: { + event: ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED, + properties: { + owner: payload.owner, + repo: payload.issueRepo, + issue_number: payload.issueNumber, + reason: "fetch_failed", + error_message: message, + }, + }, + }; + } + + if (!issue) { + return { + kind: "not_found", + title: "GitHub issue not found", + description: `${payload.owner}/${payload.issueRepo}#${payload.issueNumber} could not be opened.`, + analytics: { + event: ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED, + properties: { + owner: payload.owner, + repo: payload.issueRepo, + issue_number: payload.issueNumber, + reason: "not_found", + }, + }, + }; + } + + const labelsText = + issue.labels.length > 0 ? `\nLabels: ${issue.labels.join(", ")}` : ""; + const prompt = `GitHub Issue: ${issue.title}\n${issue.url}${labelsText}`; + const cloudRepo = payload.repo ?? `${payload.owner}/${payload.issueRepo}`; + + return { + kind: "navigate", + navigation: { + initialPrompt: prompt, + initialCloudRepository: cloudRepo, + initialModel: payload.model, + initialMode: payload.mode, + }, + analytics: { + event: ANALYTICS_EVENTS.DEEP_LINK_ISSUE, + properties: { + owner: payload.owner, + repo: payload.issueRepo, + issue_number: payload.issueNumber, + mode: payload.mode, + model: payload.model, + }, + }, + }; + } +} diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts b/packages/core/src/editor/cloud-prompt.test.ts similarity index 78% rename from apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts rename to packages/core/src/editor/cloud-prompt.test.ts index 5d7131c9d7..bc5b27a720 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.test.ts +++ b/packages/core/src/editor/cloud-prompt.test.ts @@ -1,24 +1,14 @@ -import { fileURLToPath } from "node:url"; import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockFs = vi.hoisted(() => ({ - readAbsoluteFile: { query: vi.fn() }, - readFileAsBase64: { query: vi.fn() }, -})); - -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - fs: mockFs, - }, -})); - import { buildCloudPromptBlocks, buildCloudTaskDescription, serializeCloudPrompt, stripAbsoluteFileTags, -} from "./cloud-prompt"; +} from "@posthog/core/editor/cloud-prompt"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const readFileAsBase64 = vi.fn<(filePath: string) => Promise>(); function resourceLinksFrom(blocks: ContentBlock[]): string[] { return blocks.flatMap((b) => @@ -52,15 +42,15 @@ describe("cloud-prompt", () => { it("excludes folder paths from absolute attachment list", async () => { const prompt = 'scan and '; - const blocks = await buildCloudPromptBlocks(prompt, [ - "/abs/dir", - "/tmp/test.txt", - ]); + const blocks = await buildCloudPromptBlocks( + prompt, + ["/abs/dir", "/tmp/test.txt"], + readFileAsBase64, + ); const uris = resourceLinksFrom(blocks); expect(uris).toHaveLength(1); expect(uris[0]).toContain("test.txt"); - expect(mockFs.readAbsoluteFile.query).not.toHaveBeenCalled(); }); it("builds a safe cloud task description for local attachments", () => { @@ -76,6 +66,8 @@ describe("cloud-prompt", () => { it("uses resource_link path references for text attachments", async () => { const blocks = await buildCloudPromptBlocks( 'read this ', + [], + readFileAsBase64, ); expect(blocks).toEqual([ @@ -93,13 +85,16 @@ describe("cloud-prompt", () => { throw new Error("Expected a resource_link attachment block"); } - expect(fileURLToPath(attachmentBlock.uri)).toBe("/tmp/test.txt"); - expect(mockFs.readAbsoluteFile.query).not.toHaveBeenCalled(); + expect(decodeURIComponent(new URL(attachmentBlock.uri).pathname)).toBe( + "/tmp/test.txt", + ); }); it("encodes Windows drive paths as file URIs", async () => { const blocks = await buildCloudPromptBlocks( 'read ', + [], + readFileAsBase64, ); const uris = resourceLinksFrom(blocks); @@ -112,6 +107,8 @@ describe("cloud-prompt", () => { // Actual UNC path: \\server\share\My Folder\file.txt const blocks = await buildCloudPromptBlocks( 'read ', + [], + readFileAsBase64, ); const uris = resourceLinksFrom(blocks); @@ -121,10 +118,12 @@ describe("cloud-prompt", () => { it("embeds image attachments as ACP image blocks", async () => { const fakeBase64 = btoa("tiny-image-data"); - mockFs.readFileAsBase64.query.mockResolvedValue(fakeBase64); + readFileAsBase64.mockResolvedValue(fakeBase64); const blocks = await buildCloudPromptBlocks( 'check ', + [], + readFileAsBase64, ); expect(blocks).toHaveLength(2); @@ -139,60 +138,85 @@ describe("cloud-prompt", () => { it("rejects images over 5 MB", async () => { // 5 MB in base64 is ~6.67M chars; generate slightly over const oversize = "A".repeat(7_000_000); - mockFs.readFileAsBase64.query.mockResolvedValue(oversize); + readFileAsBase64.mockResolvedValue(oversize); await expect( - buildCloudPromptBlocks('see '), + buildCloudPromptBlocks( + 'see ', + [], + readFileAsBase64, + ), ).rejects.toThrow(/too large/); }); it("rejects unsupported image formats", async () => { await expect( - buildCloudPromptBlocks('see '), + buildCloudPromptBlocks( + 'see ', + [], + readFileAsBase64, + ), ).rejects.toThrow(/Unsupported image/); }); it("treats SVG attachments as text resource links", async () => { const blocks = await buildCloudPromptBlocks( 'see ', + [], + readFileAsBase64, ); expect(blocks[1]).toMatchObject({ type: "resource_link", name: "icon.svg", }); - expect(mockFs.readFileAsBase64.query).not.toHaveBeenCalled(); + expect(readFileAsBase64).not.toHaveBeenCalled(); }); it("rejects HEIC and HEIF as unsupported attachments (not images)", async () => { await expect( - buildCloudPromptBlocks('see '), + buildCloudPromptBlocks( + 'see ', + [], + readFileAsBase64, + ), ).rejects.toThrow(/Unsupported attachment/); await expect( - buildCloudPromptBlocks('see '), + buildCloudPromptBlocks( + 'see ', + [], + readFileAsBase64, + ), ).rejects.toThrow(/Unsupported attachment/); }); it("does not rely on readAbsoluteFile for txt attachments", async () => { const blocks = await buildCloudPromptBlocks( 'read ', + [], + readFileAsBase64, ); expect(blocks[1]).toMatchObject({ type: "resource_link", name: "maybe-missing-on-disk.txt", }); - expect(mockFs.readAbsoluteFile.query).not.toHaveBeenCalled(); }); it("throws when readFileAsBase64 returns falsy for images", async () => { - mockFs.readFileAsBase64.query.mockResolvedValue(null); + readFileAsBase64.mockResolvedValue(null); await expect( - buildCloudPromptBlocks('see '), + buildCloudPromptBlocks( + 'see ', + [], + readFileAsBase64, + ), ).rejects.toThrow(/Unable to read/); }); it("throws on empty prompt with no attachments", async () => { - await expect(buildCloudPromptBlocks("")).rejects.toThrow(/cannot be empty/); + await expect( + buildCloudPromptBlocks("", [], readFileAsBase64), + ).rejects.toThrow(/cannot be empty/); }); it("serializes structured prompts for pending cloud messages", () => { diff --git a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts b/packages/core/src/editor/cloud-prompt.ts similarity index 90% rename from apps/code/src/renderer/features/editor/utils/cloud-prompt.ts rename to packages/core/src/editor/cloud-prompt.ts index 079d30a1c2..6ab68831be 100644 --- a/apps/code/src/renderer/features/editor/utils/cloud-prompt.ts +++ b/packages/core/src/editor/cloud-prompt.ts @@ -1,19 +1,18 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import { CLOUD_PROMPT_PREFIX, + getFileExtension, + getFileName, getImageMimeType, + isAbsolutePath, isClaudeImageFile, isRasterImageFile, + pathToFileUri, serializeCloudPrompt, + unescapeXmlAttr, } from "@posthog/shared"; -import { trpcClient } from "@renderer/trpc/client"; -import { - getFileExtension, - getFileName, - isAbsolutePath, - pathToFileUri, -} from "@utils/path"; -import { unescapeXmlAttr } from "@utils/xml"; + +export type ReadFileAsBase64 = (filePath: string) => Promise; const ABSOLUTE_FILE_TAG_REGEX = //g; const FOLDER_TAG_REGEX = //g; @@ -158,12 +157,15 @@ export function buildCloudTaskDescription( : attachmentSummary; } -async function buildAttachmentBlock(filePath: string): Promise { +async function buildAttachmentBlock( + filePath: string, + readFileAsBase64: ReadFileAsBase64, +): Promise { const fileName = getFileName(filePath); const uri = pathToFileUri(filePath); if (isClaudeImageFile(fileName)) { - const base64 = await trpcClient.fs.readFileAsBase64.query({ filePath }); + const base64 = await readFileAsBase64(filePath); if (!base64) { throw new Error(`Unable to read attached image ${fileName}`); } @@ -194,7 +196,6 @@ async function buildAttachmentBlock(filePath: string): Promise { ); } - // Path-only: workspace text via `resource_link`; images above still embed base64. return { type: "resource_link", uri, @@ -202,16 +203,18 @@ async function buildAttachmentBlock(filePath: string): Promise { }; } -/** Test/harness prompts: text → `resource_link` only (production cloud uses uploads + artifact_ids). */ export async function buildCloudPromptBlocks( prompt: string, filePaths: string[] = [], + readFileAsBase64: ReadFileAsBase64, ): Promise { const promptText = stripAbsoluteFileTags(prompt); const attachmentPaths = getAbsoluteAttachmentPaths(prompt, filePaths); const attachmentBlocks = await Promise.all( - attachmentPaths.map(buildAttachmentBlock), + attachmentPaths.map((filePath) => + buildAttachmentBlock(filePath, readFileAsBase64), + ), ); const blocks: ContentBlock[] = []; diff --git a/apps/code/src/renderer/features/editor/utils/prompt-builder.ts b/packages/core/src/editor/prompt-builder.ts similarity index 90% rename from apps/code/src/renderer/features/editor/utils/prompt-builder.ts rename to packages/core/src/editor/prompt-builder.ts index 1367c96e30..cdcfa0f00e 100644 --- a/apps/code/src/renderer/features/editor/utils/prompt-builder.ts +++ b/packages/core/src/editor/prompt-builder.ts @@ -1,5 +1,5 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { isAbsolutePath, pathToFileUri } from "@utils/path"; +import { isAbsolutePath, pathToFileUri } from "@posthog/shared"; export async function buildPromptBlocks( textContent: string, diff --git a/packages/core/src/external-apps/external-apps.module.ts b/packages/core/src/external-apps/external-apps.module.ts new file mode 100644 index 0000000000..5e72c6ce5c --- /dev/null +++ b/packages/core/src/external-apps/external-apps.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { ExternalAppService } from "./externalAppService"; +import { EXTERNAL_APPS_SERVICE } from "./identifiers"; + +export const externalAppsCoreModule = new ContainerModule(({ bind }) => { + bind(EXTERNAL_APPS_SERVICE).to(ExternalAppService).inSingletonScope(); +}); diff --git a/packages/core/src/external-apps/externalAppService.test.ts b/packages/core/src/external-apps/externalAppService.test.ts new file mode 100644 index 0000000000..71afb146ca --- /dev/null +++ b/packages/core/src/external-apps/externalAppService.test.ts @@ -0,0 +1,154 @@ +import type { Workspace } from "@posthog/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ExternalAppService } from "./externalAppService"; +import type { + ExternalAppsFocusCoordinator, + ExternalAppsWorkspaceClient, +} from "./identifiers"; + +function makeClient(): { + [K in keyof ExternalAppsWorkspaceClient]: ReturnType; +} { + return { + openInApp: vi.fn().mockResolvedValue({ success: true }), + setLastUsed: vi.fn().mockResolvedValue(undefined), + getDetectedApps: vi + .fn() + .mockResolvedValue([{ id: "vscode", name: "VS Code" }]), + copyPath: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeFocus(): { + [K in keyof ExternalAppsFocusCoordinator]: ReturnType; +} { + return { + getSession: vi.fn().mockReturnValue(null), + enableFocus: vi.fn(), + }; +} + +const worktreeWorkspace: Workspace = { + mode: "worktree", + branchName: "feature", + worktreePath: "/wt/feature", + folderPath: "/repo", +} as unknown as Workspace; + +describe("ExternalAppService.openExternalApp", () => { + let client: ReturnType; + let focus: ReturnType; + let service: ExternalAppService; + + beforeEach(() => { + client = makeClient(); + focus = makeFocus(); + service = new ExternalAppService( + client as unknown as ExternalAppsWorkspaceClient, + focus as unknown as ExternalAppsFocusCoordinator, + ); + }); + + it("opens the file, records last-used, and resolves the app name", async () => { + const outcome = await service.openExternalApp( + { type: "open-in-app", appId: "vscode" }, + "/repo/file.ts", + "file.ts", + ); + + expect(client.openInApp).toHaveBeenCalledWith("vscode", "/repo/file.ts"); + expect(client.setLastUsed).toHaveBeenCalledWith("vscode"); + expect(outcome).toEqual({ + kind: "opened", + appName: "VS Code", + displayName: "file.ts", + focus: undefined, + }); + }); + + it("returns open-failed without recording last-used when openInApp fails", async () => { + client.openInApp.mockResolvedValue({ success: false, error: "no app" }); + + const outcome = await service.openExternalApp( + { type: "open-in-app", appId: "vscode" }, + "/repo/file.ts", + "file.ts", + ); + + expect(client.setLastUsed).not.toHaveBeenCalled(); + expect(outcome).toEqual({ kind: "open-failed", error: "no app" }); + }); + + it("copies the path for a copy-path action", async () => { + const outcome = await service.openExternalApp( + { type: "copy-path" }, + "/repo/file.ts", + "file.ts", + ); + + expect(client.copyPath).toHaveBeenCalledWith("/repo/file.ts"); + expect(client.openInApp).not.toHaveBeenCalled(); + expect(outcome).toEqual({ kind: "copied", filePath: "/repo/file.ts" }); + }); + + it("rebases the path onto the main repo when already focused", async () => { + focus.getSession.mockReturnValue({ worktreePath: "/wt/feature" }); + + await service.openExternalApp( + { type: "open-in-app", appId: "vscode" }, + "/wt/feature/src/a.ts", + "a.ts", + { workspace: worktreeWorkspace, mainRepoPath: "/repo" }, + ); + + expect(focus.enableFocus).not.toHaveBeenCalled(); + expect(client.openInApp).toHaveBeenCalledWith("vscode", "/repo/src/a.ts"); + }); + + it("runs the focus saga as a precondition then rebases the path", async () => { + focus.getSession.mockReturnValue(null); + focus.enableFocus.mockResolvedValue({ + success: true, + session: { mainStashRef: null }, + wasSwap: false, + }); + + const outcome = await service.openExternalApp( + { type: "open-in-app", appId: "vscode" }, + "/wt/feature/src/a.ts", + "a.ts", + { workspace: worktreeWorkspace, mainRepoPath: "/repo" }, + ); + + expect(focus.enableFocus).toHaveBeenCalledWith({ + mainRepoPath: "/repo", + worktreePath: "/wt/feature", + branch: "feature", + }); + expect(client.openInApp).toHaveBeenCalledWith("vscode", "/repo/src/a.ts"); + expect(outcome).toMatchObject({ + kind: "opened", + focus: { branchName: "feature" }, + }); + }); + + it("returns focus-failed and does not open when the focus saga fails", async () => { + focus.getSession.mockReturnValue(null); + focus.enableFocus.mockResolvedValue({ + success: false, + error: "dirty", + session: null, + wasSwap: false, + }); + + const outcome = await service.openExternalApp( + { type: "open-in-app", appId: "vscode" }, + "/wt/feature/src/a.ts", + "a.ts", + { workspace: worktreeWorkspace, mainRepoPath: "/repo" }, + ); + + expect(client.openInApp).not.toHaveBeenCalled(); + expect(outcome).toEqual({ kind: "focus-failed", error: "dirty" }); + }); +}); diff --git a/packages/core/src/external-apps/externalAppService.ts b/packages/core/src/external-apps/externalAppService.ts new file mode 100644 index 0000000000..a7679dd0c4 --- /dev/null +++ b/packages/core/src/external-apps/externalAppService.ts @@ -0,0 +1,148 @@ +import type { Workspace } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import type { ExternalAppAction } from "../context-menu/schemas"; +import type { FocusSagaResult } from "../focus/service"; +import { + EXTERNAL_APPS_FOCUS_COORDINATOR, + EXTERNAL_APPS_WORKSPACE_CLIENT, + type ExternalAppsFocusCoordinator, + type ExternalAppsWorkspaceClient, +} from "./identifiers"; + +export interface ExternalAppWorkspaceContext { + workspace: Workspace | null; + mainRepoPath?: string; +} + +export type ExternalAppActionOutcome = + | { + kind: "opened"; + appName: string; + displayName: string; + focus?: { branchName: string; result: FocusSagaResult }; + } + | { kind: "open-failed"; error: string } + | { kind: "focus-failed"; error: string } + | { kind: "copied"; filePath: string }; + +interface EnsureFocusResult { + effectivePath: string; + focus?: { branchName: string; result: FocusSagaResult }; + blockingError?: string; +} + +@injectable() +export class ExternalAppService { + constructor( + @inject(EXTERNAL_APPS_WORKSPACE_CLIENT) + private readonly client: ExternalAppsWorkspaceClient, + @inject(EXTERNAL_APPS_FOCUS_COORDINATOR) + private readonly focus: ExternalAppsFocusCoordinator, + ) {} + + async openExternalApp( + action: ExternalAppAction, + filePath: string, + displayName: string, + workspaceContext?: ExternalAppWorkspaceContext, + ): Promise { + if (action.type === "copy-path") { + await this.client.copyPath(filePath); + return { kind: "copied", filePath }; + } + + const focusResult = await this.ensureWorkspaceFocused( + filePath, + workspaceContext, + ); + if (focusResult.blockingError) { + return { kind: "focus-failed", error: focusResult.blockingError }; + } + + const openResult = await this.client.openInApp( + action.appId, + focusResult.effectivePath, + ); + if (!openResult.success) { + return { + kind: "open-failed", + error: openResult.error || "Unknown error", + }; + } + + await this.client.setLastUsed(action.appId); + const apps = await this.client.getDetectedApps(); + const app = apps.find((a) => a.id === action.appId); + + return { + kind: "opened", + appName: app?.name || "external app", + displayName, + focus: focusResult.focus, + }; + } + + private async ensureWorkspaceFocused( + filePath: string, + workspaceContext?: ExternalAppWorkspaceContext, + ): Promise { + const workspace = workspaceContext?.workspace; + if (!workspace) { + return { effectivePath: filePath }; + } + + const { mainRepoPath } = workspaceContext; + if ( + workspace.mode !== "worktree" || + !workspace.branchName || + !workspace.worktreePath + ) { + return { effectivePath: filePath }; + } + + const session = this.focus.getSession(); + const isAlreadyFocused = session?.worktreePath === workspace.worktreePath; + + if (!mainRepoPath) { + return { effectivePath: filePath }; + } + + if (isAlreadyFocused) { + return { + effectivePath: this.rebasePath( + filePath, + workspace.worktreePath, + mainRepoPath, + ), + }; + } + + const result = await this.focus.enableFocus({ + mainRepoPath: workspace.folderPath, + worktreePath: workspace.worktreePath, + branch: workspace.branchName, + }); + + if (!result.success) { + return { effectivePath: filePath, blockingError: result.error }; + } + + return { + effectivePath: this.rebasePath( + filePath, + workspace.worktreePath, + mainRepoPath, + ), + focus: { branchName: workspace.branchName, result }, + }; + } + + private rebasePath( + filePath: string, + worktreePath: string, + mainRepoPath: string, + ): string { + const relativePath = filePath.replace(worktreePath, ""); + return `${mainRepoPath}${relativePath}`; + } +} diff --git a/packages/core/src/external-apps/identifiers.ts b/packages/core/src/external-apps/identifiers.ts new file mode 100644 index 0000000000..1669157f99 --- /dev/null +++ b/packages/core/src/external-apps/identifiers.ts @@ -0,0 +1,45 @@ +import type { FocusSagaResult } from "../focus/service"; + +export const EXTERNAL_APPS_SERVICE = Symbol.for( + "posthog.core.externalAppsService", +); + +export const EXTERNAL_APPS_WORKSPACE_CLIENT = Symbol.for( + "posthog.core.externalAppsWorkspaceClient", +); + +export const EXTERNAL_APPS_FOCUS_COORDINATOR = Symbol.for( + "posthog.core.externalAppsFocusCoordinator", +); + +export interface ExternalAppsDetectedApp { + id: string; + name: string; +} + +export interface ExternalAppsOpenResult { + success: boolean; + error?: string; +} + +export interface ExternalAppsWorkspaceClient { + openInApp(appId: string, targetPath: string): Promise; + setLastUsed(appId: string): Promise; + getDetectedApps(): Promise; + copyPath(targetPath: string): Promise; +} + +export interface ExternalAppsFocusSession { + worktreePath: string; +} + +export interface ExternalAppsFocusParams { + mainRepoPath: string; + worktreePath: string; + branch: string; +} + +export interface ExternalAppsFocusCoordinator { + getSession(): ExternalAppsFocusSession | null; + enableFocus(params: ExternalAppsFocusParams): Promise; +} diff --git a/packages/core/src/focus/focus-host.module.ts b/packages/core/src/focus/focus-host.module.ts new file mode 100644 index 0000000000..2a72f6480a --- /dev/null +++ b/packages/core/src/focus/focus-host.module.ts @@ -0,0 +1,8 @@ +import { ContainerModule } from "inversify"; +import { FocusHostService } from "./focus-service"; +import { FOCUS_SERVICE } from "./identifiers"; + +export const focusHostModule = new ContainerModule(({ bind }) => { + bind(FocusHostService).toSelf().inSingletonScope(); + bind(FOCUS_SERVICE).toService(FocusHostService); +}); diff --git a/packages/core/src/focus/focus-service.ts b/packages/core/src/focus/focus-service.ts new file mode 100644 index 0000000000..515374e279 --- /dev/null +++ b/packages/core/src/focus/focus-service.ts @@ -0,0 +1,186 @@ +import { TypedEventEmitter } from "@posthog/shared"; +import type { FocusBranchRenamedEvent } from "@posthog/workspace-client/types"; +import { inject, injectable } from "inversify"; +import { + FOCUS_SESSION_STORE, + FOCUS_WORKSPACE_CLIENT, + FOCUS_WORKTREE_PATHS, + type FocusSessionStore, + type FocusWorkspaceClient, + type FocusWorktreePaths, +} from "./host-focus"; +import { + type FocusResult, + FocusServiceEvent, + type FocusServiceEvents, + type FocusSession, + type IFocusService, + type StashResult, +} from "./identifiers"; + +@injectable() +export class FocusHostService + extends TypedEventEmitter + implements IFocusService +{ + constructor( + @inject(FOCUS_WORKSPACE_CLIENT) + private readonly workspaceClient: FocusWorkspaceClient, + @inject(FOCUS_SESSION_STORE) + private readonly store: FocusSessionStore, + @inject(FOCUS_WORKTREE_PATHS) + private readonly paths: FocusWorktreePaths, + ) { + super(); + this.focus.onBranchRenamed.subscribe(undefined, { + onData: (event) => { + void this.handleBranchRenamed(event); + }, + onError: () => {}, + }); + this.focus.onForeignBranchCheckout.subscribe(undefined, { + onData: (event) => { + this.emit(FocusServiceEvent.ForeignBranchCheckout, event); + }, + onError: () => {}, + }); + } + + private get focus() { + return this.workspaceClient.focus; + } + + getSession(mainRepoPath: string): FocusSession | null { + return this.store.getSession(mainRepoPath); + } + + async saveSession(session: FocusSession): Promise { + this.store.saveSession(session); + await this.focus.saveSession.mutate(session); + } + + async deleteSession(mainRepoPath: string): Promise { + this.store.deleteSession(mainRepoPath); + await this.focus.deleteSession.mutate({ mainRepoPath }); + } + + isFocusActive(mainRepoPath: string): boolean { + return this.getSession(mainRepoPath) !== null; + } + + validateFocusOperation( + currentBranch: string | null, + targetBranch: string, + ): string | null { + if (!currentBranch) { + return "Cannot focus: main repo is in detached HEAD state."; + } + if (currentBranch === targetBranch) { + return `Cannot focus: already on branch "${targetBranch}".`; + } + return null; + } + + async getCommitSha(repoPath: string): Promise { + return await this.focus.getCommitSha.query({ repoPath }); + } + + async findWorktreeByBranch( + mainRepoPath: string, + branch: string, + ): Promise { + return await this.focus.findWorktreeByBranch.query({ + mainRepoPath, + branch, + }); + } + + toRelativeWorktreePath(absolutePath: string, mainRepoPath: string): string { + return this.paths.toRelativeWorktreePath(absolutePath, mainRepoPath); + } + + toAbsoluteWorktreePath(relativePath: string): string { + return this.paths.toAbsoluteWorktreePath(relativePath); + } + + async worktreeExistsAtPath(relativePath: string): Promise { + return await this.paths.worktreeExistsAtPath(relativePath); + } + + async cleanWorkingTree(repoPath: string): Promise { + await this.focus.cleanWorkingTree.mutate({ repoPath }); + } + + async detachWorktree(worktreePath: string): Promise { + return await this.focus.detachWorktree.mutate({ worktreePath }); + } + + async reattachWorktree( + worktreePath: string, + branchName: string, + ): Promise { + return await this.focus.reattachWorktree.mutate({ + worktreePath, + branch: branchName, + }); + } + + async isDirty(repoPath: string): Promise { + return await this.focus.isDirty.query({ repoPath }); + } + + async stash(repoPath: string, message: string): Promise { + return await this.focus.stash.mutate({ repoPath, message }); + } + + async stashApply(repoPath: string, stashRef: string): Promise { + return await this.focus.stashApply.mutate({ repoPath, stashRef }); + } + + async stashPop(repoPath: string): Promise { + return await this.focus.stashPop.mutate({ repoPath }); + } + + async checkout(repoPath: string, branch: string): Promise { + return await this.focus.checkout.mutate({ repoPath, branch }); + } + + async startSync(mainRepoPath: string, worktreePath: string): Promise { + await this.focus.startSync.mutate({ mainRepoPath, worktreePath }); + } + + async stopSync(): Promise { + await this.focus.stopSync.mutate(); + } + + async startWatchingMainRepo(mainRepoPath: string): Promise { + await this.focus.startWatchingMainRepo.mutate({ mainRepoPath }); + } + + async stopWatchingMainRepo(): Promise { + await this.focus.stopWatchingMainRepo.mutate(); + } + + private async handleBranchRenamed( + event: FocusBranchRenamedEvent, + ): Promise { + const remoteSession = await this.focus.getSession + .query({ mainRepoPath: event.mainRepoPath }) + .catch(() => null); + const localSession = this.getSession(event.mainRepoPath); + const sessionToPersist = + remoteSession ?? + (localSession + ? { + ...localSession, + branch: event.newBranch, + } + : null); + + if (sessionToPersist) { + this.store.saveSession(sessionToPersist); + } + + this.emit(FocusServiceEvent.BranchRenamed, event); + } +} diff --git a/packages/core/src/focus/host-focus.ts b/packages/core/src/focus/host-focus.ts new file mode 100644 index 0000000000..304bef5a26 --- /dev/null +++ b/packages/core/src/focus/host-focus.ts @@ -0,0 +1,26 @@ +import type { WorkspaceClient } from "@posthog/workspace-client/client"; +import type { FocusSession } from "./identifiers"; + +export const FOCUS_WORKSPACE_CLIENT = Symbol.for( + "posthog.core.focusWorkspaceClient", +); +export const FOCUS_SESSION_STORE = Symbol.for("posthog.core.focusSessionStore"); +export const FOCUS_WORKTREE_PATHS = Symbol.for( + "posthog.core.focusWorktreePaths", +); + +export interface FocusWorkspaceClient { + focus: WorkspaceClient["focus"]; +} + +export interface FocusSessionStore { + getSession(mainRepoPath: string): FocusSession | null; + saveSession(session: FocusSession): void; + deleteSession(mainRepoPath: string): void; +} + +export interface FocusWorktreePaths { + toRelativeWorktreePath(absolutePath: string, mainRepoPath: string): string; + toAbsoluteWorktreePath(relativePath: string): string; + worktreeExistsAtPath(relativePath: string): Promise; +} diff --git a/packages/core/src/focus/identifiers.ts b/packages/core/src/focus/identifiers.ts new file mode 100644 index 0000000000..5f49dd11be --- /dev/null +++ b/packages/core/src/focus/identifiers.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +export const FOCUS_SERVICE = Symbol.for("posthog.core.focusService"); + +export const focusResultSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), + stashPopWarning: z.string().optional(), +}); + +export type FocusResult = z.infer; + +export const stashResultSchema = focusResultSchema.extend({ + stashRef: z.string().optional(), +}); + +export type StashResult = z.infer; + +export const focusSessionSchema = z.object({ + mainRepoPath: z.string(), + worktreePath: z.string(), + branch: z.string(), + originalBranch: z.string(), + mainStashRef: z.string().nullable(), + commitSha: z.string(), +}); + +export type FocusSession = z.infer; + +export const repoPathInput = z.object({ repoPath: z.string() }); +export const mainRepoPathInput = z.object({ mainRepoPath: z.string() }); +export const stashInput = z.object({ + repoPath: z.string(), + message: z.string(), +}); +export const checkoutInput = z.object({ + repoPath: z.string(), + branch: z.string(), +}); +export const worktreeInput = z.object({ worktreePath: z.string() }); +export const reattachInput = z.object({ + worktreePath: z.string(), + branch: z.string(), +}); +export const syncInput = z.object({ + mainRepoPath: z.string(), + worktreePath: z.string(), +}); +export const findWorktreeInput = z.object({ + mainRepoPath: z.string(), + branch: z.string(), +}); + +export const FocusServiceEvent = { + BranchRenamed: "branchRenamed", + ForeignBranchCheckout: "foreignBranchCheckout", +} as const; + +export interface FocusServiceEvents { + [FocusServiceEvent.BranchRenamed]: { + mainRepoPath: string; + worktreePath: string; + oldBranch: string; + newBranch: string; + }; + [FocusServiceEvent.ForeignBranchCheckout]: { + mainRepoPath: string; + worktreePath: string; + focusedBranch: string; + foreignBranch: string; + }; +} + +export interface IFocusService { + getSession(mainRepoPath: string): FocusSession | null; + saveSession(session: FocusSession): Promise; + deleteSession(mainRepoPath: string): Promise; + isFocusActive(mainRepoPath: string): boolean; + validateFocusOperation( + currentBranch: string | null, + targetBranch: string, + ): string | null; + isDirty(repoPath: string): Promise; + getCommitSha(repoPath: string): Promise; + findWorktreeByBranch( + mainRepoPath: string, + branch: string, + ): Promise; + toRelativeWorktreePath(absolutePath: string, mainRepoPath: string): string; + toAbsoluteWorktreePath(relativePath: string): string; + worktreeExistsAtPath(relativePath: string): Promise; + stash(repoPath: string, message: string): Promise; + stashPop(repoPath: string): Promise; + stashApply(repoPath: string, stashRef: string): Promise; + checkout(repoPath: string, branch: string): Promise; + detachWorktree(worktreePath: string): Promise; + reattachWorktree(worktreePath: string, branch: string): Promise; + cleanWorkingTree(repoPath: string): Promise; + startSync(mainRepoPath: string, worktreePath: string): Promise; + stopSync(): Promise; + startWatchingMainRepo(mainRepoPath: string): Promise; + stopWatchingMainRepo(): Promise; + toIterable( + event: K, + options: { signal?: AbortSignal }, + ): AsyncIterable; +} diff --git a/packages/core/src/focus/service.test.ts b/packages/core/src/focus/service.test.ts new file mode 100644 index 0000000000..89086592c5 --- /dev/null +++ b/packages/core/src/focus/service.test.ts @@ -0,0 +1,339 @@ +import type { + FocusResult, + FocusSession, + StashResult, +} from "@posthog/workspace-client/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type EnableFocusParams, + FocusController, + type FocusControllerDeps, +} from "./service"; + +const MAIN_REPO = "/repo/main"; +const WORKTREE = "/repo/worktrees/feature"; +const OTHER_WORKTREE = "/repo/worktrees/other"; + +const ok: FocusResult = { success: true }; + +function createSession(overrides: Partial = {}): FocusSession { + return { + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + branch: "feature", + originalBranch: "main", + mainStashRef: null, + commitSha: "sha-main", + ...overrides, + }; +} + +function createParams( + overrides: Partial = {}, +): EnableFocusParams { + return { + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + branch: "feature", + ...overrides, + }; +} + +type Deps = { + [K in keyof FocusControllerDeps]: ReturnType; +} & FocusControllerDeps; + +function createDeps(overrides: Partial = {}): Deps { + const stashResult: StashResult = { success: true, stashRef: "stash@{0}" }; + const deps: FocusControllerDeps = { + cancelSessionPrompt: vi.fn(async () => {}), + checkout: vi.fn(async () => ok), + cleanWorkingTree: vi.fn(async () => {}), + deleteSession: vi.fn(async () => {}), + detachWorktree: vi.fn(async () => ok), + getCommitSha: vi.fn(async () => "sha-main"), + getCurrentBranch: vi.fn(async () => "main"), + getSession: vi.fn(async () => null), + isDirty: vi.fn(async () => false), + listLocalTaskIds: vi.fn(async () => []), + listSessionIds: vi.fn(async () => []), + listWorktreeTaskIds: vi.fn(async () => []), + notifySessionContext: vi.fn(async () => {}), + reattachWorktree: vi.fn(async () => ok), + saveSession: vi.fn(async () => {}), + stash: vi.fn(async () => stashResult), + stashApply: vi.fn(async () => ok), + startSync: vi.fn(async () => {}), + startWatchingMainRepo: vi.fn(async () => {}), + stopSync: vi.fn(async () => {}), + stopWatchingMainRepo: vi.fn(async () => {}), + toRelativeWorktreePath: vi.fn(async (absolutePath: string) => absolutePath), + worktreeExistsAtPath: vi.fn(async () => true), + ...overrides, + }; + return deps as Deps; +} + +describe("FocusController.enableFocus", () => { + let deps: Deps; + let controller: FocusController; + + beforeEach(() => { + deps = createDeps(); + controller = new FocusController(deps); + }); + + it("focuses a clean repo without stashing", async () => { + const result = await controller.enableFocus(createParams(), null); + + expect(result.success).toBe(true); + expect(deps.stash).not.toHaveBeenCalled(); + expect(result.session?.mainStashRef).toBeNull(); + }); + + it("runs the host steps in dependency order on the happy path", async () => { + await controller.enableFocus(createParams(), null); + + expect(deps.detachWorktree).toHaveBeenCalledWith(WORKTREE); + expect(deps.checkout).toHaveBeenCalledWith(MAIN_REPO, "feature"); + expect(deps.startSync).toHaveBeenCalledWith(MAIN_REPO, WORKTREE); + expect(deps.startWatchingMainRepo).toHaveBeenCalledWith(MAIN_REPO); + }); + + it("persists a session derived from the current branch and commit", async () => { + deps.getCurrentBranch.mockResolvedValue("main"); + deps.getCommitSha.mockResolvedValue("sha-xyz"); + + const result = await controller.enableFocus(createParams(), null); + + expect(result.session).toEqual({ + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + branch: "feature", + originalBranch: "main", + mainStashRef: null, + commitSha: "sha-xyz", + }); + expect(deps.saveSession).toHaveBeenCalledWith(result.session); + }); + + it("stashes dirty changes and records the stash ref on the session", async () => { + deps.isDirty.mockResolvedValue(true); + + const result = await controller.enableFocus(createParams(), null); + + expect(deps.stash).toHaveBeenCalledTimes(1); + expect(result.session?.mainStashRef).toBe("stash@{0}"); + }); + + it("returns the existing session without re-running when already focused", async () => { + const current = createSession(); + + const result = await controller.enableFocus(createParams(), current); + + expect(result).toEqual({ success: true, session: current, wasSwap: false }); + expect(deps.detachWorktree).not.toHaveBeenCalled(); + }); + + it("swaps focus by unfocusing the current session first", async () => { + const current = createSession({ worktreePath: OTHER_WORKTREE }); + + const result = await controller.enableFocus(createParams(), current); + + expect(result.success).toBe(true); + expect(result.wasSwap).toBe(true); + // unfocus reattaches the previously focused worktree before the new focus. + expect(deps.reattachWorktree).toHaveBeenCalledWith( + OTHER_WORKTREE, + "feature", + ); + }); + + it("fails when the main repo is in detached HEAD state", async () => { + deps.getCurrentBranch.mockResolvedValue(null); + + const result = await controller.enableFocus(createParams(), null); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/detached HEAD/i); + expect(deps.detachWorktree).not.toHaveBeenCalled(); + }); + + it("fails when already on the target branch", async () => { + deps.getCurrentBranch.mockResolvedValue("feature"); + + const result = await controller.enableFocus(createParams(), null); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/already on branch "feature"/); + }); + + it("translates a checkout-overwrite failure into an actionable message", async () => { + deps.checkout.mockResolvedValue({ + success: false, + error: "error: Your local changes would be overwritten by checkout", + }); + + const result = await controller.enableFocus(createParams(), null); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/uncommitted changes would be overwritten/); + }); + + it("rolls back stash and worktree detach when checkout fails", async () => { + deps.isDirty.mockResolvedValue(true); + deps.checkout.mockResolvedValue({ success: false, error: "boom" }); + + const result = await controller.enableFocus(createParams(), null); + + expect(result.success).toBe(false); + // detach_worktree rollback reattaches; stash_dirty_changes rollback re-applies. + expect(deps.reattachWorktree).toHaveBeenCalledWith(WORKTREE, "feature"); + expect(deps.stashApply).toHaveBeenCalledWith(MAIN_REPO, "stash@{0}"); + }); + + it("fails and does not detach when stashing dirty changes fails", async () => { + deps.isDirty.mockResolvedValue(true); + deps.stash.mockResolvedValue({ success: false, error: "stash failed" }); + + const result = await controller.enableFocus(createParams(), null); + + expect(result.success).toBe(false); + expect(deps.detachWorktree).not.toHaveBeenCalled(); + }); +}); + +describe("FocusController.disableFocus", () => { + let deps: Deps; + let controller: FocusController; + + beforeEach(() => { + deps = createDeps(); + controller = new FocusController(deps); + }); + + it("restores the original branch and reattaches the worktree", async () => { + const result = await controller.disableFocus(createSession()); + + expect(result.success).toBe(true); + expect(deps.checkout).toHaveBeenCalledWith(MAIN_REPO, "main"); + expect(deps.reattachWorktree).toHaveBeenCalledWith(WORKTREE, "feature"); + expect(deps.deleteSession).toHaveBeenCalledWith(MAIN_REPO); + }); + + it("does not warn when there was no stash to restore", async () => { + const result = await controller.disableFocus(createSession()); + + expect(result).toEqual({ success: true, stashPopWarning: undefined }); + expect(deps.stashApply).not.toHaveBeenCalled(); + }); + + it("re-applies a recorded stash on disable", async () => { + const result = await controller.disableFocus( + createSession({ mainStashRef: "stash@{2}" }), + ); + + expect(deps.stashApply).toHaveBeenCalledWith(MAIN_REPO, "stash@{2}"); + expect(result.success && result.stashPopWarning).toBeUndefined(); + }); + + it("surfaces a recoverable warning when stash apply fails", async () => { + deps.stashApply.mockResolvedValue({ success: false, error: "conflict" }); + + const result = await controller.disableFocus( + createSession({ mainStashRef: "stash@{2}" }), + ); + + expect(result.success).toBe(true); + expect(result.success && result.stashPopWarning).toMatch(/stash@\{2\}/); + }); + + it("fails and rolls back when reattaching the worktree fails", async () => { + deps.reattachWorktree.mockResolvedValue({ + success: false, + error: "locked", + }); + + const result = await controller.disableFocus(createSession()); + + expect(result.success).toBe(false); + // checkout_original_branch rollback restores the focused branch. + expect(deps.checkout).toHaveBeenCalledWith(MAIN_REPO, "feature"); + }); +}); + +describe("FocusController.restore", () => { + let deps: Deps; + let controller: FocusController; + + beforeEach(() => { + deps = createDeps(); + controller = new FocusController(deps); + }); + + it("returns null when there is no persisted session", async () => { + deps.getSession.mockResolvedValue(null); + + expect(await controller.restore(MAIN_REPO)).toBeNull(); + expect(deps.startWatchingMainRepo).not.toHaveBeenCalled(); + }); + + it("discards a session whose original branch equals its focused branch", async () => { + deps.getSession.mockResolvedValue( + createSession({ branch: "main", originalBranch: "main" }), + ); + + expect(await controller.restore(MAIN_REPO)).toBeNull(); + expect(deps.deleteSession).toHaveBeenCalledWith(MAIN_REPO); + }); + + it("discards a session whose worktree no longer exists", async () => { + deps.getSession.mockResolvedValue(createSession()); + deps.worktreeExistsAtPath.mockResolvedValue(false); + + expect(await controller.restore(MAIN_REPO)).toBeNull(); + expect(deps.deleteSession).toHaveBeenCalledWith(MAIN_REPO); + }); + + it("discards a session when the main repo is in detached HEAD", async () => { + deps.getSession.mockResolvedValue(createSession()); + deps.getCurrentBranch.mockResolvedValue(null); + + expect(await controller.restore(MAIN_REPO)).toBeNull(); + expect(deps.deleteSession).toHaveBeenCalledWith(MAIN_REPO); + }); + + it("restores and starts syncing when the focused branch is still checked out", async () => { + const session = createSession(); + deps.getSession.mockResolvedValue(session); + deps.getCurrentBranch.mockResolvedValue("feature"); + + const result = await controller.restore(MAIN_REPO); + + expect(result).toEqual(session); + expect(deps.startSync).toHaveBeenCalledWith(MAIN_REPO, WORKTREE); + expect(deps.startWatchingMainRepo).toHaveBeenCalledWith(MAIN_REPO); + }); + + it("adopts a renamed branch when the commit still matches the session", async () => { + deps.getSession.mockResolvedValue(createSession({ commitSha: "sha-keep" })); + deps.getCurrentBranch.mockResolvedValue("feature-renamed"); + deps.getCommitSha.mockResolvedValue("sha-keep"); + + const result = await controller.restore(MAIN_REPO); + + expect(result?.branch).toBe("feature-renamed"); + expect(deps.saveSession).toHaveBeenCalledWith( + expect.objectContaining({ branch: "feature-renamed" }), + ); + }); + + it("discards a session when the branch changed and the commit diverged", async () => { + deps.getSession.mockResolvedValue(createSession({ commitSha: "sha-old" })); + deps.getCurrentBranch.mockResolvedValue("some-other-branch"); + deps.getCommitSha.mockResolvedValue("sha-new"); + + expect(await controller.restore(MAIN_REPO)).toBeNull(); + expect(deps.deleteSession).toHaveBeenCalledWith(MAIN_REPO); + }); +}); diff --git a/packages/core/src/focus/service.ts b/packages/core/src/focus/service.ts index d388ba29db..4d7fdc8211 100644 --- a/packages/core/src/focus/service.ts +++ b/packages/core/src/focus/service.ts @@ -492,8 +492,8 @@ class FocusRestoreSaga extends Saga< return null; } - // PORT NOTE: restore explicitly re-saves the validated session so the - // workspace-server watcher has the current in-memory session before startWatchingMainRepo. + // restore explicitly re-saves the validated session so the workspace-server + // watcher has the current in-memory session before startWatchingMainRepo. await this.readOnlyStep("save_session", () => this.deps.saveSession(validatedSession), ); diff --git a/apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts b/packages/core/src/git-interaction/branchCreation.test.ts similarity index 65% rename from apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts rename to packages/core/src/git-interaction/branchCreation.test.ts index 90ba900ef1..311f082bc4 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts +++ b/packages/core/src/git-interaction/branchCreation.test.ts @@ -1,28 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { mockCreateBranchMutate, mockInvalidateGitBranchQueries } = vi.hoisted( - () => ({ - mockCreateBranchMutate: vi.fn(), - mockInvalidateGitBranchQueries: vi.fn(), - }), -); - -vi.mock("@renderer/trpc", () => ({ - trpcClient: { - git: { - createBranch: { - mutate: mockCreateBranchMutate, - }, - }, - }, -})); - -vi.mock("@features/git-interaction/utils/gitCacheKeys", () => ({ - invalidateGitBranchQueries: mockInvalidateGitBranchQueries, -})); - import { createBranch, getBranchNameInputState } from "./branchCreation"; +const mockCreateBranch = vi.fn(); +const writeClient = { + createBranch: mockCreateBranch, +}; + describe("branchCreation", () => { beforeEach(() => { vi.clearAllMocks(); @@ -47,6 +30,7 @@ describe("branchCreation", () => { describe("createBranch", () => { it("returns missing-repo error when repo path is not provided", async () => { const result = await createBranch({ + writeClient, repoPath: undefined, rawBranchName: "feature/test", }); @@ -56,12 +40,12 @@ describe("branchCreation", () => { error: "Select a repository folder first.", reason: "missing-repo", }); - expect(mockCreateBranchMutate).not.toHaveBeenCalled(); - expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + expect(mockCreateBranch).not.toHaveBeenCalled(); }); it("returns validation error for empty branch name", async () => { const result = await createBranch({ + writeClient, repoPath: "/repo", rawBranchName: " ", }); @@ -71,12 +55,12 @@ describe("branchCreation", () => { error: "Branch name is required.", reason: "validation", }); - expect(mockCreateBranchMutate).not.toHaveBeenCalled(); - expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + expect(mockCreateBranch).not.toHaveBeenCalled(); }); it("returns validation error for invalid branch names", async () => { const result = await createBranch({ + writeClient, repoPath: "/repo", rawBranchName: "feature..branch", }); @@ -86,23 +70,19 @@ describe("branchCreation", () => { error: 'Branch name cannot contain "..".', reason: "validation", }); - expect(mockCreateBranchMutate).not.toHaveBeenCalled(); - expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); + expect(mockCreateBranch).not.toHaveBeenCalled(); }); - it("creates branch with trimmed name and invalidates branch queries", async () => { - mockCreateBranchMutate.mockResolvedValueOnce(undefined); + it("creates branch with trimmed name", async () => { + mockCreateBranch.mockResolvedValueOnce(undefined); const result = await createBranch({ + writeClient, repoPath: "/repo", rawBranchName: " feature/test ", }); - expect(mockCreateBranchMutate).toHaveBeenCalledWith({ - directoryPath: "/repo", - branchName: "feature/test", - }); - expect(mockInvalidateGitBranchQueries).toHaveBeenCalledWith("/repo"); + expect(mockCreateBranch).toHaveBeenCalledWith("/repo", "feature/test"); expect(result).toEqual({ success: true, branchName: "feature/test", @@ -111,9 +91,10 @@ describe("branchCreation", () => { it("returns request error with message when mutate throws Error", async () => { const error = new Error("boom"); - mockCreateBranchMutate.mockRejectedValueOnce(error); + mockCreateBranch.mockRejectedValueOnce(error); const result = await createBranch({ + writeClient, repoPath: "/repo", rawBranchName: "feature/test", }); @@ -124,13 +105,13 @@ describe("branchCreation", () => { reason: "request", rawError: error, }); - expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); }); it("returns fallback error when mutate throws non-Error value", async () => { - mockCreateBranchMutate.mockRejectedValueOnce("oops"); + mockCreateBranch.mockRejectedValueOnce("oops"); const result = await createBranch({ + writeClient, repoPath: "/repo", rawBranchName: "feature/test", }); @@ -141,7 +122,6 @@ describe("branchCreation", () => { reason: "request", rawError: "oops", }); - expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts b/packages/core/src/git-interaction/branchCreation.ts similarity index 80% rename from apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts rename to packages/core/src/git-interaction/branchCreation.ts index 60b0955092..eb36664969 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts +++ b/packages/core/src/git-interaction/branchCreation.ts @@ -1,9 +1,8 @@ -import { - sanitizeBranchName, - validateBranchName, -} from "@features/git-interaction/utils/branchNameValidation"; -import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; -import { trpcClient } from "@renderer/trpc"; +import { sanitizeBranchName, validateBranchName } from "./branchName"; + +interface BranchCreator { + createBranch(repoPath: string, branchName: string): Promise; +} export interface BranchNameInputState { sanitized: string; @@ -23,6 +22,7 @@ export type CreateBranchResult = }; interface CreateBranchInput { + writeClient: BranchCreator; repoPath?: string; rawBranchName: string; } @@ -40,6 +40,7 @@ export function getBranchNameInputState(value: string): BranchNameInputState { } export async function createBranch({ + writeClient, repoPath, rawBranchName, }: CreateBranchInput): Promise { @@ -70,12 +71,7 @@ export async function createBranch({ } try { - await trpcClient.git.createBranch.mutate({ - directoryPath: repoPath, - branchName, - }); - - invalidateGitBranchQueries(repoPath); + await writeClient.createBranch(repoPath, branchName); return { success: true, diff --git a/apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.test.ts b/packages/core/src/git-interaction/branchName.test.ts similarity index 80% rename from apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.test.ts rename to packages/core/src/git-interaction/branchName.test.ts index dd3789f400..b6247967d9 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.test.ts +++ b/packages/core/src/git-interaction/branchName.test.ts @@ -1,8 +1,9 @@ +import { describe, expect, it } from "vitest"; import { sanitizeBranchName, + suggestBranchName, validateBranchName, -} from "@features/git-interaction/utils/branchNameValidation"; -import { describe, expect, it } from "vitest"; +} from "./branchName"; describe("sanitizeBranchName", () => { it("replaces spaces with dashes", () => { @@ -88,3 +89,27 @@ describe("validateBranchName", () => { expect(validateBranchName("a..b")).toBe('Branch name cannot contain "..".'); }); }); + +describe("suggestBranchName", () => { + it("returns the base name when no collision exists", () => { + expect(suggestBranchName("Fix bug", "abc", [])).toBe( + "posthog-code/fix-bug", + ); + }); + + it("appends -2 on first collision", () => { + expect(suggestBranchName("Fix bug", "abc", ["posthog-code/fix-bug"])).toBe( + "posthog-code/fix-bug-2", + ); + }); + + it("increments past consecutive collisions", () => { + expect( + suggestBranchName("Fix bug", "abc", [ + "posthog-code/fix-bug", + "posthog-code/fix-bug-2", + "posthog-code/fix-bug-3", + ]), + ).toBe("posthog-code/fix-bug-4"); + }); +}); diff --git a/apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.ts b/packages/core/src/git-interaction/branchName.ts similarity index 65% rename from apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.ts rename to packages/core/src/git-interaction/branchName.ts index 51b4f12e0d..d5301081eb 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.ts +++ b/packages/core/src/git-interaction/branchName.ts @@ -1,17 +1,9 @@ -/** - * Replaces spaces with dashes so users can type natural words and get a - * valid branch name without thinking about it. - */ +import { BRANCH_PREFIX } from "@posthog/shared"; + export function sanitizeBranchName(input: string): string { return input.replace(/ /g, "-"); } -/** - * Validates a branch name against the rules in `git check-ref-format`. - * Returns the first error message found, or `null` when the name is valid. - * Returns `null` for an empty string — the caller handles the empty case - * by disabling the submit button. - */ export function validateBranchName(name: string): string | null { if (name === "") return null; @@ -61,3 +53,31 @@ export function validateBranchName(name: string): string | null { return null; } + +export function deriveBranchName(title: string, fallbackId: string): string { + const slug = title + .toLowerCase() + .trim() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 60) + .replace(/-$/, ""); + + if (!slug) return `${BRANCH_PREFIX}task-${fallbackId}`; + return `${BRANCH_PREFIX}${slug}`; +} + +export function suggestBranchName( + title: string, + fallbackId: string, + existingBranches: string[], +): string { + const base = deriveBranchName(title, fallbackId); + + if (!existingBranches.includes(base)) return base; + + let n = 2; + while (existingBranches.includes(`${base}-${n}`)) n++; + return `${base}-${n}`; +} diff --git a/apps/code/src/renderer/features/git-interaction/utils/deriveBranchName.test.ts b/packages/core/src/git-interaction/deriveBranchName.test.ts similarity index 96% rename from apps/code/src/renderer/features/git-interaction/utils/deriveBranchName.test.ts rename to packages/core/src/git-interaction/deriveBranchName.test.ts index 72e293f7b7..1d4cc0b860 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/deriveBranchName.test.ts +++ b/packages/core/src/git-interaction/deriveBranchName.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { deriveBranchName } from "./deriveBranchName"; +import { deriveBranchName } from "./branchName"; describe("deriveBranchName", () => { it("converts a simple title to a branch name", () => { diff --git a/apps/code/src/renderer/features/git-interaction/utils/diffStats.ts b/packages/core/src/git-interaction/diffStats.ts similarity index 65% rename from apps/code/src/renderer/features/git-interaction/utils/diffStats.ts rename to packages/core/src/git-interaction/diffStats.ts index ab0d883265..6a67aae272 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/diffStats.ts +++ b/packages/core/src/git-interaction/diffStats.ts @@ -1,4 +1,4 @@ -import type { ChangedFile } from "@shared/types"; +import type { ChangedFile } from "@posthog/shared/domain-types"; export interface DiffStats { filesChanged: number; @@ -28,3 +28,16 @@ export function formatFileCountLabel( } return `${totalFileCount} file${totalFileCount === 1 ? "" : "s"}`; } + +export function partitionByStaged(files: ChangedFile[]): { + stagedFiles: ChangedFile[]; + unstagedFiles: ChangedFile[]; +} { + const stagedFiles: ChangedFile[] = []; + const unstagedFiles: ChangedFile[] = []; + for (const f of files) { + if (f.staged) stagedFiles.push(f); + else unstagedFiles.push(f); + } + return { stagedFiles, unstagedFiles }; +} diff --git a/apps/code/src/renderer/features/git-interaction/utils/errorPrompts.ts b/packages/core/src/git-interaction/errorPrompts.ts similarity index 97% rename from apps/code/src/renderer/features/git-interaction/utils/errorPrompts.ts rename to packages/core/src/git-interaction/errorPrompts.ts index 607102a2eb..05d0683156 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/errorPrompts.ts +++ b/packages/core/src/git-interaction/errorPrompts.ts @@ -1,4 +1,4 @@ -import type { CreatePrStep } from "../types"; +import type { CreatePrStep } from "./types"; export interface FixWithAgentPrompt { label: string; diff --git a/packages/core/src/git-interaction/git-interaction.module.ts b/packages/core/src/git-interaction/git-interaction.module.ts new file mode 100644 index 0000000000..c82421a135 --- /dev/null +++ b/packages/core/src/git-interaction/git-interaction.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { GitInteractionService } from "./gitInteractionService"; +import { GIT_INTERACTION_SERVICE } from "./identifiers"; + +export const gitInteractionModule = new ContainerModule(({ bind }) => { + bind(GIT_INTERACTION_SERVICE).to(GitInteractionService).inSingletonScope(); +}); diff --git a/apps/code/src/renderer/features/git-interaction/state/gitInteractionLogic.test.ts b/packages/core/src/git-interaction/gitInteractionLogic.test.ts similarity index 100% rename from apps/code/src/renderer/features/git-interaction/state/gitInteractionLogic.test.ts rename to packages/core/src/git-interaction/gitInteractionLogic.test.ts diff --git a/apps/code/src/renderer/features/git-interaction/state/gitInteractionLogic.ts b/packages/core/src/git-interaction/gitInteractionLogic.ts similarity index 95% rename from apps/code/src/renderer/features/git-interaction/state/gitInteractionLogic.ts rename to packages/core/src/git-interaction/gitInteractionLogic.ts index 152b9602df..725ae2a7c0 100644 --- a/apps/code/src/renderer/features/git-interaction/state/gitInteractionLogic.ts +++ b/packages/core/src/git-interaction/gitInteractionLogic.ts @@ -1,7 +1,4 @@ -import type { - GitMenuAction, - GitMenuActionId, -} from "@features/git-interaction/types"; +import type { GitMenuAction, GitMenuActionId } from "./types"; interface GitState { repoPath?: string; @@ -108,7 +105,6 @@ function getCreatePrDisabledReason( if (s.prStatus?.prExists) return "PR already exists."; - // Something must be shippable: uncommitted changes or unpushed commits const hasShippableWork = s.hasChanges || s.aheadOfRemote > 0 || s.aheadOfDefault > 0 || !s.hasRemote; if (!hasShippableWork) return "No changes to ship."; @@ -154,8 +150,6 @@ function getPrimaryAction( pushAction: GitMenuAction, viewPrAction: GitMenuAction | null, ): GitMenuAction { - // When a PR already exists, the user usually wants to ship more work to it - // (commit → push) rather than create a new one. View PR is the fallback. if (viewPrAction) { if (commitAction.enabled) return commitAction; if (pushAction.enabled) return pushAction; diff --git a/packages/core/src/git-interaction/gitInteractionService.test.ts b/packages/core/src/git-interaction/gitInteractionService.test.ts new file mode 100644 index 0000000000..f3436a2d4f --- /dev/null +++ b/packages/core/src/git-interaction/gitInteractionService.test.ts @@ -0,0 +1,321 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type GitInteractionEffects, + GitInteractionService, + type GitStagingContext, + type IGitWriteClient, +} from "./gitInteractionService"; + +const stagingContext: GitStagingContext = { + staged_file_count: 0, + unstaged_file_count: 0, + commit_all: true, + staged_only: false, +}; + +function makeWriteClient( + overrides: Partial = {}, +): IGitWriteClient { + return { + commit: vi.fn(async () => ({ + success: true, + message: "ok", + commitSha: "sha", + branch: "main", + })), + push: vi.fn(async () => ({ success: true, message: "ok" })), + sync: vi.fn(async () => ({ + success: true, + pullMessage: "ok", + pushMessage: "ok", + })), + publish: vi.fn(async () => ({ + success: true, + message: "ok", + branch: "feature", + })), + createBranch: vi.fn(async () => {}), + createPr: vi.fn(async () => ({ + success: true, + message: "ok", + prUrl: "https://example.test/pr/1", + failedStep: null, + })), + openPr: vi.fn(async () => ({ + success: true, + message: "ok", + prUrl: "https://pr", + })), + generateCommitMessage: vi.fn(async () => ({ message: "generated" })), + generatePrTitleAndBody: vi.fn(async () => ({ title: "t", body: "b" })), + linkBranch: vi.fn(async () => {}), + onCreatePrProgress: vi.fn(() => () => {}), + ...overrides, + }; +} + +function makeEffects( + overrides: Partial = {}, +): GitInteractionEffects { + return { + trackGitAction: vi.fn(), + trackPrCreated: vi.fn(), + hasShippedFirstPr: vi.fn(() => true), + markFirstPrShipped: vi.fn(), + celebrate: vi.fn(), + openExternalUrl: vi.fn(), + attachPrUrlToTask: vi.fn(), + getConversationContext: vi.fn(() => undefined), + logError: vi.fn(), + logWarn: vi.fn(), + ...overrides, + }; +} + +function commitInput(over: Record = {}) { + return { + repoPath: "/repo", + taskId: "task-1", + message: "msg", + stagedOnly: false, + stagingContext, + hasRemote: true, + pushDisabledReason: null, + commitPush: false, + ...over, + }; +} + +describe("GitInteractionService.runCommit", () => { + let git: IGitWriteClient; + let effects: GitInteractionEffects; + let service: GitInteractionService; + + beforeEach(() => { + git = makeWriteClient(); + effects = makeEffects(); + service = new GitInteractionService(git, effects); + }); + + it("commits and tallies success", async () => { + const result = await service.runCommit(commitInput()); + expect(result.outcome).toBe("committed"); + expect(effects.trackGitAction).toHaveBeenCalledWith( + "task-1", + "commit", + true, + stagingContext, + ); + }); + + it("blocks commit-push when push is disabled", async () => { + const result = await service.runCommit( + commitInput({ commitPush: true, pushDisabledReason: "behind remote" }), + ); + expect(result).toEqual({ outcome: "error", message: "behind remote" }); + expect(git.commit).not.toHaveBeenCalled(); + }); + + it("generates a fallback message when empty", async () => { + const result = await service.runCommit(commitInput({ message: "" })); + expect(git.generateCommitMessage).toHaveBeenCalled(); + expect(result.outcome).toBe("committed"); + if (result.outcome === "committed") { + expect(result.generatedMessage).toBe("generated"); + } + }); + + it("returns generate-failed when fallback yields no message", async () => { + git = makeWriteClient({ + generateCommitMessage: vi.fn(async () => ({ message: "" })), + }); + service = new GitInteractionService(git, effects); + const result = await service.runCommit(commitInput({ message: "" })); + expect(result.outcome).toBe("generate-failed"); + }); + + it("chains into push on commit-push", async () => { + const result = await service.runCommit(commitInput({ commitPush: true })); + expect(git.push).toHaveBeenCalledTimes(1); + if (result.outcome === "committed") { + expect(result.next?.mode).toBe("push"); + expect(result.next?.result.outcome).toBe("success"); + } + }); + + it("chains into publish when no remote", async () => { + const result = await service.runCommit( + commitInput({ commitPush: true, hasRemote: false }), + ); + expect(git.publish).toHaveBeenCalledTimes(1); + if (result.outcome === "committed") { + expect(result.next?.mode).toBe("publish"); + } + }); +}); + +describe("GitInteractionService.runPush", () => { + it("dispatches sync mode", async () => { + const git = makeWriteClient(); + const service = new GitInteractionService(git, makeEffects()); + const controller = new AbortController(); + const result = await service.runPush({ + repoPath: "/repo", + taskId: "t", + mode: "sync", + signal: controller.signal, + }); + expect(git.sync).toHaveBeenCalled(); + expect(result.outcome).toBe("success"); + }); + + it("returns aborted when signal is aborted on throw", async () => { + const controller = new AbortController(); + const git = makeWriteClient({ + push: vi.fn(async () => { + controller.abort(); + throw new Error("aborted"); + }), + }); + const service = new GitInteractionService(git, makeEffects()); + const result = await service.runPush({ + repoPath: "/repo", + taskId: "t", + mode: "push", + signal: controller.signal, + }); + expect(result.outcome).toBe("aborted"); + }); + + it("maps sync failure messages", async () => { + const git = makeWriteClient({ + sync: vi.fn(async () => ({ + success: false, + pullMessage: "pull bad", + pushMessage: "push bad", + })), + }); + const service = new GitInteractionService(git, makeEffects()); + const result = await service.runPush({ + repoPath: "/repo", + taskId: "t", + mode: "sync", + signal: new AbortController().signal, + }); + expect(result).toEqual({ + outcome: "error", + message: "Pull: pull bad, Push: push bad", + }); + }); +}); + +describe("GitInteractionService.runBranch", () => { + it("links branch to task on success", async () => { + const git = makeWriteClient(); + const effects = makeEffects(); + const service = new GitInteractionService(git, effects); + const result = await service.runBranch({ + repoPath: "/repo", + taskId: "t", + rawBranchName: "feature-x", + }); + expect(result).toEqual({ outcome: "success", branchName: "feature-x" }); + expect(git.linkBranch).toHaveBeenCalledWith("t", "feature-x"); + expect(effects.trackGitAction).toHaveBeenCalledWith( + "t", + "branch-here", + true, + ); + }); + + it("returns error on validation failure", async () => { + const git = makeWriteClient(); + const service = new GitInteractionService(git, makeEffects()); + const result = await service.runBranch({ + repoPath: "/repo", + taskId: "t", + rawBranchName: "", + }); + expect(result.outcome).toBe("error"); + expect(git.createBranch).not.toHaveBeenCalled(); + }); +}); + +describe("GitInteractionService.runCreatePr", () => { + function prInput(over: Record = {}) { + return { + repoPath: "/repo", + taskId: "t", + flowId: "flow-1", + needsBranch: true, + branchName: "feature-x", + currentBranch: "main", + commitMessage: "", + prTitle: "", + prBody: "", + draft: false, + stagedOnly: false, + stagingContext, + onStep: vi.fn(), + ...over, + }; + } + + it("celebrates and tallies on first PR", async () => { + const git = makeWriteClient(); + const effects = makeEffects({ hasShippedFirstPr: vi.fn(() => false) }); + const service = new GitInteractionService(git, effects); + const result = await service.runCreatePr(prInput()); + expect(result.outcome).toBe("success"); + expect(effects.markFirstPrShipped).toHaveBeenCalled(); + expect(effects.celebrate).toHaveBeenCalled(); + expect(effects.trackPrCreated).toHaveBeenCalledWith("t", true); + expect(effects.openExternalUrl).toHaveBeenCalledWith( + "https://example.test/pr/1", + ); + expect(effects.attachPrUrlToTask).toHaveBeenCalledWith( + "t", + "https://example.test/pr/1", + ); + if (result.outcome === "success") { + expect(result.linkedBranchName).toBe("feature-x"); + expect(result.branchInvalidated).toBe(true); + } + }); + + it("does not celebrate when already shipped", async () => { + const git = makeWriteClient(); + const effects = makeEffects({ hasShippedFirstPr: vi.fn(() => true) }); + const service = new GitInteractionService(git, effects); + await service.runCreatePr(prInput()); + expect(effects.celebrate).not.toHaveBeenCalled(); + }); + + it("unsubscribes the progress listener", async () => { + const unsubscribe = vi.fn(); + const git = makeWriteClient({ + onCreatePrProgress: vi.fn(() => unsubscribe), + }); + const service = new GitInteractionService(git, makeEffects()); + await service.runCreatePr(prInput()); + expect(unsubscribe).toHaveBeenCalled(); + }); + + it("reports failedStep on failure", async () => { + const git = makeWriteClient({ + createPr: vi.fn(async () => ({ + success: false, + message: "boom", + prUrl: null, + failedStep: "pushing" as const, + })), + }); + const service = new GitInteractionService(git, makeEffects()); + const result = await service.runCreatePr(prInput()); + expect(result).toMatchObject({ + outcome: "error", + message: "boom", + failedStep: "pushing", + }); + }); +}); diff --git a/packages/core/src/git-interaction/gitInteractionService.ts b/packages/core/src/git-interaction/gitInteractionService.ts new file mode 100644 index 0000000000..fe86571ab1 --- /dev/null +++ b/packages/core/src/git-interaction/gitInteractionService.ts @@ -0,0 +1,464 @@ +import { inject, injectable } from "inversify"; +import type { + CommitOutput, + CreatePrOutput, + CreatePrStep, + GitStateSnapshot, + OpenPrOutput, + PublishOutput, + PushOutput, + SyncOutput, +} from "../git/router-schemas"; +import { createBranch } from "./branchCreation"; +import { GIT_INTERACTION_EFFECTS, GIT_WRITE_CLIENT } from "./identifiers"; + +export type GitPushMode = "push" | "sync" | "publish"; + +export type GitActionType = + | "commit" + | "push" + | "sync" + | "publish" + | "create-pr" + | "view-pr" + | "update-pr" + | "branch-here"; + +export interface GitStagingContext { + staged_file_count: number; + unstaged_file_count: number; + commit_all: boolean; + staged_only: boolean; +} + +export interface IGitWriteClient { + commit(input: { + directoryPath: string; + message: string; + stagedOnly?: boolean; + taskId: string; + }): Promise; + push(directoryPath: string, signal: AbortSignal): Promise; + sync(directoryPath: string, signal: AbortSignal): Promise; + publish(directoryPath: string, signal: AbortSignal): Promise; + createBranch(directoryPath: string, branchName: string): Promise; + createPr(input: { + directoryPath: string; + flowId: string; + branchName?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + draft?: boolean; + stagedOnly?: boolean; + taskId: string; + conversationContext?: string; + }): Promise; + openPr(directoryPath: string): Promise; + generateCommitMessage(input: { + directoryPath: string; + conversationContext?: string; + }): Promise<{ message: string }>; + generatePrTitleAndBody(input: { + directoryPath: string; + conversationContext?: string; + }): Promise<{ title: string; body: string }>; + linkBranch(taskId: string, branchName: string): Promise; + onCreatePrProgress( + flowId: string, + onStep: (step: CreatePrStep) => void, + ): () => void; +} + +export interface GitInteractionEffects { + trackGitAction( + taskId: string, + actionType: GitActionType, + success: boolean, + stagingContext?: GitStagingContext, + ): void; + trackPrCreated(taskId: string, success: boolean): void; + hasShippedFirstPr(): boolean; + markFirstPrShipped(): void; + celebrate(): void; + openExternalUrl(url: string): void; + attachPrUrlToTask(taskId: string, prUrl: string): void; + getConversationContext(taskId: string): string | undefined; + logError(message: string, error: unknown): void; + logWarn(message: string, context: Record): void; +} + +export interface RunCommitInput { + repoPath: string; + taskId: string; + message: string; + stagedOnly: boolean; + stagingContext: GitStagingContext; + hasRemote: boolean; + pushDisabledReason: string | null; + commitPush: boolean; +} + +export type RunCommitResult = + | { outcome: "error"; message: string } + | { outcome: "generate-failed"; message: string } + | { + outcome: "committed"; + snapshot?: GitStateSnapshot; + generatedMessage?: string; + next?: { mode: GitPushMode; result: RunPushResult }; + }; + +export interface RunPushInput { + repoPath: string; + taskId: string; + mode: GitPushMode; + signal: AbortSignal; +} + +export type RunPushResult = + | { outcome: "success"; snapshot?: GitStateSnapshot } + | { outcome: "error"; message: string } + | { outcome: "aborted" }; + +export interface RunBranchInput { + repoPath: string; + taskId: string; + rawBranchName: string; +} + +export type RunBranchResult = + | { outcome: "success"; branchName: string } + | { outcome: "error"; message: string }; + +export interface RunCreatePrInput { + repoPath: string; + taskId: string; + flowId: string; + needsBranch: boolean; + branchName: string; + currentBranch: string | null; + commitMessage: string; + prTitle: string; + prBody: string; + draft: boolean; + stagedOnly: boolean; + stagingContext: GitStagingContext; + onStep: (step: CreatePrStep) => void; +} + +export type RunCreatePrResult = + | { + outcome: "success"; + snapshot?: GitStateSnapshot; + prUrl: string | null; + linkedBranchName: string | null; + branchInvalidated: boolean; + } + | { outcome: "error"; message: string; failedStep: CreatePrStep | null }; + +@injectable() +export class GitInteractionService { + constructor( + @inject(GIT_WRITE_CLIENT) + private readonly git: IGitWriteClient, + @inject(GIT_INTERACTION_EFFECTS) + private readonly effects: GitInteractionEffects, + ) {} + + async runCommit(input: RunCommitInput): Promise { + if (input.commitPush && input.pushDisabledReason) { + return { outcome: "error", message: input.pushDisabledReason }; + } + + let message = input.message; + let generatedMessage: string | undefined; + + if (!message) { + const generated = await this.generateMessageForCommit(input); + if (generated.outcome !== "ok") return generated.result; + message = generated.message; + generatedMessage = generated.message; + } + + const result = await this.git.commit({ + directoryPath: input.repoPath, + message, + stagedOnly: input.stagedOnly || undefined, + taskId: input.taskId, + }); + + if (!result.success) { + this.effects.trackGitAction( + input.taskId, + "commit", + false, + input.stagingContext, + ); + return { outcome: "error", message: result.message || "Commit failed." }; + } + + this.effects.trackGitAction( + input.taskId, + "commit", + true, + input.stagingContext, + ); + + let next: { mode: GitPushMode; result: RunPushResult } | undefined; + if (input.commitPush) { + const mode: GitPushMode = input.hasRemote ? "push" : "publish"; + const controller = new AbortController(); + const pushResult = await this.runPush({ + repoPath: input.repoPath, + taskId: input.taskId, + mode, + signal: controller.signal, + }); + next = { mode, result: pushResult }; + } + + return { + outcome: "committed", + snapshot: result.state, + generatedMessage, + next, + }; + } + + private async generateMessageForCommit( + input: RunCommitInput, + ): Promise< + | { outcome: "ok"; message: string } + | { outcome: "failed"; result: RunCommitResult } + > { + try { + const generated = await this.git.generateCommitMessage({ + directoryPath: input.repoPath, + conversationContext: this.effects.getConversationContext(input.taskId), + }); + if (!generated.message) { + return { + outcome: "failed", + result: { + outcome: "generate-failed", + message: "No changes detected to generate a commit message.", + }, + }; + } + return { outcome: "ok", message: generated.message }; + } catch (error) { + this.effects.logError("Failed to generate commit message", error); + return { + outcome: "failed", + result: { + outcome: "generate-failed", + message: + error instanceof Error + ? error.message + : "Failed to generate commit message.", + }, + }; + } + } + + async runPush(input: RunPushInput): Promise { + try { + const result = await this.dispatchPush(input); + + if (!result.success) { + const message = + "message" in result + ? result.message + : `Pull: ${result.pullMessage}, Push: ${result.pushMessage}`; + this.effects.trackGitAction(input.taskId, input.mode, false); + return { outcome: "error", message: message || "Push failed." }; + } + + this.effects.trackGitAction(input.taskId, input.mode, true); + return { outcome: "success", snapshot: result.state }; + } catch (error) { + this.effects.trackGitAction(input.taskId, input.mode, false); + if (input.signal.aborted) { + return { outcome: "aborted" }; + } + this.effects.logError("Push failed", error); + return { + outcome: "error", + message: error instanceof Error ? error.message : "Push failed.", + }; + } + } + + private dispatchPush( + input: RunPushInput, + ): Promise { + if (input.mode === "sync") { + return this.git.sync(input.repoPath, input.signal); + } + if (input.mode === "publish") { + return this.git.publish(input.repoPath, input.signal); + } + return this.git.push(input.repoPath, input.signal); + } + + async runBranch(input: RunBranchInput): Promise { + const result = await createBranch({ + writeClient: { + createBranch: (directoryPath: string, branchName: string) => + this.git.createBranch(directoryPath, branchName), + }, + repoPath: input.repoPath, + rawBranchName: input.rawBranchName, + }); + + if (!result.success) { + if (result.reason === "request") { + this.effects.logError( + "Failed to create branch", + result.rawError ?? result.error, + ); + this.effects.trackGitAction(input.taskId, "branch-here", false); + } + return { outcome: "error", message: result.error }; + } + + this.effects.trackGitAction(input.taskId, "branch-here", true); + + this.git.linkBranch(input.taskId, result.branchName).catch((err) => + this.effects.logWarn("Failed to link branch to task", { + taskId: input.taskId, + err, + }), + ); + + return { outcome: "success", branchName: result.branchName }; + } + + async runCreatePr(input: RunCreatePrInput): Promise { + const unsubscribe = this.git.onCreatePrProgress(input.flowId, input.onStep); + + try { + const result = await this.git.createPr({ + directoryPath: input.repoPath, + flowId: input.flowId, + branchName: input.needsBranch ? input.branchName.trim() : undefined, + commitMessage: input.commitMessage.trim() || undefined, + prTitle: input.prTitle.trim() || undefined, + prBody: input.prBody.trim() || undefined, + draft: input.draft || undefined, + stagedOnly: input.stagedOnly || undefined, + taskId: input.taskId, + conversationContext: this.effects.getConversationContext(input.taskId), + }); + + if (!result.success) { + this.effects.trackGitAction( + input.taskId, + "create-pr", + false, + input.stagingContext, + ); + return { + outcome: "error", + message: result.message, + failedStep: result.failedStep ?? null, + }; + } + + this.effects.trackGitAction( + input.taskId, + "create-pr", + true, + input.stagingContext, + ); + this.effects.trackPrCreated(input.taskId, true); + + if (!this.effects.hasShippedFirstPr()) { + this.effects.markFirstPrShipped(); + this.effects.celebrate(); + } + + const linkedBranchName = input.needsBranch + ? input.branchName.trim() + : input.currentBranch; + + if (result.prUrl) { + this.effects.openExternalUrl(result.prUrl); + this.effects.attachPrUrlToTask(input.taskId, result.prUrl); + } + + return { + outcome: "success", + snapshot: result.state, + prUrl: result.prUrl, + linkedBranchName, + branchInvalidated: input.needsBranch, + }; + } catch (error) { + this.effects.logError("Create PR flow failed", error); + return { + outcome: "error", + message: + error instanceof Error ? error.message : "Create PR flow failed.", + failedStep: null, + }; + } finally { + unsubscribe(); + } + } + + async viewPr(repoPath: string): Promise { + const result = await this.git.openPr(repoPath); + if (result.success && result.prUrl) { + return result.prUrl; + } + return null; + } + + async generateCommitMessage( + repoPath: string, + taskId: string, + ): Promise<{ message: string } | { error: string }> { + try { + const result = await this.git.generateCommitMessage({ + directoryPath: repoPath, + conversationContext: this.effects.getConversationContext(taskId), + }); + if (result.message) return { message: result.message }; + return { error: "No changes detected to generate a commit message." }; + } catch (error) { + this.effects.logError("Failed to generate commit message", error); + return { + error: + error instanceof Error + ? error.message + : "Failed to generate commit message.", + }; + } + } + + async generatePrTitleAndBody( + repoPath: string, + taskId: string, + ): Promise<{ title: string; body: string } | { error: string }> { + try { + const result = await this.git.generatePrTitleAndBody({ + directoryPath: repoPath, + conversationContext: this.effects.getConversationContext(taskId), + }); + if (result.title || result.body) { + return { title: result.title, body: result.body }; + } + return { error: "No changes detected to generate PR description." }; + } catch (error) { + this.effects.logError("Failed to generate PR title and body", error); + return { + error: + error instanceof Error + ? error.message + : "Failed to generate PR description.", + }; + } + } +} diff --git a/apps/code/src/renderer/features/git-interaction/utils/gitStatusUtils.ts b/packages/core/src/git-interaction/gitStatusUtils.ts similarity index 91% rename from apps/code/src/renderer/features/git-interaction/utils/gitStatusUtils.ts rename to packages/core/src/git-interaction/gitStatusUtils.ts index b536c00bf1..f16b84d82f 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/gitStatusUtils.ts +++ b/packages/core/src/git-interaction/gitStatusUtils.ts @@ -1,4 +1,4 @@ -import type { GitFileStatus } from "@shared/types"; +import type { GitFileStatus } from "@posthog/shared/domain-types"; export type StatusColor = "green" | "orange" | "red" | "blue" | "gray"; export interface StatusIndicator { diff --git a/packages/core/src/git-interaction/identifiers.ts b/packages/core/src/git-interaction/identifiers.ts new file mode 100644 index 0000000000..556cb77d91 --- /dev/null +++ b/packages/core/src/git-interaction/identifiers.ts @@ -0,0 +1,7 @@ +export const GIT_INTERACTION_SERVICE = Symbol.for( + "posthog.core.gitInteractionService", +); +export const GIT_WRITE_CLIENT = Symbol.for("posthog.core.gitWriteClient"); +export const GIT_INTERACTION_EFFECTS = Symbol.for( + "posthog.core.gitInteractionEffects", +); diff --git a/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx b/packages/core/src/git-interaction/prStatus.ts similarity index 70% rename from apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx rename to packages/core/src/git-interaction/prStatus.ts index 96c4c8860a..9d9e990902 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx +++ b/packages/core/src/git-interaction/prStatus.ts @@ -1,12 +1,6 @@ -import type { PrActionType } from "@main/services/git/schemas"; -import { - Check, - GitMerge, - GitPullRequest, - type Icon, - PencilSimple, - X, -} from "@phosphor-icons/react"; +import type { PrActionType } from "@posthog/shared"; + +export type PrVisualIcon = "merged" | "pull-request"; export interface PrAction { id: PrActionType; @@ -15,7 +9,7 @@ export interface PrAction { export interface PrVisualConfig { color: "gray" | "green" | "red" | "purple"; - Icon: Icon; + icon: PrVisualIcon; label: string; actions: PrAction[]; } @@ -28,7 +22,7 @@ export function getPrVisualConfig( if (merged) { return { color: "purple", - Icon: GitMerge, + icon: "merged", label: "Merged", actions: [], }; @@ -36,7 +30,7 @@ export function getPrVisualConfig( if (state === "closed") { return { color: "red", - Icon: GitPullRequest, + icon: "pull-request", label: "Closed", actions: [{ id: "reopen", label: "Reopen PR" }], }; @@ -44,7 +38,7 @@ export function getPrVisualConfig( if (draft) { return { color: "gray", - Icon: GitPullRequest, + icon: "pull-request", label: "Draft", actions: [ { id: "ready", label: "Ready for review" }, @@ -54,7 +48,7 @@ export function getPrVisualConfig( } return { color: "green", - Icon: GitPullRequest, + icon: "pull-request", label: "Open", actions: [ { id: "draft", label: "Convert to draft" }, @@ -86,16 +80,3 @@ export const PR_ACTION_LABELS: Record = { export function parsePrNumber(prUrl: string): string | undefined { return prUrl.match(/\/pull\/(\d+)/)?.[1]; } - -export function getPrActionIcon(action: PrActionType): React.ReactNode { - switch (action) { - case "close": - return ; - case "reopen": - return ; - case "ready": - return ; - case "draft": - return ; - } -} diff --git a/packages/core/src/git-interaction/stagingPlan.test.ts b/packages/core/src/git-interaction/stagingPlan.test.ts new file mode 100644 index 0000000000..c1ed5dc185 --- /dev/null +++ b/packages/core/src/git-interaction/stagingPlan.test.ts @@ -0,0 +1,90 @@ +import type { ChangedFile } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { deriveCreatePrPlan, deriveStagingPlan } from "./stagingPlan"; + +function file(path: string, staged: boolean): ChangedFile { + return { path, status: "modified", staged } as ChangedFile; +} + +describe("deriveStagingPlan", () => { + it("flags stagedOnly when both staged and unstaged exist and commitAll is off", () => { + const plan = deriveStagingPlan( + [file("a", true)], + [file("b", false)], + false, + ); + expect(plan.stagedOnly).toBe(true); + expect(plan.stagingContext.staged_only).toBe(true); + }); + + it("does not flag stagedOnly when commitAll is on", () => { + const plan = deriveStagingPlan([file("a", true)], [file("b", false)], true); + expect(plan.stagedOnly).toBe(false); + }); + + it("does not flag stagedOnly when only staged files exist", () => { + const plan = deriveStagingPlan([file("a", true)], [], false); + expect(plan.stagedOnly).toBe(false); + }); + + it("tallies file counts and commit_all", () => { + const plan = deriveStagingPlan( + [file("a", true), file("b", true)], + [file("c", false)], + true, + ); + expect(plan.stagingContext).toEqual({ + staged_file_count: 2, + unstaged_file_count: 1, + commit_all: true, + staged_only: false, + }); + }); +}); + +describe("deriveCreatePrPlan", () => { + it("needs a branch when not on a feature branch", () => { + const plan = deriveCreatePrPlan({ + isFeatureBranch: false, + prExists: false, + hasChanges: true, + stagedFileCount: 0, + unstagedFileCount: 1, + }); + expect(plan.needsBranch).toBe(true); + expect(plan.needsCommit).toBe(true); + }); + + it("needs a branch when a PR already exists even on a feature branch", () => { + const plan = deriveCreatePrPlan({ + isFeatureBranch: true, + prExists: true, + hasChanges: false, + stagedFileCount: 0, + unstagedFileCount: 0, + }); + expect(plan.needsBranch).toBe(true); + }); + + it("does not need a branch on a feature branch with no PR", () => { + const plan = deriveCreatePrPlan({ + isFeatureBranch: true, + prExists: false, + hasChanges: true, + stagedFileCount: 1, + unstagedFileCount: 0, + }); + expect(plan.needsBranch).toBe(false); + }); + + it("disables commitAll when staging is mixed", () => { + const plan = deriveCreatePrPlan({ + isFeatureBranch: true, + prExists: false, + hasChanges: true, + stagedFileCount: 1, + unstagedFileCount: 1, + }); + expect(plan.commitAll).toBe(false); + }); +}); diff --git a/packages/core/src/git-interaction/stagingPlan.ts b/packages/core/src/git-interaction/stagingPlan.ts new file mode 100644 index 0000000000..71295b9547 --- /dev/null +++ b/packages/core/src/git-interaction/stagingPlan.ts @@ -0,0 +1,47 @@ +import type { ChangedFile } from "@posthog/shared/domain-types"; +import type { GitStagingContext } from "./gitInteractionService"; + +export interface StagingPlan { + stagingContext: GitStagingContext; + stagedOnly: boolean; +} + +export function deriveStagingPlan( + stagedFiles: ChangedFile[], + unstagedFiles: ChangedFile[], + commitAll: boolean, +): StagingPlan { + const hasMixedStaging = stagedFiles.length > 0 && unstagedFiles.length > 0; + const stagedOnly = hasMixedStaging && !commitAll; + return { + stagedOnly, + stagingContext: { + staged_file_count: stagedFiles.length, + unstaged_file_count: unstagedFiles.length, + commit_all: commitAll, + staged_only: stagedOnly, + }, + }; +} + +export interface CreatePrPlan { + needsBranch: boolean; + needsCommit: boolean; + commitAll: boolean; +} + +export function deriveCreatePrPlan(input: { + isFeatureBranch: boolean; + prExists: boolean; + hasChanges: boolean; + stagedFileCount: number; + unstagedFileCount: number; +}): CreatePrPlan { + const hasMixedStaging = + input.stagedFileCount > 0 && input.unstagedFileCount > 0; + return { + needsBranch: !input.isFeatureBranch || input.prExists, + needsCommit: input.hasChanges, + commitAll: !hasMixedStaging, + }; +} diff --git a/apps/code/src/renderer/features/git-interaction/types.ts b/packages/core/src/git-interaction/types.ts similarity index 100% rename from apps/code/src/renderer/features/git-interaction/types.ts rename to packages/core/src/git-interaction/types.ts diff --git a/packages/core/src/git-pr/create-pr-saga.test.ts b/packages/core/src/git-pr/create-pr-saga.test.ts new file mode 100644 index 0000000000..ecff662d9f --- /dev/null +++ b/packages/core/src/git-pr/create-pr-saga.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import { type CreatePrDeps, CreatePrSaga } from "./create-pr-saga"; + +function makeDeps(over: Partial = {}): CreatePrDeps { + return { + getCurrentBranch: vi.fn().mockResolvedValue("main"), + createBranch: vi.fn().mockResolvedValue(undefined), + getChangedFilesHead: vi.fn().mockResolvedValue([{ path: "x.ts" }]), + generateCommitMessage: vi.fn().mockResolvedValue({ message: "feat: x" }), + getHeadSha: vi.fn().mockResolvedValue("abc123"), + commit: vi.fn().mockResolvedValue({ success: true, message: "ok" }), + resetSoft: vi.fn().mockResolvedValue(undefined), + getSyncStatus: vi.fn().mockResolvedValue({ hasRemote: true }), + push: vi.fn().mockResolvedValue({ success: true, message: "ok" }), + publish: vi.fn().mockResolvedValue({ success: true, message: "ok" }), + generatePrTitleAndBody: vi + .fn() + .mockResolvedValue({ title: "T", body: "B" }), + createPr: vi.fn().mockResolvedValue({ + success: true, + message: "ok", + prUrl: "https://github.com/o/r/pull/1", + }), + onProgress: vi.fn(), + ...over, + }; +} + +describe("CreatePrSaga", () => { + it("runs commit -> push -> create-pr and returns the PR url", async () => { + const deps = makeDeps(); + const saga = new CreatePrSaga(deps); + + const result = await saga.run({ directoryPath: "/repo" }); + + expect(deps.commit).toHaveBeenCalled(); + expect(deps.push).toHaveBeenCalled(); + expect(deps.publish).not.toHaveBeenCalled(); + expect(deps.createPr).toHaveBeenCalled(); + if (!result.success) throw new Error(`saga failed: ${result.error}`); + expect(result.data.prUrl).toBe("https://github.com/o/r/pull/1"); + }); + + it("publishes instead of pushing when there is no remote", async () => { + const deps = makeDeps({ + getSyncStatus: vi.fn().mockResolvedValue({ hasRemote: false }), + }); + const saga = new CreatePrSaga(deps); + + await saga.run({ directoryPath: "/repo" }); + + expect(deps.publish).toHaveBeenCalled(); + expect(deps.push).not.toHaveBeenCalled(); + }); + + it("skips committing when there are no changed files", async () => { + const deps = makeDeps({ + getChangedFilesHead: vi.fn().mockResolvedValue([]), + }); + const saga = new CreatePrSaga(deps); + + await saga.run({ directoryPath: "/repo" }); + + expect(deps.commit).not.toHaveBeenCalled(); + expect(deps.createPr).toHaveBeenCalled(); + }); +}); diff --git a/apps/code/src/main/services/git/create-pr-saga.ts b/packages/core/src/git-pr/create-pr-saga.ts similarity index 83% rename from apps/code/src/main/services/git/create-pr-saga.ts rename to packages/core/src/git-pr/create-pr-saga.ts index 5c6b6ee839..aa49f71e0a 100644 --- a/apps/code/src/main/services/git/create-pr-saga.ts +++ b/packages/core/src/git-pr/create-pr-saga.ts @@ -1,14 +1,18 @@ -import { getGitOperationManager } from "@posthog/git/operation-manager"; -import { getHeadSha } from "@posthog/git/queries"; import { Saga, type SagaLogger } from "@posthog/shared"; -import type { - ChangedFile, - CommitOutput, - CreatePrProgressPayload, - GitSyncStatus, - PublishOutput, - PushOutput, -} from "./schemas"; + +export type CreatePrStep = + | "creating-branch" + | "committing" + | "pushing" + | "creating-pr" + | "complete" + | "error"; + +/** Minimal shape the saga reads from a git write result (commit/push/publish). */ +interface GitOpResult { + success: boolean; + message: string; +} export interface CreatePrSagaInput { directoryPath: string; @@ -25,23 +29,24 @@ export interface CreatePrSagaOutput { prUrl: string | null; } +// Host git operations the saga orchestrates. The host (apps/code GitService) +// binds these to @posthog/git CLI calls; the saga itself stays host-agnostic. export interface CreatePrDeps { getCurrentBranch(dir: string): Promise; createBranch(dir: string, name: string): Promise; - checkoutBranch( - dir: string, - name: string, - ): Promise<{ previousBranch: string; currentBranch: string }>; - getChangedFilesHead(dir: string): Promise; + getChangedFilesHead(dir: string): Promise; generateCommitMessage(dir: string): Promise<{ message: string }>; + getHeadSha(dir: string): Promise; commit( dir: string, message: string, options?: { stagedOnly?: boolean; taskId?: string }, - ): Promise; - getSyncStatus(dir: string): Promise; - push(dir: string): Promise; - publish(dir: string): Promise; + ): Promise; + /** Soft-reset to `sha` (commit rollback). */ + resetSoft(dir: string, sha: string): Promise; + getSyncStatus(dir: string): Promise<{ hasRemote: boolean }>; + push(dir: string): Promise; + publish(dir: string): Promise; generatePrTitleAndBody(dir: string): Promise<{ title: string; body: string }>; createPr( dir: string, @@ -49,11 +54,7 @@ export interface CreatePrDeps { body?: string, draft?: boolean, ): Promise<{ success: boolean; message: string; prUrl: string | null }>; - onProgress( - step: CreatePrProgressPayload["step"], - message: string, - prUrl?: string, - ): void; + onProgress(step: CreatePrStep, message: string, prUrl?: string): void; } export class CreatePrSaga extends Saga { @@ -121,7 +122,7 @@ export class CreatePrSaga extends Saga { this.deps.onProgress("committing", "Committing changes..."); const preCommitSha = await this.readOnlyStep("get-pre-commit-sha", () => - getHeadSha(directoryPath), + this.deps.getHeadSha(directoryPath), ); await this.step({ @@ -139,10 +140,7 @@ export class CreatePrSaga extends Saga { return result; }, rollback: async () => { - const manager = getGitOperationManager(); - await manager.executeWrite(directoryPath, (git) => - git.reset(["--soft", preCommitSha]), - ); + await this.deps.resetSoft(directoryPath, preCommitSha); }, }); } diff --git a/packages/core/src/git-pr/git-pr.module.ts b/packages/core/src/git-pr/git-pr.module.ts new file mode 100644 index 0000000000..fbafe33a44 --- /dev/null +++ b/packages/core/src/git-pr/git-pr.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { GitPrService } from "./git-pr"; +import { GIT_PR_SERVICE } from "./identifiers"; + +export const gitPrModule = new ContainerModule(({ bind }) => { + bind(GIT_PR_SERVICE).to(GitPrService).inSingletonScope(); +}); diff --git a/packages/core/src/git-pr/git-pr.test.ts b/packages/core/src/git-pr/git-pr.test.ts new file mode 100644 index 0000000000..43c6299ab8 --- /dev/null +++ b/packages/core/src/git-pr/git-pr.test.ts @@ -0,0 +1,207 @@ +import type { WorkbenchLogger } from "@posthog/di/logger"; +import { describe, expect, it, vi } from "vitest"; +import { GitPrService } from "./git-pr"; +import type { CreatePrHost, GitDiffSource } from "./identifiers"; + +const noopLogger: WorkbenchLogger = { + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + scope: () => noopLogger, +}; + +function makeDiffSource(over: Partial = {}): GitDiffSource { + return { + getStagedDiff: vi.fn().mockResolvedValue(""), + getUnstagedDiff: vi.fn().mockResolvedValue(""), + getCommitConventions: vi.fn().mockResolvedValue({ + conventionalCommits: false, + commonPrefixes: [], + sampleMessages: [], + }), + getChangedFilesHead: vi.fn().mockResolvedValue([]), + getDefaultBranch: vi.fn().mockResolvedValue("main"), + getCurrentBranch: vi.fn().mockResolvedValue("feature"), + getDiffAgainstRemote: vi.fn().mockResolvedValue(""), + getCommitsBetweenBranches: vi.fn().mockResolvedValue([]), + getPrTemplate: vi.fn().mockResolvedValue({ template: null }), + fetchIfStale: vi.fn().mockResolvedValue(undefined), + ...over, + }; +} + +function makeLlm(content: string) { + return { + prompt: vi.fn().mockResolvedValue({ content }), + } as unknown as ConstructorParameters[1]; +} + +describe("GitPrService.generateCommitMessage", () => { + it("returns an empty message when there is no diff and no changed files", async () => { + const llm = makeLlm("should-not-be-used"); + const service = new GitPrService(makeDiffSource(), llm, noopLogger); + + const result = await service.generateCommitMessage("/repo"); + + expect(result).toEqual({ message: "" }); + expect(llm.prompt).not.toHaveBeenCalled(); + }); + + it("prompts the LLM with the staged diff and returns the trimmed message", async () => { + const llm = makeLlm(" feat: add widget\n"); + const diffSource = makeDiffSource({ + getStagedDiff: vi.fn().mockResolvedValue("diff --git a/x b/x"), + getChangedFilesHead: vi + .fn() + .mockResolvedValue([{ status: "modified", path: "x.ts" }]), + }); + const service = new GitPrService(diffSource, llm, noopLogger); + + const result = await service.generateCommitMessage("/repo", "why context"); + + expect(result).toEqual({ message: "feat: add widget" }); + const [messages, options] = (llm.prompt as ReturnType).mock + .calls[0]; + expect(messages[0].content).toContain("diff --git a/x b/x"); + expect(messages[0].content).toContain("modified: x.ts"); + expect(messages[0].content).toContain("why context"); + expect(options.system).toContain("commit message generator"); + }); +}); + +describe("GitPrService.generatePrTitleAndBody", () => { + it("returns empty title/body when there are no commits and no diff", async () => { + const llm = makeLlm("unused"); + const service = new GitPrService(makeDiffSource(), llm, noopLogger); + + const result = await service.generatePrTitleAndBody("/repo"); + + expect(result).toEqual({ title: "", body: "" }); + expect(llm.prompt).not.toHaveBeenCalled(); + }); + + it("parses TITLE/BODY out of the LLM response", async () => { + const llm = makeLlm( + "TITLE: feat: add widget\n\nBODY:\nTL;DR: adds a widget.", + ); + const diffSource = makeDiffSource({ + getCommitsBetweenBranches: vi + .fn() + .mockResolvedValue([{ message: "add widget" }]), + getDiffAgainstRemote: vi.fn().mockResolvedValue("diff --git a/x b/x"), + }); + const service = new GitPrService(diffSource, llm, noopLogger); + + const result = await service.generatePrTitleAndBody("/repo"); + + expect(result.title).toBe("feat: add widget"); + expect(result.body).toBe("TL;DR: adds a widget."); + expect(diffSource.fetchIfStale).toHaveBeenCalledWith("/repo"); + }); +}); + +function makeHost(over: Partial = {}): CreatePrHost { + return { + getSessionEnvForTask: vi.fn().mockResolvedValue(undefined), + getCurrentBranch: vi.fn().mockResolvedValue("feature"), + createBranch: vi.fn().mockResolvedValue(undefined), + getChangedFilesHead: vi.fn().mockResolvedValue([]), + getHeadSha: vi.fn().mockResolvedValue("abc1234"), + commit: vi.fn().mockResolvedValue({ success: true, message: "committed" }), + resetSoft: vi.fn().mockResolvedValue(undefined), + getSyncStatus: vi.fn().mockResolvedValue({ hasRemote: true }), + push: vi.fn().mockResolvedValue({ success: true, message: "pushed" }), + publish: vi.fn().mockResolvedValue({ success: true, message: "published" }), + createPrViaGh: vi.fn().mockResolvedValue({ + success: true, + message: "Pull request created", + prUrl: "https://github.com/o/r/pull/1", + }), + linkBranch: vi.fn(), + getPrState: vi.fn().mockResolvedValue({ prStatus: "open" }), + ...over, + }; +} + +describe("GitPrService.createPr", () => { + it("commits, pushes, creates the PR, links the branch, and reports completion", async () => { + const host = makeHost({ + getChangedFilesHead: vi + .fn() + .mockResolvedValue([{ status: "modified", path: "x.ts" }]), + }); + const service = new GitPrService(makeDiffSource(), makeLlm(""), noopLogger); + const onProgress = vi.fn(); + + const result = await service.createPr( + { + directoryPath: "/repo", + commitMessage: "feat: x", + prTitle: "feat: x", + prBody: "body", + taskId: "task-1", + }, + host, + onProgress, + ); + + expect(result.success).toBe(true); + expect(result.prUrl).toBe("https://github.com/o/r/pull/1"); + expect(result.state).toEqual({ prStatus: "open" }); + expect(host.commit).toHaveBeenCalledWith("/repo", "feat: x", { + stagedOnly: undefined, + taskId: "task-1", + env: undefined, + }); + expect(host.push).toHaveBeenCalledWith("/repo", undefined); + expect(host.linkBranch).toHaveBeenCalledWith("task-1", "feature", "user"); + expect(onProgress).toHaveBeenLastCalledWith( + "complete", + "Pull request created", + "https://github.com/o/r/pull/1", + ); + }); + + it("publishes instead of pushing when there is no remote", async () => { + const host = makeHost({ + getChangedFilesHead: vi.fn().mockResolvedValue([]), + getSyncStatus: vi.fn().mockResolvedValue({ hasRemote: false }), + }); + const service = new GitPrService(makeDiffSource(), makeLlm(""), noopLogger); + + const result = await service.createPr( + { directoryPath: "/repo", prTitle: "t", prBody: "b" }, + host, + vi.fn(), + ); + + expect(result.success).toBe(true); + expect(host.publish).toHaveBeenCalledWith("/repo", undefined); + expect(host.push).not.toHaveBeenCalled(); + }); + + it("rolls back the commit and reports the failed step when push fails", async () => { + const host = makeHost({ + getChangedFilesHead: vi + .fn() + .mockResolvedValue([{ status: "modified", path: "x.ts" }]), + push: vi.fn().mockResolvedValue({ success: false, message: "boom" }), + }); + const service = new GitPrService(makeDiffSource(), makeLlm(""), noopLogger); + const onProgress = vi.fn(); + + const result = await service.createPr( + { directoryPath: "/repo", commitMessage: "feat: x" }, + host, + onProgress, + ); + + expect(result.success).toBe(false); + expect(result.message).toBe("boom"); + expect(result.failedStep).toBe("pushing"); + expect(host.resetSoft).toHaveBeenCalledWith("/repo", "abc1234"); + expect(host.createPrViaGh).not.toHaveBeenCalled(); + expect(onProgress).toHaveBeenLastCalledWith("error", "boom"); + }); +}); diff --git a/packages/core/src/git-pr/git-pr.ts b/packages/core/src/git-pr/git-pr.ts new file mode 100644 index 0000000000..d15f07848c --- /dev/null +++ b/packages/core/src/git-pr/git-pr.ts @@ -0,0 +1,305 @@ +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; +import { LLM_GATEWAY_SERVICE } from "../llm-gateway/identifiers"; +import type { LlmGatewayService } from "../llm-gateway/llm-gateway"; +import { CreatePrSaga, type CreatePrStep } from "./create-pr-saga"; +import { + type CreatePrHost, + type CreatePrInput, + type CreatePrResult, + GIT_DIFF_SOURCE, + type GitDiffSource, + type GitPrLogger, +} from "./identifiers"; + +const MAX_DIFF_LENGTH = 8000; + +@injectable() +export class GitPrService { + private readonly log: GitPrLogger; + + constructor( + @inject(GIT_DIFF_SOURCE) + private readonly gitDiff: GitDiffSource, + @inject(LLM_GATEWAY_SERVICE) + private readonly llm: LlmGatewayService, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + this.log = logger.scope("git-pr"); + } + + async generateCommitMessage( + directoryPath: string, + conversationContext?: string, + ): Promise<{ message: string }> { + const [stagedDiff, unstagedDiff, conventions, changedFiles] = + await Promise.all([ + this.gitDiff.getStagedDiff(directoryPath), + this.gitDiff.getUnstagedDiff(directoryPath), + this.gitDiff.getCommitConventions(directoryPath), + this.gitDiff.getChangedFilesHead(directoryPath), + ]); + + const diff = stagedDiff || unstagedDiff; + if (!diff && changedFiles.length === 0) { + return { message: "" }; + } + + const truncatedDiff = + diff.length > MAX_DIFF_LENGTH + ? `${diff.slice(0, MAX_DIFF_LENGTH)}\n... (diff truncated)` + : diff; + + const filesSummary = changedFiles + .map((f) => `${f.status}: ${f.path}`) + .join("\n"); + + const conventionHint = conventions.conventionalCommits + ? `This repository uses conventional commits. Common prefixes: ${ + conventions.commonPrefixes.join(", ") || "feat, fix, docs, chore" + }. +Example messages from this repo: +${conventions.sampleMessages.slice(0, 3).join("\n")}` + : `Example messages from this repo: +${conventions.sampleMessages.slice(0, 3).join("\n")}`; + + const system = `You are a git commit message generator. Generate a concise, descriptive commit message for the given changes. + +${conventionHint} + +Rules: +- First line should be a short summary (max 72 chars) +- Use imperative mood ("Add feature" not "Added feature") +- Be specific about what changed +- If using conventional commits, include the appropriate prefix +- If conversation context is provided, use it to understand WHY the changes were made and reflect that intent +- Do not include any explanation, just output the commit message`; + + const contextSection = conversationContext + ? `\n\nConversation context (why these changes were made):\n${conversationContext}` + : ""; + + const userMessage = `Generate a commit message for these changes: + +Changed files: +${filesSummary} + +Diff: +${truncatedDiff}${contextSection}`; + + this.log.debug("Generating commit message", { + fileCount: changedFiles.length, + diffLength: diff.length, + conventionalCommits: conventions.conventionalCommits, + hasConversationContext: !!conversationContext, + }); + + const response = await this.llm.prompt( + [{ role: "user", content: userMessage }], + { system }, + ); + + return { message: response.content.trim() }; + } + + async generatePrTitleAndBody( + directoryPath: string, + conversationContext?: string, + ): Promise<{ title: string; body: string }> { + await this.gitDiff.fetchIfStale(directoryPath); + + const [defaultBranch, currentBranch, prTemplate] = await Promise.all([ + this.gitDiff.getDefaultBranch(directoryPath), + this.gitDiff.getCurrentBranch(directoryPath), + this.gitDiff.getPrTemplate(directoryPath), + ]); + + const head = currentBranch ?? undefined; + const [branchDiff, stagedDiff, unstagedDiff, commits, conventions] = + await Promise.all([ + this.gitDiff.getDiffAgainstRemote(directoryPath, defaultBranch), + this.gitDiff.getStagedDiff(directoryPath), + this.gitDiff.getUnstagedDiff(directoryPath), + this.gitDiff.getCommitsBetweenBranches( + directoryPath, + defaultBranch, + head, + 30, + ), + this.gitDiff.getCommitConventions(directoryPath), + ]); + + const uncommittedDiff = [stagedDiff, unstagedDiff] + .filter(Boolean) + .join("\n"); + const parts = [branchDiff, uncommittedDiff].filter(Boolean); + const fullDiff = parts.join("\n"); + if (commits.length === 0 && !fullDiff) { + return { title: "", body: "" }; + } + const commitsSummary = commits.map((c) => `- ${c.message}`).join("\n"); + const truncatedDiff = fullDiff + ? fullDiff.length > MAX_DIFF_LENGTH + ? `${fullDiff.slice(0, MAX_DIFF_LENGTH)}\n... (diff truncated)` + : fullDiff + : ""; + + const templateHint = prTemplate.template + ? `The repository has a PR template. Use it as a guide for structure but adapt the content to match the actual changes:\n${prTemplate.template.slice( + 0, + 2000, + )}` + : ""; + + const conventionHint = conventions.conventionalCommits + ? `- Use conventional commit format for the title (e.g., "feat(scope): description"). Common prefixes: ${ + conventions.commonPrefixes.join(", ") || "feat, fix, docs, chore" + }.` + : ""; + + const system = `You are a PR description generator. Generate a title and detailed description for a pull request. + +Output format (use exactly this format): +TITLE: + +BODY: + + +Rules for the title: +- Short and descriptive (max 72 chars) +- Use imperative mood ("Add feature" not "Added feature") +- Be specific about what the PR accomplishes +${conventionHint} + +Rules for the body: +- Start with a TL;DR section (1-2 sentences summarizing the change) +- Include a "What changed?" section with bullet points describing the key changes +- If conversation context is provided, use it to explain WHY the changes were made in the TL;DR +- Be thorough but concise +- Use markdown formatting +- Only describe changes that are actually in the diff — do not invent or assume changes +${templateHint} + +Do not include any explanation outside the TITLE and BODY sections.`; + + const contextSection = conversationContext + ? `\n\nConversation context (why these changes were made):\n${conversationContext}` + : ""; + + const userMessage = `Generate a PR title and description for these changes: + +Branch: ${currentBranch ?? "unknown"} -> ${defaultBranch} + +Commits in this PR: +${commitsSummary || "(no commits yet - changes are uncommitted)"} + +Diff: +${truncatedDiff || "(no diff available)"}${contextSection}`; + + this.log.debug("Generating PR title and body", { + commitCount: commits.length, + diffLength: fullDiff.length, + hasTemplate: !!prTemplate.template, + hasConversationContext: !!conversationContext, + conventionalCommits: conventions.conventionalCommits, + }); + + const response = await this.llm.prompt( + [{ role: "user", content: userMessage }], + { system, maxTokens: 2000 }, + ); + + const content = response.content.trim(); + const titleMatch = content.match(/^TITLE:\s*(.+?)(?:\n|$)/m); + const bodyMatch = content.match(/BODY:\s*([\s\S]+)$/m); + + return { + title: titleMatch?.[1]?.trim() ?? "", + body: bodyMatch?.[1]?.trim() ?? "", + }; + } + + /** + * Orchestrate branch -> commit -> push -> PR creation as a saga. Host git/gh + * operations come through `host`; commit-message and PR-description generation + * reuse this service's own LLM-backed methods. Progress is reported through + * `onProgress` so the host can stream it to the renderer. + */ + async createPr( + input: CreatePrInput, + host: CreatePrHost, + onProgress: (step: CreatePrStep, message: string, prUrl?: string) => void, + ): Promise { + const { directoryPath } = input; + const sessionEnv = await host.getSessionEnvForTask(input.taskId); + + const saga = new CreatePrSaga( + { + getCurrentBranch: (dir) => host.getCurrentBranch(dir), + createBranch: (dir, name) => host.createBranch(dir, name), + getChangedFilesHead: (dir) => host.getChangedFilesHead(dir), + generateCommitMessage: (dir) => + this.generateCommitMessage(dir, input.conversationContext), + getHeadSha: (dir) => host.getHeadSha(dir), + commit: (dir, message, options) => + host.commit(dir, message, { ...options, env: sessionEnv }), + resetSoft: (dir, sha) => host.resetSoft(dir, sha), + getSyncStatus: (dir) => host.getSyncStatus(dir), + push: (dir) => host.push(dir, sessionEnv), + publish: (dir) => host.publish(dir, sessionEnv), + generatePrTitleAndBody: (dir) => + this.generatePrTitleAndBody(dir, input.conversationContext), + createPr: (dir, title, body, draft) => + host.createPrViaGh(dir, title, body, draft, sessionEnv), + onProgress, + }, + this.log, + ); + + const result = await saga.run({ + directoryPath, + branchName: input.branchName, + commitMessage: input.commitMessage, + prTitle: input.prTitle, + prBody: input.prBody, + draft: input.draft, + stagedOnly: input.stagedOnly, + taskId: input.taskId, + }); + + if (!result.success) { + onProgress("error", result.error); + return { + success: false, + message: result.error, + prUrl: null, + failedStep: result.failedStep, + }; + } + + const state = await host.getPrState(directoryPath); + + if (input.taskId) { + const linkedBranch = + input.branchName ?? (await host.getCurrentBranch(directoryPath)); + if (linkedBranch) { + host.linkBranch(input.taskId, linkedBranch, "user"); + } + } + + onProgress( + "complete", + "Pull request created", + result.data.prUrl ?? undefined, + ); + + return { + success: true, + message: "Pull request created", + prUrl: result.data.prUrl, + failedStep: null, + state, + }; + } +} diff --git a/packages/core/src/git-pr/identifiers.ts b/packages/core/src/git-pr/identifiers.ts new file mode 100644 index 0000000000..d288e71282 --- /dev/null +++ b/packages/core/src/git-pr/identifiers.ts @@ -0,0 +1,104 @@ +import type { SagaLogger } from "@posthog/shared"; + +export const GIT_PR_SERVICE = Symbol.for("posthog.core.gitPrService"); +export const GIT_DIFF_SOURCE = Symbol.for("posthog.core.gitDiffSource"); + +export interface GitCommitConventions { + conventionalCommits: boolean; + commonPrefixes: string[]; + sampleMessages: string[]; +} + +export interface GitChangedFileSummary { + status: string; + path: string; +} + +export interface GitCommitSummary { + message: string; +} + +export interface GitPrTemplate { + template: string | null; +} + +export interface GitDiffSource { + getStagedDiff(directoryPath: string): Promise; + getUnstagedDiff(directoryPath: string): Promise; + getCommitConventions(directoryPath: string): Promise; + getChangedFilesHead(directoryPath: string): Promise; + getDefaultBranch(directoryPath: string): Promise; + getCurrentBranch(directoryPath: string): Promise; + getDiffAgainstRemote( + directoryPath: string, + baseBranch: string, + ): Promise; + getCommitsBetweenBranches( + directoryPath: string, + baseBranch: string, + head: string | undefined, + limit: number, + ): Promise; + getPrTemplate(directoryPath: string): Promise; + fetchIfStale(directoryPath: string): Promise; +} + +export interface GitPrLogger extends SagaLogger {} + +export interface CreatePrInput { + directoryPath: string; + branchName?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + draft?: boolean; + stagedOnly?: boolean; + taskId?: string; + conversationContext?: string; +} + +export interface CreatePrResult { + success: boolean; + message: string; + prUrl: string | null; + failedStep: string | null; + state?: unknown; +} + +export interface CreatePrHost { + getSessionEnvForTask( + taskId: string | undefined, + ): Promise | undefined>; + getCurrentBranch(directoryPath: string): Promise; + createBranch(directoryPath: string, name: string): Promise; + getChangedFilesHead(directoryPath: string): Promise; + getHeadSha(directoryPath: string): Promise; + commit( + directoryPath: string, + message: string, + options: { + stagedOnly?: boolean; + taskId?: string; + env?: Record; + }, + ): Promise<{ success: boolean; message: string }>; + resetSoft(directoryPath: string, sha: string): Promise; + getSyncStatus(directoryPath: string): Promise<{ hasRemote: boolean }>; + push( + directoryPath: string, + env?: Record, + ): Promise<{ success: boolean; message: string }>; + publish( + directoryPath: string, + env?: Record, + ): Promise<{ success: boolean; message: string }>; + createPrViaGh( + directoryPath: string, + title?: string, + body?: string, + draft?: boolean, + env?: Record, + ): Promise<{ success: boolean; message: string; prUrl: string | null }>; + linkBranch(taskId: string, branch: string, source: "user"): void; + getPrState(directoryPath: string): Promise; +} diff --git a/packages/core/src/git/git-host.module.ts b/packages/core/src/git/git-host.module.ts new file mode 100644 index 0000000000..e975d0ca6d --- /dev/null +++ b/packages/core/src/git/git-host.module.ts @@ -0,0 +1,8 @@ +import { ContainerModule } from "inversify"; +import { GitHostService } from "./git-host"; +import { GIT_SERVICE } from "./identifiers"; + +export const gitHostModule = new ContainerModule(({ bind }) => { + bind(GitHostService).toSelf().inSingletonScope(); + bind(GIT_SERVICE).toService(GitHostService); +}); diff --git a/packages/core/src/git/git-host.ts b/packages/core/src/git/git-host.ts new file mode 100644 index 0000000000..ede87caad8 --- /dev/null +++ b/packages/core/src/git/git-host.ts @@ -0,0 +1,276 @@ +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import type { GitPrService } from "../git-pr/git-pr"; +import type { CreatePrHost } from "../git-pr/identifiers"; +import { GIT_PR_SERVICE } from "../git-pr/identifiers"; +import { + GitServiceEvent, + type GitServiceEvents, + type GitWorkspaceLookup, + type HostGitAgentService, + type HostGitWorkspaceClient, + type SidebarPrState, + type TaskPrStatus, +} from "./host-git"; +import { + GIT_AGENT_SERVICE, + GIT_WORKSPACE_CLIENT, + GIT_WORKSPACE_LOOKUP, +} from "./identifiers"; +import type { + CreatePrInput, + CreatePrOutput, + GitStateSnapshot, +} from "./router-schemas"; + +function mapPrState( + state: string | null, + merged: boolean, + draft: boolean, +): SidebarPrState { + const lower = state?.toLowerCase() ?? null; + if (merged || lower === "merged") return "merged"; + if (lower === "closed") return "closed"; + if (draft) return "draft"; + if (lower === "open") return "open"; + return null; +} + +@injectable() +export class GitHostService extends TypedEventEmitter { + private readonly log: ScopedLogger; + + constructor( + @inject(GIT_WORKSPACE_CLIENT) + private readonly workspaceClient: HostGitWorkspaceClient, + @inject(GIT_PR_SERVICE) + private readonly gitPrService: GitPrService, + @inject(GIT_AGENT_SERVICE) + private readonly agentService: HostGitAgentService, + @inject(GIT_WORKSPACE_LOOKUP) + private readonly workspaceLookup: GitWorkspaceLookup, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + super(); + this.log = logger.scope("git-host"); + } + + private get git() { + return this.workspaceClient.git; + } + + async cloneRepository( + repoUrl: string, + targetPath: string, + cloneId: string, + ): Promise<{ cloneId: string }> { + const subscription = this.git.onCloneProgress.subscribe(undefined, { + onData: (payload) => { + if (payload.cloneId === cloneId) { + this.emit(GitServiceEvent.CloneProgress, payload); + } + }, + onError: (err) => + this.log.warn("clone progress subscription error", { err }), + }); + try { + return await this.git.cloneRepository.mutate({ + repoUrl, + targetPath, + cloneId, + }); + } finally { + subscription.unsubscribe(); + } + } + + async createPr(input: CreatePrInput): Promise { + const flowId = input.flowId; + const result = await this.gitPrService.createPr( + { + directoryPath: input.directoryPath, + branchName: input.branchName, + commitMessage: input.commitMessage, + prTitle: input.prTitle, + prBody: input.prBody, + draft: input.draft, + stagedOnly: input.stagedOnly, + taskId: input.taskId, + conversationContext: input.conversationContext, + }, + this.buildCreatePrHost(), + (step, message, prUrl) => { + this.emit(GitServiceEvent.CreatePrProgress, { + flowId, + step, + message, + prUrl, + }); + }, + ); + + return { + success: result.success, + message: result.message, + prUrl: result.prUrl, + failedStep: result.failedStep as CreatePrOutput["failedStep"], + state: result.state as GitStateSnapshot | undefined, + }; + } + + async getTaskPrStatus( + taskId: string, + cloudPrUrl: string | null, + ): Promise { + const workspace = await this.workspaceLookup.getWorkspace(taskId); + if (!workspace) return { prState: null, hasDiff: false }; + + const { mode, worktreePath, folderPath, linkedBranch } = workspace; + const isCloud = mode === "cloud"; + const repoPath = worktreePath ?? (folderPath || null); + + if (isCloud && cloudPrUrl) { + const details = await this.git.getPrDetailsByUrl.query({ + prUrl: cloudPrUrl, + }); + if (details) { + return { + prState: mapPrState(details.state, details.merged, details.draft), + hasDiff: false, + }; + } + return { prState: null, hasDiff: false }; + } + + if (isCloud) return { prState: null, hasDiff: false }; + + if (linkedBranch && repoPath) { + const prUrl = await this.git.getPrUrlForBranch.query({ + directoryPath: repoPath, + branchName: linkedBranch, + }); + if (prUrl) { + const details = await this.git.getPrDetailsByUrl.query({ prUrl }); + if (details) { + return { + prState: mapPrState(details.state, details.merged, details.draft), + hasDiff: false, + }; + } + } + return { prState: null, hasDiff: false }; + } + + if (worktreePath) { + const prStatus = await this.git.getPrStatus.query({ + directoryPath: worktreePath, + }); + if (prStatus.prExists && prStatus.prState) { + return { + prState: mapPrState( + prStatus.prState, + false, + prStatus.isDraft ?? false, + ), + hasDiff: false, + }; + } + + const [diffStats, syncStatus] = await Promise.all([ + this.git.getDiffStats.query({ directoryPath: worktreePath }), + this.git.getGitSyncStatus.query({ directoryPath: worktreePath }), + ]); + + const hasDiff = + (diffStats?.filesChanged ?? 0) > 0 || + (syncStatus?.aheadOfDefault ?? 0) > 0; + + return { prState: null, hasDiff }; + } + + return { prState: null, hasDiff: false }; + } + + private async getSessionEnv( + taskId: string | undefined, + ): Promise | undefined> { + if (!taskId) return undefined; + try { + const env = await this.agentService.getSessionEnvForTask(taskId); + return Object.keys(env).length > 0 ? env : undefined; + } catch (err) { + this.log.warn("Failed to load session env for task", { taskId, err }); + return undefined; + } + } + + private async getPrStateSnapshot( + directoryPath: string, + ): Promise { + const [changedFiles, diffStats, syncStatus, latestCommit, prStatus] = + await Promise.allSettled([ + this.git.getChangedFilesHead.query({ directoryPath }), + this.git.getDiffStats.query({ directoryPath }), + this.git.getGitSyncStatus.query({ directoryPath, forceRefresh: true }), + this.git.getLatestCommit.query({ directoryPath }), + this.git.getPrStatus.query({ directoryPath }), + ]); + const ok = (r: PromiseSettledResult): T | undefined => + r.status === "fulfilled" ? r.value : undefined; + return { + changedFiles: ok(changedFiles), + diffStats: ok(diffStats), + syncStatus: ok(syncStatus), + latestCommit: ok(latestCommit) ?? undefined, + prStatus: ok(prStatus), + }; + } + + private buildCreatePrHost(): CreatePrHost { + const git = this.git; + return { + getSessionEnvForTask: (taskId) => this.getSessionEnv(taskId), + getCurrentBranch: (dir) => + git.getCurrentBranch.query({ directoryPath: dir }), + createBranch: async (dir, name) => { + await git.createBranch.mutate({ directoryPath: dir, branchName: name }); + }, + getChangedFilesHead: (dir) => + git.getChangedFilesHead.query({ directoryPath: dir }), + getHeadSha: (dir) => git.getHeadSha.query({ directoryPath: dir }), + commit: (dir, message, options) => + git.commit.mutate({ + directoryPath: dir, + message, + stagedOnly: options.stagedOnly, + env: options.env, + }), + resetSoft: async (dir, sha) => { + await git.resetSoft.mutate({ directoryPath: dir, sha }); + }, + getSyncStatus: (dir) => + git.getGitSyncStatus.query({ directoryPath: dir }), + push: (dir, env) => + git.push.mutate({ directoryPath: dir, remote: "origin", env }), + publish: (dir, env) => + git.publish.mutate({ directoryPath: dir, remote: "origin", env }), + createPrViaGh: (dir, title, body, draft, env) => + git.createPrViaGh.mutate({ + directoryPath: dir, + title, + body, + draft, + env, + }), + linkBranch: (taskId, branch, source) => + this.workspaceLookup.linkBranch(taskId, branch, source), + getPrState: (dir) => this.getPrStateSnapshot(dir), + }; + } +} diff --git a/packages/core/src/git/host-git.ts b/packages/core/src/git/host-git.ts new file mode 100644 index 0000000000..396a9163ef --- /dev/null +++ b/packages/core/src/git/host-git.ts @@ -0,0 +1,61 @@ +import type { WorkspaceClient } from "@posthog/workspace-client/client"; +import type { + CloneProgressPayload, + CreatePrInput, + CreatePrOutput, + CreatePrProgressPayload, +} from "./router-schemas"; + +export const GitServiceEvent = { + CloneProgress: "cloneProgress", + CreatePrProgress: "createPrProgress", +} as const; + +export interface GitServiceEvents { + [GitServiceEvent.CloneProgress]: CloneProgressPayload; + [GitServiceEvent.CreatePrProgress]: CreatePrProgressPayload; +} + +export interface HostGitService { + cloneRepository( + repoUrl: string, + targetPath: string, + cloneId: string, + ): Promise<{ cloneId: string }>; + createPr(input: CreatePrInput): Promise; + getTaskPrStatus( + taskId: string, + cloudPrUrl: string | null, + ): Promise; + toIterable( + event: K, + options: { signal?: AbortSignal }, + ): AsyncIterable; +} + +export interface HostGitWorkspaceClient { + git: WorkspaceClient["git"]; +} + +export interface HostGitAgentService { + getSessionEnvForTask(taskId: string): Promise>; +} + +export type SidebarPrState = "merged" | "open" | "draft" | "closed" | null; + +export interface TaskPrStatus { + prState: SidebarPrState; + hasDiff: boolean; +} + +export interface GitPrWorkspaceInfo { + mode: string; + worktreePath?: string | null; + folderPath?: string | null; + linkedBranch?: string | null; +} + +export interface GitWorkspaceLookup { + getWorkspace(taskId: string): Promise; + linkBranch(taskId: string, branch: string, source: "user"): void; +} diff --git a/packages/core/src/git/identifiers.ts b/packages/core/src/git/identifiers.ts new file mode 100644 index 0000000000..3f489cca8f --- /dev/null +++ b/packages/core/src/git/identifiers.ts @@ -0,0 +1,8 @@ +export const GIT_SERVICE = Symbol.for("posthog.core.gitService"); +export const GIT_WORKSPACE_CLIENT = Symbol.for( + "posthog.core.gitWorkspaceClient", +); +export const GIT_AGENT_SERVICE = Symbol.for("posthog.core.gitAgentService"); +export const GIT_WORKSPACE_LOOKUP = Symbol.for( + "posthog.core.gitWorkspaceLookup", +); diff --git a/apps/code/src/main/services/git/schemas.ts b/packages/core/src/git/router-schemas.ts similarity index 81% rename from apps/code/src/main/services/git/schemas.ts rename to packages/core/src/git/router-schemas.ts index f25a73f69c..2a6c155b9b 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/packages/core/src/git/router-schemas.ts @@ -1,6 +1,25 @@ +import { + type GitHubIssue, + type GithubIssueState, + type GithubPullRequest, + type GithubRef, + type GithubRefKind, + type GithubRefState, + githubIssueSchema, + githubIssueStateSchema, + githubRefKindSchema, + githubRefSchema, + githubRefStateSchema, + type PrActionType, + type PrReviewComment, + type PrReviewThread, + prActionTypeSchema, + prReviewCommentSchema, + prReviewCommentUserSchema, + prReviewThreadSchema, +} from "@posthog/shared"; import { z } from "zod"; -// Common schemas export const directoryPathInput = z.object({ directoryPath: z.string(), }); @@ -66,7 +85,6 @@ export const gitRepoInfoSchema = z.object({ export type GitRepoInfo = z.infer; -// detectRepo schemas export const detectRepoInput = z.object({ directoryPath: z.string(), }); @@ -83,14 +101,12 @@ export const detectRepoOutput = z export type DetectRepoInput = z.infer; export type DetectRepoResult = z.infer; -// validateRepo schemas export const validateRepoInput = z.object({ directoryPath: z.string(), }); export const validateRepoOutput = z.boolean(); -// cloneRepository schemas export const cloneRepositoryInput = z.object({ repoUrl: z.string(), targetPath: z.string(), @@ -111,43 +127,35 @@ export const cloneProgressPayload = z.object({ export type CloneProgressPayload = z.infer; -// getChangedFilesHead schemas export const getChangedFilesHeadInput = directoryPathInput; export const getChangedFilesHeadOutput = z.array(changedFileSchema); -// getFileAtHead schemas export const getFileAtHeadInput = z.object({ directoryPath: z.string(), filePath: z.string(), }); export const getFileAtHeadOutput = z.string().nullable(); -// Shared diff schemas (getDiffHead, getDiffCached, getDiffUnstaged) export const diffInput = z.object({ directoryPath: z.string(), ignoreWhitespace: z.boolean().optional(), }); export const diffOutput = z.string(); -// getDiffStats schemas export const getDiffStatsInput = directoryPathInput; export const getDiffStatsOutput = diffStatsSchema; -// stageFiles / unstageFiles shared schema export const stageFilesInput = z.object({ directoryPath: z.string(), paths: z.array(z.string()), }); -// getCurrentBranch schemas export const getCurrentBranchInput = directoryPathInput; export const getCurrentBranchOutput = z.string().nullable(); -// getAllBranches schemas export const getAllBranchesInput = directoryPathInput; export const getAllBranchesOutput = z.array(z.string()); -// getGitBusyState schemas export const gitBusyOperationSchema = z.enum([ "rebase", "merge", @@ -163,12 +171,9 @@ export const gitBusyStateSchema = z.union([ }), ]); -export type { GitBusyOperation, GitBusyState } from "../../../shared/types"; - export const getGitBusyStateInput = directoryPathInput; export const getGitBusyStateOutput = gitBusyStateSchema; -// createBranch schemas export const createBranchInput = z.object({ directoryPath: z.string(), branchName: z.string(), @@ -183,26 +188,21 @@ export const checkoutBranchOutput = z.object({ currentBranch: z.string(), }); -// discardFileChanges schemas export const discardFileChangesInput = z.object({ directoryPath: z.string(), filePath: z.string(), fileStatus: gitFileStatusSchema, }); -// getGitSyncStatus schemas export const getGitSyncStatusInput = directoryPathInput; export const getGitSyncStatusOutput = gitSyncStatusSchema; -// getLatestCommit schemas export const getLatestCommitInput = directoryPathInput; export const getLatestCommitOutput = gitCommitInfoSchema.nullable(); -// getGitRepoInfo schemas export const getGitRepoInfoInput = directoryPathInput; export const getGitRepoInfoOutput = gitRepoInfoSchema.nullable(); -// Push operation export const pushInput = z.object({ directoryPath: z.string(), remote: z.string().default("origin"), @@ -212,7 +212,6 @@ export const pushInput = z.object({ export type PushInput = z.infer; -// Pull operation export const pullInput = z.object({ directoryPath: z.string(), remote: z.string().default("origin"), @@ -221,7 +220,6 @@ export const pullInput = z.object({ export type PullInput = z.infer; -// Commit operation export const commitInput = z.object({ directoryPath: z.string(), message: z.string(), @@ -233,7 +231,6 @@ export const commitInput = z.object({ export type CommitInput = z.infer; -// Git CLI status export const gitStatusOutput = z.object({ installed: z.boolean(), version: z.string().nullable(), @@ -241,7 +238,6 @@ export const gitStatusOutput = z.object({ export type GitStatusOutput = z.infer; -// GitHub CLI status export const ghStatusOutput = z.object({ installed: z.boolean(), version: z.string().nullable(), @@ -260,7 +256,6 @@ export const ghAuthTokenOutput = z.object({ export type GhAuthTokenOutput = z.infer; -// Pull request status export const prStatusInput = directoryPathInput; export const prStatusOutput = z.object({ hasRemote: z.boolean(), @@ -279,7 +274,6 @@ export const prStatusOutput = z.object({ export type PrStatusInput = z.infer; export type PrStatusOutput = z.infer; -// Look up the PR for an arbitrary branch (not necessarily the current one). export const getPrUrlForBranchInput = z.object({ directoryPath: z.string(), branchName: z.string(), @@ -289,7 +283,6 @@ export const getPrUrlForBranchOutput = z.string().nullable(); export type GetPrUrlForBranchInput = z.infer; export type GetPrUrlForBranchOutput = z.infer; -// Create PR operation export const createPrInput = z.object({ directoryPath: z.string(), flowId: z.string(), @@ -305,7 +298,6 @@ export const createPrInput = z.object({ export type CreatePrInput = z.infer; -// Open PR operation export const openPrInput = directoryPathInput; export const openPrOutput = z.object({ success: z.boolean(), @@ -316,7 +308,6 @@ export const openPrOutput = z.object({ export type OpenPrInput = z.infer; export type OpenPrOutput = z.infer; -// Publish (push with upstream) operation export const publishInput = z.object({ directoryPath: z.string(), remote: z.string().default("origin"), @@ -324,7 +315,6 @@ export const publishInput = z.object({ export type PublishInput = z.infer; -// Sync (pull then push) operation export const syncInput = z.object({ directoryPath: z.string(), remote: z.string().default("origin"), @@ -332,7 +322,6 @@ export const syncInput = z.object({ export type SyncInput = z.infer; -// PR Template lookup export const getPrTemplateInput = directoryPathInput; export const getPrTemplateOutput = z.object({ @@ -342,7 +331,6 @@ export const getPrTemplateOutput = z.object({ export type GetPrTemplateOutput = z.infer; -// Commit conventions analysis export const getCommitConventionsInput = z.object({ directoryPath: z.string(), sampleSize: z.number().default(20), @@ -358,13 +346,11 @@ export type GetCommitConventionsOutput = z.infer< typeof getCommitConventionsOutput >; -// getPrChangedFiles schemas export const getPrChangedFilesInput = z.object({ prUrl: z.string(), }); export const getPrChangedFilesOutput = z.array(changedFileSchema); -// getPrDetailsByUrl schemas export const getPrDetailsByUrlInput = z.object({ prUrl: z.string(), }); @@ -375,46 +361,19 @@ export const getPrDetailsByUrlOutput = z.object({ }); export type PrDetailsByUrlOutput = z.infer; -// getPrReviewComments schemas -export const prReviewCommentUserSchema = z.object({ - login: z.string(), - avatar_url: z.string(), -}); - -export const prReviewCommentSchema = z.object({ - id: z.number(), - body: z.string(), - path: z.string(), - line: z.number().nullable(), - original_line: z.number().nullable(), - side: z.enum(["LEFT", "RIGHT"]), - start_line: z.number().nullable(), - start_side: z.enum(["LEFT", "RIGHT"]).nullable(), - diff_hunk: z.string(), - in_reply_to_id: z.number().nullish(), - user: prReviewCommentUserSchema, - created_at: z.string(), - updated_at: z.string(), - subject_type: z.enum(["line", "file"]).nullable(), -}); - -export type PrReviewComment = z.infer; - -export const prReviewThreadSchema = z.object({ - nodeId: z.string(), - isResolved: z.boolean(), - rootId: z.number(), - filePath: z.string(), - comments: z.array(prReviewCommentSchema), -}); -export type PrReviewThread = z.infer; +export { + prActionTypeSchema, + prReviewCommentSchema, + prReviewCommentUserSchema, + prReviewThreadSchema, +}; +export type { PrActionType, PrReviewComment, PrReviewThread }; export const getPrReviewCommentsInput = z.object({ prUrl: z.string(), }); export const getPrReviewCommentsOutput = z.array(prReviewThreadSchema); -// resolveReviewThread schemas export const resolveReviewThreadInput = z.object({ prUrl: z.string(), threadNodeId: z.string(), @@ -428,7 +387,6 @@ export type ResolveReviewThreadOutput = z.infer< typeof resolveReviewThreadOutput >; -// replyToPrComment schemas export const replyToPrCommentInput = z.object({ prUrl: z.string(), commentId: z.number(), @@ -440,13 +398,9 @@ export const replyToPrCommentOutput = z.object({ }); export type ReplyToPrCommentOutput = z.infer; -// updatePrByUrl schemas -export const prActionType = z.enum(["close", "reopen", "ready", "draft"]); -export type PrActionType = z.infer; - export const updatePrByUrlInput = z.object({ prUrl: z.string(), - action: prActionType, + action: prActionTypeSchema, }); export const updatePrByUrlOutput = z.object({ success: z.boolean(), @@ -568,31 +522,21 @@ export const discardFileChangesOutput = z.object({ export type DiscardFileChangesOutput = z.infer; -export const githubRefKindSchema = z.enum(["issue", "pr"]); -export type GithubRefKind = z.infer; - -export const githubRefStateSchema = z.enum(["OPEN", "CLOSED", "MERGED"]); -export type GithubRefState = z.infer; - -export const githubRefSchema = z.object({ - kind: githubRefKindSchema, - number: z.number(), - title: z.string(), - state: githubRefStateSchema, - labels: z.array(z.string()), - url: z.string(), - repo: z.string(), - isDraft: z.boolean().optional(), -}); - -export type GithubRef = z.infer; - -// Legacy alias kept so callers that previously consumed only issues continue to work. -export const githubIssueStateSchema = githubRefStateSchema; -export type GithubIssueState = GithubRefState; -export const githubIssueSchema = githubRefSchema; -export type GitHubIssue = GithubRef; -export type GithubPullRequest = GithubRef; +export { + githubIssueSchema, + githubIssueStateSchema, + githubRefKindSchema, + githubRefSchema, + githubRefStateSchema, +}; +export type { + GitHubIssue, + GithubIssueState, + GithubPullRequest, + GithubRef, + GithubRefKind, + GithubRefState, +}; export const searchGithubRefsInput = z.object({ directoryPath: z.string(), diff --git a/apps/code/src/main/services/handoff/handoff-saga.test.ts b/packages/core/src/handoff/handoff-saga.test.ts similarity index 79% rename from apps/code/src/main/services/handoff/handoff-saga.test.ts rename to packages/core/src/handoff/handoff-saga.test.ts index eb6760457a..4ca68cdce8 100644 --- a/apps/code/src/main/services/handoff/handoff-saga.test.ts +++ b/packages/core/src/handoff/handoff-saga.test.ts @@ -1,11 +1,11 @@ -import type * as AgentResume from "@posthog/agent/resume"; -import type * as AgentTypes from "@posthog/agent/types"; +import type { GitHandoffCheckpoint } from "@posthog/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { HandoffSagaDeps, HandoffSagaInput } from "./handoff-saga"; -import { HandoffSaga } from "./handoff-saga"; - -const mockResumeFromLog = vi.hoisted(() => vi.fn()); -const mockFormatConversation = vi.hoisted(() => vi.fn()); +import { + type HandoffResumeState, + HandoffSaga, + type HandoffSagaDeps, + type HandoffSagaInput, +} from "./handoff-saga"; const DEFAULT_LOCAL_GIT_STATE = { head: "abc123", @@ -15,11 +15,6 @@ const DEFAULT_LOCAL_GIT_STATE = { upstreamMergeRef: "refs/heads/feature/handoff", }; -vi.mock("@posthog/agent/resume", () => ({ - resumeFromLog: mockResumeFromLog, - formatConversationForResume: mockFormatConversation, -})); - function createInput( overrides: Partial = {}, ): HandoffSagaInput { @@ -34,8 +29,8 @@ function createInput( } function createCheckpoint( - overrides: Partial = {}, -): AgentTypes.GitCheckpointEvent { + overrides: Partial = {}, +): GitHandoffCheckpoint { return { checkpointId: "checkpoint-1", commit: "checkpointcommit123", @@ -45,7 +40,6 @@ function createCheckpoint( branch: "feature/handoff", indexTree: "index123", worktreeTree: "worktree123", - artifactPath: "gs://bucket/checkpoint-1.bundle", timestamp: "2026-04-07T00:00:00Z", upstreamRemote: "origin", upstreamMergeRef: "refs/heads/feature/handoff", @@ -54,21 +48,28 @@ function createCheckpoint( }; } +function createResumeState( + overrides: Partial = {}, +): HandoffResumeState { + return { + conversation: [], + latestGitCheckpoint: null, + ...overrides, + }; +} + function createDeps(overrides: Partial = {}): HandoffSagaDeps { return { - createApiClient: vi.fn().mockReturnValue({ - getTaskRun: vi.fn().mockResolvedValue({ - log_url: "https://logs.example.com/run-1.ndjson", - }), - updateTaskRun: vi.fn().mockResolvedValue({}), + markRunEnvironmentLocal: vi.fn().mockResolvedValue(undefined), + fetchResumeState: vi.fn().mockResolvedValue({ + resumeState: createResumeState(), + cloudLogUrl: "https://logs.example.com/run-1.ndjson", }), + formatConversation: vi.fn().mockReturnValue("conversation summary"), applyGitCheckpoint: vi.fn().mockResolvedValue(undefined), updateWorkspaceMode: vi.fn(), attachWorkspaceToFolder: vi.fn().mockReturnValue({ revert: vi.fn() }), - reconnectSession: vi.fn().mockResolvedValue({ - sessionId: "session-1", - channel: "ch-1", - }), + reconnectSession: vi.fn().mockResolvedValue({ sessionId: "session-1" }), closeCloudRun: vi.fn().mockResolvedValue(undefined), seedLocalLogs: vi.fn().mockResolvedValue(undefined), killSession: vi.fn().mockResolvedValue(undefined), @@ -78,18 +79,6 @@ function createDeps(overrides: Partial = {}): HandoffSagaDeps { }; } -function createResumeState( - overrides: Partial = {}, -): AgentResume.ResumeState { - return { - conversation: [], - latestGitCheckpoint: null, - interrupted: false, - logEntryCount: 0, - ...overrides, - }; -} - function getProgressSteps(deps: HandoffSagaDeps): string[] { return (deps.onProgress as ReturnType).mock.calls.map( (call: unknown[]) => call[0] as string, @@ -100,11 +89,20 @@ async function runSaga( overrides: { input?: Partial; deps?: Partial; - resumeState?: Partial; + resumeState?: Partial; + cloudLogUrl?: string | null; } = {}, ) { - mockResumeFromLog.mockResolvedValue(createResumeState(overrides.resumeState)); - const deps = createDeps(overrides.deps); + const deps = createDeps({ + fetchResumeState: vi.fn().mockResolvedValue({ + resumeState: createResumeState(overrides.resumeState), + cloudLogUrl: + overrides.cloudLogUrl === undefined + ? "https://logs.example.com/run-1.ndjson" + : overrides.cloudLogUrl, + }), + ...overrides.deps, + }); const saga = new HandoffSaga(deps); const result = await saga.run(createInput(overrides.input)); return { deps, result }; @@ -113,7 +111,6 @@ async function runSaga( describe("HandoffSaga", () => { beforeEach(() => { vi.clearAllMocks(); - mockFormatConversation.mockReturnValue("conversation summary"); }); it("completes happy path with checkpoint", async () => { @@ -124,7 +121,6 @@ describe("HandoffSaga", () => { { role: "user", content: [{ type: "text", text: "hello" }] }, ], latestGitCheckpoint: checkpoint, - logEntryCount: 10, }, }); @@ -147,14 +143,27 @@ describe("HandoffSaga", () => { ); const closeOrder = (deps.closeCloudRun as ReturnType).mock .invocationCallOrder[0]; - const fetchOrder = mockResumeFromLog.mock.invocationCallOrder[0]; + const fetchOrder = (deps.fetchResumeState as ReturnType).mock + .invocationCallOrder[0]; expect(closeOrder).toBeLessThan(fetchOrder); }); + it("marks the run environment local before rebuilding state", async () => { + const { deps } = await runSaga(); + + expect(deps.markRunEnvironmentLocal).toHaveBeenCalledWith( + "task-1", + "run-1", + ); + const envOrder = (deps.markRunEnvironmentLocal as ReturnType) + .mock.invocationCallOrder[0]; + const fetchOrder = (deps.fetchResumeState as ReturnType).mock + .invocationCallOrder[0]; + expect(envOrder).toBeLessThan(fetchOrder); + }); + it("skips checkpoint apply when no checkpoint is present", async () => { - const { deps, result } = await runSaga({ - resumeState: { logEntryCount: 5 }, - }); + const { deps, result } = await runSaga(); expect(result.success).toBe(true); if (!result.success) return; @@ -172,27 +181,20 @@ describe("HandoffSaga", () => { }); it("skips seeding logs when cloudLogUrl is falsy", async () => { - const apiClient = { - getTaskRun: vi.fn().mockResolvedValue({ log_url: undefined }), - }; - const { deps } = await runSaga({ - deps: { - createApiClient: vi.fn().mockReturnValue(apiClient), - }, - }); + const { deps } = await runSaga({ cloudLogUrl: null }); expect(deps.seedLocalLogs).not.toHaveBeenCalled(); }); it("sets pending context with handoff summary", async () => { - mockFormatConversation.mockReturnValue("User said hello"); - const { deps } = await runSaga({ + deps: { + formatConversation: vi.fn().mockReturnValue("User said hello"), + }, resumeState: { conversation: [ { role: "user", content: [{ type: "text", text: "hello" }] }, ], - logEntryCount: 1, }, }); @@ -282,9 +284,9 @@ describe("HandoffSaga", () => { }); it("fails at fetch_and_rebuild without touching workspace state", async () => { - mockResumeFromLog.mockRejectedValue(new Error("API down")); - - const deps = createDeps(); + const deps = createDeps({ + fetchResumeState: vi.fn().mockRejectedValue(new Error("API down")), + }); const saga = new HandoffSaga(deps); const result = await saga.run(createInput()); @@ -312,7 +314,6 @@ describe("HandoffSaga", () => { "/repo", "task-1", "run-1", - expect.any(Object), DEFAULT_LOCAL_GIT_STATE, ); }); diff --git a/apps/code/src/main/services/handoff/handoff-saga.ts b/packages/core/src/handoff/handoff-saga.ts similarity index 78% rename from apps/code/src/main/services/handoff/handoff-saga.ts rename to packages/core/src/handoff/handoff-saga.ts index 05d38d3aed..dcf9d6bc13 100644 --- a/apps/code/src/main/services/handoff/handoff-saga.ts +++ b/packages/core/src/handoff/handoff-saga.ts @@ -1,15 +1,12 @@ -import type { PostHogAPIClient } from "@posthog/agent/posthog-api"; -import type * as AgentResume from "@posthog/agent/resume"; import { - formatConversationForResume, - resumeFromLog, -} from "@posthog/agent/resume"; -import type * as AgentTypes from "@posthog/agent/types"; -import { Saga, type SagaLogger } from "@posthog/shared"; -import type { SessionResponse } from "../agent/schemas"; -import type { HandoffBaseDeps, HandoffExecuteInput } from "./schemas"; + type GitHandoffCheckpoint, + type HandoffLocalGitState, + Saga, + type SagaLogger, +} from "@posthog/shared"; +import type { HandoffBaseDeps, HandoffSagaInput } from "./types"; -export type HandoffSagaInput = HandoffExecuteInput; +export type { HandoffSagaInput } from "./types"; export interface HandoffSagaOutput { sessionId: string; @@ -17,18 +14,24 @@ export interface HandoffSagaOutput { conversationTurns: number; } +export interface HandoffResumeState { + conversation: unknown[]; + latestGitCheckpoint: GitHandoffCheckpoint | null; +} + export interface HandoffSagaDeps extends HandoffBaseDeps { - attachWorkspaceToFolder( + markRunEnvironmentLocal(taskId: string, runId: string): Promise; + fetchResumeState( taskId: string, - repoPath: string, - ): { revert: () => void }; + runId: string, + ): Promise<{ resumeState: HandoffResumeState; cloudLogUrl: string | null }>; + formatConversation(conversation: unknown[]): string; applyGitCheckpoint( - checkpoint: AgentTypes.GitCheckpointEvent, + checkpoint: GitHandoffCheckpoint, repoPath: string, taskId: string, runId: string, - apiClient: PostHogAPIClient, - localGitState?: AgentTypes.HandoffLocalGitState, + localGitState?: HandoffLocalGitState, ): Promise; reconnectSession(params: { taskId: string; @@ -39,14 +42,18 @@ export interface HandoffSagaDeps extends HandoffBaseDeps { logUrl: string; sessionId?: string; adapter?: "claude" | "codex"; - }): Promise; + }): Promise<{ sessionId: string } | null>; closeCloudRun( taskId: string, runId: string, apiHost: string, teamId: number, - localGitState?: AgentTypes.HandoffLocalGitState, + localGitState?: HandoffLocalGitState, ): Promise; + attachWorkspaceToFolder( + taskId: string, + repoPath: string, + ): { revert: () => void }; seedLocalLogs(runId: string, logUrl: string): Promise; setPendingContext(taskRunId: string, context: string): void; } @@ -78,24 +85,16 @@ export class HandoffSaga extends Saga { ); }); - const apiClient = this.deps.createApiClient(apiHost, teamId); - await this.readOnlyStep("update_run_environment", async () => { - await apiClient.updateTaskRun(taskId, runId, { - environment: "local", - }); + await this.deps.markRunEnvironmentLocal(taskId, runId); }); const { resumeState, cloudLogUrl } = await this.readOnlyStep( "fetch_and_rebuild", async () => { - const taskRun = await apiClient.getTaskRun(taskId, runId); - const state = await resumeFromLog({ - taskId, - runId, - apiClient, - }); - return { resumeState: state, cloudLogUrl: taskRun.log_url }; + const { resumeState: state, cloudLogUrl: logUrl } = + await this.deps.fetchResumeState(taskId, runId); + return { resumeState: state, cloudLogUrl: logUrl }; }, ); @@ -115,7 +114,6 @@ export class HandoffSaga extends Saga { repoPath, taskId, runId, - apiClient, input.localGitState, ); checkpointApplied = true; @@ -154,7 +152,7 @@ export class HandoffSaga extends Saga { repoPath, apiHost, projectId: teamId, - logUrl: cloudLogUrl, + logUrl: cloudLogUrl ?? "", sessionId: input.sessionId, adapter: input.adapter, }); @@ -186,10 +184,10 @@ export class HandoffSaga extends Saga { } private buildHandoffContext( - conversation: AgentResume.ConversationTurn[], + conversation: unknown[], checkpointApplied: boolean, ): string { - const conversationSummary = formatConversationForResume(conversation); + const conversationSummary = this.deps.formatConversation(conversation); const fileStatus = checkpointApplied ? "The workspace git state and files have been restored from the cloud session checkpoint." diff --git a/apps/code/src/main/services/handoff/handoff-to-cloud-saga.test.ts b/packages/core/src/handoff/handoff-to-cloud-saga.test.ts similarity index 95% rename from apps/code/src/main/services/handoff/handoff-to-cloud-saga.test.ts rename to packages/core/src/handoff/handoff-to-cloud-saga.test.ts index bb7a570991..9b1e40fbca 100644 --- a/apps/code/src/main/services/handoff/handoff-to-cloud-saga.test.ts +++ b/packages/core/src/handoff/handoff-to-cloud-saga.test.ts @@ -13,7 +13,7 @@ function createDeps( checkpointRef: "refs/posthog-code-checkpoint/checkpoint-1", }), persistCheckpointToLog: vi.fn().mockResolvedValue(undefined), - countLocalLogEntries: vi.fn().mockReturnValue(7), + countLocalLogEntries: vi.fn().mockResolvedValue(7), resumeRunInCloud: vi.fn().mockResolvedValue(undefined), killSession: vi.fn().mockResolvedValue(undefined), updateWorkspaceMode: vi.fn(), @@ -68,7 +68,7 @@ describe("HandoffToCloudSaga", () => { it("reports logEntryCount of 0 when no local cache exists", async () => { const deps = createDeps({ - countLocalLogEntries: vi.fn().mockReturnValue(0), + countLocalLogEntries: vi.fn().mockResolvedValue(0), }); const saga = new HandoffToCloudSaga(deps); diff --git a/apps/code/src/main/services/handoff/handoff-to-cloud-saga.ts b/packages/core/src/handoff/handoff-to-cloud-saga.ts similarity index 77% rename from apps/code/src/main/services/handoff/handoff-to-cloud-saga.ts rename to packages/core/src/handoff/handoff-to-cloud-saga.ts index 7201555a1d..86c424c2b0 100644 --- a/apps/code/src/main/services/handoff/handoff-to-cloud-saga.ts +++ b/packages/core/src/handoff/handoff-to-cloud-saga.ts @@ -1,8 +1,12 @@ -import type * as AgentTypes from "@posthog/agent/types"; -import { Saga, type SagaLogger } from "@posthog/shared"; -import type { HandoffBaseDeps, HandoffToCloudExecuteInput } from "./schemas"; +import { + type GitHandoffCheckpoint, + type HandoffLocalGitState, + Saga, + type SagaLogger, +} from "@posthog/shared"; +import type { HandoffBaseDeps, HandoffToCloudSagaInput } from "./types"; -export type HandoffToCloudSagaInput = HandoffToCloudExecuteInput; +export type { HandoffToCloudSagaInput } from "./types"; export interface HandoffToCloudSagaOutput { checkpointCaptured: boolean; @@ -11,12 +15,10 @@ export interface HandoffToCloudSagaOutput { export interface HandoffToCloudSagaDeps extends HandoffBaseDeps { captureGitCheckpoint( - localGitState?: AgentTypes.HandoffLocalGitState, - ): Promise; - persistCheckpointToLog( - checkpoint: AgentTypes.GitCheckpointEvent, - ): Promise; - countLocalLogEntries(runId: string): number; + localGitState?: HandoffLocalGitState, + ): Promise; + persistCheckpointToLog(checkpoint: GitHandoffCheckpoint): Promise; + countLocalLogEntries(runId: string): Promise; resumeRunInCloud(): Promise; } @@ -69,7 +71,7 @@ export class HandoffToCloudSaga extends Saga< this.deps.killSession(runId), ); - const logEntryCount = this.deps.countLocalLogEntries(runId); + const logEntryCount = await this.deps.countLocalLogEntries(runId); await this.step({ name: "update_workspace", diff --git a/packages/core/src/handoff/handoff.module.ts b/packages/core/src/handoff/handoff.module.ts new file mode 100644 index 0000000000..a9ac70b0ea --- /dev/null +++ b/packages/core/src/handoff/handoff.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { HandoffService } from "./handoff"; +import { HANDOFF_SERVICE } from "./identifiers"; + +export const handoffModule = new ContainerModule(({ bind }) => { + bind(HANDOFF_SERVICE).to(HandoffService).inSingletonScope(); +}); diff --git a/packages/core/src/handoff/handoff.test.ts b/packages/core/src/handoff/handoff.test.ts new file mode 100644 index 0000000000..6d0aa7170d --- /dev/null +++ b/packages/core/src/handoff/handoff.test.ts @@ -0,0 +1,125 @@ +import type { HandoffHost } from "@posthog/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { extractHandoffErrorCode, HandoffService } from "./handoff"; +import type { HandoffPreflightInput } from "./schemas"; + +const DEFAULT_LOCAL_GIT_STATE = { + head: "abc123", + branch: "main", + upstreamHead: "def456", + upstreamRemote: "origin", + upstreamMergeRef: "refs/heads/main", +}; + +function createService(hostOverrides: Partial = {}): { + service: HandoffService; + host: HandoffHost; +} { + const host = { + getChangedFiles: vi.fn().mockResolvedValue([]), + getLocalGitState: vi.fn().mockResolvedValue(DEFAULT_LOCAL_GIT_STATE), + markRunEnvironmentLocal: vi.fn(), + fetchResumeState: vi.fn(), + formatConversation: vi.fn(), + applyGitCheckpoint: vi.fn(), + reconnectSession: vi.fn(), + attachWorkspaceToFolder: vi.fn(), + seedLocalLogs: vi.fn(), + setPendingContext: vi.fn(), + killSession: vi.fn(), + updateWorkspaceMode: vi.fn(), + captureGitCheckpoint: vi.fn(), + persistCheckpointToLog: vi.fn(), + countLocalLogEntries: vi.fn(), + resumeRunInCloud: vi.fn(), + cleanupLocalAfterCloudHandoff: vi.fn(), + deleteLocalLogCache: vi.fn(), + ...hostOverrides, + } as unknown as HandoffHost; + const cloudTaskService = { sendCommand: vi.fn() } as never; + const scopedLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const logger = { ...scopedLogger, scope: () => scopedLogger } as never; + + return { service: new HandoffService(host, cloudTaskService, logger), host }; +} + +function createPreflightInput( + overrides: Partial = {}, +): HandoffPreflightInput { + return { + taskId: "task-1", + runId: "run-1", + repoPath: "/repo/path", + apiHost: "https://us.posthog.com", + teamId: 2, + ...overrides, + }; +} + +describe("HandoffService.preflight", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns canHandoff=true when working tree is clean", async () => { + const { service } = createService(); + const result = await service.preflight(createPreflightInput()); + + expect(result.canHandoff).toBe(true); + expect(result.localTreeDirty).toBe(false); + expect(result.reason).toBeUndefined(); + expect(result.localGitState).toEqual(DEFAULT_LOCAL_GIT_STATE); + }); + + it("returns canHandoff=false when working tree has changes", async () => { + const { service } = createService({ + getChangedFiles: vi + .fn() + .mockResolvedValue([{ path: "src/index.ts", status: "modified" }]), + }); + const result = await service.preflight(createPreflightInput()); + + expect(result.canHandoff).toBe(false); + expect(result.localTreeDirty).toBe(true); + expect(result.reason).toContain("uncommitted changes"); + }); + + it("checks the correct repo path", async () => { + const { service, host } = createService(); + await service.preflight(createPreflightInput({ repoPath: "/custom/path" })); + + expect(host.getChangedFiles).toHaveBeenCalledWith("/custom/path"); + }); + + it("returns canHandoff=true when git check throws", async () => { + const { service } = createService({ + getChangedFiles: vi.fn().mockRejectedValue(new Error("git not found")), + }); + const result = await service.preflight(createPreflightInput()); + + expect(result.canHandoff).toBe(true); + expect(result.localTreeDirty).toBe(false); + }); +}); + +describe("extractHandoffErrorCode", () => { + it("detects GitHub authorization failures in backend error payloads", () => { + const message = + 'Failed request: [400] {"type":"validation_error","code":"github_authorization_required","detail":"Link a GitHub account"}'; + + expect(extractHandoffErrorCode(message)).toBe( + "github_authorization_required", + ); + }); + + it("ignores unrelated failures", () => { + expect(extractHandoffErrorCode("Failed request: [500] boom")).toBe( + undefined, + ); + }); +}); diff --git a/packages/core/src/handoff/handoff.ts b/packages/core/src/handoff/handoff.ts new file mode 100644 index 0000000000..55b424f428 --- /dev/null +++ b/packages/core/src/handoff/handoff.ts @@ -0,0 +1,269 @@ +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { + type HandoffHost, + type SagaLogger, + TypedEventEmitter, +} from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import type { CloudTaskService } from "../cloud-task/cloud-task"; +import { CLOUD_TASK_SERVICE } from "../cloud-task/identifiers"; +import { HandoffSaga, type HandoffSagaDeps } from "./handoff-saga"; +import { + HandoffToCloudSaga, + type HandoffToCloudSagaDeps, +} from "./handoff-to-cloud-saga"; +import { HANDOFF_HOST } from "./identifiers"; +import { + type HandoffErrorCode, + HandoffEvent, + type HandoffExecuteInput, + type HandoffExecuteResult, + type HandoffPreflightInput, + type HandoffPreflightResult, + type HandoffServiceEvents, + type HandoffToCloudExecuteInput, + type HandoffToCloudExecuteResult, + type HandoffToCloudPreflightInput, + type HandoffToCloudPreflightResult, +} from "./schemas"; + +const GITHUB_AUTHORIZATION_REQUIRED_CODE = "github_authorization_required"; +const GITHUB_AUTHORIZATION_REQUIRED_MESSAGE = + "Connect GitHub in your browser, then retry Continue in cloud."; + +export function extractHandoffErrorCode( + message: string | undefined, +): HandoffErrorCode | undefined { + if (message?.includes(GITHUB_AUTHORIZATION_REQUIRED_CODE)) { + return GITHUB_AUTHORIZATION_REQUIRED_CODE; + } + return undefined; +} + +@injectable() +export class HandoffService extends TypedEventEmitter { + private readonly logger: SagaLogger; + + constructor( + @inject(HANDOFF_HOST) + private readonly host: HandoffHost, + @inject(CLOUD_TASK_SERVICE) + private readonly cloudTaskService: CloudTaskService, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, + ) { + super(); + this.logger = workbenchLogger.scope("handoff"); + } + + async preflight( + input: HandoffPreflightInput, + ): Promise { + const { repoPath } = input; + + let localTreeDirty = false; + let localGitState: HandoffPreflightResult["localGitState"]; + let changedFileDetails: HandoffPreflightResult["changedFiles"]; + try { + const changedFiles = await this.host.getChangedFiles(repoPath); + localTreeDirty = changedFiles.length > 0; + changedFileDetails = changedFiles.map((f) => ({ + path: f.path, + status: f.status, + linesAdded: f.linesAdded, + linesRemoved: f.linesRemoved, + })); + localGitState = await this.host.getLocalGitState(repoPath); + } catch (err) { + this.logger.warn("Failed to check local working tree", { repoPath, err }); + } + + const canHandoff = !localTreeDirty; + const reason = localTreeDirty + ? "Local working tree has uncommitted changes. Commit or stash them first." + : undefined; + + return { + canHandoff, + reason, + localTreeDirty, + localGitState, + changedFiles: changedFileDetails, + }; + } + + async execute(input: HandoffExecuteInput): Promise { + const ctx = { apiHost: input.apiHost, teamId: input.teamId }; + + const deps: HandoffSagaDeps = { + markRunEnvironmentLocal: (taskId, runId) => + this.host.markRunEnvironmentLocal(ctx, taskId, runId), + + fetchResumeState: (taskId, runId) => + this.host.fetchResumeState(ctx, taskId, runId), + + formatConversation: (conversation) => + this.host.formatConversation(conversation), + + applyGitCheckpoint: ( + checkpoint, + repoPath, + taskId, + runId, + localGitState, + ) => + this.host.applyGitCheckpoint( + ctx, + checkpoint, + repoPath, + taskId, + runId, + localGitState, + ), + + closeCloudRun: async (taskId, runId, apiHost, teamId, localGitState) => { + const result = await this.cloudTaskService.sendCommand({ + taskId, + runId, + apiHost, + teamId, + method: "close", + params: localGitState ? { localGitState } : undefined, + }); + if (!result.success) { + this.logger.warn("Close command failed, continuing with handoff", { + error: result.error, + }); + } + }, + + updateWorkspaceMode: (taskId, mode) => + this.host.updateWorkspaceMode(taskId, mode), + + attachWorkspaceToFolder: (taskId, repoPath) => + this.host.attachWorkspaceToFolder(taskId, repoPath), + + seedLocalLogs: (runId, logUrl) => this.host.seedLocalLogs(runId, logUrl), + + reconnectSession: (params) => this.host.reconnectSession(params), + + killSession: (taskRunId) => this.host.killSession(taskRunId), + + setPendingContext: (taskRunId, context) => + this.host.setPendingContext(taskRunId, context), + + onProgress: (step, message) => { + this.emit(HandoffEvent.Progress, { + taskId: input.taskId, + step, + message, + }); + }, + }; + + const saga = new HandoffSaga(deps, this.logger); + const result = await saga.run(input); + + if (!result.success) { + this.logger.error("Handoff saga failed", { + error: result.error, + failedStep: result.failedStep, + }); + deps.onProgress("failed", result.error ?? "Handoff failed"); + return { + success: false, + error: `Handoff failed at step '${result.failedStep}': ${result.error}`, + }; + } + + return { + success: true, + sessionId: result.data.sessionId, + }; + } + + async preflightToCloud( + input: HandoffToCloudPreflightInput, + ): Promise { + const { repoPath } = input; + + let localGitState: HandoffToCloudPreflightResult["localGitState"]; + try { + localGitState = await this.host.getLocalGitState(repoPath); + } catch (err) { + this.logger.warn("Failed to read local git state for cloud handoff", { + repoPath, + err, + }); + } + + return { canHandoff: true, localGitState }; + } + + async executeToCloud( + input: HandoffToCloudExecuteInput, + ): Promise { + const { taskId, runId, repoPath, apiHost, teamId } = input; + const ctx = { apiHost, teamId }; + + const deps: HandoffToCloudSagaDeps = { + captureGitCheckpoint: (localGitState) => + this.host.captureGitCheckpoint( + ctx, + repoPath, + taskId, + runId, + localGitState, + ), + + persistCheckpointToLog: (checkpoint) => + this.host.persistCheckpointToLog(ctx, taskId, runId, checkpoint), + + countLocalLogEntries: (taskRunId) => + this.host.countLocalLogEntries(taskRunId), + + resumeRunInCloud: () => this.host.resumeRunInCloud(ctx, taskId, runId), + + killSession: (taskRunId) => this.host.killSession(taskRunId), + + updateWorkspaceMode: (tid, mode) => + this.host.updateWorkspaceMode(tid, mode), + + onProgress: (step, message) => { + this.emit(HandoffEvent.Progress, { taskId, step, message }); + }, + }; + + const saga = new HandoffToCloudSaga(deps, this.logger); + const result = await saga.run(input); + + if (!result.success) { + this.logger.error("Handoff to cloud saga failed", { + error: result.error, + failedStep: result.failedStep, + }); + deps.onProgress("failed", result.error ?? "Handoff to cloud failed"); + const code = extractHandoffErrorCode(result.error); + return { + success: false, + code, + error: + code === GITHUB_AUTHORIZATION_REQUIRED_CODE + ? GITHUB_AUTHORIZATION_REQUIRED_MESSAGE + : `Handoff to cloud failed at step '${result.failedStep}': ${result.error}`, + }; + } + + await this.host.cleanupLocalAfterCloudHandoff( + repoPath, + input.localGitState?.branch ?? null, + ); + + await this.host.deleteLocalLogCache(runId); + + return { + success: true, + logEntryCount: result.data.logEntryCount, + }; + } +} diff --git a/packages/core/src/handoff/identifiers.ts b/packages/core/src/handoff/identifiers.ts new file mode 100644 index 0000000000..8414780084 --- /dev/null +++ b/packages/core/src/handoff/identifiers.ts @@ -0,0 +1,2 @@ +export const HANDOFF_SERVICE = Symbol.for("posthog.core.handoffService"); +export const HANDOFF_HOST = Symbol.for("posthog.core.handoffHost"); diff --git a/apps/code/src/main/services/handoff/schemas.ts b/packages/core/src/handoff/schemas.ts similarity index 80% rename from apps/code/src/main/services/handoff/schemas.ts rename to packages/core/src/handoff/schemas.ts index 290a818b8c..b384ed27a4 100644 --- a/apps/code/src/main/services/handoff/schemas.ts +++ b/packages/core/src/handoff/schemas.ts @@ -1,7 +1,15 @@ -import type { PostHogAPIClient } from "@posthog/agent/posthog-api"; -import { handoffLocalGitStateSchema } from "@posthog/agent/server/schemas"; import { z } from "zod"; -import type { WorkspaceMode } from "../../db/repositories/workspace-repository"; +import type { HandoffStep } from "./types"; + +export type { HandoffStep } from "./types"; + +export const handoffLocalGitStateSchema = z.object({ + head: z.string().nullable(), + branch: z.string().nullable(), + upstreamHead: z.string().nullable(), + upstreamRemote: z.string().nullable(), + upstreamMergeRef: z.string().nullable(), +}); const handoffBaseInput = z.object({ taskId: z.string(), @@ -99,16 +107,6 @@ export type HandoffToCloudExecuteResult = z.infer< typeof handoffToCloudExecuteResult >; -export type HandoffStep = - | "fetching_logs" - | "applying_git_checkpoint" - | "spawning_agent" - | "capturing_checkpoint" - | "stopping_agent" - | "starting_cloud_run" - | "complete" - | "failed"; - export interface HandoffProgressPayload { taskId: string; step: HandoffStep; @@ -122,10 +120,3 @@ export const HandoffEvent = { export interface HandoffServiceEvents { [HandoffEvent.Progress]: HandoffProgressPayload; } - -export interface HandoffBaseDeps { - createApiClient(apiHost: string, teamId: number): PostHogAPIClient; - killSession(taskRunId: string): Promise; - updateWorkspaceMode(taskId: string, mode: WorkspaceMode): void; - onProgress(step: HandoffStep, message: string): void; -} diff --git a/packages/core/src/handoff/types.ts b/packages/core/src/handoff/types.ts new file mode 100644 index 0000000000..8e781a4e69 --- /dev/null +++ b/packages/core/src/handoff/types.ts @@ -0,0 +1,37 @@ +import type { HandoffLocalGitState, WorkspaceMode } from "@posthog/shared"; + +export type HandoffStep = + | "fetching_logs" + | "applying_git_checkpoint" + | "spawning_agent" + | "capturing_checkpoint" + | "stopping_agent" + | "starting_cloud_run" + | "complete" + | "failed"; + +export interface HandoffSagaInput { + taskId: string; + runId: string; + repoPath: string; + apiHost: string; + teamId: number; + sessionId?: string; + adapter?: "claude" | "codex"; + localGitState?: HandoffLocalGitState; +} + +export interface HandoffToCloudSagaInput { + taskId: string; + runId: string; + repoPath: string; + apiHost: string; + teamId: number; + localGitState?: HandoffLocalGitState; +} + +export interface HandoffBaseDeps { + killSession(taskRunId: string): Promise; + updateWorkspaceMode(taskId: string, mode: WorkspaceMode): void; + onProgress(step: HandoffStep, message: string): void; +} diff --git a/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.test.ts b/packages/core/src/inbox/buildCreatePrReportPrompt.test.ts similarity index 94% rename from apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.test.ts rename to packages/core/src/inbox/buildCreatePrReportPrompt.test.ts index 5087a137d1..1ba7a95222 100644 --- a/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.test.ts +++ b/packages/core/src/inbox/buildCreatePrReportPrompt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildCreatePrReportPrompt } from "./buildCreatePrReportPrompt"; +import { buildCreatePrReportPrompt } from "./reportPrompts"; describe("buildCreatePrReportPrompt", () => { it.each([ diff --git a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts b/packages/core/src/inbox/buildDiscussReportPrompt.test.ts similarity index 97% rename from apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts rename to packages/core/src/inbox/buildDiscussReportPrompt.test.ts index f0ae48cac5..47a0366381 100644 --- a/apps/code/src/renderer/features/inbox/utils/buildDiscussReportPrompt.test.ts +++ b/packages/core/src/inbox/buildDiscussReportPrompt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildDiscussReportPrompt } from "./buildDiscussReportPrompt"; +import { buildDiscussReportPrompt } from "./reportPrompts"; describe("buildDiscussReportPrompt", () => { it("uses the production deeplink scheme outside dev builds", () => { diff --git a/packages/core/src/inbox/bulkActionService.test.ts b/packages/core/src/inbox/bulkActionService.test.ts new file mode 100644 index 0000000000..3463f72472 --- /dev/null +++ b/packages/core/src/inbox/bulkActionService.test.ts @@ -0,0 +1,57 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { describe, expect, it, vi } from "vitest"; +import { InboxBulkActionService } from "./bulkActionService"; + +function fakeClient(overrides: Partial = {}) { + return { + updateSignalReportState: vi.fn().mockResolvedValue({}), + deleteSignalReport: vi.fn().mockResolvedValue({}), + reingestSignalReport: vi.fn().mockResolvedValue({}), + ...overrides, + } as unknown as PostHogAPIClient; +} + +describe("InboxBulkActionService", () => { + it("suppresses every selected report and tallies success", async () => { + const client = fakeClient(); + const service = new InboxBulkActionService(); + const result = await service.suppressReports(client, ["a", "b", "c"]); + expect(client.updateSignalReportState).toHaveBeenCalledTimes(3); + expect(result).toEqual({ successCount: 3, failureCount: 0 }); + }); + + it("forwards the dismissal reason when suppressing", async () => { + const client = fakeClient(); + const service = new InboxBulkActionService(); + await service.suppressReports(client, ["a"], { + reason: "already_fixed", + note: "n", + }); + const body = (client.updateSignalReportState as ReturnType) + .mock.calls[0][1]; + expect(body.state).toBe("suppressed"); + expect(body.dismissal_reason).toBe("already_fixed"); + }); + + it("tallies partial failure across the fan-out", async () => { + const client = fakeClient({ + deleteSignalReport: vi + .fn() + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({}), + }); + const service = new InboxBulkActionService(); + const result = await service.deleteReports(client, ["a", "b", "c"]); + expect(result).toEqual({ successCount: 2, failureCount: 1 }); + }); + + it("snoozes and reingests through the api client", async () => { + const client = fakeClient(); + const service = new InboxBulkActionService(); + await service.snoozeReports(client, ["a"]); + await service.reingestReports(client, ["b"]); + expect(client.updateSignalReportState).toHaveBeenCalledTimes(1); + expect(client.reingestSignalReport).toHaveBeenCalledWith("b"); + }); +}); diff --git a/packages/core/src/inbox/bulkActionService.ts b/packages/core/src/inbox/bulkActionService.ts new file mode 100644 index 0000000000..28ad206753 --- /dev/null +++ b/packages/core/src/inbox/bulkActionService.ts @@ -0,0 +1,57 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { injectable } from "inversify"; +import { + type BulkActionResult, + buildSnoozeRequest, + buildSuppressRequest, + type DismissReportInput, + tallySettledResults, +} from "./bulkActions"; + +@injectable() +export class InboxBulkActionService { + private async runBulk( + reportIds: string[], + perReport: (reportId: string) => Promise, + ): Promise { + const results = await Promise.allSettled(reportIds.map(perReport)); + return tallySettledResults(results); + } + + async suppressReports( + client: PostHogAPIClient, + reportIds: string[], + dismissal?: DismissReportInput, + ): Promise { + return this.runBulk(reportIds, (reportId) => + client.updateSignalReportState(reportId, buildSuppressRequest(dismissal)), + ); + } + + async snoozeReports( + client: PostHogAPIClient, + reportIds: string[], + ): Promise { + return this.runBulk(reportIds, (reportId) => + client.updateSignalReportState(reportId, buildSnoozeRequest()), + ); + } + + async deleteReports( + client: PostHogAPIClient, + reportIds: string[], + ): Promise { + return this.runBulk(reportIds, (reportId) => + client.deleteSignalReport(reportId), + ); + } + + async reingestReports( + client: PostHogAPIClient, + reportIds: string[], + ): Promise { + return this.runBulk(reportIds, (reportId) => + client.reingestSignalReport(reportId), + ); + } +} diff --git a/packages/core/src/inbox/bulkActions.ts b/packages/core/src/inbox/bulkActions.ts new file mode 100644 index 0000000000..0839bbd9c5 --- /dev/null +++ b/packages/core/src/inbox/bulkActions.ts @@ -0,0 +1,177 @@ +import type { DismissalReasonOptionValue } from "@posthog/shared"; +import type { SignalReport } from "@posthog/shared/domain-types"; +import { inboxStatusLabel } from "./statusLabels"; + +export type BulkActionName = "suppress" | "snooze" | "delete" | "reingest"; + +export interface BulkActionResult { + successCount: number; + failureCount: number; +} + +/** Active workflow statuses for snooze and suppress. Terminal `suppressed` / `deleted` are excluded. */ +export const suppressibleStatuses = new Set([ + "potential", + "candidate", + "in_progress", + "pending_input", + "ready", + "failed", +]); + +/** Clause after "Disabled because …" (see `@components/ui/Button`). */ +export const DISABLED_NO_SELECTION = "you haven't selected a report"; + +/** Statuses that block suppression; labels match `inboxStatusLabel`. */ +export const SUPPRESS_BLOCKED_STATUS_PHRASE = ( + ["suppressed", "deleted"] as const satisfies readonly SignalReport["status"][] +) + .map((status) => inboxStatusLabel(status)) + .join(" or "); + +export interface SelectedReportEligibility { + selectedReports: SignalReport[]; + selectedIds: string[]; + selectedCount: number; + snoozeDisabledReason: string | null; + suppressDisabledReason: string | null; + deleteDisabledReason: string | null; + reingestDisabledReason: string | null; +} + +export function formatBulkActionSummary( + action: BulkActionName, + result: BulkActionResult, +): string { + const { successCount, failureCount } = result; + const pluralized = successCount === 1 ? "report" : "reports"; + const formulated = + action === "suppress" + ? `${pluralized} dismissed` + : action === "snooze" + ? `${pluralized} snoozed` + : action === "delete" + ? `${pluralized} deleted` + : `${pluralized} reingested`; + if (failureCount === 0) { + return `${successCount} ${formulated}`; + } + return `${successCount} ${formulated}, ${failureCount} failed`; +} + +export function getSnoozeOrSuppressDisabledReason( + selectedCount: number, + selectedReports: SignalReport[], +): string | null { + if (selectedCount === 0) { + return DISABLED_NO_SELECTION; + } + const ok = selectedReports.every((report) => + suppressibleStatuses.has(report.status), + ); + if (ok) { + return null; + } + return `every selected report must not already be ${SUPPRESS_BLOCKED_STATUS_PHRASE}`; +} + +export function getSelectedReportEligibility( + reports: SignalReport[], + selectedIds: string[], +): SelectedReportEligibility { + const selectedIdSet = new Set(selectedIds); + const selectedReports = reports.filter((report) => + selectedIdSet.has(report.id), + ); + const selectedCount = selectedReports.length; + + const snoozeOrSuppressDisabledReason = getSnoozeOrSuppressDisabledReason( + selectedCount, + selectedReports, + ); + + return { + selectedReports, + selectedIds: selectedReports.map((report) => report.id), + selectedCount, + snoozeDisabledReason: snoozeOrSuppressDisabledReason, + suppressDisabledReason: snoozeOrSuppressDisabledReason, + deleteDisabledReason: selectedCount === 0 ? DISABLED_NO_SELECTION : null, + reingestDisabledReason: selectedCount === 0 ? DISABLED_NO_SELECTION : null, + }; +} + +/** Toolbar: selected report ids. Dismiss dialog: that report's id, or null when closed. */ +export type InboxBulkSelection = string[] | string | null; + +const emptyBulkIds: string[] = []; + +export function effectiveBulkIdsFromSelection( + selection: InboxBulkSelection, +): string[] { + if (selection == null) { + return emptyBulkIds; + } + if (Array.isArray(selection)) { + return selection; + } + return [selection]; +} + +export function bulkSelectionKey(selection: InboxBulkSelection): string { + if (selection == null) { + return ""; + } + if (Array.isArray(selection)) { + return selection.join("\0"); + } + return selection; +} + +export interface DismissReportInput { + reason: DismissalReasonOptionValue; + note: string; +} + +export type SuppressStateRequest = { + state: "suppressed"; + dismissal_reason?: DismissalReasonOptionValue; + dismissal_note?: string; +}; + +/** Body for `updateSignalReportState` when suppressing/dismissing. Notes are clamped to 4000 chars. */ +export function buildSuppressRequest( + dismissal?: DismissReportInput, +): SuppressStateRequest { + if (!dismissal) { + return { state: "suppressed" }; + } + return { + state: "suppressed", + dismissal_reason: dismissal.reason, + dismissal_note: dismissal.note.slice(0, 4000), + }; +} + +export type SnoozeStateRequest = { + state: "potential"; + snooze_for: number; +}; + +/** Body for `updateSignalReportState` when snoozing. */ +export function buildSnoozeRequest(): SnoozeStateRequest { + return { state: "potential", snooze_for: 1 }; +} + +/** Tally `Promise.allSettled` results into a success/failure count. */ +export function tallySettledResults( + results: PromiseSettledResult[], +): BulkActionResult { + const successCount = results.filter( + (result) => result.status === "fulfilled", + ).length; + return { + successCount, + failureCount: results.length - successCount, + }; +} diff --git a/packages/core/src/inbox/dataSourceService.ts b/packages/core/src/inbox/dataSourceService.ts new file mode 100644 index 0000000000..4a3da86886 --- /dev/null +++ b/packages/core/src/inbox/dataSourceService.ts @@ -0,0 +1,144 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { inject, injectable } from "inversify"; +import { LINEAR_OAUTH_FLOW, type LinearOAuthFlow } from "./identifiers"; + +export type DataSourceType = "github" | "linear" | "zendesk" | "pganalyze"; + +const REQUIRED_SCHEMAS: Record = { + github: ["issues"], + linear: ["issues"], + zendesk: ["tickets"], + pganalyze: ["issues", "servers"], +}; + +const FULL_TABLE_REPLICATION = "full_refresh" as const; + +export function schemasPayload(source: DataSourceType) { + return REQUIRED_SCHEMAS[source].map((name) => ({ + name, + should_sync: true, + sync_type: FULL_TABLE_REPLICATION, + })); +} + +const POLL_INTERVAL_MS = 3_000; +const POLL_TIMEOUT_MS = 300_000; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export interface GithubDataSourceParams { + repository: string; + githubIntegrationId: number; +} + +export interface ZendeskDataSourceParams { + subdomain: string; + apiKey: string; + email: string; +} + +export interface PgAnalyzeDataSourceParams { + apiKey: string; + organizationSlug: string; +} + +@injectable() +export class DataSourceService { + constructor( + @inject(LINEAR_OAUTH_FLOW) + private readonly linearOAuth: LinearOAuthFlow, + ) {} + + async createGithubDataSource( + client: PostHogAPIClient, + projectId: number, + params: GithubDataSourceParams, + ): Promise { + await client.createExternalDataSource(projectId, { + source_type: "Github", + payload: { + repository: params.repository, + auth_method: { + selection: "oauth", + github_integration_id: params.githubIntegrationId, + }, + schemas: schemasPayload("github"), + }, + }); + } + + async createLinearDataSource( + client: PostHogAPIClient, + projectId: number, + linearIntegrationId: number | string, + ): Promise { + await client.createExternalDataSource(projectId, { + source_type: "Linear", + payload: { + linear_integration_id: linearIntegrationId, + schemas: schemasPayload("linear"), + }, + }); + } + + async createZendeskDataSource( + client: PostHogAPIClient, + projectId: number, + params: ZendeskDataSourceParams, + ): Promise { + await client.createExternalDataSource(projectId, { + source_type: "Zendesk", + payload: { + subdomain: params.subdomain, + api_key: params.apiKey, + email_address: params.email, + schemas: schemasPayload("zendesk"), + }, + }); + } + + async createPgAnalyzeDataSource( + client: PostHogAPIClient, + projectId: number, + params: PgAnalyzeDataSourceParams, + ): Promise { + await client.createExternalDataSource(projectId, { + source_type: "PgAnalyze", + payload: { + api_key: params.apiKey, + organization_slug: params.organizationSlug, + schemas: schemasPayload("pganalyze"), + }, + }); + } + + async connectLinearAndAwaitIntegration( + client: PostHogAPIClient, + region: string, + projectId: number, + signal?: AbortSignal, + ): Promise { + await this.linearOAuth.startFlow(region, projectId); + + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + if (signal?.aborted) { + throw new Error("Linear connection cancelled"); + } + await delay(POLL_INTERVAL_MS); + try { + const integrations = await client.getIntegrationsForProject(projectId); + const linear = integrations.find( + (i: { kind: string }) => i.kind === "linear", + ) as { id: number | string } | undefined; + if (linear) { + return linear.id; + } + } catch {} + } + + throw new Error("Connection timed out. Please try again."); + } +} diff --git a/packages/core/src/inbox/engagement.ts b/packages/core/src/inbox/engagement.ts new file mode 100644 index 0000000000..5e40498efb --- /dev/null +++ b/packages/core/src/inbox/engagement.ts @@ -0,0 +1,84 @@ +import type { InboxReportActionProperties } from "@posthog/shared/analytics-events"; +import type { SignalReport } from "@posthog/shared/domain-types"; + +/** Report age at fire time in hours, rounded to one decimal. Clamped at 0 to guard against clock skew. */ +export function reportAgeHours(createdAt: string | null | undefined): number { + if (!createdAt) return 0; + const ageMs = Date.now() - new Date(createdAt).getTime(); + if (!Number.isFinite(ageMs)) return 0; + return Math.max(0, Math.round((ageMs / 3_600_000) * 10) / 10); +} + +/** Live tracker snapshot for the currently-open report. */ +export interface OpenReportSnapshot { + reportId: string; + rank: number; + reportPriority: string | null; + reportActionability: string | null; +} + +export type ResolvedActionProperties = Pick< + InboxReportActionProperties, + "rank" | "list_size" | "priority" | "actionability" +>; + +export interface ResolveActionPropertiesInput { + reportId: string; + rankOverride?: number; + listSizeOverride?: number; + priorityOverride?: string | null; + actionabilityOverride?: string | null; + openSnapshot: OpenReportSnapshot | null; + visibleReports: SignalReport[]; +} + +/** + * Resolve rank / list_size / priority / actionability for an INBOX_REPORT_ACTION event. + * + * Precedence: explicit override -> live open-info snapshot (current report only) -> + * a one-shot lookup in the visible list. Callers firing after an async mutation should + * pass pre-mutation overrides; by then the visible list has been re-queried without the + * affected report. + */ +export function resolveActionProperties( + input: ResolveActionPropertiesInput, +): ResolvedActionProperties { + const { + reportId, + rankOverride, + listSizeOverride, + priorityOverride, + actionabilityOverride, + openSnapshot, + visibleReports, + } = input; + + const currentInfo = + openSnapshot && openSnapshot.reportId === reportId ? openSnapshot : null; + const matchedReport = currentInfo + ? null + : (visibleReports.find((r) => r.id === reportId) ?? null); + + const rank = + rankOverride !== undefined + ? rankOverride + : currentInfo + ? currentInfo.rank + : visibleReports.findIndex((r) => r.id === reportId); + const listSize = + listSizeOverride !== undefined ? listSizeOverride : visibleReports.length; + const priority = + priorityOverride !== undefined + ? priorityOverride + : currentInfo + ? currentInfo.reportPriority + : (matchedReport?.priority ?? null); + const actionability = + actionabilityOverride !== undefined + ? actionabilityOverride + : currentInfo + ? currentInfo.reportActionability + : (matchedReport?.actionability ?? null); + + return { rank, list_size: listSize, priority, actionability }; +} diff --git a/packages/core/src/inbox/identifiers.ts b/packages/core/src/inbox/identifiers.ts new file mode 100644 index 0000000000..3d515f0be0 --- /dev/null +++ b/packages/core/src/inbox/identifiers.ts @@ -0,0 +1,29 @@ +export const INBOX_BULK_ACTION_SERVICE = Symbol.for( + "posthog.core.inbox.bulkActionService", +); +export const SIGNAL_SOURCE_SERVICE = Symbol.for( + "posthog.core.inbox.signalSourceService", +); +export const SIGNAL_REPORT_TASK_SERVICE = Symbol.for( + "posthog.core.inbox.signalReportTaskService", +); +export const REPORT_MODEL_RESOLVER = Symbol.for( + "posthog.core.inbox.reportModelResolver", +); +export const DATA_SOURCE_SERVICE = Symbol.for( + "posthog.core.inbox.dataSourceService", +); +export const LINEAR_OAUTH_FLOW = Symbol.for( + "posthog.core.inbox.linearOAuthFlow", +); + +export interface ReportModelResolver { + resolveDefaultModel( + apiHost: string, + adapter: "claude" | "codex", + ): Promise; +} + +export interface LinearOAuthFlow { + startFlow(region: string, projectId: number): Promise; +} diff --git a/packages/core/src/inbox/inbox.module.ts b/packages/core/src/inbox/inbox.module.ts new file mode 100644 index 0000000000..ddcd38a0e4 --- /dev/null +++ b/packages/core/src/inbox/inbox.module.ts @@ -0,0 +1,25 @@ +import { ContainerModule } from "inversify"; +import { InboxBulkActionService } from "./bulkActionService"; +import { DataSourceService } from "./dataSourceService"; +import { + DATA_SOURCE_SERVICE, + INBOX_BULK_ACTION_SERVICE, + SIGNAL_REPORT_TASK_SERVICE, + SIGNAL_SOURCE_SERVICE, +} from "./identifiers"; +import { SignalReportTaskService } from "./signalReportTaskService"; +import { SignalSourceService } from "./signalSourceService"; + +export const inboxCoreModule = new ContainerModule(({ bind }) => { + bind(InboxBulkActionService).toSelf().inSingletonScope(); + bind(INBOX_BULK_ACTION_SERVICE).toService(InboxBulkActionService); + + bind(SignalSourceService).toSelf().inSingletonScope(); + bind(SIGNAL_SOURCE_SERVICE).toService(SignalSourceService); + + bind(SignalReportTaskService).toSelf().inSingletonScope(); + bind(SIGNAL_REPORT_TASK_SERVICE).toService(SignalReportTaskService); + + bind(DataSourceService).toSelf().inSingletonScope(); + bind(DATA_SOURCE_SERVICE).toService(DataSourceService); +}); diff --git a/packages/core/src/inbox/reportActionEvents.test.ts b/packages/core/src/inbox/reportActionEvents.test.ts new file mode 100644 index 0000000000..bab541d409 --- /dev/null +++ b/packages/core/src/inbox/reportActionEvents.test.ts @@ -0,0 +1,87 @@ +import type { SignalReport } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + buildBulkActionEvents, + buildDetailActionEvent, + snapshotReportList, +} from "./reportActionEvents"; + +function fakeReport(overrides: Partial = {}): SignalReport { + return { + id: "r1", + title: "Report one", + summary: null, + status: "ready", + total_weight: 0, + signal_count: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + artefact_count: 0, + priority: "P1", + actionability: "immediately_actionable", + ...overrides, + } as SignalReport; +} + +describe("snapshotReportList", () => { + it("captures rank, title, and list size per report", () => { + const snapshot = snapshotReportList([ + fakeReport({ id: "a", title: "A" }), + fakeReport({ id: "b", title: "B" }), + ]); + expect(snapshot.listSize).toBe(2); + expect(snapshot.byId.get("b")).toMatchObject({ rank: 1, title: "B" }); + }); +}); + +describe("buildBulkActionEvents", () => { + it("derives one toolbar event per target with bulk flags", () => { + const snapshot = snapshotReportList([ + fakeReport({ id: "a", priority: "P0" }), + fakeReport({ id: "b", priority: "P2" }), + ]); + const events = buildBulkActionEvents("delete", ["a", "b"], snapshot); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + report_id: "a", + action_type: "delete", + surface: "toolbar", + is_bulk: true, + bulk_size: 2, + rank: 0, + list_size: 2, + priority: "P0", + }); + }); + + it("marks a single target as non-bulk and falls back for unknown ids", () => { + const snapshot = snapshotReportList([fakeReport({ id: "a" })]); + const events = buildBulkActionEvents("snooze", ["gone"], snapshot); + expect(events[0]).toMatchObject({ + is_bulk: false, + bulk_size: 1, + rank: -1, + report_title: null, + priority: null, + }); + }); +}); + +describe("buildDetailActionEvent", () => { + it("fills detail-pane boilerplate and merges extras", () => { + const event = buildDetailActionEvent( + fakeReport({ id: "x", title: "X" }), + "expand_why", + { why_field: "priority" }, + ); + expect(event).toMatchObject({ + report_id: "x", + report_title: "X", + action_type: "expand_why", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + why_field: "priority", + }); + }); +}); diff --git a/packages/core/src/inbox/reportActionEvents.ts b/packages/core/src/inbox/reportActionEvents.ts new file mode 100644 index 0000000000..4381ac9af7 --- /dev/null +++ b/packages/core/src/inbox/reportActionEvents.ts @@ -0,0 +1,103 @@ +import type { InboxReportActionProperties } from "@posthog/shared/analytics-events"; +import type { SignalReport } from "@posthog/shared/domain-types"; +import { reportAgeHours } from "./engagement"; + +export interface ReportListSnapshotEntry { + rank: number; + title: string | null; + createdAt: string | null; + priority: string | null; + actionability: string | null; +} + +export interface ReportListSnapshot { + byId: Map; + listSize: number; +} + +export function snapshotReportList( + reports: SignalReport[], +): ReportListSnapshot { + return { + byId: new Map( + reports.map( + (report, index) => + [ + report.id, + { + rank: index, + title: report.title, + createdAt: report.created_at, + priority: report.priority ?? null, + actionability: report.actionability ?? null, + } satisfies ReportListSnapshotEntry, + ] as const, + ), + ), + listSize: reports.length, + }; +} + +export function buildBulkActionEvents( + actionType: InboxReportActionProperties["action_type"], + targetIds: string[], + snapshot: ReportListSnapshot, +): InboxReportActionProperties[] { + const isBulk = targetIds.length > 1; + return targetIds.map((reportId) => { + const entry = snapshot.byId.get(reportId); + return { + report_id: reportId, + report_title: entry?.title ?? null, + report_age_hours: reportAgeHours(entry?.createdAt), + action_type: actionType, + surface: "toolbar", + is_bulk: isBulk, + bulk_size: targetIds.length, + rank: entry?.rank ?? -1, + list_size: snapshot.listSize, + priority: entry?.priority ?? null, + actionability: entry?.actionability ?? null, + }; + }); +} + +export type DetailActionExtra = Partial< + Omit< + InboxReportActionProperties, + | "report_id" + | "report_title" + | "report_age_hours" + | "action_type" + | "surface" + | "is_bulk" + | "bulk_size" + | "rank" + | "list_size" + > +>; + +export type DetailActionEvent = Omit< + InboxReportActionProperties, + "rank" | "list_size" | "priority" | "actionability" +> & { + priority?: string | null; + actionability?: string | null; +}; + +export function buildDetailActionEvent( + report: SignalReport, + actionType: InboxReportActionProperties["action_type"], + extra?: DetailActionExtra, +): DetailActionEvent { + return { + report_id: report.id, + report_title: report.title, + report_age_hours: reportAgeHours(report.created_at), + action_type: actionType, + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + ...extra, + }; +} diff --git a/packages/core/src/inbox/reportActionRules.ts b/packages/core/src/inbox/reportActionRules.ts new file mode 100644 index 0000000000..a272c1290a --- /dev/null +++ b/packages/core/src/inbox/reportActionRules.ts @@ -0,0 +1,27 @@ +import type { SignalReport, Task } from "@posthog/shared/domain-types"; +import { getTaskPrUrl } from "./reportTasks"; + +export function isReportAwaitingInput(report: SignalReport): boolean { + return ( + report.status === "pending_input" || + (report.status === "ready" && + report.actionability === "requires_human_input") + ); +} + +export function canCreateImplementationPr(report: SignalReport): boolean { + return ( + isReportAwaitingInput(report) || + (report.status === "ready" && + report.actionability === "immediately_actionable" && + report.already_addressed !== true) + ); +} + +export function resolveHeaderImplementationPrUrl( + report: SignalReport, + implementationTask: Task | null, +): string | null { + const fromTask = implementationTask ? getTaskPrUrl(implementationTask) : null; + return fromTask ?? report.implementation_pr_url ?? null; +} diff --git a/packages/core/src/inbox/reportArtefacts.ts b/packages/core/src/inbox/reportArtefacts.ts new file mode 100644 index 0000000000..2a452e8b6d --- /dev/null +++ b/packages/core/src/inbox/reportArtefacts.ts @@ -0,0 +1,55 @@ +import type { + ActionabilityJudgmentArtefact, + ActionabilityJudgmentContent, + PriorityJudgmentArtefact, + SignalFindingArtefact, + SignalReportArtefactsResponse, + SuggestedReviewer, + SuggestedReviewersArtefact, +} from "@posthog/shared/domain-types"; + +type ReportArtefact = SignalReportArtefactsResponse["results"][number]; + +export function selectSuggestedReviewers( + artefacts: ReportArtefact[], +): SuggestedReviewer[] { + const artefact = artefacts.find( + (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", + ); + return artefact?.content ?? []; +} + +export function buildSignalFindingMap( + artefacts: ReportArtefact[], +): Map { + const map = new Map(); + for (const a of artefacts) { + if (a.type === "signal_finding") { + const finding = a as SignalFindingArtefact; + map.set(finding.content.signal_id, finding.content); + } + } + return map; +} + +export function selectActionabilityJudgment( + artefacts: ReportArtefact[], +): ActionabilityJudgmentContent | null { + for (const a of artefacts) { + if (a.type === "actionability_judgment") { + return (a as ActionabilityJudgmentArtefact).content; + } + } + return null; +} + +export function selectPriorityExplanation( + artefacts: ReportArtefact[], +): string | null { + for (const a of artefacts) { + if (a.type === "priority_judgment") { + return (a as PriorityJudgmentArtefact).content.explanation || null; + } + } + return null; +} diff --git a/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts b/packages/core/src/inbox/reportFilters.test.ts similarity index 97% rename from apps/code/src/renderer/features/inbox/utils/filterReports.test.ts rename to packages/core/src/inbox/reportFilters.test.ts index 6042daec01..b94979dbcd 100644 --- a/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts +++ b/packages/core/src/inbox/reportFilters.test.ts @@ -1,10 +1,10 @@ -import type { SignalReport } from "@shared/types"; +import type { SignalReport } from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; import { buildSignalReportListOrdering, buildSuggestedReviewerFilterParam, filterReportsBySearch, -} from "./filterReports"; +} from "./reportFilters"; function makeReport(overrides: Partial = {}): SignalReport { return { diff --git a/apps/code/src/renderer/features/inbox/utils/filterReports.ts b/packages/core/src/inbox/reportFilters.ts similarity index 77% rename from apps/code/src/renderer/features/inbox/utils/filterReports.ts rename to packages/core/src/inbox/reportFilters.ts index 82848f4ae1..a0ae6ea5e1 100644 --- a/apps/code/src/renderer/features/inbox/utils/filterReports.ts +++ b/packages/core/src/inbox/reportFilters.ts @@ -2,7 +2,7 @@ import type { SignalReport, SignalReportOrderingField, SignalReportStatus, -} from "@shared/types"; +} from "@posthog/shared/domain-types"; function normalizeReviewerId(value: string): string { return value.trim(); @@ -70,3 +70,21 @@ export function buildSuggestedReviewerFilterParam( return Array.from(new Set(normalizedIds)).join(","); } + +/** Count of reports surfaced to the current user as up for review. */ +export function countUpForReview(reports: SignalReport[]): number { + return reports.filter(isReportUpForReview).length; +} + +/** Deduped list of enabled source products across the given reports' sources. */ +export function deriveEnabledProducts( + sources: { source_product: string; enabled: boolean }[], +): string[] { + const enabled = new Set(); + for (const source of sources) { + if (source.enabled) { + enabled.add(source.source_product); + } + } + return Array.from(enabled); +} diff --git a/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.ts b/packages/core/src/inbox/reportPrompts.ts similarity index 51% rename from apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.ts rename to packages/core/src/inbox/reportPrompts.ts index 3e67772b07..9f1bed5958 100644 --- a/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.ts +++ b/packages/core/src/inbox/reportPrompts.ts @@ -1,4 +1,8 @@ -import { getDeeplinkProtocol } from "@shared/deeplink"; +import { + buildInboxDeeplink, + buildDiscussReportPrompt as buildSharedDiscussReportPrompt, + getDeeplinkProtocol, +} from "@posthog/shared"; interface BuildCreatePrReportPromptOptions { reportId: string; @@ -12,3 +16,20 @@ export function buildCreatePrReportPrompt({ const reportLink = `${getDeeplinkProtocol(isDevBuild)}://inbox/${reportId}`; return `Act on PostHog inbox report ${reportId} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, its signals, and any suggested reviewers; investigate the root cause; implement the fix; and open a PR. If you can't fetch the report, stop and report that instead of guessing what it contains.`; } + +interface BuildDiscussReportPromptOptions { + reportId: string; + reportTitle?: string | null; + question?: string; + isDevBuild: boolean; +} + +export function buildDiscussReportPrompt({ + reportId, + reportTitle, + question, + isDevBuild, +}: BuildDiscussReportPromptOptions): string { + const reportLink = buildInboxDeeplink(reportId, reportTitle, { isDevBuild }); + return buildSharedDiscussReportPrompt({ reportId, reportLink, question }); +} diff --git a/packages/core/src/inbox/reportRepository.ts b/packages/core/src/inbox/reportRepository.ts new file mode 100644 index 0000000000..5fb7ebe276 --- /dev/null +++ b/packages/core/src/inbox/reportRepository.ts @@ -0,0 +1,23 @@ +import type { SignalReportTask, Task } from "@posthog/shared/domain-types"; + +export const REPOSITORY_SOURCE_RELATIONSHIPS: SignalReportTask["relationship"][] = + ["repo_selection", "research", "implementation"]; + +export async function resolveReportRepository( + reportTasks: SignalReportTask[], + getTask: (taskId: string) => Promise, +): Promise { + for (const relationship of REPOSITORY_SOURCE_RELATIONSHIPS) { + const reportTask = reportTasks.find( + (task) => task.relationship === relationship, + ); + if (!reportTask) { + continue; + } + const task = await getTask(reportTask.task_id); + if (task?.repository) { + return task.repository.toLowerCase(); + } + } + return null; +} diff --git a/packages/core/src/inbox/reportSignals.ts b/packages/core/src/inbox/reportSignals.ts new file mode 100644 index 0000000000..ab50fee579 --- /dev/null +++ b/packages/core/src/inbox/reportSignals.ts @@ -0,0 +1,28 @@ +import type { Signal } from "@posthog/shared/domain-types"; + +function isSessionProblemSignal(signal: Signal): boolean { + return ( + signal.source_product === "session_replay" && + signal.source_type === "session_problem" + ); +} + +export interface PartitionedSignals { + evidence: Signal[]; + signals: Signal[]; +} + +export function partitionSessionProblemSignals( + allSignals: Signal[], +): PartitionedSignals { + const evidence: Signal[] = []; + const signals: Signal[] = []; + for (const signal of allSignals) { + if (isSessionProblemSignal(signal)) { + evidence.push(signal); + } else { + signals.push(signal); + } + } + return { evidence, signals }; +} diff --git a/packages/core/src/inbox/reportTaskCreation.ts b/packages/core/src/inbox/reportTaskCreation.ts new file mode 100644 index 0000000000..d194d09dda --- /dev/null +++ b/packages/core/src/inbox/reportTaskCreation.ts @@ -0,0 +1,65 @@ +import type { TaskCreationInput } from "@posthog/shared"; + +/** Minimal shape of a preview-config option we scan for the default model. */ +export interface PreviewConfigOption { + id?: string; + category?: string; + type?: string; + currentValue?: string | boolean | null; +} + +/** Pick the default model id out of the agent's preview-config options, if present. */ +export function selectModelFromOptions( + options: PreviewConfigOption[], +): string | undefined { + const modelOption = options.find( + (o) => o.id === "model" || o.category === "model", + ); + if ( + modelOption?.type === "select" && + typeof modelOption.currentValue === "string" && + modelOption.currentValue + ) { + return modelOption.currentValue; + } + return undefined; +} + +export interface BuildSignalReportTaskInput { + prompt: string; + reportId: string; + cloudRepository: string; + githubUserIntegrationId: string; + adapter: "claude" | "codex"; + model: string; + reasoningLevel?: string; +} + +/** Build the `TaskCreationInput` for an inbox direct-create (Discuss / Create-PR) flow. */ +export function buildSignalReportTaskInput( + args: BuildSignalReportTaskInput, +): TaskCreationInput { + const { + prompt, + reportId, + cloudRepository, + githubUserIntegrationId, + adapter, + model, + reasoningLevel, + } = args; + return { + content: prompt, + taskDescription: prompt, + repository: cloudRepository, + githubUserIntegrationId, + workspaceMode: "cloud", + executionMode: "auto", + adapter, + model, + reasoningLevel: reasoningLevel ?? undefined, + cloudPrAuthorshipMode: "user", + cloudRunSource: "signal_report", + signalReportId: reportId, + }; +} diff --git a/packages/core/src/inbox/reportTasks.ts b/packages/core/src/inbox/reportTasks.ts new file mode 100644 index 0000000000..8f2d45fc9d --- /dev/null +++ b/packages/core/src/inbox/reportTasks.ts @@ -0,0 +1,44 @@ +import type { SignalReportTask, Task } from "@posthog/shared/domain-types"; + +export type ReportTaskRelationship = SignalReportTask["relationship"]; + +export const DISPLAYED_RELATIONSHIPS: ReportTaskRelationship[] = [ + "implementation", + "research", +]; + +export interface ReportTaskData { + task: Task; + relationship: ReportTaskRelationship; + startedAt: string; +} + +/** Keep only report-task relationships that the detail pane renders. */ +export function selectDisplayedReportTasks( + reportTasks: SignalReportTask[], +): SignalReportTask[] { + return reportTasks.filter((rt) => + DISPLAYED_RELATIONSHIPS.includes(rt.relationship), + ); +} + +/** Sort report tasks by their relationship's display rank. */ +export function sortByRelationship(tasks: ReportTaskData[]): ReportTaskData[] { + return [...tasks].sort( + (a, b) => + DISPLAYED_RELATIONSHIPS.indexOf(a.relationship) - + DISPLAYED_RELATIONSHIPS.indexOf(b.relationship), + ); +} + +/** Extract the PR url from a task's latest run output, if present. */ +export function getTaskPrUrl(task: Task): string | null { + const output = task.latest_run?.output; + if (output && typeof output === "object" && !Array.isArray(output)) { + const prUrl = (output as Record).pr_url; + if (typeof prUrl === "string" && prUrl.length > 0) { + return prUrl; + } + } + return null; +} diff --git a/packages/core/src/inbox/signalReportTaskService.test.ts b/packages/core/src/inbox/signalReportTaskService.test.ts new file mode 100644 index 0000000000..6e1cf43e6a --- /dev/null +++ b/packages/core/src/inbox/signalReportTaskService.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TaskService } from "../task-detail/taskService"; +import type { ReportModelResolver } from "./identifiers"; +import { + type CreateSignalReportTaskInput, + SignalReportTaskService, +} from "./signalReportTaskService"; + +function makeInput( + overrides: Partial = {}, +): CreateSignalReportTaskInput { + return { + kind: "discuss", + reportId: "r1", + reportTitle: "Title", + cloudRepository: "owner/repo", + githubUserIntegrationId: "ghu_1", + cloudRegion: "us", + adapter: "claude", + modelOverride: "claude-sonnet", + isDevBuild: false, + ...overrides, + }; +} + +function makeService( + taskOverrides: Partial = {}, + resolver: Partial = {}, +) { + const createTask = vi + .fn() + .mockResolvedValue({ success: true, data: { task: {} } }); + const taskService = { + createTask, + ...taskOverrides, + } as unknown as TaskService; + const modelResolver = { + resolveDefaultModel: vi.fn().mockResolvedValue("default-model"), + ...resolver, + } as ReportModelResolver; + return { + service: new SignalReportTaskService(taskService, modelResolver), + createTask, + modelResolver, + }; +} + +describe("SignalReportTaskService", () => { + it("aborts without creating a task when no repository", async () => { + const { service, createTask } = makeService(); + const result = await service.createSignalReportTask( + makeInput({ cloudRepository: null }), + vi.fn(), + ); + expect(result.status).toBe("missing-repository"); + expect(createTask).not.toHaveBeenCalled(); + }); + + it("aborts when no integration id", async () => { + const { service } = makeService(); + const result = await service.createSignalReportTask( + makeInput({ githubUserIntegrationId: null }), + vi.fn(), + ); + expect(result.status).toBe("missing-integration"); + }); + + it("falls back to the model resolver when no override", async () => { + const { service, createTask, modelResolver } = makeService(); + const result = await service.createSignalReportTask( + makeInput({ modelOverride: null }), + vi.fn(), + ); + expect(modelResolver.resolveDefaultModel).toHaveBeenCalled(); + expect(createTask).toHaveBeenCalledTimes(1); + expect(result.status).toBe("created"); + }); + + it("aborts with missing-model when no model can be resolved", async () => { + const { service, createTask } = makeService( + {}, + { resolveDefaultModel: vi.fn().mockResolvedValue(undefined) }, + ); + const result = await service.createSignalReportTask( + makeInput({ modelOverride: null }), + vi.fn(), + ); + expect(result.status).toBe("missing-model"); + expect(createTask).not.toHaveBeenCalled(); + }); + + it("returns create-failed when the saga fails", async () => { + const { service } = makeService({ + createTask: vi + .fn() + .mockResolvedValue({ success: false, error: "nope", failedStep: "x" }), + }); + const result = await service.createSignalReportTask(makeInput(), vi.fn()); + expect(result.status).toBe("create-failed"); + if (result.status === "create-failed") { + expect(result.error).toBe("nope"); + } + }); + + it("returns errored when createTask throws", async () => { + const { service } = makeService({ + createTask: vi.fn().mockRejectedValue(new Error("boom")), + }); + const result = await service.createSignalReportTask(makeInput(), vi.fn()); + expect(result.status).toBe("errored"); + }); +}); diff --git a/packages/core/src/inbox/signalReportTaskService.ts b/packages/core/src/inbox/signalReportTaskService.ts new file mode 100644 index 0000000000..eb85eefdbe --- /dev/null +++ b/packages/core/src/inbox/signalReportTaskService.ts @@ -0,0 +1,116 @@ +import { + type CloudRegion, + getCloudUrlFromRegion, + type TaskCreationOutput, +} from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import { + type CreateTaskResult, + TASK_SERVICE, + type TaskService, +} from "../task-detail/taskService"; +import { REPORT_MODEL_RESOLVER, type ReportModelResolver } from "./identifiers"; +import { + buildCreatePrReportPrompt, + buildDiscussReportPrompt, +} from "./reportPrompts"; +import { buildSignalReportTaskInput } from "./reportTaskCreation"; + +export type SignalReportTaskKind = "discuss" | "create-pr"; + +export interface CreateSignalReportTaskInput { + kind: SignalReportTaskKind; + reportId: string; + reportTitle: string | null; + cloudRepository: string | null; + githubUserIntegrationId: string | null; + cloudRegion: CloudRegion | null; + adapter: "claude" | "codex"; + modelOverride?: string | null; + reasoningLevel?: string; + question?: string; + isDevBuild: boolean; +} + +export type CreateSignalReportTaskResult = + | { status: "missing-repository" } + | { status: "missing-integration" } + | { status: "not-authenticated" } + | { status: "missing-model" } + | { status: "created" } + | { status: "create-failed"; error?: string; failedStep?: string } + | { status: "errored"; error: string }; + +@injectable() +export class SignalReportTaskService { + constructor( + @inject(TASK_SERVICE) private readonly taskService: TaskService, + @inject(REPORT_MODEL_RESOLVER) + private readonly modelResolver: ReportModelResolver, + ) {} + + async createSignalReportTask( + input: CreateSignalReportTaskInput, + onTaskReady: (output: TaskCreationOutput) => void, + ): Promise { + if (!input.cloudRepository) { + return { status: "missing-repository" }; + } + if (!input.githubUserIntegrationId) { + return { status: "missing-integration" }; + } + if (!input.cloudRegion) { + return { status: "not-authenticated" }; + } + + const apiHost = getCloudUrlFromRegion(input.cloudRegion); + const model = + input.modelOverride ?? + (await this.modelResolver.resolveDefaultModel(apiHost, input.adapter)); + if (!model) { + return { status: "missing-model" }; + } + + const prompt = + input.kind === "discuss" + ? buildDiscussReportPrompt({ + reportId: input.reportId, + reportTitle: input.reportTitle, + question: input.question, + isDevBuild: input.isDevBuild, + }) + : buildCreatePrReportPrompt({ + reportId: input.reportId, + isDevBuild: input.isDevBuild, + }); + + const taskInput = buildSignalReportTaskInput({ + prompt, + reportId: input.reportId, + cloudRepository: input.cloudRepository, + githubUserIntegrationId: input.githubUserIntegrationId, + adapter: input.adapter, + model, + reasoningLevel: input.reasoningLevel, + }); + + let result: CreateTaskResult; + try { + result = await this.taskService.createTask(taskInput, onTaskReady); + } catch (error) { + return { + status: "errored", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + + if (result.success) { + return { status: "created" }; + } + return { + status: "create-failed", + error: result.error, + failedStep: result.failedStep, + }; + } +} diff --git a/packages/core/src/inbox/signalSourceService.test.ts b/packages/core/src/inbox/signalSourceService.test.ts new file mode 100644 index 0000000000..ff06734571 --- /dev/null +++ b/packages/core/src/inbox/signalSourceService.test.ts @@ -0,0 +1,147 @@ +import type { + ExternalDataSource, + PostHogAPIClient, + SignalSourceConfig, +} from "@posthog/api-client/posthog-client"; +import { describe, expect, it, vi } from "vitest"; +import { + computeSourceValues, + deriveSourceStates, + SignalSourceService, +} from "./signalSourceService"; + +function config( + product: SignalSourceConfig["source_product"], + sourceType: SignalSourceConfig["source_type"], + enabled: boolean, +): SignalSourceConfig { + return { + id: `${product}-${sourceType}`, + source_product: product, + source_type: sourceType, + enabled, + config: {}, + created_at: "", + updated_at: "", + status: null, + }; +} + +function fakeClient(overrides: Partial = {}) { + return { + createSignalSourceConfig: vi.fn().mockResolvedValue({}), + updateSignalSourceConfig: vi.fn().mockResolvedValue({}), + updateExternalDataSchema: vi.fn().mockResolvedValue({}), + updateEvaluation: vi.fn().mockResolvedValue({}), + updateSignalTeamConfig: vi.fn().mockResolvedValue({}), + updateSignalUserAutonomyConfig: vi.fn().mockResolvedValue({}), + deleteSignalUserAutonomyConfig: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as PostHogAPIClient; +} + +describe("computeSourceValues", () => { + it("requires all three error_tracking source types enabled", () => { + const partial = computeSourceValues([ + config("error_tracking", "issue_created", true), + config("error_tracking", "issue_reopened", true), + ]); + expect(partial.error_tracking).toBe(false); + + const full = computeSourceValues([ + config("error_tracking", "issue_created", true), + config("error_tracking", "issue_reopened", true), + config("error_tracking", "issue_spiking", true), + ]); + expect(full.error_tracking).toBe(true); + }); + + it("enables a non-error source when any config is enabled", () => { + const values = computeSourceValues([config("github", "issue", true)]); + expect(values.github).toBe(true); + }); +}); + +describe("deriveSourceStates", () => { + it("flags a warehouse source needing setup when no external source is connected", () => { + const states = deriveSourceStates([], []); + expect(states.github?.requiresSetup).toBe(true); + expect(states.error_tracking?.requiresSetup).toBe(false); + }); +}); + +describe("SignalSourceService.toggleSource", () => { + it("returns requiresSetup for a warehouse source with no external data source", async () => { + const service = new SignalSourceService(); + const result = await service.toggleSource( + fakeClient(), + 1, + "github", + true, + [], + [], + ); + expect(result.requiresSetup).toBe(true); + }); + + it("fans out error_tracking across the three source types", async () => { + const client = fakeClient(); + const service = new SignalSourceService(); + await service.toggleSource(client, 1, "error_tracking", true, [], []); + expect(client.createSignalSourceConfig).toHaveBeenCalledTimes(3); + }); + + it("reports first connection when no config existed", async () => { + const client = fakeClient(); + const service = new SignalSourceService(); + const result = await service.toggleSource( + client, + 1, + "session_replay", + true, + [], + [], + ); + expect(result.isFirstConnection).toBe(true); + expect(client.createSignalSourceConfig).toHaveBeenCalledTimes(1); + }); + + it("ensures the issues table syncs with full_refresh for github before enabling", async () => { + const client = fakeClient(); + const service = new SignalSourceService(); + const external: ExternalDataSource[] = [ + { + id: "ext1", + source_type: "Github", + status: "running", + schemas: [ + { id: "s1", name: "issues", should_sync: false, sync_type: null }, + ], + }, + ]; + await service.toggleSource(client, 1, "github", true, [], external); + expect(client.updateExternalDataSchema).toHaveBeenCalledWith(1, "s1", { + should_sync: true, + sync_type: "full_refresh", + }); + }); +}); + +describe("SignalSourceService.updateUserAutonomyPriority", () => { + it("deletes the config when priority is null", async () => { + const client = fakeClient(); + const service = new SignalSourceService(); + await service.updateUserAutonomyPriority(client, null); + expect(client.deleteSignalUserAutonomyConfig).toHaveBeenCalledTimes(1); + expect(client.updateSignalUserAutonomyConfig).not.toHaveBeenCalled(); + }); +}); + +describe("SignalSourceService.buildSlackNotificationBody", () => { + it("only writes passed keys translated to snake_case", () => { + const service = new SignalSourceService(); + const body = service.buildSlackNotificationBody({ channel: "#alerts" }); + expect(body).toEqual({ slack_notification_channel: "#alerts" }); + expect("slack_notification_integration_id" in body).toBe(false); + }); +}); diff --git a/packages/core/src/inbox/signalSourceService.ts b/packages/core/src/inbox/signalSourceService.ts new file mode 100644 index 0000000000..6138ca1e42 --- /dev/null +++ b/packages/core/src/inbox/signalSourceService.ts @@ -0,0 +1,417 @@ +import type { + ExternalDataSource, + ExternalDataSourceSchema, + PostHogAPIClient, + SignalSourceConfig, +} from "@posthog/api-client/posthog-client"; +import type { SignalUserAutonomyConfig } from "@posthog/shared/domain-types"; +import { injectable } from "inversify"; + +export interface SignalSourceValues { + session_replay: boolean; + error_tracking: boolean; + github: boolean; + linear: boolean; + zendesk: boolean; + conversations: boolean; + pganalyze: boolean; +} + +export type SignalSourceProduct = keyof SignalSourceValues; + +export type WarehouseSourceProduct = + | "github" + | "linear" + | "zendesk" + | "pganalyze"; + +export interface SignalSourceState { + requiresSetup: boolean; + syncStatus: SignalSourceConfig["status"]; +} + +export interface ToggleSourceResult { + requiresSetup: boolean; + isFirstConnection: boolean; +} + +type SourceProduct = SignalSourceConfig["source_product"]; +type SourceType = SignalSourceConfig["source_type"]; + +const SOURCE_TYPE_MAP: Record< + Exclude, + SourceType +> = { + session_replay: "session_analysis_cluster", + github: "issue", + linear: "issue", + zendesk: "ticket", + conversations: "ticket", + pganalyze: "issue", +}; + +const ERROR_TRACKING_SOURCE_TYPES: SourceType[] = [ + "issue_created", + "issue_reopened", + "issue_spiking", +]; + +const DATA_WAREHOUSE_SOURCES: Record< + WarehouseSourceProduct, + { dwSourceType: string; requiredTable: string } +> = { + github: { dwSourceType: "Github", requiredTable: "issues" }, + linear: { dwSourceType: "Linear", requiredTable: "issues" }, + zendesk: { dwSourceType: "Zendesk", requiredTable: "tickets" }, + pganalyze: { dwSourceType: "PgAnalyze", requiredTable: "issues" }, +}; + +const ALL_SOURCE_PRODUCTS: SignalSourceProduct[] = [ + "session_replay", + "error_tracking", + "github", + "linear", + "zendesk", + "conversations", + "pganalyze", +]; + +function isWarehouseSource( + product: SignalSourceProduct, +): product is WarehouseSourceProduct { + return product in DATA_WAREHOUSE_SOURCES; +} + +function findExternalSource( + product: SignalSourceProduct, + externalSources: ExternalDataSource[] | undefined, +): ExternalDataSource | null { + if (!isWarehouseSource(product) || !externalSources) { + return null; + } + const dwConfig = DATA_WAREHOUSE_SOURCES[product]; + return ( + externalSources.find( + (s) => + s.source_type.toLowerCase() === dwConfig.dwSourceType.toLowerCase(), + ) ?? null + ); +} + +export function computeSourceValues( + configs: SignalSourceConfig[] | undefined, +): SignalSourceValues { + const result: SignalSourceValues = { + session_replay: false, + error_tracking: false, + github: false, + linear: false, + zendesk: false, + conversations: false, + pganalyze: false, + }; + if (!configs?.length) { + return result; + } + for (const product of ALL_SOURCE_PRODUCTS) { + if (product === "error_tracking") { + result.error_tracking = ERROR_TRACKING_SOURCE_TYPES.every((st) => + configs.some( + (c) => + c.source_product === "error_tracking" && + c.source_type === st && + c.enabled, + ), + ); + } else { + result[product] = configs.some( + (c) => c.source_product === product && c.enabled, + ); + } + } + return result; +} + +export function deriveSourceStates( + configs: SignalSourceConfig[] | undefined, + externalSources: ExternalDataSource[] | undefined, +): Partial> { + const serverValues = computeSourceValues(configs); + const states: Partial> = {}; + for (const product of ALL_SOURCE_PRODUCTS) { + const config = configs?.find((c) => c.source_product === product); + if (isWarehouseSource(product)) { + states[product] = { + requiresSetup: + !findExternalSource(product, externalSources) && + !serverValues[product], + syncStatus: config?.status ?? null, + }; + } else { + states[product] = { + requiresSetup: false, + syncStatus: config?.status ?? null, + }; + } + } + return states; +} + +function parseSchemas( + source: ExternalDataSource | null, +): ExternalDataSourceSchema[] | null { + if (!source?.schemas || !Array.isArray(source.schemas)) { + return null; + } + return source.schemas; +} + +@injectable() +export class SignalSourceService { + private readonly pending = new Set(); + + isPending(product: SignalSourceProduct): boolean { + return this.pending.has(product); + } + + async ensureRequiredTableSyncing( + client: PostHogAPIClient, + projectId: number, + product: WarehouseSourceProduct, + externalSources: ExternalDataSource[] | undefined, + ): Promise { + const dwConfig = DATA_WAREHOUSE_SOURCES[product]; + const schemas = parseSchemas(findExternalSource(product, externalSources)); + if (!schemas) { + return; + } + + const requiredSchema = schemas.find( + (s) => s.name.toLowerCase() === dwConfig.requiredTable, + ); + if (!requiredSchema) { + return; + } + + const issuesFullReplication = + (product === "github" || product === "linear") && + dwConfig.requiredTable === "issues"; + + if (issuesFullReplication) { + const needsUpdate = + !requiredSchema.should_sync || + requiredSchema.sync_type !== "full_refresh"; + if (needsUpdate) { + await client.updateExternalDataSchema(projectId, requiredSchema.id, { + should_sync: true, + sync_type: "full_refresh", + }); + } + return; + } + + if (!requiredSchema.should_sync) { + await client.updateExternalDataSchema(projectId, requiredSchema.id, { + should_sync: true, + }); + } + } + + requiresSetup( + product: SignalSourceProduct, + externalSources: ExternalDataSource[] | undefined, + ): boolean { + return ( + isWarehouseSource(product) && + !findExternalSource(product, externalSources) + ); + } + + async toggleSource( + client: PostHogAPIClient, + projectId: number, + product: SignalSourceProduct, + enabled: boolean, + configs: SignalSourceConfig[] | undefined, + externalSources: ExternalDataSource[] | undefined, + ): Promise { + if (this.pending.has(product)) { + return { requiresSetup: false, isFirstConnection: false }; + } + + if ( + enabled && + isWarehouseSource(product) && + this.requiresSetup(product, externalSources) + ) { + return { requiresSetup: true, isFirstConnection: false }; + } + + if (enabled && isWarehouseSource(product)) { + await this.ensureRequiredTableSyncing( + client, + projectId, + product, + externalSources, + ); + } + + const hadExistingConfig = !!configs?.some( + (c) => c.source_product === product, + ); + + this.pending.add(product); + try { + if (product === "error_tracking") { + await this.upsertErrorTracking(client, projectId, enabled, configs); + } else { + await this.upsertSingleSource( + client, + projectId, + product, + enabled, + configs, + ); + } + } finally { + this.pending.delete(product); + } + + return { requiresSetup: false, isFirstConnection: !hadExistingConfig }; + } + + private async upsertErrorTracking( + client: PostHogAPIClient, + projectId: number, + enabled: boolean, + configs: SignalSourceConfig[] | undefined, + ): Promise { + for (const sourceType of ERROR_TRACKING_SOURCE_TYPES) { + const existing = configs?.find( + (c) => + c.source_product === "error_tracking" && c.source_type === sourceType, + ); + if (existing) { + await client.updateSignalSourceConfig(projectId, existing.id, { + enabled, + }); + } else if (enabled) { + await client.createSignalSourceConfig(projectId, { + source_product: "error_tracking", + source_type: sourceType, + enabled: true, + }); + } + } + } + + private async upsertSingleSource( + client: PostHogAPIClient, + projectId: number, + product: Exclude, + enabled: boolean, + configs: SignalSourceConfig[] | undefined, + ): Promise { + const existing = configs?.find((c) => c.source_product === product); + if (existing) { + await client.updateSignalSourceConfig(projectId, existing.id, { + enabled, + }); + } else if (enabled) { + await client.createSignalSourceConfig(projectId, { + source_product: product, + source_type: + SOURCE_TYPE_MAP[ + product as Exclude< + SourceProduct, + "error_tracking" | "llm_analytics" + > + ], + enabled: true, + }); + } + } + + async completeSetup( + client: PostHogAPIClient, + projectId: number, + product: WarehouseSourceProduct, + configs: SignalSourceConfig[] | undefined, + ): Promise { + const existing = configs?.find((c) => c.source_product === product); + if (!existing) { + await client.createSignalSourceConfig(projectId, { + source_product: product, + source_type: SOURCE_TYPE_MAP[product], + enabled: true, + }); + } else if (!existing.enabled) { + await client.updateSignalSourceConfig(projectId, existing.id, { + enabled: true, + }); + } + return { requiresSetup: false, isFirstConnection: !existing }; + } + + async toggleEvaluation( + client: PostHogAPIClient, + projectId: number, + evaluationId: string, + enabled: boolean, + ): Promise { + await client.updateEvaluation(projectId, evaluationId, { enabled }); + } + + async updateAutostartPriority( + client: PostHogAPIClient, + priority: string, + ): Promise { + await client.updateSignalTeamConfig({ + default_autostart_priority: priority, + }); + } + + async updateUserAutonomyPriority( + client: PostHogAPIClient, + priority: string | null, + ): Promise { + if (priority === null) { + await client.deleteSignalUserAutonomyConfig(); + return; + } + await client.updateSignalUserAutonomyConfig({ + autostart_priority: priority, + }); + } + + buildSlackNotificationBody(updates: { + integrationId?: number | null; + channel?: string | null; + minPriority?: string | null; + }): Record { + const body: Record = {}; + if ("integrationId" in updates) { + body.slack_notification_integration_id = updates.integrationId ?? null; + } + if ("channel" in updates) { + body.slack_notification_channel = updates.channel ?? null; + } + if ("minPriority" in updates) { + body.slack_notification_min_priority = updates.minPriority ?? null; + } + return body; + } + + async updateSlackNotifications( + client: PostHogAPIClient, + updates: { + integrationId?: number | null; + channel?: string | null; + minPriority?: string | null; + }, + ): Promise { + return client.updateSignalUserAutonomyConfig( + this.buildSlackNotificationBody(updates), + ); + } +} diff --git a/packages/core/src/inbox/statusLabels.ts b/packages/core/src/inbox/statusLabels.ts new file mode 100644 index 0000000000..c787db3665 --- /dev/null +++ b/packages/core/src/inbox/statusLabels.ts @@ -0,0 +1,24 @@ +import type { SignalReportStatus } from "@posthog/shared/domain-types"; + +export function inboxStatusLabel(status: SignalReportStatus): string { + switch (status) { + case "ready": + return "Ready"; + case "pending_input": + return "Needs input"; + case "in_progress": + return "Researching"; + case "candidate": + return "Queued"; + case "potential": + return "Gathering"; + case "failed": + return "Failed"; + case "suppressed": + return "Suppressed"; + case "deleted": + return "Deleted"; + default: + return status; + } +} diff --git a/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.test.ts b/packages/core/src/inbox/suggestedReviewers.test.ts similarity index 97% rename from apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.test.ts rename to packages/core/src/inbox/suggestedReviewers.test.ts index f8ee0c1a88..e160f7b56c 100644 --- a/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.test.ts +++ b/packages/core/src/inbox/suggestedReviewers.test.ts @@ -1,9 +1,9 @@ -import type { AvailableSuggestedReviewer } from "@shared/types"; +import type { AvailableSuggestedReviewer } from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; import { buildSuggestedReviewerFilterOptions, getSuggestedReviewerDisplayName, -} from "./suggestedReviewerFilters"; +} from "./suggestedReviewers"; function makeReviewer( overrides: Partial = {}, diff --git a/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.ts b/packages/core/src/inbox/suggestedReviewers.ts similarity index 97% rename from apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.ts rename to packages/core/src/inbox/suggestedReviewers.ts index d8a772917d..c46f59672f 100644 --- a/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.ts +++ b/packages/core/src/inbox/suggestedReviewers.ts @@ -1,4 +1,4 @@ -import type { AvailableSuggestedReviewer } from "@shared/types"; +import type { AvailableSuggestedReviewer } from "@posthog/shared/domain-types"; export interface CurrentSuggestedReviewerUser { uuid: string; diff --git a/packages/core/src/integrations/branches.test.ts b/packages/core/src/integrations/branches.test.ts new file mode 100644 index 0000000000..f8ba78baff --- /dev/null +++ b/packages/core/src/integrations/branches.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + BRANCHES_FIRST_PAGE_SIZE, + BRANCHES_PAGE_SIZE, + branchPageSizeForOffset, + computeNextBranchOffset, + flattenBranchPages, + type GithubBranchesPage, +} from "./branches"; + +const page = ( + branches: string[], + hasMore: boolean, + defaultBranch: string | null = null, +): GithubBranchesPage => ({ branches, hasMore, defaultBranch }); + +describe("branchPageSizeForOffset", () => { + it("uses the first-page size for offset 0", () => { + expect(branchPageSizeForOffset(0)).toBe(BRANCHES_FIRST_PAGE_SIZE); + expect(branchPageSizeForOffset(50)).toBe(BRANCHES_PAGE_SIZE); + }); +}); + +describe("computeNextBranchOffset", () => { + it("returns undefined when the last page has no more", () => { + expect( + computeNextBranchOffset(page(["a"], false), [page(["a"], false)]), + ).toBe(undefined); + }); + + it("sums branch counts across pages for the next offset", () => { + const pages = [page(["a", "b"], true), page(["c"], true)]; + expect(computeNextBranchOffset(pages[1], pages)).toBe(3); + }); +}); + +describe("flattenBranchPages", () => { + it("returns empty defaults when there are no pages", () => { + expect(flattenBranchPages(undefined)).toEqual({ + branches: [], + defaultBranch: null, + }); + }); + + it("flattens branches and pulls defaultBranch from the first page", () => { + const pages = [page(["a", "b"], true, "main"), page(["c"], false, "dev")]; + expect(flattenBranchPages(pages)).toEqual({ + branches: ["a", "b", "c"], + defaultBranch: "main", + }); + }); +}); diff --git a/packages/core/src/integrations/branches.ts b/packages/core/src/integrations/branches.ts new file mode 100644 index 0000000000..623fdce0d1 --- /dev/null +++ b/packages/core/src/integrations/branches.ts @@ -0,0 +1,37 @@ +export interface GithubBranchesPage { + branches: string[]; + defaultBranch: string | null; + hasMore: boolean; +} + +export const BRANCHES_FIRST_PAGE_SIZE = 50; +export const BRANCHES_PAGE_SIZE = 100; + +export function branchPageSizeForOffset(offset: number): number { + return offset === 0 ? BRANCHES_FIRST_PAGE_SIZE : BRANCHES_PAGE_SIZE; +} + +export function computeNextBranchOffset( + lastPage: GithubBranchesPage, + allPages: ReadonlyArray, +): number | undefined { + if (!lastPage.hasMore) return undefined; + return allPages.reduce((total, page) => total + page.branches.length, 0); +} + +export interface FlattenedBranches { + branches: string[]; + defaultBranch: string | null; +} + +export function flattenBranchPages( + pages: ReadonlyArray | undefined, +): FlattenedBranches { + if (!pages || !pages.length) { + return { branches: [], defaultBranch: null }; + } + return { + branches: pages.flatMap((page) => page.branches), + defaultBranch: pages[0]?.defaultBranch ?? null, + }; +} diff --git a/packages/core/src/integrations/connectEligibility.test.ts b/packages/core/src/integrations/connectEligibility.test.ts new file mode 100644 index 0000000000..652a12db1d --- /dev/null +++ b/packages/core/src/integrations/connectEligibility.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + computeShouldUseTeamFlow, + validateInstallUrl, +} from "./connectEligibility"; + +describe("computeShouldUseTeamFlow", () => { + it("is true only for admins on a project without a team integration in a known region", () => { + expect( + computeShouldUseTeamFlow({ + isAdmin: true, + projectHasTeamIntegration: false, + cloudRegion: "us", + }), + ).toBe(true); + }); + + it("is false for non-admins", () => { + expect( + computeShouldUseTeamFlow({ + isAdmin: false, + projectHasTeamIntegration: false, + cloudRegion: "us", + }), + ).toBe(false); + }); + + it("is false when the project already has a team integration", () => { + expect( + computeShouldUseTeamFlow({ + isAdmin: true, + projectHasTeamIntegration: true, + cloudRegion: "us", + }), + ).toBe(false); + }); + + it("is false when the cloud region is unknown", () => { + expect( + computeShouldUseTeamFlow({ + isAdmin: true, + projectHasTeamIntegration: false, + cloudRegion: null, + }), + ).toBe(false); + }); +}); + +describe("validateInstallUrl", () => { + it("returns the trimmed url", () => { + expect(validateInstallUrl(" https://x ")).toBe("https://x"); + }); + + it("throws when empty", () => { + expect(() => validateInstallUrl("")).toThrow(); + expect(() => validateInstallUrl(null)).toThrow(); + }); +}); diff --git a/packages/core/src/integrations/connectEligibility.ts b/packages/core/src/integrations/connectEligibility.ts new file mode 100644 index 0000000000..5df64a405b --- /dev/null +++ b/packages/core/src/integrations/connectEligibility.ts @@ -0,0 +1,25 @@ +export interface TeamFlowEligibility { + isAdmin: boolean | null; + projectHasTeamIntegration: boolean | null; + cloudRegion: string | null; +} + +export function computeShouldUseTeamFlow( + eligibility: TeamFlowEligibility, +): boolean { + return ( + eligibility.isAdmin === true && + eligibility.projectHasTeamIntegration === false && + eligibility.cloudRegion != null + ); +} + +export function validateInstallUrl( + installUrl: string | null | undefined, +): string { + const trimmed = installUrl?.trim() ?? ""; + if (!trimmed) { + throw new Error("GitHub connection did not return a URL"); + } + return trimmed; +} diff --git a/packages/core/src/integrations/connectErrors.test.ts b/packages/core/src/integrations/connectErrors.test.ts new file mode 100644 index 0000000000..49cf0ec4ce --- /dev/null +++ b/packages/core/src/integrations/connectErrors.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { describeGithubConnectError } from "./connectErrors"; + +describe("describeGithubConnectError", () => { + it("returns an empty string for no error", () => { + expect(describeGithubConnectError(null)).toBe(""); + }); + + it("maps a known error code to a friendly message", () => { + expect( + describeGithubConnectError({ message: "raw", code: "access_denied" }), + ).toContain("declined access"); + }); + + it("falls back to the raw message for unknown codes", () => { + expect( + describeGithubConnectError({ message: "raw message", code: "unknown" }), + ).toBe("raw message"); + }); +}); diff --git a/packages/core/src/integrations/connectErrors.ts b/packages/core/src/integrations/connectErrors.ts new file mode 100644 index 0000000000..a11528c191 --- /dev/null +++ b/packages/core/src/integrations/connectErrors.ts @@ -0,0 +1,41 @@ +export interface GithubConnectError { + message: string; + code: string | null; +} + +export const GITHUB_CONNECT_ERROR_MESSAGES: Record = { + access_denied: + "You declined access on GitHub. Try again to grant the permissions PostHog Code needs.", + github_oauth_error: "GitHub returned an error during sign-in. Please retry.", + missing_params: "GitHub returned an incomplete response. Please retry.", + invalid_state: + "The connection link expired before you finished. Please retry.", + invalid_installation: + "This GitHub installation isn't reachable from your account. Try a different account or org.", + invalid_team: + "Your project access changed during sign-in. Please retry from the current project.", + invalid_installation_id: + "GitHub returned an invalid installation. Please retry.", + exchange_failed: + "Couldn't exchange the GitHub authorization code. Please retry.", + installation_verify_failed: + "Couldn't verify your access to this GitHub installation. Please retry.", + installation_not_authorized: + "Your GitHub account isn't authorized for this installation. Ask the org admin to grant access, or sign in with a different GitHub account.", + installation_fetch_failed: + "Couldn't fetch installation details from GitHub. Please retry.", + installation_token_failed: + "Couldn't get an access token from GitHub. Please retry.", + integration_create_failed: + "Couldn't save the GitHub connection. Please retry.", +}; + +export function describeGithubConnectError( + error: GithubConnectError | null, +): string { + if (!error) return ""; + if (error.code && GITHUB_CONNECT_ERROR_MESSAGES[error.code]) { + return GITHUB_CONNECT_ERROR_MESSAGES[error.code]; + } + return error.message; +} diff --git a/packages/core/src/integrations/connectMachine.test.ts b/packages/core/src/integrations/connectMachine.test.ts new file mode 100644 index 0000000000..20e42ee141 --- /dev/null +++ b/packages/core/src/integrations/connectMachine.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + CONNECT_INITIAL_STATUS, + connectReducer, + deriveConnectFlags, + githubInvalidationKeys, + slackInvalidationKeys, + toConnectError, +} from "./connectMachine"; + +describe("connectReducer", () => { + it("begin clears error and moves to connecting", () => { + expect( + connectReducer( + { state: "error", error: { message: "x", code: null } }, + { type: "begin" }, + ), + ).toEqual({ state: "connecting", error: null }); + }); + + it("fail records the error", () => { + const error = { message: "boom", code: "x" }; + expect( + connectReducer(CONNECT_INITIAL_STATUS, { type: "fail", error }), + ).toEqual({ state: "error", error }); + }); + + it("succeed and reset return to idle", () => { + expect( + connectReducer(CONNECT_INITIAL_STATUS, { type: "succeed" }).state, + ).toBe("idle"); + expect( + connectReducer(CONNECT_INITIAL_STATUS, { type: "reset" }).state, + ).toBe("idle"); + }); + + it("timeout preserves the existing error", () => { + const status = { + state: "error" as const, + error: { message: "e", code: null }, + }; + expect(connectReducer(status, { type: "timeout" })).toEqual({ + state: "timed-out", + error: status.error, + }); + }); +}); + +describe("deriveConnectFlags", () => { + it("derives boolean flags from state", () => { + expect(deriveConnectFlags("connecting")).toEqual({ + isConnecting: true, + isTimedOut: false, + hasError: false, + }); + expect(deriveConnectFlags("error").hasError).toBe(true); + expect(deriveConnectFlags("timed-out").isTimedOut).toBe(true); + }); +}); + +describe("toConnectError", () => { + it("uses the error message when given an Error", () => { + expect(toConnectError(new Error("nope"), "fallback")).toEqual({ + message: "nope", + code: null, + }); + }); + + it("falls back for non-Error values", () => { + expect(toConnectError("x", "fallback").message).toBe("fallback"); + }); +}); + +describe("invalidation keys", () => { + it("omits the project key when projectId is null", () => { + expect(githubInvalidationKeys(null)).toEqual([ + ["integrations", "list"], + ["user-github-integrations"], + ["github_login"], + ]); + }); + + it("includes the project key when projectId is set", () => { + expect(githubInvalidationKeys(7)[0]).toEqual(["integrations", 7]); + }); + + it("slack keys cover list and root", () => { + expect(slackInvalidationKeys()).toEqual([ + ["integrations", "list"], + ["integrations"], + ]); + }); +}); diff --git a/packages/core/src/integrations/connectMachine.ts b/packages/core/src/integrations/connectMachine.ts new file mode 100644 index 0000000000..5550c72da4 --- /dev/null +++ b/packages/core/src/integrations/connectMachine.ts @@ -0,0 +1,84 @@ +export type ConnectState = "idle" | "connecting" | "timed-out" | "error"; + +export interface ConnectError { + message: string; + code: string | null; +} + +export interface ConnectStatus { + state: ConnectState; + error: ConnectError | null; +} + +export type ConnectAction = + | { type: "begin" } + | { type: "succeed" } + | { type: "fail"; error: ConnectError } + | { type: "timeout" } + | { type: "reset" }; + +export const CONNECT_INITIAL_STATUS: ConnectStatus = { + state: "idle", + error: null, +}; + +export function connectReducer( + status: ConnectStatus, + action: ConnectAction, +): ConnectStatus { + switch (action.type) { + case "begin": + return { state: "connecting", error: null }; + case "succeed": + return { state: "idle", error: null }; + case "fail": + return { state: "error", error: action.error }; + case "timeout": + return { state: "timed-out", error: status.error }; + case "reset": + return { state: "idle", error: null }; + default: + return status; + } +} + +export interface ConnectFlags { + isConnecting: boolean; + isTimedOut: boolean; + hasError: boolean; +} + +export function deriveConnectFlags(state: ConnectState): ConnectFlags { + return { + isConnecting: state === "connecting", + isTimedOut: state === "timed-out", + hasError: state === "error", + }; +} + +export function toConnectError( + error: unknown, + fallbackMessage: string, +): ConnectError { + return { + message: error instanceof Error ? error.message : fallbackMessage, + code: null, + }; +} + +export function githubInvalidationKeys( + projectId: number | null = null, +): ReadonlyArray> { + const keys: ReadonlyArray[] = []; + if (projectId !== null) { + keys.push(["integrations", projectId]); + } + keys.push(["integrations", "list"]); + keys.push(["user-github-integrations"]); + keys.push(["github_login"]); + return keys; +} + +export function slackInvalidationKeys(): ReadonlyArray> { + return [["integrations", "list"], ["integrations"]]; +} diff --git a/packages/core/src/integrations/github.test.ts b/packages/core/src/integrations/github.test.ts new file mode 100644 index 0000000000..00071264ee --- /dev/null +++ b/packages/core/src/integrations/github.test.ts @@ -0,0 +1,215 @@ +import type { IDeepLinkRegistry } from "@posthog/platform/deep-link"; +import type { IMainWindow } from "@posthog/platform/main-window"; +import { describe, expect, it, vi } from "vitest"; +import { GitHubIntegrationEvent, GitHubIntegrationService } from "./github"; + +function makeLogger() { + const scoped = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + return { ...scoped, scope: vi.fn(() => scoped) }; +} + +function createMockDeepLinkService() { + const handlers = new Map< + string, + (path: string, params: URLSearchParams) => boolean + >(); + return { + registerHandler: vi.fn((key, handler) => handlers.set(key, handler)), + _invoke(key: string, params: URLSearchParams) { + const handler = handlers.get(key); + if (!handler) throw new Error(`No handler for key: ${key}`); + return handler("", params); + }, + }; +} + +function createMockMainWindow(): IMainWindow { + return { + isMinimized: vi.fn(() => false), + restore: vi.fn(), + focus: vi.fn(), + close: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + minimize: vi.fn(), + maximize: vi.fn(), + unmaximize: vi.fn(), + isMaximized: vi.fn(() => false), + isVisible: vi.fn(() => true), + setTitle: vi.fn(), + loadURL: vi.fn(), + webContents: {} as never, + on: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + } as unknown as IMainWindow; +} + +function createDeps() { + const deepLink = createMockDeepLinkService(); + const urlLauncher = { launch: vi.fn().mockResolvedValue(undefined) }; + const mainWindow = createMockMainWindow(); + const service = new GitHubIntegrationService( + deepLink as unknown as IDeepLinkRegistry, + urlLauncher as never, + mainWindow, + makeLogger(), + ); + return { service, deepLink, urlLauncher, mainWindow }; +} + +describe("GitHubIntegrationService.startFlow", () => { + it("launches an authorize URL scoped to the project and returns success", async () => { + const { service, urlLauncher } = createDeps(); + + const result = await service.startFlow("us", 42); + + expect(result).toEqual({ success: true }); + const launched = urlLauncher.launch.mock.calls[0][0]; + expect(launched).toContain("/api/environments/42/integrations/authorize/"); + expect(launched).toContain("kind=github"); + }); + + it("returns a failure result when launching the browser throws", async () => { + const { service, urlLauncher } = createDeps(); + urlLauncher.launch.mockRejectedValue(new Error("no browser")); + + const result = await service.startFlow("us", 42); + + expect(result).toEqual({ success: false, error: "no browser" }); + }); + + it("emits FlowTimedOut after the timeout elapses", async () => { + vi.useFakeTimers(); + try { + const { service } = createDeps(); + const timedOut = vi.fn(); + service.on(GitHubIntegrationEvent.FlowTimedOut, timedOut); + + await service.startFlow("us", 7); + vi.advanceTimersByTime(5 * 60 * 1000); + + expect(timedOut).toHaveBeenCalledWith({ projectId: 7 }); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("GitHubIntegrationService callback handling", () => { + it("registers the integration deep-link handler", () => { + const { deepLink } = createDeps(); + expect(deepLink.registerHandler).toHaveBeenCalledWith( + "integration", + expect.any(Function), + ); + }); + + it("parses a successful callback and emits it when a listener exists", () => { + const { service, deepLink } = createDeps(); + const listener = vi.fn(); + service.on(GitHubIntegrationEvent.Callback, listener); + + const result = deepLink._invoke( + "integration", + new URLSearchParams( + "provider=github&project_id=42&installation_id=inst_1&status=success", + ), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith({ + provider: "github", + projectId: 42, + installationId: "inst_1", + status: "success", + errorCode: null, + errorMessage: null, + }); + }); + + it("treats a non-numeric project_id as null", () => { + const { service, deepLink } = createDeps(); + const listener = vi.fn(); + service.on(GitHubIntegrationEvent.Callback, listener); + + deepLink._invoke( + "integration", + new URLSearchParams("provider=github&project_id=not-a-number"), + ); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ projectId: null }), + ); + }); + + it("captures error status with error code and message", () => { + const { service, deepLink } = createDeps(); + const listener = vi.fn(); + service.on(GitHubIntegrationEvent.Callback, listener); + + deepLink._invoke( + "integration", + new URLSearchParams( + "provider=github&status=error&error_code=denied&error_message=User+declined", + ), + ); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + status: "error", + errorCode: "denied", + errorMessage: "User declined", + }), + ); + }); + + it("queues the callback when no listener exists and consumes it once", () => { + const { service, deepLink } = createDeps(); + + deepLink._invoke( + "integration", + new URLSearchParams("provider=github&project_id=5&status=success"), + ); + + expect(service.consumePendingCallback()).toEqual( + expect.objectContaining({ projectId: 5, status: "success" }), + ); + expect(service.consumePendingCallback()).toBeNull(); + }); + + it("focuses and restores the window on callback", () => { + const { deepLink, mainWindow } = createDeps(); + vi.mocked(mainWindow.isMinimized).mockReturnValue(true); + + deepLink._invoke("integration", new URLSearchParams("provider=github")); + + expect(mainWindow.restore).toHaveBeenCalled(); + expect(mainWindow.focus).toHaveBeenCalled(); + }); + + it("cancels the flow timeout so a late callback does not fire FlowTimedOut", async () => { + vi.useFakeTimers(); + try { + const { service, deepLink } = createDeps(); + const timedOut = vi.fn(); + service.on(GitHubIntegrationEvent.FlowTimedOut, timedOut); + + await service.startFlow("us", 7); + deepLink._invoke( + "integration", + new URLSearchParams("provider=github&project_id=7&status=success"), + ); + vi.advanceTimersByTime(5 * 60 * 1000); + + expect(timedOut).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/code/src/main/services/github-integration/service.ts b/packages/core/src/integrations/github.ts similarity index 76% rename from apps/code/src/main/services/github-integration/service.ts rename to packages/core/src/integrations/github.ts index 87524cd277..d3234a7604 100644 --- a/apps/code/src/main/services/github-integration/service.ts +++ b/packages/core/src/integrations/github.ts @@ -1,14 +1,27 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; -import type { IUrlLauncher } from "@posthog/platform/url-launcher"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { + DEEP_LINK_SERVICE, + type IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { + type IUrlLauncher, + URL_LAUNCHER_SERVICE, +} from "@posthog/platform/url-launcher"; +import { + type CloudRegion, + getCloudUrlFromRegion, + TypedEventEmitter, +} from "@posthog/shared"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import type { DeepLinkService } from "../deep-link/service"; -import type { CloudRegion, StartGitHubFlowOutput } from "./schemas"; - -const log = logger.scope("github-integration-service"); +import type { StartIntegrationFlowOutput } from "./schemas"; const FLOW_TIMEOUT_MS = 5 * 60 * 1000; @@ -39,17 +52,22 @@ export interface GitHubIntegrationEvents { export class GitHubIntegrationService extends TypedEventEmitter { private pendingCallback: IntegrationCallback | null = null; private flowTimeout: ReturnType | null = null; + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.UrlLauncher) + @inject(DEEP_LINK_SERVICE) + private readonly deepLinkService: IDeepLinkRegistry, + @inject(URL_LAUNCHER_SERVICE) private readonly urlLauncher: IUrlLauncher, - @inject(MAIN_TOKENS.MainWindow) + @inject(MAIN_WINDOW_SERVICE) private readonly mainWindow: IMainWindow, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, ) { super(); + this.log = workbenchLogger.scope("github-integration-service"); + this.deepLinkService.registerHandler("integration", (_path, params) => this.handleCallback(params), ); @@ -58,7 +76,7 @@ export class GitHubIntegrationService extends TypedEventEmitter { + ): Promise { try { const cloudUrl = getCloudUrlFromRegion(region); const nextPath = `/account-connected/github-integration?provider=github&project_id=${projectId}&connect_from=posthog_code`; @@ -66,7 +84,7 @@ export class GitHubIntegrationService extends TypedEventEmitter { - log.warn("GitHub integration flow timed out", { projectId }); + this.log.warn("GitHub integration flow timed out", { projectId }); this.flowTimeout = null; this.emit(GitHubIntegrationEvent.FlowTimedOut, { projectId }); }, FLOW_TIMEOUT_MS); @@ -76,7 +94,7 @@ export class GitHubIntegrationService extends TypedEventEmitter = {}, +): GithubConnectClient { + return { + startUserConnect: vi + .fn() + .mockResolvedValue({ install_url: "https://github.test/install" }), + launchUrl: vi.fn().mockResolvedValue(undefined), + startTeamFlow: vi.fn().mockResolvedValue({ success: true }), + ...overrides, + }; +} + +describe("GithubConnectService.connect", () => { + it("runs the team flow for an eligible admin and reports flow team", async () => { + const client = makeClient(); + const service = new GithubConnectService(client); + + const outcome = await service.connect({ + projectId: 7, + isAdmin: true, + projectHasTeamIntegration: false, + cloudRegion: "us", + }); + + expect(outcome).toEqual({ flow: "team" }); + expect(client.startTeamFlow).toHaveBeenCalledWith({ + region: "us", + projectId: 7, + }); + expect(client.startUserConnect).not.toHaveBeenCalled(); + expect(client.launchUrl).not.toHaveBeenCalled(); + }); + + it("falls through to the user flow when the team decision is false", async () => { + const client = makeClient(); + const service = new GithubConnectService(client); + + const outcome = await service.connect({ + projectId: 7, + isAdmin: false, + projectHasTeamIntegration: false, + cloudRegion: "us", + }); + + expect(outcome).toEqual({ flow: "user" }); + expect(client.startTeamFlow).not.toHaveBeenCalled(); + expect(client.startUserConnect).toHaveBeenCalledWith(7); + expect(client.launchUrl).toHaveBeenCalledWith( + "https://github.test/install", + ); + }); + + it("throws when the team flow reports failure", async () => { + const client = makeClient({ + startTeamFlow: vi + .fn() + .mockResolvedValue({ success: false, error: "nope" }), + }); + const service = new GithubConnectService(client); + + await expect( + service.connect({ + projectId: 7, + isAdmin: true, + projectHasTeamIntegration: false, + cloudRegion: "us", + }), + ).rejects.toThrow("nope"); + }); + + it("throws when the user flow returns an empty install url", async () => { + const client = makeClient({ + startUserConnect: vi.fn().mockResolvedValue({ install_url: "" }), + }); + const service = new GithubConnectService(client); + + await expect( + service.connect({ + projectId: 7, + isAdmin: false, + projectHasTeamIntegration: true, + cloudRegion: "us", + }), + ).rejects.toThrow("GitHub connection did not return a URL"); + expect(client.launchUrl).not.toHaveBeenCalled(); + }); +}); + +describe("GithubConnectService.connectUser", () => { + it("always runs the user flow and launches the validated url", async () => { + const client = makeClient(); + const service = new GithubConnectService(client); + + const outcome = await service.connectUser(42); + + expect(outcome).toEqual({ flow: "user" }); + expect(client.startUserConnect).toHaveBeenCalledWith(42); + expect(client.launchUrl).toHaveBeenCalledWith( + "https://github.test/install", + ); + expect(client.startTeamFlow).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/integrations/githubConnectService.ts b/packages/core/src/integrations/githubConnectService.ts new file mode 100644 index 0000000000..6d7f7f4d6e --- /dev/null +++ b/packages/core/src/integrations/githubConnectService.ts @@ -0,0 +1,58 @@ +import { inject, injectable } from "inversify"; +import { + computeShouldUseTeamFlow, + validateInstallUrl, +} from "./connectEligibility"; +import { GITHUB_CONNECT_CLIENT, type GithubConnectClient } from "./identifiers"; + +export interface ConnectInput { + projectId: number; + isAdmin: boolean | null; + projectHasTeamIntegration: boolean | null; + cloudRegion: string | null; +} + +export interface ConnectOutcome { + flow: "team" | "user"; +} + +@injectable() +export class GithubConnectService { + constructor( + @inject(GITHUB_CONNECT_CLIENT) + private readonly client: GithubConnectClient, + ) {} + + async connect(input: ConnectInput): Promise { + const useTeamFlow = computeShouldUseTeamFlow({ + isAdmin: input.isAdmin, + projectHasTeamIntegration: input.projectHasTeamIntegration, + cloudRegion: input.cloudRegion, + }); + + if (useTeamFlow && input.cloudRegion) { + const result = await this.client.startTeamFlow({ + region: input.cloudRegion, + projectId: input.projectId, + }); + if (!result.success) { + throw new Error(result.error ?? "Failed to start GitHub connection"); + } + return { flow: "team" }; + } + + await this.runUserFlow(input.projectId); + return { flow: "user" }; + } + + async connectUser(projectId: number): Promise { + await this.runUserFlow(projectId); + return { flow: "user" }; + } + + private async runUserFlow(projectId: number): Promise { + const res = await this.client.startUserConnect(projectId); + const installUrl = validateInstallUrl(res.install_url); + await this.client.launchUrl(installUrl); + } +} diff --git a/packages/core/src/integrations/identifiers.ts b/packages/core/src/integrations/identifiers.ts new file mode 100644 index 0000000000..ebc267f8c4 --- /dev/null +++ b/packages/core/src/integrations/identifiers.ts @@ -0,0 +1,46 @@ +export const GITHUB_INTEGRATION_SERVICE = Symbol.for( + "posthog.core.githubIntegrationService", +); + +export const LINEAR_INTEGRATION_SERVICE = Symbol.for( + "posthog.core.linearIntegrationService", +); + +export const SLACK_INTEGRATION_SERVICE = Symbol.for( + "posthog.core.slackIntegrationService", +); + +export interface RepositoriesClient { + refreshTeamRepository(integrationId: number): Promise; + refreshUserRepository(installationId: string): Promise; +} + +export const REPOSITORIES_CLIENT = Symbol.for( + "posthog.core.repositoriesClient", +); + +export const REPOSITORIES_SERVICE = Symbol.for( + "posthog.core.repositoriesService", +); + +export interface TeamFlowResult { + success: boolean; + error?: string; +} + +export interface GithubConnectClient { + startUserConnect(projectId: number): Promise<{ install_url: string }>; + launchUrl(url: string): Promise; + startTeamFlow(input: { + region: string; + projectId: number; + }): Promise; +} + +export const GITHUB_CONNECT_CLIENT = Symbol.for( + "posthog.core.githubConnectClient", +); + +export const GITHUB_CONNECT_SERVICE = Symbol.for( + "posthog.core.githubConnectService", +); diff --git a/packages/core/src/integrations/integrations.module.ts b/packages/core/src/integrations/integrations.module.ts new file mode 100644 index 0000000000..bd57929177 --- /dev/null +++ b/packages/core/src/integrations/integrations.module.ts @@ -0,0 +1,21 @@ +import { ContainerModule } from "inversify"; +import { GitHubIntegrationService } from "./github"; +import { + GITHUB_INTEGRATION_SERVICE, + LINEAR_INTEGRATION_SERVICE, + SLACK_INTEGRATION_SERVICE, +} from "./identifiers"; +import { LinearIntegrationService } from "./linear"; +import { SlackIntegrationService } from "./slack"; + +export const integrationsModule = new ContainerModule(({ bind }) => { + bind(GITHUB_INTEGRATION_SERVICE) + .to(GitHubIntegrationService) + .inSingletonScope(); + bind(LINEAR_INTEGRATION_SERVICE) + .to(LinearIntegrationService) + .inSingletonScope(); + bind(SLACK_INTEGRATION_SERVICE) + .to(SlackIntegrationService) + .inSingletonScope(); +}); diff --git a/packages/core/src/integrations/linear.test.ts b/packages/core/src/integrations/linear.test.ts new file mode 100644 index 0000000000..a8cc351746 --- /dev/null +++ b/packages/core/src/integrations/linear.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from "vitest"; +import { LinearIntegrationService } from "./linear"; + +function createService() { + const urlLauncher = { launch: vi.fn().mockResolvedValue(undefined) }; + const service = new LinearIntegrationService(urlLauncher as never); + return { service, urlLauncher }; +} + +describe("LinearIntegrationService.startFlow", () => { + it("launches a linear authorize URL scoped to the project and returns success", async () => { + const { service, urlLauncher } = createService(); + + const result = await service.startFlow("us", 42); + + expect(result).toEqual({ success: true }); + const launched = urlLauncher.launch.mock.calls[0][0]; + expect(launched).toContain("/api/environments/42/integrations/authorize/"); + expect(launched).toContain("kind=linear"); + }); + + it("returns a failure result when launching the browser throws", async () => { + const { service, urlLauncher } = createService(); + urlLauncher.launch.mockRejectedValue(new Error("no browser")); + + expect(await service.startFlow("us", 42)).toEqual({ + success: false, + error: "no browser", + }); + }); +}); diff --git a/apps/code/src/main/services/linear-integration/service.ts b/packages/core/src/integrations/linear.ts similarity index 60% rename from apps/code/src/main/services/linear-integration/service.ts rename to packages/core/src/integrations/linear.ts index 1cf3ff2a40..a61ee53bb1 100644 --- a/apps/code/src/main/services/linear-integration/service.ts +++ b/packages/core/src/integrations/linear.ts @@ -1,29 +1,27 @@ -import type { IUrlLauncher } from "@posthog/platform/url-launcher"; -import { getCloudUrlFromRegion } from "@shared/utils/urls.js"; +import { + type IUrlLauncher, + URL_LAUNCHER_SERVICE, +} from "@posthog/platform/url-launcher"; +import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens.js"; -import { logger } from "../../utils/logger.js"; -import type { CloudRegion, StartLinearFlowOutput } from "./schemas.js"; - -const log = logger.scope("linear-integration-service"); +import type { StartIntegrationFlowOutput } from "./schemas"; @injectable() export class LinearIntegrationService { constructor( - @inject(MAIN_TOKENS.UrlLauncher) + @inject(URL_LAUNCHER_SERVICE) private readonly urlLauncher: IUrlLauncher, ) {} public async startFlow( region: CloudRegion, projectId: number, - ): Promise { + ): Promise { try { const cloudUrl = getCloudUrlFromRegion(region); const next = `${cloudUrl}/project/${projectId}`; const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=linear&next=${encodeURIComponent(next)}`; - log.info("Opening Linear authorization URL in browser"); await this.urlLauncher.launch(authorizeUrl); return { success: true }; diff --git a/packages/core/src/integrations/repositories.test.ts b/packages/core/src/integrations/repositories.test.ts new file mode 100644 index 0000000000..230337e5f1 --- /dev/null +++ b/packages/core/src/integrations/repositories.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { + combineGithubRepositories, + combineRepositoryPicker, + combineUserGithubRepositories, + getIntegrationIdForRepo, + isRepoInIntegration, + normalizeRepoKey, + type RepositoryQueryResult, + type TeamRepositoriesResult, + type UserRepositoriesResult, + type UserRepositoryIntegrationRef, +} from "./repositories"; + +function result( + data: T | undefined, + flags: Partial, "data">> = {}, +): RepositoryQueryResult { + return { + data, + isPending: flags.isPending ?? false, + isError: flags.isError ?? false, + isRefetching: flags.isRefetching ?? false, + }; +} + +describe("combineGithubRepositories", () => { + it("builds a repo->integration map and keeps the first integration to claim a repo", () => { + const results: RepositoryQueryResult[] = [ + result({ integrationId: 1, repos: ["a/x", "a/y"] }), + result({ integrationId: 2, repos: ["a/x", "a/z"] }), + ]; + + const combined = combineGithubRepositories(results); + + expect(combined.repositoryMap).toEqual({ + "a/x": 1, + "a/y": 1, + "a/z": 2, + }); + expect(combined.isPending).toBe(false); + }); + + it("reports pending when any result is pending", () => { + const combined = combineGithubRepositories([ + result(undefined, { isPending: true }), + ]); + expect(combined.isPending).toBe(true); + }); +}); + +describe("combineUserGithubRepositories", () => { + it("tracks reposByInstallationId and tallies failed installation ids", () => { + const results: RepositoryQueryResult[] = [ + result({ + userIntegrationId: "u1", + installationId: "i1", + repos: ["a/x"], + }), + result(undefined, { isError: true }), + ]; + + const combined = combineUserGithubRepositories(results, ["i1", "i2"]); + + expect(combined.repositoryMap["a/x"]).toEqual({ + userIntegrationId: "u1", + installationId: "i1", + }); + expect(combined.reposByInstallationId).toEqual({ i1: ["a/x"] }); + expect(combined.failedInstallationIds).toEqual(["i2"]); + }); +}); + +describe("combineRepositoryPicker", () => { + it("merges pages, derives hasMore/isRefreshing/isPending", () => { + const combined = combineRepositoryPicker([ + { + data: { + ref: { userIntegrationId: "u1", installationId: "i1" }, + repositories: ["a/x"], + hasMore: true, + }, + isPending: false, + isError: false, + isRefetching: true, + }, + ]); + + expect(Object.keys(combined.repositoryMap)).toEqual(["a/x"]); + expect(combined.hasMore).toBe(true); + expect(combined.isRefreshing).toBe(true); + }); +}); + +describe("repo key helpers", () => { + it("normalizes case", () => { + expect(normalizeRepoKey("Acme/Repo")).toBe("acme/repo"); + }); + + it("looks up integration id case-insensitively", () => { + expect(getIntegrationIdForRepo({ "a/x": 5 }, "A/X")).toBe(5); + }); + + it("treats empty repo key as in-integration", () => { + expect(isRepoInIntegration({}, "")).toBe(true); + expect(isRepoInIntegration({ "a/x": 1 }, "A/X")).toBe(true); + expect(isRepoInIntegration({}, "a/x")).toBe(false); + }); +}); diff --git a/packages/core/src/integrations/repositories.ts b/packages/core/src/integrations/repositories.ts new file mode 100644 index 0000000000..96fac8f862 --- /dev/null +++ b/packages/core/src/integrations/repositories.ts @@ -0,0 +1,157 @@ +export interface RepositoryQueryResult { + data: TData | undefined; + isPending: boolean; + isError: boolean; + isRefetching: boolean; +} + +export interface TeamRepositoriesResult { + integrationId: number; + repos?: string[] | null; +} + +export interface CombinedTeamRepositories { + repositoryMap: Record; + isPending: boolean; +} + +export function combineGithubRepositories( + results: ReadonlyArray>, +): CombinedTeamRepositories { + const map: Record = {}; + let pending = false; + for (const result of results) { + if (result.isPending) pending = true; + if (!result.data) continue; + for (const repo of result.data.repos ?? []) { + if (!(repo in map)) { + map[repo] = result.data.integrationId; + } + } + } + return { repositoryMap: map, isPending: pending }; +} + +export interface UserRepositoryIntegrationRef { + userIntegrationId: string; + installationId: string; +} + +export interface UserRepositoriesResult { + userIntegrationId: string; + installationId: string; + repos?: string[] | null; +} + +export interface CombinedUserRepositories { + repositoryMap: Record; + reposByInstallationId: Record; + isPending: boolean; + failedInstallationIds: string[]; +} + +export function combineUserGithubRepositories( + results: ReadonlyArray>, + installationIds: ReadonlyArray, +): CombinedUserRepositories { + const map: Record = {}; + const reposByInstallationId: Record = {}; + const failedInstallationIds: string[] = []; + let pending = false; + + results.forEach((result, index) => { + if (result.isPending) pending = true; + if (result.isError) { + const installationId = installationIds[index] ?? null; + if (installationId) failedInstallationIds.push(installationId); + } + if (!result.data) return; + const installationRepos = result.data.repos ?? []; + reposByInstallationId[result.data.installationId] = installationRepos; + for (const repo of installationRepos) { + if (!(repo in map)) { + map[repo] = { + userIntegrationId: result.data.userIntegrationId, + installationId: result.data.installationId, + }; + } + } + }); + + return { + repositoryMap: map, + reposByInstallationId, + isPending: pending, + failedInstallationIds, + }; +} + +export interface RepositoryPageResult { + ref: TRef; + repositories?: string[] | null; + hasMore?: boolean; +} + +export interface CombinedRepositoryPicker { + repositoryMap: Record; + isPending: boolean; + isRefreshing: boolean; + hasMore: boolean; +} + +export function combineRepositoryPicker( + results: ReadonlyArray>>, +): CombinedRepositoryPicker { + const map: Record = {}; + let pending = false; + let refreshing = false; + let hasMoreResults = false; + + for (const result of results) { + if (result.isPending) pending = true; + if (result.isRefetching) refreshing = true; + if (!result.data) continue; + + if (result.data.hasMore) { + hasMoreResults = true; + } + + for (const repo of result.data.repositories ?? []) { + if (!(repo in map)) { + map[repo] = result.data.ref; + } + } + } + + return { + repositoryMap: map, + isPending: pending, + isRefreshing: refreshing, + hasMore: hasMoreResults, + }; +} + +export function normalizeRepoKey(repoKey: string | null | undefined): string { + return repoKey?.toLowerCase() ?? ""; +} + +export function getRepoEntry( + repositoryMap: Record, + repoKey: string, +): TRef | undefined { + return repositoryMap[normalizeRepoKey(repoKey)]; +} + +export function getIntegrationIdForRepo( + repositoryMap: Record, + repoKey: string, +): number | undefined { + return repositoryMap[normalizeRepoKey(repoKey)]; +} + +export function isRepoInIntegration( + repositoryMap: Record, + repoKey: string, +): boolean { + return !repoKey || normalizeRepoKey(repoKey) in repositoryMap; +} diff --git a/packages/core/src/integrations/repositoriesService.test.ts b/packages/core/src/integrations/repositoriesService.test.ts new file mode 100644 index 0000000000..32d9f3b04c --- /dev/null +++ b/packages/core/src/integrations/repositoriesService.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RepositoriesClient } from "./identifiers"; +import { RepositoriesService } from "./repositoriesService"; + +function makeClient(): RepositoriesClient { + return { + refreshTeamRepository: vi.fn().mockResolvedValue([]), + refreshUserRepository: vi.fn().mockResolvedValue([]), + }; +} + +describe("RepositoriesService", () => { + it("fans out a team refresh across every integration id", async () => { + const client = makeClient(); + const service = new RepositoriesService(client); + + await service.refreshTeamRepositories([1, 2, 3]); + + expect(client.refreshTeamRepository).toHaveBeenCalledTimes(3); + expect(client.refreshTeamRepository).toHaveBeenCalledWith(1); + expect(client.refreshTeamRepository).toHaveBeenCalledWith(2); + expect(client.refreshTeamRepository).toHaveBeenCalledWith(3); + }); + + it("fans out a user refresh across every installation id", async () => { + const client = makeClient(); + const service = new RepositoriesService(client); + + await service.refreshUserRepositories(["a", "b"]); + + expect(client.refreshUserRepository).toHaveBeenCalledTimes(2); + expect(client.refreshUserRepository).toHaveBeenCalledWith("a"); + expect(client.refreshUserRepository).toHaveBeenCalledWith("b"); + }); + + it("skips the team client call when there are no integrations", async () => { + const client = makeClient(); + const service = new RepositoriesService(client); + + await service.refreshTeamRepositories([]); + + expect(client.refreshTeamRepository).not.toHaveBeenCalled(); + }); + + it("skips the user client call when there are no installations", async () => { + const client = makeClient(); + const service = new RepositoriesService(client); + + await service.refreshUserRepositories([]); + + expect(client.refreshUserRepository).not.toHaveBeenCalled(); + }); + + it("propagates a refresh failure from any integration", async () => { + const client = makeClient(); + (client.refreshTeamRepository as ReturnType) + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(new Error("boom")); + const service = new RepositoriesService(client); + + await expect(service.refreshTeamRepositories([1, 2])).rejects.toThrow( + "boom", + ); + }); + + it("refreshes team repos then returns the per-integration refetch keys", async () => { + const client = makeClient(); + const service = new RepositoriesService(client); + + const keys = await service.refreshTeamRepositoriesAndKeys([1, 2]); + + expect(client.refreshTeamRepository).toHaveBeenCalledTimes(2); + expect(keys).toEqual([ + { queryKey: ["integrations", "repositories", 1], exact: true }, + { queryKey: ["integrations", "repositories", 2], exact: true }, + { queryKey: ["integrations", "repository-picker"], exact: false }, + ]); + }); + + it("refreshes user repos then returns the per-installation refetch keys", async () => { + const client = makeClient(); + const service = new RepositoriesService(client); + + const keys = await service.refreshUserRepositoriesAndKeys(["a", "b"]); + + expect(client.refreshUserRepository).toHaveBeenCalledTimes(2); + expect(keys).toEqual([ + { + queryKey: ["user-github-integrations", "repositories", "a"], + exact: true, + }, + { + queryKey: ["user-github-integrations", "repositories", "b"], + exact: true, + }, + { + queryKey: ["user-github-integrations", "repository-picker"], + exact: false, + }, + ]); + }); +}); diff --git a/packages/core/src/integrations/repositoriesService.ts b/packages/core/src/integrations/repositoriesService.ts new file mode 100644 index 0000000000..b2cb2ef596 --- /dev/null +++ b/packages/core/src/integrations/repositoriesService.ts @@ -0,0 +1,51 @@ +import { inject, injectable } from "inversify"; +import { REPOSITORIES_CLIENT, type RepositoriesClient } from "./identifiers"; +import { + type RepositoryRefetchKey, + teamRepositoryRefreshKeys, + userRepositoryRefreshKeys, +} from "./repositoryKeys"; + +@injectable() +export class RepositoriesService { + constructor( + @inject(REPOSITORIES_CLIENT) + private readonly client: RepositoriesClient, + ) {} + + async refreshTeamRepositories(integrationIds: number[]): Promise { + if (integrationIds.length === 0) { + return; + } + await Promise.all( + integrationIds.map((integrationId) => + this.client.refreshTeamRepository(integrationId), + ), + ); + } + + async refreshUserRepositories(installationIds: string[]): Promise { + if (installationIds.length === 0) { + return; + } + await Promise.all( + installationIds.map((installationId) => + this.client.refreshUserRepository(installationId), + ), + ); + } + + async refreshTeamRepositoriesAndKeys( + integrationIds: number[], + ): Promise { + await this.refreshTeamRepositories(integrationIds); + return teamRepositoryRefreshKeys(integrationIds); + } + + async refreshUserRepositoriesAndKeys( + installationIds: string[], + ): Promise { + await this.refreshUserRepositories(installationIds); + return userRepositoryRefreshKeys(installationIds); + } +} diff --git a/packages/core/src/integrations/repositoryKeys.test.ts b/packages/core/src/integrations/repositoryKeys.test.ts new file mode 100644 index 0000000000..3c8b16e1c0 --- /dev/null +++ b/packages/core/src/integrations/repositoryKeys.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + integrationKeys, + teamRepositoryRefreshKeys, + userGithubIntegrationKeys, + userRepositoryRefreshKeys, +} from "./repositoryKeys"; + +describe("repositoryKeys", () => { + it("namespaces team repository keys", () => { + expect(integrationKeys.repositories(7)).toEqual([ + "integrations", + "repositories", + 7, + ]); + }); + + it("namespaces user repository keys", () => { + expect(userGithubIntegrationKeys.repositories("inst")).toEqual([ + "user-github-integrations", + "repositories", + "inst", + ]); + }); + + it("derives team refetch keys with an exact key per integration plus the picker", () => { + expect(teamRepositoryRefreshKeys([1, 2])).toEqual([ + { queryKey: ["integrations", "repositories", 1], exact: true }, + { queryKey: ["integrations", "repositories", 2], exact: true }, + { queryKey: ["integrations", "repository-picker"], exact: false }, + ]); + }); + + it("derives only the picker key when there are no integrations", () => { + expect(teamRepositoryRefreshKeys([])).toEqual([ + { queryKey: ["integrations", "repository-picker"], exact: false }, + ]); + }); + + it("derives user refetch keys with an exact key per installation plus the picker", () => { + expect(userRepositoryRefreshKeys(["a"])).toEqual([ + { + queryKey: ["user-github-integrations", "repositories", "a"], + exact: true, + }, + { + queryKey: ["user-github-integrations", "repository-picker"], + exact: false, + }, + ]); + }); +}); diff --git a/packages/core/src/integrations/repositoryKeys.ts b/packages/core/src/integrations/repositoryKeys.ts new file mode 100644 index 0000000000..9a4574f673 --- /dev/null +++ b/packages/core/src/integrations/repositoryKeys.ts @@ -0,0 +1,78 @@ +export const integrationKeys = { + all: ["integrations"] as const, + list: () => [...integrationKeys.all, "list"] as const, + repositories: (integrationId?: number) => + [...integrationKeys.all, "repositories", integrationId] as const, + repositoryPicker: (integrationId?: number, search?: string, limit?: number) => + [ + ...integrationKeys.all, + "repository-picker", + integrationId, + search, + limit, + ] as const, + branches: (integrationId?: number, repo?: string | null, search?: string) => + [...integrationKeys.all, "branches", integrationId, repo, search] as const, +}; + +export const userGithubIntegrationKeys = { + all: ["user-github-integrations"] as const, + list: () => [...userGithubIntegrationKeys.all, "list"] as const, + repositories: (installationId?: string) => + [...userGithubIntegrationKeys.all, "repositories", installationId] as const, + repositoryPicker: ( + installationId?: string, + search?: string, + limit?: number, + ) => + [ + ...userGithubIntegrationKeys.all, + "repository-picker", + installationId, + search, + limit, + ] as const, + branches: (installationId?: string, repo?: string | null, search?: string) => + [ + ...userGithubIntegrationKeys.all, + "branches", + installationId, + repo, + search, + ] as const, +}; + +export interface RepositoryRefetchKey { + queryKey: ReadonlyArray; + exact: boolean; +} + +export function teamRepositoryRefreshKeys( + integrationIds: ReadonlyArray, +): RepositoryRefetchKey[] { + const keys: RepositoryRefetchKey[] = integrationIds.map((integrationId) => ({ + queryKey: integrationKeys.repositories(integrationId), + exact: true, + })); + keys.push({ + queryKey: [...integrationKeys.all, "repository-picker"], + exact: false, + }); + return keys; +} + +export function userRepositoryRefreshKeys( + installationIds: ReadonlyArray, +): RepositoryRefetchKey[] { + const keys: RepositoryRefetchKey[] = installationIds.map( + (installationId) => ({ + queryKey: userGithubIntegrationKeys.repositories(installationId), + exact: true, + }), + ); + keys.push({ + queryKey: [...userGithubIntegrationKeys.all, "repository-picker"], + exact: false, + }); + return keys; +} diff --git a/apps/code/src/main/services/integration-flow-schemas.ts b/packages/core/src/integrations/schemas.ts similarity index 100% rename from apps/code/src/main/services/integration-flow-schemas.ts rename to packages/core/src/integrations/schemas.ts diff --git a/packages/core/src/integrations/selectors.test.ts b/packages/core/src/integrations/selectors.test.ts new file mode 100644 index 0000000000..ddc4878be1 --- /dev/null +++ b/packages/core/src/integrations/selectors.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { classifyIntegrations, type Integration } from "./selectors"; + +const integration = (id: number, kind: string): Integration => ({ id, kind }); + +describe("classifyIntegrations", () => { + it("splits integrations by provider kind and derives presence flags", () => { + const result = classifyIntegrations([ + integration(1, "github"), + integration(2, "slack"), + integration(3, "github"), + integration(4, "other"), + ]); + + expect(result.githubIntegrations.map((i) => i.id)).toEqual([1, 3]); + expect(result.slackIntegrations.map((i) => i.id)).toEqual([2]); + expect(result.hasGithubIntegration).toBe(true); + expect(result.hasSlackIntegration).toBe(true); + }); + + it("reports no integrations for an empty list", () => { + const result = classifyIntegrations([]); + expect(result.hasGithubIntegration).toBe(false); + expect(result.hasSlackIntegration).toBe(false); + }); +}); diff --git a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts b/packages/core/src/integrations/selectors.ts similarity index 61% rename from apps/code/src/renderer/features/integrations/stores/integrationStore.ts rename to packages/core/src/integrations/selectors.ts index 022f1eea8a..7e89beb3d9 100644 --- a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts +++ b/packages/core/src/integrations/selectors.ts @@ -1,5 +1,3 @@ -import { create } from "zustand"; - export interface IntegrationAccount { name?: string; type?: string; @@ -18,25 +16,16 @@ export interface Integration { [key: string]: unknown; } -interface IntegrationStore { - integrations: Integration[]; - setIntegrations: (integrations: Integration[]) => void; -} - -interface IntegrationSelectors { +export interface ClassifiedIntegrations { githubIntegrations: Integration[]; hasGithubIntegration: boolean; slackIntegrations: Integration[]; hasSlackIntegration: boolean; } -export const useIntegrationStore = create((set) => ({ - integrations: [], - setIntegrations: (integrations) => set({ integrations }), -})); - -export const useIntegrationSelectors = (): IntegrationSelectors => { - const integrations = useIntegrationStore((state) => state.integrations); +export function classifyIntegrations( + integrations: ReadonlyArray, +): ClassifiedIntegrations { const githubIntegrations = integrations.filter((i) => i.kind === "github"); const slackIntegrations = integrations.filter((i) => i.kind === "slack"); @@ -46,4 +35,4 @@ export const useIntegrationSelectors = (): IntegrationSelectors => { slackIntegrations, hasSlackIntegration: slackIntegrations.length > 0, }; -}; +} diff --git a/packages/core/src/integrations/slack.test.ts b/packages/core/src/integrations/slack.test.ts new file mode 100644 index 0000000000..23f98f7f22 --- /dev/null +++ b/packages/core/src/integrations/slack.test.ts @@ -0,0 +1,201 @@ +import type { IDeepLinkRegistry } from "@posthog/platform/deep-link"; +import type { IMainWindow } from "@posthog/platform/main-window"; +import { describe, expect, it, vi } from "vitest"; +import { SlackIntegrationEvent, SlackIntegrationService } from "./slack"; + +function makeLogger() { + const scoped = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + return { ...scoped, scope: vi.fn(() => scoped) }; +} + +function createMockDeepLinkService() { + const handlers = new Map< + string, + (path: string, params: URLSearchParams) => boolean + >(); + return { + registerHandler: vi.fn((key, handler) => handlers.set(key, handler)), + _invoke(key: string, params: URLSearchParams) { + const handler = handlers.get(key); + if (!handler) throw new Error(`No handler for key: ${key}`); + return handler("", params); + }, + }; +} + +function createMockMainWindow(): IMainWindow { + return { + isMinimized: vi.fn(() => false), + restore: vi.fn(), + focus: vi.fn(), + close: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + minimize: vi.fn(), + maximize: vi.fn(), + unmaximize: vi.fn(), + isMaximized: vi.fn(() => false), + isVisible: vi.fn(() => true), + setTitle: vi.fn(), + loadURL: vi.fn(), + webContents: {} as never, + on: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + } as unknown as IMainWindow; +} + +function createDeps() { + const deepLink = createMockDeepLinkService(); + const urlLauncher = { launch: vi.fn().mockResolvedValue(undefined) }; + const mainWindow = createMockMainWindow(); + const service = new SlackIntegrationService( + deepLink as unknown as IDeepLinkRegistry, + urlLauncher as never, + mainWindow, + makeLogger(), + ); + return { service, deepLink, urlLauncher, mainWindow }; +} + +describe("SlackIntegrationService.startFlow", () => { + it("launches a slack authorize URL and returns success", async () => { + const { service, urlLauncher } = createDeps(); + + const result = await service.startFlow("us", 42); + + expect(result).toEqual({ success: true }); + const launched = urlLauncher.launch.mock.calls[0][0]; + expect(launched).toContain("/api/environments/42/integrations/authorize/"); + expect(launched).toContain("kind=slack"); + }); + + it("returns a failure result when launching the browser throws", async () => { + const { service, urlLauncher } = createDeps(); + urlLauncher.launch.mockRejectedValue(new Error("no browser")); + + expect(await service.startFlow("us", 42)).toEqual({ + success: false, + error: "no browser", + }); + }); + + it("emits FlowTimedOut after the timeout elapses", async () => { + vi.useFakeTimers(); + try { + const { service } = createDeps(); + const timedOut = vi.fn(); + service.on(SlackIntegrationEvent.FlowTimedOut, timedOut); + + await service.startFlow("us", 7); + vi.advanceTimersByTime(5 * 60 * 1000); + + expect(timedOut).toHaveBeenCalledWith({ projectId: 7 }); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("SlackIntegrationService callback handling", () => { + it("registers the slack-integration deep-link handler", () => { + const { deepLink } = createDeps(); + expect(deepLink.registerHandler).toHaveBeenCalledWith( + "slack-integration", + expect.any(Function), + ); + }); + + it("parses project and integration ids on success", () => { + const { service, deepLink } = createDeps(); + const listener = vi.fn(); + service.on(SlackIntegrationEvent.Callback, listener); + + const result = deepLink._invoke( + "slack-integration", + new URLSearchParams("project_id=42&integration_id=99&status=success"), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith({ + projectId: 42, + integrationId: 99, + status: "success", + errorCode: null, + errorMessage: null, + }); + }); + + it("treats a non-numeric integration_id as null", () => { + const { service, deepLink } = createDeps(); + const listener = vi.fn(); + service.on(SlackIntegrationEvent.Callback, listener); + + deepLink._invoke( + "slack-integration", + new URLSearchParams("project_id=1&integration_id=oops"), + ); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ integrationId: null }), + ); + }); + + it("captures error status with code and message", () => { + const { service, deepLink } = createDeps(); + const listener = vi.fn(); + service.on(SlackIntegrationEvent.Callback, listener); + + deepLink._invoke( + "slack-integration", + new URLSearchParams("status=error&error_code=denied&error_message=nope"), + ); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + status: "error", + errorCode: "denied", + errorMessage: "nope", + }), + ); + }); + + it("queues the callback when no listener exists and consumes it once", () => { + const { service, deepLink } = createDeps(); + + deepLink._invoke( + "slack-integration", + new URLSearchParams("project_id=5&status=success"), + ); + + expect(service.consumePendingCallback()).toEqual( + expect.objectContaining({ projectId: 5, status: "success" }), + ); + expect(service.consumePendingCallback()).toBeNull(); + }); + + it("cancels the flow timeout so a late callback does not fire FlowTimedOut", async () => { + vi.useFakeTimers(); + try { + const { service, deepLink } = createDeps(); + const timedOut = vi.fn(); + service.on(SlackIntegrationEvent.FlowTimedOut, timedOut); + + await service.startFlow("us", 7); + deepLink._invoke( + "slack-integration", + new URLSearchParams("project_id=7&status=success"), + ); + vi.advanceTimersByTime(5 * 60 * 1000); + + expect(timedOut).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/code/src/main/services/slack-integration/service.ts b/packages/core/src/integrations/slack.ts similarity index 68% rename from apps/code/src/main/services/slack-integration/service.ts rename to packages/core/src/integrations/slack.ts index 126677a8e7..52ad52c55f 100644 --- a/apps/code/src/main/services/slack-integration/service.ts +++ b/packages/core/src/integrations/slack.ts @@ -1,14 +1,27 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; -import type { IUrlLauncher } from "@posthog/platform/url-launcher"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { + DEEP_LINK_SERVICE, + type IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { + type IUrlLauncher, + URL_LAUNCHER_SERVICE, +} from "@posthog/platform/url-launcher"; +import { + type CloudRegion, + getCloudUrlFromRegion, + TypedEventEmitter, +} from "@posthog/shared"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import type { DeepLinkService } from "../deep-link/service"; -import type { CloudRegion, StartSlackFlowOutput } from "./schemas"; - -const log = logger.scope("slack-integration-service"); +import type { StartIntegrationFlowOutput } from "./schemas"; const FLOW_TIMEOUT_MS = 5 * 60 * 1000; @@ -34,32 +47,26 @@ export interface SlackIntegrationEvents { [SlackIntegrationEvent.FlowTimedOut]: SlackFlowTimedOut; } -/** - * Drives the in-app "Connect Slack" flow: - * 1. The renderer asks for `startFlow(region, projectId)`, which opens the user's - * default browser at PostHog Cloud's Slack OAuth authorize endpoint. - * 2. PostHog Cloud completes Slack OAuth, creates the team-level Slack `Integration` - * row, and redirects to `/account-connected/slack-integration?integration_id=…`, - * which sends a `posthog-code://slack-integration?…` deep link. - * 3. The deep-link handler emits a `Callback` event; renderers refresh integrations. - * - * Mirrors `GitHubIntegrationService` so each provider's deep-link handler is independent. - */ @injectable() export class SlackIntegrationService extends TypedEventEmitter { private pendingCallback: SlackIntegrationCallback | null = null; private flowTimeout: ReturnType | null = null; + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.UrlLauncher) + @inject(DEEP_LINK_SERVICE) + private readonly deepLinkService: IDeepLinkRegistry, + @inject(URL_LAUNCHER_SERVICE) private readonly urlLauncher: IUrlLauncher, - @inject(MAIN_TOKENS.MainWindow) + @inject(MAIN_WINDOW_SERVICE) private readonly mainWindow: IMainWindow, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, ) { super(); + this.log = workbenchLogger.scope("slack-integration-service"); + this.deepLinkService.registerHandler("slack-integration", (_path, params) => this.handleCallback(params), ); @@ -68,17 +75,15 @@ export class SlackIntegrationService extends TypedEventEmitter { + ): Promise { try { const cloudUrl = getCloudUrlFromRegion(region); - // Lands on PostHog Cloud's AccountConnected page, which forwards to - // `posthog-code://slack-integration?…` with `integration_id` set. const nextPath = `/account-connected/slack-integration?provider=slack&project_id=${projectId}&connect_from=posthog_code`; const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=slack&next=${encodeURIComponent(nextPath)}`; this.clearFlowTimeout(); this.flowTimeout = setTimeout(() => { - log.warn("Slack integration flow timed out", { projectId }); + this.log.warn("Slack integration flow timed out", { projectId }); this.flowTimeout = null; this.emit(SlackIntegrationEvent.FlowTimedOut, { projectId }); }, FLOW_TIMEOUT_MS); @@ -88,7 +93,7 @@ export class SlackIntegrationService extends TypedEventEmitter ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -import type { DeepLinkHandler, DeepLinkService } from "../deep-link/service"; -import { InboxLinkEvent, InboxLinkService } from "./service"; +import { InboxLinkEvent, InboxLinkService } from "./inbox-link"; + +function makeLogger() { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + scope: vi.fn(() => logger), + }; + return logger; +} function makeDeepLinkService() { const handlers = new Map(); @@ -27,7 +29,7 @@ function makeDeepLinkService() { return handler(path, new URLSearchParams()); }, }; - return service as unknown as DeepLinkService & { + return service as unknown as IDeepLinkRegistry & { trigger: (key: string, path: string) => boolean; }; } @@ -52,7 +54,7 @@ describe("InboxLinkService", () => { beforeEach(() => { deepLinkService = makeDeepLinkService(); mainWindow = makeMainWindow(); - service = new InboxLinkService(deepLinkService, mainWindow); + service = new InboxLinkService(deepLinkService, mainWindow, makeLogger()); }); it("registers an 'inbox' handler on the DeepLinkService", () => { diff --git a/apps/code/src/main/services/inbox-link/service.ts b/packages/core/src/links/inbox-link.ts similarity index 59% rename from apps/code/src/main/services/inbox-link/service.ts rename to packages/core/src/links/inbox-link.ts index 8d78e8b409..0809cb9e33 100644 --- a/apps/code/src/main/services/inbox-link/service.ts +++ b/packages/core/src/links/inbox-link.ts @@ -1,11 +1,15 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { + DEEP_LINK_SERVICE, + type IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { TypedEventEmitter } from "@posthog/shared"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import type { DeepLinkService } from "../deep-link/service"; - -const log = logger.scope("inbox-link-service"); +import type { LinkLogger } from "./identifiers"; export const InboxLinkEvent = { OpenReport: "openReport", @@ -22,14 +26,18 @@ export interface PendingInboxDeepLink { @injectable() export class InboxLinkService extends TypedEventEmitter { private pendingDeepLink: PendingInboxDeepLink | null = null; + private readonly log: LinkLogger; constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.MainWindow) + @inject(DEEP_LINK_SERVICE) + private readonly deepLinkService: IDeepLinkRegistry, + @inject(MAIN_WINDOW_SERVICE) private readonly mainWindow: IMainWindow, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, ) { super(); + this.log = workbenchLogger.scope("inbox-link-service"); this.deepLinkService.registerHandler("inbox", (path) => this.handleInboxLink(path), @@ -37,27 +45,26 @@ export class InboxLinkService extends TypedEventEmitter { } private handleInboxLink(path: string): boolean { - // path format: "abc123" from posthog-code://inbox/abc123 const reportId = path.split("/")[0]; if (!reportId) { - log.warn("Inbox link missing report ID"); + this.log.warn("Inbox link missing report ID"); return false; } const hasListeners = this.listenerCount(InboxLinkEvent.OpenReport) > 0; if (hasListeners) { - log.info(`Emitting inbox link event: reportId=${reportId}`); + this.log.info(`Emitting inbox link event: reportId=${reportId}`); this.emit(InboxLinkEvent.OpenReport, { reportId }); } else { - log.info( + this.log.info( `Queueing inbox link (renderer not ready): reportId=${reportId}`, ); this.pendingDeepLink = { reportId }; } - log.info("Deep link focusing window", { reportId }); + this.log.info("Deep link focusing window", { reportId }); if (this.mainWindow.isMinimized()) { this.mainWindow.restore(); } @@ -70,7 +77,9 @@ export class InboxLinkService extends TypedEventEmitter { const pending = this.pendingDeepLink; this.pendingDeepLink = null; if (pending) { - log.info(`Consumed pending inbox link: reportId=${pending.reportId}`); + this.log.info( + `Consumed pending inbox link: reportId=${pending.reportId}`, + ); } return pending; } diff --git a/apps/code/src/main/services/new-task-link/service.test.ts b/packages/core/src/links/new-task-link.test.ts similarity index 96% rename from apps/code/src/main/services/new-task-link/service.test.ts rename to packages/core/src/links/new-task-link.test.ts index bfb6c84b0a..d0f4458b62 100644 --- a/apps/code/src/main/services/new-task-link/service.test.ts +++ b/packages/core/src/links/new-task-link.test.ts @@ -1,19 +1,18 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - +import type { IDeepLinkRegistry } from "@posthog/platform/deep-link"; import type { IMainWindow } from "@posthog/platform/main-window"; -import type { DeepLinkService } from "../deep-link/service"; -import { NewTaskLinkEvent, NewTaskLinkService } from "./service"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NewTaskLinkEvent, NewTaskLinkService } from "./new-task-link"; + +function makeLogger() { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + scope: vi.fn(() => logger), + }; + return logger; +} function createMockDeepLinkService() { const handlers = new Map< @@ -63,8 +62,9 @@ describe("NewTaskLinkService", () => { mockDeepLink = createMockDeepLinkService(); mockWindow = createMockMainWindow(); service = new NewTaskLinkService( - mockDeepLink as unknown as DeepLinkService, + mockDeepLink as unknown as IDeepLinkRegistry, mockWindow, + makeLogger(), ); }); diff --git a/apps/code/src/main/services/new-task-link/service.ts b/packages/core/src/links/new-task-link.ts similarity index 59% rename from apps/code/src/main/services/new-task-link/service.ts rename to packages/core/src/links/new-task-link.ts index fbbe19c428..9c8a7e1932 100644 --- a/apps/code/src/main/services/new-task-link/service.ts +++ b/packages/core/src/links/new-task-link.ts @@ -1,27 +1,21 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; -import type { NewTaskLinkPayload, NewTaskSharedParams } from "@shared/types"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { + DEEP_LINK_SERVICE, + type IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { + decodePlanBase64, + type NewTaskLinkPayload, + type NewTaskSharedParams, + parseGitHubIssueUrl, + TypedEventEmitter, +} from "@posthog/shared"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import type { DeepLinkService } from "../deep-link/service"; - -const log = logger.scope("new-task-link-service"); - -function decodePlanBase64(encoded: string): string | null { - try { - const normalized = encoded - .replace(/-/g, "+") - .replace(/_/g, "/") - .replace(/ /g, "+"); - const padding = (4 - (normalized.length % 4)) % 4; - const padded = normalized + "=".repeat(padding); - if (!/^[A-Za-z0-9+/]*=*$/.test(padded)) return null; - return Buffer.from(padded, "base64").toString("utf-8"); - } catch { - return null; - } -} +import type { LinkLogger } from "./identifiers"; export const NewTaskLinkEvent = { Action: "action", @@ -36,14 +30,18 @@ export interface NewTaskLinkEvents { @injectable() export class NewTaskLinkService extends TypedEventEmitter { private pendingLink: NewTaskLinkPayload | null = null; + private readonly log: LinkLogger; constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.MainWindow) + @inject(DEEP_LINK_SERVICE) + private readonly deepLinkService: IDeepLinkRegistry, + @inject(MAIN_WINDOW_SERVICE) private readonly mainWindow: IMainWindow, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, ) { super(); + this.log = workbenchLogger.scope("new-task-link-service"); this.deepLinkService.registerHandler("new", (_path, params) => this.handleNew(params), @@ -69,7 +67,7 @@ export class NewTaskLinkService extends TypedEventEmitter { const prompt = params.get("prompt") ?? undefined; if (!prompt && !shared.repo) { - log.warn("New task link requires at least prompt or repo"); + this.log.warn("New task link requires at least prompt or repo"); return false; } @@ -79,7 +77,7 @@ export class NewTaskLinkService extends TypedEventEmitter { ...shared, }; - log.info("Handling new task link", { + this.log.info("Handling new task link", { hasPrompt: !!prompt, repo: shared.repo, }); @@ -90,13 +88,13 @@ export class NewTaskLinkService extends TypedEventEmitter { const planEncoded = params.get("plan"); if (!planEncoded) { - log.warn("Plan link missing plan parameter"); + this.log.warn("Plan link missing plan parameter"); return false; } const plan = decodePlanBase64(planEncoded); if (plan === null) { - log.error("Plan link has invalid base64 encoding"); + this.log.error("Plan link has invalid base64 encoding"); return false; } @@ -107,7 +105,7 @@ export class NewTaskLinkService extends TypedEventEmitter { ...shared, }; - log.info("Handling plan link", { + this.log.info("Handling plan link", { planLength: plan.length, repo: shared.repo, }); @@ -118,13 +116,13 @@ export class NewTaskLinkService extends TypedEventEmitter { const url = params.get("url"); if (!url) { - log.warn("Issue link missing url parameter"); + this.log.warn("Issue link missing url parameter"); return false; } - const parsed = this.parseGitHubIssueUrl(url); + const parsed = parseGitHubIssueUrl(url); if (!parsed) { - log.warn("Issue link has invalid GitHub issue URL", { url }); + this.log.warn("Issue link has invalid GitHub issue URL", { url }); return false; } @@ -138,7 +136,7 @@ export class NewTaskLinkService extends TypedEventEmitter { ...shared, }; - log.info("Handling issue link", { + this.log.info("Handling issue link", { owner: parsed.owner, repo: parsed.repo, number: parsed.number, @@ -146,33 +144,14 @@ export class NewTaskLinkService extends TypedEventEmitter { return this.emitOrQueue(payload); } - private parseGitHubIssueUrl( - url: string, - ): { owner: string; repo: string; number: number } | null { - try { - const parsed = new URL(url); - if (parsed.hostname !== "github.com") return null; - - const parts = parsed.pathname.split("/").filter(Boolean); - if (parts.length !== 4 || parts[2] !== "issues") return null; - - const issueNumber = Number.parseInt(parts[3], 10); - if (Number.isNaN(issueNumber) || issueNumber <= 0) return null; - - return { owner: parts[0], repo: parts[1], number: issueNumber }; - } catch { - return null; - } - } - private emitOrQueue(payload: NewTaskLinkPayload): boolean { const hasListeners = this.listenerCount(NewTaskLinkEvent.Action) > 0; if (hasListeners) { - log.info(`Emitting new task link event: action=${payload.action}`); + this.log.info(`Emitting new task link event: action=${payload.action}`); this.emit(NewTaskLinkEvent.Action, payload); } else { - log.info( + this.log.info( `Queueing new task link (renderer not ready): action=${payload.action}`, ); this.pendingLink = payload; @@ -190,7 +169,7 @@ export class NewTaskLinkService extends TypedEventEmitter { const pending = this.pendingLink; this.pendingLink = null; if (pending) { - log.info(`Consumed pending new task link: action=${pending.action}`); + this.log.info(`Consumed pending new task link: action=${pending.action}`); } return pending; } diff --git a/packages/core/src/links/task-link.test.ts b/packages/core/src/links/task-link.test.ts new file mode 100644 index 0000000000..24e7639588 --- /dev/null +++ b/packages/core/src/links/task-link.test.ts @@ -0,0 +1,169 @@ +import type { IDeepLinkRegistry } from "@posthog/platform/deep-link"; +import type { IMainWindow } from "@posthog/platform/main-window"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TaskLinkEvent, TaskLinkService } from "./task-link"; + +function makeLogger() { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + scope: vi.fn(() => logger), + }; + return logger; +} + +function createMockDeepLinkService() { + const handlers = new Map< + string, + (path: string, params: URLSearchParams) => boolean + >(); + return { + registerHandler: vi.fn((key, handler) => handlers.set(key, handler)), + _invoke(key: string, path: string) { + const handler = handlers.get(key); + if (!handler) throw new Error(`No handler for key: ${key}`); + return handler(path, new URLSearchParams()); + }, + }; +} + +function createMockMainWindow(): IMainWindow { + return { + isMinimized: vi.fn(() => false), + restore: vi.fn(), + focus: vi.fn(), + close: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + minimize: vi.fn(), + maximize: vi.fn(), + unmaximize: vi.fn(), + isMaximized: vi.fn(() => false), + isVisible: vi.fn(() => true), + setTitle: vi.fn(), + loadURL: vi.fn(), + webContents: {} as never, + on: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + } as unknown as IMainWindow; +} + +describe("TaskLinkService", () => { + let service: TaskLinkService; + let mockDeepLink: ReturnType; + let mockWindow: IMainWindow; + + beforeEach(() => { + vi.clearAllMocks(); + mockDeepLink = createMockDeepLinkService(); + mockWindow = createMockMainWindow(); + service = new TaskLinkService( + mockDeepLink as unknown as IDeepLinkRegistry, + mockWindow, + makeLogger(), + ); + }); + + describe("constructor", () => { + it("registers a handler for the task key", () => { + expect(mockDeepLink.registerHandler).toHaveBeenCalledWith( + "task", + expect.any(Function), + ); + }); + }); + + describe("handleTaskLink", () => { + it("rejects an empty path with no task ID", () => { + expect(mockDeepLink._invoke("task", "")).toBe(false); + }); + + it("emits OpenTask with just a task ID when a listener exists", () => { + const listener = vi.fn(); + service.on(TaskLinkEvent.OpenTask, listener); + + const result = mockDeepLink._invoke("task", "task-123"); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith({ + taskId: "task-123", + taskRunId: undefined, + }); + }); + + it("parses a task run ID from the .../run/ path", () => { + const listener = vi.fn(); + service.on(TaskLinkEvent.OpenTask, listener); + + mockDeepLink._invoke("task", "task-123/run/run-456"); + + expect(listener).toHaveBeenCalledWith({ + taskId: "task-123", + taskRunId: "run-456", + }); + }); + + it("ignores a second path segment that is not 'run'", () => { + const listener = vi.fn(); + service.on(TaskLinkEvent.OpenTask, listener); + + mockDeepLink._invoke("task", "task-123/foo/bar"); + + expect(listener).toHaveBeenCalledWith({ + taskId: "task-123", + taskRunId: undefined, + }); + }); + + it("focuses the window and restores it when minimized", () => { + vi.mocked(mockWindow.isMinimized).mockReturnValue(true); + + mockDeepLink._invoke("task", "task-123"); + + expect(mockWindow.restore).toHaveBeenCalled(); + expect(mockWindow.focus).toHaveBeenCalled(); + }); + + it("does not restore the window when it is not minimized", () => { + vi.mocked(mockWindow.isMinimized).mockReturnValue(false); + + mockDeepLink._invoke("task", "task-123"); + + expect(mockWindow.restore).not.toHaveBeenCalled(); + expect(mockWindow.focus).toHaveBeenCalled(); + }); + }); + + describe("pending deep link queueing", () => { + it("queues the link when no listeners exist", () => { + mockDeepLink._invoke("task", "task-123/run/run-456"); + + expect(service.consumePendingDeepLink()).toEqual({ + taskId: "task-123", + taskRunId: "run-456", + }); + }); + + it("clears the pending link after consuming it", () => { + mockDeepLink._invoke("task", "task-123"); + + expect(service.consumePendingDeepLink()).not.toBeNull(); + expect(service.consumePendingDeepLink()).toBeNull(); + }); + + it("does not queue when a listener is present", () => { + service.on(TaskLinkEvent.OpenTask, vi.fn()); + + mockDeepLink._invoke("task", "task-123"); + + expect(service.consumePendingDeepLink()).toBeNull(); + }); + + it("returns null when nothing is pending", () => { + expect(service.consumePendingDeepLink()).toBeNull(); + }); + }); +}); diff --git a/apps/code/src/main/services/task-link/service.ts b/packages/core/src/links/task-link.ts similarity index 59% rename from apps/code/src/main/services/task-link/service.ts rename to packages/core/src/links/task-link.ts index 463cf71c0e..af23d33073 100644 --- a/apps/code/src/main/services/task-link/service.ts +++ b/packages/core/src/links/task-link.ts @@ -1,11 +1,15 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { + DEEP_LINK_SERVICE, + type IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { TypedEventEmitter } from "@posthog/shared"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import type { DeepLinkService } from "../deep-link/service"; - -const log = logger.scope("task-link-service"); +import type { LinkLogger } from "./identifiers"; export const TaskLinkEvent = { OpenTask: "openTask", @@ -22,19 +26,19 @@ export interface PendingDeepLink { @injectable() export class TaskLinkService extends TypedEventEmitter { - /** - * Pending deep link that was received before renderer was ready. - * This handles the case where the app is launched via deep link. - */ private pendingDeepLink: PendingDeepLink | null = null; + private readonly log: LinkLogger; constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.MainWindow) + @inject(DEEP_LINK_SERVICE) + private readonly deepLinkService: IDeepLinkRegistry, + @inject(MAIN_WINDOW_SERVICE) private readonly mainWindow: IMainWindow, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, ) { super(); + this.log = workbenchLogger.scope("task-link-service"); this.deepLinkService.registerHandler("task", (path) => this.handleTaskLink(path), @@ -42,36 +46,30 @@ export class TaskLinkService extends TypedEventEmitter { } private handleTaskLink(path: string): boolean { - // path formats: - // "abc123" from posthog-code://task/abc123 - // "abc123/run/xyz789" from posthog-code://task/abc123/run/xyz789 const parts = path.split("/"); const taskId = parts[0]; const taskRunId = parts[1] === "run" ? parts[2] : undefined; if (!taskId) { - log.warn("Task link missing task ID"); + this.log.warn("Task link missing task ID"); return false; } - // Check if renderer is ready (has any listeners) const hasListeners = this.listenerCount(TaskLinkEvent.OpenTask) > 0; if (hasListeners) { - log.info( + this.log.info( `Emitting task link event: taskId=${taskId}, taskRunId=${taskRunId ?? "none"}`, ); this.emit(TaskLinkEvent.OpenTask, { taskId, taskRunId }); } else { - // Renderer not ready yet - queue it for later - log.info( + this.log.info( `Queueing task link (renderer not ready): taskId=${taskId}, taskRunId=${taskRunId ?? "none"}`, ); this.pendingDeepLink = { taskId, taskRunId }; } - // Focus the window - log.info("Deep link focusing window", { taskId, taskRunId }); + this.log.info("Deep link focusing window", { taskId, taskRunId }); if (this.mainWindow.isMinimized()) { this.mainWindow.restore(); } @@ -80,15 +78,11 @@ export class TaskLinkService extends TypedEventEmitter { return true; } - /** - * Get and clear any pending deep link. - * Called by renderer on mount to handle deep links that arrived before it was ready. - */ public consumePendingDeepLink(): PendingDeepLink | null { const pending = this.pendingDeepLink; this.pendingDeepLink = null; if (pending) { - log.info( + this.log.info( `Consumed pending task link: taskId=${pending.taskId}, taskRunId=${pending.taskRunId ?? "none"}`, ); } diff --git a/packages/core/src/llm-gateway/identifiers.ts b/packages/core/src/llm-gateway/identifiers.ts new file mode 100644 index 0000000000..3ef42646a5 --- /dev/null +++ b/packages/core/src/llm-gateway/identifiers.ts @@ -0,0 +1,23 @@ +export const LLM_GATEWAY_SERVICE = Symbol.for("posthog.core.llmGatewayService"); +export const LLM_GATEWAY_HOST = Symbol.for("posthog.core.llmGatewayHost"); + +export interface LlmGatewayAuth { + getValidAccessToken(): Promise<{ accessToken: string; apiHost: string }>; + authenticatedFetch(url: string, init?: RequestInit): Promise; +} + +export interface LlmGatewayEndpoints { + messagesUrl(apiHost: string): string; + usageUrl(apiHost: string): string; + invalidatePlanCacheUrl(apiHost: string): string; + defaultModel: string; +} + +export interface LlmGatewayHost extends LlmGatewayAuth, LlmGatewayEndpoints {} + +export interface LlmGatewayLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} diff --git a/packages/core/src/llm-gateway/llm-gateway.module.ts b/packages/core/src/llm-gateway/llm-gateway.module.ts new file mode 100644 index 0000000000..cb4fb83045 --- /dev/null +++ b/packages/core/src/llm-gateway/llm-gateway.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { LLM_GATEWAY_SERVICE } from "./identifiers"; +import { LlmGatewayService } from "./llm-gateway"; + +export const llmGatewayModule = new ContainerModule(({ bind }) => { + bind(LLM_GATEWAY_SERVICE).to(LlmGatewayService).inSingletonScope(); +}); diff --git a/packages/core/src/llm-gateway/llm-gateway.test.ts b/packages/core/src/llm-gateway/llm-gateway.test.ts new file mode 100644 index 0000000000..7840442f8c --- /dev/null +++ b/packages/core/src/llm-gateway/llm-gateway.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + LlmGatewayAuth, + LlmGatewayEndpoints, + LlmGatewayHost, + LlmGatewayLogger, +} from "./identifiers"; +import { LlmGatewayError, LlmGatewayService } from "./llm-gateway"; + +const API_HOST = "https://app.example.com"; + +function createJsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function createService( + authenticatedFetch: LlmGatewayAuth["authenticatedFetch"], +) { + const auth: LlmGatewayAuth = { + getValidAccessToken: vi + .fn() + .mockResolvedValue({ accessToken: "tok", apiHost: API_HOST }), + authenticatedFetch, + }; + + const endpoints: LlmGatewayEndpoints = { + messagesUrl: (host) => `${host}/gateway/v1/messages`, + usageUrl: (host) => `${host}/gateway/usage`, + invalidatePlanCacheUrl: (host) => `${host}/gateway/invalidate`, + defaultModel: "claude-default", + }; + + const host: LlmGatewayHost = { ...auth, ...endpoints }; + + const log: LlmGatewayLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const logger = { ...log, scope: () => log }; + + const service = new LlmGatewayService(host, logger); + return { service, auth, endpoints, log }; +} + +const SUCCESS_BODY = { + id: "msg_1", + type: "message" as const, + role: "assistant" as const, + content: [{ type: "text" as const, text: "hello world" }], + model: "claude-resolved", + stop_reason: "end_turn", + usage: { input_tokens: 12, output_tokens: 7 }, +}; + +describe("LlmGatewayService.prompt", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it("returns parsed content, model, and usage on success", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(createJsonResponse(SUCCESS_BODY)); + const { service } = createService(fetchMock); + + const result = await service.prompt([{ role: "user", content: "hi" }]); + + expect(result).toEqual({ + content: "hello world", + model: "claude-resolved", + stopReason: "end_turn", + usage: { inputTokens: 12, outputTokens: 7 }, + }); + }); + + it("posts to the resolved messages URL with the default model and request body", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(createJsonResponse(SUCCESS_BODY)); + const { service } = createService(fetchMock); + + await service.prompt([{ role: "user", content: "hi" }], { + system: "be terse", + maxTokens: 256, + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${API_HOST}/gateway/v1/messages`); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body); + expect(body.model).toBe("claude-default"); + expect(body.system).toBe("be terse"); + expect(body.max_tokens).toBe(256); + expect(body.stream).toBe(false); + }); + + it("throws a typed LlmGatewayError with parsed error fields on non-ok response", async () => { + const fetchMock = vi.fn().mockResolvedValue( + createJsonResponse( + { + error: { + message: "rate limited", + type: "rate_limit", + code: "slow_down", + }, + }, + 429, + ), + ); + const { service } = createService(fetchMock); + + await expect( + service.prompt([{ role: "user", content: "hi" }]), + ).rejects.toMatchObject({ + name: "LlmGatewayError", + message: "rate limited", + type: "rate_limit", + code: "slow_down", + statusCode: 429, + }); + }); + + it("throws a timeout LlmGatewayError when the request aborts via the internal timeout", async () => { + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")); + }); + }); + }); + const { service } = createService(fetchMock as never); + + const promise = service.prompt([{ role: "user", content: "hi" }], { + timeoutMs: 5, + }); + + await expect(promise).rejects.toBeInstanceOf(LlmGatewayError); + await expect(promise).rejects.toMatchObject({ type: "timeout" }); + }); +}); + +describe("LlmGatewayService.fetchUsage", () => { + const USAGE_BODY = { + product: "code", + user_id: 1, + sustained: { + used_percent: 10, + reset_at: "2026-01-01T00:00:00.000Z", + exceeded: false, + }, + burst: { + used_percent: 20, + reset_at: "2026-01-01T00:00:00.000Z", + exceeded: false, + }, + is_rate_limited: false, + is_pro: true, + }; + + it("returns the schema-parsed usage payload", async () => { + const fetchMock = vi.fn().mockResolvedValue(createJsonResponse(USAGE_BODY)); + const { service } = createService(fetchMock); + + const usage = await service.fetchUsage(); + + expect(usage.product).toBe("code"); + expect(usage.is_pro).toBe(true); + expect(usage.sustained.used_percent).toBe(10); + }); + + it("throws a usage_error LlmGatewayError on non-ok response", async () => { + const fetchMock = vi.fn().mockResolvedValue(createJsonResponse({}, 503)); + const { service } = createService(fetchMock); + + await expect(service.fetchUsage()).rejects.toMatchObject({ + type: "usage_error", + statusCode: 503, + }); + }); +}); + +describe("LlmGatewayService.invalidatePlanCache", () => { + it("POSTs to the invalidate URL and resolves on success", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + const { service } = createService(fetchMock); + + await expect(service.invalidatePlanCache()).resolves.toBeUndefined(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${API_HOST}/gateway/invalidate`); + expect(init.method).toBe("POST"); + }); + + it("throws a plan_cache_error LlmGatewayError on non-ok response", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 500 })); + const { service } = createService(fetchMock); + + await expect(service.invalidatePlanCache()).rejects.toMatchObject({ + type: "plan_cache_error", + statusCode: 500, + }); + }); +}); diff --git a/apps/code/src/main/services/llm-gateway/service.ts b/packages/core/src/llm-gateway/llm-gateway.ts similarity index 72% rename from apps/code/src/main/services/llm-gateway/service.ts rename to packages/core/src/llm-gateway/llm-gateway.ts index 11813e474f..d2eae2f7e3 100644 --- a/apps/code/src/main/services/llm-gateway/service.ts +++ b/packages/core/src/llm-gateway/llm-gateway.ts @@ -1,13 +1,12 @@ -import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; -import { - getGatewayInvalidatePlanCacheUrl, - getGatewayUsageUrl, - getLlmGatewayUrl, -} from "@posthog/agent/posthog-api"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import type { AuthService } from "../auth/service"; +import { + LLM_GATEWAY_HOST, + type LlmGatewayAuth, + type LlmGatewayEndpoints, + type LlmGatewayHost, + type LlmGatewayLogger, +} from "./identifiers"; import { type AnthropicErrorResponse, type AnthropicMessagesRequest, @@ -18,8 +17,6 @@ import { usageOutput, } from "./schemas"; -const log = logger.scope("llm-gateway"); - export class LlmGatewayError extends Error { constructor( message: string, @@ -35,9 +32,19 @@ export class LlmGatewayError extends Error { @injectable() export class LlmGatewayService { constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, - ) {} + @inject(LLM_GATEWAY_HOST) + host: LlmGatewayHost, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + this.auth = host; + this.endpoints = host; + this.log = logger.scope("llm-gateway"); + } + + private readonly auth: LlmGatewayAuth; + private readonly endpoints: LlmGatewayEndpoints; + private readonly log: LlmGatewayLogger; async prompt( messages: LlmMessage[], @@ -52,14 +59,13 @@ export class LlmGatewayService { const { system, maxTokens, - model = DEFAULT_GATEWAY_MODEL, + model = this.endpoints.defaultModel, signal, timeoutMs = 60_000, } = options; - const auth = await this.authService.getValidAccessToken(); - const gatewayUrl = getLlmGatewayUrl(auth.apiHost); - const messagesUrl = `${gatewayUrl}/v1/messages`; + const auth = await this.auth.getValidAccessToken(); + const messagesUrl = this.endpoints.messagesUrl(auth.apiHost); const requestBody: AnthropicMessagesRequest = { model, @@ -75,7 +81,7 @@ export class LlmGatewayService { requestBody.system = system; } - log.debug("Sending request to LLM gateway", { + this.log.debug("Sending request to LLM gateway", { url: messagesUrl, model, messageCount: messages.length, @@ -93,7 +99,7 @@ export class LlmGatewayService { let response: Response; try { - response = await this.authService.authenticatedFetch(fetch, messagesUrl, { + response = await this.auth.authenticatedFetch(messagesUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -121,7 +127,7 @@ export class LlmGatewayService { try { errorData = JSON.parse(errorBody) as AnthropicErrorResponse; } catch { - log.error("Failed to parse error response", { + this.log.error("Failed to parse error response", { errorBody, status: response.status, }); @@ -133,7 +139,7 @@ export class LlmGatewayService { const errorType = errorData?.error?.type || "unknown_error"; const errorCode = errorData?.error?.code; - log.error("LLM gateway request failed", { + this.log.error("LLM gateway request failed", { status: response.status, errorType, errorMessage, @@ -152,7 +158,7 @@ export class LlmGatewayService { const textContent = data.content.find((c) => c.type === "text"); const content = textContent?.text || ""; - log.debug("LLM gateway response received", { + this.log.debug("LLM gateway response received", { model: data.model, stopReason: data.stop_reason, inputTokens: data.usage.input_tokens, @@ -171,23 +177,23 @@ export class LlmGatewayService { } async fetchUsage(): Promise { - const auth = await this.authService.getValidAccessToken(); - const usageUrl = getGatewayUsageUrl(auth.apiHost); + const auth = await this.auth.getValidAccessToken(); + const usageUrl = this.endpoints.usageUrl(auth.apiHost); - log.debug("Fetching usage from gateway", { url: usageUrl }); + this.log.debug("Fetching usage from gateway", { url: usageUrl }); let response: Response; try { - response = await this.authService.authenticatedFetch(fetch, usageUrl); + response = await this.auth.authenticatedFetch(usageUrl); } catch (err) { - log.warn("Usage fetch network error", { + this.log.warn("Usage fetch network error", { error: err instanceof Error ? err.message : String(err), }); throw err; } if (!response.ok) { - log.warn("Usage fetch failed", { status: response.status }); + this.log.warn("Usage fetch failed", { status: response.status }); throw new LlmGatewayError( `Failed to fetch usage: HTTP ${response.status}`, "usage_error", @@ -200,12 +206,12 @@ export class LlmGatewayService { } async invalidatePlanCache(): Promise { - const auth = await this.authService.getValidAccessToken(); - const url = getGatewayInvalidatePlanCacheUrl(auth.apiHost); + const auth = await this.auth.getValidAccessToken(); + const url = this.endpoints.invalidatePlanCacheUrl(auth.apiHost); - log.debug("Invalidating plan cache", { url }); + this.log.debug("Invalidating plan cache", { url }); - const response = await this.authService.authenticatedFetch(fetch, url, { + const response = await this.auth.authenticatedFetch(url, { method: "POST", }); diff --git a/apps/code/src/main/services/llm-gateway/schemas.ts b/packages/core/src/llm-gateway/schemas.ts similarity index 65% rename from apps/code/src/main/services/llm-gateway/schemas.ts rename to packages/core/src/llm-gateway/schemas.ts index 7c569c8953..9b985139b9 100644 --- a/apps/code/src/main/services/llm-gateway/schemas.ts +++ b/packages/core/src/llm-gateway/schemas.ts @@ -1,4 +1,3 @@ -import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; import { z } from "zod"; export const llmMessageSchema = z.object({ @@ -12,7 +11,7 @@ export const promptInput = z.object({ system: z.string().optional(), messages: z.array(llmMessageSchema), maxTokens: z.number().optional(), - model: z.string().default(DEFAULT_GATEWAY_MODEL), + model: z.string().optional(), }); export type PromptInput = z.infer; @@ -58,21 +57,8 @@ export interface AnthropicErrorResponse { }; } -export const usageBucketSchema = z.object({ - used_percent: z.number(), - reset_at: z.string().datetime(), - exceeded: z.boolean(), -}); - -export const usageOutput = z.object({ - product: z.string(), - user_id: z.number(), - sustained: usageBucketSchema, - burst: usageBucketSchema, - is_rate_limited: z.boolean(), - is_pro: z.boolean(), - billing_period_end: z.string().datetime().nullable().optional(), -}); - -export type UsageBucket = z.infer; -export type UsageOutput = z.infer; +export type { UsageBucket, UsageOutput } from "../usage/schemas"; +export { + usageBucketSchema, + usageOutput, +} from "../usage/schemas"; diff --git a/packages/core/src/mcp-apps/identifiers.ts b/packages/core/src/mcp-apps/identifiers.ts new file mode 100644 index 0000000000..8f1d05a9cb --- /dev/null +++ b/packages/core/src/mcp-apps/identifiers.ts @@ -0,0 +1 @@ +export const MCP_APPS_SERVICE = Symbol.for("posthog.core.mcpAppsService"); diff --git a/packages/core/src/mcp-apps/mcp-apps.module.ts b/packages/core/src/mcp-apps/mcp-apps.module.ts new file mode 100644 index 0000000000..d6dd9fb2f3 --- /dev/null +++ b/packages/core/src/mcp-apps/mcp-apps.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { MCP_APPS_SERVICE } from "./identifiers"; +import { McpAppsService } from "./mcp-apps"; + +export const mcpAppsModule = new ContainerModule(({ bind }) => { + bind(MCP_APPS_SERVICE).to(McpAppsService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/mcp-apps/service.ts b/packages/core/src/mcp-apps/mcp-apps.ts similarity index 88% rename from apps/code/src/main/services/mcp-apps/service.ts rename to packages/core/src/mcp-apps/mcp-apps.ts index 46c89bb266..09f9a4e43c 100644 --- a/apps/code/src/main/services/mcp-apps/service.ts +++ b/packages/core/src/mcp-apps/mcp-apps.ts @@ -1,7 +1,17 @@ import { Client } from "@modelcontextprotocol/sdk/client"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; -import type { IUrlLauncher } from "@posthog/platform/url-launcher"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { + type IUrlLauncher, + URL_LAUNCHER_SERVICE, +} from "@posthog/platform/url-launcher"; +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable } from "inversify"; import { type McpAppsDiscoveryCompleteEvent, McpAppsServiceEvent, @@ -14,13 +24,7 @@ import { type McpToolUiAssociation, type McpToolUiMeta, type McpUiResource, -} from "@shared/types/mcp-apps"; -import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; - -const log = logger.scope("mcp-apps-service"); +} from "./schemas"; const UI_MIME_TYPE = "text/html;profile=mcp-app"; const MAX_HTML_SIZE = 5 * 1024 * 1024; // 5MB @@ -41,12 +45,17 @@ export class McpAppsService extends TypedEventEmitter { private pendingConnections = new Map>(); private pendingFetches = new Map>(); private resourceMetaCache = new Map(); + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.UrlLauncher) + @inject(URL_LAUNCHER_SERVICE) private readonly urlLauncher: IUrlLauncher, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, ) { super(); + + this.log = workbenchLogger.scope("mcp-apps-service"); } /** @@ -74,7 +83,7 @@ export class McpAppsService extends TypedEventEmitter { ); const toolKeys = [...this.toolAssociations.keys()]; - log.info("Discovery complete", { + this.log.info("Discovery complete", { serverNames, toolKeys, associationCount: this.toolAssociations.size, @@ -97,7 +106,7 @@ export class McpAppsService extends TypedEventEmitter { const [toolsList, resourcesList] = await Promise.all([ conn.client.listTools(), conn.client.listResources().catch((err) => { - log.warn("listResources failed during discovery", { + this.log.warn("listResources failed during discovery", { serverName, error: err instanceof Error ? err.message : String(err), }); @@ -130,7 +139,7 @@ export class McpAppsService extends TypedEventEmitter { } } } catch (err) { - log.warn("Failed to discover UI tools for server", { + this.log.warn("Failed to discover UI tools for server", { serverName, error: err instanceof Error ? err.message : String(err), }); @@ -146,14 +155,14 @@ export class McpAppsService extends TypedEventEmitter { ): Promise { const existing = this.connections.get(serverName); if (existing) { - log.debug("Reusing existing MCP connection", { serverName }); + this.log.debug("Reusing existing MCP connection", { serverName }); return existing; } // Deduplicate concurrent connection attempts const pending = this.pendingConnections.get(serverName); if (pending) { - log.info("Joining pending MCP connection attempt", { serverName }); + this.log.info("Joining pending MCP connection attempt", { serverName }); return pending; } @@ -198,7 +207,7 @@ export class McpAppsService extends TypedEventEmitter { await client.connect(transport); - log.info("Lazy MCP connection established", { + this.log.info("Lazy MCP connection established", { serverName: config.name, serverVersion: client.getServerVersion(), }); @@ -214,21 +223,21 @@ export class McpAppsService extends TypedEventEmitter { async getUiResourceForTool(toolKey: string): Promise { const association = this.toolAssociations.get(toolKey); if (!association) { - log.debug("getUiResourceForTool: no association found", { toolKey }); + this.log.debug("getUiResourceForTool: no association found", { toolKey }); return null; } // Return cached resource immediately const cached = this.resourceCache.get(association.resourceUri); if (cached) { - log.debug("getUiResourceForTool: cache hit", { toolKey }); + this.log.debug("getUiResourceForTool: cache hit", { toolKey }); return cached; } // Deduplicate concurrent fetches for the same resource URI const pendingFetch = this.pendingFetches.get(association.resourceUri); if (pendingFetch) { - log.debug("getUiResourceForTool: joining pending fetch", { + this.log.debug("getUiResourceForTool: joining pending fetch", { toolKey, uri: association.resourceUri, }); @@ -236,7 +245,7 @@ export class McpAppsService extends TypedEventEmitter { } // Start the fetch for this resource URI - log.debug("getUiResourceForTool: starting lazy fetch", { + this.log.debug("getUiResourceForTool: starting lazy fetch", { toolKey, serverName: association.serverName, uri: association.resourceUri, @@ -264,7 +273,7 @@ export class McpAppsService extends TypedEventEmitter { (c) => "text" in c && c.mimeType === UI_MIME_TYPE, ); if (!textContent || !("text" in textContent)) { - log.warn("UI resource had no matching text content", { + this.log.warn("UI resource had no matching text content", { serverName: association.serverName, uri: association.resourceUri, contentsCount: resourceResult.contents.length, @@ -273,7 +282,7 @@ export class McpAppsService extends TypedEventEmitter { } if (textContent.text.length > MAX_HTML_SIZE) { - log.warn("UI resource HTML exceeds size limit", { + this.log.warn("UI resource HTML exceeds size limit", { uri: association.resourceUri, size: textContent.text.length, limit: MAX_HTML_SIZE, @@ -295,7 +304,7 @@ export class McpAppsService extends TypedEventEmitter { }; this.resourceCache.set(association.resourceUri, resource); - log.info("Lazily fetched and cached UI resource", { + this.log.info("Lazily fetched and cached UI resource", { serverName: association.serverName, uri: association.resourceUri, htmlLength: textContent.text.length, @@ -304,7 +313,7 @@ export class McpAppsService extends TypedEventEmitter { return resource; } catch (err) { - log.warn("Failed to lazily fetch UI resource", { + this.log.warn("Failed to lazily fetch UI resource", { serverName: association.serverName, uri: association.resourceUri, error: err instanceof Error ? err.message : String(err), @@ -315,7 +324,7 @@ export class McpAppsService extends TypedEventEmitter { hasUiForTool(toolKey: string): boolean { const has = this.toolAssociations.has(toolKey); - log.debug("hasUiForTool", { toolKey, result: has }); + this.log.debug("hasUiForTool", { toolKey, result: has }); return has; } @@ -368,7 +377,7 @@ export class McpAppsService extends TypedEventEmitter { } notifyToolInput(toolKey: string, toolCallId: string, args: unknown): void { - log.info("notifyToolInput", { toolKey, toolCallId }); + this.log.info("notifyToolInput", { toolKey, toolCallId }); this.emit(McpAppsServiceEvent.ToolInput, { toolKey, toolCallId, @@ -382,7 +391,7 @@ export class McpAppsService extends TypedEventEmitter { result: unknown, isError?: boolean, ): void { - log.info("notifyToolResult", { toolKey, toolCallId, isError }); + this.log.info("notifyToolResult", { toolKey, toolCallId, isError }); this.emit(McpAppsServiceEvent.ToolResult, { toolKey, toolCallId, @@ -392,7 +401,7 @@ export class McpAppsService extends TypedEventEmitter { } notifyToolCancelled(toolKey: string, toolCallId: string): void { - log.info("notifyToolCancelled", { toolKey, toolCallId }); + this.log.info("notifyToolCancelled", { toolKey, toolCallId }); this.emit(McpAppsServiceEvent.ToolCancelled, { toolKey, toolCallId, @@ -405,7 +414,7 @@ export class McpAppsService extends TypedEventEmitter { * Intended for developer debugging via the File > Developer menu. */ async refreshDiscovery(): Promise { - log.info("refreshDiscovery: clearing caches and re-running discovery"); + this.log.info("refreshDiscovery: clearing caches and re-running discovery"); // Close existing connections for (const [, conn] of this.connections) { @@ -424,7 +433,7 @@ export class McpAppsService extends TypedEventEmitter { if (serverNames.length > 0) { await this.handleDiscovery(serverNames); } else { - log.warn( + this.log.warn( "refreshDiscovery: no server configs stored, nothing to discover", ); } @@ -437,7 +446,7 @@ export class McpAppsService extends TypedEventEmitter { try { await conn.client.close(); } catch (err) { - log.warn("Error closing MCP connection", { + this.log.warn("Error closing MCP connection", { serverName, error: err instanceof Error ? err.message : String(err), }); diff --git a/apps/code/src/shared/types/mcp-apps.ts b/packages/core/src/mcp-apps/schemas.ts similarity index 100% rename from apps/code/src/shared/types/mcp-apps.ts rename to packages/core/src/mcp-apps/schemas.ts diff --git a/packages/core/src/mcp-servers/customServerForm.test.ts b/packages/core/src/mcp-servers/customServerForm.test.ts new file mode 100644 index 0000000000..9b48da11a5 --- /dev/null +++ b/packages/core/src/mcp-servers/customServerForm.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + buildCustomServerRequest, + type CustomServerFormValues, + canSubmitCustomServer, + isValidMcpUrl, +} from "./customServerForm"; + +function values( + overrides: Partial = {}, +): CustomServerFormValues { + return { + name: "My server", + url: "https://mcp.example.com/stream", + description: "A server", + authType: "oauth", + apiKey: "", + clientId: "", + clientSecret: "", + ...overrides, + }; +} + +describe("isValidMcpUrl", () => { + it("accepts https and http urls", () => { + expect(isValidMcpUrl("https://x.com/y")).toBe(true); + expect(isValidMcpUrl("http://x.com/y")).toBe(true); + }); + + it("trims before validating", () => { + expect(isValidMcpUrl(" https://x.com/y ")).toBe(true); + }); + + it("rejects non-http schemes and bare hosts", () => { + expect(isValidMcpUrl("ftp://x.com")).toBe(false); + expect(isValidMcpUrl("x.com")).toBe(false); + expect(isValidMcpUrl("")).toBe(false); + }); +}); + +describe("canSubmitCustomServer", () => { + it("requires a non-empty name and a valid url", () => { + expect(canSubmitCustomServer({ name: "X", url: "https://x.com" })).toBe( + true, + ); + expect(canSubmitCustomServer({ name: " ", url: "https://x.com" })).toBe( + false, + ); + expect(canSubmitCustomServer({ name: "X", url: "nope" })).toBe(false); + }); +}); + +describe("buildCustomServerRequest", () => { + it("trims the base fields", () => { + const req = buildCustomServerRequest( + values({ name: " N ", url: " https://x.com ", description: " d " }), + ); + expect(req.name).toBe("N"); + expect(req.url).toBe("https://x.com"); + expect(req.description).toBe("d"); + }); + + it("includes api_key only for api_key auth when present", () => { + expect( + buildCustomServerRequest(values({ authType: "api_key", apiKey: "k" })) + .api_key, + ).toBe("k"); + expect( + buildCustomServerRequest(values({ authType: "oauth", apiKey: "k" })) + .api_key, + ).toBeUndefined(); + expect( + buildCustomServerRequest(values({ authType: "api_key", apiKey: "" })) + .api_key, + ).toBeUndefined(); + }); + + it("includes client_id/client_secret only for oauth when non-empty", () => { + const req = buildCustomServerRequest( + values({ authType: "oauth", clientId: " cid ", clientSecret: " sec " }), + ); + expect(req.client_id).toBe("cid"); + expect(req.client_secret).toBe("sec"); + + const apiKeyReq = buildCustomServerRequest( + values({ authType: "api_key", clientId: "cid", clientSecret: "sec" }), + ); + expect(apiKeyReq.client_id).toBeUndefined(); + expect(apiKeyReq.client_secret).toBeUndefined(); + }); +}); diff --git a/packages/core/src/mcp-servers/customServerForm.ts b/packages/core/src/mcp-servers/customServerForm.ts new file mode 100644 index 0000000000..6d9452939d --- /dev/null +++ b/packages/core/src/mcp-servers/customServerForm.ts @@ -0,0 +1,51 @@ +import type { McpAuthType } from "@posthog/api-client/types"; + +export interface CustomServerFormValues { + name: string; + url: string; + description: string; + authType: McpAuthType; + apiKey: string; + clientId: string; + clientSecret: string; +} + +export interface CustomServerRequest { + name: string; + url: string; + description: string; + auth_type: McpAuthType; + api_key?: string; + client_id?: string; + client_secret?: string; +} + +export function isValidMcpUrl(url: string): boolean { + return /^https?:\/\/.+/i.test(url.trim()); +} + +export function canSubmitCustomServer( + values: Pick, +): boolean { + return values.name.trim() !== "" && isValidMcpUrl(values.url); +} + +export function buildCustomServerRequest( + values: CustomServerFormValues, +): CustomServerRequest { + return { + name: values.name.trim(), + url: values.url.trim(), + description: values.description.trim(), + auth_type: values.authType, + ...(values.authType === "api_key" && values.apiKey + ? { api_key: values.apiKey } + : {}), + ...(values.authType === "oauth" && values.clientId.trim() + ? { client_id: values.clientId.trim() } + : {}), + ...(values.authType === "oauth" && values.clientSecret.trim() + ? { client_secret: values.clientSecret.trim() } + : {}), + }; +} diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.test.ts b/packages/core/src/mcp-servers/filters.test.ts similarity index 96% rename from apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.test.ts rename to packages/core/src/mcp-servers/filters.test.ts index b6f322f7ae..594e224463 100644 --- a/apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.test.ts +++ b/packages/core/src/mcp-servers/filters.test.ts @@ -1,6 +1,6 @@ -import type { McpRecommendedServer } from "@renderer/api/posthogClient"; +import type { McpRecommendedServer } from "@posthog/api-client/types"; import { describe, expect, it } from "vitest"; -import { filterServersByCategory, filterServersByQuery } from "./mcpFilters"; +import { filterServersByCategory, filterServersByQuery } from "./filters"; function server( overrides: Partial, diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.ts b/packages/core/src/mcp-servers/filters.ts similarity index 96% rename from apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.ts rename to packages/core/src/mcp-servers/filters.ts index a8cc52cfdf..cab7a152ba 100644 --- a/apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.ts +++ b/packages/core/src/mcp-servers/filters.ts @@ -2,7 +2,7 @@ import type { McpCategory, McpRecommendedServer, McpServerInstallation, -} from "@renderer/api/posthogClient"; +} from "@posthog/api-client/types"; export function filterServersByCategory( servers: McpRecommendedServer[], diff --git a/packages/core/src/mcp-servers/installFlow.test.ts b/packages/core/src/mcp-servers/installFlow.test.ts new file mode 100644 index 0000000000..0450b3349f --- /dev/null +++ b/packages/core/src/mcp-servers/installFlow.test.ts @@ -0,0 +1,122 @@ +import type { McpServerInstallation } from "@posthog/api-client/types"; +import { describe, expect, it, vi } from "vitest"; +import { + type InstallFlowClient, + type IOAuthCallback, + installCustomWithOAuth, + installTemplateWithOAuth, + reauthorizeWithOAuth, +} from "./installFlow"; + +function makeOAuth( + openResult: { success?: boolean; error?: string } = { success: true }, +): IOAuthCallback { + return { + getCallbackUrl: vi.fn().mockResolvedValue({ callbackUrl: "cb://here" }), + openAndWaitForCallback: vi.fn().mockResolvedValue(openResult), + }; +} + +const installedInstallation = { + id: "inst-1", +} as McpServerInstallation; + +describe("installTemplateWithOAuth", () => { + it("builds the request with install_source + callback url and returns success when no redirect", async () => { + const oauth = makeOAuth(); + const client: InstallFlowClient = { + installMcpTemplate: vi.fn().mockResolvedValue(installedInstallation), + installCustomMcpServer: vi.fn(), + authorizeMcpInstallation: vi.fn(), + }; + + const result = await installTemplateWithOAuth(client, oauth, { + template_id: "tpl-1", + api_key: "k", + }); + + expect(client.installMcpTemplate).toHaveBeenCalledWith({ + template_id: "tpl-1", + api_key: "k", + install_source: "posthog-code", + posthog_code_callback_url: "cb://here", + }); + expect(oauth.openAndWaitForCallback).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it("opens and waits when the response carries a redirect_url", async () => { + const oauth = makeOAuth({ success: true }); + const client: InstallFlowClient = { + installMcpTemplate: vi + .fn() + .mockResolvedValue({ redirect_url: "https://auth" }), + installCustomMcpServer: vi.fn(), + authorizeMcpInstallation: vi.fn(), + }; + + const result = await installTemplateWithOAuth(client, oauth, { + template_id: "tpl-1", + }); + + expect(oauth.openAndWaitForCallback).toHaveBeenCalledWith({ + redirectUrl: "https://auth", + }); + expect(result).toEqual({ success: true }); + }); +}); + +describe("installCustomWithOAuth", () => { + it("forwards the custom payload and branches on redirect_url", async () => { + const oauth = makeOAuth({ error: "denied" }); + const client: InstallFlowClient = { + installMcpTemplate: vi.fn(), + installCustomMcpServer: vi + .fn() + .mockResolvedValue({ redirect_url: "https://auth" }), + authorizeMcpInstallation: vi.fn(), + }; + + const result = await installCustomWithOAuth(client, oauth, { + name: "N", + url: "https://x", + description: "d", + auth_type: "oauth", + }); + + expect(client.installCustomMcpServer).toHaveBeenCalledWith({ + name: "N", + url: "https://x", + description: "d", + auth_type: "oauth", + install_source: "posthog-code", + posthog_code_callback_url: "cb://here", + }); + expect(result).toEqual({ error: "denied" }); + }); +}); + +describe("reauthorizeWithOAuth", () => { + it("authorizes then opens the redirect", async () => { + const oauth = makeOAuth(); + const client: InstallFlowClient = { + installMcpTemplate: vi.fn(), + installCustomMcpServer: vi.fn(), + authorizeMcpInstallation: vi + .fn() + .mockResolvedValue({ redirect_url: "https://reauth" }), + }; + + const result = await reauthorizeWithOAuth(client, oauth, "inst-1"); + + expect(client.authorizeMcpInstallation).toHaveBeenCalledWith({ + installation_id: "inst-1", + install_source: "posthog-code", + posthog_code_callback_url: "cb://here", + }); + expect(oauth.openAndWaitForCallback).toHaveBeenCalledWith({ + redirectUrl: "https://reauth", + }); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/packages/core/src/mcp-servers/installFlow.ts b/packages/core/src/mcp-servers/installFlow.ts new file mode 100644 index 0000000000..5dbd11d5c8 --- /dev/null +++ b/packages/core/src/mcp-servers/installFlow.ts @@ -0,0 +1,109 @@ +import type { + McpAuthType, + McpServerInstallation, +} from "@posthog/api-client/types"; + +interface OAuthRedirect { + redirect_url: string; +} + +type InstallResult = McpServerInstallation | OAuthRedirect; + +export interface InstallFlowClient { + installMcpTemplate(options: { + template_id: string; + api_key?: string; + install_source?: "posthog" | "posthog-code"; + posthog_code_callback_url?: string; + }): Promise; + installCustomMcpServer(options: { + name: string; + url: string; + description?: string; + auth_type: McpAuthType; + api_key?: string; + client_id?: string; + client_secret?: string; + install_source?: "posthog" | "posthog-code"; + posthog_code_callback_url?: string; + }): Promise; + authorizeMcpInstallation(options: { + installation_id: string; + install_source?: "posthog" | "posthog-code"; + posthog_code_callback_url?: string; + }): Promise; +} + +export interface IOAuthCallback { + getCallbackUrl(): Promise<{ callbackUrl: string }>; + openAndWaitForCallback(args: { + redirectUrl: string; + }): Promise; +} + +export interface OAuthCallbackResult { + success?: boolean; + error?: string; +} + +const INSTALL_SOURCE = "posthog-code" as const; + +function hasRedirect(data: InstallResult): data is OAuthRedirect { + return "redirect_url" in data && !!data.redirect_url; +} + +export async function installTemplateWithOAuth( + client: InstallFlowClient, + oauth: IOAuthCallback, + vars: { template_id: string; api_key?: string }, +): Promise { + const { callbackUrl } = await oauth.getCallbackUrl(); + const data = await client.installMcpTemplate({ + ...vars, + install_source: INSTALL_SOURCE, + posthog_code_callback_url: callbackUrl, + }); + if (hasRedirect(data)) { + return oauth.openAndWaitForCallback({ redirectUrl: data.redirect_url }); + } + return { success: true }; +} + +export async function installCustomWithOAuth( + client: InstallFlowClient, + oauth: IOAuthCallback, + vars: { + name: string; + url: string; + description: string; + auth_type: McpAuthType; + api_key?: string; + client_id?: string; + client_secret?: string; + }, +): Promise { + const { callbackUrl } = await oauth.getCallbackUrl(); + const data = await client.installCustomMcpServer({ + ...vars, + install_source: INSTALL_SOURCE, + posthog_code_callback_url: callbackUrl, + }); + if (hasRedirect(data)) { + return oauth.openAndWaitForCallback({ redirectUrl: data.redirect_url }); + } + return { success: true }; +} + +export async function reauthorizeWithOAuth( + client: InstallFlowClient, + oauth: IOAuthCallback, + installationId: string, +): Promise { + const { callbackUrl } = await oauth.getCallbackUrl(); + const data = await client.authorizeMcpInstallation({ + installation_id: installationId, + install_source: INSTALL_SOURCE, + posthog_code_callback_url: callbackUrl, + }); + return oauth.openAndWaitForCallback({ redirectUrl: data.redirect_url }); +} diff --git a/packages/core/src/mcp-servers/resolveServerName.test.ts b/packages/core/src/mcp-servers/resolveServerName.test.ts new file mode 100644 index 0000000000..2960eecc37 --- /dev/null +++ b/packages/core/src/mcp-servers/resolveServerName.test.ts @@ -0,0 +1,89 @@ +import type { + McpRecommendedServer, + McpServerInstallation, +} from "@posthog/api-client/types"; +import { describe, expect, it } from "vitest"; +import { + resolveServerDetails, + resolveServerName, + sortInstallationsByName, +} from "./resolveServerName"; + +function installation( + overrides: Partial = {}, +): McpServerInstallation { + return { + id: "inst-1", + template_id: null, + name: "", + icon_key: "", + proxy_url: "https://proxy.example.com/inst-1", + tool_count: 0, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + needs_reauth: false, + pending_oauth: false, + ...overrides, + } as McpServerInstallation; +} + +function template( + overrides: Partial, +): McpRecommendedServer { + return { + id: "tpl-1", + name: "Template", + url: "https://example.com/mcp", + description: "", + auth_type: "oauth", + ...overrides, + } as McpRecommendedServer; +} + +describe("resolveServerName", () => { + it("prefers display_name, then name, then template name, then url", () => { + expect( + resolveServerName(installation({ display_name: "D", name: "N" }), null), + ).toBe("D"); + expect(resolveServerName(installation({ name: "N" }), null)).toBe("N"); + expect(resolveServerName(installation({}), template({ name: "T" }))).toBe( + "T", + ); + expect(resolveServerName(installation({ url: "https://u" }), null)).toBe( + "https://u", + ); + expect(resolveServerName(installation({}), null)).toBe("Server"); + }); +}); + +describe("resolveServerDetails", () => { + it("resolves description/docs/icon/auth fallbacks", () => { + const out = resolveServerDetails( + installation({ name: "N", icon_key: "" }), + template({ + description: "desc", + docs_url: "https://docs", + icon_key: "k", + }), + ); + expect(out.name).toBe("N"); + expect(out.description).toBe("desc"); + expect(out.docsUrl).toBe("https://docs"); + expect(out.iconKey).toBe("k"); + expect(out.authType).toBe("oauth"); + }); +}); + +describe("sortInstallationsByName", () => { + it("sorts case-insensitively by resolved name", () => { + const map = new Map(); + const out = sortInstallationsByName( + [ + installation({ id: "1", display_name: "banana" }), + installation({ id: "2", display_name: "Apple" }), + ], + map, + ); + expect(out.map((i) => i.id)).toEqual(["2", "1"]); + }); +}); diff --git a/packages/core/src/mcp-servers/resolveServerName.ts b/packages/core/src/mcp-servers/resolveServerName.ts new file mode 100644 index 0000000000..f63e540b1e --- /dev/null +++ b/packages/core/src/mcp-servers/resolveServerName.ts @@ -0,0 +1,59 @@ +import type { + McpRecommendedServer, + McpServerInstallation, +} from "@posthog/api-client/types"; + +export function resolveServerName( + installation: McpServerInstallation, + template: McpRecommendedServer | null, +): string { + return ( + installation.display_name || + installation.name || + template?.name || + installation.url || + "Server" + ); +} + +export interface ResolvedServerDetails { + name: string; + description: string; + docsUrl: string | null; + iconKey: string | null; + authType: McpRecommendedServer["auth_type"] | undefined; +} + +export function resolveServerDetails( + installation: McpServerInstallation | null, + template: McpRecommendedServer | null, +): ResolvedServerDetails { + return { + name: + installation?.display_name || + installation?.name || + template?.name || + installation?.url || + "Server", + description: installation?.description || template?.description || "", + docsUrl: template?.docs_url || null, + iconKey: installation?.icon_key || template?.icon_key || null, + authType: installation?.auth_type || template?.auth_type, + }; +} + +export function sortInstallationsByName( + installations: McpServerInstallation[], + templatesById: Map, +): McpServerInstallation[] { + const nameOf = (installation: McpServerInstallation) => + resolveServerName( + installation, + installation.template_id + ? (templatesById.get(installation.template_id) ?? null) + : null, + ); + return [...installations].sort((a, b) => + nameOf(a).localeCompare(nameOf(b), undefined, { sensitivity: "base" }), + ); +} diff --git a/apps/code/src/renderer/features/mcp-servers/components/parts/statusBadge.test.ts b/packages/core/src/mcp-servers/status.test.ts similarity index 90% rename from apps/code/src/renderer/features/mcp-servers/components/parts/statusBadge.test.ts rename to packages/core/src/mcp-servers/status.test.ts index 3d7c270e27..8b6d02c9ca 100644 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/statusBadge.test.ts +++ b/packages/core/src/mcp-servers/status.test.ts @@ -1,6 +1,6 @@ -import type { McpServerInstallation } from "@renderer/api/posthogClient"; +import type { McpServerInstallation } from "@posthog/api-client/types"; import { describe, expect, it } from "vitest"; -import { getInstallationStatus } from "./statusBadge"; +import { getInstallationStatus } from "./status"; function makeInstallation( overrides: Partial = {}, diff --git a/packages/core/src/mcp-servers/status.ts b/packages/core/src/mcp-servers/status.ts new file mode 100644 index 0000000000..05b329e1f1 --- /dev/null +++ b/packages/core/src/mcp-servers/status.ts @@ -0,0 +1,11 @@ +import type { McpServerInstallation } from "@posthog/api-client/types"; + +export type InstallationStatus = "connected" | "pending_oauth" | "needs_reauth"; + +export function getInstallationStatus( + installation: McpServerInstallation, +): InstallationStatus { + if (installation.pending_oauth) return "pending_oauth"; + if (installation.needs_reauth) return "needs_reauth"; + return "connected"; +} diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.test.ts b/packages/core/src/mcp-servers/toolBulk.test.ts similarity index 95% rename from apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.test.ts rename to packages/core/src/mcp-servers/toolBulk.test.ts index f61f6cbbd6..a85ebb56ef 100644 --- a/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.test.ts +++ b/packages/core/src/mcp-servers/toolBulk.test.ts @@ -1,6 +1,6 @@ -import type { McpInstallationTool } from "@renderer/api/posthogClient"; +import type { McpInstallationTool } from "@posthog/api-client/types"; import { describe, expect, it, vi } from "vitest"; -import { dispatchBulkApproval } from "./mcpToolBulk"; +import { dispatchBulkApproval } from "./toolBulk"; function tool( name: string, diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.ts b/packages/core/src/mcp-servers/toolBulk.ts similarity index 95% rename from apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.ts rename to packages/core/src/mcp-servers/toolBulk.ts index 4a57aeddcd..214a9f2685 100644 --- a/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.ts +++ b/packages/core/src/mcp-servers/toolBulk.ts @@ -1,7 +1,7 @@ import type { McpApprovalState, McpInstallationTool, -} from "@renderer/api/posthogClient"; +} from "@posthog/api-client/types"; interface ToolApprovalClient { updateMcpToolApproval: ( diff --git a/packages/core/src/mcp-servers/toolDerivation.test.ts b/packages/core/src/mcp-servers/toolDerivation.test.ts new file mode 100644 index 0000000000..fdc0349cfb --- /dev/null +++ b/packages/core/src/mcp-servers/toolDerivation.test.ts @@ -0,0 +1,71 @@ +import type { McpInstallationTool } from "@posthog/api-client/types"; +import { describe, expect, it } from "vitest"; +import { + countActiveTools, + countRemovedTools, + countToolsByApproval, + filterToolsByName, + sortToolsForDisplay, +} from "./toolDerivation"; + +function tool( + name: string, + overrides: Partial = {}, +): McpInstallationTool { + return { + id: `tool-${name}`, + tool_name: name, + display_name: name, + description: "", + input_schema: {}, + approval_state: "needs_approval", + last_seen_at: "2026-01-01T00:00:00Z", + removed_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("countToolsByApproval", () => { + it("tallies non-removed tools by approval state", () => { + const counts = countToolsByApproval([ + tool("a", { approval_state: "approved" }), + tool("b", { approval_state: "approved" }), + tool("c", { approval_state: "do_not_use" }), + tool("d", { approval_state: "approved", removed_at: "2026-04-01" }), + ]); + expect(counts.approved).toBe(2); + expect(counts.do_not_use).toBe(1); + }); +}); + +describe("sortToolsForDisplay", () => { + it("sorts active before removed, then alphabetically", () => { + const out = sortToolsForDisplay([ + tool("zebra"), + tool("apple", { removed_at: "2026-04-01" }), + tool("mango"), + ]); + expect(out.map((t) => t.tool_name)).toEqual(["mango", "zebra", "apple"]); + }); +}); + +describe("filterToolsByName", () => { + it("substring-matches case-insensitively, empty returns all", () => { + const tools = [tool("readFile"), tool("writeFile"), tool("listDir")]; + expect(filterToolsByName(tools, "file").map((t) => t.tool_name)).toEqual([ + "readFile", + "writeFile", + ]); + expect(filterToolsByName(tools, "")).toHaveLength(3); + }); +}); + +describe("count helpers", () => { + it("counts active and removed", () => { + const tools = [tool("a"), tool("b", { removed_at: "2026-04-01" })]; + expect(countActiveTools(tools)).toBe(1); + expect(countRemovedTools(tools)).toBe(1); + }); +}); diff --git a/packages/core/src/mcp-servers/toolDerivation.ts b/packages/core/src/mcp-servers/toolDerivation.ts new file mode 100644 index 0000000000..b98f61e2e6 --- /dev/null +++ b/packages/core/src/mcp-servers/toolDerivation.ts @@ -0,0 +1,45 @@ +import type { + McpApprovalState, + McpInstallationTool, +} from "@posthog/api-client/types"; + +export function countToolsByApproval( + tools: McpInstallationTool[], +): Record { + return tools.reduce( + (acc, t) => { + if (t.removed_at || !t.approval_state) return acc; + acc[t.approval_state] = (acc[t.approval_state] ?? 0) + 1; + return acc; + }, + {} as Record, + ); +} + +export function sortToolsForDisplay( + tools: McpInstallationTool[], +): McpInstallationTool[] { + return [...tools].sort((a, b) => { + if (!!a.removed_at !== !!b.removed_at) { + return a.removed_at ? 1 : -1; + } + return a.tool_name.localeCompare(b.tool_name); + }); +} + +export function filterToolsByName( + tools: McpInstallationTool[], + term: string, +): McpInstallationTool[] { + const q = term.trim().toLowerCase(); + if (!q) return tools; + return tools.filter((t) => t.tool_name.toLowerCase().includes(q)); +} + +export function countActiveTools(tools: McpInstallationTool[]): number { + return tools.filter((t) => !t.removed_at).length; +} + +export function countRemovedTools(tools: McpInstallationTool[]): number { + return tools.filter((t) => !!t.removed_at).length; +} diff --git a/packages/core/src/mcp-servers/toolRefresh.test.ts b/packages/core/src/mcp-servers/toolRefresh.test.ts new file mode 100644 index 0000000000..54f1214d37 --- /dev/null +++ b/packages/core/src/mcp-servers/toolRefresh.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { type AutoRefreshState, shouldAutoRefreshTools } from "./toolRefresh"; + +function state(overrides: Partial = {}): AutoRefreshState { + return { + autoRefreshIfEmpty: true, + installationId: "inst-1", + isLoading: false, + toolsLength: 0, + alreadyRefreshed: false, + refreshPending: false, + ...overrides, + }; +} + +describe("shouldAutoRefreshTools", () => { + it("fires for an empty, settled, opt-in installation", () => { + expect(shouldAutoRefreshTools(state())).toBe(true); + }); + + it("does not fire when the opt-in flag is off", () => { + expect(shouldAutoRefreshTools(state({ autoRefreshIfEmpty: false }))).toBe( + false, + ); + }); + + it("does not fire without an installation", () => { + expect(shouldAutoRefreshTools(state({ installationId: null }))).toBe(false); + }); + + it("waits while the tools query is loading", () => { + expect(shouldAutoRefreshTools(state({ isLoading: true }))).toBe(false); + }); + + it("does not fire when tools already exist", () => { + expect(shouldAutoRefreshTools(state({ toolsLength: 3 }))).toBe(false); + }); + + it("does not re-fire once already refreshed this session", () => { + expect(shouldAutoRefreshTools(state({ alreadyRefreshed: true }))).toBe( + false, + ); + }); + + it("does not fire while a refresh is already pending", () => { + expect(shouldAutoRefreshTools(state({ refreshPending: true }))).toBe(false); + }); +}); diff --git a/packages/core/src/mcp-servers/toolRefresh.ts b/packages/core/src/mcp-servers/toolRefresh.ts new file mode 100644 index 0000000000..ae5a7ed664 --- /dev/null +++ b/packages/core/src/mcp-servers/toolRefresh.ts @@ -0,0 +1,18 @@ +export interface AutoRefreshState { + autoRefreshIfEmpty: boolean; + installationId: string | null; + isLoading: boolean; + toolsLength: number; + alreadyRefreshed: boolean; + refreshPending: boolean; +} + +export function shouldAutoRefreshTools(state: AutoRefreshState): boolean { + if (!state.autoRefreshIfEmpty) return false; + if (!state.installationId) return false; + if (state.isLoading) return false; + if (state.toolsLength > 0) return false; + if (state.alreadyRefreshed) return false; + if (state.refreshPending) return false; + return true; +} diff --git a/packages/core/src/message-editor/commands.ts b/packages/core/src/message-editor/commands.ts new file mode 100644 index 0000000000..a16caaca6a --- /dev/null +++ b/packages/core/src/message-editor/commands.ts @@ -0,0 +1,51 @@ +import type { FeedbackType } from "@posthog/shared/analytics-events"; + +export function basename(path: string): string { + const trimmed = path.replace(/[\\/]+$/, ""); + const idx = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return idx >= 0 ? trimmed.slice(idx + 1) || trimmed : trimmed; +} + +export interface ParsedCommandLine { + name: string; + args: string | undefined; +} + +const COMMAND_LINE_REGEX = /^\/(\S+)(?:\s+(.*))?$/; + +export function parseCommandLine(text: string): ParsedCommandLine | null { + const match = text.match(COMMAND_LINE_REGEX); + if (!match) return null; + return { name: match[1], args: match[2] }; +} + +export interface FeedbackEventInput { + taskId: string; + taskRunId?: string; + logUrl?: string; + eventCount: number; + feedbackType: FeedbackType; + comment?: string; +} + +export interface FeedbackEventPayload { + task_id: string; + task_run_id: string | undefined; + log_url: string | undefined; + event_count: number; + feedback_type: FeedbackType; + feedback_comment: string | undefined; +} + +export function buildFeedbackEventPayload( + input: FeedbackEventInput, +): FeedbackEventPayload { + return { + task_id: input.taskId, + task_run_id: input.taskRunId, + log_url: input.logUrl, + event_count: input.eventCount, + feedback_type: input.feedbackType, + feedback_comment: input.comment?.trim() || undefined, + }; +} diff --git a/apps/code/src/renderer/features/message-editor/utils/content.test.ts b/packages/core/src/message-editor/content.test.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/utils/content.test.ts rename to packages/core/src/message-editor/content.test.ts diff --git a/apps/code/src/renderer/features/message-editor/utils/content.ts b/packages/core/src/message-editor/content.ts similarity index 98% rename from apps/code/src/renderer/features/message-editor/utils/content.ts rename to packages/core/src/message-editor/content.ts index f0da426568..07b8646a36 100644 --- a/apps/code/src/renderer/features/message-editor/utils/content.ts +++ b/packages/core/src/message-editor/content.ts @@ -1,4 +1,4 @@ -import { escapeXmlAttr, unescapeXmlAttr } from "@utils/xml"; +import { escapeXmlAttr, unescapeXmlAttr } from "@posthog/shared"; export interface MentionChip { type: diff --git a/apps/code/src/renderer/features/message-editor/utils/githubIssueChip.test.ts b/packages/core/src/message-editor/githubIssueChip.test.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/utils/githubIssueChip.test.ts rename to packages/core/src/message-editor/githubIssueChip.test.ts diff --git a/apps/code/src/renderer/features/message-editor/utils/githubIssueChip.ts b/packages/core/src/message-editor/githubIssueChip.ts similarity index 64% rename from apps/code/src/renderer/features/message-editor/utils/githubIssueChip.ts rename to packages/core/src/message-editor/githubIssueChip.ts index de44aae8fb..76b1026573 100644 --- a/apps/code/src/renderer/features/message-editor/utils/githubIssueChip.ts +++ b/packages/core/src/message-editor/githubIssueChip.ts @@ -1,5 +1,6 @@ -import type { GithubRefState } from "../types"; +import type { GithubRefState } from "@posthog/shared"; import type { MentionChip } from "./content"; +import type { ParsedGithubIssueUrl } from "./githubIssueUrl"; export interface GithubIssueChipSource { number: number; @@ -36,3 +37,16 @@ export const GITHUB_ISSUE_STATE_COLORS: Record = { export function githubIssueStateColor(state: GithubRefState): string { return GITHUB_ISSUE_STATE_COLORS[state]; } + +export function buildGithubRefPlaceholderChip( + parsed: ParsedGithubIssueUrl, +): MentionChip { + const source = { + number: parsed.number, + title: "Loading...", + url: parsed.normalizedUrl, + }; + return parsed.kind === "pr" + ? githubPullRequestToMentionChip(source) + : githubIssueToMentionChip(source); +} diff --git a/apps/code/src/renderer/features/message-editor/utils/githubIssueUrl.test.ts b/packages/core/src/message-editor/githubIssueUrl.test.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/utils/githubIssueUrl.test.ts rename to packages/core/src/message-editor/githubIssueUrl.test.ts diff --git a/apps/code/src/renderer/features/message-editor/utils/githubIssueUrl.ts b/packages/core/src/message-editor/githubIssueUrl.ts similarity index 94% rename from apps/code/src/renderer/features/message-editor/utils/githubIssueUrl.ts rename to packages/core/src/message-editor/githubIssueUrl.ts index 96e5ae4a2e..094eab0c1d 100644 --- a/apps/code/src/renderer/features/message-editor/utils/githubIssueUrl.ts +++ b/packages/core/src/message-editor/githubIssueUrl.ts @@ -1,4 +1,4 @@ -import type { GithubRefKind } from "../types"; +import type { GithubRefKind } from "@posthog/shared"; export type { GithubRefKind }; diff --git a/packages/core/src/message-editor/paste.ts b/packages/core/src/message-editor/paste.ts new file mode 100644 index 0000000000..a9307bcb66 --- /dev/null +++ b/packages/core/src/message-editor/paste.ts @@ -0,0 +1,31 @@ +const URL_ONLY_REGEX = /^https?:\/\/\S+$/; + +export function isUrlOnly(text: string): boolean { + return URL_ONLY_REGEX.test(text); +} + +export function buildMarkdownLink(selectedText: string, url: string): string { + return `[${selectedText}](${url})`; +} + +export function isBashModeText(text: string): boolean { + return text.trimStart().startsWith("!"); +} + +export function extractBashCommand(text: string): string { + return text.slice(1).trim(); +} + +export function shouldAutoConvertLongText( + text: string, + threshold: string, +): boolean { + return threshold !== "off" && text.length > Number(threshold); +} + +export function buildPastedTextLabel( + pasteNumber: number, + lineCount: number, +): string { + return `Pasted text #${pasteNumber} (${lineCount} lines)`; +} diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts b/packages/core/src/message-editor/persistFile.test.ts similarity index 59% rename from apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts rename to packages/core/src/message-editor/persistFile.test.ts index 7a7e73fd56..e3b2d99570 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts +++ b/packages/core/src/message-editor/persistFile.test.ts @@ -4,26 +4,6 @@ const mockSaveClipboardImage = vi.hoisted(() => vi.fn()); const mockSaveClipboardText = vi.hoisted(() => vi.fn()); const mockSaveClipboardFile = vi.hoisted(() => vi.fn()); const mockDownscaleImageFile = vi.hoisted(() => vi.fn()); -const mockGetFilePath = vi.hoisted(() => vi.fn()); - -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - os: { - saveClipboardImage: { - mutate: mockSaveClipboardImage, - }, - saveClipboardText: { - mutate: mockSaveClipboardText, - }, - saveClipboardFile: { - mutate: mockSaveClipboardFile, - }, - downscaleImageFile: { - mutate: mockDownscaleImageFile, - }, - }, - }, -})); vi.mock("@posthog/shared", async () => { const actual = @@ -31,24 +11,30 @@ vi.mock("@posthog/shared", async () => { return { ...actual, getImageMimeType: () => "image/png" }; }); -vi.mock("@utils/getFilePath", () => ({ - getFilePath: mockGetFilePath, -})); - -const mockToastWarning = vi.hoisted(() => vi.fn()); -vi.mock("@renderer/utils/toast", () => ({ - toast: { warning: mockToastWarning }, -})); - import { + arrayBufferToBase64, + type FilePersistHost, persistBrowserFile, persistImageFile, persistImageFilePath, persistTextContent, - resolveAndAttachDroppedFiles, resolveDroppedFile, } from "./persistFile"; +const host: FilePersistHost = { + saveClipboardImage: mockSaveClipboardImage, + saveClipboardText: mockSaveClipboardText, + saveClipboardFile: mockSaveClipboardFile, + downscaleImageFile: mockDownscaleImageFile, +}; + +describe("arrayBufferToBase64", () => { + it("encodes bytes to base64", () => { + const buffer = new TextEncoder().encode("hello").buffer; + expect(arrayBufferToBase64(buffer)).toBe(btoa("hello")); + }); +}); + describe("persistFile", () => { beforeEach(() => { vi.clearAllMocks(); @@ -60,7 +46,7 @@ describe("persistFile", () => { name: "notes.md", }); - const result = await persistTextContent("# hello", "notes.md"); + const result = await persistTextContent(host, "# hello", "notes.md"); expect(mockSaveClipboardText).toHaveBeenCalledWith({ text: "# hello", @@ -85,7 +71,7 @@ describe("persistFile", () => { arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), } as unknown as File; - const result = await persistImageFile(file); + const result = await persistImageFile(host, file); expect(mockSaveClipboardImage).toHaveBeenCalledWith( expect.objectContaining({ @@ -113,7 +99,7 @@ describe("persistFile", () => { arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), } as unknown as File; - const result = await persistBrowserFile(file); + const result = await persistBrowserFile(host, file); expect(result).toEqual({ id: "/tmp/posthog-code-clipboard/attachment-abc/img.png", @@ -133,7 +119,7 @@ describe("persistFile", () => { arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), } as unknown as File; - await expect(persistBrowserFile(file)).resolves.toEqual({ + await expect(persistBrowserFile(host, file)).resolves.toEqual({ id: "/tmp/posthog-code-clipboard/attachment-def/archive.zip", label: "archive.zip", }); @@ -143,28 +129,6 @@ describe("persistFile", () => { originalName: "archive.zip", }); }); - - it("returns the preserved filename for browser-selected text files", async () => { - mockSaveClipboardFile.mockResolvedValue({ - path: "/tmp/posthog-code-clipboard/attachment-456/config.json", - name: "config.json", - }); - - const file = { - name: "config.json", - type: "application/json", - arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), - } as unknown as File; - - await expect(persistBrowserFile(file)).resolves.toEqual({ - id: "/tmp/posthog-code-clipboard/attachment-456/config.json", - label: "config.json", - }); - expect(mockSaveClipboardFile).toHaveBeenCalledWith({ - base64Data: expect.any(String), - originalName: "config.json", - }); - }); }); describe("persistImageFilePath", () => { @@ -179,7 +143,10 @@ describe("persistImageFilePath", () => { mimeType: "image/jpeg", }); - const result = await persistImageFilePath("/Users/me/Desktop/photo.png"); + const result = await persistImageFilePath( + host, + "/Users/me/Desktop/photo.png", + ); expect(mockDownscaleImageFile).toHaveBeenCalledWith({ filePath: "/Users/me/Desktop/photo.png", @@ -193,7 +160,7 @@ describe("persistImageFilePath", () => { it("propagates errors from downscaleImageFile", async () => { mockDownscaleImageFile.mockRejectedValue(new Error("Image too large")); - await expect(persistImageFilePath("/big/image.png")).rejects.toThrow( + await expect(persistImageFilePath(host, "/big/image.png")).rejects.toThrow( "Image too large", ); }); @@ -204,25 +171,20 @@ describe("resolveDroppedFile", () => { vi.clearAllMocks(); }); - it("returns null when getFilePath returns empty string", async () => { - mockGetFilePath.mockReturnValue(""); - + it("returns null when filePath is empty", async () => { const file = { name: "test.txt" } as File; - expect(await resolveDroppedFile(file)).toBeNull(); + expect(await resolveDroppedFile(host, file, "")).toBeNull(); }); it("returns file attachment directly for non-image files", async () => { - mockGetFilePath.mockReturnValue("/Users/me/doc.pdf"); - const file = { name: "doc.pdf" } as File; - const result = await resolveDroppedFile(file); + const result = await resolveDroppedFile(host, file, "/Users/me/doc.pdf"); expect(result).toEqual({ id: "/Users/me/doc.pdf", label: "doc.pdf" }); expect(mockDownscaleImageFile).not.toHaveBeenCalled(); }); it("routes image files through downscaleImageFile", async () => { - mockGetFilePath.mockReturnValue("/Users/me/photo.png"); mockDownscaleImageFile.mockResolvedValue({ path: "/tmp/posthog-code-clipboard/attachment-bbb/photo.jpg", name: "photo.jpg", @@ -230,7 +192,7 @@ describe("resolveDroppedFile", () => { }); const file = { name: "photo.png" } as File; - const result = await resolveDroppedFile(file); + const result = await resolveDroppedFile(host, file, "/Users/me/photo.png"); expect(mockDownscaleImageFile).toHaveBeenCalledWith({ filePath: "/Users/me/photo.png", @@ -241,51 +203,19 @@ describe("resolveDroppedFile", () => { }); }); - it("falls back to original path and shows warning toast when image downscaling fails", async () => { - mockGetFilePath.mockReturnValue("/Users/me/corrupt.png"); + it("falls back to original path and invokes onDownscaleFailed when downscaling fails", async () => { mockDownscaleImageFile.mockRejectedValue(new Error("decode failed")); + const onDownscaleFailed = vi.fn(); const file = { name: "corrupt.png" } as File; - expect(await resolveDroppedFile(file)).toEqual({ + expect( + await resolveDroppedFile(host, file, "/Users/me/corrupt.png", { + onDownscaleFailed, + }), + ).toEqual({ id: "/Users/me/corrupt.png", label: "corrupt.png", }); - expect(mockToastWarning).toHaveBeenCalledWith( - "Image could not be downscaled", - { description: "Attaching original file instead" }, - ); - }); -}); - -describe("resolveAndAttachDroppedFiles", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("calls addAttachment for each resolved file", async () => { - mockGetFilePath - .mockReturnValueOnce("/Users/me/a.txt") - .mockReturnValueOnce("") - .mockReturnValueOnce("/Users/me/b.txt"); - - const files = [ - { name: "a.txt" }, - { name: "skip.txt" }, - { name: "b.txt" }, - ] as unknown as FileList; - Object.defineProperty(files, "length", { value: 3 }); - - const addAttachment = vi.fn(); - await resolveAndAttachDroppedFiles(files, addAttachment); - - expect(addAttachment).toHaveBeenCalledTimes(2); - expect(addAttachment).toHaveBeenCalledWith({ - id: "/Users/me/a.txt", - label: "a.txt", - }); - expect(addAttachment).toHaveBeenCalledWith({ - id: "/Users/me/b.txt", - label: "b.txt", - }); + expect(onDownscaleFailed).toHaveBeenCalledOnce(); }); }); diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts b/packages/core/src/message-editor/persistFile.ts similarity index 55% rename from apps/code/src/renderer/features/message-editor/utils/persistFile.ts rename to packages/core/src/message-editor/persistFile.ts index 1e366b57b2..96584418ce 100644 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts +++ b/packages/core/src/message-editor/persistFile.ts @@ -1,12 +1,9 @@ import { getImageMimeType, isRasterImageFile } from "@posthog/shared"; -import { trpcClient } from "@renderer/trpc/client"; -import { toast } from "@renderer/utils/toast"; -import { getFilePath } from "@utils/getFilePath"; import type { FileAttachment } from "./content"; const CHUNK_SIZE = 8192; -function arrayBufferToBase64(buffer: ArrayBuffer): string { +export function arrayBufferToBase64(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); const chunks: string[] = []; for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { @@ -21,12 +18,34 @@ export interface PersistedFile { mimeType?: string; } -export async function persistImageFile(file: File): Promise { +export interface FilePersistHost { + saveClipboardImage(input: { + base64Data: string; + mimeType: string; + originalName: string; + }): Promise<{ path: string; name: string; mimeType: string }>; + saveClipboardText(input: { + text: string; + originalName?: string; + }): Promise<{ path: string; name: string }>; + saveClipboardFile(input: { + base64Data: string; + originalName: string; + }): Promise<{ path: string; name: string }>; + downscaleImageFile(input: { + filePath: string; + }): Promise<{ path: string; name: string }>; +} + +export async function persistImageFile( + host: FilePersistHost, + file: File, +): Promise { const arrayBuffer = await file.arrayBuffer(); const base64Data = arrayBufferToBase64(arrayBuffer); const mimeType = file.type || getImageMimeType(file.name); - const result = await trpcClient.os.saveClipboardImage.mutate({ + const result = await host.saveClipboardImage({ base64Data, mimeType, originalName: file.name, @@ -35,21 +54,22 @@ export async function persistImageFile(file: File): Promise { } export async function persistTextContent( + host: FilePersistHost, text: string, originalName?: string, ): Promise { - const result = await trpcClient.os.saveClipboardText.mutate({ - text, - originalName, - }); + const result = await host.saveClipboardText({ text, originalName }); return { path: result.path, name: result.name }; } -export async function persistGenericFile(file: File): Promise { +export async function persistGenericFile( + host: FilePersistHost, + file: File, +): Promise { const arrayBuffer = await file.arrayBuffer(); const base64Data = arrayBufferToBase64(arrayBuffer); - const result = await trpcClient.os.saveClipboardFile.mutate({ + const result = await host.saveClipboardFile({ base64Data, originalName: file.name, }); @@ -62,25 +82,30 @@ export async function persistGenericFile(file: File): Promise { } export async function persistImageFilePath( + host: FilePersistHost, filePath: string, ): Promise<{ id: string; label: string }> { - const result = await trpcClient.os.downscaleImageFile.mutate({ filePath }); + const result = await host.downscaleImageFile({ filePath }); return { id: result.path, label: result.name }; } +export interface ResolveDroppedFileOptions { + onDownscaleFailed?: () => void; +} + export async function resolveDroppedFile( + host: FilePersistHost, file: File, + filePath: string | null, + options?: ResolveDroppedFileOptions, ): Promise { - const filePath = getFilePath(file); if (!filePath) return null; if (isRasterImageFile(file.name)) { try { - return await persistImageFilePath(filePath); + return await persistImageFilePath(host, filePath); } catch { - toast.warning("Image could not be downscaled", { - description: "Attaching original file instead", - }); + options?.onDownscaleFailed?.(); return { id: filePath, label: file.name }; } } @@ -88,24 +113,15 @@ export async function resolveDroppedFile( return { id: filePath, label: file.name }; } -export async function resolveAndAttachDroppedFiles( - files: FileList, - addAttachment: (attachment: FileAttachment) => void, -): Promise { - for (let i = 0; i < files.length; i++) { - const attachment = await resolveDroppedFile(files[i]); - if (attachment) addAttachment(attachment); - } -} - export async function persistBrowserFile( + host: FilePersistHost, file: File, ): Promise<{ id: string; label: string }> { if (file.type.startsWith("image/")) { - const result = await persistImageFile(file); + const result = await persistImageFile(host, file); return { id: result.path, label: result.name }; } - const result = await persistGenericFile(file); + const result = await persistGenericFile(host, file); return { id: result.path, label: result.name }; } diff --git a/apps/code/src/renderer/features/message-editor/tiptap/suggestionLoader.test.ts b/packages/core/src/message-editor/suggestionLoader.test.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/tiptap/suggestionLoader.test.ts rename to packages/core/src/message-editor/suggestionLoader.test.ts diff --git a/apps/code/src/renderer/features/message-editor/tiptap/suggestionLoader.ts b/packages/core/src/message-editor/suggestionLoader.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/tiptap/suggestionLoader.ts rename to packages/core/src/message-editor/suggestionLoader.ts diff --git a/packages/core/src/message-editor/suggestions.ts b/packages/core/src/message-editor/suggestions.ts new file mode 100644 index 0000000000..6cffc1cb2b --- /dev/null +++ b/packages/core/src/message-editor/suggestions.ts @@ -0,0 +1,134 @@ +import { isAbsolutePath } from "@posthog/shared"; +import Fuse, { type IFuseOptions } from "fuse.js"; + +export interface CommandLike { + name: string; + description?: string; +} + +export interface FileItemLike { + path: string; + name: string; + dir: string; + kind: "file" | "directory"; +} + +export interface FileSuggestionShape { + id: string; + label: string; + description?: string; + filename?: string; + path: string; + kind?: "file" | "directory"; + chipType?: "file" | "folder"; +} + +export interface CommandSuggestionShape { + id: string; + label: string; + description?: string; + command: T; +} + +const COMMAND_FUSE_OPTIONS: IFuseOptions = { + keys: [ + { name: "name", weight: 0.7 }, + { name: "description", weight: 0.3 }, + ], + threshold: 0.3, + includeScore: true, +}; + +export function searchCommands( + commands: T[], + query: string, +): T[] { + if (!query.trim()) { + return commands; + } + + const fuse = new Fuse(commands, COMMAND_FUSE_OPTIONS); + const results = fuse.search(query); + + const lowerQuery = query.toLowerCase(); + results.sort((a, b) => { + const aStartsWithQuery = a.item.name.toLowerCase().startsWith(lowerQuery); + const bStartsWithQuery = b.item.name.toLowerCase().startsWith(lowerQuery); + + if (aStartsWithQuery && !bStartsWithQuery) return -1; + if (!aStartsWithQuery && bStartsWithQuery) return 1; + return (a.score ?? 0) - (b.score ?? 0); + }); + + return results.map((result) => result.item); +} + +export function mergeCommands( + codeCommands: T[], + agentCommands: T[], +): T[] { + const merged = [...codeCommands, ...agentCommands]; + return [...new Map(merged.map((cmd) => [cmd.name, cmd])).values()]; +} + +export function shapeCommandSuggestions( + commands: T[], +): CommandSuggestionShape[] { + return commands.map((cmd) => ({ + id: cmd.name, + label: cmd.name, + description: cmd.description, + command: cmd, + })); +} + +export function parentDirLabel(dir: string, name: string): string { + const parent = dir.split("/").filter(Boolean).pop(); + return parent ? `${parent}/${name}` : name; +} + +export function getAbsolutePathSuggestion( + query: string, +): FileSuggestionShape | null { + if (!isAbsolutePath(query)) return null; + if (!/\.\w+$/.test(query)) return null; + + const parts = query.split("/"); + const name = parts.pop() ?? query; + const dir = parts.join("/"); + return { + id: query, + label: parentDirLabel(dir, name), + description: dir || undefined, + filename: name, + path: query, + }; +} + +export function shapeFileSuggestions( + matched: FileItemLike[], + repoPath: string, + absoluteMatch: FileSuggestionShape | null, +): FileSuggestionShape[] { + const results: FileSuggestionShape[] = matched.map((file) => { + const isDirectory = file.kind === "directory"; + return { + id: file.path, + label: parentDirLabel(file.dir, file.name), + description: file.dir || undefined, + filename: file.name, + path: file.path, + kind: file.kind, + chipType: isDirectory ? "folder" : "file", + }; + }); + + if ( + absoluteMatch && + !results.some((r) => `${repoPath}/${r.id}` === absoluteMatch.id) + ) { + results.unshift(absoluteMatch); + } + + return results; +} diff --git a/packages/core/src/notification/identifiers.ts b/packages/core/src/notification/identifiers.ts new file mode 100644 index 0000000000..010fa00240 --- /dev/null +++ b/packages/core/src/notification/identifiers.ts @@ -0,0 +1,3 @@ +export const NOTIFICATION_SERVICE = Symbol.for( + "posthog.core.notificationService", +); diff --git a/packages/core/src/notification/notification.test.ts b/packages/core/src/notification/notification.test.ts new file mode 100644 index 0000000000..e925e57243 --- /dev/null +++ b/packages/core/src/notification/notification.test.ts @@ -0,0 +1,136 @@ +import type { INotifier, NotifyOptions } from "@posthog/platform/notifier"; +import { describe, expect, it, vi } from "vitest"; +import { TaskLinkEvent } from "../links/task-link"; +import { NotificationService } from "./notification"; + +function makeLogger() { + const scoped = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + return { ...scoped, scope: vi.fn(() => scoped) }; +} + +function createDeps(supported = true) { + let lastNotify: NotifyOptions | undefined; + let focusHandler: (() => void) | undefined; + + const notifier: INotifier = { + isSupported: vi.fn(() => supported), + notify: vi.fn((options: NotifyOptions) => { + lastNotify = options; + }), + setUnreadIndicator: vi.fn(), + requestAttention: vi.fn(), + }; + + const mainWindow = { + onFocus: vi.fn((handler: () => void) => { + focusHandler = handler; + }), + isMinimized: vi.fn(() => false), + restore: vi.fn(), + focus: vi.fn(), + }; + + const taskLinkService = { emit: vi.fn() }; + + const service = new NotificationService( + taskLinkService as never, + notifier, + mainWindow as never, + makeLogger(), + ); + + return { + service, + notifier, + mainWindow, + taskLinkService, + getLastNotify: () => lastNotify, + getFocusHandler: () => focusHandler, + }; +} + +describe("NotificationService.send", () => { + it("does not notify when the platform is unsupported", () => { + const { service, notifier } = createDeps(false); + service.send("t", "b", false); + expect(notifier.notify).not.toHaveBeenCalled(); + }); + + it("forwards title, body and silent to the notifier", () => { + const { service, getLastNotify } = createDeps(); + service.send("Title", "Body", true); + expect(getLastNotify()).toMatchObject({ + title: "Title", + body: "Body", + silent: true, + }); + }); + + it("focuses the window when the notification is clicked", () => { + const { service, mainWindow, getLastNotify } = createDeps(); + mainWindow.isMinimized.mockReturnValue(true); + + service.send("Title", "Body", false); + getLastNotify()?.onClick?.(); + + expect(mainWindow.restore).toHaveBeenCalled(); + expect(mainWindow.focus).toHaveBeenCalled(); + }); + + it("emits OpenTask on click when a taskId is provided", () => { + const { service, taskLinkService, getLastNotify } = createDeps(); + + service.send("Title", "Body", false, "task-9"); + getLastNotify()?.onClick?.(); + + expect(taskLinkService.emit).toHaveBeenCalledWith(TaskLinkEvent.OpenTask, { + taskId: "task-9", + }); + }); + + it("does not emit OpenTask on click without a taskId", () => { + const { service, taskLinkService, getLastNotify } = createDeps(); + + service.send("Title", "Body", false); + getLastNotify()?.onClick?.(); + + expect(taskLinkService.emit).not.toHaveBeenCalled(); + }); +}); + +describe("NotificationService dock badge", () => { + it("sets the unread indicator once and is idempotent", () => { + const { service, notifier } = createDeps(); + + service.showDockBadge(); + service.showDockBadge(); + + expect(notifier.setUnreadIndicator).toHaveBeenCalledTimes(1); + expect(notifier.setUnreadIndicator).toHaveBeenCalledWith(true); + }); + + it("clears the badge on window focus only when a badge is set", () => { + const { service, notifier, getFocusHandler } = createDeps(); + service.init(); + + getFocusHandler()?.(); + expect(notifier.setUnreadIndicator).not.toHaveBeenCalled(); + + service.showDockBadge(); + vi.mocked(notifier.setUnreadIndicator).mockClear(); + + getFocusHandler()?.(); + expect(notifier.setUnreadIndicator).toHaveBeenCalledWith(false); + }); + + it("requests attention when bouncing the dock", () => { + const { service, notifier } = createDeps(); + service.bounceDock(); + expect(notifier.requestAttention).toHaveBeenCalled(); + }); +}); diff --git a/apps/code/src/main/services/notification/service.ts b/packages/core/src/notification/notification.ts similarity index 52% rename from apps/code/src/main/services/notification/service.ts rename to packages/core/src/notification/notification.ts index 4d27d27d58..16985e00e9 100644 --- a/apps/code/src/main/services/notification/service.ts +++ b/packages/core/src/notification/notification.ts @@ -1,24 +1,34 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; -import type { INotifier } from "@posthog/platform/notifier"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { type INotifier, NOTIFIER_SERVICE } from "@posthog/platform/notifier"; import { inject, injectable, postConstruct } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TaskLinkEvent, type TaskLinkService } from "../task-link/service"; - -const log = logger.scope("notification"); +import { TASK_LINK_SERVICE } from "../links/identifiers"; +import { TaskLinkEvent, type TaskLinkService } from "../links/task-link"; @injectable() export class NotificationService { private hasBadge = false; + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.TaskLinkService) + @inject(TASK_LINK_SERVICE) private readonly taskLinkService: TaskLinkService, - @inject(MAIN_TOKENS.Notifier) + @inject(NOTIFIER_SERVICE) private readonly notifier: INotifier, - @inject(MAIN_TOKENS.MainWindow) + @inject(MAIN_WINDOW_SERVICE) private readonly mainWindow: IMainWindow, - ) {} + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + this.log = logger.scope("notification"); + } @postConstruct() init(): void { @@ -27,7 +37,7 @@ export class NotificationService { send(title: string, body: string, silent: boolean, taskId?: string): void { if (!this.notifier.isSupported()) { - log.warn("Notifications not supported on this platform"); + this.log.warn("Notifications not supported on this platform"); return; } @@ -36,7 +46,10 @@ export class NotificationService { body, silent, onClick: () => { - log.info("Notification clicked, focusing window", { title, taskId }); + this.log.info("Notification clicked, focusing window", { + title, + taskId, + }); if (this.mainWindow.isMinimized()) { this.mainWindow.restore(); } @@ -44,29 +57,29 @@ export class NotificationService { if (taskId) { this.taskLinkService.emit(TaskLinkEvent.OpenTask, { taskId }); - log.info("Notification clicked, navigating to task", { taskId }); + this.log.info("Notification clicked, navigating to task", { taskId }); } }, }); - log.info("Notification sent", { title, body, silent, taskId }); + this.log.info("Notification sent", { title, body, silent, taskId }); } showDockBadge(): void { if (this.hasBadge) return; this.hasBadge = true; this.notifier.setUnreadIndicator(true); - log.info("Dock badge shown"); + this.log.info("Dock badge shown"); } bounceDock(): void { this.notifier.requestAttention(); - log.info("Dock bounce triggered"); + this.log.info("Dock bounce triggered"); } private clearDockBadge(): void { if (!this.hasBadge) return; this.hasBadge = false; this.notifier.setUnreadIndicator(false); - log.info("Dock badge cleared"); + this.log.info("Dock badge cleared"); } } diff --git a/packages/core/src/oauth/identifiers.ts b/packages/core/src/oauth/identifiers.ts new file mode 100644 index 0000000000..92bbd936c1 --- /dev/null +++ b/packages/core/src/oauth/identifiers.ts @@ -0,0 +1,17 @@ +export const OAUTH_SERVICE = Symbol.for("posthog.core.oauthService"); +export const OAUTH_HOST = Symbol.for("posthog.core.oauthHost"); + +export interface OAuthCallbackReceiver { + waitForCode(options: { + port: number; + timeoutMs: number; + signal?: AbortSignal; + onListening?: () => void; + }): Promise; +} + +export interface OAuthEnv { + readonly isDev: boolean; +} + +export interface OAuthHost extends OAuthCallbackReceiver, OAuthEnv {} diff --git a/packages/core/src/oauth/oauth.module.ts b/packages/core/src/oauth/oauth.module.ts new file mode 100644 index 0000000000..e080a7dad4 --- /dev/null +++ b/packages/core/src/oauth/oauth.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { OAUTH_SERVICE } from "./identifiers"; +import { OAuthService } from "./oauth"; + +export const oauthModule = new ContainerModule(({ bind }) => { + bind(OAUTH_SERVICE).to(OAuthService).inSingletonScope(); +}); diff --git a/packages/core/src/oauth/oauth.test.ts b/packages/core/src/oauth/oauth.test.ts new file mode 100644 index 0000000000..a742523fa4 --- /dev/null +++ b/packages/core/src/oauth/oauth.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OAuthEnv, OAuthHost } from "./identifiers"; +import { OAuthService } from "./oauth"; + +const fetchMock = vi.fn(); + +function createDeps(env: Partial = {}) { + let callbackHandler: + | ((path: string, searchParams: URLSearchParams) => boolean) + | undefined; + + const deepLinkService = { + registerHandler: vi.fn( + ( + _name: string, + handler: (path: string, searchParams: URLSearchParams) => boolean, + ) => { + callbackHandler = handler; + }, + ), + getProtocol: vi.fn(() => "posthog-code"), + }; + + const urlLauncher = { launch: vi.fn().mockResolvedValue(undefined) }; + + const mainWindow = { + isMinimized: vi.fn(() => false), + restore: vi.fn(), + focus: vi.fn(), + }; + + const host: OAuthHost = { + waitForCode: vi.fn(), + isDev: false, + ...env, + }; + + const scopedLog = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const log = { ...scopedLog, scope: vi.fn(() => scopedLog) }; + + const crypto = { + randomBase64Url: vi.fn(() => "code-verifier"), + sha256Base64Url: vi.fn(() => "code-challenge"), + }; + + const service = new OAuthService( + deepLinkService as never, + urlLauncher as never, + mainWindow as never, + host, + log, + crypto as never, + ); + + return { + service, + deepLinkService, + urlLauncher, + mainWindow, + host, + log, + getCallbackHandler: () => callbackHandler, + }; +} + +const TOKEN_RESPONSE = { + access_token: "at", + expires_in: 3600, + token_type: "Bearer", + scope: "", + refresh_token: "rt", +}; + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("OAuthService.refreshToken", () => { + it("returns the token payload on success", async () => { + const { service } = createDeps(); + fetchMock.mockResolvedValue(jsonResponse(TOKEN_RESPONSE)); + + const result = await service.refreshToken("rt", "us"); + + expect(result.success).toBe(true); + expect(result.data).toEqual(TOKEN_RESPONSE); + }); + + it("maps 401 to an auth_error", async () => { + const { service } = createDeps(); + fetchMock.mockResolvedValue(jsonResponse({}, 401)); + + const result = await service.refreshToken("rt", "us"); + + expect(result.success).toBe(false); + expect(result.errorCode).toBe("auth_error"); + }); + + it("maps 403 to an auth_error", async () => { + const { service } = createDeps(); + fetchMock.mockResolvedValue(jsonResponse({}, 403)); + + const result = await service.refreshToken("rt", "us"); + + expect(result.errorCode).toBe("auth_error"); + }); + + it("maps 5xx to a server_error", async () => { + const { service } = createDeps(); + fetchMock.mockResolvedValue(jsonResponse({}, 503)); + + const result = await service.refreshToken("rt", "us"); + + expect(result.errorCode).toBe("server_error"); + }); + + it("maps other 4xx to an unknown_error", async () => { + const { service } = createDeps(); + fetchMock.mockResolvedValue(jsonResponse({}, 400)); + + const result = await service.refreshToken("rt", "us"); + + expect(result.errorCode).toBe("unknown_error"); + }); + + it("maps a thrown fetch to a network_error with a friendly message", async () => { + const { service } = createDeps(); + fetchMock.mockRejectedValue(new TypeError("fetch failed")); + + const result = await service.refreshToken("rt", "us"); + + expect(result.errorCode).toBe("network_error"); + expect(result.error).toContain("internet connection"); + }); +}); + +describe("OAuthService.cancelFlow", () => { + it("succeeds when there is no pending flow", () => { + const { service } = createDeps(); + expect(service.cancelFlow()).toEqual({ success: true }); + }); +}); + +describe("OAuthService deep-link callback handler", () => { + it("registers a callback handler on construction", () => { + const { deepLinkService } = createDeps(); + expect(deepLinkService.registerHandler).toHaveBeenCalledWith( + "callback", + expect.any(Function), + ); + }); + + it("refocuses the window when a callback arrives with no in-app flow", () => { + const { getCallbackHandler, mainWindow } = createDeps(); + mainWindow.isMinimized.mockReturnValue(true); + + const handled = getCallbackHandler()?.( + "callback", + new URLSearchParams("code=abc"), + ); + + expect(handled).toBe(true); + expect(mainWindow.restore).toHaveBeenCalled(); + expect(mainWindow.focus).toHaveBeenCalled(); + }); +}); diff --git a/apps/code/src/main/services/oauth/service.ts b/packages/core/src/oauth/oauth.ts similarity index 63% rename from apps/code/src/main/services/oauth/service.ts rename to packages/core/src/oauth/oauth.ts index 3ee31add2f..825ad4fc89 100644 --- a/apps/code/src/main/services/oauth/service.ts +++ b/packages/core/src/oauth/oauth.ts @@ -1,19 +1,30 @@ -import * as crypto from "node:crypto"; -import * as http from "node:http"; -import type { Socket } from "node:net"; -import type { IMainWindow } from "@posthog/platform/main-window"; -import type { IUrlLauncher } from "@posthog/platform/url-launcher"; import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { CRYPTO_SERVICE, type ICrypto } from "@posthog/platform/crypto"; +import { + DEEP_LINK_SERVICE, + type IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { + type IUrlLauncher, + URL_LAUNCHER_SERVICE, +} from "@posthog/platform/url-launcher"; +import { + type BackoffOptions, + getCloudUrlFromRegion, getOauthClientIdFromRegion, OAUTH_SCOPES, -} from "@shared/constants/oauth"; -import { type BackoffOptions, sleepWithBackoff } from "@shared/utils/backoff"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; + sleepWithBackoff, +} from "@posthog/shared"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { isDevBuild } from "../../utils/env"; -import { logger } from "../../utils/logger"; -import type { DeepLinkService } from "../deep-link/service"; +import { OAUTH_HOST, type OAuthHost } from "./identifiers"; import type { CancelFlowOutput, CloudRegion, @@ -22,8 +33,6 @@ import type { StartFlowOutput, } from "./schemas"; -const log = logger.scope("oauth-service"); - const OAUTH_TIMEOUT_MS = 180_000; // 3 minutes const DEV_CALLBACK_PORT = 8237; @@ -47,23 +56,30 @@ interface PendingOAuthFlow { config: OAuthConfig; resolve: (code: string) => void; reject: (error: Error) => void; - timeoutId: NodeJS.Timeout; - server?: http.Server; - connections?: Set; + timeoutId?: NodeJS.Timeout; + abortController?: AbortController; } @injectable() export class OAuthService { private pendingFlow: PendingOAuthFlow | null = null; + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.UrlLauncher) + @inject(DEEP_LINK_SERVICE) + private readonly deepLinkService: IDeepLinkRegistry, + @inject(URL_LAUNCHER_SERVICE) private readonly urlLauncher: IUrlLauncher, - @inject(MAIN_TOKENS.MainWindow) + @inject(MAIN_WINDOW_SERVICE) private readonly mainWindow: IMainWindow, + @inject(OAUTH_HOST) + private readonly host: OAuthHost, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + @inject(CRYPTO_SERVICE) + private readonly crypto: ICrypto, ) { + this.log = logger.scope("oauth-service"); // Register OAuth callback handler for deep links this.deepLinkService.registerHandler("callback", (_path, searchParams) => this.handleOAuthCallback(searchParams), @@ -77,10 +93,12 @@ export class OAuthService { if (!this.pendingFlow) { // Same deep link as desktop sign-in (`posthog-code://callback`), but auth finished in // the browser (e.g. GitHub on PostHog Cloud) — refocus so the user lands back in Code. - log.info( + this.log.info( "OAuth callback deep link with no in-app flow — refocusing (e.g. return from web auth)", ); - log.info("oauth callback deep link (no in-app flow) — focusing window"); + this.log.info( + "oauth callback deep link (no in-app flow) — focusing window", + ); if (this.mainWindow.isMinimized()) this.mainWindow.restore(); this.mainWindow.focus(); return true; @@ -108,7 +126,7 @@ export class OAuthService { * Get the redirect URI based on environment. */ private getRedirectUri(): string { - return isDevBuild() + return this.host.isDev ? `http://localhost:${DEV_CALLBACK_PORT}/callback` : `${this.deepLinkService.getProtocol()}://callback`; } @@ -200,7 +218,7 @@ export class OAuthService { const isAuthError = response.status === 401 || response.status === 403; // 5xx are server errors - should be retried const isServerError = response.status >= 500; - log.warn( + this.log.warn( `Token refresh failed: ${response.status} ${response.statusText}`, ); return { @@ -214,7 +232,7 @@ export class OAuthService { }; } - const tokenResponse: OAuthTokenResponse = await response.json(); + const tokenResponse = (await response.json()) as OAuthTokenResponse; return { success: true, @@ -235,11 +253,14 @@ export class OAuthService { public cancelFlow(): CancelFlowOutput { try { if (this.pendingFlow) { - // Clean up HTTP server if in dev mode - if (this.pendingFlow.server) { - this.cleanupHttpServer(); + if (this.pendingFlow.abortController) { + // Dev HTTP-callback path: stop the workspace-server callback server. + this.pendingFlow.abortController.abort(); + this.pendingFlow = null; } else { - clearTimeout(this.pendingFlow.timeoutId); + if (this.pendingFlow.timeoutId) { + clearTimeout(this.pendingFlow.timeoutId); + } this.pendingFlow.reject(new Error("OAuth flow cancelled")); this.pendingFlow = null; } @@ -285,151 +306,39 @@ export class OAuthService { } /** - * Wait for OAuth callback via HTTP server (development). + * Wait for OAuth callback via the workspace-server HTTP server (development). */ private async waitForHttpCallback( codeVerifier: string, config: OAuthConfig, authUrl: string, ): Promise { - return new Promise((resolve, reject) => { - const connections = new Set(); - - const server = http.createServer((req, res) => { - if (!req.url) { - res.writeHead(400); - res.end(); - return; - } - - const url = new URL(req.url, `http://localhost:${DEV_CALLBACK_PORT}`); - - if (url.pathname === "/callback") { - const code = url.searchParams.get("code"); - const error = url.searchParams.get("error"); - - if (error) { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end( - this.getCallbackHtml( - error === "access_denied" ? "cancelled" : "error", - ), - ); - this.cleanupHttpServer(); - reject(new Error(`OAuth error: ${error}`)); - return; - } - - if (code) { - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(this.getCallbackHtml("success")); - this.cleanupHttpServer(); - resolve(code); - return; - } - - res.writeHead(400, { "Content-Type": "text/html" }); - res.end(this.getCallbackHtml("error")); - } else { - res.writeHead(404); - res.end(); - } - }); - - server.on("connection", (conn) => { - connections.add(conn); - conn.on("close", () => connections.delete(conn)); - }); - - const timeoutId = setTimeout(() => { - this.cleanupHttpServer(); - reject(new Error("Authorization timed out")); - }, OAUTH_TIMEOUT_MS); - - this.pendingFlow = { - codeVerifier, - config, - resolve, - reject, - timeoutId, - server, - connections, - }; - - server.listen(DEV_CALLBACK_PORT, () => { - log.info( - `Dev OAuth callback server listening on port ${DEV_CALLBACK_PORT}`, - ); - // Open the browser for authentication - this.urlLauncher.launch(authUrl).catch((error) => { - this.cleanupHttpServer(); - reject(new Error(`Failed to open browser: ${error.message}`)); - }); - }); - - server.on("error", (error) => { - this.cleanupHttpServer(); - reject(new Error(`Failed to start callback server: ${error.message}`)); - }); - }); - } - - /** - * Generate HTML for the callback page. - */ - private getCallbackHtml(status: "success" | "cancelled" | "error"): string { - const titles = { - success: "Authorization successful!", - cancelled: "Authorization cancelled", - error: "Authorization failed", - }; - const messages = { - success: "You can close this window and return to PostHog Code.", - cancelled: "You can close this window and return to PostHog Code.", - error: "You can close this window and return to PostHog Code.", + const abortController = new AbortController(); + this.pendingFlow = { + codeVerifier, + config, + resolve: () => {}, + reject: () => {}, + abortController, }; - return ` - - - - ${titles[status]} - - - - - -

${titles[status]}

-

${messages[status]}

- - -`; - } - - /** - * Clean up HTTP server used in development. - */ - private cleanupHttpServer(): void { - if (this.pendingFlow?.server) { - // Destroy all connections - if (this.pendingFlow.connections) { - for (const conn of this.pendingFlow.connections) { - conn.destroy(); - } - this.pendingFlow.connections.clear(); - } - this.pendingFlow.server.close(); - } - if (this.pendingFlow?.timeoutId) { - clearTimeout(this.pendingFlow.timeoutId); + try { + return await this.host.waitForCode({ + port: DEV_CALLBACK_PORT, + timeoutMs: OAUTH_TIMEOUT_MS, + signal: abortController.signal, + onListening: () => { + this.log.info( + `Dev OAuth callback server listening on port ${DEV_CALLBACK_PORT}`, + ); + this.urlLauncher.launch(authUrl).catch(() => { + abortController.abort(); + }); + }, + }); + } finally { + this.pendingFlow = null; } - this.pendingFlow = null; } private async exchangeCodeForToken( @@ -462,7 +371,7 @@ export class OAuthService { // "fetch failed", "terminated", etc.) leaks to the UI as-is, so we replace // it with something users can act on. lastError = NETWORK_ERROR_MESSAGE; - log.warn("Token exchange network error", { + this.log.warn("Token exchange network error", { attempt, error: error instanceof Error ? error.message : String(error), }); @@ -472,7 +381,7 @@ export class OAuthService { } if (response.ok) { - return response.json(); + return (await response.json()) as OAuthTokenResponse; } lastError = `Token exchange failed: ${response.status} ${response.statusText}`; @@ -481,7 +390,7 @@ export class OAuthService { throw new Error(lastError); } - log.warn("Token exchange server error", { + this.log.warn("Token exchange server error", { attempt, status: response.status, }); @@ -520,7 +429,7 @@ export class OAuthService { codeVerifier: string, authUrl: string, ): Promise { - const code = isDevBuild() + const code = this.host.isDev ? await this.waitForHttpCallback(codeVerifier, config, authUrl) : await this.waitForDeepLinkCallback(codeVerifier, config, authUrl); @@ -537,11 +446,11 @@ export class OAuthService { } private generateCodeVerifier(): string { - return crypto.randomBytes(32).toString("base64url"); + return this.crypto.randomBase64Url(32); } private generateCodeChallenge(verifier: string): string { - return crypto.createHash("sha256").update(verifier).digest("base64url"); + return this.crypto.sha256Base64Url(verifier); } /** diff --git a/packages/core/src/oauth/schemas.ts b/packages/core/src/oauth/schemas.ts new file mode 100644 index 0000000000..2526f3b776 --- /dev/null +++ b/packages/core/src/oauth/schemas.ts @@ -0,0 +1 @@ +export * from "@posthog/core/auth/oauth.schemas"; diff --git a/packages/core/src/onboarding/analytics.test.ts b/packages/core/src/onboarding/analytics.test.ts new file mode 100644 index 0000000000..d215eed76f --- /dev/null +++ b/packages/core/src/onboarding/analytics.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + buildAbandonedProps, + buildCompletedProps, + buildStepCompletedProps, + durationSeconds, +} from "./analytics"; + +describe("durationSeconds", () => { + it("rounds milliseconds to whole seconds", () => { + expect(durationSeconds(1000, 4400)).toBe(3); + }); +}); + +describe("buildStepCompletedProps", () => { + it("computes duration and merges context", () => { + const props = buildStepCompletedProps({ + stepId: "select-repo", + stepIndex: 4, + totalSteps: 5, + stepEnteredAtMs: 1000, + nowMs: 6000, + context: { github_connected: true }, + }); + expect(props).toEqual({ + step_id: "select-repo", + step_index: 4, + total_steps: 5, + duration_seconds: 5, + github_connected: true, + }); + }); +}); + +describe("buildCompletedProps", () => { + it("shapes completion flags and duration", () => { + expect( + buildCompletedProps({ + flowStartedAtMs: 0, + nowMs: 10000, + githubConnected: true, + repoSkipped: false, + }), + ).toEqual({ + duration_seconds: 10, + github_connected: true, + repo_skipped: false, + }); + }); +}); + +describe("buildAbandonedProps", () => { + it("captures the last step and duration", () => { + expect( + buildAbandonedProps({ + lastStepId: "welcome", + flowStartedAtMs: 0, + nowMs: 2000, + }), + ).toEqual({ last_step_id: "welcome", duration_seconds: 2 }); + }); +}); diff --git a/packages/core/src/onboarding/analytics.ts b/packages/core/src/onboarding/analytics.ts new file mode 100644 index 0000000000..b0d711734b --- /dev/null +++ b/packages/core/src/onboarding/analytics.ts @@ -0,0 +1,56 @@ +import type { + OnboardingAbandonedProperties, + OnboardingCompletedProperties, + OnboardingStepCompletedProperties, + OnboardingStepId, +} from "@posthog/shared/analytics-events"; + +export function durationSeconds(startedAtMs: number, nowMs: number): number { + return Math.round((nowMs - startedAtMs) / 1000); +} + +export type StepCompletedContext = Omit< + OnboardingStepCompletedProperties, + "step_id" | "step_index" | "total_steps" | "duration_seconds" +>; + +export function buildStepCompletedProps(opts: { + stepId: OnboardingStepId; + stepIndex: number; + totalSteps: number; + stepEnteredAtMs: number; + nowMs: number; + context?: StepCompletedContext; +}): OnboardingStepCompletedProperties { + return { + step_id: opts.stepId, + step_index: opts.stepIndex, + total_steps: opts.totalSteps, + duration_seconds: durationSeconds(opts.stepEnteredAtMs, opts.nowMs), + ...opts.context, + }; +} + +export function buildCompletedProps(opts: { + flowStartedAtMs: number; + nowMs: number; + githubConnected: boolean; + repoSkipped: boolean; +}): OnboardingCompletedProperties { + return { + duration_seconds: durationSeconds(opts.flowStartedAtMs, opts.nowMs), + github_connected: opts.githubConnected, + repo_skipped: opts.repoSkipped, + }; +} + +export function buildAbandonedProps(opts: { + lastStepId: OnboardingStepId; + flowStartedAtMs: number; + nowMs: number; +}): OnboardingAbandonedProperties { + return { + last_step_id: opts.lastStepId, + duration_seconds: durationSeconds(opts.flowStartedAtMs, opts.nowMs), + }; +} diff --git a/packages/core/src/onboarding/githubConnectPanel.test.ts b/packages/core/src/onboarding/githubConnectPanel.test.ts new file mode 100644 index 0000000000..b26adc2211 --- /dev/null +++ b/packages/core/src/onboarding/githubConnectPanel.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; +import { + buildConnectFailedProps, + buildConnectFailureFingerprint, + buildInstallationSettingsUrl, + deriveAlternativeConnectedProjects, + deriveConnectButtonState, + getGithubPanelMessage, + isAnyIntegrationStale, + resolveSelectedProjectId, +} from "./githubConnectPanel"; + +describe("getGithubPanelMessage", () => { + it("prioritizes the connect error message", () => { + expect( + getGithubPanelMessage({ + hasConnectError: true, + connectErrorMessage: "boom", + timedOut: false, + isConnecting: false, + }), + ).toBe("boom"); + }); + + it("falls through timeout, connecting, then default", () => { + const base = { + hasConnectError: false, + connectErrorMessage: "", + }; + expect( + getGithubPanelMessage({ ...base, timedOut: true, isConnecting: false }), + ).toMatch(/didn't hear back/); + expect( + getGithubPanelMessage({ ...base, timedOut: false, isConnecting: true }), + ).toBe("Waiting for GitHub..."); + expect( + getGithubPanelMessage({ ...base, timedOut: false, isConnecting: false }), + ).toMatch(/Unlocks cloud runs/); + }); +}); + +describe("resolveSelectedProjectId", () => { + const projects = [{ id: 7 }, { id: 8 }]; + + it("prefers the manual selection", () => { + expect(resolveSelectedProjectId(3, 5, projects)).toBe(3); + }); + + it("falls back to current project then first project then null", () => { + expect(resolveSelectedProjectId(null, 5, projects)).toBe(5); + expect(resolveSelectedProjectId(null, null, projects)).toBe(7); + expect(resolveSelectedProjectId(null, null, [])).toBeNull(); + }); +}); + +describe("deriveAlternativeConnectedProjects", () => { + const projects = [{ id: 1 }, { id: 2 }, { id: 3 }]; + + it("is empty when the user already has a personal integration", () => { + expect(deriveAlternativeConnectedProjects(true, projects, 1)).toEqual([]); + }); + + it("excludes the selected project", () => { + expect( + deriveAlternativeConnectedProjects(false, projects, 2).map((p) => p.id), + ).toEqual([1, 3]); + }); +}); + +describe("isAnyIntegrationStale", () => { + it("detects a failed installation", () => { + const integrations = [{ installation_id: "a" }, { installation_id: "b" }]; + expect(isAnyIntegrationStale(integrations, ["b"])).toBe(true); + expect(isAnyIntegrationStale(integrations, ["z"])).toBe(false); + }); +}); + +describe("buildInstallationSettingsUrl", () => { + it("builds an org settings url", () => { + expect( + buildInstallationSettingsUrl( + { type: "Organization", name: "acme" }, + "42", + ), + ).toBe("https://github.com/organizations/acme/settings/installations/42"); + }); + + it("builds a personal settings url otherwise", () => { + expect(buildInstallationSettingsUrl({ type: "User" }, "42")).toBe( + "https://github.com/settings/installations/42", + ); + expect(buildInstallationSettingsUrl(null, "42")).toBe( + "https://github.com/settings/installations/42", + ); + }); +}); + +describe("buildConnectFailureFingerprint", () => { + it("is null when there is no failure", () => { + expect( + buildConnectFailureFingerprint({ + hasConnectError: false, + timedOut: false, + errorCode: null, + }), + ).toBeNull(); + }); + + it("prefers timeout over error code", () => { + expect( + buildConnectFailureFingerprint({ + hasConnectError: true, + timedOut: true, + errorCode: "bad", + }), + ).toBe("timeout"); + }); + + it("uses the error code, falling back to error", () => { + expect( + buildConnectFailureFingerprint({ + hasConnectError: true, + timedOut: false, + errorCode: "bad", + }), + ).toBe("bad"); + expect( + buildConnectFailureFingerprint({ + hasConnectError: true, + timedOut: false, + errorCode: null, + }), + ).toBe("error"); + }); +}); + +describe("buildConnectFailedProps", () => { + it("maps timeout to a timeout reason without an error type", () => { + expect( + buildConnectFailedProps({ + hasConnectError: false, + timedOut: true, + errorCode: "ignored", + }), + ).toEqual({ reason: "timeout", error_type: "ignored" }); + }); + + it("maps error to an error reason carrying the code", () => { + expect( + buildConnectFailedProps({ + hasConnectError: true, + timedOut: false, + errorCode: "bad", + }), + ).toEqual({ reason: "error", error_type: "bad" }); + expect( + buildConnectFailedProps({ + hasConnectError: true, + timedOut: false, + errorCode: null, + }), + ).toEqual({ reason: "error", error_type: undefined }); + }); +}); + +describe("deriveConnectButtonState", () => { + it("is a fresh connect when idle", () => { + expect( + deriveConnectButtonState({ + isConnecting: false, + hasConnectError: false, + timedOut: false, + }), + ).toEqual({ isRetry: false, shouldReset: false, label: "Connect GitHub" }); + }); + + it("labels a retry on error and asks to reset", () => { + expect( + deriveConnectButtonState({ + isConnecting: false, + hasConnectError: true, + timedOut: false, + }), + ).toEqual({ isRetry: true, shouldReset: true, label: "Try again" }); + }); + + it("labels retry connection while connecting", () => { + expect( + deriveConnectButtonState({ + isConnecting: true, + hasConnectError: false, + timedOut: true, + }), + ).toEqual({ isRetry: true, shouldReset: false, label: "Retry connection" }); + }); +}); diff --git a/packages/core/src/onboarding/githubConnectPanel.ts b/packages/core/src/onboarding/githubConnectPanel.ts new file mode 100644 index 0000000000..10ea08b5d1 --- /dev/null +++ b/packages/core/src/onboarding/githubConnectPanel.ts @@ -0,0 +1,112 @@ +export interface GithubPanelMessageOptions { + hasConnectError: boolean; + connectErrorMessage: string; + timedOut: boolean; + isConnecting: boolean; +} + +export function getGithubPanelMessage( + options: GithubPanelMessageOptions, +): string { + if (options.hasConnectError) return options.connectErrorMessage; + if (options.timedOut) { + return "We didn't hear back from GitHub. If the browser tab was closed, click Connect again."; + } + if (options.isConnecting) return "Waiting for GitHub..."; + return "Unlocks cloud runs, branch pushes, and PR review on this account."; +} + +export function resolveSelectedProjectId( + manuallySelectedProjectId: number | null, + currentProjectId: number | null | undefined, + projects: { id: number }[], +): number | null { + if (manuallySelectedProjectId !== null) return manuallySelectedProjectId; + return currentProjectId ?? projects[0]?.id ?? null; +} + +export function deriveAlternativeConnectedProjects< + TProject extends { id: number }, +>( + hasGitIntegration: boolean, + projectsWithGithub: TProject[], + selectedProjectId: number | null, +): TProject[] { + if (hasGitIntegration) return []; + if (!projectsWithGithub.length) return []; + return projectsWithGithub.filter( + (project) => project.id !== selectedProjectId, + ); +} + +export interface GithubInstallationAccount { + name?: string | null; + type?: string | null; +} + +export function isAnyIntegrationStale( + integrations: { installation_id: string }[], + failedInstallationIds: string[], +): boolean { + return integrations.some((integration) => + failedInstallationIds.includes(integration.installation_id), + ); +} + +export function buildInstallationSettingsUrl( + account: GithubInstallationAccount | null | undefined, + installationId: string, +): string { + if (account?.type === "Organization" && account.name) { + return `https://github.com/organizations/${account.name}/settings/installations/${installationId}`; + } + return `https://github.com/settings/installations/${installationId}`; +} + +export interface ConnectFailureInputs { + hasConnectError: boolean; + timedOut: boolean; + errorCode: string | null | undefined; +} + +export function buildConnectFailureFingerprint( + inputs: ConnectFailureInputs, +): string | null { + if (!inputs.hasConnectError && !inputs.timedOut) return null; + if (inputs.timedOut) return "timeout"; + return inputs.errorCode ?? "error"; +} + +export interface ConnectFailedProps { + reason: "timeout" | "error"; + error_type?: string; +} + +export function buildConnectFailedProps( + inputs: ConnectFailureInputs, +): ConnectFailedProps { + return { + reason: inputs.timedOut ? "timeout" : "error", + error_type: inputs.errorCode ?? undefined, + }; +} + +export interface ConnectButtonState { + isRetry: boolean; + shouldReset: boolean; + label: string; +} + +export function deriveConnectButtonState(inputs: { + isConnecting: boolean; + hasConnectError: boolean; + timedOut: boolean; +}): ConnectButtonState { + const isRetry = inputs.hasConnectError || inputs.timedOut; + const label = inputs.isConnecting + ? "Retry connection" + : isRetry + ? "Try again" + : "Connect GitHub"; + return { isRetry, shouldReset: inputs.hasConnectError, label }; +} diff --git a/packages/core/src/onboarding/githubConnectService.test.ts b/packages/core/src/onboarding/githubConnectService.test.ts new file mode 100644 index 0000000000..0a3f7e99d9 --- /dev/null +++ b/packages/core/src/onboarding/githubConnectService.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; +import { GithubConnectService } from "./githubConnectService"; +import type { GithubConnectClient } from "./identifiers"; + +function makeClient( + disconnect: GithubConnectClient["disconnectGithubUserIntegration"] = vi + .fn() + .mockResolvedValue(undefined), +): GithubConnectClient { + return { disconnectGithubUserIntegration: disconnect }; +} + +describe("GithubConnectService", () => { + it("disconnects an installation through the client", async () => { + const disconnect = vi.fn().mockResolvedValue(undefined); + const service = new GithubConnectService(makeClient(disconnect)); + + await service.disconnectInstallation("install-1"); + + expect(disconnect).toHaveBeenCalledWith("install-1"); + }); + + it("reconnect disconnects then runs the connect flow in order", async () => { + const calls: string[] = []; + const disconnect = vi.fn().mockImplementation(async () => { + calls.push("disconnect"); + }); + const connect = vi.fn().mockImplementation(async () => { + calls.push("connect"); + }); + const service = new GithubConnectService(makeClient(disconnect)); + + await service.reconnectStaleInstallation("install-1", connect); + + expect(calls).toEqual(["disconnect", "connect"]); + }); + + it("reports the in-flight installation while reconnecting", async () => { + let resolveDisconnect: () => void = () => undefined; + const disconnect = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveDisconnect = resolve; + }), + ); + const service = new GithubConnectService(makeClient(disconnect)); + + const pending = service.reconnectStaleInstallation( + "install-1", + vi.fn().mockResolvedValue(undefined), + ); + + expect(service.isReconnecting("install-1")).toBe(true); + expect(service.isReconnecting("install-2")).toBe(false); + expect(service.isAnyReconnectInFlight()).toBe(true); + + resolveDisconnect(); + await pending; + + expect(service.isAnyReconnectInFlight()).toBe(false); + }); + + it("refuses a second reconnect while one is in flight", async () => { + let resolveDisconnect: () => void = () => undefined; + const disconnect = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveDisconnect = resolve; + }), + ); + const connect = vi.fn().mockResolvedValue(undefined); + const service = new GithubConnectService(makeClient(disconnect)); + + const first = service.reconnectStaleInstallation("install-1", connect); + await service.reconnectStaleInstallation("install-2", connect); + + expect(disconnect).toHaveBeenCalledTimes(1); + expect(disconnect).toHaveBeenCalledWith("install-1"); + + resolveDisconnect(); + await first; + }); + + it("clears the gate even when connect throws", async () => { + const service = new GithubConnectService(makeClient()); + const connect = vi.fn().mockRejectedValue(new Error("boom")); + + await expect( + service.reconnectStaleInstallation("install-1", connect), + ).rejects.toThrow("boom"); + expect(service.isAnyReconnectInFlight()).toBe(false); + }); + + describe("shouldReportFailure", () => { + it("reports a fingerprint once then dedups it", () => { + const service = new GithubConnectService(makeClient()); + + expect(service.shouldReportFailure("error")).toBe(true); + expect(service.shouldReportFailure("error")).toBe(false); + }); + + it("reports a changed fingerprint again", () => { + const service = new GithubConnectService(makeClient()); + + expect(service.shouldReportFailure("timeout")).toBe(true); + expect(service.shouldReportFailure("error")).toBe(true); + }); + + it("clears tracking on a null fingerprint so the next failure reports", () => { + const service = new GithubConnectService(makeClient()); + + service.shouldReportFailure("error"); + expect(service.shouldReportFailure(null)).toBe(false); + expect(service.shouldReportFailure("error")).toBe(true); + }); + }); +}); diff --git a/packages/core/src/onboarding/githubConnectService.ts b/packages/core/src/onboarding/githubConnectService.ts new file mode 100644 index 0000000000..829e598c73 --- /dev/null +++ b/packages/core/src/onboarding/githubConnectService.ts @@ -0,0 +1,49 @@ +import { inject, injectable } from "inversify"; +import { GITHUB_CONNECT_CLIENT, type GithubConnectClient } from "./identifiers"; + +@injectable() +export class GithubConnectService { + private reconnectingInstallationId: string | null = null; + private reportedFailureFingerprint: string | null = null; + + constructor( + @inject(GITHUB_CONNECT_CLIENT) + private readonly client: GithubConnectClient, + ) {} + + shouldReportFailure(fingerprint: string | null): boolean { + if (fingerprint === null) { + this.reportedFailureFingerprint = null; + return false; + } + if (this.reportedFailureFingerprint === fingerprint) return false; + this.reportedFailureFingerprint = fingerprint; + return true; + } + + async disconnectInstallation(installationId: string): Promise { + await this.client.disconnectGithubUserIntegration(installationId); + } + + isReconnecting(installationId: string): boolean { + return this.reconnectingInstallationId === installationId; + } + + isAnyReconnectInFlight(): boolean { + return this.reconnectingInstallationId !== null; + } + + async reconnectStaleInstallation( + installationId: string, + connect: () => Promise, + ): Promise { + if (this.reconnectingInstallationId !== null) return; + this.reconnectingInstallationId = installationId; + try { + await this.client.disconnectGithubUserIntegration(installationId); + await connect(); + } finally { + this.reconnectingInstallationId = null; + } + } +} diff --git a/packages/core/src/onboarding/identifiers.ts b/packages/core/src/onboarding/identifiers.ts new file mode 100644 index 0000000000..e89ff8d7be --- /dev/null +++ b/packages/core/src/onboarding/identifiers.ts @@ -0,0 +1,11 @@ +export interface GithubConnectClient { + disconnectGithubUserIntegration(installationId: string): Promise; +} + +export const GITHUB_CONNECT_CLIENT = Symbol.for( + "posthog.core.githubConnectClient", +); + +export const GITHUB_CONNECT_SERVICE = Symbol.for( + "posthog.core.githubConnectService", +); diff --git a/packages/core/src/onboarding/onboarding.module.ts b/packages/core/src/onboarding/onboarding.module.ts new file mode 100644 index 0000000000..84e7a462fa --- /dev/null +++ b/packages/core/src/onboarding/onboarding.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { GithubConnectService } from "./githubConnectService"; +import { GITHUB_CONNECT_SERVICE } from "./identifiers"; + +export const onboardingModule = new ContainerModule(({ bind }) => { + bind(GITHUB_CONNECT_SERVICE).to(GithubConnectService).inSingletonScope(); +}); diff --git a/packages/core/src/onboarding/projectsWithIntegrations.test.ts b/packages/core/src/onboarding/projectsWithIntegrations.test.ts new file mode 100644 index 0000000000..7085b9fbad --- /dev/null +++ b/packages/core/src/onboarding/projectsWithIntegrations.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { deriveProjectsWithIntegrations } from "./projectsWithIntegrations"; + +const project = (id: number, name: string) => ({ + id, + name, + organization: { id: "org", name: "Org" }, +}); + +describe("deriveProjectsWithIntegrations", () => { + it("sorts projects by name and derives hasGithubIntegration", () => { + const projects = [project(1, "Beta"), project(2, "Alpha")]; + const integrations = [[{ kind: "slack" }], [{ kind: "github" }]]; + + const result = deriveProjectsWithIntegrations(projects, integrations); + + expect(result.projects.map((p) => p.name)).toEqual(["Alpha", "Beta"]); + expect(result.projects[0].hasGithubIntegration).toBe(true); + expect(result.projects[1].hasGithubIntegration).toBe(false); + }); + + it("filters projects with github into projectsWithGithub", () => { + const projects = [project(1, "Alpha"), project(2, "Beta")]; + const integrations = [[{ kind: "github" }], []]; + + const result = deriveProjectsWithIntegrations(projects, integrations); + + expect(result.projectsWithGithub.map((p) => p.id)).toEqual([1]); + }); + + it("treats missing integration data as empty", () => { + const result = deriveProjectsWithIntegrations( + [project(1, "Alpha")], + [undefined], + ); + expect(result.projects[0].integrations).toEqual([]); + expect(result.projects[0].hasGithubIntegration).toBe(false); + }); +}); diff --git a/packages/core/src/onboarding/projectsWithIntegrations.ts b/packages/core/src/onboarding/projectsWithIntegrations.ts new file mode 100644 index 0000000000..a842862677 --- /dev/null +++ b/packages/core/src/onboarding/projectsWithIntegrations.ts @@ -0,0 +1,51 @@ +export interface OnboardingIntegration { + kind: string; + [key: string]: unknown; +} + +export interface OnboardingProject { + id: number; + name: string; + organization: { id: string; name: string }; +} + +export interface ProjectWithIntegrations< + TIntegration extends OnboardingIntegration = OnboardingIntegration, +> { + id: number; + name: string; + organization: { id: string; name: string }; + integrations: TIntegration[]; + hasGithubIntegration: boolean; +} + +export function deriveProjectsWithIntegrations< + TProject extends OnboardingProject, + TIntegration extends OnboardingIntegration, +>( + projects: TProject[], + integrationsByIndex: (TIntegration[] | undefined)[], +): { + projects: ProjectWithIntegrations[]; + projectsWithGithub: ProjectWithIntegrations[]; +} { + const projectsWithIntegrations = projects + .map((project, index) => { + const integrations = integrationsByIndex[index] ?? []; + const hasGithubIntegration = integrations.some( + (integration) => integration.kind === "github", + ); + return { + ...project, + integrations, + hasGithubIntegration, + } as ProjectWithIntegrations; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + const projectsWithGithub = projectsWithIntegrations.filter( + (project) => project.hasGithubIntegration, + ); + + return { projects: projectsWithIntegrations, projectsWithGithub }; +} diff --git a/packages/core/src/onboarding/repoProvider.test.ts b/packages/core/src/onboarding/repoProvider.test.ts new file mode 100644 index 0000000000..a5732e8560 --- /dev/null +++ b/packages/core/src/onboarding/repoProvider.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + inferRepositoryProvider, + repoMatchesGitHubRepos, + toDetectedRepo, +} from "./repoProvider"; + +describe("inferRepositoryProvider", () => { + it("returns local when there is no remote", () => { + expect(inferRepositoryProvider(undefined)).toBe("local"); + }); + + it("classifies github and gitlab hosts", () => { + expect(inferRepositoryProvider("git@github.com:acme/app.git")).toBe( + "github", + ); + expect(inferRepositoryProvider("https://gitlab.com/acme/app.git")).toBe( + "gitlab", + ); + }); + + it("returns none for other hosts", () => { + expect(inferRepositoryProvider("https://bitbucket.org/acme/app")).toBe( + "none", + ); + }); +}); + +describe("toDetectedRepo", () => { + it("returns null for empty input", () => { + expect(toDetectedRepo(null)).toBeNull(); + expect(toDetectedRepo(undefined)).toBeNull(); + }); + + it("shapes the detect result into a DetectedRepo", () => { + expect( + toDetectedRepo({ + organization: "acme", + repository: "app", + remote: "git@github.com:acme/app.git", + branch: "main", + }), + ).toEqual({ + organization: "acme", + repository: "app", + fullName: "acme/app", + remote: "git@github.com:acme/app.git", + branch: "main", + }); + }); + + it("coerces null remote/branch to undefined", () => { + const repo = toDetectedRepo({ + organization: "acme", + repository: "app", + remote: null, + branch: null, + }); + expect(repo?.remote).toBeUndefined(); + expect(repo?.branch).toBeUndefined(); + }); +}); + +describe("repoMatchesGitHubRepos", () => { + const detected = { + organization: "acme", + repository: "app", + fullName: "acme/app", + }; + + it("returns false without a detected repo or repositories", () => { + expect(repoMatchesGitHubRepos(null, ["acme/app"])).toBe(false); + expect(repoMatchesGitHubRepos(detected, [])).toBe(false); + }); + + it("matches case-insensitively", () => { + expect(repoMatchesGitHubRepos(detected, ["ACME/App"])).toBe(true); + expect(repoMatchesGitHubRepos(detected, ["other/repo"])).toBe(false); + }); +}); diff --git a/packages/core/src/onboarding/repoProvider.ts b/packages/core/src/onboarding/repoProvider.ts new file mode 100644 index 0000000000..361222cd4b --- /dev/null +++ b/packages/core/src/onboarding/repoProvider.ts @@ -0,0 +1,43 @@ +import type { RepositoryProvider } from "@posthog/shared/analytics-events"; +import type { DetectedRepo } from "./steps"; + +export interface DetectRepoResult { + organization: string; + repository: string; + remote?: string | null; + branch?: string | null; +} + +export function inferRepositoryProvider( + remote: string | undefined, +): RepositoryProvider { + if (!remote) return "local"; + const host = remote + .match(/^(?:[a-z]+:\/\/)?(?:[^@/]+@)?([a-z0-9.-]+)[:/]/i)?.[1] + ?.toLowerCase(); + if (host === "gitlab.com") return "gitlab"; + if (host === "github.com") return "github"; + return "none"; +} + +export function toDetectedRepo( + result: DetectRepoResult | null | undefined, +): DetectedRepo | null { + if (!result) return null; + return { + organization: result.organization, + repository: result.repository, + fullName: `${result.organization}/${result.repository}`, + remote: result.remote ?? undefined, + branch: result.branch ?? undefined, + }; +} + +export function repoMatchesGitHubRepos( + detectedRepo: DetectedRepo | null, + repositories: string[], +): boolean { + if (!detectedRepo || repositories.length === 0) return false; + const target = detectedRepo.fullName.toLowerCase(); + return repositories.some((repo) => repo.toLowerCase() === target); +} diff --git a/packages/core/src/onboarding/steps.test.ts b/packages/core/src/onboarding/steps.test.ts new file mode 100644 index 0000000000..b56f9d34a6 --- /dev/null +++ b/packages/core/src/onboarding/steps.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + computeActiveSteps, + isFirstStep, + isLastStep, + nextStep, + ONBOARDING_STEPS, + previousStep, + stepDirection, +} from "./steps"; + +describe("computeActiveSteps", () => { + it("drops invite-code when the user already has code access", () => { + expect(computeActiveSteps(true)).not.toContain("invite-code"); + }); + + it("keeps invite-code when access is unknown or false", () => { + expect(computeActiveSteps(false)).toEqual(ONBOARDING_STEPS); + expect(computeActiveSteps(null)).toEqual(ONBOARDING_STEPS); + expect(computeActiveSteps(undefined)).toEqual(ONBOARDING_STEPS); + }); +}); + +describe("step navigation", () => { + const steps = computeActiveSteps(true); + + it("identifies first and last steps", () => { + expect(isFirstStep(0)).toBe(true); + expect(isFirstStep(1)).toBe(false); + expect(isLastStep(steps, steps.length - 1)).toBe(true); + expect(isLastStep(steps, 0)).toBe(false); + }); + + it("advances and retreats within bounds", () => { + expect(nextStep(steps, 0)).toBe(steps[1]); + expect(nextStep(steps, steps.length - 1)).toBeNull(); + expect(previousStep(steps, 1)).toBe(steps[0]); + expect(previousStep(steps, 0)).toBeNull(); + }); + + it("derives navigation direction", () => { + expect(stepDirection(steps, 0, steps[2])).toBe(1); + expect(stepDirection(steps, 2, steps[0])).toBe(-1); + expect(stepDirection(steps, 1, steps[1])).toBe(1); + }); +}); diff --git a/packages/core/src/onboarding/steps.ts b/packages/core/src/onboarding/steps.ts new file mode 100644 index 0000000000..f792ff5589 --- /dev/null +++ b/packages/core/src/onboarding/steps.ts @@ -0,0 +1,76 @@ +export type OnboardingStep = + | "welcome" + | "project-select" + | "invite-code" + | "connect-github" + | "install-cli" + | "select-repo"; + +export const ONBOARDING_STEPS: OnboardingStep[] = [ + "welcome", + "project-select", + "invite-code", + "connect-github", + "install-cli", + "select-repo", +]; + +export interface DetectedRepo { + organization: string; + repository: string; + fullName: string; + remote?: string; + branch?: string; +} + +export function computeActiveSteps( + hasCodeAccess: boolean | null | undefined, +): OnboardingStep[] { + if (hasCodeAccess === true) { + return ONBOARDING_STEPS.filter((step) => step !== "invite-code"); + } + return ONBOARDING_STEPS; +} + +export function stepIndexOf( + activeSteps: OnboardingStep[], + step: OnboardingStep, +): number { + return activeSteps.indexOf(step); +} + +export function isFirstStep(currentIndex: number): boolean { + return currentIndex === 0; +} + +export function isLastStep( + activeSteps: OnboardingStep[], + currentIndex: number, +): boolean { + return currentIndex === activeSteps.length - 1; +} + +export function nextStep( + activeSteps: OnboardingStep[], + currentIndex: number, +): OnboardingStep | null { + if (isLastStep(activeSteps, currentIndex)) return null; + return activeSteps[currentIndex + 1]; +} + +export function previousStep( + activeSteps: OnboardingStep[], + currentIndex: number, +): OnboardingStep | null { + if (isFirstStep(currentIndex)) return null; + return activeSteps[currentIndex - 1]; +} + +export function stepDirection( + activeSteps: OnboardingStep[], + currentIndex: number, + target: OnboardingStep, +): 1 | -1 { + const targetIndex = activeSteps.indexOf(target); + return targetIndex >= currentIndex ? 1 : -1; +} diff --git a/apps/code/src/renderer/features/panels/constants/panelConstants.ts b/packages/core/src/panels/panelConstants.ts similarity index 80% rename from apps/code/src/renderer/features/panels/constants/panelConstants.ts rename to packages/core/src/panels/panelConstants.ts index aa990772c6..d7e300f2fa 100644 --- a/apps/code/src/renderer/features/panels/constants/panelConstants.ts +++ b/packages/core/src/panels/panelConstants.ts @@ -5,12 +5,6 @@ export const PANEL_SIZES = { SIZE_DIFF_THRESHOLD: 0.1, } as const; -export const UI_SIZES = { - TAB_HEIGHT: 40, - TAB_LABEL_MAX_WIDTH: 200, - DROP_ZONE_SIZE: "20%", -} as const; - export const DEFAULT_PANEL_IDS = { ROOT: "root", MAIN_PANEL: "main-panel", diff --git a/packages/core/src/panels/panelLayoutTransforms.test.ts b/packages/core/src/panels/panelLayoutTransforms.test.ts new file mode 100644 index 0000000000..a7a2c3a2cd --- /dev/null +++ b/packages/core/src/panels/panelLayoutTransforms.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + addRecentFile, + closeTab, + createInitialTaskLayout, + openTab, +} from "./panelLayoutTransforms"; +import { createFileTabId, resetPanelIdCounter } from "./panelStoreHelpers"; +import { findTabInTree } from "./panelTree"; +import type { TaskLayout } from "./panelTypes"; + +function applyUpdates( + layout: TaskLayout, + updates: Partial, +): TaskLayout { + return { ...layout, ...updates }; +} + +describe("panelLayoutTransforms", () => { + beforeEach(() => { + resetPanelIdCounter(); + }); + + describe("createInitialTaskLayout", () => { + it("creates a leaf main panel with logs and shell tabs", () => { + const layout = createInitialTaskLayout(); + expect(layout.panelTree.type).toBe("leaf"); + if (layout.panelTree.type !== "leaf") return; + expect(layout.panelTree.content.tabs.map((t) => t.id)).toEqual([ + "logs", + "shell", + ]); + expect(layout.panelTree.content.activeTabId).toBe("logs"); + }); + }); + + describe("openTab", () => { + it("adds a new file tab to the main panel", () => { + const layout = createInitialTaskLayout(); + const tabId = createFileTabId("src/App.tsx"); + const next = applyUpdates(layout, openTab(layout, tabId, false)); + + expect(findTabInTree(next.panelTree, tabId)).not.toBeNull(); + expect(next.panelTree.type).toBe("leaf"); + if (next.panelTree.type !== "leaf") return; + expect(next.panelTree.content.tabs.length).toBe(3); + expect(next.panelTree.content.activeTabId).toBe(tabId); + }); + + it("activates an existing tab instead of duplicating it", () => { + const layout = createInitialTaskLayout(); + const tabId = createFileTabId("src/App.tsx"); + const opened = applyUpdates(layout, openTab(layout, tabId, false)); + const reopened = applyUpdates(opened, openTab(opened, tabId, false)); + + if (reopened.panelTree.type !== "leaf") return; + const occurrences = reopened.panelTree.content.tabs.filter( + (t) => t.id === tabId, + ); + expect(occurrences.length).toBe(1); + }); + }); + + describe("closeTab", () => { + it("removes the tab and selects a fallback", () => { + const layout = createInitialTaskLayout(); + const tabId = createFileTabId("src/App.tsx"); + const opened = applyUpdates(layout, openTab(layout, tabId, false)); + const closed = applyUpdates( + opened, + closeTab(opened, "main-panel", tabId), + ); + + expect(findTabInTree(closed.panelTree, tabId)).toBeNull(); + }); + }); + + describe("addRecentFile", () => { + it("dedupes and prepends, capping at the max", () => { + const result = addRecentFile(["b", "a"], "a"); + expect(result).toEqual(["a", "b"]); + }); + + it("caps at MAX_RECENT_FILES", () => { + const initial = Array.from({ length: 12 }, (_, i) => `f${i}`); + const result = addRecentFile(initial, "new"); + expect(result.length).toBe(10); + expect(result[0]).toBe("new"); + }); + }); +}); diff --git a/packages/core/src/panels/panelLayoutTransforms.ts b/packages/core/src/panels/panelLayoutTransforms.ts new file mode 100644 index 0000000000..97ddf8b7c4 --- /dev/null +++ b/packages/core/src/panels/panelLayoutTransforms.ts @@ -0,0 +1,654 @@ +import { DEFAULT_PANEL_IDS, DEFAULT_TAB_IDS } from "./panelConstants"; +import { + addNewTabToPanel, + applyCleanupWithFallback, + generatePanelId, + getLeafPanel, + getSplitConfig, + selectNextTabAfterClose, + updateMetadataForTab, +} from "./panelStoreHelpers"; +import { + addTabToPanel, + cleanupNode, + findTabInPanel, + findTabInTree, + removeTabFromPanel, + updateTreeNode, +} from "./panelTree"; +import type { PanelNode, SplitDirection, Tab, TaskLayout } from "./panelTypes"; + +export const MAX_RECENT_FILES = 10; + +export function createDefaultPanelTree(): PanelNode { + return { + type: "leaf", + id: DEFAULT_PANEL_IDS.MAIN_PANEL, + content: { + id: DEFAULT_PANEL_IDS.MAIN_PANEL, + tabs: [ + { + id: DEFAULT_TAB_IDS.LOGS, + label: "Chat", + data: { type: "logs" }, + component: null, + closeable: false, + draggable: true, + }, + { + id: DEFAULT_TAB_IDS.SHELL, + label: "Terminal", + data: { + type: "terminal", + terminalId: DEFAULT_TAB_IDS.SHELL, + cwd: "", + }, + component: null, + closeable: true, + draggable: true, + }, + ], + activeTabId: DEFAULT_TAB_IDS.LOGS, + showTabs: true, + droppable: true, + }, + }; +} + +export function createInitialTaskLayout(): TaskLayout { + return { + panelTree: createDefaultPanelTree(), + openFiles: [], + recentFiles: [], + draggingTabId: null, + draggingTabPanelId: null, + focusedPanelId: DEFAULT_PANEL_IDS.MAIN_PANEL, + }; +} + +export function openTab( + layout: TaskLayout, + tabId: string, + asPreview = true, + targetPanelId?: string, +): Partial { + const existingTab = findTabInTree(layout.panelTree, tabId); + + if (existingTab) { + const updatedTree = updateTreeNode( + layout.panelTree, + existingTab.panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + return { + ...panel, + content: { + ...panel.content, + tabs: asPreview + ? panel.content.tabs + : panel.content.tabs.map((tab) => + tab.id === tabId ? { ...tab, isPreview: false } : tab, + ), + activeTabId: tabId, + }, + }; + }, + ); + + return { panelTree: updatedTree }; + } + + const resolvedPanelId = + targetPanelId ?? layout.focusedPanelId ?? DEFAULT_PANEL_IDS.MAIN_PANEL; + let targetPanel = getLeafPanel(layout.panelTree, resolvedPanelId); + + if (!targetPanel) { + targetPanel = getLeafPanel(layout.panelTree, DEFAULT_PANEL_IDS.MAIN_PANEL); + } + if (!targetPanel) return {}; + + const panelId = targetPanel.id; + const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => + addNewTabToPanel(panel, tabId, true, asPreview), + ); + + const metadata = updateMetadataForTab(layout, tabId, "add"); + + return { + panelTree: updatedTree, + ...metadata, + }; +} + +export function findNonMainLeafPanel(node: PanelNode): PanelNode | null { + if (node.type === "leaf") { + return node.id !== DEFAULT_PANEL_IDS.MAIN_PANEL ? node : null; + } + if (node.type === "group") { + for (const child of node.children) { + const found = findNonMainLeafPanel(child); + if (found) return found; + } + } + return null; +} + +export function openTabInSplit( + layout: TaskLayout, + tabId: string, + asPreview = true, +): Partial { + const existingTab = findTabInTree(layout.panelTree, tabId); + + if (existingTab) { + const updatedTree = updateTreeNode( + layout.panelTree, + existingTab.panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + return { + ...panel, + content: { + ...panel.content, + tabs: asPreview + ? panel.content.tabs + : panel.content.tabs.map((tab) => + tab.id === tabId ? { ...tab, isPreview: false } : tab, + ), + activeTabId: tabId, + }, + }; + }, + ); + + return { panelTree: updatedTree }; + } + + const nonMainPanel = findNonMainLeafPanel(layout.panelTree); + + if (nonMainPanel) { + const updatedTree = updateTreeNode( + layout.panelTree, + nonMainPanel.id, + (panel) => addNewTabToPanel(panel, tabId, true, asPreview), + ); + + const metadata = updateMetadataForTab(layout, tabId, "add"); + return { panelTree: updatedTree, ...metadata }; + } + + const newPanelId = generatePanelId(); + const newPanel: PanelNode = { + type: "leaf", + id: newPanelId, + content: { + id: newPanelId, + tabs: [], + activeTabId: "", + showTabs: true, + droppable: true, + }, + }; + + const mainPanel = getLeafPanel( + layout.panelTree, + DEFAULT_PANEL_IDS.MAIN_PANEL, + ); + if (!mainPanel) return {}; + + const splitTree = updateTreeNode( + layout.panelTree, + DEFAULT_PANEL_IDS.MAIN_PANEL, + (panel) => ({ + type: "group" as const, + id: generatePanelId(), + direction: "horizontal" as const, + sizes: [50, 50], + children: [panel, newPanel], + }), + ); + + const finalTree = updateTreeNode(splitTree, newPanelId, (panel) => + addNewTabToPanel(panel, tabId, true, asPreview), + ); + + const metadata = updateMetadataForTab(layout, tabId, "add"); + return { panelTree: finalTree, focusedPanelId: newPanelId, ...metadata }; +} + +export function addRecentFile( + recentFiles: string[] | undefined, + filePath: string, +): string[] { + return [filePath, ...(recentFiles || []).filter((f) => f !== filePath)].slice( + 0, + MAX_RECENT_FILES, + ); +} + +export function keepTab(layout: TaskLayout, panelId: string, tabId: string) { + const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { + if (panel.type !== "leaf") return panel; + return { + ...panel, + content: { + ...panel.content, + tabs: panel.content.tabs.map((tab) => + tab.id === tabId ? { ...tab, isPreview: false } : tab, + ), + }, + }; + }); + return { panelTree: updatedTree }; +} + +export function closeTab( + layout: TaskLayout, + panelId: string, + tabId: string, +): Partial { + const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { + if (panel.type !== "leaf") return panel; + + const tabIndex = panel.content.tabs.findIndex((t) => t.id === tabId); + const remainingTabs = panel.content.tabs.filter((t) => t.id !== tabId); + + const newActiveTabId = selectNextTabAfterClose( + remainingTabs, + tabIndex, + panel.content.activeTabId, + tabId, + ); + + return { + ...panel, + content: { + ...panel.content, + tabs: remainingTabs, + activeTabId: newActiveTabId, + }, + }; + }); + + const cleanedTree = applyCleanupWithFallback( + cleanupNode(updatedTree), + layout.panelTree, + ); + const metadata = updateMetadataForTab(layout, tabId, "remove"); + + return { + panelTree: cleanedTree, + ...metadata, + }; +} + +export function closeOtherTabs( + layout: TaskLayout, + panelId: string, + tabId: string, +): Partial { + const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { + if (panel.type !== "leaf") return panel; + + const remainingTabs = panel.content.tabs.filter( + (t) => t.id === tabId || t.closeable === false, + ); + + return { + ...panel, + content: { + ...panel.content, + tabs: remainingTabs, + activeTabId: tabId, + }, + }; + }); + + return { panelTree: updatedTree }; +} + +export function closeTabsToRight( + layout: TaskLayout, + panelId: string, + tabId: string, +): Partial { + const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { + if (panel.type !== "leaf") return panel; + + const tabIndex = panel.content.tabs.findIndex((t) => t.id === tabId); + if (tabIndex === -1) return panel; + + const remainingTabs = panel.content.tabs.filter( + (t, index) => index <= tabIndex || t.closeable === false, + ); + + return { + ...panel, + content: { + ...panel.content, + tabs: remainingTabs, + activeTabId: tabId, + }, + }; + }); + + return { panelTree: updatedTree }; +} + +export function reorderTabs( + layout: TaskLayout, + panelId: string, + sourceIndex: number, + targetIndex: number, +): Partial { + const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { + if (panel.type !== "leaf") return panel; + + const tabs = [...panel.content.tabs]; + const [removed] = tabs.splice(sourceIndex, 1); + tabs.splice(targetIndex, 0, removed); + + return { + ...panel, + content: { + ...panel.content, + tabs, + }, + }; + }); + + return { panelTree: updatedTree }; +} + +export function moveTab( + layout: TaskLayout, + tabId: string, + sourcePanelId: string, + targetPanelId: string, +): Partial { + const sourcePanel = getLeafPanel(layout.panelTree, sourcePanelId); + if (!sourcePanel) return {}; + + const tab = findTabInPanel(sourcePanel, tabId); + if (!tab) return {}; + + const treeAfterRemove = updateTreeNode( + layout.panelTree, + sourcePanelId, + (panel) => removeTabFromPanel(panel, tabId), + ); + + const treeAfterAdd = updateTreeNode(treeAfterRemove, targetPanelId, (panel) => + addTabToPanel(panel, tab), + ); + + const cleanedTree = applyCleanupWithFallback( + cleanupNode(treeAfterAdd), + layout.panelTree, + ); + + const focusedPanelId = + layout.focusedPanelId === sourcePanelId + ? targetPanelId + : layout.focusedPanelId; + + return { panelTree: cleanedTree, focusedPanelId }; +} + +export function splitPanelTree( + layout: TaskLayout, + tabId: string, + sourcePanelId: string, + targetPanelId: string, + direction: SplitDirection, +): Partial { + const sourcePanel = getLeafPanel(layout.panelTree, sourcePanelId); + if (!sourcePanel) return {}; + + const targetPanel = getLeafPanel(layout.panelTree, targetPanelId); + if (!targetPanel) return {}; + + const tab = findTabInPanel(sourcePanel, tabId); + if (!tab) return {}; + + if (sourcePanelId === targetPanelId && targetPanel.content.tabs.length <= 1) { + const singleTabConfig = getSplitConfig(direction); + const newPanelId = generatePanelId(); + const terminalTabId = `shell-${Date.now()}`; + const newPanel: PanelNode = { + type: "leaf", + id: newPanelId, + content: { + id: newPanelId, + tabs: [ + { + id: terminalTabId, + label: "Terminal", + data: { + type: "terminal", + terminalId: terminalTabId, + cwd: "", + }, + component: null, + draggable: true, + closeable: true, + }, + ], + activeTabId: terminalTabId, + showTabs: true, + droppable: true, + }, + }; + + const updatedTree = updateTreeNode( + layout.panelTree, + targetPanelId, + (panel) => ({ + type: "group" as const, + id: generatePanelId(), + direction: singleTabConfig.splitDirection, + sizes: [50, 50], + children: singleTabConfig.isAfter + ? [panel, newPanel] + : [newPanel, panel], + }), + ); + + return { panelTree: updatedTree, focusedPanelId: newPanelId }; + } + + const config = getSplitConfig(direction); + const newPanelId = generatePanelId(); + const newPanel: PanelNode = { + type: "leaf", + id: newPanelId, + content: { + id: newPanelId, + tabs: [tab], + activeTabId: tab.id, + showTabs: true, + droppable: true, + }, + }; + + const treeAfterRemove = updateTreeNode( + layout.panelTree, + sourcePanelId, + (panel) => removeTabFromPanel(panel, tabId), + ); + + const updatedTree = updateTreeNode( + treeAfterRemove, + targetPanelId, + (panel) => { + const newGroup: PanelNode = { + type: "group", + id: generatePanelId(), + direction: config.splitDirection, + sizes: [50, 50], + children: config.isAfter ? [panel, newPanel] : [newPanel, panel], + }; + return newGroup; + }, + ); + + const cleanedTree = applyCleanupWithFallback( + cleanupNode(updatedTree), + layout.panelTree, + ); + + return { panelTree: cleanedTree }; +} + +export function updateSizes( + layout: TaskLayout, + groupId: string, + sizes: number[], +): Partial { + const updatedTree = updateTreeNode(layout.panelTree, groupId, (node) => { + if (node.type !== "group") return node; + return { ...node, sizes }; + }); + + return { panelTree: updatedTree }; +} + +export function updateTabMetadata( + layout: TaskLayout, + tabId: string, + metadata: Partial>, +): Partial { + const tabLocation = findTabInTree(layout.panelTree, tabId); + if (!tabLocation) return {}; + + const updatedTree = updateTreeNode( + layout.panelTree, + tabLocation.panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + + const updatedTabs = panel.content.tabs.map((tab) => + tab.id === tabId ? { ...tab, ...metadata } : tab, + ); + + return { + ...panel, + content: { + ...panel.content, + tabs: updatedTabs, + }, + }; + }, + ); + + return { panelTree: updatedTree }; +} + +export function updateTabLabel( + layout: TaskLayout, + tabId: string, + label: string, +): Partial { + const tabLocation = findTabInTree(layout.panelTree, tabId); + if (!tabLocation) return {}; + + const updatedTree = updateTreeNode( + layout.panelTree, + tabLocation.panelId, + (panel) => { + if (panel.type !== "leaf") return panel; + + const updatedTabs = panel.content.tabs.map((tab) => + tab.id === tabId ? { ...tab, label } : tab, + ); + + return { + ...panel, + content: { + ...panel.content, + tabs: updatedTabs, + }, + }; + }, + ); + + return { panelTree: updatedTree }; +} + +export function setActiveTab( + layout: TaskLayout, + panelId: string, + tabId: string, +): Partial { + const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { + if (panel.type !== "leaf") return panel; + return { + ...panel, + content: { ...panel.content, activeTabId: tabId }, + }; + }); + + return { panelTree: updatedTree }; +} + +export function addTerminalTab( + layout: TaskLayout, + panelId: string, +): Partial { + const tabId = `shell-${Date.now()}`; + const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { + if (panel.type !== "leaf") return panel; + return addTabToPanel(panel, { + id: tabId, + label: "Terminal", + data: { type: "terminal", terminalId: tabId, cwd: "" }, + component: null, + draggable: true, + closeable: true, + }); + }); + + return { panelTree: updatedTree }; +} + +export function addActionTab( + layout: TaskLayout, + panelId: string, + action: { actionId: string; command: string; cwd: string; label: string }, +): Partial { + const tabId = `action-${action.actionId}`; + const existingTab = findTabInTree(layout.panelTree, tabId); + if (existingTab) return {}; + + const targetPanel = getLeafPanel(layout.panelTree, panelId); + if (!targetPanel) return {}; + + const updatedTree = updateTreeNode(layout.panelTree, panelId, (panel) => { + if (panel.type !== "leaf") return panel; + + const newTab: Tab = { + id: tabId, + label: action.label, + data: { + type: "action", + actionId: action.actionId, + command: action.command, + cwd: action.cwd, + label: action.label, + }, + component: null, + draggable: true, + closeable: true, + }; + + return { + ...panel, + content: { + ...panel.content, + tabs: [...panel.content.tabs, newTab], + }, + }; + }); + + return { panelTree: updatedTree }; +} diff --git a/apps/code/src/renderer/features/panels/store/panelUtils.ts b/packages/core/src/panels/panelSizeMath.ts similarity index 75% rename from apps/code/src/renderer/features/panels/store/panelUtils.ts rename to packages/core/src/panels/panelSizeMath.ts index e953cbb729..0a6bd44b13 100644 --- a/apps/code/src/renderer/features/panels/store/panelUtils.ts +++ b/packages/core/src/panels/panelSizeMath.ts @@ -1,3 +1,6 @@ +import { PANEL_SIZES } from "./panelConstants"; +import type { GroupPanel } from "./panelTypes"; + const MIN_PANEL_SIZE = 15; export const normalizeSizes = ( @@ -52,3 +55,21 @@ export const redistributeSizes = ( return normalizeSizes(redistributed, redistributed.length); }; + +export function calculateDefaultSize(node: GroupPanel, index: number): number { + return node.sizes?.[index] ?? 100 / node.children.length; +} + +export function shouldUpdateSizes( + currentSizes: number[], + storeSizes: number[], +): boolean { + if (currentSizes.length !== storeSizes.length) { + return false; + } + + return currentSizes.some( + (size, i) => + Math.abs(size - storeSizes[i]) > PANEL_SIZES.SIZE_DIFF_THRESHOLD, + ); +} diff --git a/apps/code/src/renderer/features/panels/store/panelStoreHelpers.ts b/packages/core/src/panels/panelStoreHelpers.ts similarity index 80% rename from apps/code/src/renderer/features/panels/store/panelStoreHelpers.ts rename to packages/core/src/panels/panelStoreHelpers.ts index 3b953ddfb4..2b41453240 100644 --- a/apps/code/src/renderer/features/panels/store/panelStoreHelpers.ts +++ b/packages/core/src/panels/panelStoreHelpers.ts @@ -1,11 +1,15 @@ -import { DEFAULT_TAB_IDS } from "../constants/panelConstants"; -import type { SplitDirection, TaskLayout } from "./panelLayoutStore"; -import type { GroupPanel, LeafPanel, PanelNode, Tab } from "./panelTypes"; +import { DEFAULT_TAB_IDS } from "./panelConstants"; +import type { + GroupPanel, + LeafPanel, + PanelNode, + SplitDirection, + Tab, + TaskLayout, +} from "./panelTypes"; -// Constants export const DEFAULT_FALLBACK_TAB = DEFAULT_TAB_IDS.LOGS; -// Tab ID utilities export type TabType = "file" | "system"; export interface ParsedTabId { @@ -32,7 +36,6 @@ export function createTabLabel(tabId: string): string { return parsed.value; } -// Panel finding utilities export function findPanelById( node: PanelNode, panelId: string, @@ -67,7 +70,6 @@ export function getGroupPanel( return panel?.type === "group" ? panel : null; } -// Panel ID generation let nextPanelId = 1; export function generatePanelId(): string { @@ -78,29 +80,6 @@ export function resetPanelIdCounter(): void { nextPanelId = 1; } -// State update wrapper -export function updateTaskLayout( - state: { taskLayouts: Record }, - taskId: string, - updater: (layout: TaskLayout) => Partial, -): { taskLayouts: Record } { - const layout = state.taskLayouts[taskId]; - if (!layout) return state; - - const updates = updater(layout); - - return { - taskLayouts: { - ...state.taskLayouts, - [taskId]: { - ...layout, - ...updates, - }, - }, - }; -} - -// Tree update helpers export function createNewTab( tabId: string, closeable = true, @@ -109,14 +88,13 @@ export function createNewTab( const parsed = parseTabId(tabId); let data: Tab["data"]; - // Build typed data based on tab type switch (parsed.type) { case "file": data = { type: "file", relativePath: parsed.value, - absolutePath: "", // Will be populated by tab injection - repoPath: "", // Will be populated by tab injection + absolutePath: "", + repoPath: "", }; break; case "system": @@ -155,7 +133,6 @@ export function addNewTabToPanel( ): PanelNode { if (panel.type !== "leaf") return panel; - // If opening as preview, remove any existing preview tab first const tabs = isPreview ? panel.content.tabs.filter((tab) => !tab.isPreview) : panel.content.tabs; @@ -188,7 +165,6 @@ export function selectNextTabAfterClose( return tabs[nextIndex].id; } -// Split direction utilities export interface SplitConfig { splitDirection: "horizontal" | "vertical"; isAfter: boolean; @@ -206,7 +182,6 @@ export function getSplitConfig(direction: SplitDirection): SplitConfig { }; } -// Metadata tracking utilities export function updateMetadataForTab( layout: TaskLayout, tabId: string, @@ -225,7 +200,6 @@ export function updateMetadataForTab( return { openFiles: layout.openFiles }; } -// Cleanup utilities export function applyCleanupWithFallback( cleanedTree: PanelNode | null, originalTree: PanelNode, @@ -233,7 +207,6 @@ export function applyCleanupWithFallback( return cleanedTree || originalTree; } -// Tab active state utilities export function isTabActiveInTree(tree: PanelNode, tabId: string): boolean { if (tree.type === "leaf") { return tree.content.activeTabId === tabId; diff --git a/apps/code/src/renderer/features/panels/store/panelTree.ts b/packages/core/src/panels/panelTree.ts similarity index 83% rename from apps/code/src/renderer/features/panels/store/panelTree.ts rename to packages/core/src/panels/panelTree.ts index 889a60dc11..ce790737bb 100644 --- a/apps/code/src/renderer/features/panels/store/panelTree.ts +++ b/packages/core/src/panels/panelTree.ts @@ -1,5 +1,5 @@ +import { normalizeSizes, redistributeSizes } from "./panelSizeMath"; import type { PanelNode, Tab } from "./panelTypes"; -import { normalizeSizes, redistributeSizes } from "./panelUtils"; const isLeafNode = ( node: PanelNode | null, @@ -149,33 +149,24 @@ export const cleanupNode = (node: PanelNode): PanelNode | null => { }; }; -/** - * Merges new tree content (components) with existing tree layout (structure, sizes, active tabs). - * This allows updating component props while preserving user layout modifications. - * Returns the same reference if no changes were made to prevent unnecessary re-renders. - */ export const mergeTreeContent = ( existingTree: PanelNode, newTree: PanelNode, ): PanelNode => { - // If types don't match, prefer the existing layout structure if (existingTree.type !== newTree.type) { return existingTree; } if (isLeafNode(existingTree) && isLeafNode(newTree)) { - // Create a map of new tabs by ID for quick lookup const newTabsMap = new Map( newTree.content.tabs.map((tab) => [tab.id, tab]), ); const existingTabIds = new Set(existingTree.content.tabs.map((t) => t.id)); - // Update existing tabs with new components if they exist in new tree const updatedTabs = existingTree.content.tabs .map((existingTab) => { const newTab = newTabsMap.get(existingTab.id); if (newTab) { - // Always update component and callbacks (they contain new task data) return { ...existingTab, component: newTab.component, @@ -187,24 +178,20 @@ export const mergeTreeContent = ( } return existingTab; }) - .filter((tab) => newTabsMap.has(tab.id)); // Remove tabs not in new tree + .filter((tab) => newTabsMap.has(tab.id)); - // Add new tabs that don't exist in existing tree const newTabsToAdd = newTree.content.tabs.filter( (tab) => !existingTabIds.has(tab.id), ); const finalTabs = [...updatedTabs, ...newTabsToAdd]; - // Preserve the active tab if it still exists, otherwise use first tab const activeTabId = finalTabs.some( (t) => t.id === existingTree.content.activeTabId, ) ? existingTree.content.activeTabId : finalTabs[0]?.id || ""; - // Always return a new node because React components need to update - // (components contain new task data even if tab structure is the same) return { ...existingTree, content: { @@ -216,8 +203,6 @@ export const mergeTreeContent = ( } if (isGroupNode(existingTree) && isGroupNode(newTree)) { - // Recursively merge children - // Match children by index (assumes same structure) const mergedChildren = existingTree.children.map((existingChild, index) => { const newChild = newTree.children[index]; if (newChild) { @@ -226,7 +211,6 @@ export const mergeTreeContent = ( return existingChild; }); - // Check if any children actually changed const childrenChanged = mergedChildren.some( (child, index) => child !== existingTree.children[index], ); @@ -238,7 +222,6 @@ export const mergeTreeContent = ( return { ...existingTree, children: mergedChildren, - // Preserve existing sizes and direction }; } diff --git a/apps/code/src/renderer/features/panels/store/panelTypes.ts b/packages/core/src/panels/panelTypes.ts similarity index 80% rename from apps/code/src/renderer/features/panels/store/panelTypes.ts rename to packages/core/src/panels/panelTypes.ts index d50c9e9f43..88e9f2798c 100644 --- a/apps/code/src/renderer/features/panels/store/panelTypes.ts +++ b/packages/core/src/panels/panelTypes.ts @@ -2,10 +2,6 @@ export type PanelId = string; export type TabId = string; export type GroupId = string; -/** - * Discriminated union for tab-specific data - * Each tab type can carry its own typed data - */ export type TabData = | { type: "file"; @@ -35,18 +31,20 @@ export type TabData = type: "other"; }; +export type TabRender = unknown; + export type Tab = { id: TabId; label: string; data: TabData; - component?: React.ReactNode; + component?: TabRender; closeable?: boolean; draggable?: boolean; onClose?: () => void; onSelect?: () => void; - icon?: React.ReactNode; + icon?: TabRender; hasUnsavedChanges?: boolean; - badge?: React.ReactNode; + badge?: TabRender; isPreview?: boolean; }; @@ -76,3 +74,12 @@ export type GroupPanel = { export type PanelNode = LeafPanel | GroupPanel; export type SplitDirection = "top" | "bottom" | "left" | "right"; + +export interface TaskLayout { + panelTree: PanelNode; + openFiles: string[]; + recentFiles: string[]; + draggingTabId: string | null; + draggingTabPanelId: string | null; + focusedPanelId: string | null; +} diff --git a/packages/core/src/panels/resolveTabPath.ts b/packages/core/src/panels/resolveTabPath.ts new file mode 100644 index 0000000000..89b2778c08 --- /dev/null +++ b/packages/core/src/panels/resolveTabPath.ts @@ -0,0 +1,11 @@ +import { isAbsolutePath } from "@posthog/shared"; + +export function resolveTabAbsolutePath( + relativePath: string, + repoPath: string, +): string { + if (isAbsolutePath(relativePath)) { + return relativePath; + } + return repoPath ? `${repoPath}/${relativePath}` : relativePath; +} diff --git a/packages/core/src/panels/resolveWorkspaceForRepoPath.ts b/packages/core/src/panels/resolveWorkspaceForRepoPath.ts new file mode 100644 index 0000000000..5079e3ab3c --- /dev/null +++ b/packages/core/src/panels/resolveWorkspaceForRepoPath.ts @@ -0,0 +1,18 @@ +interface RepoPathCandidate { + worktreePath?: string | null; + folderPath?: string | null; +} + +export function resolveWorkspaceForRepoPath( + workspaces: Record, + repoPath: string | undefined, +): T | null { + if (!repoPath) return null; + + return ( + Object.values(workspaces).find( + (ws): ws is T => + !!ws && (ws.worktreePath === repoPath || ws.folderPath === repoPath), + ) ?? null + ); +} diff --git a/packages/core/src/provisioning/identifiers.ts b/packages/core/src/provisioning/identifiers.ts new file mode 100644 index 0000000000..be519414b1 --- /dev/null +++ b/packages/core/src/provisioning/identifiers.ts @@ -0,0 +1,3 @@ +export const PROVISIONING_SERVICE = Symbol.for( + "posthog.core.provisioningService", +); diff --git a/packages/core/src/provisioning/output.test.ts b/packages/core/src/provisioning/output.test.ts new file mode 100644 index 0000000000..6269ccc248 --- /dev/null +++ b/packages/core/src/provisioning/output.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { appendOutputChunk, stripAnsi } from "./output"; + +describe("stripAnsi", () => { + it("removes ANSI color escape sequences", () => { + expect(stripAnsi("\x1b[31mred\x1b[0m")).toBe("red"); + }); + + it("leaves plain text untouched", () => { + expect(stripAnsi("plain")).toBe("plain"); + }); +}); + +describe("appendOutputChunk", () => { + it("appends a plain newline-delimited chunk as new lines", () => { + expect(appendOutputChunk([], "a\nb")).toEqual(["a", "b"]); + }); + + it("continues onto the existing last line when no newline boundary", () => { + expect(appendOutputChunk(["foo"], "bar")).toEqual(["foobar"]); + }); + + it("overwrites the current line on carriage return", () => { + expect(appendOutputChunk(["old"], "\rnew")).toEqual(["new"]); + }); + + it("appends a fresh line when carriage return has no prior segment", () => { + expect(appendOutputChunk([], "\rfresh")).toEqual(["fresh"]); + }); + + it("strips ANSI sequences before processing", () => { + expect(appendOutputChunk([], "\x1b[32mgreen\x1b[0m")).toEqual(["green"]); + }); + + it("keeps only the last segment after a carriage return overwrite within a part", () => { + expect(appendOutputChunk([], "first\rsecond")).toEqual(["second"]); + }); +}); diff --git a/packages/core/src/provisioning/output.ts b/packages/core/src/provisioning/output.ts new file mode 100644 index 0000000000..67b1deffa4 --- /dev/null +++ b/packages/core/src/provisioning/output.ts @@ -0,0 +1,33 @@ +// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is required to strip ANSI sequences +const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g; + +export function stripAnsi(text: string): string { + return text.replace(ANSI_RE, ""); +} + +function processOutput(lines: string[], chunk: string): string[] { + const next = [...lines]; + const parts = chunk.split("\n"); + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const crSegments = part.split("\r"); + const lastSegment = crSegments[crSegments.length - 1]; + + if (i === 0 && next.length > 0) { + if (crSegments.length > 1) { + next[next.length - 1] = lastSegment; + } else { + next[next.length - 1] += lastSegment; + } + } else { + next.push(lastSegment); + } + } + + return next; +} + +export function appendOutputChunk(lines: string[], rawChunk: string): string[] { + return processOutput(lines, stripAnsi(rawChunk)); +} diff --git a/packages/core/src/provisioning/provisioning.test.ts b/packages/core/src/provisioning/provisioning.test.ts new file mode 100644 index 0000000000..c2cec42f35 --- /dev/null +++ b/packages/core/src/provisioning/provisioning.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { ProvisioningEvent, ProvisioningService } from "./provisioning"; + +describe("ProvisioningService", () => { + it("emits an Output event carrying the task id and data", () => { + const service = new ProvisioningService(); + const listener = vi.fn(); + service.on(ProvisioningEvent.Output, listener); + + service.emitOutput("task-1", "hello world"); + + expect(listener).toHaveBeenCalledWith({ + taskId: "task-1", + data: "hello world", + }); + }); + + it("emits one event per emitOutput call", () => { + const service = new ProvisioningService(); + const listener = vi.fn(); + service.on(ProvisioningEvent.Output, listener); + + service.emitOutput("task-1", "a"); + service.emitOutput("task-1", "b"); + + expect(listener).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/code/src/main/services/provisioning/service.ts b/packages/core/src/provisioning/provisioning.ts similarity index 88% rename from apps/code/src/main/services/provisioning/service.ts rename to packages/core/src/provisioning/provisioning.ts index 67d2e0c804..1e1624acc2 100644 --- a/apps/code/src/main/services/provisioning/service.ts +++ b/packages/core/src/provisioning/provisioning.ts @@ -1,5 +1,5 @@ +import { TypedEventEmitter } from "@posthog/shared"; import { injectable } from "inversify"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; export const ProvisioningEvent = { Output: "output", diff --git a/packages/core/src/secure-store/identifiers.ts b/packages/core/src/secure-store/identifiers.ts new file mode 100644 index 0000000000..24017161c9 --- /dev/null +++ b/packages/core/src/secure-store/identifiers.ts @@ -0,0 +1,24 @@ +export interface SecureStoreBackend { + has(key: string): boolean; + get(key: string): unknown; + set(key: string, value: string): void; + delete(key: string): void; + clear(): void; +} + +export interface SecureStoreLogger { + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; + debug(message: string, ...args: unknown[]): void; +} + +export const SECURE_STORE_BACKEND = Symbol.for( + "posthog.core.secureStoreBackend", +); + +export const SECURE_STORE_LOGGER = Symbol.for("posthog.core.secureStoreLogger"); + +export const SECURE_STORE_SERVICE = Symbol.for( + "posthog.core.secureStoreService", +); diff --git a/packages/core/src/secure-store/schemas.ts b/packages/core/src/secure-store/schemas.ts new file mode 100644 index 0000000000..42f1811fe0 --- /dev/null +++ b/packages/core/src/secure-store/schemas.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const secureStoreGetInput = z.object({ key: z.string() }); +export const secureStoreSetInput = z.object({ + key: z.string(), + value: z.string(), +}); +export const secureStoreRemoveInput = z.object({ key: z.string() }); + +export type SecureStoreGetInput = z.infer; +export type SecureStoreSetInput = z.infer; +export type SecureStoreRemoveInput = z.infer; diff --git a/packages/core/src/sessions/acpNotifications.ts b/packages/core/src/sessions/acpNotifications.ts new file mode 100644 index 0000000000..c04721865e --- /dev/null +++ b/packages/core/src/sessions/acpNotifications.ts @@ -0,0 +1,36 @@ +export const POSTHOG_NOTIFICATIONS = { + BRANCH_CREATED: "_posthog/branch_created", + RUN_STARTED: "_posthog/run_started", + TASK_COMPLETE: "_posthog/task_complete", + TURN_COMPLETE: "_posthog/turn_complete", + ERROR: "_posthog/error", + CONSOLE: "_posthog/console", + SDK_SESSION: "_posthog/sdk_session", + GIT_CHECKPOINT: "_posthog/git_checkpoint", + MODE_CHANGE: "_posthog/mode_change", + SESSION_RESUME: "_posthog/session/resume", + USER_MESSAGE: "_posthog/user_message", + CANCEL: "_posthog/cancel", + CLOSE: "_posthog/close", + STATUS: "_posthog/status", + PROGRESS: "_posthog/progress", + TASK_NOTIFICATION: "_posthog/task_notification", + COMPACT_BOUNDARY: "_posthog/compact_boundary", + USAGE_UPDATE: "_posthog/usage_update", + PERMISSION_RESPONSE: "_posthog/permission_response", +} as const; + +type PosthogNotification = + (typeof POSTHOG_NOTIFICATIONS)[keyof typeof POSTHOG_NOTIFICATIONS]; + +function matchesExt(method: string | undefined, expected: string): boolean { + if (!method) return false; + return method === expected || method === `_${expected}`; +} + +export function isNotification( + method: string | undefined, + expected: PosthogNotification, +): boolean { + return matchesExt(method, expected); +} diff --git a/packages/core/src/sessions/chatTitle.test.ts b/packages/core/src/sessions/chatTitle.test.ts new file mode 100644 index 0000000000..311e062861 --- /dev/null +++ b/packages/core/src/sessions/chatTitle.test.ts @@ -0,0 +1,115 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + decideTitleGeneration, + formatPromptsForTitleInput, + getFallbackTaskTitle, + isAutoTitleLocked, + isPlaceholderTaskTitle, + REGENERATE_INTERVAL, + selectPromptsForTitle, +} from "./chatTitle"; + +function task(overrides: Partial): Task { + return { + title: "Fix login", + description: "Fix login", + title_manually_set: false, + ...overrides, + } as Task; +} + +describe("isPlaceholderTaskTitle", () => { + it("treats an empty title as a placeholder", () => { + expect(isPlaceholderTaskTitle({ title: " ", description: "x" })).toBe( + true, + ); + }); + + it("treats a title equal to the description fallback as a placeholder", () => { + expect( + isPlaceholderTaskTitle({ title: "Fix login", description: "Fix login" }), + ).toBe(true); + }); + + it("treats a custom title as not a placeholder", () => { + expect( + isPlaceholderTaskTitle({ title: "Custom", description: "Fix login" }), + ).toBe(false); + }); +}); + +describe("isAutoTitleLocked", () => { + it("is false when the title was not manually set", () => { + expect(isAutoTitleLocked(task({ title_manually_set: false }))).toBe(false); + }); + + it("is false when manually set but the title still matches the fallback", () => { + expect( + isAutoTitleLocked(task({ title_manually_set: true, title: "Fix login" })), + ).toBe(false); + }); + + it("is true when manually set to a custom title", () => { + expect( + isAutoTitleLocked(task({ title_manually_set: true, title: "Custom" })), + ).toBe(true); + }); +}); + +describe("getFallbackTaskTitle", () => { + it("falls back to Untitled when the description is empty", () => { + expect(getFallbackTaskTitle(" ")).toBe("Untitled"); + }); +}); + +describe("decideTitleGeneration", () => { + it("generates from the first prompt", () => { + const decision = decideTitleGeneration({ + promptCount: 1, + lastGeneratedAtCount: 0, + initialDescriptionHandled: false, + task: { title: "Custom", description: "d" }, + }); + expect(decision.shouldGenerateFromPrompts).toBe(true); + }); + + it("regenerates every REGENERATE_INTERVAL prompts", () => { + const decision = decideTitleGeneration({ + promptCount: 1 + REGENERATE_INTERVAL, + lastGeneratedAtCount: 1, + initialDescriptionHandled: false, + task: { title: "Custom", description: "d" }, + }); + expect(decision.shouldGenerateFromPrompts).toBe(true); + }); + + it("generates from a placeholder task description before any prompt", () => { + const decision = decideTitleGeneration({ + promptCount: 0, + lastGeneratedAtCount: 0, + initialDescriptionHandled: false, + task: { title: "Fix login", description: "Fix login" }, + }); + expect(decision.shouldGenerateFromTaskDescription).toBe(true); + }); +}); + +describe("selectPromptsForTitle", () => { + it("returns all prompts on the first prompt", () => { + expect(selectPromptsForTitle(["a"], 1)).toEqual(["a"]); + }); + + it("returns the last REGENERATE_INTERVAL prompts otherwise", () => { + const prompts = Array.from({ length: 10 }, (_, i) => `p${i}`); + expect(selectPromptsForTitle(prompts, 10)).toHaveLength( + REGENERATE_INTERVAL, + ); + }); +}); + +describe("formatPromptsForTitleInput", () => { + it("numbers prompts from one", () => { + expect(formatPromptsForTitleInput(["a", "b"])).toBe("1. a\n2. b"); + }); +}); diff --git a/packages/core/src/sessions/chatTitle.ts b/packages/core/src/sessions/chatTitle.ts new file mode 100644 index 0000000000..0693206449 --- /dev/null +++ b/packages/core/src/sessions/chatTitle.ts @@ -0,0 +1,69 @@ +import { xmlToPlainText } from "@posthog/core/message-editor/content"; +import type { Task } from "@posthog/shared/domain-types"; + +export const REGENERATE_INTERVAL = 7; + +export function getFallbackTaskTitle(description: string): string { + const plainText = xmlToPlainText(description).trim(); + return (plainText || "Untitled").slice(0, 255); +} + +export function isPlaceholderTaskTitle( + task: Pick, +): boolean { + if (task.title.trim().length === 0) { + return true; + } + + const fallbackTitle = getFallbackTaskTitle(task.description); + return task.title === fallbackTitle; +} + +export function isAutoTitleLocked(task: Task | undefined): boolean { + if (!task?.title_manually_set) { + return false; + } + + return !isPlaceholderTaskTitle(task); +} + +export interface TitleGenerationDecision { + shouldGenerateFromPrompts: boolean; + shouldGenerateFromTaskDescription: boolean; +} + +export function decideTitleGeneration(input: { + promptCount: number; + lastGeneratedAtCount: number; + initialDescriptionHandled: boolean; + task: Pick; +}): TitleGenerationDecision { + const { promptCount, lastGeneratedAtCount, initialDescriptionHandled, task } = + input; + + const shouldGenerateFromPrompts = + (promptCount === 1 && lastGeneratedAtCount === 0) || + (promptCount > 1 && + promptCount - lastGeneratedAtCount >= REGENERATE_INTERVAL); + + const shouldGenerateFromTaskDescription = + promptCount === 0 && + !initialDescriptionHandled && + task.description.trim().length > 0 && + isPlaceholderTaskTitle(task); + + return { shouldGenerateFromPrompts, shouldGenerateFromTaskDescription }; +} + +export function selectPromptsForTitle( + prompts: string[], + promptCount: number, +): string[] { + const promptsForTitle = + promptCount === 1 ? prompts : prompts.slice(-REGENERATE_INTERVAL); + return promptsForTitle; +} + +export function formatPromptsForTitleInput(prompts: string[]): string { + return prompts.map((p, i) => `${i + 1}. ${p}`).join("\n"); +} diff --git a/packages/core/src/sessions/cloudArtifactIdentifiers.ts b/packages/core/src/sessions/cloudArtifactIdentifiers.ts new file mode 100644 index 0000000000..c2c8649341 --- /dev/null +++ b/packages/core/src/sessions/cloudArtifactIdentifiers.ts @@ -0,0 +1,49 @@ +export interface CloudArtifactUploadRequest { + name: string; + type: "user_attachment"; + size: number; + content_type?: string; + source?: string; +} + +export interface CloudArtifactPresignedPost { + url: string; + fields: Record; +} + +export interface PreparedCloudArtifact extends CloudArtifactUploadRequest { + id: string; + presigned_post: CloudArtifactPresignedPost; +} + +export interface FinalizedCloudArtifact { + id: string; +} + +export interface CloudArtifactClient { + prepareTaskStagedArtifactUploads( + taskId: string, + artifacts: CloudArtifactUploadRequest[], + ): Promise; + finalizeTaskStagedArtifactUploads( + taskId: string, + artifacts: PreparedCloudArtifact[], + ): Promise; + prepareTaskRunArtifactUploads( + taskId: string, + runId: string, + artifacts: CloudArtifactUploadRequest[], + ): Promise; + finalizeTaskRunArtifactUploads( + taskId: string, + runId: string, + artifacts: PreparedCloudArtifact[], + ): Promise; +} + +export const CLOUD_ARTIFACT_SERVICE = Symbol.for( + "posthog.core.sessions.cloudArtifactService", +); +export const CLOUD_ARTIFACT_READ_FILE_AS_BASE64 = Symbol.for( + "posthog.core.sessions.cloudArtifactReadFileAsBase64", +); diff --git a/packages/core/src/sessions/cloudArtifactService.test.ts b/packages/core/src/sessions/cloudArtifactService.test.ts new file mode 100644 index 0000000000..53c3a67bb6 --- /dev/null +++ b/packages/core/src/sessions/cloudArtifactService.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CloudArtifactClient } from "./cloudArtifactIdentifiers"; +import { + CLOUD_ATTACHMENT_MAX_SIZE_BYTES, + CLOUD_PDF_ATTACHMENT_MAX_SIZE_BYTES, + CloudArtifactService, +} from "./cloudArtifactService"; + +function makeClient(): CloudArtifactClient { + return { + prepareTaskStagedArtifactUploads: vi.fn(), + finalizeTaskStagedArtifactUploads: vi.fn(), + prepareTaskRunArtifactUploads: vi.fn(), + finalizeTaskRunArtifactUploads: vi.fn(), + }; +} + +describe("CloudArtifactService", () => { + it("returns empty ids when no file paths are provided", async () => { + const service = new CloudArtifactService(vi.fn()); + expect( + await service.uploadRunAttachments(makeClient(), "t", "r", []), + ).toEqual([]); + }); + + it("rejects attachments that exceed the max size", async () => { + const oversized = CLOUD_ATTACHMENT_MAX_SIZE_BYTES + 1; + const base64 = btoa("a".repeat(oversized)); + const service = new CloudArtifactService(vi.fn().mockResolvedValue(base64)); + + await expect( + service.uploadRunAttachments(makeClient(), "task-1", "run-1", [ + "/tmp/huge.bin", + ]), + ).rejects.toThrow(/exceeds the 30MB attachment limit/); + }); + + it("rejects PDFs that exceed the stricter cloud limit", async () => { + const oversized = CLOUD_PDF_ATTACHMENT_MAX_SIZE_BYTES + 1; + const base64 = btoa("a".repeat(oversized)); + const service = new CloudArtifactService(vi.fn().mockResolvedValue(base64)); + + await expect( + service.uploadRunAttachments(makeClient(), "task-1", "run-1", [ + "/tmp/large.pdf", + ]), + ).rejects.toThrow( + /exceeds the 10MB attachment limit for PDFs in cloud runs/, + ); + }); + + it("throws when a file cannot be read", async () => { + const service = new CloudArtifactService(vi.fn().mockResolvedValue(null)); + + await expect( + service.uploadRunAttachments(makeClient(), "task-1", "run-1", [ + "/tmp/missing.txt", + ]), + ).rejects.toThrow(/Unable to read attached file missing\.txt/); + }); + + it("runs prepare, POST, finalize and tallies the artifact ids", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue({ ok: true } as Response); + const base64 = btoa("hello"); + const service = new CloudArtifactService(vi.fn().mockResolvedValue(base64)); + + const client = makeClient(); + ( + client.prepareTaskRunArtifactUploads as ReturnType + ).mockResolvedValue([ + { + id: "prep-1", + name: "a.txt", + type: "user_attachment", + size: 5, + presigned_post: { url: "https://s3/upload", fields: { key: "k" } }, + }, + ]); + ( + client.finalizeTaskRunArtifactUploads as ReturnType + ).mockResolvedValue([{ id: "artifact-1" }]); + + const ids = await service.uploadRunAttachments(client, "task-1", "run-1", [ + "/tmp/a.txt", + ]); + + expect(ids).toEqual(["artifact-1"]); + expect(fetchMock).toHaveBeenCalledWith( + "https://s3/upload", + expect.objectContaining({ method: "POST" }), + ); + fetchMock.mockRestore(); + }); +}); diff --git a/packages/core/src/sessions/cloudArtifactService.ts b/packages/core/src/sessions/cloudArtifactService.ts new file mode 100644 index 0000000000..4c2d837d1e --- /dev/null +++ b/packages/core/src/sessions/cloudArtifactService.ts @@ -0,0 +1,250 @@ +import type { ReadFileAsBase64 } from "@posthog/core/editor/cloud-prompt"; +import { getFileName } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import { + CLOUD_ARTIFACT_READ_FILE_AS_BASE64, + type CloudArtifactClient, + type CloudArtifactUploadRequest, + type FinalizedCloudArtifact, + type PreparedCloudArtifact, +} from "./cloudArtifactIdentifiers"; + +const ATTACHMENT_SOURCE = "posthog_code"; +const DEFAULT_CONTENT_TYPE = "application/octet-stream"; +export const CLOUD_ATTACHMENT_MAX_SIZE_BYTES = 30 * 1024 * 1024; +export const CLOUD_PDF_ATTACHMENT_MAX_SIZE_BYTES = 10 * 1024 * 1024; + +const CONTENT_TYPE_BY_EXTENSION: Record = { + bmp: "image/bmp", + c: "text/plain", + cc: "text/plain", + conf: "text/plain", + cpp: "text/plain", + css: "text/css", + csv: "text/csv", + gif: "image/gif", + go: "text/plain", + h: "text/plain", + html: "text/html", + ini: "text/plain", + java: "text/plain", + jpeg: "image/jpeg", + jpg: "image/jpeg", + js: "text/javascript", + json: "application/json", + jsx: "text/javascript", + log: "text/plain", + md: "text/markdown", + pdf: "application/pdf", + png: "image/png", + py: "text/x-python", + rb: "text/plain", + rs: "text/plain", + sh: "text/x-shellscript", + sql: "application/sql", + svg: "image/svg+xml", + toml: "application/toml", + ts: "text/typescript", + tsx: "text/typescript", + txt: "text/plain", + webp: "image/webp", + xml: "application/xml", + yaml: "application/yaml", + yml: "application/yaml", + zip: "application/zip", +}; + +interface LoadedCloudAttachment { + filePath: string; + bytes: Uint8Array; + upload: CloudArtifactUploadRequest; +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; +} + +function getFileExtension(filePath: string): string { + const parts = getFileName(filePath).split("."); + return parts.length > 1 ? (parts.at(-1)?.toLowerCase() ?? "") : ""; +} + +function inferContentType(filePath: string): string { + return ( + CONTENT_TYPE_BY_EXTENSION[getFileExtension(filePath)] ?? + DEFAULT_CONTENT_TYPE + ); +} + +function getCloudAttachmentMaxSizeBytes( + filePath: string, + contentType: string, +): number { + const extension = getFileExtension(filePath); + const normalizedContentType = + contentType.split(";")[0]?.trim().toLowerCase() ?? ""; + + if (extension === "pdf" || normalizedContentType === "application/pdf") { + return CLOUD_PDF_ATTACHMENT_MAX_SIZE_BYTES; + } + + return CLOUD_ATTACHMENT_MAX_SIZE_BYTES; +} + +function getCloudAttachmentSizeError( + filePath: string, + maxSizeBytes: number, +): string { + const maxMb = Math.floor(maxSizeBytes / (1024 * 1024)); + + if (getFileExtension(filePath) === "pdf") { + return `${getFileName(filePath)} exceeds the ${maxMb}MB attachment limit for PDFs in cloud runs`; + } + + return `${getFileName(filePath)} exceeds the ${maxMb}MB attachment limit`; +} + +@injectable() +export class CloudArtifactService { + constructor( + @inject(CLOUD_ARTIFACT_READ_FILE_AS_BASE64) + private readonly readFileAsBase64: ReadFileAsBase64, + ) {} + + async uploadTaskStagedAttachments( + client: CloudArtifactClient, + taskId: string, + filePaths: string[], + ): Promise { + if (!filePaths.length) { + return []; + } + + const attachments = await this.loadCloudAttachments(filePaths); + const preparedArtifacts = await client.prepareTaskStagedArtifactUploads( + taskId, + attachments.map((attachment) => attachment.upload), + ); + + await this.uploadPreparedArtifacts(attachments, preparedArtifacts); + + const finalizedArtifacts = await client.finalizeTaskStagedArtifactUploads( + taskId, + preparedArtifacts, + ); + + return finalizedArtifacts.map((artifact) => artifact.id); + } + + async uploadRunAttachments( + client: CloudArtifactClient, + taskId: string, + runId: string, + filePaths: string[], + ): Promise { + if (!filePaths.length) { + return []; + } + + const attachments = await this.loadCloudAttachments(filePaths); + const preparedArtifacts = await client.prepareTaskRunArtifactUploads( + taskId, + runId, + attachments.map((attachment) => attachment.upload), + ); + + await this.uploadPreparedArtifacts(attachments, preparedArtifacts); + + const finalizedArtifacts = await client.finalizeTaskRunArtifactUploads( + taskId, + runId, + preparedArtifacts, + ); + + return finalizedArtifacts.map((artifact) => artifact.id); + } + + private async loadCloudAttachments( + filePaths: string[], + ): Promise { + return Promise.all( + filePaths.map(async (filePath) => { + const base64 = await this.readFileAsBase64(filePath); + if (!base64) { + throw new Error( + `Unable to read attached file ${getFileName(filePath)}`, + ); + } + + const bytes = base64ToUint8Array(base64); + const contentType = inferContentType(filePath); + const maxSizeBytes = getCloudAttachmentMaxSizeBytes( + filePath, + contentType, + ); + if (bytes.byteLength > maxSizeBytes) { + throw new Error(getCloudAttachmentSizeError(filePath, maxSizeBytes)); + } + return { + filePath, + bytes, + upload: { + name: getFileName(filePath), + type: "user_attachment", + source: ATTACHMENT_SOURCE, + size: bytes.byteLength, + content_type: contentType, + }, + }; + }), + ); + } + + private async uploadPreparedArtifacts( + attachments: LoadedCloudAttachment[], + preparedArtifacts: PreparedCloudArtifact[], + ): Promise { + if (attachments.length !== preparedArtifacts.length) { + throw new Error("Prepared uploads do not match the selected attachments"); + } + + await Promise.all( + preparedArtifacts.map(async (preparedArtifact, index) => { + const attachment = attachments[index]; + const formData = new FormData(); + + for (const [key, value] of Object.entries( + preparedArtifact.presigned_post.fields, + )) { + formData.append(key, value); + } + + formData.append( + "file", + new Blob([attachment.bytes], { + type: attachment.upload.content_type || DEFAULT_CONTENT_TYPE, + }), + attachment.upload.name, + ); + + const response = await fetch(preparedArtifact.presigned_post.url, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`Failed to upload ${attachment.upload.name}`); + } + }), + ); + } +} + +export type { FinalizedCloudArtifact }; diff --git a/packages/core/src/sessions/cloudLogGap.test.ts b/packages/core/src/sessions/cloudLogGap.test.ts new file mode 100644 index 0000000000..7ecc532091 --- /dev/null +++ b/packages/core/src/sessions/cloudLogGap.test.ts @@ -0,0 +1,160 @@ +import type { StoredLogEntry } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { + type CloudLogGapReconcileRequest, + classifyCloudLogAppend, + classifyCloudLogGap, + mergeCloudLogGapRequests, +} from "./cloudLogGap"; + +function entry(line: string): StoredLogEntry { + return { + type: "notification", + notification: { method: line }, + } as unknown as StoredLogEntry; +} + +function request( + over: Partial = {}, +): CloudLogGapReconcileRequest { + return { + taskId: "t1", + taskRunId: "r1", + expectedCount: 10, + currentCount: 5, + newEntries: [], + ...over, + }; +} + +describe("mergeCloudLogGapRequests", () => { + it("returns next when there is no current request", () => { + const next = request(); + expect(mergeCloudLogGapRequests(undefined, next)).toBe(next); + }); + + it("widens the range and concatenates entries", () => { + const current = request({ + currentCount: 3, + expectedCount: 8, + newEntries: [entry("a")], + logUrl: "old", + }); + const next = request({ + currentCount: 6, + expectedCount: 12, + newEntries: [entry("b")], + logUrl: undefined, + }); + + const merged = mergeCloudLogGapRequests(current, next); + expect(merged.currentCount).toBe(3); + expect(merged.expectedCount).toBe(12); + expect(merged.newEntries).toHaveLength(2); + expect(merged.logUrl).toBe("old"); + }); + + it("prefers next.logUrl when present", () => { + const merged = mergeCloudLogGapRequests( + request({ logUrl: "old" }), + request({ logUrl: "new" }), + ); + expect(merged.logUrl).toBe("new"); + }); +}); + +describe("classifyCloudLogGap", () => { + const base = { + expectedCount: 10, + latestCount: 0, + totalLineCount: 0, + parseFailureCount: 0, + previousDeficiency: undefined, + }; + + it("is already-current when the store caught up", () => { + expect(classifyCloudLogGap({ ...base, latestCount: 10 })).toEqual({ + kind: "already-current", + }); + }); + + it("fills when the fetch covered the expected count", () => { + expect(classifyCloudLogGap({ ...base, totalLineCount: 12 })).toEqual({ + kind: "fill", + processedLineCount: 12, + }); + }); + + it("commits best-effort on parse failures", () => { + expect( + classifyCloudLogGap({ ...base, totalLineCount: 7, parseFailureCount: 1 }), + ).toEqual({ + kind: "commit-best-effort", + processedLineCount: 10, + reason: "parse-failure", + }); + }); + + it("commits best-effort on a stable repeated deficit", () => { + expect( + classifyCloudLogGap({ + ...base, + totalLineCount: 7, + previousDeficiency: { expectedCount: 10, observedLineCount: 7 }, + }), + ).toEqual({ + kind: "commit-best-effort", + processedLineCount: 10, + reason: "stable-deficit", + }); + }); + + it("waits when short but the deficit is new (likely lag)", () => { + expect(classifyCloudLogGap({ ...base, totalLineCount: 7 })).toEqual({ + kind: "wait", + deficiency: { expectedCount: 10, observedLineCount: 7 }, + }); + }); + + it("waits when the previous deficit differs from the current one", () => { + expect( + classifyCloudLogGap({ + ...base, + totalLineCount: 7, + previousDeficiency: { expectedCount: 10, observedLineCount: 5 }, + }), + ).toMatchObject({ kind: "wait" }); + }); +}); + +describe("classifyCloudLogAppend", () => { + it("is caught up when the store already has the expected lines", () => { + expect(classifyCloudLogAppend(5, 5, 3)).toEqual({ kind: "caught-up" }); + }); + + it("is caught up when the store is ahead of the expected count", () => { + expect(classifyCloudLogAppend(6, 5, 3)).toEqual({ kind: "caught-up" }); + }); + + it("appends only the tail when the batch covers the gap", () => { + expect(classifyCloudLogAppend(2, 5, 10)).toEqual({ + kind: "append-tail", + tailCount: 3, + }); + }); + + it("appends the whole batch at the delta === available boundary", () => { + expect(classifyCloudLogAppend(0, 3, 3)).toEqual({ + kind: "append-tail", + tailCount: 3, + }); + }); + + it("reports a gap when the batch is one short of the delta", () => { + expect(classifyCloudLogAppend(0, 4, 3)).toEqual({ kind: "gap" }); + }); + + it("reports a gap when the batch cannot cover a large deficit", () => { + expect(classifyCloudLogAppend(0, 100, 3)).toEqual({ kind: "gap" }); + }); +}); diff --git a/packages/core/src/sessions/cloudLogGap.ts b/packages/core/src/sessions/cloudLogGap.ts new file mode 100644 index 0000000000..1a4d851e0a --- /dev/null +++ b/packages/core/src/sessions/cloudLogGap.ts @@ -0,0 +1,144 @@ +import type { StoredLogEntry } from "@posthog/shared"; + +/** + * Pure logic for reconciling cloud session log gaps. The session service owns + * the I/O (fetching logs, writing the store); this module owns the decisions: + * how to coalesce overlapping reconcile requests, and — given the counts a + * fetch returned — what the service should do next. + */ + +export interface CloudLogGapReconcileRequest { + taskId: string; + taskRunId: string; + expectedCount: number; + currentCount: number; + newEntries: StoredLogEntry[]; + logUrl?: string; +} + +export interface CloudLogGapDeficiency { + expectedCount: number; + observedLineCount: number; +} + +/** + * Coalesce a queued reconcile request with a newer one, widening the range to + * cover both (lowest currentCount, highest expectedCount) and concatenating + * their entries so no observed event is dropped. + */ +export function mergeCloudLogGapRequests( + current: CloudLogGapReconcileRequest | undefined, + next: CloudLogGapReconcileRequest, +): CloudLogGapReconcileRequest { + if (!current) return next; + + return { + taskId: next.taskId, + taskRunId: next.taskRunId, + currentCount: Math.min(current.currentCount, next.currentCount), + expectedCount: Math.max(current.expectedCount, next.expectedCount), + newEntries: [...current.newEntries, ...next.newEntries], + logUrl: next.logUrl ?? current.logUrl, + }; +} + +export type CloudLogAppendPlan = + | { kind: "caught-up" } + | { kind: "append-tail"; tailCount: number } + | { kind: "gap" }; + +/** + * Decide how to apply a batch of streamed cloud log entries, given how many + * lines the store has already committed (`currentLineCount`), how many the + * update claims should exist (`expectedLineCount`), and how many entries the + * update actually carried (`availableEntryCount`): + * - `caught-up`: the store already has everything; drop the batch. + * - `append-tail`: append only the last `tailCount` entries (the batch covers + * the gap; earlier entries are duplicates already in the store). + * - `gap`: the batch cannot cover the gap; fall back to a reconcile fetch. + * + * Boundary: when `delta === availableEntryCount` the whole batch is the tail, + * so it is still an `append-tail`, not a `gap`. + */ +export function classifyCloudLogAppend( + currentLineCount: number, + expectedLineCount: number, + availableEntryCount: number, +): CloudLogAppendPlan { + const delta = expectedLineCount - currentLineCount; + if (delta <= 0) { + return { kind: "caught-up" }; + } + if (delta <= availableEntryCount) { + return { kind: "append-tail", tailCount: delta }; + } + return { kind: "gap" }; +} + +export type CloudLogGapAction = + | { kind: "already-current" } + | { kind: "fill"; processedLineCount: number } + | { + kind: "commit-best-effort"; + processedLineCount: number; + reason: "parse-failure" | "stable-deficit"; + } + | { kind: "wait"; deficiency: CloudLogGapDeficiency }; + +export interface CloudLogGapInput { + /** Entry count the latest cloud update claims should exist. */ + expectedCount: number; + /** Entries already committed to the store for this run. */ + latestCount: number; + /** Entries the just-completed fetch actually parsed. */ + totalLineCount: number; + /** Lines the fetch failed to parse (proof of corruption). */ + parseFailureCount: number; + /** Deficit observed on the previous reconcile pass, if any. */ + previousDeficiency: CloudLogGapDeficiency | undefined; +} + +/** + * Decide what to do after a reconcile fetch: + * - `already-current`: the store already caught up; drop any tracked deficit. + * - `fill`: the fetch covered the gap; commit everything it returned. + * - `commit-best-effort`: the gap is unrecoverable (parse failure or a stable + * repeat of the same deficit); commit what we have and stop looping. + * - `wait`: still short, but likely lag; record the deficit and retry later. + */ +export function classifyCloudLogGap( + input: CloudLogGapInput, +): CloudLogGapAction { + const { + expectedCount, + latestCount, + totalLineCount, + parseFailureCount, + previousDeficiency, + } = input; + + if (latestCount >= expectedCount) { + return { kind: "already-current" }; + } + + if (totalLineCount >= expectedCount) { + return { kind: "fill", processedLineCount: totalLineCount }; + } + + const sameDeficiencyAsBefore = + previousDeficiency?.expectedCount === expectedCount && + previousDeficiency?.observedLineCount === totalLineCount; + + if (parseFailureCount > 0 || sameDeficiencyAsBefore) { + return { + kind: "commit-best-effort", + processedLineCount: expectedCount, + reason: parseFailureCount > 0 ? "parse-failure" : "stable-deficit", + }; + } + + return { + kind: "wait", + deficiency: { expectedCount, observedLineCount: totalLineCount }, + }; +} diff --git a/packages/core/src/sessions/cloudLogGapReconciler.test.ts b/packages/core/src/sessions/cloudLogGapReconciler.test.ts new file mode 100644 index 0000000000..5e1bbeb022 --- /dev/null +++ b/packages/core/src/sessions/cloudLogGapReconciler.test.ts @@ -0,0 +1,205 @@ +import type { StoredLogEntry } from "@posthog/shared"; +import { describe, expect, it, vi } from "vitest"; +import type { CloudLogGapReconcileRequest } from "./cloudLogGap"; +import { + type CloudLogGapFetchResult, + CloudLogGapReconciler, + type CloudLogGapReconcilerDeps, + type CloudLogGapReconcilerSession, +} from "./cloudLogGapReconciler"; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function entry(method: string): StoredLogEntry { + return { + type: "notification", + notification: { method }, + } as unknown as StoredLogEntry; +} + +function request( + over: Partial = {}, +): CloudLogGapReconcileRequest { + return { + taskId: "t1", + taskRunId: "r1", + expectedCount: 5, + currentCount: 0, + newEntries: [], + logUrl: "https://logs/r1", + ...over, + }; +} + +function createDeps( + over: Partial<{ + fetch: CloudLogGapFetchResult; + session: CloudLogGapReconcilerSession | undefined; + }> = {}, +) { + const session: CloudLogGapReconcilerSession | undefined = + over.session === undefined + ? { taskId: "t1", processedLineCount: 0, logUrl: "https://logs/r1" } + : over.session; + + const fetchLogs = vi.fn( + async (): Promise => + over.fetch ?? { + rawEntries: [entry("a"), entry("b")], + totalLineCount: 5, + parseFailureCount: 0, + }, + ); + const getSession = vi.fn(() => session); + const commit = vi.fn(); + const logger = { warn: vi.fn() }; + + const deps: CloudLogGapReconcilerDeps = { + fetchLogs, + getSession, + commit, + logger, + }; + return { deps, fetchLogs, getSession, commit, logger }; +} + +describe("CloudLogGapReconciler", () => { + it("fills the gap and commits the fetched log with the resolved url", async () => { + const { deps, commit } = createDeps({ + fetch: { + rawEntries: [entry("a")], + totalLineCount: 5, + parseFailureCount: 0, + }, + }); + new CloudLogGapReconciler(deps).reconcile(request()); + await tick(); + + expect(commit).toHaveBeenCalledWith( + "r1", + [entry("a")], + "https://logs/r1", + 5, + ); + }); + + it("does not commit when the store already caught up", async () => { + const { deps, commit } = createDeps({ + session: { taskId: "t1", processedLineCount: 5, logUrl: undefined }, + }); + new CloudLogGapReconciler(deps).reconcile(request()); + await tick(); + + expect(commit).not.toHaveBeenCalled(); + }); + + it("does nothing when the run was swapped out from under the fetch", async () => { + const { deps, commit } = createDeps({ + session: { + taskId: "different", + processedLineCount: 0, + logUrl: undefined, + }, + }); + new CloudLogGapReconciler(deps).reconcile(request()); + await tick(); + + expect(commit).not.toHaveBeenCalled(); + }); + + it("waits (no commit) when short with a fresh deficit", async () => { + const { deps, commit, logger } = createDeps({ + fetch: { + rawEntries: [entry("a")], + totalLineCount: 3, + parseFailureCount: 0, + }, + }); + new CloudLogGapReconciler(deps).reconcile(request()); + await tick(); + + expect(commit).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + "Cloud task log count inconsistency", + expect.objectContaining({ taskRunId: "r1" }), + ); + }); + + it("commits best-effort immediately on a parse failure", async () => { + const { deps, commit } = createDeps({ + fetch: { + rawEntries: [entry("a")], + totalLineCount: 3, + parseFailureCount: 2, + }, + }); + new CloudLogGapReconciler(deps).reconcile(request()); + await tick(); + + expect(commit).toHaveBeenCalledWith( + "r1", + [entry("a")], + "https://logs/r1", + 5, + ); + }); + + it("commits best-effort once the same deficit repeats", async () => { + const { deps, commit } = createDeps({ + fetch: { + rawEntries: [entry("a")], + totalLineCount: 3, + parseFailureCount: 0, + }, + }); + const reconciler = new CloudLogGapReconciler(deps); + + reconciler.reconcile(request()); + await tick(); + expect(commit).not.toHaveBeenCalled(); + + reconciler.reconcile(request()); + await tick(); + expect(commit).toHaveBeenCalledTimes(1); + expect(commit).toHaveBeenCalledWith( + "r1", + [entry("a")], + "https://logs/r1", + 5, + ); + }); + + it("forgetting the deficit makes the next short fetch wait again", async () => { + const { deps, commit } = createDeps({ + fetch: { + rawEntries: [entry("a")], + totalLineCount: 3, + parseFailureCount: 0, + }, + }); + const reconciler = new CloudLogGapReconciler(deps); + + reconciler.reconcile(request()); + await tick(); + reconciler.forgetDeficiency("r1"); + + reconciler.reconcile(request()); + await tick(); + expect(commit).not.toHaveBeenCalled(); + }); + + it("coalesces a concurrent request into a single in-flight loop", async () => { + const { deps, fetchLogs } = createDeps(); + // Never-resolving fetch keeps the first loop in-flight. + fetchLogs.mockImplementation( + () => new Promise(() => {}), + ); + const reconciler = new CloudLogGapReconciler(deps); + + reconciler.reconcile(request()); + reconciler.reconcile(request({ expectedCount: 8 })); + await tick(); + + expect(fetchLogs).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/sessions/cloudLogGapReconciler.ts b/packages/core/src/sessions/cloudLogGapReconciler.ts new file mode 100644 index 0000000000..428de0e995 --- /dev/null +++ b/packages/core/src/sessions/cloudLogGapReconciler.ts @@ -0,0 +1,184 @@ +import type { StoredLogEntry } from "@posthog/shared"; +import { + type CloudLogGapDeficiency, + type CloudLogGapReconcileRequest, + classifyCloudLogGap, + mergeCloudLogGapRequests, +} from "./cloudLogGap"; + +export interface CloudLogGapFetchResult { + rawEntries: StoredLogEntry[]; + totalLineCount: number; + parseFailureCount: number; +} + +export interface CloudLogGapReconcilerSession { + taskId: string; + processedLineCount: number; + logUrl: string | undefined; +} + +export interface CloudLogGapReconcilerLogger { + warn(message: string, data?: Record): void; +} + +/** + * Host I/O the reconciler orchestrates over. The session service supplies these + * (log fetching, store read, the commit-to-store side effect); the reconciler + * owns the queue/coalesce/retry control flow and the gap-classification flow. + */ +export interface CloudLogGapReconcilerDeps { + fetchLogs( + logUrl: string | undefined, + taskRunId: string, + minEntryCount: number, + ): Promise; + getSession(taskRunId: string): CloudLogGapReconcilerSession | undefined; + commit( + taskRunId: string, + rawEntries: StoredLogEntry[], + logUrl: string | undefined, + processedLineCount: number, + ): void; + logger: CloudLogGapReconcilerLogger; +} + +interface ReconcileState { + pendingRequest?: CloudLogGapReconcileRequest; +} + +/** + * Reconciles cloud session log gaps. When a streamed cloud update claims more + * entries than it carried (a gap), the service hands the request here; the + * reconciler fetches the authoritative log, decides via `classifyCloudLogGap` + * whether to fill / commit-best-effort / wait, and coalesces concurrent + * requests for the same run so only one fetch loop runs at a time. + */ +export class CloudLogGapReconciler { + private readonly inFlight = new Map(); + private readonly deficiency = new Map(); + + constructor(private readonly deps: CloudLogGapReconcilerDeps) {} + + /** Queue a reconcile. Concurrent requests for the same run are coalesced. */ + reconcile(request: CloudLogGapReconcileRequest): void { + const reconcileKey = `${request.taskId}:${request.taskRunId}`; + const existing = this.inFlight.get(reconcileKey); + if (existing) { + existing.pendingRequest = mergeCloudLogGapRequests( + existing.pendingRequest, + request, + ); + return; + } + + this.inFlight.set(reconcileKey, {}); + void this.runLoop(reconcileKey, request) + .catch((err: unknown) => { + this.deps.logger.warn("Failed to reconcile cloud task log gap", { + taskId: request.taskId, + taskRunId: request.taskRunId, + err, + }); + }) + .finally(() => { + this.inFlight.delete(reconcileKey); + }); + } + + /** Forget the tracked deficit for a run (on teardown / watch stop). */ + forgetDeficiency(taskRunId: string): void { + this.deficiency.delete(taskRunId); + } + + /** Drop all in-flight reconciles and tracked deficits (on full reset). */ + clear(): void { + this.inFlight.clear(); + this.deficiency.clear(); + } + + private async runLoop( + reconcileKey: string, + initialRequest: CloudLogGapReconcileRequest, + ): Promise { + let request: CloudLogGapReconcileRequest | undefined = initialRequest; + + while (request) { + await this.reconcileOnce(request); + const state = this.inFlight.get(reconcileKey); + request = state?.pendingRequest; + if (state) { + state.pendingRequest = undefined; + } + } + } + + private async reconcileOnce( + request: CloudLogGapReconcileRequest, + ): Promise { + const { + taskId, + taskRunId, + expectedCount, + currentCount, + newEntries, + logUrl, + } = request; + + const { rawEntries, totalLineCount, parseFailureCount } = + await this.deps.fetchLogs(logUrl, taskRunId, expectedCount); + + const session = this.deps.getSession(taskRunId); + if (!session || session.taskId !== taskId) { + return; + } + + const action = classifyCloudLogGap({ + expectedCount, + latestCount: session.processedLineCount ?? 0, + totalLineCount, + parseFailureCount, + previousDeficiency: this.deficiency.get(taskRunId), + }); + + if (action.kind === "already-current") { + this.deficiency.delete(taskRunId); + return; + } + + if (action.kind === "commit-best-effort") { + this.deps.logger.warn( + "Cloud task log gap unrecoverable; committing best-effort", + { + taskRunId, + expectedCount, + observedLineCount: totalLineCount, + parseFailureCount, + fetchedEntries: rawEntries.length, + reason: action.reason, + }, + ); + } + + if (action.kind === "fill" || action.kind === "commit-best-effort") { + this.deficiency.delete(taskRunId); + this.deps.commit( + taskRunId, + rawEntries, + logUrl ?? session.logUrl, + action.processedLineCount, + ); + return; + } + + this.deficiency.set(taskRunId, action.deficiency); + this.deps.logger.warn("Cloud task log count inconsistency", { + taskRunId, + currentCount, + expectedCount, + fetchedCount: rawEntries.length, + parseFailureCount, + entriesReceived: newEntries.length, + }); + } +} diff --git a/packages/core/src/sessions/cloudPrompt.test.ts b/packages/core/src/sessions/cloudPrompt.test.ts new file mode 100644 index 0000000000..0a8d50eca2 --- /dev/null +++ b/packages/core/src/sessions/cloudPrompt.test.ts @@ -0,0 +1,46 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { + combineQueuedCloudPrompts, + promptToQueuedEditorContent, +} from "./cloudPrompt"; + +describe("cloudPrompt", () => { + it("preserves attachment blocks when combining queued cloud prompts", () => { + const prompt: ContentBlock[] = [ + { type: "text", text: "read this" }, + { + type: "resource_link", + uri: "file:///tmp/test.txt", + name: "test.txt", + mimeType: "text/plain", + }, + ]; + + expect( + combineQueuedCloudPrompts([ + { + content: "read this\n\nAttached files: test.txt", + rawPrompt: prompt, + }, + ]), + ).toEqual(prompt); + }); + + it("restores queued editor content with attachments from prompt blocks", () => { + const prompt: ContentBlock[] = [ + { type: "text", text: "read this" }, + { + type: "resource_link", + uri: "file:///tmp/test.txt", + name: "test.txt", + mimeType: "text/plain", + }, + ]; + + expect(promptToQueuedEditorContent(prompt)).toEqual({ + segments: [{ type: "text", text: "read this" }], + attachments: [{ id: "/tmp/test.txt", label: "test.txt" }], + }); + }); +}); diff --git a/packages/core/src/sessions/cloudPrompt.ts b/packages/core/src/sessions/cloudPrompt.ts new file mode 100644 index 0000000000..9a554f36cf --- /dev/null +++ b/packages/core/src/sessions/cloudPrompt.ts @@ -0,0 +1,174 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { + buildCloudTaskDescription, + getAbsoluteAttachmentPaths, + stripAbsoluteFileTags, +} from "@posthog/core/editor/cloud-prompt"; +import type { EditorContent } from "@posthog/core/message-editor/content"; +import { getFileName, pathToFileUri } from "@posthog/shared"; + +const FILE_URI_PREFIX = "file://"; + +export interface CloudPromptTransport { + filePaths: string[]; + messageText?: string; + promptText: string; +} + +export type QueuedCloudPrompt = string | ContentBlock[]; + +function decodeFileUri(uri: string): string | null { + if (!uri.startsWith(FILE_URI_PREFIX)) { + return null; + } + + const encodedPath = uri.slice(FILE_URI_PREFIX.length); + const normalizedPath = encodedPath.startsWith("/") + ? encodedPath + : `/${encodedPath}`; + + try { + return normalizedPath + .split("/") + .map((segment, index) => + index === 0 && segment === "" ? segment : decodeURIComponent(segment), + ) + .join("/"); + } catch { + return null; + } +} + +function collectBlockAttachmentPaths(prompt: ContentBlock[]): string[] { + const filePaths = prompt + .map((block) => { + if (block.type === "resource_link") { + return decodeFileUri(block.uri); + } + + if (block.type === "resource") { + return block.resource.uri ? decodeFileUri(block.resource.uri) : null; + } + + if (block.type === "image") { + return block.uri ? decodeFileUri(block.uri) : null; + } + + return null; + }) + .filter((value): value is string => Boolean(value)); + + return Array.from(new Set(filePaths)); +} + +function summarizePrompt(text: string, filePaths: string[]): string { + if (filePaths.length === 0) { + return text.trim(); + } + + const attachmentSummary = `Attached files: ${filePaths.map(getFileName).join(", ")}`; + return text.trim() + ? `${text.trim()}\n\n${attachmentSummary}` + : attachmentSummary; +} + +export function getCloudPromptTransport( + prompt: string | ContentBlock[], + filePaths: string[] = [], +): CloudPromptTransport { + if (typeof prompt === "string") { + const attachmentPaths = getAbsoluteAttachmentPaths(prompt, filePaths); + const messageText = stripAbsoluteFileTags(prompt).trim(); + + return { + filePaths: attachmentPaths, + messageText: messageText || undefined, + promptText: buildCloudTaskDescription(prompt, filePaths).trim(), + }; + } + + const promptText = prompt + .filter( + (block): block is Extract => + block.type === "text", + ) + .map((block) => block.text) + .join("") + .trim(); + const attachmentPaths = collectBlockAttachmentPaths(prompt); + + return { + filePaths: attachmentPaths, + messageText: promptText || undefined, + promptText: summarizePrompt(promptText, attachmentPaths), + }; +} + +export function cloudPromptToBlocks(prompt: QueuedCloudPrompt): ContentBlock[] { + if (typeof prompt !== "string") { + return prompt; + } + + const transport = getCloudPromptTransport(prompt); + const blocks: ContentBlock[] = []; + + if (transport.messageText) { + blocks.push({ type: "text", text: transport.messageText }); + } + + for (const filePath of transport.filePaths) { + blocks.push({ + type: "resource_link", + uri: pathToFileUri(filePath), + name: getFileName(filePath), + }); + } + + return blocks; +} + +export function promptToQueuedEditorContent( + prompt: QueuedCloudPrompt, +): EditorContent { + const transport = getCloudPromptTransport(prompt); + const attachments = transport.filePaths.map((filePath) => ({ + id: filePath, + label: getFileName(filePath), + })); + const text = + typeof prompt === "string" + ? stripAbsoluteFileTags(prompt) + : (transport.messageText ?? ""); + + return { + segments: [{ type: "text", text }], + ...(attachments.length > 0 ? { attachments } : {}), + }; +} + +export function combineQueuedCloudPrompts( + queuedPrompts: Array<{ content: string; rawPrompt?: QueuedCloudPrompt }>, +): QueuedCloudPrompt | null { + if (queuedPrompts.length === 0) { + return null; + } + + const blocks: ContentBlock[] = []; + + for (const [index, queuedPrompt] of queuedPrompts.entries()) { + const promptBlocks = cloudPromptToBlocks( + queuedPrompt.rawPrompt ?? queuedPrompt.content, + ); + if (promptBlocks.length === 0) { + continue; + } + + if (index > 0 && blocks.length > 0) { + blocks.push({ type: "text", text: "\n\n" }); + } + + blocks.push(...promptBlocks); + } + + return blocks.length > 0 ? blocks : null; +} diff --git a/packages/core/src/sessions/cloudRunIdleTracker.test.ts b/packages/core/src/sessions/cloudRunIdleTracker.test.ts new file mode 100644 index 0000000000..49c7ae2c13 --- /dev/null +++ b/packages/core/src/sessions/cloudRunIdleTracker.test.ts @@ -0,0 +1,132 @@ +import type { AcpMessage, AgentSession } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { CloudRunIdleTracker } from "./cloudRunIdleTracker"; + +function runStarted(runId: string): AcpMessage { + return { + type: "acp_message", + ts: 1, + message: { + jsonrpc: "2.0", + method: "_posthog/run_started", + params: { runId }, + }, + } as AcpMessage; +} + +function turnComplete(): AcpMessage { + return { + type: "acp_message", + ts: 2, + message: { jsonrpc: "2.0", method: "_posthog/turn_complete", params: {} }, + } as AcpMessage; +} + +function promptRequest(id = 1): AcpMessage { + return { + type: "acp_message", + ts: 3, + message: { jsonrpc: "2.0", id, method: "session/prompt", params: {} }, + } as AcpMessage; +} + +function session( + taskRunId: string, + events: AcpMessage[], + agentIdleForRunId?: string, +): AgentSession { + return { taskRunId, events, agentIdleForRunId } as AgentSession; +} + +describe("CloudRunIdleTracker.evaluateIdle", () => { + it("uses the agentIdleForRunId fast path without caching", () => { + const tracker = new CloudRunIdleTracker(); + const result = tracker.evaluateIdle(session("r1", [], "r1")); + + expect(result).toEqual({ idle: true, shouldCacheToStore: false }); + }); + + it("reports idle after a run_started then turn_complete", () => { + const tracker = new CloudRunIdleTracker(); + const result = tracker.evaluateIdle( + session("r1", [runStarted("r1"), turnComplete()]), + ); + + expect(result).toEqual({ idle: true, shouldCacheToStore: true }); + }); + + it("reports busy when a prompt follows the last turn_complete", () => { + const tracker = new CloudRunIdleTracker(); + const result = tracker.evaluateIdle( + session("r1", [runStarted("r1"), turnComplete(), promptRequest()]), + ); + + expect(result.idle).toBe(false); + }); + + it("ignores events before the current run's run_started", () => { + const tracker = new CloudRunIdleTracker(); + // turn_complete before run_started should not count as idle + const result = tracker.evaluateIdle( + session("r1", [turnComplete(), runStarted("r1")]), + ); + + expect(result.idle).toBe(false); + }); + + it("scans incrementally across calls", () => { + const tracker = new CloudRunIdleTracker(); + const events = [runStarted("r1"), promptRequest()]; + + expect(tracker.evaluateIdle(session("r1", events)).idle).toBe(false); + + events.push(turnComplete()); + expect(tracker.evaluateIdle(session("r1", events)).idle).toBe(true); + }); +}); + +describe("CloudRunIdleTracker mark/capture/restore", () => { + it("markIdle then capture reflects an idle scan state", () => { + const tracker = new CloudRunIdleTracker(); + const s = session("r1", [runStarted("r1")]); + tracker.markIdle(s); + + const snapshot = tracker.capture(s); + expect(snapshot.taskRunId).toBe("r1"); + expect(snapshot.scanState?.idle).toBe(true); + }); + + it("restoreAfterFailedSend restores prior evidence when no new prompt arrived", () => { + const tracker = new CloudRunIdleTracker(); + const before = session("r1", [runStarted("r1")], "r1"); + tracker.markIdle(before); + const snapshot = tracker.capture(before); + + // Simulate a failed send: markBusy advanced the marker, no new events. + tracker.markBusy(before); + const restored = tracker.restoreAfterFailedSend(snapshot, before); + + expect(restored).toEqual({ agentIdleForRunId: "r1" }); + expect(tracker.capture(before).scanState?.idle).toBe(true); + }); + + it("does not restore when a new prompt arrived after the snapshot", () => { + const tracker = new CloudRunIdleTracker(); + const before = session("r1", [runStarted("r1")], "r1"); + tracker.markIdle(before); + const snapshot = tracker.capture(before); + + const after = session("r1", [runStarted("r1"), promptRequest()], "r1"); + tracker.markBusy(after); + expect(tracker.restoreAfterFailedSend(snapshot, after)).toBeUndefined(); + }); + + it("delete and clear drop tracked state", () => { + const tracker = new CloudRunIdleTracker(); + const s = session("r1", [runStarted("r1")]); + tracker.markBusy(s); + tracker.delete("r1"); + // After delete, evaluateIdle re-scans from scratch. + expect(tracker.evaluateIdle(session("r1", [])).idle).toBe(false); + }); +}); diff --git a/apps/code/src/renderer/features/sessions/service/cloudRunIdleTracker.ts b/packages/core/src/sessions/cloudRunIdleTracker.ts similarity index 95% rename from apps/code/src/renderer/features/sessions/service/cloudRunIdleTracker.ts rename to packages/core/src/sessions/cloudRunIdleTracker.ts index 712e7fcbed..24ef58e367 100644 --- a/apps/code/src/renderer/features/sessions/service/cloudRunIdleTracker.ts +++ b/packages/core/src/sessions/cloudRunIdleTracker.ts @@ -1,6 +1,5 @@ -import type { AgentSession } from "@features/sessions/stores/sessionStore"; -import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent"; -import { isJsonRpcRequest } from "@shared/types/session-events"; +import { type AgentSession, isJsonRpcRequest } from "@posthog/shared"; +import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; interface CloudRunIdleScanState { nextEventIndex: number; diff --git a/packages/core/src/sessions/cloudRunOptions.test.ts b/packages/core/src/sessions/cloudRunOptions.test.ts new file mode 100644 index 0000000000..7f5a004c56 --- /dev/null +++ b/packages/core/src/sessions/cloudRunOptions.test.ts @@ -0,0 +1,88 @@ +import type { AgentSession } from "@posthog/shared"; +import type { TaskRun } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + getCloudPrAuthorshipMode, + getCloudRunSource, + getCloudRuntimeOptions, +} from "./cloudRunOptions"; + +describe("getCloudPrAuthorshipMode", () => { + it("honors an explicit user/bot mode", () => { + expect(getCloudPrAuthorshipMode({ pr_authorship_mode: "bot" })).toBe("bot"); + expect(getCloudPrAuthorshipMode({ pr_authorship_mode: "user" })).toBe( + "user", + ); + }); + + it("defaults signal_report runs to bot, everything else to user", () => { + expect(getCloudPrAuthorshipMode({ run_source: "signal_report" })).toBe( + "bot", + ); + expect(getCloudPrAuthorshipMode({ run_source: "manual" })).toBe("user"); + expect(getCloudPrAuthorshipMode({})).toBe("user"); + }); + + it("ignores an invalid explicit mode and falls back to run_source", () => { + expect( + getCloudPrAuthorshipMode({ + pr_authorship_mode: "nonsense", + run_source: "signal_report", + }), + ).toBe("bot"); + }); +}); + +describe("getCloudRunSource", () => { + it("maps signal_report through and everything else to manual", () => { + expect(getCloudRunSource({ run_source: "signal_report" })).toBe( + "signal_report", + ); + expect(getCloudRunSource({ run_source: "whatever" })).toBe("manual"); + expect(getCloudRunSource({})).toBe("manual"); + }); +}); + +describe("getCloudRuntimeOptions", () => { + const session = (overrides: Partial): AgentSession => + ({ configOptions: [], ...overrides }) as unknown as AgentSession; + + it("prefers the session config option, then the previous run", () => { + const result = getCloudRuntimeOptions( + session({ + configOptions: [ + { category: "model", currentValue: "opus" }, + { category: "thought_level", currentValue: "high" }, + // biome-ignore lint/suspicious/noExplicitAny: minimal config option shape + ] as any, + adapter: undefined, + }), + { + model: "sonnet", + reasoning_effort: "low", + runtime_adapter: "claude_code", + } as unknown as TaskRun, + ); + expect(result.model).toBe("opus"); + expect(result.reasoningLevel).toBe("high"); + expect(result.adapter).toBe("claude_code"); + }); + + it("falls back to the previous run when the session has no config value", () => { + const result = getCloudRuntimeOptions(session({ configOptions: [] }), { + model: "sonnet", + reasoning_effort: "low", + runtime_adapter: "claude_code", + } as unknown as TaskRun); + expect(result.model).toBe("sonnet"); + expect(result.reasoningLevel).toBe("low"); + expect(result.adapter).toBe("claude_code"); + }); + + it("returns undefined fields when neither source provides a value", () => { + const result = getCloudRuntimeOptions(session({ configOptions: [] })); + expect(result.model).toBeUndefined(); + expect(result.reasoningLevel).toBeUndefined(); + expect(result.adapter).toBeUndefined(); + }); +}); diff --git a/packages/core/src/sessions/cloudRunOptions.ts b/packages/core/src/sessions/cloudRunOptions.ts new file mode 100644 index 0000000000..eee690c19b --- /dev/null +++ b/packages/core/src/sessions/cloudRunOptions.ts @@ -0,0 +1,60 @@ +import { + type Adapter, + type AgentSession, + type CloudRunSource, + getConfigOptionByCategory, + type PrAuthorshipMode, +} from "@posthog/shared"; +import type { TaskRun } from "@posthog/shared/domain-types"; + +/** + * Pure derivations of a cloud run's options from the host run state / session + * config. Extracted from the renderer SessionService so the keystone keeps only + * the I/O and these decisions are testable in isolation (Tiger-Style: the leaf + * computes, the service applies). + */ + +export function getCloudPrAuthorshipMode( + state: Record, +): PrAuthorshipMode { + const explicitMode = state.pr_authorship_mode; + if (explicitMode === "user" || explicitMode === "bot") { + return explicitMode; + } + return state.run_source === "signal_report" ? "bot" : "user"; +} + +export function getCloudRunSource( + state: Record, +): CloudRunSource { + return state.run_source === "signal_report" ? "signal_report" : "manual"; +} + +export interface CloudRuntimeOptions { + adapter?: Adapter; + model?: string; + reasoningLevel?: string; +} + +export function getCloudRuntimeOptions( + session: AgentSession, + previousRun?: TaskRun, +): CloudRuntimeOptions { + const modelOption = getConfigOptionByCategory(session.configOptions, "model"); + const thoughtLevelOption = getConfigOptionByCategory( + session.configOptions, + "thought_level", + ); + + return { + adapter: session.adapter ?? previousRun?.runtime_adapter ?? undefined, + model: + typeof modelOption?.currentValue === "string" + ? modelOption.currentValue + : (previousRun?.model ?? undefined), + reasoningLevel: + typeof thoughtLevelOption?.currentValue === "string" + ? thoughtLevelOption.currentValue + : (previousRun?.reasoning_effort ?? undefined), + }; +} diff --git a/packages/core/src/sessions/cloudSessionConfig.test.ts b/packages/core/src/sessions/cloudSessionConfig.test.ts new file mode 100644 index 0000000000..4890cc3551 --- /dev/null +++ b/packages/core/src/sessions/cloudSessionConfig.test.ts @@ -0,0 +1,76 @@ +import type { StoredLogEntry } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { + buildCloudDefaultConfigOptions, + extractLatestConfigOptionsFromEntries, +} from "./cloudSessionConfig"; + +function configUpdateEntry( + configOptions: unknown, + sessionUpdate = "config_option_update", +): StoredLogEntry { + return { + type: "notification", + notification: { + method: "session/update", + params: { update: { sessionUpdate, configOptions } }, + }, + } as unknown as StoredLogEntry; +} + +describe("extractLatestConfigOptionsFromEntries", () => { + it("returns undefined when no config_option_update entries exist", () => { + expect(extractLatestConfigOptionsFromEntries([])).toBeUndefined(); + expect( + extractLatestConfigOptionsFromEntries([ + configUpdateEntry([{ id: "mode" }], "agent_message"), + ]), + ).toBeUndefined(); + }); + + it("returns the latest config options across multiple updates", () => { + const result = extractLatestConfigOptionsFromEntries([ + configUpdateEntry([{ id: "mode", currentValue: "plan" }]), + configUpdateEntry([{ id: "mode", currentValue: "auto" }]), + ]); + + expect(result).toEqual([{ id: "mode", currentValue: "auto" }]); + }); +}); + +describe("buildCloudDefaultConfigOptions", () => { + it("includes a mode select with options and the chosen current value", () => { + const options = buildCloudDefaultConfigOptions("plan"); + const mode = options.find((o) => o.id === "mode"); + + expect(mode?.currentValue).toBe("plan"); + if (mode?.type !== "select") { + throw new Error("expected mode to be a select option"); + } + expect(mode.options.length).toBeGreaterThan(0); + }); + + it("defaults claude sessions to plan and codex sessions to auto", () => { + const claude = buildCloudDefaultConfigOptions(undefined, "claude"); + const codex = buildCloudDefaultConfigOptions(undefined, "codex"); + + expect(claude.find((o) => o.id === "mode")?.currentValue).toBe("plan"); + expect(codex.find((o) => o.id === "mode")?.currentValue).toBe("auto"); + }); + + it("appends extra options after the mode option", () => { + const extra = [ + { + id: "model", + name: "Model", + type: "select" as const, + currentValue: "x", + options: [], + }, + ]; + const options = buildCloudDefaultConfigOptions("plan", "claude", extra); + + expect(options[0].id).toBe("mode"); + expect(options.at(-1)?.id).toBe("model"); + }); +}); diff --git a/packages/core/src/sessions/cloudSessionConfig.ts b/packages/core/src/sessions/cloudSessionConfig.ts new file mode 100644 index 0000000000..2f7ae927f9 --- /dev/null +++ b/packages/core/src/sessions/cloudSessionConfig.ts @@ -0,0 +1,78 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import type { Adapter, StoredLogEntry } from "@posthog/shared"; +import { getAvailableCodexModes, getAvailableModes } from "./executionModes"; + +/** + * Pure derivations of cloud session config options. No store or host access — + * just shaping the config-option list the mode switcher renders. + */ + +/** + * Pull the most recent `config_option_update` payload out of a run's stored log + * entries, so a reconnecting cloud session restores its last known options. + */ +export function extractLatestConfigOptionsFromEntries( + entries: StoredLogEntry[], +): SessionConfigOption[] | undefined { + let latest: SessionConfigOption[] | undefined; + for (const entry of entries) { + if ( + entry.type !== "notification" || + entry.notification?.method !== "session/update" + ) { + continue; + } + const params = entry.notification.params as + | { + update?: { + sessionUpdate?: string; + configOptions?: SessionConfigOption[]; + }; + } + | undefined; + if ( + params?.update?.sessionUpdate === "config_option_update" && + params.update.configOptions + ) { + latest = params.update.configOptions; + } + } + return latest; +} + +/** + * Build default configOptions for cloud sessions so the mode switcher is + * available in the UI even without a local agent connection. + * + * The `extra` options (model, thought_level) come from the preview-config trpc + * query, which is async. Callers populate them after the session exists. + */ +export function buildCloudDefaultConfigOptions( + initialMode: string | undefined, + adapter: Adapter = "claude", + extra: SessionConfigOption[] = [], +): SessionConfigOption[] { + const modes = + adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); + const currentMode = + typeof initialMode === "string" + ? initialMode + : adapter === "codex" + ? "auto" + : "plan"; + return [ + { + id: "mode", + name: "Approval Preset", + type: "select", + currentValue: currentMode, + options: modes.map((mode) => ({ + value: mode.id, + name: mode.name, + })), + category: "mode" as SessionConfigOption["category"], + description: "Choose an approval and sandboxing preset for your session", + }, + ...extra, + ]; +} diff --git a/packages/core/src/sessions/connectRouting.test.ts b/packages/core/src/sessions/connectRouting.test.ts new file mode 100644 index 0000000000..5104413c43 --- /dev/null +++ b/packages/core/src/sessions/connectRouting.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + computeAutoRetryFinalState, + OFFLINE_SESSION_MESSAGE, + routeLocalConnect, +} from "./connectRouting"; + +describe("routeLocalConnect", () => { + it("routes to no-auth when auth is missing", () => { + expect( + routeLocalConnect({ + hasAuth: false, + latestRunId: "run-1", + latestRunLogUrl: "https://logs/run-1", + }), + ).toEqual({ kind: "no-auth" }); + }); + + it("routes to resume-existing when run id and log url are present", () => { + expect( + routeLocalConnect({ + hasAuth: true, + latestRunId: "run-1", + latestRunLogUrl: "https://logs/run-1", + }), + ).toEqual({ + kind: "resume-existing", + taskRunId: "run-1", + logUrl: "https://logs/run-1", + }); + }); + + it("routes to create-new when there is no prior run", () => { + expect(routeLocalConnect({ hasAuth: true })).toEqual({ + kind: "create-new", + }); + }); + + it("routes to create-new when run id exists but log url is missing", () => { + expect(routeLocalConnect({ hasAuth: true, latestRunId: "run-1" })).toEqual({ + kind: "create-new", + }); + }); + + it("routes to create-new when log url exists but run id is missing", () => { + expect( + routeLocalConnect({ + hasAuth: true, + latestRunLogUrl: "https://logs/run-1", + }), + ).toEqual({ kind: "create-new" }); + }); +}); + +describe("computeAutoRetryFinalState", () => { + it("returns a disconnected offline state when the device went offline", () => { + expect( + computeAutoRetryFinalState({ + wentOffline: true, + lastRetryMessage: "boom", + originalMessage: "first boom", + }), + ).toEqual({ + status: "disconnected", + errorTitle: undefined, + errorMessage: OFFLINE_SESSION_MESSAGE, + }); + }); + + it("returns an error state with the last retry message when still online", () => { + expect( + computeAutoRetryFinalState({ + wentOffline: false, + lastRetryMessage: "retry boom", + originalMessage: "first boom", + }), + ).toEqual({ + status: "error", + errorTitle: "Failed to connect", + errorMessage: "retry boom", + }); + }); + + it("falls back to the original message when no retry message is set", () => { + expect( + computeAutoRetryFinalState({ + wentOffline: false, + lastRetryMessage: "", + originalMessage: "first boom", + }), + ).toEqual({ + status: "error", + errorTitle: "Failed to connect", + errorMessage: "first boom", + }); + }); +}); diff --git a/packages/core/src/sessions/connectRouting.ts b/packages/core/src/sessions/connectRouting.ts new file mode 100644 index 0000000000..483de0495a --- /dev/null +++ b/packages/core/src/sessions/connectRouting.ts @@ -0,0 +1,52 @@ +import type { SessionStatus } from "@posthog/shared"; + +export type LocalConnectRoute = + | { kind: "no-auth" } + | { kind: "resume-existing"; taskRunId: string; logUrl: string } + | { kind: "create-new" }; + +export function routeLocalConnect(input: { + hasAuth: boolean; + latestRunId?: string | null; + latestRunLogUrl?: string | null; +}): LocalConnectRoute { + if (!input.hasAuth) { + return { kind: "no-auth" }; + } + if (input.latestRunId && input.latestRunLogUrl) { + return { + kind: "resume-existing", + taskRunId: input.latestRunId, + logUrl: input.latestRunLogUrl, + }; + } + return { kind: "create-new" }; +} + +export const OFFLINE_SESSION_MESSAGE = + "No internet connection. Connect when you're back online."; + +export interface AutoRetryFinalState { + status: Extract; + errorTitle?: string; + errorMessage: string; +} + +export function computeAutoRetryFinalState(input: { + wentOffline: boolean; + lastRetryMessage: string; + originalMessage: string; +}): AutoRetryFinalState { + if (input.wentOffline) { + return { + status: "disconnected", + errorTitle: undefined, + errorMessage: OFFLINE_SESSION_MESSAGE, + }; + } + return { + status: "error", + errorTitle: "Failed to connect", + errorMessage: input.lastRetryMessage || input.originalMessage, + }; +} diff --git a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.test.ts b/packages/core/src/sessions/contextUsage.test.ts similarity index 94% rename from apps/code/src/renderer/features/sessions/hooks/useContextUsage.test.ts rename to packages/core/src/sessions/contextUsage.test.ts index 88c37ffa02..67f518b69c 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.test.ts +++ b/packages/core/src/sessions/contextUsage.test.ts @@ -1,6 +1,6 @@ -import type { AcpMessage } from "@shared/types/session-events"; +import type { AcpMessage } from "@posthog/shared"; import { describe, expect, it } from "vitest"; -import { extractContextUsage } from "./useContextUsage"; +import { extractContextUsage } from "./contextUsage"; function usageUpdateEvent(used: number, size: number): AcpMessage { return { diff --git a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts b/packages/core/src/sessions/contextUsage.ts similarity index 74% rename from apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts rename to packages/core/src/sessions/contextUsage.ts index 73a8c68623..4ae4311cd5 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts +++ b/packages/core/src/sessions/contextUsage.ts @@ -1,8 +1,5 @@ -import type { AcpMessage } from "@shared/types/session-events"; -import { useMemo } from "react"; +import type { AcpMessage } from "@posthog/shared"; -// Duplicated rather than imported from `packages/agent` to keep the renderer -// off that dep; lift into `@posthog/shared` if the shape drifts. export interface ContextBreakdown { systemPrompt: number; tools: number; @@ -21,15 +18,6 @@ export interface ContextUsage { breakdown: ContextBreakdown | null; } -/** - * Extract the latest context window usage from session events. - * Scans backwards to find the most recent usage_update notification. - * Re-derives on each new event, giving live updates during streaming. - */ -export function useContextUsage(events: AcpMessage[]): ContextUsage | null { - return useMemo(() => extractContextUsage(events), [events]); -} - export function extractContextUsage(events: AcpMessage[]): ContextUsage | null { let aggregate: Omit | null = null; let breakdown: ContextBreakdown | null = null; @@ -91,9 +79,6 @@ function extractAggregate( function extractBreakdown(msg: AcpMessage["message"]): ContextBreakdown | null { if (!("method" in msg) || !("params" in msg)) return null; - // Method may be received as either `_posthog/usage_update` or - // `__posthog/usage_update` depending on how the transport stringifies it - // (see acp-extensions.ts:matchesExt). if ( msg.method !== "_posthog/usage_update" && msg.method !== "__posthog/usage_update" diff --git a/packages/core/src/sessions/executionModes.ts b/packages/core/src/sessions/executionModes.ts new file mode 100644 index 0000000000..8d471d44f1 --- /dev/null +++ b/packages/core/src/sessions/executionModes.ts @@ -0,0 +1,59 @@ +export interface ModeInfo { + id: string; + name: string; + description: string; +} + +const availableModes: ModeInfo[] = [ + { + id: "default", + name: "Default", + description: "Standard behavior, prompts for dangerous operations", + }, + { + id: "acceptEdits", + name: "Accept Edits", + description: "Auto-accept file edit operations", + }, + { + id: "plan", + name: "Plan Mode", + description: "Planning mode, no actual tool execution", + }, + { + id: "bypassPermissions", + name: "Bypass Permissions", + description: "Auto-accept all permission requests", + }, + { + id: "auto", + name: "Auto Mode", + description: "Use a model classifier to approve/deny permission prompts", + }, +]; + +const codexModes: ModeInfo[] = [ + { + id: "read-only", + name: "Read Only", + description: "Read-only access, no file modifications", + }, + { + id: "auto", + name: "Auto", + description: "Standard behavior, prompts for dangerous operations", + }, + { + id: "full-access", + name: "Full Access", + description: "Auto-accept all permission requests", + }, +]; + +export function getAvailableModes(): ModeInfo[] { + return availableModes; +} + +export function getAvailableCodexModes(): ModeInfo[] { + return codexModes; +} diff --git a/packages/core/src/sessions/localHandoffService.test.ts b/packages/core/src/sessions/localHandoffService.test.ts new file mode 100644 index 0000000000..2f4df2dd29 --- /dev/null +++ b/packages/core/src/sessions/localHandoffService.test.ts @@ -0,0 +1,186 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type LocalHandoffDialog, + type LocalHandoffHost, + type LocalHandoffNotifier, + type LocalHandoffPending, + LocalHandoffService, +} from "./localHandoffService"; +import type { SessionService } from "./sessionService"; + +function makeDeps() { + let pending: LocalHandoffPending | null = null; + + const sessionService = { + preflightToLocal: vi.fn(), + handoffToLocal: vi.fn().mockResolvedValue(undefined), + }; + + const host: LocalHandoffHost = { + getRepositoryByRemoteUrl: vi.fn().mockResolvedValue(null), + selectDirectory: vi.fn().mockResolvedValue(null), + addFolder: vi.fn().mockResolvedValue(undefined), + }; + + const dialog: LocalHandoffDialog = { + openConfirm: vi.fn(), + closeConfirm: vi.fn(), + cancelPendingFlow: vi.fn(), + hideDirtyTree: vi.fn(), + getPendingAfterCommit: vi.fn(() => pending), + clearPendingAfterCommit: vi.fn(() => { + pending = null; + }), + openDirtyTreeForPendingHandoff: vi.fn(), + }; + + const notifier: LocalHandoffNotifier = { + error: vi.fn(), + warn: vi.fn(), + logError: vi.fn(), + }; + + const service = new LocalHandoffService( + sessionService as unknown as SessionService, + host, + dialog, + notifier, + ); + + return { + service, + sessionService, + host, + dialog, + notifier, + setPending: (value: LocalHandoffPending | null) => { + pending = value; + }, + }; +} + +describe("LocalHandoffService.continueAfterDirtyTree", () => { + let deps: ReturnType; + + beforeEach(() => { + deps = makeDeps(); + }); + + it("hides the dirty tree dialog regardless of branch state", () => { + deps.service.continueAfterDirtyTree({ + isFeatureBranch: true, + suggestedBranchName: "fix/thing", + }); + expect(deps.dialog.hideDirtyTree).toHaveBeenCalledOnce(); + }); + + it("routes straight to commit when already on a feature branch", () => { + const step = deps.service.continueAfterDirtyTree({ + isFeatureBranch: true, + suggestedBranchName: "fix/thing", + }); + expect(step).toEqual({ step: "open-commit" }); + }); + + it("routes to branch creation with the suggested name otherwise", () => { + const step = deps.service.continueAfterDirtyTree({ + isFeatureBranch: false, + suggestedBranchName: "fix/thing", + }); + expect(step).toEqual({ step: "open-branch", suggestedName: "fix/thing" }); + }); +}); + +describe("LocalHandoffService.afterBranchCreated", () => { + it("advances to the commit step", () => { + const { service } = makeDeps(); + expect(service.afterBranchCreated()).toEqual({ step: "open-commit" }); + }); +}); + +describe("LocalHandoffService.afterCommit", () => { + it("resumes the pending handoff once a commit succeeds", async () => { + const deps = makeDeps(); + deps.setPending({ + taskId: "task-1", + repoPath: "/repo", + branchName: "fix/thing", + }); + + await deps.service.afterCommit(); + + expect(deps.dialog.clearPendingAfterCommit).toHaveBeenCalledOnce(); + expect(deps.sessionService.handoffToLocal).toHaveBeenCalledWith( + "task-1", + "/repo", + ); + }); + + it("is a no-op when there is no pending handoff", async () => { + const deps = makeDeps(); + deps.setPending(null); + + await deps.service.afterCommit(); + + expect(deps.sessionService.handoffToLocal).not.toHaveBeenCalled(); + }); + + it("reports an error when resuming the handoff fails", async () => { + const deps = makeDeps(); + deps.setPending({ + taskId: "task-1", + repoPath: "/repo", + branchName: null, + }); + deps.sessionService.handoffToLocal.mockRejectedValueOnce(new Error("boom")); + + await deps.service.afterCommit(); + + expect(deps.notifier.error).toHaveBeenCalledWith( + "Failed to continue locally: boom", + ); + }); +}); + +describe("LocalHandoffService.start", () => { + const task = { repository: "https://example.com/repo.git" } as Task; + + it("hands off immediately when preflight is clean", async () => { + const deps = makeDeps(); + deps.host.getRepositoryByRemoteUrl = vi + .fn() + .mockResolvedValue({ path: "/repo" }); + deps.sessionService.preflightToLocal.mockResolvedValue({ + canHandoff: true, + }); + + await deps.service.start("task-1", task); + + expect(deps.dialog.closeConfirm).toHaveBeenCalled(); + expect(deps.sessionService.handoffToLocal).toHaveBeenCalledWith( + "task-1", + "/repo", + ); + }); + + it("opens the dirty-tree dialog when the local tree is dirty", async () => { + const deps = makeDeps(); + deps.host.getRepositoryByRemoteUrl = vi + .fn() + .mockResolvedValue({ path: "/repo" }); + deps.sessionService.preflightToLocal.mockResolvedValue({ + canHandoff: false, + localTreeDirty: true, + changedFiles: [{ path: "a.ts" }], + localGitState: { branch: "main" }, + }); + + await deps.service.start("task-1", task); + + expect(deps.dialog.openDirtyTreeForPendingHandoff).toHaveBeenCalledWith( + [{ path: "a.ts" }], + { taskId: "task-1", repoPath: "/repo", branchName: "main" }, + ); + }); +}); diff --git a/packages/core/src/sessions/localHandoffService.ts b/packages/core/src/sessions/localHandoffService.ts new file mode 100644 index 0000000000..6856c30d67 --- /dev/null +++ b/packages/core/src/sessions/localHandoffService.ts @@ -0,0 +1,196 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { inject, injectable } from "inversify"; +import { SESSION_SERVICE, type SessionService } from "./sessionService"; + +export const LOCAL_HANDOFF_SERVICE = Symbol.for( + "posthog.core.sessions.localHandoffService", +); + +export const LOCAL_HANDOFF_HOST = Symbol.for( + "posthog.core.sessions.localHandoffHost", +); + +export const LOCAL_HANDOFF_DIALOG = Symbol.for( + "posthog.core.sessions.localHandoffDialog", +); + +export const LOCAL_HANDOFF_NOTIFIER = Symbol.for( + "posthog.core.sessions.localHandoffNotifier", +); + +export interface LocalHandoffHost { + getRepositoryByRemoteUrl(input: { + remoteUrl: string; + }): Promise<{ path: string } | null>; + selectDirectory(): Promise; + addFolder(input: { + folderPath: string; + remoteUrl?: string; + }): Promise; +} + +export interface LocalHandoffPending { + taskId: string; + repoPath: string; + branchName: string | null; +} + +export interface ContinueAfterDirtyTreeContext { + isFeatureBranch: boolean; + suggestedBranchName: string; +} + +export type ContinueAfterDirtyTreeStep = + | { step: "open-commit" } + | { step: "open-branch"; suggestedName: string }; + +export interface LocalHandoffDialog { + openConfirm(taskId: string, branchName: string | null): void; + closeConfirm(): void; + cancelPendingFlow(): void; + hideDirtyTree(): void; + getPendingAfterCommit(): LocalHandoffPending | null; + clearPendingAfterCommit(): void; + openDirtyTreeForPendingHandoff( + changedFiles: unknown[], + pending: LocalHandoffPending, + ): void; +} + +export interface LocalHandoffNotifier { + error(message: string): void; + warn(message: string, data?: unknown): void; + logError(message: string, data?: unknown): void; +} + +@injectable() +export class LocalHandoffService { + constructor( + @inject(SESSION_SERVICE) + private readonly sessionService: SessionService, + @inject(LOCAL_HANDOFF_HOST) + private readonly host: LocalHandoffHost, + @inject(LOCAL_HANDOFF_DIALOG) + private readonly dialog: LocalHandoffDialog, + @inject(LOCAL_HANDOFF_NOTIFIER) + private readonly notifier: LocalHandoffNotifier, + ) {} + + public openConfirm(taskId: string, branchName: string | null): void { + this.dialog.openConfirm(taskId, branchName); + } + + public closeConfirm(): void { + this.dialog.closeConfirm(); + } + + public cancelPendingFlow(): void { + this.dialog.cancelPendingFlow(); + } + + public hideDirtyTree(): void { + this.dialog.hideDirtyTree(); + } + + public getPendingAfterCommit(): LocalHandoffPending | null { + return this.dialog.getPendingAfterCommit(); + } + + public async start(taskId: string, task: Task): Promise { + try { + const targetPath = + (await this.resolveRepoPathFromRemote(task.repository)) ?? + (await this.resolveRepoPathFromPicker(task.repository)); + + if (!targetPath) return; + + const preflight = await this.sessionService.preflightToLocal( + taskId, + targetPath, + ); + + if (preflight.canHandoff) { + this.closeConfirm(); + await this.sessionService.handoffToLocal(taskId, targetPath); + return; + } + + if (preflight.localTreeDirty && preflight.changedFiles) { + this.dialog.openDirtyTreeForPendingHandoff(preflight.changedFiles, { + taskId, + repoPath: targetPath, + branchName: preflight.localGitState?.branch ?? null, + }); + return; + } + + this.notifier.error(preflight.reason ?? "Cannot continue locally"); + this.closeConfirm(); + } catch (error) { + this.notifier.logError("Failed to hand off to local", error); + const message = error instanceof Error ? error.message : "Unknown error"; + this.notifier.error(`Failed to continue locally: ${message}`); + this.closeConfirm(); + } + } + + public continueAfterDirtyTree( + ctx: ContinueAfterDirtyTreeContext, + ): ContinueAfterDirtyTreeStep { + this.dialog.hideDirtyTree(); + if (ctx.isFeatureBranch) { + return { step: "open-commit" }; + } + return { step: "open-branch", suggestedName: ctx.suggestedBranchName }; + } + + public afterBranchCreated(): ContinueAfterDirtyTreeStep { + return { step: "open-commit" }; + } + + public async afterCommit(): Promise { + await this.resumePending(); + } + + public async resumePending(): Promise { + const pending = this.getPendingAfterCommit(); + if (!pending) return; + + this.dialog.clearPendingAfterCommit(); + + try { + await this.sessionService.handoffToLocal( + pending.taskId, + pending.repoPath, + ); + } catch (error) { + this.notifier.logError("Failed to resume handoff to local", error); + const message = error instanceof Error ? error.message : "Unknown error"; + this.notifier.error(`Failed to continue locally: ${message}`); + } + } + + private async resolveRepoPathFromRemote( + remoteUrl: string | undefined | null, + ): Promise { + if (!remoteUrl) return null; + const repo = await this.host.getRepositoryByRemoteUrl({ + remoteUrl, + }); + return repo?.path ?? null; + } + + private async resolveRepoPathFromPicker( + remoteUrl: string | null | undefined, + ): Promise { + const selectedPath = await this.host.selectDirectory(); + if (!selectedPath) return null; + + await this.host.addFolder({ + folderPath: selectedPath, + remoteUrl: remoteUrl ?? undefined, + }); + + return selectedPath; + } +} diff --git a/packages/core/src/sessions/permissionResponse.test.ts b/packages/core/src/sessions/permissionResponse.test.ts new file mode 100644 index 0000000000..7b6e8f26bb --- /dev/null +++ b/packages/core/src/sessions/permissionResponse.test.ts @@ -0,0 +1,80 @@ +import type { PermissionRequest } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { + isOtherPermissionOption, + planPermissionResponse, +} from "./permissionResponse"; + +function makePermission( + options: Array<{ + optionId: string; + kind?: string; + _meta?: Record; + }>, + toolCallKind?: string, +): PermissionRequest & { toolCallId: string } { + return { + taskRunId: "run-1", + receivedAt: 0, + toolCallId: "tool-1", + toolCall: toolCallKind ? { kind: toolCallKind } : undefined, + options, + } as unknown as PermissionRequest & { toolCallId: string }; +} + +describe("isOtherPermissionOption", () => { + it("recognizes both canonical other ids", () => { + expect(isOtherPermissionOption("_other")).toBe(true); + expect(isOtherPermissionOption("other")).toBe(true); + expect(isOtherPermissionOption("allow")).toBe(false); + }); +}); + +describe("planPermissionResponse", () => { + it("flags allow_always upgrade when option is allow_always and not a mode switch", () => { + const permission = makePermission([ + { optionId: "allow", kind: "allow_always" }, + ]); + const plan = planPermissionResponse(permission, "allow"); + expect(plan.applyAllowAlwaysUpgrade).toBe(true); + }); + + it("does not upgrade for allow_always when tool call is a mode switch", () => { + const permission = makePermission( + [{ optionId: "allow", kind: "allow_always" }], + "switch_mode", + ); + const plan = planPermissionResponse(permission, "allow"); + expect(plan.applyAllowAlwaysUpgrade).toBe(false); + }); + + it("responds with custom input for the other option", () => { + const permission = makePermission([{ optionId: "_other" }]); + const plan = planPermissionResponse(permission, "_other", "do this"); + expect(plan.respondWithCustomInput).toBe(true); + expect(plan.resendPromptText).toBeNull(); + }); + + it("responds with custom input when option meta opts in", () => { + const permission = makePermission([ + { optionId: "feedback", _meta: { customInput: true } }, + ]); + const plan = planPermissionResponse(permission, "feedback", "more detail"); + expect(plan.respondWithCustomInput).toBe(true); + expect(plan.resendPromptText).toBeNull(); + }); + + it("re-sends custom input as a prompt for a plain option", () => { + const permission = makePermission([{ optionId: "allow" }]); + const plan = planPermissionResponse(permission, "allow", "follow up"); + expect(plan.respondWithCustomInput).toBe(false); + expect(plan.resendPromptText).toBe("follow up"); + }); + + it("responds plainly with no custom input", () => { + const permission = makePermission([{ optionId: "allow" }]); + const plan = planPermissionResponse(permission, "allow"); + expect(plan.respondWithCustomInput).toBe(false); + expect(plan.resendPromptText).toBeNull(); + }); +}); diff --git a/packages/core/src/sessions/permissionResponse.ts b/packages/core/src/sessions/permissionResponse.ts new file mode 100644 index 0000000000..44db453815 --- /dev/null +++ b/packages/core/src/sessions/permissionResponse.ts @@ -0,0 +1,54 @@ +import type { PermissionRequest } from "@posthog/shared"; + +const OTHER_OPTION_ID = "_other"; +const OTHER_OPTION_ID_ALT = "other"; + +export function isOtherPermissionOption(optionId: string): boolean { + return optionId === OTHER_OPTION_ID || optionId === OTHER_OPTION_ID_ALT; +} + +export interface PermissionSelectionPlan { + applyAllowAlwaysUpgrade: boolean; + respondWithCustomInput: boolean; + resendPromptText: string | null; +} + +export function planPermissionResponse( + permission: PermissionRequest, + optionId: string, + customInput?: string, +): PermissionSelectionPlan { + const selectedOption = permission.options.find( + (o) => o.optionId === optionId, + ); + const isModeSwitch = permission.toolCall?.kind === "switch_mode"; + const applyAllowAlwaysUpgrade = + selectedOption?.kind === "allow_always" && !isModeSwitch; + + const optionTakesCustomInput = + isOtherPermissionOption(optionId) || + (selectedOption?._meta as { customInput?: boolean } | undefined) + ?.customInput === true; + + if (customInput && optionTakesCustomInput) { + return { + applyAllowAlwaysUpgrade, + respondWithCustomInput: true, + resendPromptText: null, + }; + } + + if (customInput) { + return { + applyAllowAlwaysUpgrade, + respondWithCustomInput: false, + resendPromptText: customInput, + }; + } + + return { + applyAllowAlwaysUpgrade, + respondWithCustomInput: false, + resendPromptText: null, + }; +} diff --git a/apps/code/src/renderer/utils/promptContent.test.ts b/packages/core/src/sessions/promptContent.test.ts similarity index 100% rename from apps/code/src/renderer/utils/promptContent.test.ts rename to packages/core/src/sessions/promptContent.test.ts diff --git a/apps/code/src/renderer/utils/promptContent.ts b/packages/core/src/sessions/promptContent.ts similarity index 98% rename from apps/code/src/renderer/utils/promptContent.ts rename to packages/core/src/sessions/promptContent.ts index 66436bcc3a..5754d7f4e1 100644 --- a/apps/code/src/renderer/utils/promptContent.ts +++ b/packages/core/src/sessions/promptContent.ts @@ -1,5 +1,5 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { getFileName } from "@utils/path"; +import { getFileName } from "@posthog/shared"; export const ATTACHMENT_URI_PREFIX = "attachment://"; diff --git a/apps/code/src/renderer/utils/session.test.ts b/packages/core/src/sessions/sessionEvents.test.ts similarity index 69% rename from apps/code/src/renderer/utils/session.test.ts rename to packages/core/src/sessions/sessionEvents.test.ts index 8f62f80fa7..d57bb33126 100644 --- a/apps/code/src/renderer/utils/session.test.ts +++ b/packages/core/src/sessions/sessionEvents.test.ts @@ -1,9 +1,15 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; -import type { AcpMessage } from "@shared/types/session-events"; +import type { AcpMessage } from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { makeAttachmentUri } from "./promptContent"; -import { extractUserPromptsFromEvents, isFatalSessionError } from "./session"; +import { + extractUserPromptsFromEvents, + hasSessionPromptEvent, + isAbsoluteFolderPath, + isFatalSessionError, + promptReferencesAbsoluteFolder, +} from "./sessionEvents"; describe("isFatalSessionError", () => { it("detects fatal 'Internal error' pattern", () => { @@ -167,3 +173,67 @@ describe("extractUserPromptsFromEvents", () => { ]); }); }); + +describe("hasSessionPromptEvent", () => { + const promptRequest: AcpMessage = { + type: "acp_message", + ts: 1, + message: { jsonrpc: "2.0", id: 1, method: "session/prompt", params: {} }, + }; + const notification: AcpMessage = { + type: "acp_message", + ts: 2, + message: { jsonrpc: "2.0", method: "session/update", params: {} }, + }; + + it("is true when a session/prompt request is present", () => { + expect(hasSessionPromptEvent([notification, promptRequest])).toBe(true); + }); + + it("is false when no session/prompt request is present", () => { + expect(hasSessionPromptEvent([notification])).toBe(false); + expect(hasSessionPromptEvent([])).toBe(false); + }); +}); + +describe("isAbsoluteFolderPath", () => { + it.each(["/Users/x/repo", "~/repo", "C:\\repo", "D:/repo"])( + "treats %s as absolute", + (path) => { + expect(isAbsoluteFolderPath(path)).toBe(true); + }, + ); + + it.each(["repo", "./repo", "src/index.ts"])( + "treats %s as not absolute", + (path) => { + expect(isAbsoluteFolderPath(path)).toBe(false); + }, + ); +}); + +describe("promptReferencesAbsoluteFolder", () => { + it("detects an absolute folder tag in a string prompt", () => { + expect( + promptReferencesAbsoluteFolder('see '), + ).toBe(true); + }); + + it("returns false for a relative folder tag", () => { + expect( + promptReferencesAbsoluteFolder('see '), + ).toBe(false); + }); + + it("scans ContentBlock text for absolute folder tags", () => { + const blocks: ContentBlock[] = [ + { type: "text", text: "intro" }, + { type: "text", text: '' }, + ]; + expect(promptReferencesAbsoluteFolder(blocks)).toBe(true); + }); + + it("returns false when no folder tag is present", () => { + expect(promptReferencesAbsoluteFolder("just text")).toBe(false); + }); +}); diff --git a/apps/code/src/renderer/utils/session.ts b/packages/core/src/sessions/sessionEvents.ts similarity index 77% rename from apps/code/src/renderer/utils/session.ts rename to packages/core/src/sessions/sessionEvents.ts index ec99a997d2..efb1ef85f3 100644 --- a/apps/code/src/renderer/utils/session.ts +++ b/packages/core/src/sessions/sessionEvents.ts @@ -13,12 +13,10 @@ import type { JsonRpcRequest, StoredLogEntry, UserShellExecuteParams, -} from "@shared/types/session-events"; -import { - isJsonRpcNotification, - isJsonRpcRequest, -} from "@shared/types/session-events"; -import { extractPromptDisplayContent } from "@utils/promptContent"; +} from "@posthog/shared"; +import { isJsonRpcNotification, isJsonRpcRequest } from "@posthog/shared"; +import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; +import { extractPromptDisplayContent } from "./promptContent"; /** * Convert a stored log entry to an ACP message. @@ -221,4 +219,57 @@ export function normalizePromptToBlocks( return typeof prompt === "string" ? [{ type: "text", text: prompt }] : prompt; } -export { isFatalSessionError, isRateLimitError } from "@shared/errors"; +export { isFatalSessionError, isRateLimitError } from "@posthog/shared"; + +/** + * Whether a list of events already contains a `session/prompt` request. + */ +export function hasSessionPromptEvent(events: AcpMessage[]): boolean { + return events.some( + (event) => + isJsonRpcRequest(event.message) && + event.message.method === "session/prompt", + ); +} + +/** + * Whether an event is a turn-complete notification. + */ +export function isTurnCompleteEvent(event: AcpMessage): boolean { + const msg = event.message; + return ( + "method" in msg && + isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE) + ); +} + +const FOLDER_TAG_REGEX = //g; + +/** + * Whether a path string looks like an absolute (or home-relative) folder path. + */ +export function isAbsoluteFolderPath(path: string): boolean { + return ( + path.startsWith("/") || path.startsWith("~") || /^[A-Za-z]:[\\/]/.test(path) + ); +} + +/** + * Whether a prompt references an absolute folder via a `` tag. + */ +export function promptReferencesAbsoluteFolder( + prompt: string | ContentBlock[], +): boolean { + const text = + typeof prompt === "string" + ? prompt + : prompt + .map((block) => + "text" in block && typeof block.text === "string" ? block.text : "", + ) + .join(""); + for (const match of text.matchAll(FOLDER_TAG_REGEX)) { + if (isAbsoluteFolderPath(match[1])) return true; + } + return false; +} diff --git a/packages/core/src/sessions/sessionFactory.test.ts b/packages/core/src/sessions/sessionFactory.test.ts new file mode 100644 index 0000000000..c52c8dc5f8 --- /dev/null +++ b/packages/core/src/sessions/sessionFactory.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { createBaseSession } from "./sessionFactory"; + +describe("createBaseSession", () => { + it("builds a connecting session with empty collections", () => { + const session = createBaseSession("run-1", "task-1", "My Task"); + + expect(session).toMatchObject({ + taskRunId: "run-1", + taskId: "task-1", + taskTitle: "My Task", + channel: "agent-event:run-1", + status: "connecting", + isPromptPending: false, + isCompacting: false, + promptStartedAt: null, + pausedDurationMs: 0, + }); + expect(session.events).toEqual([]); + expect(session.messageQueue).toEqual([]); + expect(session.optimisticItems).toEqual([]); + expect(session.pendingPermissions).toBeInstanceOf(Map); + expect(session.pendingPermissions.size).toBe(0); + expect(typeof session.startedAt).toBe("number"); + }); + + it("derives the channel name from the task run id", () => { + expect(createBaseSession("abc", "t", "title").channel).toBe( + "agent-event:abc", + ); + }); + + it("returns independent collection instances per call", () => { + const a = createBaseSession("run-a", "task-a", "A"); + const b = createBaseSession("run-b", "task-b", "B"); + a.events.push({ message: { method: "x" } } as never); + expect(b.events).toEqual([]); + expect(a.pendingPermissions).not.toBe(b.pendingPermissions); + }); +}); diff --git a/packages/core/src/sessions/sessionFactory.ts b/packages/core/src/sessions/sessionFactory.ts new file mode 100644 index 0000000000..ea76308228 --- /dev/null +++ b/packages/core/src/sessions/sessionFactory.ts @@ -0,0 +1,24 @@ +import type { AgentSession } from "@posthog/shared"; + +export function createBaseSession( + taskRunId: string, + taskId: string, + taskTitle: string, +): AgentSession { + return { + taskRunId, + taskId, + taskTitle, + channel: `agent-event:${taskRunId}`, + events: [], + startedAt: Date.now(), + status: "connecting", + isPromptPending: false, + isCompacting: false, + promptStartedAt: null, + pendingPermissions: new Map(), + pausedDurationMs: 0, + messageQueue: [], + optimisticItems: [], + }; +} diff --git a/packages/core/src/sessions/sessionLogs.test.ts b/packages/core/src/sessions/sessionLogs.test.ts new file mode 100644 index 0000000000..95007805dd --- /dev/null +++ b/packages/core/src/sessions/sessionLogs.test.ts @@ -0,0 +1,91 @@ +import type { AcpMessage } from "@posthog/shared"; +import { describe, expect, it, vi } from "vitest"; +import { parseSessionLogContent, planSkippedPromptFilter } from "./sessionLogs"; + +function promptEvent(id: number): AcpMessage { + return { message: { id, method: "session/prompt" } } as AcpMessage; +} +function notifyEvent(method: string): AcpMessage { + return { message: { method } } as AcpMessage; +} + +describe("parseSessionLogContent", () => { + it("parses one stored entry per line", () => { + const content = [ + JSON.stringify({ type: "request", message: { id: 1 } }), + JSON.stringify({ type: "notification", notification: { method: "x" } }), + ].join("\n"); + + const result = parseSessionLogContent(content); + + expect(result.rawEntries).toHaveLength(2); + expect(result.totalLineCount).toBe(2); + expect(result.parseFailureCount).toBe(0); + expect(result.sessionId).toBeUndefined(); + expect(result.adapter).toBeUndefined(); + }); + + it("extracts sessionId and adapter from a posthog/sdk_session notification", () => { + const content = JSON.stringify({ + type: "notification", + notification: { + method: "_posthog/sdk_session", + params: { sessionId: "sess-9", adapter: "codex" }, + }, + }); + + const result = parseSessionLogContent(content); + + expect(result.sessionId).toBe("sess-9"); + expect(result.adapter).toBe("codex"); + }); + + it("falls back to sdkSessionId when sessionId is absent", () => { + const content = JSON.stringify({ + type: "notification", + notification: { + method: "agent/posthog/sdk_session", + params: { sdkSessionId: "sdk-7" }, + }, + }); + + expect(parseSessionLogContent(content).sessionId).toBe("sdk-7"); + }); + + it("counts parse failures and invokes onParseError for each bad line", () => { + const onParseError = vi.fn(); + const content = ["not json", JSON.stringify({ type: "request" })].join( + "\n", + ); + + const result = parseSessionLogContent(content, { onParseError }); + + expect(result.parseFailureCount).toBe(1); + expect(result.rawEntries).toHaveLength(1); + expect(onParseError).toHaveBeenCalledTimes(1); + expect(onParseError).toHaveBeenCalledWith("not json"); + }); +}); + +describe("planSkippedPromptFilter", () => { + it("returns null when there is nothing to skip", () => { + expect(planSkippedPromptFilter(0, [promptEvent(1)])).toBeNull(); + expect(planSkippedPromptFilter(undefined, [promptEvent(1)])).toBeNull(); + }); + + it("returns null when no session/prompt event is present", () => { + expect( + planSkippedPromptFilter(2, [notifyEvent("a"), notifyEvent("b")]), + ).toBeNull(); + }); + + it("drops the first session/prompt event and decrements the skip count", () => { + const events = [notifyEvent("a"), promptEvent(1), notifyEvent("b")]; + const plan = planSkippedPromptFilter(2, events); + + expect(plan).not.toBeNull(); + expect(plan?.remainingSkipCount).toBe(1); + expect(plan?.events).toEqual([notifyEvent("a"), notifyEvent("b")]); + expect(events).toHaveLength(3); + }); +}); diff --git a/packages/core/src/sessions/sessionLogs.ts b/packages/core/src/sessions/sessionLogs.ts new file mode 100644 index 0000000000..b0150edf4b --- /dev/null +++ b/packages/core/src/sessions/sessionLogs.ts @@ -0,0 +1,73 @@ +import type { AcpMessage, Adapter, StoredLogEntry } from "@posthog/shared"; +import { isJsonRpcRequest } from "@posthog/shared"; + +export interface ParsedSessionLogs { + rawEntries: StoredLogEntry[]; + totalLineCount: number; + parseFailureCount: number; + sessionId?: string; + adapter?: Adapter; +} + +export function parseSessionLogContent( + content: string, + options: { onParseError?: (line: string) => void } = {}, +): ParsedSessionLogs { + const rawEntries: StoredLogEntry[] = []; + let sessionId: string | undefined; + let adapter: Adapter | undefined; + let parseFailureCount = 0; + const lines = content.trim().split("\n"); + + for (const line of lines) { + try { + const stored = JSON.parse(line) as StoredLogEntry; + rawEntries.push(stored); + + if ( + stored.type === "notification" && + stored.notification?.method?.endsWith("posthog/sdk_session") + ) { + const params = stored.notification.params as { + sessionId?: string; + sdkSessionId?: string; + adapter?: Adapter; + }; + if (params?.sessionId) sessionId = params.sessionId; + else if (params?.sdkSessionId) sessionId = params.sdkSessionId; + if (params?.adapter) adapter = params.adapter; + } + } catch { + parseFailureCount += 1; + options.onParseError?.(line); + } + } + + return { + rawEntries, + totalLineCount: lines.length, + parseFailureCount, + sessionId, + adapter, + }; +} + +export function planSkippedPromptFilter( + skipPolledPromptCount: number | undefined, + events: AcpMessage[], +): { events: AcpMessage[]; remainingSkipCount: number } | null { + if (!skipPolledPromptCount || skipPolledPromptCount <= 0) { + return null; + } + + const promptIdx = events.findIndex( + (e) => isJsonRpcRequest(e.message) && e.message.method === "session/prompt", + ); + if (promptIdx === -1) { + return null; + } + + const filtered = [...events]; + filtered.splice(promptIdx, 1); + return { events: filtered, remainingSkipCount: skipPolledPromptCount - 1 }; +} diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/packages/core/src/sessions/sessionService.ts similarity index 70% rename from apps/code/src/renderer/features/sessions/service/service.ts rename to packages/core/src/sessions/sessionService.ts index c0903429bd..af033d3931 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -1,90 +1,85 @@ +// biome-ignore-all lint/suspicious/noExplicitAny: SessionServiceDeps is the +// host seam for the ported renderer SessionService; the trpc/store/helper ports +// are satisfied by the desktop adapter and typed loosely at this boundary. import type { ContentBlock, RequestPermissionRequest, SessionConfigOption, + SessionUpdate, } from "@agentclientprotocol/sdk"; import { - createAuthenticatedClient, - getAuthenticatedClient, -} from "@features/auth/hooks/authClient"; -import { fetchAuthState } from "@features/auth/hooks/authQueries"; -import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; -import { useAddDirectoryDialogStore } from "@features/folder-picker/stores/addDirectoryDialogStore"; -import { useSessionAdapterStore } from "@features/sessions/stores/sessionAdapterStore"; -import { - getPersistedConfigOptions, - removePersistedConfigOptions, - setPersistedConfigOptions, - updatePersistedConfigOptionValue, -} from "@features/sessions/stores/sessionConfigStore"; -import type { - Adapter, - AgentSession, - PermissionRequest, -} from "@features/sessions/stores/sessionStore"; -import { + type AcpMessage, + type Adapter, + type AgentSession, + type CloudRegion, + type ExecutionMode, flattenSelectOptions, + getBackoffDelay, + getCloudUrlFromRegion, getConfigOptionByCategory, + isFatalSessionError, + isJsonRpcNotification, + isJsonRpcRequest, + isJsonRpcResponse, + isRateLimitError, mergeConfigOptions, - sessionStoreSetters, -} from "@features/sessions/stores/sessionStore"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { taskViewedApi } from "@features/sidebar/hooks/useTaskViewed"; -import { extractSkillButtonId } from "@features/skill-buttons/prompts"; -import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent"; -import { - getAvailableCodexModes, - getAvailableModes, -} from "@posthog/agent/execution-mode"; -import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; -import { getIsOnline } from "@renderer/stores/connectivityStore"; -import { trpc } from "@renderer/trpc"; -import { trpcClient } from "@renderer/trpc/client"; -import { toast } from "@renderer/utils/toast"; + type OptimisticItem, + type PermissionRequest, + type QueuedMessage, + type StoredLogEntry, + type TaskRunStatus, +} from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { type CloudTaskPermissionRequestUpdate, type CloudTaskUpdatePayload, type EffortLevel, - type ExecutionMode, effortLevelSchema, isTerminalStatus, type Task, - type TaskRun, -} from "@shared/types"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud"; -import type { AcpMessage, StoredLogEntry } from "@shared/types/session-events"; -import { isJsonRpcRequest } from "@shared/types/session-events"; -import { getBackoffDelay } from "@shared/utils/backoff"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { buildPermissionToolMetadata, track } from "@utils/analytics"; -import { logger } from "@utils/logger"; +} from "@posthog/shared/domain-types"; +import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; +import type { CloudArtifactClient } from "./cloudArtifactIdentifiers"; +import { classifyCloudLogAppend } from "./cloudLogGap"; +import { CloudLogGapReconciler } from "./cloudLogGapReconciler"; +import { CloudRunIdleTracker } from "./cloudRunIdleTracker"; +import { + getCloudPrAuthorshipMode, + getCloudRunSource, + getCloudRuntimeOptions, +} from "./cloudRunOptions"; +import { + buildCloudDefaultConfigOptions, + extractLatestConfigOptionsFromEntries, +} from "./cloudSessionConfig"; import { - notifyPermissionRequest, - notifyPromptComplete, -} from "@utils/notifications"; -import { queryClient } from "@utils/queryClient"; + computeAutoRetryFinalState, + OFFLINE_SESSION_MESSAGE, + routeLocalConnect, +} from "./connectRouting"; +import { + type PermissionSelectionPlan, + planPermissionResponse, +} from "./permissionResponse"; import { convertStoredEntriesToEvents, createUserPromptEvent, createUserShellExecuteEvent, extractPromptText, getUserShellExecutesSinceLastPrompt, - isFatalSessionError, - isRateLimitError, + hasSessionPromptEvent, + isTurnCompleteEvent, normalizePromptToBlocks, + promptReferencesAbsoluteFolder, shellExecutesToContextBlocks, -} from "@utils/session"; +} from "./sessionEvents"; +import { createBaseSession } from "./sessionFactory"; import { - cloudPromptToBlocks, - combineQueuedCloudPrompts, - getCloudPromptTransport, - uploadRunAttachments, - uploadTaskStagedAttachments, -} from "../utils/cloudArtifacts"; -import { CloudRunIdleTracker } from "./cloudRunIdleTracker"; + type ParsedSessionLogs, + parseSessionLogContent, + planSkippedPromptFilter, +} from "./sessionLogs"; -const log = logger.scope("session-service"); const LOCAL_SESSION_RECONNECT_ATTEMPTS = 3; const LOCAL_SESSION_RECONNECT_BACKOFF = { initialDelayMs: 1_000, @@ -107,119 +102,176 @@ class GitHubAuthorizationRequiredForCloudHandoffError extends Error { } } -/** - * Build default configOptions for cloud sessions so the mode switcher - * is available in the UI even without a local agent connection. - * - * The `extra` options (model, thought_level) come from the preview-config - * trpc query, which is async. Callers populate them by calling - * `fetchAndApplyCloudPreviewOptions` after the session exists in the store. - */ -function extractLatestConfigOptionsFromEntries( - entries: StoredLogEntry[], -): SessionConfigOption[] | undefined { - let latest: SessionConfigOption[] | undefined; - for (const entry of entries) { - if ( - entry.type !== "notification" || - entry.notification?.method !== "session/update" - ) { - continue; - } - const params = entry.notification.params as - | { - update?: { - sessionUpdate?: string; - configOptions?: SessionConfigOption[]; - }; - } - | undefined; - if ( - params?.update?.sessionUpdate === "config_option_update" && - params.update.configOptions - ) { - latest = params.update.configOptions; - } - } - return latest; -} +type TrpcMutation = { mutate: (input?: any) => Promise }; +type TrpcQuery = { query: (input?: any) => Promise }; +type TrpcSubscription = { + subscribe: ( + input: any, + handlers: { onData: (data: any) => void; onError?: (err: unknown) => void }, + ) => { unsubscribe: () => void }; +}; -function hasSessionPromptEvent(events: AcpMessage[]): boolean { - return events.some( - (event) => - isJsonRpcRequest(event.message) && - event.message.method === "session/prompt", - ); +export interface SessionTrpc { + agent: { + start: TrpcMutation; + reconnect: TrpcMutation; + cancel: TrpcMutation; + prompt: TrpcMutation; + cancelPrompt: TrpcMutation; + cancelPermission: TrpcMutation; + respondToPermission: TrpcMutation; + setConfigOption: TrpcMutation; + resetAll: TrpcMutation; + recordActivity: TrpcMutation; + getPreviewConfigOptions: TrpcQuery; + onSessionEvent: TrpcSubscription; + onPermissionRequest: TrpcSubscription; + onSessionIdleKilled: TrpcSubscription; + }; + workspace: { verify: TrpcQuery }; + cloudTask: { + watch: TrpcMutation; + unwatch: TrpcMutation; + retry: TrpcMutation; + sendCommand: TrpcMutation; + onUpdate: TrpcSubscription; + }; + handoff: { + execute: TrpcMutation; + executeToCloud: TrpcMutation; + preflight: TrpcQuery; + preflightToCloud: TrpcQuery; + }; + logs: { + readLocalLogs: TrpcQuery; + fetchS3Logs: TrpcQuery; + writeLocalLogs: TrpcMutation; + }; + os: { openExternal: TrpcMutation }; } -function buildCloudDefaultConfigOptions( - initialMode: string | undefined, - adapter: Adapter = "claude", - extra: SessionConfigOption[] = [], -): SessionConfigOption[] { - const modes = - adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const currentMode = - typeof initialMode === "string" - ? initialMode - : adapter === "codex" - ? "auto" - : "plan"; - return [ - { - id: "mode", - name: "Approval Preset", - type: "select", - currentValue: currentMode, - options: modes.map((mode) => ({ - value: mode.id, - name: mode.name, - })), - category: "mode" as SessionConfigOption["category"], - description: "Choose an approval and sandboxing preset for your session", +export interface ISessionStore { + setSession(session: AgentSession): void; + removeSession(taskRunId: string): void; + updateSession(taskRunId: string, updates: Partial): void; + appendEvents( + taskRunId: string, + events: AcpMessage[], + newLineCount?: number, + ): void; + updateCloudStatus( + taskRunId: string, + fields: { + status?: TaskRunStatus; + stage?: string | null; + output?: Record | null; + errorMessage?: string | null; + branch?: string | null; }, - ...extra, - ]; -} - -function isTurnCompleteEvent(event: AcpMessage): boolean { - const msg = event.message; - return ( - "method" in msg && - isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE) - ); -} - -interface AuthCredentials { - apiHost: string; - projectId: number; - client: NonNullable>>; + ): void; + setPendingPermissions( + taskRunId: string, + permissions: Map, + ): void; + enqueueMessage( + taskId: string, + content: string, + rawPrompt?: string | ContentBlock[], + ): void; + removeQueuedMessage(taskId: string, messageId: string): void; + clearMessageQueue(taskId: string): void; + dequeueMessagesAsText(taskId: string): string | null; + dequeueMessages(taskId: string): QueuedMessage[]; + prependQueuedMessages(taskId: string, messages: QueuedMessage[]): void; + appendOptimisticItem( + taskRunId: string, + item: OptimisticItem extends infer T + ? T extends { id: string } + ? Omit + : never + : never, + ): void; + clearOptimisticItems(taskRunId: string): void; + clearTailOptimisticItems(taskRunId: string): void; + replaceOptimisticWithEvent(taskRunId: string, event: AcpMessage): void; + getSessionByTaskId(taskId: string): AgentSession | undefined; + getSessions(): Record; } -interface CloudLogGapReconcileRequest { - taskId: string; - taskRunId: string; - expectedCount: number; - currentCount: number; - newEntries: StoredLogEntry[]; - logUrl?: string; +export interface SessionServiceHelpers { + extractSkillButtonId: (...args: any[]) => any; + cloudPromptToBlocks: (...args: any[]) => any; + combineQueuedCloudPrompts: (...args: any[]) => any; + getCloudPromptTransport: (...args: any[]) => any; + uploadRunAttachments: ( + client: CloudArtifactClient, + taskId: string, + runId: string, + filePaths: string[], + ) => Promise; + uploadTaskStagedAttachments: ( + client: CloudArtifactClient, + taskId: string, + filePaths: string[], + ) => Promise; } -interface ParsedSessionLogs { - rawEntries: StoredLogEntry[]; - totalLineCount: number; - parseFailureCount: number; - sessionId?: string; - adapter?: Adapter; +export interface SessionServiceDeps { + trpc: SessionTrpc; + store: ISessionStore; + h: SessionServiceHelpers; + log: { + info(message: string, data?: unknown): void; + warn(message: string, data?: unknown): void; + error(message: string, data?: unknown): void; + debug(message: string, data?: unknown): void; + }; + toast: { + error: (msg: any, opts?: any) => unknown; + info: (msg: any, opts?: any) => unknown; + }; + track: (event: string, props?: Record) => void; + buildPermissionToolMetadata: (...args: any[]) => any; + notifyPermissionRequest: (...args: any[]) => any; + notifyPromptComplete: (...args: any[]) => any; + getIsOnline: () => boolean; + fetchAuthState: () => Promise; + getAuthenticatedClient: () => Promise; + createAuthenticatedClient: (authState: any) => any; + getPersistedConfigOptions: ( + taskRunId: string, + ) => SessionConfigOption[] | undefined; + setPersistedConfigOptions: ( + taskRunId: string, + options: SessionConfigOption[], + ) => void; + removePersistedConfigOptions: (taskRunId: string) => void; + updatePersistedConfigOptionValue: (...args: any[]) => any; + adapterStore: { + getAdapter(taskRunId: string): Adapter | undefined; + setAdapter(taskRunId: string, adapter: Adapter): void; + removeAdapter(taskRunId: string): void; + }; + readonly settings: { customInstructions?: string | null }; + usageLimit: { show: (...args: any[]) => any }; + readonly addDirectoryDialog: { open: boolean }; + taskViewedApi: { markActivity(taskId: string): void }; + queryClient: { + invalidateQueries: (filters?: any) => any; + refetchQueries: (filters?: any) => any; + }; + DEFAULT_GATEWAY_MODEL: string; + WORKSPACE_QUERY_KEY: any; } -interface CloudLogGapReconcileState { - pendingRequest?: CloudLogGapReconcileRequest; -} +type AuthClient = NonNullable< + Awaited> +>; -interface CloudLogReconcileDeficiency { - expectedCount: number; - observedLineCount: number; +interface AuthCredentials { + apiHost: string; + projectId: number; + client: AuthClient; } export interface ConnectParams { @@ -232,62 +284,44 @@ export interface ConnectParams { reasoningLevel?: string; } -const FOLDER_TAG_REGEX = //g; - -function isAbsoluteFolderPath(p: string): boolean { - return p.startsWith("/") || p.startsWith("~") || /^[A-Za-z]:[\\/]/.test(p); +export interface CloudConnectionAuth { + status: string; + bootstrapComplete?: boolean; + projectId?: number | null; + cloudRegion?: CloudRegion | null; } -function promptReferencesAbsoluteFolder( - prompt: string | ContentBlock[], -): boolean { - const text = - typeof prompt === "string" - ? prompt - : prompt - .map((block) => - "text" in block && typeof block.text === "string" ? block.text : "", - ) - .join(""); - for (const match of text.matchAll(FOLDER_TAG_REGEX)) { - if (isAbsoluteFolderPath(match[1])) return true; - } - return false; -} - -// --- Singleton Service Instance --- - -let serviceInstance: SessionService | null = null; - -export function getSessionService(): SessionService { - if (!serviceInstance) { - serviceInstance = new SessionService(); - } - return serviceInstance; +export interface ReconcileTaskConnectionParams { + task: Task; + session: AgentSession | undefined; + repoPath: string | null; + isCloud: boolean; + isSuspended?: boolean; + isOnline: boolean; + cloudAuth: CloudConnectionAuth; + onCloudStatusChange?: () => void; } -export function resetSessionService(): void { - if (serviceInstance) { - serviceInstance.reset(); - serviceInstance = null; - } +const ACTIVITY_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; - sessionStoreSetters.clearAll(); +export type SessionPlan = Extract; - trpcClient.agent.resetAll.mutate().catch((err) => { - log.error("Failed to reset all sessions on main process", err); - }); -} +export const SESSION_SERVICE = Symbol.for("posthog.core.sessions.service"); export class SessionService { private connectingTasks = new Map>(); + private reconcilingTasks = new Set(); + private activityHeartbeats = new Map< + string, + ReturnType + >(); private localRepoPaths = new Map(); private localRecoveryAttempts = new Map>(); /** Re-entrance guard for cloud queue dispatch (per taskId). */ private dispatchingCloudQueues = new Set(); /** Coalesces deferred cloud queue flush timers (per taskId). */ private scheduledCloudQueueFlushes = new Set(); - private cloudRunIdleTracker = new CloudRunIdleTracker(); + private cloudRunIdleTracker: CloudRunIdleTracker; private nextCloudTaskWatchToken = 0; private subscriptions = new Map< string, @@ -308,12 +342,7 @@ export class SessionService { onStatusChange?: () => void; } >(); - private cloudLogGapReconciles = new Map(); - /** Last observed reconcile deficit per taskRunId — see reconcileCloudLogGapOnce. */ - private cloudLogReconcileDeficiency = new Map< - string, - CloudLogReconcileDeficiency - >(); + private cloudLogGapReconciler: CloudLogGapReconciler; /** Maps toolCallId → cloud requestId for routing permission responses */ private cloudPermissionRequestIds = new Map(); private idleKilledSubscription: { unsubscribe: () => void } | null = null; @@ -326,18 +355,42 @@ export class SessionService { Promise >(); - constructor() { - this.idleKilledSubscription = - trpcClient.agent.onSessionIdleKilled.subscribe(undefined, { + constructor(private readonly d: SessionServiceDeps) { + this.cloudRunIdleTracker = new CloudRunIdleTracker(); + this.cloudLogGapReconciler = new CloudLogGapReconciler({ + fetchLogs: (logUrl, taskRunId, minEntryCount) => + this.fetchSessionLogs(logUrl, taskRunId, { minEntryCount }), + getSession: (taskRunId) => { + const session = d.store.getSessions()[taskRunId]; + if (!session) return undefined; + return { + taskId: session.taskId, + processedLineCount: session.processedLineCount ?? 0, + logUrl: session.logUrl, + }; + }, + commit: (taskRunId, rawEntries, logUrl, processedLineCount) => + this.commitReconciledCloudEvents( + taskRunId, + rawEntries, + logUrl, + processedLineCount, + ), + logger: d.log, + }); + this.idleKilledSubscription = d.trpc.agent.onSessionIdleKilled.subscribe( + undefined, + { onData: (event: { taskRunId: string }) => { const { taskRunId } = event; - log.info("Session idle-killed by main process", { taskRunId }); + d.log.info("Session idle-killed by main process", { taskRunId }); this.handleIdleKill(taskRunId); }, onError: (err: unknown) => { - log.debug("Idle-killed subscription error", { error: err }); + d.log.debug("Idle-killed subscription error", { error: err }); }, - }); + }, + ); } /** @@ -356,13 +409,13 @@ export class SessionService { } // Check for existing connected session - const existingSession = sessionStoreSetters.getSessionByTaskId(taskId); + const existingSession = this.d.store.getSessionByTaskId(taskId); if (existingSession?.status === "connected") { - log.info("Already connected to task", { taskId }); + this.d.log.info("Already connected to task", { taskId }); return; } if (existingSession?.status === "connecting") { - log.info("Session already in connecting state", { taskId }); + this.d.log.info("Session already in connecting state", { taskId }); return; } @@ -389,7 +442,7 @@ export class SessionService { const taskTitle = task.title || task.description || "Task"; if (latestRun?.environment === "cloud") { - log.info("Skipping local session connect for cloud run", { + this.d.log.info("Skipping local session connect for cloud run", { taskId, taskRunId: latestRun.id, }); @@ -398,86 +451,84 @@ export class SessionService { try { const auth = await this.getAuthCredentials(); - if (!auth) { - log.error("Missing auth credentials"); + const route = routeLocalConnect({ + hasAuth: auth !== null, + latestRunId: latestRun?.id, + latestRunLogUrl: latestRun?.log_url, + }); + + if (route.kind === "no-auth" || !auth) { + this.d.log.error("Missing auth credentials"); const taskRunId = latestRun?.id ?? `error-${taskId}`; - const session = this.createBaseSession(taskRunId, taskId, taskTitle); + const session = createBaseSession(taskRunId, taskId, taskTitle); session.status = "error"; session.errorMessage = "Authentication required. Please sign in to continue."; if (initialPrompt?.length) { session.initialPrompt = initialPrompt; } - sessionStoreSetters.setSession(session); + this.d.store.setSession(session); return; } - if (latestRun?.id && latestRun?.log_url) { - if (!getIsOnline()) { - log.info("Skipping connection attempt - offline", { taskId }); + if (route.kind === "resume-existing") { + const { taskRunId: existingRunId, logUrl } = route; + if (!this.d.getIsOnline()) { + this.d.log.info("Skipping connection attempt - offline", { taskId }); const { rawEntries } = await this.fetchSessionLogs( - latestRun.log_url, - latestRun.id, + logUrl, + existingRunId, ); const events = convertStoredEntriesToEvents(rawEntries); - const session = this.createBaseSession( - latestRun.id, - taskId, - taskTitle, - ); + const session = createBaseSession(existingRunId, taskId, taskTitle); session.events = events; - session.logUrl = latestRun.log_url; + session.logUrl = logUrl; session.status = "disconnected"; - session.errorMessage = - "No internet connection. Connect when you're back online."; - sessionStoreSetters.setSession(session); + session.errorMessage = OFFLINE_SESSION_MESSAGE; + this.d.store.setSession(session); return; } const [workspaceResult, logResult] = await Promise.all([ - trpcClient.workspace.verify.query({ taskId }), - this.fetchSessionLogs(latestRun.log_url, latestRun.id), + this.d.trpc.workspace.verify.query({ taskId }), + this.fetchSessionLogs(logUrl, existingRunId), ]); if (!workspaceResult.exists) { - log.warn("Workspace no longer exists, showing error state", { + this.d.log.warn("Workspace no longer exists, showing error state", { taskId, missingPath: workspaceResult.missingPath, }); const events = convertStoredEntriesToEvents(logResult.rawEntries); - const session = this.createBaseSession( - latestRun.id, - taskId, - taskTitle, - ); + const session = createBaseSession(existingRunId, taskId, taskTitle); session.events = events; - session.logUrl = latestRun.log_url; + session.logUrl = logUrl; session.status = "error"; session.errorMessage = workspaceResult.missingPath ? `Working directory no longer exists: ${workspaceResult.missingPath}` : "The working directory for this task no longer exists. Please start a new session."; - sessionStoreSetters.setSession(session); + this.d.store.setSession(session); return; } await this.reconnectToLocalSession( taskId, - latestRun.id, + existingRunId, taskTitle, - latestRun.log_url, + logUrl, repoPath, auth, logResult, ); } else { - if (!getIsOnline()) { - log.info("Skipping connection attempt - offline", { taskId }); + if (!this.d.getIsOnline()) { + this.d.log.info("Skipping connection attempt - offline", { taskId }); const taskRunId = latestRun?.id ?? `offline-${taskId}`; - const session = this.createBaseSession(taskRunId, taskId, taskTitle); + const session = createBaseSession(taskRunId, taskId, taskTitle); session.status = "disconnected"; session.errorMessage = "No internet connection. Connect when you're back online."; - sessionStoreSetters.setSession(session); + this.d.store.setSession(session); return; } @@ -495,10 +546,10 @@ export class SessionService { } } catch (error) { const message = error instanceof Error ? error.message : String(error); - log.error("Failed to connect to task", { message }); + this.d.log.error("Failed to connect to task", { message }); const taskRunId = latestRun?.id ?? `error-${taskId}`; - const session = this.createBaseSession(taskRunId, taskId, taskTitle); + const session = createBaseSession(taskRunId, taskId, taskTitle); if (initialPrompt?.length) { session.initialPrompt = initialPrompt; } @@ -515,20 +566,20 @@ export class SessionService { } } - const shouldAutoRetry = getIsOnline(); + const shouldAutoRetry = this.d.getIsOnline(); session.status = shouldAutoRetry ? "connecting" : "error"; if (!shouldAutoRetry) { session.errorTitle = "Failed to connect"; session.errorMessage = message; } - sessionStoreSetters.setSession(session); + this.d.store.setSession(session); if (!shouldAutoRetry) return; let lastRetryMessage = message; let wentOffline = false; for (let attempt = 1; attempt <= AUTO_RETRY_MAX_ATTEMPTS; attempt++) { - log.warn("Auto-retrying failed connection", { + this.d.log.warn("Auto-retrying failed connection", { taskId, attempt, delayMs: AUTO_RETRY_DELAY_MS, @@ -536,8 +587,8 @@ export class SessionService { await new Promise((resolve) => setTimeout(resolve, AUTO_RETRY_DELAY_MS), ); - if (!getIsOnline()) { - log.warn("Skipping retry — device went offline", { + if (!this.d.getIsOnline()) { + this.d.log.warn("Skipping retry — device went offline", { taskId, attempt, }); @@ -552,7 +603,7 @@ export class SessionService { retryError instanceof Error ? retryError.message : String(retryError); - log.error("Auto-retry via clearSessionError failed", { + this.d.log.error("Auto-retry via clearSessionError failed", { taskId, attempt, error: lastRetryMessage, @@ -560,15 +611,16 @@ export class SessionService { } } - const currentSession = sessionStoreSetters.getSessionByTaskId(taskId); + const currentSession = this.d.store.getSessionByTaskId(taskId); if (!currentSession) return; - sessionStoreSetters.updateSession(currentSession.taskRunId, { - status: wentOffline ? "disconnected" : "error", - errorTitle: wentOffline ? undefined : "Failed to connect", - errorMessage: wentOffline - ? "No internet connection. Connect when you're back online." - : lastRetryMessage || message, - }); + this.d.store.updateSession( + currentSession.taskRunId, + computeAutoRetryFinalState({ + wentOffline, + lastRetryMessage, + originalMessage: message, + }), + ); } } @@ -589,15 +641,13 @@ export class SessionService { prefetchedLogs ?? (await this.fetchSessionLogs(logUrl, taskRunId)); const events = convertStoredEntriesToEvents(rawEntries); - const storedAdapter = useSessionAdapterStore - .getState() - .getAdapter(taskRunId); + const storedAdapter = this.d.adapterStore.getAdapter(taskRunId); const resolvedAdapter = adapter ?? storedAdapter; - const persistedConfigOptions = getPersistedConfigOptions(taskRunId); + const persistedConfigOptions = this.d.getPersistedConfigOptions(taskRunId); - const previous = sessionStoreSetters.getSessions()[taskRunId]; + const previous = this.d.store.getSessions()[taskRunId]; - const session = this.createBaseSession(taskRunId, taskId, taskTitle); + const session = createBaseSession(taskRunId, taskId, taskTitle); session.events = events; if (logUrl) { session.logUrl = logUrl; @@ -607,7 +657,7 @@ export class SessionService { } if (resolvedAdapter) { session.adapter = resolvedAdapter; - useSessionAdapterStore.getState().setAdapter(taskRunId, resolvedAdapter); + this.d.adapterStore.setAdapter(taskRunId, resolvedAdapter); } if (previous) { @@ -618,7 +668,7 @@ export class SessionService { session.pausedDurationMs = previous.pausedDurationMs; } - sessionStoreSetters.setSession(session); + this.d.store.setSession(session); this.subscribeToChannel(taskRunId); try { @@ -626,15 +676,15 @@ export class SessionService { const persistedMode = modeOpt?.type === "select" ? modeOpt.currentValue : undefined; - trpcClient.workspace.verify + this.d.trpc.workspace.verify .query({ taskId }) .then((workspaceResult) => { if (!workspaceResult.exists) { - log.warn("Workspace no longer exists", { + this.d.log.warn("Workspace no longer exists", { taskId, missingPath: workspaceResult.missingPath, }); - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { status: "error", errorMessage: workspaceResult.missingPath ? `Working directory no longer exists: ${workspaceResult.missingPath}` @@ -643,11 +693,11 @@ export class SessionService { } }) .catch((err) => { - log.warn("Failed to verify workspace", { taskId, err }); + this.d.log.warn("Failed to verify workspace", { taskId, err }); }); - const { customInstructions } = useSettingsStore.getState(); - const result = await trpcClient.agent.reconnect.mutate({ + const { customInstructions } = this.d.settings; + const result = await this.d.trpc.agent.reconnect.mutate({ taskId, taskRunId, repoPath, @@ -676,28 +726,28 @@ export class SessionService { configOptions = persistedConfigOptions ?? undefined; } - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { status: "connected", configOptions, }); // Persist the merged config options if (configOptions) { - setPersistedConfigOptions(taskRunId, configOptions); + this.d.setPersistedConfigOptions(taskRunId, configOptions); } // Restore persisted config options to server in parallel if (persistedConfigOptions) { await Promise.all( persistedConfigOptions.map((opt) => - trpcClient.agent.setConfigOption + this.d.trpc.agent.setConfigOption .mutate({ sessionId: taskRunId, configId: opt.id, value: String(opt.currentValue), }) .catch((error) => { - log.warn( + this.d.log.warn( "Failed to restore persisted config option after reconnect", { taskId, @@ -711,7 +761,7 @@ export class SessionService { } return true; } else { - log.warn("Reconnect returned null", { taskId, taskRunId }); + this.d.log.warn("Reconnect returned null", { taskId, taskRunId }); this.setErrorSession( taskId, taskRunId, @@ -723,7 +773,7 @@ export class SessionService { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - log.warn("Reconnect failed", { taskId, error: errorMessage }); + this.d.log.warn("Reconnect failed", { taskId, error: errorMessage }); this.setErrorSession( taskId, taskRunId, @@ -739,24 +789,27 @@ export class SessionService { const session = this.getSessionByRunId(taskRunId); try { - await trpcClient.agent.cancel.mutate({ sessionId: taskRunId }); + await this.d.trpc.agent.cancel.mutate({ sessionId: taskRunId }); } catch (error) { - log.debug("Cancel during teardown failed (session may already be gone)", { - taskRunId, - error: error instanceof Error ? error.message : String(error), - }); + this.d.log.debug( + "Cancel during teardown failed (session may already be gone)", + { + taskRunId, + error: error instanceof Error ? error.message : String(error), + }, + ); } this.unsubscribeFromChannel(taskRunId); - sessionStoreSetters.removeSession(taskRunId); + this.d.store.removeSession(taskRunId); this.cloudRunIdleTracker.delete(taskRunId); - this.cloudLogReconcileDeficiency.delete(taskRunId); + this.cloudLogGapReconciler.forgetDeficiency(taskRunId); if (session) { this.localRepoPaths.delete(session.taskId); this.localRecoveryAttempts.delete(session.taskId); } - useSessionAdapterStore.getState().removeAdapter(taskRunId); - removePersistedConfigOptions(taskRunId); + this.d.adapterStore.removeAdapter(taskRunId); + this.d.removePersistedConfigOptions(taskRunId); } /** @@ -768,7 +821,7 @@ export class SessionService { */ private handleIdleKill(taskRunId: string): void { this.unsubscribeFromChannel(taskRunId); - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { status: "error", errorMessage: "Session disconnected due to inactivity. Reconnecting…", isPromptPending: false, @@ -789,8 +842,8 @@ export class SessionService { // retry / reset flows can re-hydrate without a fresh log fetch. // Note: the error overlay is opaque, so these events aren't visible // to the user — they're carried forward for the next reconnect attempt. - const existing = sessionStoreSetters.getSessionByTaskId(taskId); - const session = this.createBaseSession(taskRunId, taskId, taskTitle); + const existing = this.d.store.getSessionByTaskId(taskId); + const session = createBaseSession(taskRunId, taskId, taskTitle); session.status = "error"; session.errorTitle = errorTitle; session.errorMessage = errorMessage; @@ -803,7 +856,7 @@ export class SessionService { if (existing?.initialPrompt?.length) { session.initialPrompt = existing.initialPrompt; } - sessionStoreSetters.setSession(session); + this.d.store.setSession(session); } private async tryAutoRecoverLocalSession( @@ -834,18 +887,18 @@ export class SessionService { reason: string, ): Promise { const repoPath = this.localRepoPaths.get(taskId); - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!repoPath || !session || session.isCloud) { return false; } - log.warn("Attempting automatic local session recovery", { + this.d.log.warn("Attempting automatic local session recovery", { taskId, taskRunId, reason, }); - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { status: "disconnected", errorTitle: undefined, errorMessage: LOCAL_SESSION_RECOVERY_MESSAGE, @@ -859,7 +912,7 @@ export class SessionService { attempt < LOCAL_SESSION_RECONNECT_ATTEMPTS; attempt++ ) { - const currentSession = sessionStoreSetters.getSessionByTaskId(taskId); + const currentSession = this.d.store.getSessionByTaskId(taskId); if (!currentSession || currentSession.taskRunId !== taskRunId) { return false; } @@ -874,7 +927,7 @@ export class SessionService { const recovered = await this.reconnectInPlace(taskId, repoPath); if (recovered) { - log.info("Automatic local session recovery succeeded", { + this.d.log.info("Automatic local session recovery succeeded", { taskId, taskRunId, attempt: attempt + 1, @@ -883,7 +936,7 @@ export class SessionService { } } - const latestSession = sessionStoreSetters.getSessionByTaskId(taskId); + const latestSession = this.d.store.getSessionByTaskId(taskId); if (latestSession?.taskRunId === taskRunId) { this.setErrorSession( taskId, @@ -894,7 +947,7 @@ export class SessionService { ); } - log.warn("Automatic local session recovery exhausted", { + this.d.log.warn("Automatic local session recovery exhausted", { taskId, taskRunId, }); @@ -915,7 +968,7 @@ export class SessionService { return; } - const latestSession = sessionStoreSetters.getSessionByTaskId(taskId); + const latestSession = this.d.store.getSessionByTaskId(taskId); if (!latestSession || latestSession.taskRunId !== taskRunId) { return; } @@ -954,10 +1007,9 @@ export class SessionService { throw new Error("Failed to create task run. Please try again."); } - const { customInstructions: startCustomInstructions } = - useSettingsStore.getState(); - const preferredModel = model ?? DEFAULT_GATEWAY_MODEL; - const result = await trpcClient.agent.start.mutate({ + const { customInstructions: startCustomInstructions } = this.d.settings; + const preferredModel = model ?? this.d.DEFAULT_GATEWAY_MODEL; + const result = await this.d.trpc.agent.start.mutate({ taskId, taskRunId: taskRun.id, repoPath, @@ -972,7 +1024,7 @@ export class SessionService { model: preferredModel, }); - const session = this.createBaseSession(taskRun.id, taskId, taskTitle); + const session = createBaseSession(taskRun.id, taskId, taskTitle); session.channel = result.channel; session.status = "connected"; session.adapter = adapter; @@ -983,12 +1035,12 @@ export class SessionService { // Persist the config options if (configOptions) { - setPersistedConfigOptions(taskRun.id, configOptions); + this.d.setPersistedConfigOptions(taskRun.id, configOptions); } // Persist the adapter if (adapter) { - useSessionAdapterStore.getState().setAdapter(taskRun.id, adapter); + this.d.adapterStore.setAdapter(taskRun.id, adapter); } // Store the initial prompt on the session so retry/reset flows can @@ -998,10 +1050,10 @@ export class SessionService { session.initialPrompt = initialPrompt; } - sessionStoreSetters.setSession(session); + this.d.store.setSession(session); this.subscribeToChannel(taskRun.id); - track(ANALYTICS_EVENTS.TASK_RUN_STARTED, { + this.d.track(ANALYTICS_EVENTS.TASK_RUN_STARTED, { task_id: taskId, execution_type: "local", initial_mode: executionMode, @@ -1020,20 +1072,20 @@ export class SessionService { logUrl: string; }): Promise { const { taskId, taskRunId, taskTitle, logUrl } = params; - const existing = sessionStoreSetters.getSessionByTaskId(taskId); + const existing = this.d.store.getSessionByTaskId(taskId); if (existing && existing.events.length > 0) return; const { rawEntries } = await this.fetchSessionLogs(logUrl, taskRunId); const events = convertStoredEntriesToEvents(rawEntries); - const session = this.createBaseSession(taskRunId, taskId, taskTitle); + const session = createBaseSession(taskRunId, taskId, taskTitle); session.events = events; session.logUrl = logUrl; session.status = "disconnected"; - sessionStoreSetters.setSession(session); + this.d.store.setSession(session); } async disconnectFromTask(taskId: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) return; await this.teardownSession(session.taskRunId); @@ -1046,17 +1098,20 @@ export class SessionService { return; } - const eventSubscription = trpcClient.agent.onSessionEvent.subscribe( + const eventSubscription = this.d.trpc.agent.onSessionEvent.subscribe( { taskRunId }, { onData: (payload: unknown) => { this.handleSessionEvent(taskRunId, payload as AcpMessage); }, onError: (err) => { - log.error("Session subscription error", { taskRunId, error: err }); + this.d.log.error("Session subscription error", { + taskRunId, + error: err, + }); const session = this.getSessionByRunId(taskRunId); if (!session || session.isCloud) { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { status: "error", errorMessage: "Lost connection to the agent. Please restart the task.", @@ -1076,14 +1131,14 @@ export class SessionService { ); const permissionSubscription = - trpcClient.agent.onPermissionRequest.subscribe( + this.d.trpc.agent.onPermissionRequest.subscribe( { taskRunId }, { onData: async (payload) => { this.handlePermissionRequest(taskRunId, payload); }, onError: (err) => { - log.error("Permission subscription error", { + this.d.log.error("Permission subscription error", { taskRunId, error: err, }); @@ -1109,7 +1164,7 @@ export class SessionService { * Called on logout or app reset. */ reset(): void { - log.info("Resetting session service", { + this.d.log.info("Resetting session service", { subscriptionCount: this.subscriptions.size, connectingCount: this.connectingTasks.size, cloudWatcherCount: this.cloudTaskWatchers.size, @@ -1129,8 +1184,7 @@ export class SessionService { this.localRepoPaths.clear(); this.localRecoveryAttempts.clear(); this.cloudPermissionRequestIds.clear(); - this.cloudLogGapReconciles.clear(); - this.cloudLogReconcileDeficiency.clear(); + this.cloudLogGapReconciler.clear(); this.dispatchingCloudQueues.clear(); this.scheduledCloudQueueFlushes.clear(); this.cloudRunIdleTracker.clear(); @@ -1146,17 +1200,17 @@ export class SessionService { for (const acpMsg of events) { const msg = acpMsg.message; if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { isPromptPending: true, promptStartedAt: acpMsg.ts, pausedDurationMs: 0, currentPromptId: msg.id, }); - const promptSession = sessionStoreSetters.getSessions()[taskRunId]; + const promptSession = this.d.store.getSessions()[taskRunId]; if (promptSession?.isCloud) { this.cloudRunIdleTracker.markBusy(promptSession); if (promptSession.agentIdleForRunId) { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { agentIdleForRunId: undefined, }); } @@ -1172,11 +1226,11 @@ export class SessionService { // Only clear pending state if this response matches the currently // in-flight prompt. A late response from a previously cancelled turn // must not be allowed to mark a newer turn as done. - const session = sessionStoreSetters.getSessions()[taskRunId]; + const session = this.d.store.getSessions()[taskRunId]; if (session && session.currentPromptId !== msg.id) { continue; } - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { isPromptPending: false, promptStartedAt: null, currentPromptId: null, @@ -1188,7 +1242,7 @@ export class SessionService { // above. Cloud sessions never see that response. const session = this.getSessionByRunId(taskRunId); if (session?.isCloud) { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { isPromptPending: false, promptStartedAt: null, currentPromptId: null, @@ -1196,13 +1250,13 @@ export class SessionService { if (isLive) { // Queued messages will start a new turn — suppress the "done" notification in that case. if (session.messageQueue.length === 0) { - notifyPromptComplete( + this.d.notifyPromptComplete( session.taskTitle, "end_turn", session.taskId, ); } - taskViewedApi.markActivity(session.taskId); + this.d.taskViewedApi.markActivity(session.taskId); } } } @@ -1220,7 +1274,7 @@ export class SessionService { "method" in msg && isNotification(msg.method, POSTHOG_NOTIFICATIONS.RUN_STARTED) ) { - const session = sessionStoreSetters.getSessions()[taskRunId]; + const session = this.d.store.getSessions()[taskRunId]; const params = (msg as { params?: { agentVersion?: unknown } }).params; const agentVersion = typeof params?.agentVersion === "string" @@ -1234,7 +1288,7 @@ export class SessionService { updates.status = "connected"; } if (Object.keys(updates).length > 0) { - sessionStoreSetters.updateSession(taskRunId, updates); + this.d.store.updateSession(taskRunId, updates); } } // Canonical "turn boundary" — flush any queued cloud messages now @@ -1243,7 +1297,7 @@ export class SessionService { "method" in msg && isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE) ) { - const session = sessionStoreSetters.getSessions()[taskRunId]; + const session = this.d.store.getSessions()[taskRunId]; if (session?.isCloud) { // Backward compat: treat turn_complete as an implicit run_started // for agents that predate the run_started notification. The turn @@ -1257,7 +1311,7 @@ export class SessionService { updates.agentIdleForRunId = taskRunId; } if (Object.keys(updates).length > 0) { - sessionStoreSetters.updateSession(taskRunId, updates); + this.d.store.updateSession(taskRunId, updates); } this.cloudRunIdleTracker.markIdle(session); if (session.messageQueue.length > 0) { @@ -1269,7 +1323,7 @@ export class SessionService { } private handleSessionEvent(taskRunId: string, acpMsg: AcpMessage): void { - const session = sessionStoreSetters.getSessions()[taskRunId]; + const session = this.d.store.getSessions()[taskRunId]; if (!session) return; const isUserPromptEcho = @@ -1279,15 +1333,15 @@ export class SessionService { // Once the agent starts responding, clear initialPrompt so that // retry reconnects to this session instead of creating a new one. if (!isUserPromptEcho && session.initialPrompt?.length) { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { initialPrompt: undefined, }); } if (isUserPromptEcho) { - sessionStoreSetters.replaceOptimisticWithEvent(taskRunId, acpMsg); + this.d.store.replaceOptimisticWithEvent(taskRunId, acpMsg); } else { - sessionStoreSetters.appendEvents(taskRunId, [acpMsg]); + this.d.store.appendEvents(taskRunId, [acpMsg]); } this.updatePromptStateFromEvents(taskRunId, [acpMsg], { isLive: true }); @@ -1316,10 +1370,14 @@ export class SessionService { // Only notify when queue is empty - queued messages will start a new turn if (stopReason && !hasQueuedMessages) { - notifyPromptComplete(session.taskTitle, stopReason, session.taskId); + this.d.notifyPromptComplete( + session.taskTitle, + stopReason, + session.taskId, + ); } - taskViewedApi.markActivity(session.taskId); + this.d.taskViewedApi.markActivity(session.taskId); } if ("method" in msg && msg.method === "session/update" && "params" in msg) { @@ -1336,12 +1394,12 @@ export class SessionService { params.update.configOptions ) { const configOptions = params.update.configOptions; - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { configOptions, }); // Persist the updated config options - setPersistedConfigOptions(taskRunId, configOptions); - log.info("Session config options updated", { taskRunId }); + this.d.setPersistedConfigOptions(taskRunId, configOptions); + this.d.log.info("Session config options updated", { taskRunId }); } // Handle context usage updates @@ -1354,7 +1412,7 @@ export class SessionService { typeof update.used === "number" && typeof update.size === "number" ) { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { contextUsed: update.used, contextSize: update.size, }); @@ -1372,10 +1430,10 @@ export class SessionService { adapter?: Adapter; }; if (params?.adapter) { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { adapter: params.adapter, }); - useSessionAdapterStore.getState().setAdapter(taskRunId, params.adapter); + this.d.adapterStore.setAdapter(taskRunId, params.adapter); } } @@ -1386,7 +1444,7 @@ export class SessionService { ) { const params = msg.params as { status?: string; isComplete?: boolean }; if (params?.status === "compacting") { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { isCompacting: !params.isComplete, }); } @@ -1396,7 +1454,7 @@ export class SessionService { "method" in msg && isNotification(msg.method, POSTHOG_NOTIFICATIONS.COMPACT_BOUNDARY) ) { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { isCompacting: false, }); @@ -1408,7 +1466,7 @@ export class SessionService { taskRunId: string, session: AgentSession, ): boolean { - const freshSession = sessionStoreSetters.getSessions()[taskRunId]; + const freshSession = this.d.store.getSessions()[taskRunId]; const hasQueuedMessages = freshSession && freshSession.messageQueue.length > 0 && @@ -1417,7 +1475,7 @@ export class SessionService { if (hasQueuedMessages) { setTimeout(() => { this.sendQueuedMessages(session.taskId).catch((err) => { - log.error("Failed to send queued messages", { + this.d.log.error("Failed to send queued messages", { taskId: session.taskId, error: err, }); @@ -1434,16 +1492,16 @@ export class SessionService { taskRunId: string; }, ): void { - log.info("Permission request received in renderer", { + this.d.log.info("Permission request received in renderer", { taskRunId, toolCallId: payload.toolCall.toolCallId, title: payload.toolCall.title, }); // Get fresh session state - const session = sessionStoreSetters.getSessions()[taskRunId]; + const session = this.d.store.getSessions()[taskRunId]; if (!session) { - log.warn("Session not found for permission request", { + this.d.log.warn("Session not found for permission request", { taskRunId, }); return; @@ -1456,25 +1514,27 @@ export class SessionService { receivedAt: Date.now(), }); - sessionStoreSetters.setPendingPermissions(taskRunId, newPermissions); - taskViewedApi.markActivity(session.taskId); - notifyPermissionRequest(session.taskTitle, session.taskId); + this.d.store.setPendingPermissions(taskRunId, newPermissions); + this.d.taskViewedApi.markActivity(session.taskId); + this.d.notifyPermissionRequest(session.taskTitle, session.taskId); } private handleCloudPermissionRequest( taskRunId: string, update: CloudTaskPermissionRequestUpdate, ): void { - log.info("Cloud permission request received", { + this.d.log.info("Cloud permission request received", { taskRunId, requestId: update.requestId, toolCallId: update.toolCall.toolCallId, title: update.toolCall.title, }); - const session = sessionStoreSetters.getSessions()[taskRunId]; + const session = this.d.store.getSessions()[taskRunId]; if (!session) { - log.warn("Session not found for cloud permission request", { taskRunId }); + this.d.log.warn("Session not found for cloud permission request", { + taskRunId, + }); return; } @@ -1492,9 +1552,9 @@ export class SessionService { receivedAt: Date.now(), }); - sessionStoreSetters.setPendingPermissions(taskRunId, newPermissions); - taskViewedApi.markActivity(session.taskId); - notifyPermissionRequest(session.taskTitle, session.taskId); + this.d.store.setPendingPermissions(taskRunId, newPermissions); + this.d.taskViewedApi.markActivity(session.taskId); + this.d.notifyPermissionRequest(session.taskTitle, session.taskId); } // --- Prompt Handling --- @@ -1507,19 +1567,19 @@ export class SessionService { taskId: string, prompt: string | ContentBlock[], ): Promise<{ stopReason: string }> { - if (!getIsOnline()) { + if (!this.d.getIsOnline()) { throw new Error( "No internet connection. Please check your connection and try again.", ); } - let session = sessionStoreSetters.getSessionByTaskId(taskId); + let session = this.d.store.getSessionByTaskId(taskId); if (!session) throw new Error("No active session for task"); // The /add-dir dialog mutates the per-task additional-directories list and // we re-read it during respawn below. Sending while it's open would race // and respawn with the pre-decision set, so block here. - if (useAddDirectoryDialogStore.getState().open) { + if (this.d.addDirectoryDialog.open) { throw new Error( "Confirm the folder access dialog before sending your message.", ); @@ -1546,8 +1606,8 @@ export class SessionService { if (session.isPromptPending || session.isCompacting) { const promptText = extractPromptText(prompt); - sessionStoreSetters.enqueueMessage(taskId, promptText); - log.info("Message queued", { + this.d.store.enqueueMessage(taskId, promptText); + this.d.log.info("Message queued", { taskId, queueLength: session.messageQueue.length + 1, reason: session.isCompacting ? "compacting" : "prompt_pending", @@ -1564,7 +1624,7 @@ export class SessionService { } const promptText = extractPromptText(prompt); - track(ANALYTICS_EVENTS.PROMPT_SENT, { + this.d.track(ANALYTICS_EVENTS.PROMPT_SENT, { task_id: taskId, is_initial: session.events.length === 0, execution_type: "local", @@ -1580,13 +1640,16 @@ export class SessionService { try { await this.reconnectInPlace(taskId, repoPath); } catch (err) { - log.error("Respawn failed; aborting prompt send", { taskId, err }); - sessionStoreSetters.clearOptimisticItems(session.taskRunId); - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.log.error("Respawn failed; aborting prompt send", { + taskId, + err, + }); + this.d.store.clearOptimisticItems(session.taskRunId); + this.d.store.updateSession(session.taskRunId, { isPromptPending: false, promptStartedAt: null, }); - toast.error("Couldn't grant the new folder access", { + this.d.toast.error("Couldn't grant the new folder access", { description: "The session needs to restart to pick up the added folder. Try sending again, or remove the folder reference.", }); @@ -1594,7 +1657,7 @@ export class SessionService { ? err : new Error("Failed to apply additional directories"); } - const refreshed = sessionStoreSetters.getSessionByTaskId(taskId); + const refreshed = this.d.store.getSessionByTaskId(taskId); if (refreshed) { session = refreshed; } @@ -1615,21 +1678,21 @@ export class SessionService { private async sendQueuedMessages( taskId: string, ): Promise<{ stopReason: string }> { - const combinedText = sessionStoreSetters.dequeueMessagesAsText(taskId); + const combinedText = this.d.store.dequeueMessagesAsText(taskId); if (!combinedText) { return { stopReason: "skipped" }; } - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) { - log.warn("No session found for queued messages, messages lost", { + this.d.log.warn("No session found for queued messages, messages lost", { taskId, lostMessageLength: combinedText.length, }); return { stopReason: "no_session" }; } - log.info("Sending queued messages as single prompt", { + this.d.log.info("Sending queued messages as single prompt", { taskId, promptLength: combinedText.length, }); @@ -1642,7 +1705,7 @@ export class SessionService { blocks = [...contextBlocks, ...blocks]; } - track(ANALYTICS_EVENTS.PROMPT_SENT, { + this.d.track(ANALYTICS_EVENTS.PROMPT_SENT, { task_id: taskId, is_initial: false, execution_type: "local", @@ -1653,7 +1716,7 @@ export class SessionService { return await this.sendLocalPrompt(session, blocks, combinedText); } catch (error) { // Log that queued messages were lost due to send failure - log.error("Failed to send queued messages, messages lost", { + this.d.log.error("Failed to send queued messages, messages lost", { taskId, lostMessageLength: combinedText.length, error, @@ -1667,20 +1730,20 @@ export class SessionService { blocks: ContentBlock[], promptText: string, ): void { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { isPromptPending: true, promptStartedAt: Date.now(), pausedDurationMs: 0, }); - const skillButtonId = extractSkillButtonId(blocks); + const skillButtonId = this.d.h.extractSkillButtonId(blocks); if (skillButtonId) { - sessionStoreSetters.appendOptimisticItem(taskRunId, { + this.d.store.appendOptimisticItem(taskRunId, { type: "skill_button_action", buttonId: skillButtonId, }); } else { - sessionStoreSetters.appendOptimisticItem(taskRunId, { + this.d.store.appendOptimisticItem(taskRunId, { type: "user_message", content: promptText, timestamp: Date.now(), @@ -1699,11 +1762,11 @@ export class SessionService { } try { - const result = await trpcClient.agent.prompt.mutate({ + const result = await this.d.trpc.agent.prompt.mutate({ sessionId: session.taskRunId, prompt: blocks, }); - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { isPromptPending: false, promptStartedAt: null, }); @@ -1714,22 +1777,22 @@ export class SessionService { const errorDetails = (error as { data?: { details?: string } }).data ?.details; - sessionStoreSetters.clearOptimisticItems(session.taskRunId); + this.d.store.clearOptimisticItems(session.taskRunId); if (isRateLimitError(errorMessage, errorDetails)) { - log.warn("Rate limit exceeded, showing usage limit modal", { + this.d.log.warn("Rate limit exceeded, showing usage limit modal", { taskRunId: session.taskRunId, }); - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { isPromptPending: false, promptStartedAt: null, }); - useUsageLimitStore.getState().show(); + this.d.usageLimit.show(); return { stopReason: "rate_limited" }; } if (isFatalSessionError(errorMessage, errorDetails)) { - log.error("Fatal prompt error, attempting recovery", { + this.d.log.error("Fatal prompt error, attempting recovery", { taskRunId: session.taskRunId, errorMessage, errorDetails, @@ -1743,7 +1806,7 @@ export class SessionService { "Session connection lost. Please retry or start a new session.", ); } else { - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { isPromptPending: false, isCompacting: false, promptStartedAt: null, @@ -1758,10 +1821,10 @@ export class SessionService { * Cancel the current prompt. */ async cancelPrompt(taskId: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) return false; - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { isPromptPending: false, promptStartedAt: null, }); @@ -1771,7 +1834,7 @@ export class SessionService { } try { - const result = await trpcClient.agent.cancelPrompt.mutate({ + const result = await this.d.trpc.agent.cancelPrompt.mutate({ sessionId: session.taskRunId, }); @@ -1781,7 +1844,7 @@ export class SessionService { const promptCount = session.events.filter( (e) => "method" in e.message && e.message.method === "session/prompt", ).length; - track(ANALYTICS_EVENTS.TASK_RUN_CANCELLED, { + this.d.track(ANALYTICS_EVENTS.TASK_RUN_CANCELLED, { task_id: taskId, execution_type: "local", duration_seconds: durationSeconds, @@ -1790,7 +1853,7 @@ export class SessionService { return result; } catch (error) { - log.error("Failed to cancel prompt", error); + this.d.log.error("Failed to cancel prompt", error); return false; } } @@ -1802,7 +1865,7 @@ export class SessionService { prompt: string | ContentBlock[], options?: { skipQueueGuard?: boolean }, ): Promise<{ stopReason: string }> { - const transport = getCloudPromptTransport(prompt); + const transport = this.d.h.getCloudPromptTransport(prompt); if (!transport.messageText && transport.filePaths.length === 0) { return { stopReason: "empty" }; } @@ -1821,8 +1884,8 @@ export class SessionService { } if (session.cloudStatus !== "in_progress") { - sessionStoreSetters.enqueueMessage(session.taskId, transport.promptText); - log.info("Cloud message queued (sandbox not ready)", { + this.d.store.enqueueMessage(session.taskId, transport.promptText); + this.d.log.info("Cloud message queued (sandbox not ready)", { taskId: session.taskId, cloudStatus: session.cloudStatus, }); @@ -1841,12 +1904,8 @@ export class SessionService { session.isCloud && session.status !== "connected" ) { - sessionStoreSetters.enqueueMessage( - session.taskId, - transport.promptText, - prompt, - ); - log.info("Cloud message queued (agent not ready)", { + this.d.store.enqueueMessage(session.taskId, transport.promptText, prompt); + this.d.log.info("Cloud message queued (agent not ready)", { taskId: session.taskId, sessionStatus: session.status, queueLength: session.messageQueue.length + 1, @@ -1858,22 +1917,21 @@ export class SessionService { // is observed. if (session.status === "disconnected" || session.status === "error") { this.retryCloudTaskWatch(session.taskId).catch((err) => { - log.warn("Auto-retry of cloud task watch from queue gate failed", { - taskId: session.taskId, - error: String(err), - }); + this.d.log.warn( + "Auto-retry of cloud task watch from queue gate failed", + { + taskId: session.taskId, + error: String(err), + }, + ); }); } return { stopReason: "queued" }; } if (!options?.skipQueueGuard && session.isPromptPending) { - sessionStoreSetters.enqueueMessage( - session.taskId, - transport.promptText, - prompt, - ); - log.info("Cloud message queued", { + this.d.store.enqueueMessage(session.taskId, transport.promptText, prompt); + this.d.log.info("Cloud message queued", { taskId: session.taskId, queueLength: session.messageQueue.length + 1, }); @@ -1899,7 +1957,7 @@ export class SessionService { session.adapter ?? "claude", ); - const artifactIds = await uploadRunAttachments( + const artifactIds = await this.d.h.uploadRunAttachments( auth.client, session.taskId, session.taskRunId, @@ -1918,21 +1976,21 @@ export class SessionService { const idleEvidenceBeforeSend = this.cloudRunIdleTracker.capture( currentSessionBeforeSend, ); - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { isPromptPending: true, promptStartedAt: Date.now(), pausedDurationMs: 0, agentIdleForRunId: undefined, }); this.cloudRunIdleTracker.markBusy(currentSessionBeforeSend); - sessionStoreSetters.appendOptimisticItem(session.taskRunId, { + this.d.store.appendOptimisticItem(session.taskRunId, { type: "user_message", content: transport.promptText, timestamp: Date.now(), pinToTop: false, }); - track(ANALYTICS_EVENTS.PROMPT_SENT, { + this.d.track(ANALYTICS_EVENTS.PROMPT_SENT, { task_id: session.taskId, is_initial: session.events.length === 0, execution_type: "cloud", @@ -1940,7 +1998,7 @@ export class SessionService { }); try { - const result = await trpcClient.cloudTask.sendCommand.mutate({ + const result = await this.d.trpc.cloudTask.sendCommand.mutate({ taskId: session.taskId, runId: session.taskRunId, apiHost: cloudCommandAuth.apiHost, @@ -1962,11 +2020,11 @@ export class SessionService { return { stopReason }; } catch (error) { - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { isPromptPending: false, promptStartedAt: null, }); - sessionStoreSetters.clearTailOptimisticItems(session.taskRunId); + this.d.store.clearTailOptimisticItems(session.taskRunId); const currentSessionAfterFailure = this.getSessionByRunId( session.taskRunId, ); @@ -1976,7 +2034,7 @@ export class SessionService { currentSessionAfterFailure, ); if (restoreResult) { - log.warn("Restored idle evidence after failed cloud send", { + this.d.log.warn("Restored idle evidence after failed cloud send", { taskId: session.taskId, taskRunId: session.taskRunId, }); @@ -1984,7 +2042,7 @@ export class SessionService { currentSessionAfterFailure.agentIdleForRunId !== restoreResult.agentIdleForRunId ) { - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { agentIdleForRunId: restoreResult.agentIdleForRunId, }); } @@ -2011,7 +2069,7 @@ export class SessionService { this.dispatchingCloudQueues.add(taskId); try { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session?.isCloud || session.messageQueue.length === 0) return; // Terminal cloud runs route through `resumeCloudRun`, which spins a // new run and consumes the prompt itself — so dispatch is fine. @@ -2024,11 +2082,11 @@ export class SessionService { session.status === "connected"); if (!canSendNow || session.isPromptPending) return; - const drained = sessionStoreSetters.dequeueMessages(taskId); - const combined = combineQueuedCloudPrompts(drained); + const drained = this.d.store.dequeueMessages(taskId); + const combined = this.d.h.combineQueuedCloudPrompts(drained); if (!combined) return; - log.info("Sending queued cloud messages", { + this.d.log.info("Sending queued cloud messages", { taskId, drainedCount: drained.length, }); @@ -2038,11 +2096,11 @@ export class SessionService { skipQueueGuard: true, }); } catch (err) { - log.warn("Cloud queue dispatch failed; re-enqueueing", { + this.d.log.warn("Cloud queue dispatch failed; re-enqueueing", { taskId, error: String(err), }); - sessionStoreSetters.prependQueuedMessages(taskId, drained); + this.d.store.prependQueuedMessages(taskId, drained); } } finally { this.dispatchingCloudQueues.delete(taskId); @@ -2062,11 +2120,11 @@ export class SessionService { throw new Error("Authentication required for cloud commands"); } - const transport = getCloudPromptTransport(prompt); + const transport = this.d.h.getCloudPromptTransport(prompt); if (!transport.messageText && transport.filePaths.length === 0) { return { stopReason: "empty" }; } - const artifactIds = await uploadTaskStagedAttachments( + const artifactIds = await this.d.h.uploadTaskStagedAttachments( authCredentials.client, session.taskId, transport.filePaths, @@ -2094,15 +2152,15 @@ export class SessionService { ? previousState.pr_base_branch : null) ?? session.cloudBranch; - const prAuthorshipMode = this.getCloudPrAuthorshipMode(previousState); + const prAuthorshipMode = getCloudPrAuthorshipMode(previousState); - log.info("Creating resume run for terminal cloud task", { + this.d.log.info("Creating resume run for terminal cloud task", { taskId: session.taskId, previousRunId: session.taskRunId, previousStatus: session.cloudStatus, }); - const runtimeOptions = this.getCloudRuntimeOptions(session, previousRun); + const runtimeOptions = getCloudRuntimeOptions(session, previousRun); // Create a new run WITH resume context — backend validates the previous run, // derives snapshot_external_id server-side, and passes everything as extra_state. @@ -2119,7 +2177,7 @@ export class SessionService { pendingUserArtifactIds: artifactIds.length > 0 ? artifactIds : undefined, prAuthorshipMode, - runSource: this.getCloudRunSource(previousState), + runSource: getCloudRunSource(previousState), signalReportId: typeof previousState.signal_report_id === "string" ? previousState.signal_report_id @@ -2133,7 +2191,7 @@ export class SessionService { // Replace session with one for the new run, preserving conversation history. // setSession handles old session cleanup via taskIdIndex. - const newSession = this.createBaseSession( + const newSession = createBaseSession( newRun.id, session.taskId, session.taskTitle, @@ -2146,7 +2204,7 @@ export class SessionService { ...session.events, createUserPromptEvent( transport.filePaths.length > 0 - ? cloudPromptToBlocks(prompt) + ? this.d.h.cloudPromptToBlocks(prompt) : [{ type: "text", text: transport.promptText }], Date.now(), ), @@ -2155,7 +2213,7 @@ export class SessionService { // Skip the first session/prompt from polled logs — we already have the // optimistic user event, so showing the polled one would duplicate it. newSession.skipPolledPromptCount = 1; - sessionStoreSetters.setSession(newSession); + this.d.store.setSession(newSession); // No enqueueMessage / isPromptPending needed — the follow-up is passed // in run state (pending_user_message), NOT via user_message command. @@ -2184,9 +2242,9 @@ export class SessionService { ); // Invalidate task queries so the UI picks up the new run metadata - queryClient.invalidateQueries({ queryKey: ["tasks"] }); + this.d.queryClient.invalidateQueries({ queryKey: ["tasks"] }); - track(ANALYTICS_EVENTS.PROMPT_SENT, { + this.d.track(ANALYTICS_EVENTS.PROMPT_SENT, { task_id: session.taskId, is_initial: false, execution_type: "cloud", @@ -2198,7 +2256,7 @@ export class SessionService { private async cancelCloudPrompt(session: AgentSession): Promise { if (isTerminalStatus(session.cloudStatus)) { - log.info("Skipping cancel for terminal cloud run", { + this.d.log.info("Skipping cancel for terminal cloud run", { taskId: session.taskId, status: session.cloudStatus, }); @@ -2207,12 +2265,12 @@ export class SessionService { const auth = await this.getCloudCommandAuth(); if (!auth) { - log.error("No auth for cloud cancel"); + this.d.log.error("No auth for cloud cancel"); return false; } try { - const result = await trpcClient.cloudTask.sendCommand.mutate({ + const result = await this.d.trpc.cloudTask.sendCommand.mutate({ taskId: session.taskId, runId: session.taskRunId, apiHost: auth.apiHost, @@ -2226,7 +2284,7 @@ export class SessionService { const promptCount = session.events.filter( (e) => "method" in e.message && e.message.method === "session/prompt", ).length; - track(ANALYTICS_EVENTS.TASK_RUN_CANCELLED, { + this.d.track(ANALYTICS_EVENTS.TASK_RUN_CANCELLED, { task_id: session.taskId, execution_type: "cloud", duration_seconds: durationSeconds, @@ -2234,13 +2292,13 @@ export class SessionService { }); if (!result.success) { - log.warn("Cloud cancel command failed", { error: result.error }); + this.d.log.warn("Cloud cancel command failed", { error: result.error }); return false; } return true; } catch (error) { - log.error("Failed to cancel cloud prompt", error); + this.d.log.error("Failed to cancel cloud prompt", error); return false; } } @@ -2249,7 +2307,7 @@ export class SessionService { apiHost: string; teamId: number; } | null> { - const authState = await fetchAuthState(); + const authState = await this.d.fetchAuthState(); if (!authState.cloudRegion || !authState.projectId) return null; return { apiHost: getCloudUrlFromRegion(authState.cloudRegion), @@ -2270,7 +2328,7 @@ export class SessionService { if (!auth) { throw new Error("No cloud auth credentials available"); } - await trpcClient.cloudTask.sendCommand.mutate({ + await this.d.trpc.cloudTask.sendCommand.mutate({ taskId: session.taskId, runId: session.taskRunId, apiHost: auth.apiHost, @@ -2286,13 +2344,10 @@ export class SessionService { const permission = session.pendingPermissions.get(toolCallId); const newPermissions = new Map(session.pendingPermissions); newPermissions.delete(toolCallId); - sessionStoreSetters.setPendingPermissions( - session.taskRunId, - newPermissions, - ); + this.d.store.setPendingPermissions(session.taskRunId, newPermissions); if (permission?.receivedAt) { - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { pausedDurationMs: (session.pausedDurationMs ?? 0) + (Date.now() - permission.receivedAt), @@ -2310,16 +2365,16 @@ export class SessionService { customInput?: string, answers?: Record, ): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) { - log.error("No session found for permission response", { taskId }); + this.d.log.error("No session found for permission response", { taskId }); return; } const permission = session.pendingPermissions.get(toolCallId); - track(ANALYTICS_EVENTS.PERMISSION_RESPONDED, { + this.d.track(ANALYTICS_EVENTS.PERMISSION_RESPONDED, { task_id: taskId, - ...buildPermissionToolMetadata(permission, optionId, customInput), + ...this.d.buildPermissionToolMetadata(permission, optionId, customInput), }); const cloudRequestId = this.cloudPermissionRequestIds.get(toolCallId); @@ -2335,7 +2390,7 @@ export class SessionService { answers, }); } else { - await trpcClient.agent.respondToPermission.mutate({ + await this.d.trpc.agent.respondToPermission.mutate({ taskRunId: session.taskRunId, toolCallId, optionId, @@ -2344,7 +2399,7 @@ export class SessionService { }); } - log.info("Permission response sent", { + this.d.log.info("Permission response sent", { taskId, toolCallId, optionId, @@ -2352,7 +2407,7 @@ export class SessionService { hasCustomInput: !!customInput, }); } catch (error) { - log.error("Failed to respond to permission", { + this.d.log.error("Failed to respond to permission", { taskId, toolCallId, optionId, @@ -2365,16 +2420,18 @@ export class SessionService { * Cancel a permission request. */ async cancelPermission(taskId: string, toolCallId: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) { - log.error("No session found for permission cancellation", { taskId }); + this.d.log.error("No session found for permission cancellation", { + taskId, + }); return; } const permission = session.pendingPermissions.get(toolCallId); - track(ANALYTICS_EVENTS.PERMISSION_CANCELLED, { + this.d.track(ANALYTICS_EVENTS.PERMISSION_CANCELLED, { task_id: taskId, - ...buildPermissionToolMetadata(permission), + ...this.d.buildPermissionToolMetadata(permission), }); const cloudRequestId = this.cloudPermissionRequestIds.get(toolCallId); @@ -2389,19 +2446,19 @@ export class SessionService { customInput: "User cancelled the permission request.", }); } else { - await trpcClient.agent.cancelPermission.mutate({ + await this.d.trpc.agent.cancelPermission.mutate({ taskRunId: session.taskRunId, toolCallId, }); } - log.info("Permission cancelled", { + this.d.log.info("Permission cancelled", { taskId, toolCallId, isCloud: !!cloudRequestId, }); } catch (error) { - log.error("Failed to cancel permission", { + this.d.log.error("Failed to cancel permission", { taskId, toolCallId, error, @@ -2420,14 +2477,14 @@ export class SessionService { configId: string, value: string, ): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) return; // Find the config option and save previous value for rollback const configOptions = session.configOptions ?? []; const optionIndex = configOptions.findIndex((opt) => opt.id === configId); if (optionIndex === -1) { - log.warn("Config option not found", { taskId, configId }); + this.d.log.warn("Config option not found", { taskId, configId }); return; } @@ -2444,10 +2501,10 @@ export class SessionService { ? ({ ...opt, currentValue: value } as SessionConfigOption) : opt, ); - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { configOptions: updatedOptions, }); - updatePersistedConfigOptionValue(session.taskRunId, configId, value); + this.d.updatePersistedConfigOptionValue(session.taskRunId, configId, value); if ( !session.isCloud && @@ -2465,7 +2522,7 @@ export class SessionService { value, }); } else { - await trpcClient.agent.setConfigOption.mutate({ + await this.d.trpc.agent.setConfigOption.mutate({ sessionId: session.taskRunId, configId, value, @@ -2478,21 +2535,21 @@ export class SessionService { ? ({ ...opt, currentValue: previousValue } as SessionConfigOption) : opt, ); - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { configOptions: rolledBackOptions, }); - updatePersistedConfigOptionValue( + this.d.updatePersistedConfigOptionValue( session.taskRunId, configId, String(previousValue), ); - log.error("Failed to set session config option", { + this.d.log.error("Failed to set session config option", { taskId, configId, value, error, }); - toast.error("Failed to change setting. Please try again."); + this.d.toast.error("Failed to change setting. Please try again."); } } @@ -2505,7 +2562,7 @@ export class SessionService { category: string, value: string, ): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) return; const configOption = getConfigOptionByCategory( @@ -2513,12 +2570,15 @@ export class SessionService { category, ); if (!configOption) { - log.warn("Config option not found for category", { taskId, category }); + this.d.log.warn("Config option not found for category", { + taskId, + category, + }); return; } if (configOption.currentValue !== value) { - track(ANALYTICS_EVENTS.SESSION_CONFIG_CHANGED, { + this.d.track(ANALYTICS_EVENTS.SESSION_CONFIG_CHANGED, { task_id: taskId, category, from_value: String(configOption.currentValue), @@ -2539,11 +2599,11 @@ export class SessionService { command: string, cwd: string, ): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) return; const event = createUserShellExecuteEvent(command, cwd, undefined, id); - sessionStoreSetters.appendEvents(session.taskRunId, [event]); + this.d.store.appendEvents(session.taskRunId, [event]); } /** @@ -2557,7 +2617,7 @@ export class SessionService { cwd: string, result: { stdout: string; stderr: string; exitCode: number }, ): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) return; const storedEntry: StoredLogEntry = { @@ -2586,7 +2646,7 @@ export class SessionService { */ async clearSessionError(taskId: string, repoPath: string): Promise { this.localRepoPaths.set(taskId, repoPath); - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (session?.initialPrompt?.length) { const { taskTitle, initialPrompt } = session; await this.teardownSession(session.taskRunId); @@ -2634,7 +2694,7 @@ export class SessionService { overrideSessionId?: string | null, ): Promise { this.localRepoPaths.set(taskId, repoPath); - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) return false; const { taskRunId, taskTitle, logUrl } = session; @@ -2642,7 +2702,7 @@ export class SessionService { // Cancel lingering backend agent (ignore errors — it may not exist // after a failed reconnect) try { - await trpcClient.agent.cancel.mutate({ sessionId: taskRunId }); + await this.d.trpc.agent.cancel.mutate({ sessionId: taskRunId }); } catch { // expected when backend has no session } @@ -2689,14 +2749,17 @@ export class SessionService { const cacheKey = `${apiHost}::${adapter}`; let pending = this.previewConfigOptionsCache.get(cacheKey); if (!pending) { - pending = trpcClient.agent.getPreviewConfigOptions + pending = this.d.trpc.agent.getPreviewConfigOptions .query({ apiHost, adapter }) .catch((err: unknown) => { - log.warn("Failed to fetch preview config options for cloud session", { - apiHost, - adapter, - error: err, - }); + this.d.log.warn( + "Failed to fetch preview config options for cloud session", + { + apiHost, + adapter, + error: err, + }, + ); this.previewConfigOptionsCache.delete(cacheKey); return [] as SessionConfigOption[]; }); @@ -2724,7 +2787,7 @@ export class SessionService { if (extras.length === 0) return; - const session = sessionStoreSetters.getSessions()[taskRunId]; + const session = this.d.store.getSessions()[taskRunId]; if (!session) return; const existingOptions = session.configOptions ?? []; @@ -2733,7 +2796,7 @@ export class SessionService { if (newExtras.length === 0) return; const merged = [...existingOptions, ...newExtras]; - sessionStoreSetters.updateSession(taskRunId, { configOptions: merged }); + this.d.store.updateSession(taskRunId, { configOptions: merged }); } /** @@ -2770,7 +2833,7 @@ export class SessionService { existingWatcher.onStatusChange = onStatusChange; } // Ensure configOptions is populated on revisit - const existing = sessionStoreSetters.getSessionByTaskId(taskId); + const existing = this.d.store.getSessionByTaskId(taskId); if (existing) { const existingMode = getConfigOptionByCategory( existing.configOptions, @@ -2781,7 +2844,7 @@ export class SessionService { const shouldRefreshConfigOptions = !existing.configOptions?.length || existing.adapter !== adapter; if (shouldRefreshConfigOptions) { - sessionStoreSetters.updateSession(existing.taskRunId, { + this.d.store.updateSession(existing.taskRunId, { adapter, configOptions: buildCloudDefaultConfigOptions(currentMode, adapter), }); @@ -2804,7 +2867,7 @@ export class SessionService { const startToken = ++this.nextCloudTaskWatchToken; // Create session in the store - const existing = sessionStoreSetters.getSessionByTaskId(taskId); + const existing = this.d.store.getSessionByTaskId(taskId); // A same-run session with history but no processedLineCount came from a // non-cloud hydration path. Reset it so the cloud snapshot becomes the // single source of truth instead of being appended on top. @@ -2824,7 +2887,7 @@ export class SessionService { shouldResetExistingSession ) { const taskTitle = existing?.taskTitle ?? "Cloud Task"; - const session = this.createBaseSession(taskRunId, taskId, taskTitle); + const session = createBaseSession(taskRunId, taskId, taskTitle); session.status = "disconnected"; session.isCloud = true; session.adapter = adapter; @@ -2832,7 +2895,7 @@ export class SessionService { initialMode, adapter, ); - sessionStoreSetters.setSession(session); + this.d.store.setSession(session); // Optimistic seeding for the initial task description is deferred // until `hydrateCloudTaskSessionFromLogs` confirms there's no prior // conversation. Otherwise reopening a task with history would flash @@ -2855,7 +2918,7 @@ export class SessionService { ); } if (Object.keys(updates).length > 0) { - sessionStoreSetters.updateSession(existing.taskRunId, updates); + this.d.store.updateSession(existing.taskRunId, updates); } } @@ -2877,7 +2940,7 @@ export class SessionService { // Subscribe before starting the main-process watcher so the first replayed // SSE/log burst cannot race ahead of the renderer subscription. - const subscription = trpcClient.cloudTask.onUpdate.subscribe( + const subscription = this.d.trpc.cloudTask.onUpdate.subscribe( { taskId, runId }, { onData: (update: CloudTaskUpdatePayload) => { @@ -2893,7 +2956,7 @@ export class SessionService { } }, onError: (err: unknown) => - log.error("Cloud task subscription error", { taskId, err }), + this.d.log.error("Cloud task subscription error", { taskId, err }), }, ); @@ -2913,7 +2976,7 @@ export class SessionService { return; } - await trpcClient.cloudTask.watch.mutate({ + await this.d.trpc.cloudTask.watch.mutate({ taskId, runId, apiHost, @@ -2923,13 +2986,13 @@ export class SessionService { // If the local watcher was torn down while the watch request was in // flight, send a compensating unwatch after the start request lands. if (!this.isCurrentCloudTaskWatcher(taskId, runId, startToken)) { - await trpcClient.cloudTask.unwatch.mutate({ taskId, runId }); + await this.d.trpc.cloudTask.unwatch.mutate({ taskId, runId }); } } catch (err: unknown) { if (!this.isCurrentCloudTaskWatcher(taskId, runId, startToken)) { return; } - log.warn("Failed to start cloud task watcher", { taskId, err }); + this.d.log.warn("Failed to start cloud task watcher", { taskId, err }); } })(); @@ -2948,14 +3011,14 @@ export class SessionService { taskRunId, ); - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session || session.taskRunId !== taskRunId) { return; } const events = convertStoredEntriesToEvents(rawEntries); const hasUserPrompt = events.some( - (e) => + (e: AcpMessage) => isJsonRpcRequest(e.message) && e.message.method === "session/prompt", ); @@ -2964,7 +3027,7 @@ export class SessionService { // brand-new task case as well as "agent has emitted lifecycle // notifications but hasn't received its first prompt yet". if (!hasUserPrompt && taskDescription?.trim()) { - sessionStoreSetters.appendOptimisticItem(taskRunId, { + this.d.store.appendOptimisticItem(taskRunId, { type: "user_message", content: taskDescription, timestamp: Date.now(), @@ -2984,7 +3047,7 @@ export class SessionService { return; } - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { events, isCloud: true, logUrl: logUrl ?? session.logUrl, @@ -2995,7 +3058,7 @@ export class SessionService { // path otherwise sees delta <= 0 and never re-evaluates the tail. this.updatePromptStateFromEvents(taskRunId, events); })().catch((err: unknown) => { - log.warn("Failed to hydrate cloud task session from logs", { + this.d.log.warn("Failed to hydrate cloud task session from logs", { taskId, taskRunId, err, @@ -3023,11 +3086,11 @@ export class SessionService { watcher.subscription.unsubscribe(); this.cloudTaskWatchers.delete(taskId); - this.cloudLogReconcileDeficiency.delete(watcher.runId); + this.cloudLogGapReconciler.forgetDeficiency(watcher.runId); } async preflightToLocal(taskId: string, repoPath: string) { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) return { canHandoff: false as const, @@ -3043,7 +3106,7 @@ export class SessionService { reason: "Authentication required", }; - const preflight = await trpcClient.handoff.preflight.query({ + const preflight = await this.d.trpc.handoff.preflight.query({ taskId, runId: session.taskRunId, repoPath, @@ -3061,9 +3124,9 @@ export class SessionService { } async handoffToLocal(taskId: string, repoPath: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) { - log.warn("No session found for handoff", { taskId }); + this.d.log.warn("No session found for handoff", { taskId }); return; } @@ -3071,7 +3134,7 @@ export class SessionService { const auth = await this.getHandoffAuth(); if (!auth) return; - sessionStoreSetters.updateSession(runId, { handoffInProgress: true }); + this.d.store.updateSession(runId, { handoffInProgress: true }); try { const preflight = await this.runHandoffPreflight( @@ -3081,7 +3144,7 @@ export class SessionService { auth, ); this.stopCloudTaskWatch(taskId); - sessionStoreSetters.updateSession(runId, { status: "connecting" }); + this.d.store.updateSession(runId, { status: "connecting" }); await this.executeHandoff( taskId, runId, @@ -3092,18 +3155,20 @@ export class SessionService { this.transitionToLocalSession(runId); this.subscribeToChannel(runId); await Promise.all([ - queryClient.refetchQueries({ queryKey: ["tasks"] }), - queryClient.refetchQueries(trpc.workspace.getAll.pathFilter()), + this.d.queryClient.refetchQueries({ queryKey: ["tasks"] }), + this.d.queryClient.refetchQueries({ + queryKey: this.d.WORKSPACE_QUERY_KEY, + }), ]); - sessionStoreSetters.updateSession(runId, { handoffInProgress: false }); - log.info("Cloud-to-local handoff complete", { taskId, runId }); + this.d.store.updateSession(runId, { handoffInProgress: false }); + this.d.log.info("Cloud-to-local handoff complete", { taskId, runId }); } catch (err) { - log.error("Handoff failed", { taskId, err }); - toast.error( + this.d.log.error("Handoff failed", { taskId, err }); + this.d.toast.error( err instanceof Error ? err.message : "Handoff to local failed", ); this.watchCloudTask(taskId, runId, auth.apiHost, auth.projectId); - sessionStoreSetters.updateSession(runId, { + this.d.store.updateSession(runId, { handoffInProgress: false, status: "disconnected", }); @@ -3111,9 +3176,9 @@ export class SessionService { } async handoffToCloud(taskId: string, repoPath: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) { - log.warn("No session found for cloud handoff", { taskId }); + this.d.log.warn("No session found for cloud handoff", { taskId }); return; } @@ -3121,25 +3186,25 @@ export class SessionService { const auth = await this.getHandoffAuth(); if (!auth) return; - sessionStoreSetters.updateSession(runId, { handoffInProgress: true }); + this.d.store.updateSession(runId, { handoffInProgress: true }); try { - const preflight = await trpcClient.handoff.preflightToCloud.query({ + const preflight = await this.d.trpc.handoff.preflightToCloud.query({ taskId, runId, repoPath, }); if (!preflight.canHandoff) { - sessionStoreSetters.updateSession(runId, { + this.d.store.updateSession(runId, { handoffInProgress: false, }); throw new Error(preflight.reason ?? "Cannot hand off to cloud"); } this.unsubscribeFromChannel(runId); - sessionStoreSetters.updateSession(runId, { status: "connecting" }); + this.d.store.updateSession(runId, { status: "connecting" }); - const result = await trpcClient.handoff.executeToCloud.mutate({ + const result = await this.d.trpc.handoff.executeToCloud.mutate({ taskId, runId, repoPath, @@ -3156,7 +3221,7 @@ export class SessionService { throw new Error(result.error ?? "Handoff to cloud failed"); } - sessionStoreSetters.updateSession(runId, { + this.d.store.updateSession(runId, { isCloud: true, cloudStatus: undefined, cloudStage: undefined, @@ -3169,22 +3234,24 @@ export class SessionService { this.watchCloudTask(taskId, runId, auth.apiHost, auth.projectId); await Promise.all([ - queryClient.refetchQueries({ queryKey: ["tasks"] }), - queryClient.refetchQueries(trpc.workspace.getAll.pathFilter()), + this.d.queryClient.refetchQueries({ queryKey: ["tasks"] }), + this.d.queryClient.refetchQueries({ + queryKey: this.d.WORKSPACE_QUERY_KEY, + }), ]); - sessionStoreSetters.updateSession(runId, { handoffInProgress: false }); - log.info("Local-to-cloud handoff complete", { taskId, runId }); + this.d.store.updateSession(runId, { handoffInProgress: false }); + this.d.log.info("Local-to-cloud handoff complete", { taskId, runId }); } catch (err) { - log.error("Handoff to cloud failed", { taskId, err }); + this.d.log.error("Handoff to cloud failed", { taskId, err }); if (err instanceof GitHubAuthorizationRequiredForCloudHandoffError) { await this.startGithubReauthForCloudHandoff(auth.projectId); } else { - toast.error( + this.d.toast.error( err instanceof Error ? err.message : "Handoff to cloud failed", ); } this.subscribeToChannel(runId); - sessionStoreSetters.updateSession(runId, { + this.d.store.updateSession(runId, { handoffInProgress: false, status: "disconnected", }); @@ -3194,9 +3261,9 @@ export class SessionService { private async startGithubReauthForCloudHandoff( projectId: number, ): Promise { - const client = await getAuthenticatedClient(); + const client = await this.d.getAuthenticatedClient(); if (!client) { - toast.error("Sign in before connecting GitHub."); + this.d.toast.error("Sign in before connecting GitHub."); return; } @@ -3205,19 +3272,19 @@ export class SessionService { await client.startGithubUserIntegrationConnect(projectId); const url = installUrl?.trim(); if (!url) { - toast.error( + this.d.toast.error( "GitHub connection did not return a URL. Please try again.", ); return; } - await trpcClient.os.openExternal.mutate({ url }); - toast.info( + await this.d.trpc.os.openExternal.mutate({ url }); + this.d.toast.info( "Connect GitHub to continue in cloud", "Complete the authorization in your browser, then click Continue again.", ); } catch (error) { - toast.error( + this.d.toast.error( error instanceof Error ? error.message : "Failed to start GitHub connection", @@ -3229,16 +3296,16 @@ export class SessionService { apiHost: string; projectId: number; } | null> { - let auth: Awaited>; + let auth: Awaited>; try { - auth = await fetchAuthState(); + auth = await this.d.fetchAuthState(); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; - toast.error(`Authentication required for handoff: ${message}`); + this.d.toast.error(`Authentication required for handoff: ${message}`); return null; } if (!auth.projectId || !auth.cloudRegion) { - toast.error("Missing project configuration for handoff"); + this.d.toast.error("Missing project configuration for handoff"); return null; } return { @@ -3252,8 +3319,8 @@ export class SessionService { runId: string, repoPath: string, auth: { apiHost: string; projectId: number }, - ): Promise>> { - const preflight = await trpcClient.handoff.preflight.query({ + ): Promise>> { + const preflight = await this.d.trpc.handoff.preflight.query({ taskId, runId, repoPath, @@ -3261,7 +3328,7 @@ export class SessionService { teamId: auth.projectId, }); if (!preflight.canHandoff) { - sessionStoreSetters.updateSession(runId, { + this.d.store.updateSession(runId, { handoffInProgress: false, }); throw new Error(preflight.reason ?? "Cannot hand off to local"); @@ -3275,10 +3342,10 @@ export class SessionService { repoPath: string, auth: { apiHost: string; projectId: number }, localGitState?: Awaited< - ReturnType + ReturnType >["localGitState"], ): Promise { - const result = await trpcClient.handoff.execute.mutate({ + const result = await this.d.trpc.handoff.execute.mutate({ taskId, runId, repoPath, @@ -3292,7 +3359,7 @@ export class SessionService { } private transitionToLocalSession(runId: string): void { - sessionStoreSetters.updateSession(runId, { + this.d.store.updateSession(runId, { isCloud: false, cloudStatus: undefined, cloudStage: undefined, @@ -3304,7 +3371,7 @@ export class SessionService { } async retryCloudTaskWatch(taskId: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session?.isCloud) { throw new Error("No active cloud session for task"); } @@ -3312,7 +3379,7 @@ export class SessionService { const previousErrorTitle = session.errorTitle; const previousErrorMessage = session.errorMessage; - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { status: "disconnected", errorTitle: undefined, errorMessage: undefined, @@ -3320,12 +3387,12 @@ export class SessionService { }); try { - await trpcClient.cloudTask.retry.mutate({ + await this.d.trpc.cloudTask.retry.mutate({ taskId, runId: session.taskRunId, }); } catch (error) { - sessionStoreSetters.updateSession(session.taskRunId, { + this.d.store.updateSession(session.taskRunId, { status: "error", errorTitle: previousErrorTitle, errorMessage: previousErrorMessage, @@ -3351,15 +3418,15 @@ export class SessionService { * Retry themselves. */ public retryUnhealthyCloudSessions(): void { - const sessions = sessionStoreSetters.getSessions(); + const sessions = this.d.store.getSessions(); for (const session of Object.values(sessions)) { if (!session.isCloud) continue; if (session.status !== "error") continue; - log.info("Auto-retrying errored cloud session on focus", { + this.d.log.info("Auto-retrying errored cloud session on focus", { taskId: session.taskId, }); this.retryCloudTaskWatch(session.taskId).catch((error) => { - log.warn("Auto-retry of errored cloud session failed", { + this.d.log.warn("Auto-retry of errored cloud session failed", { taskId: session.taskId, error, }); @@ -3368,12 +3435,283 @@ export class SessionService { } public updateSessionTaskTitle(taskId: string, taskTitle: string): void { - const session = sessionStoreSetters.getSessionByTaskId(taskId); + const session = this.d.store.getSessionByTaskId(taskId); if (!session) return; if (session.taskTitle === taskTitle) return; - sessionStoreSetters.updateSession(session.taskRunId, { taskTitle }); + this.d.store.updateSession(session.taskRunId, { taskTitle }); + } + + public startActivityHeartbeat(taskRunId: string): () => void { + const record = () => { + this.d.trpc.agent.recordActivity.mutate({ taskRunId }).catch(() => {}); + }; + + record(); + const existing = this.activityHeartbeats.get(taskRunId); + if (existing) { + clearInterval(existing); + } + const heartbeat = setInterval(record, ACTIVITY_HEARTBEAT_INTERVAL_MS); + this.activityHeartbeats.set(taskRunId, heartbeat); + + return () => { + clearInterval(heartbeat); + this.activityHeartbeats.delete(taskRunId); + }; + } + + public reconcileTaskConnection( + params: ReconcileTaskConnectionParams, + ): () => void { + const { + task, + session, + repoPath, + isCloud, + isSuspended, + isOnline, + cloudAuth, + onCloudStatusChange, + } = params; + + if (isCloud) { + return this.reconcileCloudConnection( + task, + cloudAuth, + onCloudStatusChange, + ); + } + + if (repoPath) { + return this.reconcileLocalConnection({ + task, + session, + repoPath, + isOnline, + isSuspended, + }); + } + + this.loadLogsOnlyIfDisconnected(task, session); + return () => {}; + } + + private reconcileCloudConnection( + task: Task, + cloudAuth: CloudConnectionAuth, + onCloudStatusChange?: () => void, + ): () => void { + this.updateSessionTaskTitle( + task.id, + task.title || task.description || "Cloud Task", + ); + + const runId = task.latest_run?.id; + if (!runId) return () => {}; + if (cloudAuth.status !== "authenticated") return () => {}; + if (!cloudAuth.bootstrapComplete) return () => {}; + if (!cloudAuth.projectId || !cloudAuth.cloudRegion) return () => {}; + + const initialMode = + typeof task.latest_run?.state?.initial_permission_mode === "string" + ? task.latest_run.state.initial_permission_mode + : undefined; + const adapter = + task.latest_run?.runtime_adapter === "codex" ? "codex" : "claude"; + const initialModel = task.latest_run?.model ?? undefined; + + return this.watchCloudTask( + task.id, + runId, + getCloudUrlFromRegion(cloudAuth.cloudRegion), + cloudAuth.projectId, + onCloudStatusChange, + task.latest_run?.log_url, + initialMode, + adapter, + initialModel, + task.description ?? undefined, + ); + } + + private reconcileLocalConnection(params: { + task: Task; + session: AgentSession | undefined; + repoPath: string; + isOnline: boolean; + isSuspended?: boolean; + }): () => void { + const { task, session, repoPath, isOnline, isSuspended } = params; + const taskId = task.id; + + if (this.reconcilingTasks.has(taskId)) return () => {}; + if (!isOnline) return () => {}; + if (session?.isCloud) return () => {}; + if (isSuspended) return () => {}; + + if (session?.status === "error" && session?.idleKilled) { + const taskRunId = session.taskRunId; + this.reconcilingTasks.add(taskId); + this.clearSessionError(taskId, repoPath) + .catch((error) => { + this.d.log.error("Auto-reconnect after idle kill failed", { error }); + this.d.store.updateSession(taskRunId, { + idleKilled: false, + errorMessage: + "Session disconnected due to inactivity. Click Retry to reconnect.", + }); + }) + .finally(() => { + this.reconcilingTasks.delete(taskId); + }); + return () => { + this.reconcilingTasks.delete(taskId); + }; + } + + if ( + session?.status === "connected" || + session?.status === "connecting" || + session?.status === "error" + ) { + return () => {}; + } + + if (!task.latest_run?.id) return () => {}; + + this.reconcilingTasks.add(taskId); + this.connectToTask({ task, repoPath }).finally(() => { + this.reconcilingTasks.delete(taskId); + }); + + return () => { + this.reconcilingTasks.delete(taskId); + }; + } + + private loadLogsOnlyIfDisconnected( + task: Task, + session: AgentSession | undefined, + ): void { + if (session && session.events.length > 0) return; + if (!task.latest_run?.id || !task.latest_run?.log_url) return; + + this.loadLogsOnly({ + taskId: task.id, + taskRunId: task.latest_run.id, + taskTitle: task.title || task.description || "Task", + logUrl: task.latest_run.log_url, + }); + } + + public resolveAllowAlwaysUpgradeMode( + modeOption: SessionConfigOption | undefined, + ): string | undefined { + if (modeOption?.type !== "select") return undefined; + const availableIds = new Set( + flattenSelectOptions(modeOption.options).map((opt) => opt.value), + ); + if (availableIds.has("acceptEdits")) return "acceptEdits"; + if (availableIds.has("auto")) return "auto"; + return undefined; + } + + public applyAllowAlwaysUpgrade( + taskId: string, + modeOption: SessionConfigOption | undefined, + ): void { + const upgradeMode = this.resolveAllowAlwaysUpgradeMode(modeOption); + if (!upgradeMode) return; + this.setSessionConfigOptionByCategory(taskId, "mode", upgradeMode); + } + + async resolvePermissionSelection( + taskId: string, + permission: PermissionRequest & { toolCallId: string }, + optionId: string, + modeOption: SessionConfigOption | undefined, + customInput?: string, + answers?: Record, + ): Promise { + const plan = planPermissionResponse(permission, optionId, customInput); + + if (plan.applyAllowAlwaysUpgrade) { + this.applyAllowAlwaysUpgrade(taskId, modeOption); + } + + await this.respondToPermission( + taskId, + permission.toolCallId, + optionId, + plan.respondWithCustomInput ? customInput : undefined, + answers, + ); + + return plan; + } + + async cancelPermissionAndPrompt( + taskId: string, + toolCallId: string, + ): Promise { + await this.cancelPermission(taskId, toolCallId); + await this.cancelPrompt(taskId); + } + + public selectLatestPlan(events: AcpMessage[]): SessionPlan | null { + let planIndex = -1; + let plan: SessionPlan | null = null; + let turnEndResponseIndex = -1; + + for (let i = events.length - 1; i >= 0; i--) { + const msg = events[i].message; + + if ( + turnEndResponseIndex === -1 && + isJsonRpcResponse(msg) && + (msg.result as { stopReason?: string })?.stopReason !== undefined + ) { + turnEndResponseIndex = i; + } + + if ( + planIndex === -1 && + isJsonRpcNotification(msg) && + msg.method === "session/update" + ) { + const update = (msg.params as { update?: { sessionUpdate?: string } }) + ?.update; + if (update?.sessionUpdate === "plan") { + planIndex = i; + plan = update as SessionPlan; + } + } + + if (planIndex !== -1 && turnEndResponseIndex !== -1) break; + } + + if (turnEndResponseIndex > planIndex) return null; + + return plan; + } + + public maybeRevertBypassMode( + taskId: string | undefined, + options: { + isCloud: boolean; + allowBypassPermissions: boolean; + currentModeId: string | boolean | undefined; + }, + ): void { + if (options.allowBypassPermissions) return; + if (options.isCloud) return; + const isBypass = + options.currentModeId === "bypassPermissions" || + options.currentModeId === "full-access"; + if (!isBypass || !taskId) return; + this.setSessionConfigOptionByCategory(taskId, "mode", "default"); } /** @@ -3394,7 +3732,11 @@ export class SessionService { setTimeout(() => { this.scheduledCloudQueueFlushes.delete(taskId); this.sendQueuedCloudMessages(taskId).catch((err) => - log.error("cloud queue flush failed", { taskId, reason, error: err }), + this.d.log.error("cloud queue flush failed", { + taskId, + reason, + error: err, + }), ); }, 0); } @@ -3416,7 +3758,7 @@ export class SessionService { * for THIS run (`isAgentIdleForRun`), recover readiness and drain. */ private tryRecoverIdleCloudQueue(taskRunId: string): void { - const session = sessionStoreSetters.getSessions()[taskRunId]; + const session = this.d.store.getSessions()[taskRunId]; if (!session?.isCloud || session.messageQueue.length === 0) { return; } @@ -3457,21 +3799,24 @@ export class SessionService { return; } if (idleResult.shouldCacheToStore) { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { agentIdleForRunId: taskRunId, }); } if (recoverableAfterTransportDrop) { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { status: "connected", errorTitle: undefined, errorMessage: undefined, }); - log.info("Recovered cloud session readiness after transport drop", { - taskId: session.taskId, - previousStatus: session.status, - }); + this.d.log.info( + "Recovered cloud session readiness after transport drop", + { + taskId: session.taskId, + previousStatus: session.status, + }, + ); } this.scheduleCloudQueueFlush(session.taskId, "idle-run-recovery"); @@ -3482,7 +3827,7 @@ export class SessionService { update: CloudTaskUpdatePayload, ): void { if (update.kind === "error") { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { status: "error", errorTitle: update.errorTitle, errorMessage: @@ -3511,22 +3856,25 @@ export class SessionService { update.newEntries, ); if (latestConfigOptions) { - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.updateSession(taskRunId, { configOptions: latestConfigOptions, }); - setPersistedConfigOptions(taskRunId, latestConfigOptions); + this.d.setPersistedConfigOptions(taskRunId, latestConfigOptions); } - const session = sessionStoreSetters.getSessions()[taskRunId]; + const session = this.d.store.getSessions()[taskRunId]; const currentCount = session?.processedLineCount ?? 0; const expectedCount = update.totalEntryCount; - const delta = expectedCount - currentCount; + const plan = classifyCloudLogAppend( + currentCount, + expectedCount, + update.newEntries.length, + ); - if (delta <= 0) { + if (plan.kind === "caught-up") { // Already caught up — skip duplicate entries - } else if (delta <= update.newEntries.length) { - // Normal case: append only the tail (last `delta` entries) - const entriesToAppend = update.newEntries.slice(-delta); + } else if (plan.kind === "append-tail") { + const entriesToAppend = update.newEntries.slice(-plan.tailCount); let newEvents = convertStoredEntriesToEvents(entriesToAppend); newEvents = this.filterSkippedPromptEvents( taskRunId, @@ -3534,14 +3882,14 @@ export class SessionService { newEvents, ); if (hasSessionPromptEvent(newEvents)) { - sessionStoreSetters.clearTailOptimisticItems(taskRunId); + this.d.store.clearTailOptimisticItems(taskRunId); } - sessionStoreSetters.appendEvents(taskRunId, newEvents, expectedCount); + this.d.store.appendEvents(taskRunId, newEvents, expectedCount); this.updatePromptStateFromEvents(taskRunId, newEvents, { isLive: true, }); } else { - this.reconcileCloudLogGap({ + this.cloudLogGapReconciler.reconcile({ taskId: update.taskId, taskRunId, expectedCount, @@ -3562,7 +3910,7 @@ export class SessionService { // Update cloud status fields if present if (update.kind === "status" || update.kind === "snapshot") { - sessionStoreSetters.updateCloudStatus(taskRunId, { + this.d.store.updateCloudStatus(taskRunId, { status: update.status, stage: update.stage, output: update.output, @@ -3576,13 +3924,13 @@ export class SessionService { if (isTerminalStatus(update.status)) { // Clean up any pending resume messages that couldn't be sent - const session = sessionStoreSetters.getSessions()[taskRunId]; + const session = this.d.store.getSessions()[taskRunId]; if ( session && (session.messageQueue.length > 0 || session.isPromptPending) ) { - sessionStoreSetters.clearMessageQueue(session.taskId); - sessionStoreSetters.updateSession(taskRunId, { + this.d.store.clearMessageQueue(session.taskId); + this.d.store.updateSession(taskRunId, { isPromptPending: false, }); } @@ -3591,20 +3939,6 @@ export class SessionService { } } - private getCloudPrAuthorshipMode( - state: Record, - ): PrAuthorshipMode { - const explicitMode = state.pr_authorship_mode; - if (explicitMode === "user" || explicitMode === "bot") { - return explicitMode; - } - return state.run_source === "signal_report" ? "bot" : "user"; - } - - private getCloudRunSource(state: Record): CloudRunSource { - return state.run_source === "signal_report" ? "signal_report" : "manual"; - } - /** * Filter out session/prompt events that should be skipped during resume. * When resuming a cloud run, the initial session/prompt from the new run's @@ -3618,108 +3952,39 @@ export class SessionService { session: AgentSession | undefined, events: AcpMessage[], ): AcpMessage[] { - if (!session?.skipPolledPromptCount || session.skipPolledPromptCount <= 0) { - return events; - } - - const promptIdx = events.findIndex( - (e) => - isJsonRpcRequest(e.message) && e.message.method === "session/prompt", + const plan = planSkippedPromptFilter( + session?.skipPolledPromptCount, + events, ); - if (promptIdx !== -1) { - const filtered = [...events]; - filtered.splice(promptIdx, 1); - sessionStoreSetters.updateSession(taskRunId, { - skipPolledPromptCount: (session.skipPolledPromptCount ?? 0) - 1, - }); - return filtered; + if (!plan) { + return events; } - return events; + this.d.store.updateSession(taskRunId, { + skipPolledPromptCount: plan.remainingSkipCount, + }); + return plan.events; } // --- Helper Methods --- private async getAuthCredentials(): Promise { - const authState = await fetchAuthState(); + const authState = await this.d.fetchAuthState(); const apiHost = authState.cloudRegion ? getCloudUrlFromRegion(authState.cloudRegion) : null; const projectId = authState.projectId; - const client = createAuthenticatedClient(authState); + const client = this.d.createAuthenticatedClient(authState); if (!apiHost || !projectId || !client) return null; return { apiHost, projectId, client }; } - private getCloudRuntimeOptions( - session: AgentSession, - previousRun?: TaskRun, - ): { - adapter?: Adapter; - model?: string; - reasoningLevel?: string; - } { - const modelOption = getConfigOptionByCategory( - session.configOptions, - "model", - ); - const thoughtLevelOption = getConfigOptionByCategory( - session.configOptions, - "thought_level", - ); - - return { - adapter: session.adapter ?? previousRun?.runtime_adapter ?? undefined, - model: - typeof modelOption?.currentValue === "string" - ? modelOption.currentValue - : (previousRun?.model ?? undefined), - reasoningLevel: - typeof thoughtLevelOption?.currentValue === "string" - ? thoughtLevelOption.currentValue - : (previousRun?.reasoning_effort ?? undefined), - }; - } - private parseLogContent(content: string): ParsedSessionLogs { - const rawEntries: StoredLogEntry[] = []; - let sessionId: string | undefined; - let adapter: Adapter | undefined; - let parseFailureCount = 0; - const lines = content.trim().split("\n"); - - for (const line of lines) { - try { - const stored = JSON.parse(line) as StoredLogEntry; - rawEntries.push(stored); - - if ( - stored.type === "notification" && - stored.notification?.method?.endsWith("posthog/sdk_session") - ) { - const params = stored.notification.params as { - sessionId?: string; - sdkSessionId?: string; - adapter?: Adapter; - }; - if (params?.sessionId) sessionId = params.sessionId; - else if (params?.sdkSessionId) sessionId = params.sdkSessionId; - if (params?.adapter) adapter = params.adapter; - } - } catch { - parseFailureCount += 1; - log.warn("Failed to parse log entry", { line }); - } - } - - return { - rawEntries, - totalLineCount: lines.length, - parseFailureCount, - sessionId, - adapter, - }; + return parseSessionLogContent(content, { + onParseError: (line) => + this.d.log.warn("Failed to parse log entry", { line }), + }); } private async fetchSessionLogs( @@ -3737,7 +4002,7 @@ export class SessionService { if (taskRunId) { try { - const localContent = await trpcClient.logs.readLocalLogs.query({ + const localContent = await this.d.trpc.logs.readLocalLogs.query({ taskRunId, }); if (localContent?.trim()) { @@ -3750,7 +4015,7 @@ export class SessionService { } } } catch { - log.warn("Failed to read local logs, falling back to S3", { + this.d.log.warn("Failed to read local logs, falling back to S3", { taskRunId, }); } @@ -3759,16 +4024,19 @@ export class SessionService { if (!logUrl) return localResult ?? empty; try { - const content = await trpcClient.logs.fetchS3Logs.query({ logUrl }); + const content = await this.d.trpc.logs.fetchS3Logs.query({ logUrl }); if (!content?.trim()) return localResult ?? empty; const result = this.parseLogContent(content); if (taskRunId && result.rawEntries.length > 0) { - trpcClient.logs.writeLocalLogs + this.d.trpc.logs.writeLocalLogs .mutate({ taskRunId, content }) - .catch((err) => { - log.warn("Failed to cache S3 logs locally", { taskRunId, err }); + .catch((err: unknown) => { + this.d.log.warn("Failed to cache S3 logs locally", { + taskRunId, + err, + }); }); } @@ -3785,175 +4053,28 @@ export class SessionService { } } - private reconcileCloudLogGap(request: CloudLogGapReconcileRequest): void { - const { taskId, taskRunId } = request; - const reconcileKey = `${taskId}:${taskRunId}`; - const existing = this.cloudLogGapReconciles.get(reconcileKey); - if (existing) { - existing.pendingRequest = this.mergeCloudLogGapRequests( - existing.pendingRequest, - request, - ); - return; - } - - this.cloudLogGapReconciles.set(reconcileKey, {}); - void this.runCloudLogGapReconciles(reconcileKey, request) - .catch((err: unknown) => { - log.warn("Failed to reconcile cloud task log gap", { - taskId, - taskRunId, - err, - }); - }) - .finally(() => { - this.cloudLogGapReconciles.delete(reconcileKey); - }); - } - - private mergeCloudLogGapRequests( - current: CloudLogGapReconcileRequest | undefined, - next: CloudLogGapReconcileRequest, - ): CloudLogGapReconcileRequest { - if (!current) return next; - - return { - taskId: next.taskId, - taskRunId: next.taskRunId, - currentCount: Math.min(current.currentCount, next.currentCount), - expectedCount: Math.max(current.expectedCount, next.expectedCount), - newEntries: [...current.newEntries, ...next.newEntries], - logUrl: next.logUrl ?? current.logUrl, - }; - } - - private async runCloudLogGapReconciles( - reconcileKey: string, - initialRequest: CloudLogGapReconcileRequest, - ): Promise { - let request: CloudLogGapReconcileRequest | undefined = initialRequest; - - while (request) { - await this.reconcileCloudLogGapOnce(request); - const state = this.cloudLogGapReconciles.get(reconcileKey); - request = state?.pendingRequest; - if (state) { - state.pendingRequest = undefined; - } - } - } - - private async reconcileCloudLogGapOnce({ - taskId, - taskRunId, - expectedCount, - currentCount, - newEntries, - logUrl, - }: CloudLogGapReconcileRequest): Promise { - const { rawEntries, totalLineCount, parseFailureCount } = - await this.fetchSessionLogs(logUrl, taskRunId, { - minEntryCount: expectedCount, - }); - const session = sessionStoreSetters.getSessions()[taskRunId]; - if (!session || session.taskId !== taskId) { - return; - } - - const latestCount = session.processedLineCount ?? 0; - if (latestCount >= expectedCount) { - this.cloudLogReconcileDeficiency.delete(taskRunId); - return; - } - - if (totalLineCount >= expectedCount) { - const events = convertStoredEntriesToEvents(rawEntries); - if (hasSessionPromptEvent(events)) { - sessionStoreSetters.clearTailOptimisticItems(taskRunId); - } - this.cloudRunIdleTracker.delete(taskRunId); - this.cloudLogReconcileDeficiency.delete(taskRunId); - sessionStoreSetters.updateSession(taskRunId, { - events, - isCloud: true, - logUrl: logUrl ?? session.logUrl, - processedLineCount: totalLineCount, - }); - this.updatePromptStateFromEvents(taskRunId, events); - return; - } - - // Break the reconcile loop on proven corruption (parseFailureCount > 0) - // or on a stable repeat of the same deficit. Otherwise wait — likely lag. - const previous = this.cloudLogReconcileDeficiency.get(taskRunId); - const sameDeficiencyAsBefore = - previous?.expectedCount === expectedCount && - previous?.observedLineCount === totalLineCount; - - if (parseFailureCount > 0 || sameDeficiencyAsBefore) { - log.warn("Cloud task log gap unrecoverable; committing best-effort", { - taskRunId, - expectedCount, - observedLineCount: totalLineCount, - parseFailureCount, - fetchedEntries: rawEntries.length, - reason: parseFailureCount > 0 ? "parse-failure" : "stable-deficit", - }); - const events = convertStoredEntriesToEvents(rawEntries); - if (hasSessionPromptEvent(events)) { - sessionStoreSetters.clearTailOptimisticItems(taskRunId); - } - this.cloudRunIdleTracker.delete(taskRunId); - this.cloudLogReconcileDeficiency.delete(taskRunId); - sessionStoreSetters.updateSession(taskRunId, { - events, - isCloud: true, - logUrl: logUrl ?? session.logUrl, - processedLineCount: expectedCount, - }); - this.updatePromptStateFromEvents(taskRunId, events); - return; + private commitReconciledCloudEvents( + taskRunId: string, + rawEntries: StoredLogEntry[], + logUrl: string | undefined, + processedLineCount: number, + ): void { + const events = convertStoredEntriesToEvents(rawEntries); + if (hasSessionPromptEvent(events)) { + this.d.store.clearTailOptimisticItems(taskRunId); } - - this.cloudLogReconcileDeficiency.set(taskRunId, { - expectedCount, - observedLineCount: totalLineCount, - }); - log.warn("Cloud task log count inconsistency", { - taskRunId, - currentCount, - expectedCount, - fetchedCount: rawEntries.length, - parseFailureCount, - entriesReceived: newEntries.length, + this.cloudRunIdleTracker.delete(taskRunId); + this.d.store.updateSession(taskRunId, { + events, + isCloud: true, + logUrl, + processedLineCount, }); - } - - private createBaseSession( - taskRunId: string, - taskId: string, - taskTitle: string, - ): AgentSession { - return { - taskRunId, - taskId, - taskTitle, - channel: `agent-event:${taskRunId}`, - events: [], - startedAt: Date.now(), - status: "connecting", - isPromptPending: false, - isCompacting: false, - promptStartedAt: null, - pendingPermissions: new Map(), - pausedDurationMs: 0, - messageQueue: [], - optimisticItems: [], - }; + this.updatePromptStateFromEvents(taskRunId, events); } private getSessionByRunId(taskRunId: string): AgentSession | undefined { - const sessions = sessionStoreSetters.getSessions(); + const sessions = this.d.store.getSessions(); return sessions[taskRunId]; } @@ -3964,14 +4085,14 @@ export class SessionService { storedEntry: StoredLogEntry, ): Promise { // Don't update processedLineCount - it tracks S3 log lines, not local events - sessionStoreSetters.appendEvents(session.taskRunId, [event]); + this.d.store.appendEvents(session.taskRunId, [event]); - const client = await getAuthenticatedClient(); + const client = await this.d.getAuthenticatedClient(); if (client) { try { await client.appendTaskRunLog(taskId, session.taskRunId, [storedEntry]); } catch (error) { - log.warn("Failed to persist event to logs", { error }); + this.d.log.warn("Failed to persist event to logs", { error }); } } } diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts b/packages/core/src/sessions/sessionViewState.ts similarity index 67% rename from apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts rename to packages/core/src/sessions/sessionViewState.ts index 19bbdf26cb..b166960fed 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts +++ b/packages/core/src/sessions/sessionViewState.ts @@ -1,15 +1,27 @@ -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { useIsCloudTask } from "@features/workspace/hooks/useIsCloudTask"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; -import type { Task } from "@shared/types"; -import { useSessionForTask } from "../stores/sessionStore"; +import type { AcpMessage, AgentSession, Workspace } from "@posthog/shared"; +import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; -export function useSessionViewState(taskId: string, task: Task) { - const session = useSessionForTask(taskId); - const repoPath = useCwd(taskId) ?? null; - const workspace = useWorkspace(taskId); - const isCloud = useIsCloudTask(taskId); +export interface SessionViewState { + isCloudRunNotTerminal: boolean; + isCloudRunTerminal: boolean; + cloudStatus: TaskRunStatus | null; + isRunning: boolean; + hasError: boolean; + events: AcpMessage[]; + isPromptPending: boolean; + promptStartedAt: number | null | undefined; + isInitializing: boolean; + cloudBranch: string | null; + errorTitle: string | undefined; + errorMessage: string | undefined; +} +export function deriveSessionViewState( + session: AgentSession | undefined, + task: Task, + workspace: Workspace | null, + isCloud: boolean, +): SessionViewState { const cloudStatus = session?.cloudStatus ?? null; const isCloudRunNotTerminal = isCloud && @@ -50,9 +62,6 @@ export function useSessionViewState(taskId: string, task: Task) { : null; return { - session, - repoPath, - isCloud, isCloudRunNotTerminal, isCloudRunTerminal, cloudStatus, @@ -66,6 +75,6 @@ export function useSessionViewState(taskId: string, task: Task) { errorTitle: session?.errorTitle, errorMessage: session?.errorMessage ?? - (isCloud ? session?.cloudErrorMessage : undefined), + (isCloud ? (session?.cloudErrorMessage ?? undefined) : undefined), }; } diff --git a/packages/core/src/sessions/sessions.module.ts b/packages/core/src/sessions/sessions.module.ts new file mode 100644 index 0000000000..38e11de633 --- /dev/null +++ b/packages/core/src/sessions/sessions.module.ts @@ -0,0 +1,10 @@ +import { ContainerModule } from "inversify"; +import { CLOUD_ARTIFACT_SERVICE } from "./cloudArtifactIdentifiers"; +import { CloudArtifactService } from "./cloudArtifactService"; +import { TITLE_GENERATOR_SERVICE } from "./titleGeneratorIdentifiers"; +import { TitleGeneratorService } from "./titleGeneratorService"; + +export const sessionsModule = new ContainerModule(({ bind }) => { + bind(CLOUD_ARTIFACT_SERVICE).to(CloudArtifactService).inSingletonScope(); + bind(TITLE_GENERATOR_SERVICE).to(TitleGeneratorService).inSingletonScope(); +}); diff --git a/packages/core/src/sessions/titleGeneratorIdentifiers.ts b/packages/core/src/sessions/titleGeneratorIdentifiers.ts new file mode 100644 index 0000000000..49c0a872b6 --- /dev/null +++ b/packages/core/src/sessions/titleGeneratorIdentifiers.ts @@ -0,0 +1,17 @@ +export interface FileReadClient { + readAbsoluteFile(filePath: string): Promise; +} + +export interface TitleGeneratorLogger { + error(message: string, data?: unknown): void; +} + +export const TITLE_GENERATOR_SERVICE = Symbol.for( + "posthog.core.sessions.titleGeneratorService", +); +export const TITLE_GENERATOR_FILE_READ_CLIENT = Symbol.for( + "posthog.core.sessions.titleGeneratorFileReadClient", +); +export const TITLE_GENERATOR_LOGGER = Symbol.for( + "posthog.core.sessions.titleGeneratorLogger", +); diff --git a/apps/code/src/renderer/utils/generateTitle.test.ts b/packages/core/src/sessions/titleGeneratorService.test.ts similarity index 51% rename from apps/code/src/renderer/utils/generateTitle.test.ts rename to packages/core/src/sessions/titleGeneratorService.test.ts index 6f05822267..a91422b5aa 100644 --- a/apps/code/src/renderer/utils/generateTitle.test.ts +++ b/packages/core/src/sessions/titleGeneratorService.test.ts @@ -1,39 +1,18 @@ +import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { FileReadClient } from "./titleGeneratorIdentifiers"; +import { TitleGeneratorService } from "./titleGeneratorService"; -const mockReadAbsoluteFile = vi.hoisted(() => vi.fn()); -const mockLlmPrompt = vi.hoisted(() => vi.fn()); +const readAbsoluteFile = vi.fn(); +const prompt = vi.fn(); -vi.mock("@renderer/trpc", () => ({ - trpcClient: { - fs: { - readAbsoluteFile: { query: mockReadAbsoluteFile }, - }, - llmGateway: { - prompt: { mutate: mockLlmPrompt }, - }, - }, -})); - -const mockFetchAuthState = vi.hoisted(() => vi.fn()); -vi.mock("@features/auth/hooks/authQueries", () => ({ - fetchAuthState: mockFetchAuthState, -})); - -vi.mock("@utils/logger", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - }, -})); - -import { - enrichDescriptionWithFileContent, - generateTitleAndSummary, -} from "./generateTitle"; +function makeService(): TitleGeneratorService { + const gateway = { prompt } as unknown as LlmGatewayService; + const fileReadClient: FileReadClient = { readAbsoluteFile }; + return new TitleGeneratorService(gateway, fileReadClient, { + error: vi.fn(), + }); +} describe("enrichDescriptionWithFileContent", () => { beforeEach(() => { @@ -42,42 +21,42 @@ describe("enrichDescriptionWithFileContent", () => { it("returns description unchanged when it contains real text", async () => { const description = "Fix the login bug"; - const result = await enrichDescriptionWithFileContent(description); + const result = + await makeService().enrichDescriptionWithFileContent(description); expect(result).toBe(description); - expect(mockReadAbsoluteFile).not.toHaveBeenCalled(); + expect(readAbsoluteFile).not.toHaveBeenCalled(); }); it("reads text file content when description only has file tags", async () => { - mockReadAbsoluteFile.mockResolvedValue("const x = 1;\nexport default x;"); + readAbsoluteFile.mockResolvedValue("const x = 1;\nexport default x;"); const description = '1. '; - const result = await enrichDescriptionWithFileContent(description); + const result = + await makeService().enrichDescriptionWithFileContent(description); expect(result).toBe("const x = 1;\nexport default x;"); - expect(mockReadAbsoluteFile).toHaveBeenCalledWith({ - filePath: "/tmp/code.ts", - }); + expect(readAbsoluteFile).toHaveBeenCalledWith("/tmp/code.ts"); }); it("handles multiple file tags", async () => { - mockReadAbsoluteFile + readAbsoluteFile .mockResolvedValueOnce("file one") .mockResolvedValueOnce("file two"); const description = '1. \n2. '; - const result = await enrichDescriptionWithFileContent(description); + const result = + await makeService().enrichDescriptionWithFileContent(description); expect(result).toBe("file one\n\nfile two"); }); it("uses filePaths argument over parsed tags", async () => { - mockReadAbsoluteFile.mockResolvedValue("from explicit path"); + readAbsoluteFile.mockResolvedValue("from explicit path"); const description = '1. '; - const result = await enrichDescriptionWithFileContent(description, [ - "/tmp/explicit.ts", - ]); + const result = await makeService().enrichDescriptionWithFileContent( + description, + ["/tmp/explicit.ts"], + ); expect(result).toBe("from explicit path"); - expect(mockReadAbsoluteFile).toHaveBeenCalledWith({ - filePath: "/tmp/explicit.ts", - }); + expect(readAbsoluteFile).toHaveBeenCalledWith("/tmp/explicit.ts"); }); it.each([ @@ -89,18 +68,19 @@ describe("enrichDescriptionWithFileContent", () => { { label: "read throws", description: '1. ', - setup: () => mockReadAbsoluteFile.mockRejectedValue(new Error("ENOENT")), + setup: () => readAbsoluteFile.mockRejectedValue(new Error("ENOENT")), }, { label: "read returns null", description: '1. ', - setup: () => mockReadAbsoluteFile.mockResolvedValue(null), + setup: () => readAbsoluteFile.mockResolvedValue(null), }, ])( "falls back to filename hint -- $label", async ({ description, setup }) => { setup(); - const result = await enrichDescriptionWithFileContent(description); + const result = + await makeService().enrichDescriptionWithFileContent(description); const filename = description.match(/path="[^"]*\/([^"]+)"/)?.[1]; expect(result).toBe(`[Attached: ${filename}]`); }, @@ -108,28 +88,31 @@ describe("enrichDescriptionWithFileContent", () => { it("truncates content longer than 500 chars", async () => { const longContent = "x".repeat(600); - mockReadAbsoluteFile.mockResolvedValue(longContent); + readAbsoluteFile.mockResolvedValue(longContent); const description = '1. '; - const result = await enrichDescriptionWithFileContent(description); + const result = + await makeService().enrichDescriptionWithFileContent(description); expect(result).toBe("x".repeat(500)); }); it("strips 'Attached files:' lines when checking for real text", async () => { - mockReadAbsoluteFile.mockResolvedValue("content"); + readAbsoluteFile.mockResolvedValue("content"); const description = '1. \nAttached files: a.ts'; - const result = await enrichDescriptionWithFileContent(description); + const result = + await makeService().enrichDescriptionWithFileContent(description); expect(result).toBe("content"); }); it("returns original description when no file paths found", async () => { const description = "1. \n2. "; - const result = await enrichDescriptionWithFileContent(description); + const result = + await makeService().enrichDescriptionWithFileContent(description); expect(result).toBe(description); }); it("mixes binary and text files", async () => { - mockReadAbsoluteFile.mockResolvedValue("text content"); - const result = await enrichDescriptionWithFileContent("", [ + readAbsoluteFile.mockResolvedValue("text content"); + const result = await makeService().enrichDescriptionWithFileContent("", [ "/tmp/image.jpg", "/tmp/code.ts", ]); @@ -138,67 +121,61 @@ describe("enrichDescriptionWithFileContent", () => { it("returns description unchanged for folder-only input", async () => { const description = ''; - const result = await enrichDescriptionWithFileContent(description); + const result = + await makeService().enrichDescriptionWithFileContent(description); expect(result).toBe(description); - expect(mockReadAbsoluteFile).not.toHaveBeenCalled(); + expect(readAbsoluteFile).not.toHaveBeenCalled(); }); it("reads file and drops folder for mixed file+folder input", async () => { - mockReadAbsoluteFile.mockResolvedValue("file body"); + readAbsoluteFile.mockResolvedValue("file body"); const description = ''; - const result = await enrichDescriptionWithFileContent(description); + const result = + await makeService().enrichDescriptionWithFileContent(description); expect(result).toBe("file body"); - expect(mockReadAbsoluteFile).toHaveBeenCalledTimes(1); - expect(mockReadAbsoluteFile).toHaveBeenCalledWith({ - filePath: "/tmp/a.ts", - }); + expect(readAbsoluteFile).toHaveBeenCalledTimes(1); + expect(readAbsoluteFile).toHaveBeenCalledWith("/tmp/a.ts"); }); it("treats non-chip XML-like text as real content", async () => { const description = "
hello world
"; - const result = await enrichDescriptionWithFileContent(description); + const result = + await makeService().enrichDescriptionWithFileContent(description); expect(result).toBe(description); - expect(mockReadAbsoluteFile).not.toHaveBeenCalled(); + expect(readAbsoluteFile).not.toHaveBeenCalled(); }); }); describe("generateTitleAndSummary", () => { beforeEach(() => { vi.clearAllMocks(); - mockFetchAuthState.mockResolvedValue({ status: "authenticated" }); }); it("truncates title to 255 chars", async () => { const longTitle = "A".repeat(300); - mockLlmPrompt.mockResolvedValue({ + prompt.mockResolvedValue({ content: `TITLE: ${longTitle}\nSUMMARY: A summary`, }); - const result = await generateTitleAndSummary("some content"); + const result = await makeService().generateTitleAndSummary("some content"); expect(result?.title).toHaveLength(255); expect(result?.summary).toBe("A summary"); }); - it("returns null when not authenticated", async () => { - mockFetchAuthState.mockResolvedValue({ status: "unauthenticated" }); - const result = await generateTitleAndSummary("some content"); - expect(result).toBeNull(); - expect(mockLlmPrompt).not.toHaveBeenCalled(); - }); - it("strips surrounding quotes from title", async () => { - mockLlmPrompt.mockResolvedValue({ + prompt.mockResolvedValue({ content: 'TITLE: "Fix login bug"\nSUMMARY: Fixing auth', }); - const result = await generateTitleAndSummary("fix the login bug"); + const result = + await makeService().generateTitleAndSummary("fix the login bug"); expect(result?.title).toBe("Fix login bug"); }); it("returns null on error", async () => { - mockLlmPrompt.mockRejectedValue(new Error("network error")); - const result = await generateTitleAndSummary("some content"); + prompt.mockRejectedValue(new Error("network error")); + const result = await makeService().generateTitleAndSummary("some content"); expect(result).toBeNull(); }); }); diff --git a/packages/core/src/sessions/titleGeneratorService.ts b/packages/core/src/sessions/titleGeneratorService.ts new file mode 100644 index 0000000000..dce5977bbc --- /dev/null +++ b/packages/core/src/sessions/titleGeneratorService.ts @@ -0,0 +1,157 @@ +import { LLM_GATEWAY_SERVICE } from "@posthog/core/llm-gateway/identifiers"; +import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway"; +import { xmlToContent } from "@posthog/core/message-editor/content"; +import { getFileName, isBinaryFile } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import { + type FileReadClient, + TITLE_GENERATOR_FILE_READ_CLIENT, + TITLE_GENERATOR_LOGGER, + type TitleGeneratorLogger, +} from "./titleGeneratorIdentifiers"; + +const ATTACHED_FILES_REGEX = /^\[?Attached files:.*]?$/gm; +const PASTED_TEXT_SNIPPET_LIMIT = 500; + +const SYSTEM_PROMPT = `You are a title and summary generator. Output using exactly this format: + +TITLE: +SUMMARY: <summary here> + +Convert the task description into a concise task title and a brief conversation summary. + +Title rules: +- The title should be clear, concise, and accurately reflect the content of the task. +- You should keep it short and simple, ideally no more than 6 words. +- Avoid using jargon or overly technical terms unless absolutely necessary. +- The title should be easy to understand for anyone reading it. +- Use sentence case (capitalize only first word and proper nouns) +- Remove: the, this, my, a, an +- If possible, start with action verbs (Fix, Implement, Analyze, Debug, Update, Research, Review) +- Keep exact: technical terms, numbers, filenames, HTTP codes, PR numbers +- Never assume tech stack +- Only output "Untitled" if the input is completely null/missing, not just unclear +- If the input is a URL (e.g. a GitHub issue link, PR link, or any web URL), generate a title based on what you can infer from the URL structure (repo name, issue/PR number, etc.). Never say you cannot access URLs or ask the user for more information. +- Never wrap the title in quotes + +Summary rules: +- 1-3 sentences describing what the user is working on and why +- Written from third-person perspective (e.g. "The user is fixing..." not "You are fixing...") +- Focus on the user's intent and goals, not the specific prompts +- Include relevant technical details (file names, features, bug descriptions) when mentioned +- This summary will be used as context for generating commit messages and PR descriptions + +Title examples: +- "Fix the login bug in the authentication system" → Fix authentication login bug +- "Schedule a meeting with stakeholders to discuss Q4 budget planning" → Schedule Q4 budget meeting +- "Update user documentation for new API endpoints" → Update API documentation +- "Research competitor pricing strategies for our product" → Research competitor pricing +- "Review pull request #123" → Review pull request #123 +- "debug 500 errors in production" → Debug production 500 errors +- "why is the payment flow failing" → Analyze payment flow failure +- "So how about that weather huh" → Weather chat +- "dsfkj sdkfj help me code" → Coding help request +- "👋😊" → Friendly greeting +- "aaaaaaaaaa" → Repeated letters +- " " → Empty message +- "What's the best restaurant in NYC?" → NYC restaurant recommendations +- "https://github.com/PostHog/posthog/issues/1234" → PostHog issue #1234 +- "https://github.com/PostHog/posthog/pull/567" → PostHog PR #567 +- "fix https://github.com/org/repo/issues/42" → Fix repo issue #42 + +Never include any explanation outside the TITLE and SUMMARY lines.`; + +export interface TitleAndSummary { + title: string; + summary: string; +} + +@injectable() +export class TitleGeneratorService { + constructor( + @inject(LLM_GATEWAY_SERVICE) + private readonly llmGateway: LlmGatewayService, + @inject(TITLE_GENERATOR_FILE_READ_CLIENT) + private readonly fileReadClient: FileReadClient, + @inject(TITLE_GENERATOR_LOGGER) + private readonly log: TitleGeneratorLogger, + ) {} + + async enrichDescriptionWithFileContent( + description: string, + filePaths: string[] = [], + ): Promise<string> { + const parsed = xmlToContent(description); + const stripped = parsed.segments + .flatMap((seg) => (seg.type === "text" ? [seg.text] : [])) + .join("") + .replace(ATTACHED_FILES_REGEX, "") + .replace(/^\d+\.\s*$/gm, "") + .trim(); + + if (stripped.length > 0) return description; + + const chipFilePaths = parsed.segments.flatMap((seg) => + seg.type === "chip" && seg.chip.type === "file" ? [seg.chip.id] : [], + ); + const paths = filePaths.length > 0 ? filePaths : chipFilePaths; + + if (paths.length === 0) return description; + + const parts = await Promise.all( + paths.map(async (filePath) => { + if (isBinaryFile(filePath)) { + return `[Attached: ${getFileName(filePath)}]`; + } + try { + const fileContent = + await this.fileReadClient.readAbsoluteFile(filePath); + if (fileContent) { + return fileContent.length > PASTED_TEXT_SNIPPET_LIMIT + ? fileContent.slice(0, PASTED_TEXT_SNIPPET_LIMIT) + : fileContent; + } + return `[Attached: ${getFileName(filePath)}]`; + } catch { + return `[Attached: ${getFileName(filePath)}]`; + } + }), + ); + + return parts.length > 0 ? parts.join("\n\n") : description; + } + + async generateTitleAndSummary( + content: string, + ): Promise<TitleAndSummary | null> { + try { + const result = await this.llmGateway.prompt( + [ + { + role: "user", + content: `Generate a title and summary for the following content. Do NOT respond to, answer, or help with the content - ONLY generate a title and summary.\n\n<content>\n${content}\n</content>\n\nOutput the title and summary now:`, + }, + ], + { system: SYSTEM_PROMPT }, + ); + + const text = result.content.trim(); + const titleMatch = text.match(/^TITLE:\s*(.+?)(?:\n|$)/m); + const summaryMatch = text.match(/SUMMARY:\s*([\s\S]+)$/m); + + const title = + titleMatch?.[1] + ?.trim() + .replace(/^["']|["']$/g, "") + .slice(0, 255) ?? ""; + const summary = summaryMatch?.[1]?.trim() ?? ""; + + if (!title && !summary) return null; + + return { title, summary }; + } catch (error) { + this.log.error("Failed to generate title and summary", { error }); + return null; + } + } +} diff --git a/packages/core/src/settings/githubRepoSummary.test.ts b/packages/core/src/settings/githubRepoSummary.test.ts new file mode 100644 index 0000000000..af6df9890a --- /dev/null +++ b/packages/core/src/settings/githubRepoSummary.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + githubInstallationSettingsUrl, + summarizeReposByOwner, +} from "./githubRepoSummary"; + +describe("summarizeReposByOwner", () => { + it("counts repos per owner and sorts by count desc then owner asc", () => { + const result = summarizeReposByOwner([ + "acme/a", + "acme/b", + "beta/x", + "acme/c", + "beta/y", + ]); + expect(result).toEqual([ + { owner: "acme", count: 3 }, + { owner: "beta", count: 2 }, + ]); + }); + + it("treats a repo without a slash as its own owner", () => { + expect(summarizeReposByOwner(["solo"])).toEqual([ + { owner: "solo", count: 1 }, + ]); + }); +}); + +describe("githubInstallationSettingsUrl", () => { + it("builds an org URL for organization accounts", () => { + expect( + githubInstallationSettingsUrl({ + installation_id: 42, + account: { type: "Organization", name: "acme" }, + }), + ).toBe("https://github.com/organizations/acme/settings/installations/42"); + }); + + it("builds a user URL otherwise", () => { + expect( + githubInstallationSettingsUrl({ + installation_id: 7, + account: { type: "User", name: "jane" }, + }), + ).toBe("https://github.com/settings/installations/7"); + }); +}); diff --git a/packages/core/src/settings/githubRepoSummary.ts b/packages/core/src/settings/githubRepoSummary.ts new file mode 100644 index 0000000000..7e18f13a7d --- /dev/null +++ b/packages/core/src/settings/githubRepoSummary.ts @@ -0,0 +1,38 @@ +export function summarizeReposByOwner( + repositories: readonly string[], +): { owner: string; count: number }[] { + const counts = new Map<string, number>(); + for (const repo of repositories) { + const owner = repo.includes("/") ? (repo.split("/", 1)[0] ?? repo) : repo; + counts.set(owner, (counts.get(owner) ?? 0) + 1); + } + return [...counts.entries()] + .map(([owner, count]) => ({ owner, count })) + .sort((a, b) => b.count - a.count || a.owner.localeCompare(b.owner)); +} + +export interface GithubInstallationAccount { + type?: string | null; + name?: string | null; +} + +export interface GithubInstallationLike { + installation_id: string | number; + account?: GithubInstallationAccount | null; +} + +export function githubInstallationSettingsUrl( + integration: GithubInstallationLike, +): string { + const accountType = integration.account?.type; + const accountName = integration.account?.name; + if ( + typeof accountType === "string" && + accountType.toLowerCase() === "organization" && + typeof accountName === "string" && + accountName + ) { + return `https://github.com/organizations/${accountName}/settings/installations/${integration.installation_id}`; + } + return `https://github.com/settings/installations/${integration.installation_id}`; +} diff --git a/packages/core/src/settings/posthogUrl.test.ts b/packages/core/src/settings/posthogUrl.test.ts new file mode 100644 index 0000000000..53dbfa07a0 --- /dev/null +++ b/packages/core/src/settings/posthogUrl.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { buildPostHogUrl } from "./posthogUrl"; + +describe("buildPostHogUrl", () => { + it("passes through absolute URLs", () => { + expect(buildPostHogUrl("https://x.com/y", "us")).toBe("https://x.com/y"); + }); + + it("returns null without a region", () => { + expect(buildPostHogUrl("/settings", null)).toBeNull(); + }); + + it("prefixes the region base and normalizes the leading slash", () => { + expect(buildPostHogUrl("settings/user", "us")).toBe( + buildPostHogUrl("/settings/user", "us"), + ); + }); +}); diff --git a/packages/core/src/settings/posthogUrl.ts b/packages/core/src/settings/posthogUrl.ts new file mode 100644 index 0000000000..93de7a26dc --- /dev/null +++ b/packages/core/src/settings/posthogUrl.ts @@ -0,0 +1,11 @@ +import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared"; + +export function buildPostHogUrl( + pathOrUrl: string, + region: CloudRegion | null, +): string | null { + if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl; + if (!region) return null; + const base = getCloudUrlFromRegion(region); + return `${base}${pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`}`; +} diff --git a/packages/core/src/settings/sandboxEnvironmentForm.test.ts b/packages/core/src/settings/sandboxEnvironmentForm.test.ts new file mode 100644 index 0000000000..c6429c7d4a --- /dev/null +++ b/packages/core/src/settings/sandboxEnvironmentForm.test.ts @@ -0,0 +1,115 @@ +import type { SandboxEnvironment } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + buildSandboxEnvironmentInput, + emptyForm, + formFromEnv, + isValidDomain, + validateDomains, + validateEnvVars, +} from "./sandboxEnvironmentForm"; + +describe("isValidDomain", () => { + it("accepts a bare domain", () => { + expect(isValidDomain("github.com")).toBe(true); + }); + + it("accepts a wildcard subdomain", () => { + expect(isValidDomain("*.example.com")).toBe(true); + }); + + it("rejects a URL with scheme", () => { + expect(isValidDomain("https://github.com")).toBe(false); + }); +}); + +describe("validateDomains", () => { + it("collects valid domains and skips blank lines", () => { + const result = validateDomains("github.com\n\n*.example.com\n"); + expect(result.domains).toEqual(["github.com", "*.example.com"]); + expect(result.errors).toEqual([]); + }); + + it("reports invalid domains", () => { + const result = validateDomains("github.com\nnot a domain"); + expect(result.domains).toEqual(["github.com"]); + expect(result.errors).toEqual(["Invalid domain: not a domain"]); + }); +}); + +describe("validateEnvVars", () => { + it("parses KEY=value lines and skips comments", () => { + const result = validateEnvVars("# comment\nFOO=bar\nBAZ=qux"); + expect(result.vars).toEqual({ FOO: "bar", BAZ: "qux" }); + expect(result.errors).toEqual([]); + }); + + it("reports a missing separator", () => { + const result = validateEnvVars("FOO"); + expect(result.errors).toEqual(["Line 1: missing '=' separator"]); + }); + + it("reports an invalid key", () => { + const result = validateEnvVars("1FOO=bar"); + expect(result.errors).toEqual(['Line 1: invalid key "1FOO"']); + }); +}); + +describe("emptyForm", () => { + it("defaults to full network access", () => { + expect(emptyForm().network_access_level).toBe("full"); + }); +}); + +describe("formFromEnv", () => { + it("joins allowed domains onto separate lines and clears env vars", () => { + const env = { + id: "env1", + name: "Internal", + network_access_level: "custom", + allowed_domains: ["a.com", "b.com"], + include_default_domains: false, + private: true, + } as unknown as SandboxEnvironment; + const form = formFromEnv(env); + expect(form.allowed_domains_text).toBe("a.com\nb.com"); + expect(form.environment_variables_text).toBe(""); + }); +}); + +describe("buildSandboxEnvironmentInput", () => { + it("includes domains and default flag only when custom", () => { + const form = { + ...emptyForm(), + name: "Custom", + network_access_level: "custom" as const, + include_default_domains: true, + }; + const input = buildSandboxEnvironmentInput(form, ["a.com"], {}); + expect(input.allowed_domains).toEqual(["a.com"]); + expect(input.include_default_domains).toBe(true); + }); + + it("drops domains and default flag when not custom", () => { + const form = { ...emptyForm(), name: "Full" }; + const input = buildSandboxEnvironmentInput(form, ["a.com"], {}); + expect(input.allowed_domains).toEqual([]); + expect(input.include_default_domains).toBe(false); + }); + + it("omits environment_variables when the text is blank", () => { + const form = { ...emptyForm(), name: "Full" }; + const input = buildSandboxEnvironmentInput(form, [], { FOO: "bar" }); + expect("environment_variables" in input).toBe(false); + }); + + it("includes environment_variables when the text is present", () => { + const form = { + ...emptyForm(), + name: "Full", + environment_variables_text: "FOO=bar", + }; + const input = buildSandboxEnvironmentInput(form, [], { FOO: "bar" }); + expect(input.environment_variables).toEqual({ FOO: "bar" }); + }); +}); diff --git a/packages/core/src/settings/sandboxEnvironmentForm.ts b/packages/core/src/settings/sandboxEnvironmentForm.ts new file mode 100644 index 0000000000..891e2a42c7 --- /dev/null +++ b/packages/core/src/settings/sandboxEnvironmentForm.ts @@ -0,0 +1,107 @@ +import type { + NetworkAccessLevel, + SandboxEnvironment, + SandboxEnvironmentInput, +} from "@posthog/shared/domain-types"; + +const DOMAIN_RE = + /^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; +const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export interface SandboxEnvironmentFormState { + name: string; + network_access_level: NetworkAccessLevel; + allowed_domains_text: string; + include_default_domains: boolean; + environment_variables_text: string; + private: boolean; +} + +export function isValidDomain(domain: string): boolean { + return DOMAIN_RE.test(domain); +} + +export function validateDomains(text: string): { + domains: string[]; + errors: string[]; +} { + const domains: string[] = []; + const errors: string[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (isValidDomain(trimmed)) { + domains.push(trimmed); + } else { + errors.push(`Invalid domain: ${trimmed}`); + } + } + return { domains, errors }; +} + +export function validateEnvVars(text: string): { + vars: Record<string, string>; + errors: string[]; +} { + const vars: Record<string, string> = {}; + const errors: string[] = []; + for (const [i, line] of text.split("\n").entries()) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx <= 0) { + errors.push(`Line ${i + 1}: missing '=' separator`); + continue; + } + const key = trimmed.slice(0, eqIdx).trim(); + if (!ENV_KEY_RE.test(key)) { + errors.push(`Line ${i + 1}: invalid key "${key}"`); + continue; + } + vars[key] = trimmed.slice(eqIdx + 1).trim(); + } + return { vars, errors }; +} + +export function emptyForm(): SandboxEnvironmentFormState { + return { + name: "", + network_access_level: "full", + allowed_domains_text: "", + include_default_domains: true, + environment_variables_text: "", + private: true, + }; +} + +export function formFromEnv( + env: SandboxEnvironment, +): SandboxEnvironmentFormState { + return { + name: env.name, + network_access_level: env.network_access_level, + allowed_domains_text: env.allowed_domains.join("\n"), + include_default_domains: env.include_default_domains, + environment_variables_text: "", + private: env.private, + }; +} + +export function buildSandboxEnvironmentInput( + form: SandboxEnvironmentFormState, + domains: string[], + envVars: Record<string, string>, +): SandboxEnvironmentInput { + const isCustom = form.network_access_level === "custom"; + return { + name: form.name, + network_access_level: form.network_access_level, + allowed_domains: isCustom ? domains : [], + include_default_domains: isCustom ? form.include_default_domains : false, + private: form.private, + repositories: [], + ...(form.environment_variables_text.trim() + ? { environment_variables: envVars } + : {}), + }; +} diff --git a/packages/core/src/settings/slackNotificationTarget.test.ts b/packages/core/src/settings/slackNotificationTarget.test.ts new file mode 100644 index 0000000000..ea7b12d301 --- /dev/null +++ b/packages/core/src/settings/slackNotificationTarget.test.ts @@ -0,0 +1,74 @@ +import type { SlackChannelOption } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + buildChannelTargetValue, + deriveEffectiveIntegrationId, + getSlackIntegrationLabel, + mergeVisibleChannels, + parseChannelIdFromTargetValue, + parseChannelNameFromTargetValue, +} from "./slackNotificationTarget"; + +describe("channel target value encode/decode", () => { + it("round-trips id and name", () => { + const target = buildChannelTargetValue("C123", "general"); + expect(target).toBe("C123|#general"); + expect(parseChannelIdFromTargetValue(target)).toBe("C123"); + expect(parseChannelNameFromTargetValue(target)).toBe("general"); + }); + + it("does not double-prefix the hash", () => { + expect(buildChannelTargetValue("C1", "#dev")).toBe("C1|#dev"); + }); + + it("returns null for empty values", () => { + expect(parseChannelIdFromTargetValue(null)).toBeNull(); + expect(parseChannelNameFromTargetValue(undefined)).toBeNull(); + }); +}); + +describe("getSlackIntegrationLabel", () => { + it("prefers display_name", () => { + expect(getSlackIntegrationLabel({ id: 1, display_name: "Acme" })).toBe( + "Acme", + ); + }); + + it("falls back to account name then id", () => { + expect( + getSlackIntegrationLabel({ id: 2, config: { account: { name: "Org" } } }), + ).toBe("Org"); + expect(getSlackIntegrationLabel({ id: 3 })).toBe("Slack workspace 3"); + }); +}); + +describe("deriveEffectiveIntegrationId", () => { + it("returns the selected id when set", () => { + expect(deriveEffectiveIntegrationId(5, [{ id: 1 }, { id: 2 }])).toBe(5); + }); + + it("defaults to the only integration when none selected", () => { + expect(deriveEffectiveIntegrationId(null, [{ id: 9 }])).toBe(9); + }); + + it("returns null when none selected and multiple exist", () => { + expect( + deriveEffectiveIntegrationId(null, [{ id: 1 }, { id: 2 }]), + ).toBeNull(); + }); +}); + +describe("mergeVisibleChannels", () => { + const channel = (id: string): SlackChannelOption => + ({ id, name: id }) as unknown as SlackChannelOption; + + it("injects the configured channel when missing", () => { + const merged = mergeVisibleChannels([channel("a")], "b", "beta"); + expect(merged.map((c) => c.id)).toEqual(["b", "a"]); + }); + + it("does not inject when already present", () => { + const merged = mergeVisibleChannels([channel("b")], "b", "beta"); + expect(merged.map((c) => c.id)).toEqual(["b"]); + }); +}); diff --git a/packages/core/src/settings/slackNotificationTarget.ts b/packages/core/src/settings/slackNotificationTarget.ts new file mode 100644 index 0000000000..6aec912615 --- /dev/null +++ b/packages/core/src/settings/slackNotificationTarget.ts @@ -0,0 +1,80 @@ +import type { SlackChannelOption } from "@posthog/shared/domain-types"; + +export interface SlackIntegrationLike { + id: number; + display_name?: string; + config?: { account?: { name?: string } }; +} + +export function buildChannelTargetValue( + channelId: string, + channelName: string, +): string { + const display = channelName.startsWith("#") ? channelName : `#${channelName}`; + return `${channelId}|${display}`; +} + +export function parseChannelIdFromTargetValue( + value: string | null | undefined, +): string | null { + if (!value) return null; + return value.split("|")[0]?.trim() || null; +} + +export function parseChannelNameFromTargetValue( + value: string | null | undefined, +): string | null { + if (!value) return null; + const display = value.split("|")[1]?.trim(); + if (!display) return null; + return display.startsWith("#") ? display.slice(1) : display; +} + +export function getSlackIntegrationLabel( + integration: SlackIntegrationLike, +): string { + return ( + integration.display_name ?? + integration.config?.account?.name ?? + `Slack workspace ${integration.id}` + ); +} + +export function configuredSlackChannelOption( + id: string, + name: string, +): SlackChannelOption { + return { + id, + name, + is_private: false, + is_member: true, + is_ext_shared: false, + is_private_without_access: false, + }; +} + +export function deriveEffectiveIntegrationId( + selectedId: number | null, + integrations: readonly SlackIntegrationLike[], +): number | null { + return selectedId ?? (integrations.length === 1 ? integrations[0].id : null); +} + +export function mergeVisibleChannels( + fetched: readonly SlackChannelOption[], + selectedChannelId: string | null, + selectedChannelName: string | null, +): SlackChannelOption[] { + const channels = [...fetched]; + if ( + selectedChannelId && + selectedChannelName && + !channels.some((channel) => channel.id === selectedChannelId) + ) { + channels.unshift( + configuredSlackChannelOption(selectedChannelId, selectedChannelName), + ); + } + return channels; +} diff --git a/packages/core/src/settings/updateStatus.test.ts b/packages/core/src/settings/updateStatus.test.ts new file mode 100644 index 0000000000..f6e7fbb2b0 --- /dev/null +++ b/packages/core/src/settings/updateStatus.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { deriveUpdateStatus } from "./updateStatus"; + +describe("deriveUpdateStatus", () => { + it("reports downloading", () => { + expect(deriveUpdateStatus({ checking: true, downloading: true })).toEqual({ + message: "Downloading update...", + type: "info", + checking: true, + }); + }); + + it("reports up to date", () => { + expect(deriveUpdateStatus({ checking: false, upToDate: true })).toEqual({ + message: "You're on the latest version", + type: "success", + checking: false, + }); + }); + + it("reports an update ready with a version", () => { + expect( + deriveUpdateStatus({ + checking: false, + updateReady: true, + version: "1.2.3", + }), + ).toEqual({ + message: "Update 1.2.3 ready to install", + type: "success", + checking: false, + }); + }); + + it("reports an update ready without a version", () => { + expect(deriveUpdateStatus({ checking: false, updateReady: true })).toEqual({ + message: "Update ready to install", + type: "success", + checking: false, + }); + }); + + it("clears checking when finished with no other signal", () => { + expect(deriveUpdateStatus({ checking: false })).toEqual({ + checking: false, + }); + }); + + it("returns empty while still checking", () => { + expect(deriveUpdateStatus({ checking: true })).toEqual({}); + }); +}); diff --git a/packages/core/src/settings/updateStatus.ts b/packages/core/src/settings/updateStatus.ts new file mode 100644 index 0000000000..e12ea356b3 --- /dev/null +++ b/packages/core/src/settings/updateStatus.ts @@ -0,0 +1,41 @@ +export interface RawUpdateStatus { + checking?: boolean; + downloading?: boolean; + upToDate?: boolean; + updateReady?: boolean; + version?: string; +} + +export interface DerivedUpdateStatus { + message?: string; + type?: "info" | "success" | "error"; + checking?: boolean; +} + +export function deriveUpdateStatus( + status: RawUpdateStatus, +): DerivedUpdateStatus { + if (status.checking && status.downloading) { + return { message: "Downloading update...", type: "info", checking: true }; + } + if (status.checking === false && status.upToDate) { + return { + message: "You're on the latest version", + type: "success", + checking: false, + }; + } + if (status.checking === false && status.updateReady) { + return { + message: status.version + ? `Update ${status.version} ready to install` + : "Update ready to install", + type: "success", + checking: false, + }; + } + if (status.checking === false) { + return { checking: false }; + } + return {}; +} diff --git a/packages/core/src/settings/worktreeGrouping.test.ts b/packages/core/src/settings/worktreeGrouping.test.ts new file mode 100644 index 0000000000..4742606858 --- /dev/null +++ b/packages/core/src/settings/worktreeGrouping.test.ts @@ -0,0 +1,53 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + buildTaskMap, + groupWorktrees, + parseWorktreeLimit, +} from "./worktreeGrouping"; + +const entry = (path: string) => ({ + worktreePath: path, + head: "abc", + branch: "main", + taskIds: [], +}); + +describe("groupWorktrees", () => { + it("skips folders with no worktrees and sorts by folder path", () => { + const groups = groupWorktrees( + [{ path: "/b" }, { path: "/a" }, { path: "/c" }], + [[entry("/b/wt")], undefined, [entry("/c/wt")]], + ); + expect(groups.map((g) => g.folderPath)).toEqual(["/b", "/c"]); + }); + + it("skips folders with an empty worktree list", () => { + const groups = groupWorktrees([{ path: "/a" }], [[]]); + expect(groups).toEqual([]); + }); +}); + +describe("buildTaskMap", () => { + it("indexes tasks by id", () => { + const tasks = [{ id: "t1" }, { id: "t2" }] as unknown as Task[]; + const map = buildTaskMap(tasks); + expect(map.get("t1")).toBe(tasks[0]); + expect(map.size).toBe(2); + }); + + it("returns an empty map when undefined", () => { + expect(buildTaskMap(undefined).size).toBe(0); + }); +}); + +describe("parseWorktreeLimit", () => { + it("returns the parsed value when >= 1", () => { + expect(parseWorktreeLimit("5")).toBe(5); + }); + + it("returns null for values below 1", () => { + expect(parseWorktreeLimit("0")).toBeNull(); + expect(parseWorktreeLimit("abc")).toBeNull(); + }); +}); diff --git a/packages/core/src/settings/worktreeGrouping.ts b/packages/core/src/settings/worktreeGrouping.ts new file mode 100644 index 0000000000..dd0a090b4f --- /dev/null +++ b/packages/core/src/settings/worktreeGrouping.ts @@ -0,0 +1,60 @@ +import type { Task } from "@posthog/shared/domain-types"; + +export interface WorktreeEntryData { + worktreePath: string; + head: string; + branch: string | null; + taskIds: string[]; +} + +export interface WorktreeGroupData { + folderPath: string; + worktrees: WorktreeEntryData[]; +} + +export interface FolderLike { + path: string; +} + +export function groupWorktrees( + folders: readonly FolderLike[], + worktreesByFolderIndex: readonly (readonly WorktreeEntryData[] | undefined)[], +): WorktreeGroupData[] { + const groups: WorktreeGroupData[] = []; + + for (let i = 0; i < folders.length; i++) { + const folder = folders[i]; + const worktrees = worktreesByFolderIndex[i]; + + if (!worktrees || worktrees.length === 0) continue; + + groups.push({ + folderPath: folder.path, + worktrees: worktrees.map((wt) => ({ + worktreePath: wt.worktreePath, + head: wt.head, + branch: wt.branch, + taskIds: wt.taskIds, + })), + }); + } + + return groups.sort((a, b) => a.folderPath.localeCompare(b.folderPath)); +} + +export function buildTaskMap( + tasks: readonly Task[] | undefined, +): Map<string, Task> { + const map = new Map<string, Task>(); + if (tasks) { + for (const task of tasks) { + map.set(task.id, task); + } + } + return map; +} + +export function parseWorktreeLimit(rawValue: string): number | null { + const value = Number.parseInt(rawValue, 10); + return value >= 1 ? value : null; +} diff --git a/packages/core/src/settings/worktreeMaintenanceService.test.ts b/packages/core/src/settings/worktreeMaintenanceService.test.ts new file mode 100644 index 0000000000..eb8bf299bc --- /dev/null +++ b/packages/core/src/settings/worktreeMaintenanceService.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { + deleteWorktree, + type WorktreeMaintenanceDeps, +} from "./worktreeMaintenanceService"; + +function makeDeps( + overrides: Partial<WorktreeMaintenanceDeps> = {}, +): WorktreeMaintenanceDeps { + return { + confirmDeleteWorktree: vi.fn().mockResolvedValue({ confirmed: true }), + deleteWorkspace: vi.fn().mockResolvedValue(undefined), + deleteWorktree: vi.fn().mockResolvedValue(undefined), + deleteTask: vi.fn().mockResolvedValue(undefined), + invalidate: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe("deleteWorktree", () => { + it("aborts when the user cancels confirmation", async () => { + const deps = makeDeps({ + confirmDeleteWorktree: vi.fn().mockResolvedValue({ confirmed: false }), + }); + const result = await deleteWorktree(deps, { + worktreePath: "/wt", + allTaskIds: ["t1"], + existingTaskIds: ["t1"], + folderPath: "/repo", + }); + expect(result.deleted).toBe(false); + expect(deps.deleteWorkspace).not.toHaveBeenCalled(); + }); + + it("does not confirm when there are no existing tasks", async () => { + const deps = makeDeps(); + await deleteWorktree(deps, { + worktreePath: "/wt", + allTaskIds: [], + existingTaskIds: [], + folderPath: "/repo", + }); + expect(deps.confirmDeleteWorktree).not.toHaveBeenCalled(); + expect(deps.deleteWorktree).toHaveBeenCalledWith({ + worktreePath: "/wt", + mainRepoPath: "/repo", + }); + }); + + it("deletes per-task workspaces when allTaskIds is non-empty", async () => { + const deps = makeDeps(); + await deleteWorktree(deps, { + worktreePath: "/wt", + allTaskIds: ["t1", "t2"], + existingTaskIds: ["t1"], + folderPath: "/repo", + }); + expect(deps.deleteWorkspace).toHaveBeenCalledTimes(2); + expect(deps.deleteWorktree).not.toHaveBeenCalled(); + expect(deps.deleteTask).toHaveBeenCalledWith("t1"); + expect(deps.invalidate).toHaveBeenCalledWith("/repo"); + }); +}); diff --git a/packages/core/src/settings/worktreeMaintenanceService.ts b/packages/core/src/settings/worktreeMaintenanceService.ts new file mode 100644 index 0000000000..46f44910a3 --- /dev/null +++ b/packages/core/src/settings/worktreeMaintenanceService.ts @@ -0,0 +1,54 @@ +export interface DeleteWorktreeParams { + worktreePath: string; + allTaskIds: string[]; + existingTaskIds: string[]; + folderPath: string; +} + +export interface WorktreeMaintenanceDeps { + confirmDeleteWorktree(params: { + worktreePath: string; + linkedTaskCount: number; + }): Promise<{ confirmed: boolean }>; + deleteWorkspace(params: { + taskId: string; + mainRepoPath: string; + }): Promise<unknown>; + deleteWorktree(params: { + worktreePath: string; + mainRepoPath: string; + }): Promise<unknown>; + deleteTask(taskId: string): Promise<unknown>; + invalidate(folderPath: string): Promise<void>; +} + +export async function deleteWorktree( + deps: WorktreeMaintenanceDeps, + params: DeleteWorktreeParams, +): Promise<{ deleted: boolean }> { + const { worktreePath, allTaskIds, existingTaskIds, folderPath } = params; + + if (existingTaskIds.length > 0) { + const { confirmed } = await deps.confirmDeleteWorktree({ + worktreePath, + linkedTaskCount: existingTaskIds.length, + }); + if (!confirmed) return { deleted: false }; + } + + if (allTaskIds.length > 0) { + for (const taskId of allTaskIds) { + await deps.deleteWorkspace({ taskId, mainRepoPath: folderPath }); + } + } else { + await deps.deleteWorktree({ worktreePath, mainRepoPath: folderPath }); + } + + for (const taskId of existingTaskIds) { + await deps.deleteTask(taskId); + } + + await deps.invalidate(folderPath); + + return { deleted: true }; +} diff --git a/apps/code/src/renderer/features/setup/utils/buildDiscoveredTaskPrompt.ts b/packages/core/src/setup/buildDiscoveredTaskPrompt.ts similarity index 87% rename from apps/code/src/renderer/features/setup/utils/buildDiscoveredTaskPrompt.ts rename to packages/core/src/setup/buildDiscoveredTaskPrompt.ts index 46db31c861..8f00cd592b 100644 --- a/apps/code/src/renderer/features/setup/utils/buildDiscoveredTaskPrompt.ts +++ b/packages/core/src/setup/buildDiscoveredTaskPrompt.ts @@ -1,9 +1,9 @@ -import type { DiscoveredTask } from "@features/setup/types"; -import { SKILL_BUTTONS } from "@features/skill-buttons/prompts"; +import type { DiscoveredTask } from "@posthog/core/setup/types"; +import { SKILL_BUTTON_CATALOG } from "@posthog/core/skill-buttons/catalog"; function buildExperimentTaskPrompt(task: DiscoveredTask): string { const sections: string[] = [ - SKILL_BUTTONS["run-experiment"].prompt, + SKILL_BUTTON_CATALOG["run-experiment"].prompt, "", "Use the analysis below as the starting point.", "", diff --git a/packages/core/src/setup/identifiers.ts b/packages/core/src/setup/identifiers.ts new file mode 100644 index 0000000000..924ce97203 --- /dev/null +++ b/packages/core/src/setup/identifiers.ts @@ -0,0 +1,109 @@ +import type { ActivityEntry } from "@posthog/core/setup/setupState"; +import type { StaleFlagPayload } from "@posthog/core/setup/suggestions"; +import type { DiscoveredTask } from "@posthog/core/setup/types"; + +export type DiscoverySignalSource = + | "structured_output" + | "terminal_status" + | "missing_output"; + +export type DiscoveryFailureReason = + | "timeout" + | "failed" + | "cancelled" + | "startup_error"; + +/** + * Host capabilities the setup discovery/enrichment orchestration needs. + * + * The desktop adapter wraps trpc (agent/enrichment), the authenticated PostHog + * API client (task runs), analytics, and build/env flags. The interface speaks + * product intent so the orchestration stays host-agnostic: no trpc, no Electron, + * no analytics taxonomy, no `import.meta.env` inside the package. + */ +export interface ISetupRunService { + /** Auth/project context for a discovery run. `authed` is false when no authenticated client is available. */ + getDiscoveryContext(): Promise<{ + apiHost: string | null; + projectId: number | null; + authed: boolean; + }>; + createDiscoveryTask(input: { + title: string; + description: string; + jsonSchema: Record<string, unknown>; + }): Promise<{ id: string }>; + createTaskRun(taskId: string): Promise<{ id: string | null }>; + getTaskRun( + taskId: string, + taskRunId: string, + ): Promise<{ status: string; tasks: DiscoveredTask[] | null }>; + isTerminalStatus(status: string): boolean; + + startAgent(input: { + taskId: string; + taskRunId: string; + repoPath: string; + apiHost: string; + projectId: number; + jsonSchema: Record<string, unknown>; + }): Promise<void>; + sendPrompt(input: { sessionId: string; promptText: string }): Promise<void>; + subscribeSessionEvents( + input: { taskRunId: string }, + handlers: { + onData: (payload: unknown) => void; + onError: (err: unknown) => void; + }, + ): { unsubscribe: () => void }; + + detectPosthogInstallState( + repoPath: string, + ): Promise<"initialized" | "not_installed" | "installed_no_init">; + findStaleFlagSuggestions(repoPath: string): Promise<StaleFlagPayload[]>; + + /** Whether experiment-tier suggestions are enabled (feature flag or dev build). */ + includeExperiments(): boolean; + + trackDiscoveryStarted(p: { taskId: string; taskRunId: string }): void; + trackDiscoveryCompleted(p: { + taskId: string; + taskRunId: string; + taskCount: number; + durationSeconds: number; + signalSource: DiscoverySignalSource; + }): void; + trackDiscoveryFailed(p: { + taskId?: string; + taskRunId?: string; + reason: DiscoveryFailureReason; + errorMessage?: string; + }): void; + reportError(error: Error, scope: string): void; +} + +export const SETUP_RUN_SERVICE = Symbol.for("posthog.core.setupRunService"); + +/** + * Host-supplied window onto the setup zustand store. Inverts the store + * coupling so the core orchestration writes UI state through a narrow + * interface instead of importing `@posthog/ui`. The apps composition binds + * this to a delegate over `useSetupStore.getState()`. + */ +export interface ISetupStore { + getDiscoveryStatus(repoPath: string): "idle" | "running" | "done" | "error"; + getEnricherStatus(repoPath: string): "idle" | "running" | "done" | "error"; + anyDiscoveryStarted(): boolean; + + startDiscovery(repoPath: string, taskId: string, taskRunId: string): void; + completeDiscovery(repoPath: string, tasks: DiscoveredTask[]): void; + failDiscovery(repoPath: string, message?: string): void; + pushDiscoveryActivity(repoPath: string, entry: ActivityEntry): void; + + startEnrichment(repoPath: string): void; + completeEnrichment(repoPath: string): void; + failEnrichment(repoPath: string): void; + addEnricherSuggestionIfMissing(task: DiscoveredTask): void; +} + +export const SETUP_STORE = Symbol.for("posthog.core.setupStore"); diff --git a/apps/code/src/renderer/features/setup/prompts.ts b/packages/core/src/setup/prompts.ts similarity index 98% rename from apps/code/src/renderer/features/setup/prompts.ts rename to packages/core/src/setup/prompts.ts index 8423887964..bfe4aad109 100644 --- a/apps/code/src/renderer/features/setup/prompts.ts +++ b/packages/core/src/setup/prompts.ts @@ -1,4 +1,4 @@ -import { BASE_CATEGORY_ENUM } from "./types"; +import { BASE_CATEGORY_ENUM } from "@posthog/core/setup/types"; export const WIZARD_PROMPT = `/instrument-integration diff --git a/packages/core/src/setup/sessionUpdate.ts b/packages/core/src/setup/sessionUpdate.ts new file mode 100644 index 0000000000..842592d79f --- /dev/null +++ b/packages/core/src/setup/sessionUpdate.ts @@ -0,0 +1,112 @@ +import type { ActivityEntry } from "@posthog/core/setup/setupState"; + +let activityIdCounter = 0; + +export function nextActivityId(): number { + activityIdCounter += 1; + return activityIdCounter; +} + +export function extractPathFromRawInput( + tool: string, + rawInput: Record<string, unknown> | undefined, +): string | null { + if (!rawInput) return null; + + switch (tool) { + case "Read": + case "Edit": + case "Write": + return (rawInput.file_path as string) ?? null; + case "Grep": + return (rawInput.pattern as string) + ? `"${rawInput.pattern}"${rawInput.path ? ` in ${rawInput.path}` : ""}` + : ((rawInput.path as string) ?? null); + case "Glob": + return (rawInput.pattern as string) ?? null; + case "Bash": { + const cmd = rawInput.command as string | undefined; + if (!cmd) return null; + return cmd.length > 80 ? `${cmd.slice(0, 77)}...` : cmd; + } + default: { + const filePath = + rawInput.file_path ?? rawInput.path ?? rawInput.notebook_path; + if (typeof filePath === "string") return filePath; + const pattern = rawInput.pattern; + if (typeof pattern === "string") return `"${pattern}"`; + const command = rawInput.command; + if (typeof command === "string") + return command.length > 80 ? `${command.slice(0, 77)}...` : command; + const url = rawInput.url; + if (typeof url === "string") return url; + const query = rawInput.query; + if (typeof query === "string") return query; + return null; + } + } +} + +export function extractToolCall( + update: Record<string, unknown>, +): ActivityEntry | null { + const sessionUpdate = update.sessionUpdate as string | undefined; + if (sessionUpdate !== "tool_call" && sessionUpdate !== "tool_call_update") + return null; + + const meta = update._meta as + | { claudeCode?: { toolName?: string } } + | undefined; + const tool = meta?.claudeCode?.toolName ?? "Working"; + const locations = update.locations as + | { path?: string; line?: number }[] + | undefined; + const rawInput = (update.rawInput ?? update.input) as + | Record<string, unknown> + | undefined; + const filePath = + locations?.[0]?.path ?? extractPathFromRawInput(tool, rawInput); + const title = (update.title as string) ?? ""; + const toolCallId = (update.toolCallId as string) ?? ""; + + return { id: nextActivityId(), toolCallId, tool, filePath, title }; +} + +export function extractAgentMessageText( + update: Record<string, unknown>, +): string | null { + if (update.sessionUpdate !== "agent_message_chunk") return null; + const content = update.content as + | { type?: string; text?: string } + | undefined; + if (content?.type !== "text" || !content.text) return null; + return content.text; +} + +export function handleSessionUpdate( + payload: unknown, + pushActivity: (entry: ActivityEntry) => void, + pushAssistantText?: (text: string) => void, +): void { + const acpMsg = payload as { message?: Record<string, unknown> }; + const inner = acpMsg.message; + if (!inner) return; + + if ("method" in inner && inner.method === "session/update") { + const params = inner.params as Record<string, unknown> | undefined; + if (!params) return; + + const update = (params.update as Record<string, unknown>) ?? params; + + const entry = extractToolCall(update); + if (entry) { + pushActivity(entry); + return; + } + + if (pushAssistantText) { + const text = extractAgentMessageText(update); + if (text) pushAssistantText(text); + } + } +} diff --git a/packages/core/src/setup/setup.module.ts b/packages/core/src/setup/setup.module.ts new file mode 100644 index 0000000000..1a441f15ac --- /dev/null +++ b/packages/core/src/setup/setup.module.ts @@ -0,0 +1,6 @@ +import { SetupRunService } from "@posthog/core/setup/setupRunService"; +import { ContainerModule } from "inversify"; + +export const setupCoreModule = new ContainerModule(({ bind }) => { + bind(SetupRunService).toSelf().inSingletonScope(); +}); diff --git a/packages/core/src/setup/setupRunService.test.ts b/packages/core/src/setup/setupRunService.test.ts new file mode 100644 index 0000000000..3a3d1ce6fb --- /dev/null +++ b/packages/core/src/setup/setupRunService.test.ts @@ -0,0 +1,206 @@ +import type { + ISetupRunService, + ISetupStore, +} from "@posthog/core/setup/identifiers"; +import { SetupRunService } from "@posthog/core/setup/setupRunService"; +import type { + ActivityEntry, + EnricherStatus, +} from "@posthog/core/setup/setupState"; +import type { DiscoveredTask } from "@posthog/core/setup/types"; +import type { WorkbenchLogger } from "@posthog/di/logger"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const REPO = "/repo/a"; + +function flush(): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +const noopLogger: WorkbenchLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + scope: () => noopLogger, +}; + +interface FakeStore extends ISetupStore { + discoveredTasks: DiscoveredTask[]; + enricherStatus: Map<string, EnricherStatus>; + discoveryStarted: boolean; +} + +function makeStore( + initialEnricher: Record<string, EnricherStatus> = {}, +): FakeStore { + const enricherStatus = new Map<string, EnricherStatus>( + Object.entries(initialEnricher), + ); + const discoveredTasks: DiscoveredTask[] = []; + return { + discoveredTasks, + enricherStatus, + discoveryStarted: false, + getDiscoveryStatus: () => "idle", + getEnricherStatus: (repoPath) => enricherStatus.get(repoPath) ?? "idle", + anyDiscoveryStarted() { + return this.discoveryStarted; + }, + startDiscovery() { + this.discoveryStarted = true; + }, + completeDiscovery() {}, + failDiscovery() {}, + pushDiscoveryActivity(_repoPath: string, _entry: ActivityEntry) {}, + startEnrichment(repoPath) { + enricherStatus.set(repoPath, "running"); + }, + completeEnrichment(repoPath) { + enricherStatus.set(repoPath, "done"); + }, + failEnrichment(repoPath) { + enricherStatus.set(repoPath, "error"); + }, + addEnricherSuggestionIfMissing(task) { + if ( + discoveredTasks.some( + (t) => t.id === task.id && t.repoPath === task.repoPath, + ) + ) { + return; + } + discoveredTasks.push({ ...task, source: "enricher" }); + }, + }; +} + +function makePort(overrides: Partial<ISetupRunService> = {}): ISetupRunService { + return { + getDiscoveryContext: vi.fn(async () => ({ + apiHost: null, + projectId: null, + authed: false, + })), + createDiscoveryTask: vi.fn(async () => ({ id: "task-1" })), + createTaskRun: vi.fn(async () => ({ id: "run-1" })), + getTaskRun: vi.fn(async () => ({ status: "in_progress", tasks: null })), + isTerminalStatus: vi.fn(() => false), + startAgent: vi.fn(async () => {}), + sendPrompt: vi.fn(async () => {}), + subscribeSessionEvents: vi.fn(() => ({ unsubscribe: () => {} })), + detectPosthogInstallState: vi.fn(async () => "not_installed" as const), + findStaleFlagSuggestions: vi.fn(async () => []), + includeExperiments: vi.fn(() => false), + trackDiscoveryStarted: vi.fn(), + trackDiscoveryCompleted: vi.fn(), + trackDiscoveryFailed: vi.fn(), + reportError: vi.fn(), + ...overrides, + }; +} + +let store: FakeStore; + +beforeEach(() => { + store = makeStore(); +}); + +describe("SetupRunService enricher", () => { + it("adds the sdk-health suggestion + stale flags when PostHog is initialized", async () => { + const port = makePort({ + detectPosthogInstallState: vi.fn(async () => "initialized" as const), + findStaleFlagSuggestions: vi.fn(async () => [ + { + flagKey: "old-flag", + referenceCount: 1, + references: [{ file: "a.ts", line: 1, method: "isFeatureEnabled" }], + }, + ]), + }); + const service = new SetupRunService(port, store, noopLogger); + + service.startEnricherForRepo(REPO); + await flush(); + + const ids = store.discoveredTasks.map((t) => t.id); + expect(ids).toContain("posthog-sdk-health"); + expect(ids).toContain("posthog-stale-flag-old-flag"); + expect(store.getEnricherStatus(REPO)).toBe("done"); + }); + + it("adds the posthog-setup suggestion when PostHog is not installed", async () => { + const port = makePort({ + detectPosthogInstallState: vi.fn(async () => "not_installed" as const), + }); + const service = new SetupRunService(port, store, noopLogger); + + service.startEnricherForRepo(REPO); + await flush(); + + const ids = store.discoveredTasks.map((t) => t.id); + expect(ids).toContain("posthog-setup"); + expect(port.findStaleFlagSuggestions).not.toHaveBeenCalled(); + expect(store.getEnricherStatus(REPO)).toBe("done"); + }); + + it("marks enrichment failed when install-state detection throws", async () => { + const port = makePort({ + detectPosthogInstallState: vi.fn(async () => { + throw new Error("boom"); + }), + }); + const service = new SetupRunService(port, store, noopLogger); + + service.startEnricherForRepo(REPO); + await flush(); + + expect(store.getEnricherStatus(REPO)).toBe("error"); + }); + + it("does not re-run enrichment once a repo is done", async () => { + store = makeStore({ [REPO]: "done" }); + const port = makePort(); + const service = new SetupRunService(port, store, noopLogger); + + service.startEnricherForRepo(REPO); + await flush(); + + expect(port.detectPosthogInstallState).not.toHaveBeenCalled(); + }); +}); + +describe("SetupRunService discovery gating", () => { + it("launches discovery at most once across repos", async () => { + const port = makePort(); + const service = new SetupRunService(port, store, noopLogger); + + service.startDiscovery(REPO); + service.startDiscovery("/repo/b"); + await flush(); + + expect(port.getDiscoveryContext).toHaveBeenCalledTimes(1); + }); + + it("fails fast with missing_auth when no apiHost/projectId", async () => { + const port = makePort({ + getDiscoveryContext: vi.fn(async () => ({ + apiHost: null, + projectId: null, + authed: false, + })), + }); + const service = new SetupRunService(port, store, noopLogger); + + service.startDiscovery(REPO); + await flush(); + + expect(port.trackDiscoveryFailed).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "startup_error", + errorMessage: "missing_auth", + }), + ); + expect(port.startAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/setup/setupRunService.ts b/packages/core/src/setup/setupRunService.ts new file mode 100644 index 0000000000..8a1ced1e8f --- /dev/null +++ b/packages/core/src/setup/setupRunService.ts @@ -0,0 +1,443 @@ +import { + type ISetupRunService, + type ISetupStore, + SETUP_RUN_SERVICE, + SETUP_STORE, +} from "@posthog/core/setup/identifiers"; +import { buildDiscoveryPrompt } from "@posthog/core/setup/prompts"; +import { + handleSessionUpdate, + nextActivityId, +} from "@posthog/core/setup/sessionUpdate"; +import { + buildPosthogSetupSuggestion, + buildSdkHealthSuggestion, + buildStaleFlagSuggestion, +} from "@posthog/core/setup/suggestions"; +import { + buildTaskDiscoverySchema, + type DiscoveredTask, +} from "@posthog/core/setup/types"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; + +function sleep(ms: number, signal?: AbortSignal): Promise<void> { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +@injectable() +export class SetupRunService { + private anyDiscoveryEverLaunched = false; + private discoveryStartingByRepo = new Set<string>(); + private enricherSuggestionsRunningByRepo = new Set<string>(); + + constructor( + @inject(SETUP_RUN_SERVICE) + private readonly port: ISetupRunService, + @inject(SETUP_STORE) + private readonly store: ISetupStore, + @inject(WORKBENCH_LOGGER) + private readonly logger: WorkbenchLogger, + ) {} + + // Discovery is a one-time-per-user agent run; once any repo has triggered + // it we never auto-launch another one. Errored/interrupted runs require + // explicit user retry (see setupState partialize and #2257). Enricher runs + // per repo on every selection (gated on per-repo status inside the service). + maybeStart(directory: string): void { + if (!directory) return; + if (this.store.anyDiscoveryStarted()) { + this.startEnricherForRepo(directory); + } else { + this.startSetup(directory); + } + } + + startSetup(directory: string): void { + const status = this.store.getDiscoveryStatus(directory); + if (status !== "idle") return; + this.injectEnricherSuggestions(directory); + this.startDiscovery(directory); + } + + startEnricherForRepo(directory: string): void { + this.injectEnricherSuggestions(directory); + } + + startDiscovery(directory: string): void { + if (!directory) return; + if (this.anyDiscoveryEverLaunched) return; + if (this.discoveryStartingByRepo.has(directory)) return; + const status = this.store.getDiscoveryStatus(directory); + if (status === "running" || status === "done") return; + this.anyDiscoveryEverLaunched = true; + this.discoveryStartingByRepo.add(directory); + this.runDiscovery(directory) + .catch((err) => { + this.logger.error("Discovery startup failed", { error: err }); + }) + .finally(() => { + this.discoveryStartingByRepo.delete(directory); + }); + } + + injectEnricherSuggestions(directory: string): void { + if (!directory) return; + if (this.enricherSuggestionsRunningByRepo.has(directory)) return; + const enricherStatus = this.store.getEnricherStatus(directory); + if (enricherStatus === "done" || enricherStatus === "running") return; + this.enricherSuggestionsRunningByRepo.add(directory); + this.store.startEnrichment(directory); + this.runEnricher(directory).catch((err) => { + this.logger.warn("Enricher run failed", { error: err }); + }); + } + + private async runEnricher(directory: string): Promise<void> { + try { + const installState = await this.port.detectPosthogInstallState(directory); + + if (installState === "initialized") { + this.store.addEnricherSuggestionIfMissing({ + ...buildSdkHealthSuggestion(), + repoPath: directory, + }); + await this.injectStaleFlagSuggestions(directory); + } else { + const suggestion = buildPosthogSetupSuggestion(installState); + this.store.addEnricherSuggestionIfMissing({ + ...suggestion, + repoPath: directory, + }); + } + this.store.completeEnrichment(directory); + } catch (err) { + this.logger.warn("Enricher run failed", { error: err }); + this.store.failEnrichment(directory); + } finally { + this.enricherSuggestionsRunningByRepo.delete(directory); + } + } + + private async injectStaleFlagSuggestions(directory: string): Promise<void> { + try { + const flags = await this.port.findStaleFlagSuggestions(directory); + for (const flag of flags) { + this.store.addEnricherSuggestionIfMissing({ + ...buildStaleFlagSuggestion(flag), + repoPath: directory, + }); + } + } catch (err) { + this.logger.warn("Failed to find stale flag suggestions", { error: err }); + } + } + + private async runDiscovery(directory: string): Promise<void> { + const abort = new AbortController(); + const discoveryStartedAt = Date.now(); + + try { + const { apiHost, projectId, authed } = + await this.port.getDiscoveryContext(); + if (abort.signal.aborted) return; + + if (!apiHost || !projectId) { + this.logger.error("Missing auth for discovery", { apiHost, projectId }); + this.store.failDiscovery(directory, "Authentication required."); + this.port.trackDiscoveryFailed({ + reason: "startup_error", + errorMessage: "missing_auth", + }); + return; + } + + if (!authed) { + this.store.failDiscovery(directory, "Authentication required."); + this.port.trackDiscoveryFailed({ + reason: "startup_error", + errorMessage: "unauthenticated_client", + }); + return; + } + + if (!directory) { + this.store.failDiscovery(directory, "No directory selected."); + this.port.trackDiscoveryFailed({ + reason: "startup_error", + errorMessage: "missing_directory", + }); + return; + } + + const includeExperiments = this.port.includeExperiments(); + const discoveryPrompt = buildDiscoveryPrompt({ includeExperiments }); + const discoverySchema = buildTaskDiscoverySchema({ includeExperiments }); + + const task = await this.port.createDiscoveryTask({ + title: "Discover first tasks", + description: discoveryPrompt, + jsonSchema: discoverySchema, + }); + if (abort.signal.aborted) return; + + const taskRun = await this.port.createTaskRun(task.id); + if (abort.signal.aborted) return; + if (!taskRun?.id) { + throw new Error("Failed to create discovery task run"); + } + const taskRunId = taskRun.id; + + this.store.startDiscovery(directory, task.id, taskRunId); + this.port.trackDiscoveryStarted({ + taskId: task.id, + taskRunId, + }); + + await this.port.startAgent({ + taskId: task.id, + taskRunId, + repoPath: directory, + apiHost, + projectId, + jsonSchema: discoverySchema, + }); + if (abort.signal.aborted) return; + + this.port + .sendPrompt({ sessionId: taskRunId, promptText: discoveryPrompt }) + .catch((err) => { + this.logger.error("Failed to send discovery prompt", { error: err }); + }); + + let completed = false; + let subscription: { unsubscribe: () => void } | null = null; + + type CompletionSource = + | "structured_output" + | "terminal_status" + | "missing_output"; + + const finishSuccess = ( + tasks: DiscoveredTask[], + signalSource: CompletionSource, + ) => { + if (completed || abort.signal.aborted) return; + completed = true; + subscription?.unsubscribe(); + + const durationSeconds = Math.round( + (Date.now() - discoveryStartedAt) / 1000, + ); + + this.logger.info("Discovery completed", { + taskCount: tasks.length, + signalSource, + }); + this.store.completeDiscovery(directory, tasks); + this.port.trackDiscoveryCompleted({ + taskId: task.id, + taskRunId, + taskCount: tasks.length, + durationSeconds, + signalSource, + }); + }; + + const finishFailure = ( + reason: "failed" | "cancelled" | "timeout", + message: string, + ) => { + if (completed || abort.signal.aborted) return; + completed = true; + subscription?.unsubscribe(); + + this.logger.error("Discovery failed", { reason }); + this.store.failDiscovery(directory, message); + this.port.trackDiscoveryFailed({ + taskId: task.id, + taskRunId, + reason, + }); + }; + + let signalRetryStarted = false; + const handleStructuredOutputSignal = async () => { + if (signalRetryStarted) return; + signalRetryStarted = true; + const startedAt = Date.now(); + const TIMEOUT_MS = 8000; + const MAX_DELAY_MS = 4000; + let delay = 500; + while (Date.now() - startedAt < TIMEOUT_MS) { + try { + await sleep(delay, abort.signal); + } catch { + return; + } + if (completed) return; + try { + const run = await this.port.getTaskRun(task.id, taskRunId); + if (completed || abort.signal.aborted) return; + if (run.tasks) { + finishSuccess(run.tasks, "structured_output"); + return; + } + } catch (err) { + this.logger.warn( + "Failed to fetch run after StructuredOutput signal", + { + error: err, + }, + ); + } + delay = Math.min(delay * 2, MAX_DELAY_MS); + } + }; + + let structuredOutputSeen = false; + let wrapupBuffer = ""; + const WRAPUP_TOOL_CALL_ID = "discovery-wrapup"; + const pushWrapupActivity = (text: string) => { + if (!structuredOutputSeen) return; + wrapupBuffer = (wrapupBuffer + text).slice(-200); + this.store.pushDiscoveryActivity(directory, { + id: nextActivityId(), + toolCallId: WRAPUP_TOOL_CALL_ID, + tool: "WrappingUp", + filePath: null, + title: wrapupBuffer.trim(), + }); + }; + + subscription = this.port.subscribeSessionEvents( + { taskRunId }, + { + onData: (payload: unknown) => { + handleSessionUpdate( + payload, + (entry) => { + this.store.pushDiscoveryActivity(directory, entry); + if (entry.tool === "StructuredOutput") { + structuredOutputSeen = true; + handleStructuredOutputSignal().catch((err) => + this.logger.warn("StructuredOutput handler failed", { + error: err, + }), + ); + } + }, + pushWrapupActivity, + ); + }, + onError: (err) => { + this.logger.error("Discovery subscription error", { error: err }); + }, + }, + ); + const subscriptionAtAbort = subscription; + abort.signal.addEventListener( + "abort", + () => { + subscriptionAtAbort.unsubscribe(); + }, + { once: true }, + ); + + const pollForCompletion = async () => { + const maxAttempts = 120; + const intervalMs = 5000; + + for (let i = 0; i < maxAttempts; i++) { + try { + await sleep(intervalMs, abort.signal); + } catch { + return; + } + if (completed) return; + + try { + const run = await this.port.getTaskRun(task.id, taskRunId); + if (completed || abort.signal.aborted) return; + + if (this.port.isTerminalStatus(run.status)) { + if (run.status === "completed" && run.tasks) { + finishSuccess(run.tasks, "terminal_status"); + } else if ( + run.status === "failed" || + run.status === "cancelled" + ) { + finishFailure( + run.status, + "Discovery failed. You can skip or retry.", + ); + } else { + finishSuccess([], "missing_output"); + } + return; + } + + if (run.tasks) { + finishSuccess(run.tasks, "missing_output"); + return; + } + } catch (err) { + this.logger.warn("Failed to poll discovery", { + attempt: i + 1, + error: err, + }); + } + } + + finishFailure("timeout", "Discovery timed out. You can skip or retry."); + }; + + pollForCompletion().catch((err) => { + if (abort.signal.aborted) return; + this.logger.error("Discovery poll failed", { error: err }); + if (!completed) { + completed = true; + subscription?.unsubscribe(); + this.store.failDiscovery(directory, "Discovery failed unexpectedly."); + this.port.trackDiscoveryFailed({ + taskId: task.id, + taskRunId, + reason: "failed", + errorMessage: + err instanceof Error ? err.message : "discovery_poll_error", + }); + if (err instanceof Error) { + this.port.reportError(err, "setup.discovery_poll"); + } + } + }); + } catch (err) { + if (abort.signal.aborted) return; + this.logger.error("Failed to start discovery", { error: err }); + const message = + err instanceof Error ? err.message : "Failed to start discovery."; + this.store.failDiscovery(directory, message); + this.port.trackDiscoveryFailed({ + reason: "startup_error", + errorMessage: message, + }); + if (err instanceof Error) { + this.port.reportError(err, "setup.start_discovery"); + } + } + } +} diff --git a/packages/core/src/setup/setupState.ts b/packages/core/src/setup/setupState.ts new file mode 100644 index 0000000000..b0d0a8a59c --- /dev/null +++ b/packages/core/src/setup/setupState.ts @@ -0,0 +1,207 @@ +import type { DiscoveredTask } from "@posthog/core/setup/types"; + +export type DiscoveryStatus = "idle" | "running" | "done" | "error"; +export type EnricherStatus = "idle" | "running" | "done" | "error"; + +export interface ActivityEntry { + id: number; + toolCallId: string; + tool: string; + filePath: string | null; + title: string; +} + +export interface AgentFeedState { + currentTool: string | null; + currentFilePath: string | null; + recentEntries: ActivityEntry[]; +} + +export interface RepoDiscoveryState { + status: DiscoveryStatus; + taskId: string | null; + taskRunId: string | null; + feed: AgentFeedState; + error: string | null; +} + +export interface RepoEnricherState { + status: EnricherStatus; +} + +export interface SetupStoreState { + discoveredTasks: DiscoveredTask[]; + discoveryByRepo: Record<string, RepoDiscoveryState>; + enricherByRepo: Record<string, RepoEnricherState>; +} + +export const EMPTY_FEED: AgentFeedState = { + currentTool: null, + currentFilePath: null, + recentEntries: [], +}; + +export const DEFAULT_DISCOVERY: RepoDiscoveryState = { + status: "idle", + taskId: null, + taskRunId: null, + feed: EMPTY_FEED, + error: null, +}; + +export const DEFAULT_ENRICHER: RepoEnricherState = { status: "idle" }; + +export const INITIAL_SETUP_STATE: SetupStoreState = { + discoveredTasks: [], + discoveryByRepo: {}, + enricherByRepo: {}, +}; + +export function selectRepoDiscovery( + state: SetupStoreState, + repoPath: string | null, +): RepoDiscoveryState { + if (!repoPath) return DEFAULT_DISCOVERY; + return state.discoveryByRepo[repoPath] ?? DEFAULT_DISCOVERY; +} + +export function selectRepoEnricher( + state: SetupStoreState, + repoPath: string | null, +): RepoEnricherState { + if (!repoPath) return DEFAULT_ENRICHER; + return state.enricherByRepo[repoPath] ?? DEFAULT_ENRICHER; +} + +export function isTaskForRepo( + task: DiscoveredTask, + repoPath: string | null, +): boolean { + if (!repoPath) return !task.repoPath; + return task.repoPath === repoPath; +} + +export function dropAgentTasksForRepo( + tasks: DiscoveredTask[], + repoPath: string, +): DiscoveredTask[] { + return tasks.filter( + (t) => !(t.source === "agent" && isTaskForRepo(t, repoPath)), + ); +} + +export function pushEntry( + prev: AgentFeedState, + entry: ActivityEntry, +): AgentFeedState { + const existingIdx = entry.toolCallId + ? prev.recentEntries.findIndex((e) => e.toolCallId === entry.toolCallId) + : -1; + + let newEntries: ActivityEntry[]; + if (existingIdx >= 0) { + newEntries = [...prev.recentEntries]; + const old = newEntries[existingIdx]; + newEntries[existingIdx] = { + ...old, + tool: entry.tool || old.tool, + filePath: entry.filePath || old.filePath, + title: entry.title || old.title, + }; + } else { + newEntries = [...prev.recentEntries.slice(-4), entry]; + } + + return { + currentTool: entry.tool, + currentFilePath: entry.filePath ?? prev.currentFilePath, + recentEntries: newEntries, + }; +} + +export function updateDiscovery( + state: SetupStoreState, + repoPath: string, + patch: Partial<RepoDiscoveryState>, +): Record<string, RepoDiscoveryState> { + const prev = state.discoveryByRepo[repoPath] ?? DEFAULT_DISCOVERY; + return { ...state.discoveryByRepo, [repoPath]: { ...prev, ...patch } }; +} + +export function updateEnricher( + state: SetupStoreState, + repoPath: string, + patch: Partial<RepoEnricherState>, +): Record<string, RepoEnricherState> { + const prev = state.enricherByRepo[repoPath] ?? DEFAULT_ENRICHER; + return { ...state.enricherByRepo, [repoPath]: { ...prev, ...patch } }; +} + +export function migrateSetupState( + persistedState: unknown, + version: number, +): SetupStoreState { + if (version < 2) { + const oldState = (persistedState ?? {}) as { + discoveryStatus?: string; + error?: unknown; + }; + let sentinel: Record<string, RepoDiscoveryState> = {}; + if (oldState.discoveryStatus === "done") { + sentinel = { + __migrated_v1__: { ...DEFAULT_DISCOVERY, status: "done" }, + }; + } else if ( + oldState.discoveryStatus === "error" || + oldState.discoveryStatus === "running" + ) { + sentinel = { + __migrated_v1__: { + ...DEFAULT_DISCOVERY, + status: "error", + error: + typeof oldState.error === "string" + ? oldState.error + : "Discovery was interrupted. You can skip or retry.", + }, + }; + } + return { + discoveredTasks: [], + discoveryByRepo: sentinel, + enricherByRepo: {}, + }; + } + return persistedState as SetupStoreState; +} + +export function partializeSetupState(state: SetupStoreState): SetupStoreState { + return { + discoveredTasks: state.discoveredTasks, + discoveryByRepo: Object.fromEntries( + Object.entries(state.discoveryByRepo) + .filter(([, d]) => d.status !== "idle") + .map(([repo, d]) => { + if (d.status === "running") { + return [ + repo, + { + ...DEFAULT_DISCOVERY, + status: "error", + error: "Discovery was interrupted. You can skip or retry.", + }, + ]; + } + return [ + repo, + { ...DEFAULT_DISCOVERY, status: d.status, error: d.error }, + ]; + }), + ), + enricherByRepo: Object.fromEntries( + Object.entries(state.enricherByRepo).filter( + ([, e]) => e.status === "done", + ), + ), + }; +} diff --git a/packages/core/src/setup/suggestions.test.ts b/packages/core/src/setup/suggestions.test.ts new file mode 100644 index 0000000000..610f19ff0e --- /dev/null +++ b/packages/core/src/setup/suggestions.test.ts @@ -0,0 +1,78 @@ +import { + buildPosthogSetupSuggestion, + buildSdkHealthSuggestion, + buildStaleFlagSuggestion, + type StaleFlagPayload, +} from "@posthog/core/setup/suggestions"; +import { describe, expect, it } from "vitest"; + +describe("buildStaleFlagSuggestion", () => { + const flag: StaleFlagPayload = { + flagKey: "old-checkout", + referenceCount: 3, + references: [ + { file: "src/a.ts", line: 10, method: "isFeatureEnabled" }, + { file: "src/b.ts", line: 22, method: "useFeatureFlag" }, + ], + }; + + it("derives a stable id from the flag key so dismissal sticks", () => { + expect(buildStaleFlagSuggestion(flag).id).toBe( + "posthog-stale-flag-old-checkout", + ); + }); + + it("anchors file/lineHint to the first reference", () => { + const task = buildStaleFlagSuggestion(flag); + expect(task.file).toBe("src/a.ts"); + expect(task.lineHint).toBe(10); + }); + + it("lists references and a '…and N more' tail when truncated", () => { + const recommendation = buildStaleFlagSuggestion(flag).recommendation ?? ""; + expect(recommendation).toContain("- src/a.ts:10 (isFeatureEnabled)"); + expect(recommendation).toContain("- src/b.ts:22 (useFeatureFlag)"); + // referenceCount 3 with 2 shown → 1 more + expect(recommendation).toContain("…and 1 more."); + }); + + it("omits the truncation tail when all references are shown", () => { + const task = buildStaleFlagSuggestion({ ...flag, referenceCount: 2 }); + expect(task.recommendation).not.toContain("more."); + }); + + it("singularizes the reference count in the description", () => { + const task = buildStaleFlagSuggestion({ + flagKey: "f", + referenceCount: 1, + references: [{ file: "x.ts", line: 1, method: "m" }], + }); + expect(task.description).toContain("referenced in 1 place "); + }); +}); + +describe("buildSdkHealthSuggestion", () => { + it("is a stable enricher posthog_setup suggestion", () => { + const task = buildSdkHealthSuggestion(); + expect(task).toMatchObject({ + id: "posthog-sdk-health", + source: "enricher", + category: "posthog_setup", + prompt: "/diagnosing-sdk-health", + }); + }); +}); + +describe("buildPosthogSetupSuggestion", () => { + it("returns the install suggestion when not installed", () => { + const task = buildPosthogSetupSuggestion("not_installed"); + expect(task.id).toBe("posthog-setup"); + expect(task.prompt).toBe("/instrument-integration"); + }); + + it("returns the finish-init suggestion when installed but not initialized", () => { + const task = buildPosthogSetupSuggestion("installed_no_init"); + expect(task.id).toBe("posthog-finish-init"); + expect(task.prompt).toContain("skip install steps"); + }); +}); diff --git a/packages/core/src/setup/suggestions.ts b/packages/core/src/setup/suggestions.ts new file mode 100644 index 0000000000..fa406195c0 --- /dev/null +++ b/packages/core/src/setup/suggestions.ts @@ -0,0 +1,83 @@ +import type { DiscoveredTask } from "@posthog/core/setup/types"; + +export interface StaleFlagPayload { + flagKey: string; + references: { file: string; line: number; method: string }[]; + referenceCount: number; +} + +export function buildStaleFlagSuggestion( + flag: StaleFlagPayload, +): DiscoveredTask { + const refs = flag.references; + const first = refs[0]; + const moreCount = Math.max(0, flag.referenceCount - refs.length); + const referencesBlock = refs + .map((r) => `- ${r.file}:${r.line} (${r.method})`) + .join("\n"); + const recommendation = `Remove the flag check and inline the winning branch. Code references:\n${referencesBlock}${moreCount > 0 ? `\n…and ${moreCount} more.` : ""}`; + return { + // Stable id keyed off the flag key so dismissal sticks across re-runs. + id: `posthog-stale-flag-${flag.flagKey}`, + source: "enricher", + category: "stale_feature_flag", + title: `Clean up stale flag "${flag.flagKey}"`, + description: `\`${flag.flagKey}\` hasn't been evaluated in 30+ days but is still referenced in ${flag.referenceCount} place${flag.referenceCount === 1 ? "" : "s"} in this codebase.`, + impact: + "Stale flags accumulate dead code paths and conditional branches that nobody is exercising any more — they make refactors riskier and obscure what's actually live in production.", + recommendation, + file: first?.file, + lineHint: first?.line, + prompt: `/cleaning-up-stale-feature-flags Clean up stale flag "${flag.flagKey}"\n\n${recommendation}`, + }; +} + +export function buildSdkHealthSuggestion(): DiscoveredTask { + return { + id: "posthog-sdk-health", + source: "enricher", + category: "posthog_setup", + title: "Check PostHog SDK health", + description: + "Run a quick health check on the PostHog SDKs installed in this repo: confirm they're on supported versions, flag anything outdated or deprecated, and bump the safely-upgradable ones.", + impact: + "Outdated SDKs miss bug fixes, security patches, and new features (newer event types, recording APIs, flag evaluation behavior). Catching version drift early avoids surprise breakage when you eventually upgrade.", + recommendation: + 'Click "Implement as new task" — the agent uses the bundled diagnosing-sdk-health skill to inspect each PostHog SDK\'s version, compare it against the latest, and open a PR with safe bumps. Breaking-change upgrades are flagged for your review rather than applied automatically.', + prompt: "/diagnosing-sdk-health", + }; +} + +export function buildPosthogSetupSuggestion( + state: "not_installed" | "installed_no_init", +): DiscoveredTask { + if (state === "not_installed") { + return { + id: "posthog-setup", + source: "enricher", + category: "posthog_setup", + title: "Set up PostHog", + description: + "PostHog isn't installed in this repo yet. Run this task to detect your framework, install the SDK, instrument analytics + error tracking + replay, and open a PR with the changes.", + impact: + "Without PostHog wired in, you have no visibility into how users interact with the product, no error or session-replay coverage, and no way to gate releases behind feature flags.", + recommendation: + 'Click "Implement as new task" — the agent runs the bundled instrument-integration skill, sets up env vars, installs the SDK with your project\'s package manager, and opens a PR.', + prompt: "/instrument-integration", + }; + } + return { + id: "posthog-finish-init", + source: "enricher", + category: "posthog_setup", + title: "Finish wiring PostHog", + description: + "The PostHog SDK is declared in this repo but `posthog.init(...)` (or the framework-equivalent provider) isn't called. Events won't be captured until that's wired up.", + impact: + "Until init runs, all PostHog calls are no-ops — you'll see no events in the project, no error reports, and no session replays despite the SDK being installed.", + recommendation: + 'Click "Implement as new task" — the agent adds the init call and provider component for your framework, sets up the public-token + host env vars, and opens a PR. The SDK package itself is left alone.', + prompt: + "/instrument-integration\n\nThe SDK is already declared in this repo — skip install steps and focus on adding the init call, provider, and env vars.", + }; +} diff --git a/apps/code/src/renderer/features/setup/types.ts b/packages/core/src/setup/types.ts similarity index 100% rename from apps/code/src/renderer/features/setup/types.ts rename to packages/core/src/setup/types.ts diff --git a/packages/core/src/sidebar/buildSidebarData.ts b/packages/core/src/sidebar/buildSidebarData.ts new file mode 100644 index 0000000000..7c61c08a1e --- /dev/null +++ b/packages/core/src/sidebar/buildSidebarData.ts @@ -0,0 +1,213 @@ +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import { getRepositoryInfo } from "./groupTasks"; +import type { TaskData } from "./sidebarData.types"; + +export type SortMode = "updated" | "created"; +export type OrganizeMode = "by-project" | "chronological"; + +export interface FullTask { + id: string; + title: string; + repository?: string | null; + created_at: string; + updated_at: string; + origin_product?: string; + latest_run?: { + status?: TaskRunStatus | null; + environment?: "local" | "cloud" | null; + output?: { pr_url?: unknown } | null; + state?: Record<string, unknown> | null; + } | null; +} + +export interface SidebarTask { + id: string; + title: string; + repository?: string | null; + created_at: string; + updated_at: string; + origin_product?: string; + slack_thread_url?: string; + latest_run?: { + status?: TaskRunStatus | null; + environment?: "local" | "cloud" | null; + output?: { pr_url?: unknown } | null; + } | null; +} + +export function narrowFullTask(task: FullTask): SidebarTask { + const slackThreadUrl = task.latest_run?.state?.slack_thread_url; + return { + id: task.id, + title: task.title, + repository: task.repository ?? null, + created_at: task.created_at, + updated_at: task.updated_at, + latest_run: task.latest_run + ? { + status: task.latest_run.status, + environment: task.latest_run.environment ?? null, + output: task.latest_run.output ?? null, + } + : null, + origin_product: task.origin_product, + slack_thread_url: + typeof slackThreadUrl === "string" ? slackThreadUrl : undefined, + }; +} + +export interface FilterVisibleOptions { + archivedIds: ReadonlySet<string>; + workspaceIds: ReadonlySet<string>; + provisioningIds: ReadonlySet<string>; + showAllUsers: boolean; + showInternal: boolean; +} + +export function filterVisibleTasks( + rawTasks: SidebarTask[], + options: FilterVisibleOptions, +): SidebarTask[] { + return rawTasks.filter( + (task) => + !options.archivedIds.has(task.id) && + (options.showAllUsers || + options.showInternal || + options.workspaceIds.has(task.id) || + options.provisioningIds.has(task.id)), + ); +} + +export interface TaskSession { + isPromptPending?: boolean; + pendingPermissions?: { size: number }; + cloudStatus?: TaskRunStatus; + cloudOutput?: { pr_url?: unknown } | null; +} + +export interface TaskWorkspace { + folderId?: string | null; + folderPath?: string | null; + branchName?: string | null; + linkedBranch?: string | null; +} + +export interface TaskTimestamp { + lastViewedAt?: number | null; + lastActivityAt?: number | null; +} + +export interface DeriveTaskDataContext { + session: TaskSession | undefined; + workspace: TaskWorkspace | undefined; + timestamp: TaskTimestamp | undefined; + pinnedIds: ReadonlySet<string>; + suspendedIds: ReadonlySet<string>; + slackTaskIds: ReadonlySet<string>; + slackThreadUrlByTaskId: ReadonlyMap<string, string>; +} + +export function deriveTaskData( + task: SidebarTask, + ctx: DeriveTaskDataContext, +): TaskData { + const { session, workspace, timestamp } = ctx; + const apiUpdatedAt = new Date(task.updated_at).getTime(); + const localActivity = timestamp?.lastActivityAt; + const lastActivityAt = localActivity + ? Math.max(apiUpdatedAt, localActivity) + : apiUpdatedAt; + const createdAt = new Date(task.created_at).getTime(); + + const taskLastViewedAt = timestamp?.lastViewedAt; + const isUnread = + taskLastViewedAt != null && lastActivityAt > taskLastViewedAt; + + const cloudPrUrl = + typeof task.latest_run?.output?.pr_url === "string" + ? task.latest_run.output.pr_url + : ((session?.cloudOutput?.pr_url as string | undefined) ?? null); + + const originProduct = + task.origin_product ?? + (ctx.slackTaskIds.has(task.id) ? "slack" : undefined); + const slackThreadUrl = + task.slack_thread_url ?? ctx.slackThreadUrlByTaskId.get(task.id); + + return { + id: task.id, + title: task.title, + createdAt, + lastActivityAt, + isGenerating: session?.isPromptPending ?? false, + isUnread, + isPinned: ctx.pinnedIds.has(task.id), + isSuspended: ctx.suspendedIds.has(task.id), + needsPermission: (session?.pendingPermissions?.size ?? 0) > 0, + repository: getRepositoryInfo(task, workspace?.folderPath ?? undefined), + folderId: workspace?.folderId || undefined, + taskRunStatus: session?.cloudStatus ?? task.latest_run?.status ?? undefined, + taskRunEnvironment: task.latest_run?.environment ?? undefined, + originProduct, + slackThreadUrl, + folderPath: workspace?.folderPath ?? null, + cloudPrUrl, + branchName: workspace?.branchName ?? null, + linkedBranch: workspace?.linkedBranch ?? null, + }; +} + +function getSortValue(task: TaskData, sortMode: SortMode): number { + return sortMode === "updated" ? task.lastActivityAt : task.createdAt; +} + +function sortTasks(tasks: TaskData[], sortMode: SortMode): TaskData[] { + return [...tasks].sort( + (a, b) => getSortValue(b, sortMode) - getSortValue(a, sortMode), + ); +} + +export interface PartitionedTasks { + pinnedTasks: TaskData[]; + sortedUnpinnedTasks: TaskData[]; + totalCount: number; +} + +export function partitionAndSortTasks( + taskData: TaskData[], + sortMode: SortMode, +): PartitionedTasks { + const pinned: TaskData[] = []; + const unpinned: TaskData[] = []; + for (const task of taskData) { + if (task.isPinned) { + pinned.push(task); + } else { + unpinned.push(task); + } + } + return { + pinnedTasks: sortTasks(pinned, sortMode), + sortedUnpinnedTasks: sortTasks(unpinned, sortMode), + totalCount: unpinned.length, + }; +} + +export interface ChronologicalSlice { + flatTasks: TaskData[]; + hasMore: boolean; +} + +export function sliceChronological( + sortedUnpinnedTasks: TaskData[], + organizeMode: OrganizeMode, + historyVisibleCount: number, +): ChronologicalSlice { + if (organizeMode !== "chronological") { + return { flatTasks: sortedUnpinnedTasks, hasMore: false }; + } + return { + flatTasks: sortedUnpinnedTasks.slice(0, historyVisibleCount), + hasMore: sortedUnpinnedTasks.length > historyVisibleCount, + }; +} diff --git a/apps/code/src/renderer/features/sidebar/utils/groupTasks.test.ts b/packages/core/src/sidebar/groupTasks.test.ts similarity index 99% rename from apps/code/src/renderer/features/sidebar/utils/groupTasks.test.ts rename to packages/core/src/sidebar/groupTasks.test.ts index 53d4fe7625..f69c5f64e5 100644 --- a/apps/code/src/renderer/features/sidebar/utils/groupTasks.test.ts +++ b/packages/core/src/sidebar/groupTasks.test.ts @@ -1,4 +1,4 @@ -import type { Task } from "@shared/types"; +import type { Task } from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; import { type GroupableTask, diff --git a/apps/code/src/renderer/features/sidebar/utils/groupTasks.ts b/packages/core/src/sidebar/groupTasks.ts similarity index 79% rename from apps/code/src/renderer/features/sidebar/utils/groupTasks.ts rename to packages/core/src/sidebar/groupTasks.ts index 20eef66b2b..0eadeb1429 100644 --- a/apps/code/src/renderer/features/sidebar/utils/groupTasks.ts +++ b/packages/core/src/sidebar/groupTasks.ts @@ -1,5 +1,9 @@ -import { getTaskRepository, parseRepository } from "@renderer/utils/repository"; -import { normalizeRepoKey } from "@shared/utils/repo"; +import { + getRelativeDateGroup, + getTaskRepository, + normalizeRepoKey, + parseRepository, +} from "@posthog/shared"; export interface TaskRepositoryInfo { fullPath: string; @@ -99,3 +103,25 @@ export function groupByRepository<T extends GroupableTask>( return aIndex - bIndex; }); } + +export interface RelativeDateGroup<T> { + label: string | null; + tasks: T[]; +} + +export function groupTasksByRelativeDate< + T extends Record<K, number>, + K extends string, +>(tasks: T[], timestampKey: K): RelativeDateGroup<T>[] { + const groups: RelativeDateGroup<T>[] = []; + for (const task of tasks) { + const label = getRelativeDateGroup(task[timestampKey]); + const last = groups[groups.length - 1]; + if (last && last.label === label) { + last.tasks.push(task); + } else { + groups.push({ label, tasks: [task] }); + } + } + return groups; +} diff --git a/packages/core/src/sidebar/selection.test.ts b/packages/core/src/sidebar/selection.test.ts new file mode 100644 index 0000000000..9663cd72f2 --- /dev/null +++ b/packages/core/src/sidebar/selection.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { + computeEffectiveBulkIds, + computeOrderedVisibleTaskIds, + computePriorTaskIds, + computeRangeSelection, + dedupeTaskIds, + formatArchiveResult, + pruneToVisible, +} from "./selection"; +import type { TaskData } from "./sidebarData.types"; + +function makeTaskData(id: string, overrides: Partial<TaskData> = {}): TaskData { + return { + id, + title: id, + createdAt: 0, + lastActivityAt: 0, + isGenerating: false, + isUnread: false, + isPinned: false, + needsPermission: false, + repository: null, + isSuspended: false, + folderPath: null, + cloudPrUrl: null, + branchName: null, + linkedBranch: null, + ...overrides, + }; +} + +describe("computeRangeSelection", () => { + const orderedIds = ["t1", "t2", "t3", "t4", "t5"]; + + it.each([ + { direction: "forward", anchor: "t2", target: "t4" }, + { direction: "backward", anchor: "t4", target: "t2" }, + ])("selects a $direction range", ({ anchor, target }) => { + const result = computeRangeSelection(anchor, target, orderedIds, []); + expect(result.selectedTaskIds).toEqual(["t2", "t3", "t4"]); + }); + + it("merges range with existing selection", () => { + const result = computeRangeSelection("t3", "t5", orderedIds, ["t1"]); + expect(result.selectedTaskIds).toEqual(["t1", "t3", "t4", "t5"]); + }); + + it.each([ + { name: "no anchor", anchor: null }, + { name: "anchor not in list", anchor: "t99" }, + ])("selects just the target when $name", ({ anchor }) => { + const result = computeRangeSelection(anchor, "t3", orderedIds, []); + expect(result.selectedTaskIds).toEqual(["t3"]); + }); + + it("updates lastClickedId to the target", () => { + const result = computeRangeSelection("t1", "t3", orderedIds, []); + expect(result.lastClickedId).toBe("t3"); + }); +}); + +describe("dedupeTaskIds", () => { + it("removes duplicates preserving order", () => { + expect(dedupeTaskIds(["t1", "t2", "t1", "t3", "t2"])).toEqual([ + "t1", + "t2", + "t3", + ]); + }); +}); + +describe("pruneToVisible", () => { + it("keeps only visible ids", () => { + expect(pruneToVisible(["t1", "t2", "t3"], ["t2", "t4"])).toEqual(["t2"]); + }); +}); + +describe("computeEffectiveBulkIds", () => { + it("returns empty when nothing selected", () => { + expect(computeEffectiveBulkIds([], "t1")).toEqual([]); + }); + + it("returns selection unchanged when no active task", () => { + expect(computeEffectiveBulkIds(["t1", "t2"], null)).toEqual(["t1", "t2"]); + }); + + it("prepends active task when not already selected", () => { + expect(computeEffectiveBulkIds(["t2"], "t1")).toEqual(["t1", "t2"]); + }); + + it("leaves selection unchanged when active task already selected", () => { + expect(computeEffectiveBulkIds(["t1", "t2"], "t1")).toEqual(["t1", "t2"]); + }); +}); + +describe("computeOrderedVisibleTaskIds", () => { + it("uses flat order in chronological mode", () => { + const ids = computeOrderedVisibleTaskIds( + { + pinnedTasks: [makeTaskData("p1")], + flatTasks: [makeTaskData("t1"), makeTaskData("t2")], + groupedTasks: [], + }, + "chronological", + new Set(), + ); + expect(ids).toEqual(["p1", "t1", "t2"]); + }); + + it("skips collapsed groups in by-project mode", () => { + const ids = computeOrderedVisibleTaskIds( + { + pinnedTasks: [], + flatTasks: [], + groupedTasks: [ + { id: "g1", name: "g1", tasks: [makeTaskData("a")] }, + { id: "g2", name: "g2", tasks: [makeTaskData("b")] }, + ], + }, + "by-project", + new Set(["g2"]), + ); + expect(ids).toEqual(["a"]); + }); +}); + +describe("computePriorTaskIds", () => { + it("returns ids created before the clicked task", () => { + const all = [ + { id: "t1", createdAt: 100 }, + { id: "t2", createdAt: 200 }, + { id: "t3", createdAt: 300 }, + ]; + expect(computePriorTaskIds(all, "t2")).toEqual(["t1"]); + }); + + it("returns empty when clicked task not found", () => { + expect(computePriorTaskIds([{ id: "t1", createdAt: 1 }], "x")).toEqual([]); + }); +}); + +describe("formatArchiveResult", () => { + it("formats success singular", () => { + expect(formatArchiveResult({ archived: 1, failed: 0 })).toEqual({ + kind: "success", + message: "1 task archived", + }); + }); + + it("formats success plural", () => { + expect(formatArchiveResult({ archived: 3, failed: 0 })).toEqual({ + kind: "success", + message: "3 tasks archived", + }); + }); + + it("formats error with failures", () => { + expect(formatArchiveResult({ archived: 2, failed: 1 })).toEqual({ + kind: "error", + message: "2 archived, 1 failed", + }); + }); +}); diff --git a/packages/core/src/sidebar/selection.ts b/packages/core/src/sidebar/selection.ts new file mode 100644 index 0000000000..28db2431b8 --- /dev/null +++ b/packages/core/src/sidebar/selection.ts @@ -0,0 +1,103 @@ +import type { SidebarData } from "./sidebarData.types"; + +export type OrganizeMode = "by-project" | "chronological"; + +export function computeOrderedVisibleTaskIds( + sidebarData: Pick<SidebarData, "pinnedTasks" | "flatTasks" | "groupedTasks">, + organizeMode: OrganizeMode, + collapsedSections: ReadonlySet<string>, +): string[] { + const ids: string[] = sidebarData.pinnedTasks.map((task) => task.id); + if (organizeMode === "by-project") { + for (const group of sidebarData.groupedTasks) { + if (collapsedSections.has(group.id)) continue; + for (const task of group.tasks) ids.push(task.id); + } + } else { + for (const task of sidebarData.flatTasks) ids.push(task.id); + } + return ids; +} + +export function computeEffectiveBulkIds( + selectedTaskIds: string[], + activeTaskId: string | null, +): string[] { + if (selectedTaskIds.length === 0) return []; + if (!activeTaskId) return selectedTaskIds; + if (selectedTaskIds.includes(activeTaskId)) return selectedTaskIds; + return [activeTaskId, ...selectedTaskIds]; +} + +export interface RangeSelection { + selectedTaskIds: string[]; + lastClickedId: string; +} + +export function computeRangeSelection( + anchorId: string | null, + toId: string, + orderedIds: string[], + current: string[], +): RangeSelection { + if (!anchorId) { + return { selectedTaskIds: [toId], lastClickedId: toId }; + } + const anchorIndex = orderedIds.indexOf(anchorId); + const toIndex = orderedIds.indexOf(toId); + if (anchorIndex === -1 || toIndex === -1) { + return { selectedTaskIds: [toId], lastClickedId: toId }; + } + const start = Math.min(anchorIndex, toIndex); + const end = Math.max(anchorIndex, toIndex); + const rangeIds = orderedIds.slice(start, end + 1); + const merged = Array.from(new Set([...current, ...rangeIds])); + return { selectedTaskIds: merged, lastClickedId: toId }; +} + +export function dedupeTaskIds(taskIds: string[]): string[] { + return Array.from(new Set(taskIds)); +} + +export function pruneToVisible( + selectedTaskIds: string[], + visibleTaskIds: string[], +): string[] { + const visible = new Set(visibleTaskIds); + return selectedTaskIds.filter((id) => visible.has(id)); +} + +export interface PriorTask { + id: string; + createdAt: number; +} + +export function computePriorTaskIds( + allVisible: PriorTask[], + clickedId: string, +): string[] { + const clicked = allVisible.find((task) => task.id === clickedId); + if (!clicked) return []; + const threshold = clicked.createdAt; + return allVisible + .filter((task) => task.id !== clickedId && task.createdAt < threshold) + .map((task) => task.id); +} + +export function formatArchiveResult(result: { + archived: number; + failed: number; +}): { kind: "success" | "error"; message: string } { + if (result.failed === 0) { + return { + kind: "success", + message: `${result.archived} ${ + result.archived === 1 ? "task" : "tasks" + } archived`, + }; + } + return { + kind: "error", + message: `${result.archived} archived, ${result.failed} failed`, + }; +} diff --git a/packages/core/src/sidebar/sidebarData.types.ts b/packages/core/src/sidebar/sidebarData.types.ts new file mode 100644 index 0000000000..ca6bafdbc5 --- /dev/null +++ b/packages/core/src/sidebar/sidebarData.types.ts @@ -0,0 +1,44 @@ +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import type { + TaskGroup as GenericTaskGroup, + TaskRepositoryInfo, +} from "./groupTasks"; + +export interface TaskData { + id: string; + title: string; + createdAt: number; + lastActivityAt: number; + isGenerating: boolean; + isUnread: boolean; + isPinned: boolean; + needsPermission: boolean; + repository: TaskRepositoryInfo | null; + isSuspended: boolean; + folderId?: string; + taskRunStatus?: TaskRunStatus; + taskRunEnvironment?: "local" | "cloud"; + originProduct?: string; + slackThreadUrl?: string; + folderPath: string | null; + cloudPrUrl: string | null; + branchName: string | null; + linkedBranch: string | null; +} + +export type TaskGroup = GenericTaskGroup<TaskData>; + +export interface SidebarData { + isHomeActive: boolean; + isInboxActive: boolean; + isCommandCenterActive: boolean; + isSkillsActive: boolean; + isMcpServersActive: boolean; + isLoading: boolean; + activeTaskId: string | null; + pinnedTasks: TaskData[]; + flatTasks: TaskData[]; + groupedTasks: TaskGroup[]; + totalCount: number; + hasMore: boolean; +} diff --git a/apps/code/src/renderer/features/sidebar/utils/summaryIds.test.ts b/packages/core/src/sidebar/summaryIds.test.ts similarity index 100% rename from apps/code/src/renderer/features/sidebar/utils/summaryIds.test.ts rename to packages/core/src/sidebar/summaryIds.test.ts diff --git a/apps/code/src/renderer/features/sidebar/utils/summaryIds.ts b/packages/core/src/sidebar/summaryIds.ts similarity index 100% rename from apps/code/src/renderer/features/sidebar/utils/summaryIds.ts rename to packages/core/src/sidebar/summaryIds.ts diff --git a/packages/core/src/sidebar/taskMeta.ts b/packages/core/src/sidebar/taskMeta.ts new file mode 100644 index 0000000000..ccbe03a7f5 --- /dev/null +++ b/packages/core/src/sidebar/taskMeta.ts @@ -0,0 +1,27 @@ +export interface RawTaskTimestamp { + pinnedAt: string | null; + lastViewedAt: string | null; + lastActivityAt: string | null; +} + +export interface TaskTimestamps { + lastViewedAt: number | null; + lastActivityAt: number | null; +} + +export function parseTimestamps( + raw: Record<string, RawTaskTimestamp>, +): Record<string, TaskTimestamps> { + const result: Record<string, TaskTimestamps> = {}; + for (const [taskId, ts] of Object.entries(raw)) { + result[taskId] = { + lastViewedAt: ts.lastViewedAt + ? new Date(ts.lastViewedAt).getTime() + : null, + lastActivityAt: ts.lastActivityAt + ? new Date(ts.lastActivityAt).getTime() + : null, + }; + } + return result; +} diff --git a/apps/code/src/renderer/features/skill-buttons/prompts.ts b/packages/core/src/skill-buttons/catalog.ts similarity index 69% rename from apps/code/src/renderer/features/skill-buttons/prompts.ts rename to packages/core/src/skill-buttons/catalog.ts index 4e68e5daa6..9079077a02 100644 --- a/apps/code/src/renderer/features/skill-buttons/prompts.ts +++ b/packages/core/src/skill-buttons/catalog.ts @@ -1,35 +1,26 @@ -import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { - Broadcast, - ChartBar, - Flask, - type Icon, - Pulse, - ToggleRight, - Warning, -} from "@phosphor-icons/react"; -import type { SkillButtonId } from "@shared/types/analytics"; +import type { SkillButtonId } from "@posthog/shared/analytics-events"; export type { SkillButtonId }; -export interface SkillButton { +export interface SkillButtonCatalogEntry { id: SkillButtonId; label: string; prompt: string; color: string; - Icon: Icon; actionTitle: string; actionDescription: string; tooltip: string; } -export const SKILL_BUTTONS: Record<SkillButtonId, SkillButton> = { +export const SKILL_BUTTON_CATALOG: Record< + SkillButtonId, + SkillButtonCatalogEntry +> = { "add-analytics": { id: "add-analytics", label: "Track events", prompt: "/instrument-product-analytics", color: "#2F80FA", - Icon: ChartBar, actionTitle: "Adding analytics", actionDescription: "to measure how this change performs in production.", tooltip: @@ -40,7 +31,6 @@ export const SKILL_BUTTONS: Record<SkillButtonId, SkillButton> = { label: "Add feature flag", prompt: "/instrument-feature-flags", color: "#30ABC6", - Icon: ToggleRight, actionTitle: "Creating a feature flag", actionDescription: "to roll this out safely and toggle it without a redeploy.", @@ -53,7 +43,6 @@ export const SKILL_BUTTONS: Record<SkillButtonId, SkillButton> = { prompt: "Set up a PostHog experiment for the feature in this task. Use the PostHog MCP to create the feature flag with control and test variants, then create the experiment in draft with a clear hypothesis and primary metric tied to the feature's success. Wire the variant into the code via posthog.getFeatureFlag. Only launch the experiment if the feature is already live in production — otherwise leave it in draft and tell me to launch it after this is merged and deployed.", color: "#B62AD9", - Icon: Flask, actionTitle: "Setting up an experiment", actionDescription: "with control and test variants tied to a primary metric, ready to launch once this ships.", @@ -65,7 +54,6 @@ export const SKILL_BUTTONS: Record<SkillButtonId, SkillButton> = { label: "Track errors", prompt: "/instrument-error-tracking", color: "#BF8113", - Icon: Warning, actionTitle: "Adding error tracking", actionDescription: "so exceptions surface in PostHog with stack traces and source maps.", @@ -77,7 +65,6 @@ export const SKILL_BUTTONS: Record<SkillButtonId, SkillButton> = { label: "Trace LLM calls", prompt: "/instrument-llm-analytics", color: "#B029D2", - Icon: Broadcast, actionTitle: "Instrumenting LLM calls", actionDescription: "for visibility into prompts, tokens, latency, and costs.", @@ -89,7 +76,6 @@ export const SKILL_BUTTONS: Record<SkillButtonId, SkillButton> = { label: "Capture logs", prompt: "/instrument-logs", color: "#C92474", - Icon: Pulse, actionTitle: "Adding logging", actionDescription: "so structured log events flow into PostHog for inspection and debugging.", @@ -107,38 +93,12 @@ export const SKILL_BUTTON_ORDER: SkillButtonId[] = [ "run-experiment", ]; -const SKILL_BUTTON_META_NAMESPACE = "posthogCode"; -const SKILL_BUTTON_META_FIELD = "skillButtonId"; +export const SKILL_BUTTON_IDS: ReadonlySet<SkillButtonId> = new Set( + Object.keys(SKILL_BUTTON_CATALOG) as SkillButtonId[], +); -export function buildSkillButtonPromptBlocks( - buttonId: SkillButtonId, -): ContentBlock[] { - return [ - { - type: "text", - text: SKILL_BUTTONS[buttonId].prompt, - _meta: { - [SKILL_BUTTON_META_NAMESPACE]: { - [SKILL_BUTTON_META_FIELD]: buttonId, - }, - }, - }, - ]; -} - -export function extractSkillButtonId( - blocks: ContentBlock[] | undefined, -): SkillButtonId | null { - if (!blocks?.length) return null; - for (const block of blocks) { - const meta = (block as { _meta?: Record<string, unknown> })._meta; - const namespace = meta?.[SKILL_BUTTON_META_NAMESPACE] as - | Record<string, unknown> - | undefined; - const id = namespace?.[SKILL_BUTTON_META_FIELD]; - if (typeof id === "string" && id in SKILL_BUTTONS) { - return id as SkillButtonId; - } - } - return null; +export function isSkillButtonId(value: unknown): value is SkillButtonId { + return ( + typeof value === "string" && SKILL_BUTTON_IDS.has(value as SkillButtonId) + ); } diff --git a/apps/code/src/renderer/features/skill-buttons/prompts.test.ts b/packages/core/src/skill-buttons/prompts.test.ts similarity index 81% rename from apps/code/src/renderer/features/skill-buttons/prompts.test.ts rename to packages/core/src/skill-buttons/prompts.test.ts index de570e360b..63b5dff366 100644 --- a/apps/code/src/renderer/features/skill-buttons/prompts.test.ts +++ b/packages/core/src/skill-buttons/prompts.test.ts @@ -1,17 +1,14 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import { describe, expect, it } from "vitest"; -import { - buildSkillButtonPromptBlocks, - extractSkillButtonId, - SKILL_BUTTONS, -} from "./prompts"; +import { SKILL_BUTTON_CATALOG } from "./catalog"; +import { buildSkillButtonPromptBlocks, extractSkillButtonId } from "./prompts"; describe("buildSkillButtonPromptBlocks", () => { it("produces a text block carrying the button id under posthogCode meta", () => { const [block] = buildSkillButtonPromptBlocks("add-analytics"); expect(block.type).toBe("text"); expect((block as { text: string }).text).toBe( - SKILL_BUTTONS["add-analytics"].prompt, + SKILL_BUTTON_CATALOG["add-analytics"].prompt, ); expect((block as { _meta?: unknown })._meta).toEqual({ posthogCode: { skillButtonId: "add-analytics" }, @@ -21,9 +18,9 @@ describe("buildSkillButtonPromptBlocks", () => { describe("extractSkillButtonId", () => { it("round-trips through buildSkillButtonPromptBlocks", () => { - for (const id of Object.keys(SKILL_BUTTONS)) { + for (const id of Object.keys(SKILL_BUTTON_CATALOG)) { const blocks = buildSkillButtonPromptBlocks( - id as keyof typeof SKILL_BUTTONS, + id as keyof typeof SKILL_BUTTON_CATALOG, ); expect(extractSkillButtonId(blocks)).toBe(id); } @@ -47,7 +44,7 @@ describe("extractSkillButtonId", () => { it("ignores plain text that happens to match a prompt string", () => { const blocks: ContentBlock[] = [ - { type: "text", text: SKILL_BUTTONS["add-analytics"].prompt }, + { type: "text", text: SKILL_BUTTON_CATALOG["add-analytics"].prompt }, ]; expect(extractSkillButtonId(blocks)).toBeNull(); }); diff --git a/packages/core/src/skill-buttons/prompts.ts b/packages/core/src/skill-buttons/prompts.ts new file mode 100644 index 0000000000..ded135a0ad --- /dev/null +++ b/packages/core/src/skill-buttons/prompts.ts @@ -0,0 +1,42 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { + isSkillButtonId, + SKILL_BUTTON_CATALOG, + type SkillButtonId, +} from "./catalog"; + +export const SKILL_BUTTON_META_NAMESPACE = "posthogCode"; +export const SKILL_BUTTON_META_FIELD = "skillButtonId"; + +export function buildSkillButtonPromptBlocks( + buttonId: SkillButtonId, +): ContentBlock[] { + return [ + { + type: "text", + text: SKILL_BUTTON_CATALOG[buttonId].prompt, + _meta: { + [SKILL_BUTTON_META_NAMESPACE]: { + [SKILL_BUTTON_META_FIELD]: buttonId, + }, + }, + }, + ]; +} + +export function extractSkillButtonId( + blocks: ContentBlock[] | undefined, +): SkillButtonId | null { + if (!blocks?.length) return null; + for (const block of blocks) { + const meta = (block as { _meta?: Record<string, unknown> })._meta; + const namespace = meta?.[SKILL_BUTTON_META_NAMESPACE] as + | Record<string, unknown> + | undefined; + const id = namespace?.[SKILL_BUTTON_META_FIELD]; + if (isSkillButtonId(id)) { + return id; + } + } + return null; +} diff --git a/packages/core/src/sleep/identifiers.ts b/packages/core/src/sleep/identifiers.ts new file mode 100644 index 0000000000..6c63647fec --- /dev/null +++ b/packages/core/src/sleep/identifiers.ts @@ -0,0 +1 @@ +export const SLEEP_SERVICE = Symbol.for("posthog.core.sleepService"); diff --git a/packages/core/src/sleep/sleep.test.ts b/packages/core/src/sleep/sleep.test.ts new file mode 100644 index 0000000000..e4f8049005 --- /dev/null +++ b/packages/core/src/sleep/sleep.test.ts @@ -0,0 +1,116 @@ +import type { IPowerManager } from "@posthog/platform/power-manager"; +import type { IWorkspaceSettings } from "@posthog/platform/workspace-settings"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SleepService } from "./sleep"; + +function makeLogger() { + const scoped = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + return { ...scoped, scope: vi.fn(() => scoped) }; +} + +function createDeps(preventSleepInitially = true) { + const release = vi.fn(); + const powerManager: IPowerManager = { + onResume: vi.fn(() => () => {}), + preventSleep: vi.fn(() => release), + }; + + let stored = preventSleepInitially; + const settings: IWorkspaceSettings = { + getPreventSleepWhileRunning: vi.fn(() => stored), + setPreventSleepWhileRunning: vi.fn((value: boolean) => { + stored = value; + }), + } as unknown as IWorkspaceSettings; + + const service = new SleepService(powerManager, settings, makeLogger()); + + return { service, powerManager, settings, release }; +} + +describe("SleepService", () => { + let ctx: ReturnType<typeof createDeps>; + + beforeEach(() => { + ctx = createDeps(true); + }); + + it("seeds the enabled flag from persisted settings", () => { + expect(ctx.service.getEnabled()).toBe(true); + expect(createDeps(false).service.getEnabled()).toBe(false); + }); + + it("does not block sleep when enabled but no activity is active", () => { + expect(ctx.powerManager.preventSleep).not.toHaveBeenCalled(); + }); + + it("blocks sleep once an activity is acquired while enabled", () => { + ctx.service.acquire("turn-1"); + expect(ctx.powerManager.preventSleep).toHaveBeenCalledTimes(1); + }); + + it("does not block sleep on acquire when disabled", () => { + const disabled = createDeps(false); + disabled.service.acquire("turn-1"); + expect(disabled.powerManager.preventSleep).not.toHaveBeenCalled(); + }); + + it("acquires the blocker only once across multiple activities", () => { + ctx.service.acquire("turn-1"); + ctx.service.acquire("turn-2"); + expect(ctx.powerManager.preventSleep).toHaveBeenCalledTimes(1); + }); + + it("keeps blocking until the last activity is released", () => { + ctx.service.acquire("turn-1"); + ctx.service.acquire("turn-2"); + + ctx.service.release("turn-1"); + expect(ctx.release).not.toHaveBeenCalled(); + + ctx.service.release("turn-2"); + expect(ctx.release).toHaveBeenCalledTimes(1); + }); + + it("treats releasing an unknown activity as a no-op", () => { + ctx.service.release("never-acquired"); + expect(ctx.powerManager.preventSleep).not.toHaveBeenCalled(); + expect(ctx.release).not.toHaveBeenCalled(); + }); + + it("releases the active blocker and persists when disabled at runtime", () => { + ctx.service.acquire("turn-1"); + + ctx.service.setEnabled(false); + + expect(ctx.service.getEnabled()).toBe(false); + expect(ctx.settings.setPreventSleepWhileRunning).toHaveBeenCalledWith( + false, + ); + expect(ctx.release).toHaveBeenCalledTimes(1); + }); + + it("starts blocking when re-enabled while an activity is still active", () => { + const disabled = createDeps(false); + disabled.service.acquire("turn-1"); + expect(disabled.powerManager.preventSleep).not.toHaveBeenCalled(); + + disabled.service.setEnabled(true); + + expect(disabled.settings.setPreventSleepWhileRunning).toHaveBeenCalledWith( + true, + ); + expect(disabled.powerManager.preventSleep).toHaveBeenCalledTimes(1); + }); + + it("releases the blocker on cleanup", () => { + ctx.service.acquire("turn-1"); + ctx.service.cleanup(); + expect(ctx.release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/code/src/main/services/sleep/service.ts b/packages/core/src/sleep/sleep.ts similarity index 58% rename from apps/code/src/main/services/sleep/service.ts rename to packages/core/src/sleep/sleep.ts index 9fa26b014c..ec49c2a84e 100644 --- a/apps/code/src/main/services/sleep/service.ts +++ b/packages/core/src/sleep/sleep.ts @@ -1,28 +1,41 @@ -import type { IPowerManager } from "@posthog/platform/power-manager"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { + type IPowerManager, + POWER_MANAGER_SERVICE, +} from "@posthog/platform/power-manager"; +import { + type IWorkspaceSettings, + WORKSPACE_SETTINGS_SERVICE, +} from "@posthog/platform/workspace-settings"; import { inject, injectable, preDestroy } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { settingsStore } from "../settingsStore"; - -const log = logger.scope("sleep"); @injectable() export class SleepService { private enabled: boolean; private releaseBlocker: (() => void) | null = null; private activeActivities = new Set<string>(); + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.PowerManager) + @inject(POWER_MANAGER_SERVICE) private readonly powerManager: IPowerManager, + @inject(WORKSPACE_SETTINGS_SERVICE) + private readonly settings: IWorkspaceSettings, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, ) { - this.enabled = settingsStore.get("preventSleepWhileRunning", false); + this.log = workbenchLogger.scope("sleep"); + this.enabled = this.settings.getPreventSleepWhileRunning(); } setEnabled(enabled: boolean): void { - log.info("setEnabled", { enabled }); + this.log.info("setEnabled", { enabled }); this.enabled = enabled; - settingsStore.set("preventSleepWhileRunning", enabled); + this.settings.setPreventSleepWhileRunning(enabled); this.updateBlocker(); } @@ -58,12 +71,12 @@ export class SleepService { this.releaseBlocker = this.powerManager.preventSleep( "prevent-app-suspension", ); - log.info("Started power save blocker"); + this.log.info("Started power save blocker"); } private stopBlocker(): void { if (!this.releaseBlocker) return; - log.info("Stopping power save blocker"); + this.log.info("Stopping power save blocker"); this.releaseBlocker(); this.releaseBlocker = null; } diff --git a/packages/core/src/task-detail/cloudRunState.ts b/packages/core/src/task-detail/cloudRunState.ts new file mode 100644 index 0000000000..f076d3ca24 --- /dev/null +++ b/packages/core/src/task-detail/cloudRunState.ts @@ -0,0 +1,35 @@ +import type { ChangedFile, Task } from "@posthog/shared/domain-types"; + +export interface CloudRunSessionLike { + cloudBranch?: string | null; + cloudStatus?: string | null; +} + +export interface CloudRunStateResult { + prUrl: string | null; + effectiveBranch: string | null; + repo: string | null; + cloudStatus: string | null; + isRunActive: boolean; +} + +export function deriveCloudRunState( + task: Task, + session: CloudRunSessionLike | null | undefined, + prUrl: string | null, +): CloudRunStateResult { + const branch = task.latest_run?.branch ?? null; + const cloudBranch = session?.cloudBranch ?? null; + const effectiveBranch = branch ?? cloudBranch; + const repo = task.repository ?? null; + + const cloudStatus = session?.cloudStatus ?? task.latest_run?.status ?? null; + const isRunActive = + cloudStatus === "queued" || + cloudStatus === "in_progress" || + (cloudStatus === null && session != null); + + return { prUrl, effectiveBranch, repo, cloudStatus, isRunActive }; +} + +export type { ChangedFile }; diff --git a/apps/code/src/renderer/features/task-detail/utils/cloudToolChanges.test.ts b/packages/core/src/task-detail/cloudToolChanges.test.ts similarity index 100% rename from apps/code/src/renderer/features/task-detail/utils/cloudToolChanges.test.ts rename to packages/core/src/task-detail/cloudToolChanges.test.ts diff --git a/apps/code/src/renderer/features/task-detail/utils/cloudToolChanges.ts b/packages/core/src/task-detail/cloudToolChanges.ts similarity index 90% rename from apps/code/src/renderer/features/task-detail/utils/cloudToolChanges.ts rename to packages/core/src/task-detail/cloudToolChanges.ts index 6eed046a4a..9030cfcd2a 100644 --- a/apps/code/src/renderer/features/task-detail/utils/cloudToolChanges.ts +++ b/packages/core/src/task-detail/cloudToolChanges.ts @@ -1,13 +1,39 @@ -import { getReadToolContent } from "@features/sessions/components/session-update/toolCallUtils"; import type { ToolCallContent, ToolCallLocation, -} from "@features/sessions/types"; -import type { ChangedFile } from "@shared/types"; -import { - type AcpMessage, - isJsonRpcNotification, -} from "@shared/types/session-events"; +} from "@agentclientprotocol/sdk"; +import { type AcpMessage, isJsonRpcNotification } from "@posthog/shared"; +import type { ChangedFile } from "@posthog/shared/domain-types"; + +function getContentText( + content: ToolCallContent[] | undefined, +): string | undefined { + if (!content?.length) return undefined; + for (const item of content) { + if (item.type === "content" && item.content.type === "text") { + return item.content.text; + } + } + return undefined; +} + +function getReadToolContent( + content: ToolCallContent[] | undefined, +): string | undefined { + const raw = getContentText(content); + if (!raw) return undefined; + + let text = raw; + text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ""); + text = text.replace(/^```\w*\n?/, "").replace(/\n?```\s*$/, ""); + text = text + .split("\n") + .map((line) => line.replace(/^\s*\d+→/, "")) + .join("\n"); + text = text.trim(); + + return text || undefined; +} export interface ParsedToolCall { toolCallId: string; diff --git a/apps/code/src/renderer/features/task-detail/utils/configOptions.ts b/packages/core/src/task-detail/configOptions.ts similarity index 100% rename from apps/code/src/renderer/features/task-detail/utils/configOptions.ts rename to packages/core/src/task-detail/configOptions.ts diff --git a/packages/core/src/task-detail/discardInfo.ts b/packages/core/src/task-detail/discardInfo.ts new file mode 100644 index 0000000000..0496796504 --- /dev/null +++ b/packages/core/src/task-detail/discardInfo.ts @@ -0,0 +1,44 @@ +import type { ChangedFile } from "@posthog/shared/domain-types"; + +export interface DiscardInfo { + message: string; + action: string; +} + +export function getDiscardInfo( + file: ChangedFile, + fileName: string, +): DiscardInfo { + switch (file.status) { + case "modified": + return { + message: `Are you sure you want to discard changes in '${fileName}'?`, + action: "Discard File", + }; + case "deleted": + return { + message: `Are you sure you want to restore '${fileName}'?`, + action: "Restore File", + }; + case "added": + return { + message: `Are you sure you want to remove '${fileName}'?`, + action: "Remove File", + }; + case "untracked": + return { + message: `Are you sure you want to delete '${fileName}'?`, + action: "Delete File", + }; + case "renamed": + return { + message: `Are you sure you want to undo the rename of '${fileName}'?`, + action: "Undo Rename File", + }; + default: + return { + message: `Are you sure you want to discard changes in '${fileName}'?`, + action: "Discard File", + }; + } +} diff --git a/packages/core/src/task-detail/identifiers.ts b/packages/core/src/task-detail/identifiers.ts new file mode 100644 index 0000000000..4369b3d0fb --- /dev/null +++ b/packages/core/src/task-detail/identifiers.ts @@ -0,0 +1,10 @@ +export const TASK_SERVICE = Symbol.for("posthog.core.taskDetail.taskService"); +export const TASK_CREATION_HOST = Symbol.for( + "posthog.core.taskDetail.taskCreationHost", +); +export const TASK_CREATION_EFFECTS = Symbol.for( + "posthog.core.taskDetail.creationEffects", +); +export const WORKSPACE_SETUP_SAGA = Symbol.for( + "posthog.core.taskDetail.workspaceSetupSaga", +); diff --git a/packages/core/src/task-detail/previewConfig.ts b/packages/core/src/task-detail/previewConfig.ts new file mode 100644 index 0000000000..90ad092d4e --- /dev/null +++ b/packages/core/src/task-detail/previewConfig.ts @@ -0,0 +1,194 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { flattenConfigValues } from "@posthog/core/task-detail/configOptions"; + +export type PreviewAdapter = "claude" | "codex"; + +export interface PreviewSettingsSnapshot { + defaultInitialTaskMode: string; + lastUsedInitialTaskMode: string | null | undefined; + defaultReasoningEffort: string; + lastUsedReasoningEffort: string | null | undefined; +} + +export interface EffortOption { + value: string; +} + +const EFFORT_RANK: Record<string, number> = { + low: 0, + medium: 1, + high: 2, + xhigh: 3, + max: 4, +}; + +export function clampEffortToAvailable( + desired: string, + available: string[], +): string | null { + if (available.length === 0) return null; + if (available.includes(desired)) return desired; + + const desiredRank = EFFORT_RANK[desired]; + if (desiredRank === undefined) { + return available[available.length - 1]; + } + + const ranked = available + .map((value) => ({ value, rank: EFFORT_RANK[value] })) + .filter((entry): entry is { value: string; rank: number } => + Number.isFinite(entry.rank), + ); + if (ranked.length === 0) return available[0]; + + return ranked.reduce((closest, entry) => + Math.abs(entry.rank - desiredRank) < Math.abs(closest.rank - desiredRank) + ? entry + : closest, + ).value; +} + +export function deriveInitialConfig( + options: SessionConfigOption[], + settings: PreviewSettingsSnapshot, + adapter: PreviewAdapter, +): SessionConfigOption[] { + const { + defaultInitialTaskMode, + lastUsedInitialTaskMode, + defaultReasoningEffort, + lastUsedReasoningEffort, + } = settings; + + const modeOpt = options.find((o) => o.id === "mode"); + const serverDefault = modeOpt?.currentValue; + const availableValues: string[] = modeOpt ? flattenConfigValues(modeOpt) : []; + + let initialMode: string; + if ( + defaultInitialTaskMode === "last_used" && + lastUsedInitialTaskMode && + availableValues.includes(lastUsedInitialTaskMode) + ) { + initialMode = lastUsedInitialTaskMode; + } else { + const fallbackDefault = adapter === "codex" ? "auto" : "plan"; + initialMode = + typeof serverDefault === "string" && + availableValues.includes(serverDefault) + ? serverDefault + : fallbackDefault; + } + + const withMode = options.map((opt) => + opt.id === "mode" + ? ({ ...opt, currentValue: initialMode } as SessionConfigOption) + : opt, + ); + + return withMode.map((opt) => { + if (opt.category !== "thought_level" || opt.type !== "select") { + return opt; + } + const validValues = flattenConfigValues(opt); + if (defaultReasoningEffort === "last_used") { + if ( + lastUsedReasoningEffort && + validValues.includes(lastUsedReasoningEffort) + ) { + return { + ...opt, + currentValue: lastUsedReasoningEffort, + } as SessionConfigOption; + } + return opt; + } + const clamped = clampEffortToAvailable(defaultReasoningEffort, validValues); + if (clamped) { + return { ...opt, currentValue: clamped } as SessionConfigOption; + } + return opt; + }); +} + +export interface ApplyConfigChangeArgs { + adapter: PreviewAdapter; + configId: string; + value: string; + effortOptions: EffortOption[] | undefined; + settings: PreviewSettingsSnapshot; +} + +export function applyConfigChange( + options: SessionConfigOption[], + args: ApplyConfigChangeArgs, +): SessionConfigOption[] { + const { adapter, configId, value, effortOptions, settings } = args; + + let updated = options.map((opt) => + opt.id === configId + ? ({ ...opt, currentValue: value } as SessionConfigOption) + : opt, + ); + + if (configId !== "model") { + return updated; + } + + const existingIdx = updated.findIndex((o) => o.category === "thought_level"); + const effortOptionId = + existingIdx >= 0 + ? updated[existingIdx].id + : adapter === "codex" + ? "reasoning_effort" + : "effort"; + + const { lastUsedReasoningEffort, defaultReasoningEffort } = settings; + const isValidEffort = (effort: unknown): effort is string => + typeof effort === "string" && + !!effortOptions?.some((e) => e.value === effort); + const resolveEffortFallback = (): string => { + if ( + defaultReasoningEffort !== "last_used" && + isValidEffort(defaultReasoningEffort) + ) { + return defaultReasoningEffort; + } + return isValidEffort(lastUsedReasoningEffort) + ? lastUsedReasoningEffort + : "high"; + }; + + if (effortOptions && existingIdx >= 0) { + const currentEffort = updated[existingIdx].currentValue; + const nextEffort = isValidEffort(currentEffort) + ? currentEffort + : resolveEffortFallback(); + updated[existingIdx] = { + ...updated[existingIdx], + currentValue: nextEffort, + options: effortOptions, + } as SessionConfigOption; + } else if (effortOptions && existingIdx === -1) { + const nextEffort = resolveEffortFallback(); + updated = [ + ...updated, + { + id: effortOptionId, + name: adapter === "codex" ? "Reasoning Level" : "Effort", + type: "select", + currentValue: nextEffort, + options: effortOptions, + category: "thought_level", + description: + adapter === "codex" + ? "Controls how much reasoning effort the model uses" + : "Controls how much effort Claude puts into its response", + } as SessionConfigOption, + ]; + } else if (!effortOptions && existingIdx >= 0) { + updated = updated.filter((o) => o.category !== "thought_level"); + } + + return updated; +} diff --git a/packages/core/src/task-detail/task-detail.module.ts b/packages/core/src/task-detail/task-detail.module.ts new file mode 100644 index 0000000000..193d444269 --- /dev/null +++ b/packages/core/src/task-detail/task-detail.module.ts @@ -0,0 +1,9 @@ +import { ContainerModule } from "inversify"; +import { TASK_SERVICE, WORKSPACE_SETUP_SAGA } from "./identifiers"; +import { TaskService } from "./taskService"; +import { WorkspaceSetupSaga } from "./workspaceSetupSaga"; + +export const taskDetailModule = new ContainerModule(({ bind }) => { + bind(TASK_SERVICE).to(TaskService).inSingletonScope(); + bind(WORKSPACE_SETUP_SAGA).to(WorkspaceSetupSaga).inSingletonScope(); +}); diff --git a/packages/core/src/task-detail/taskCreationApiClient.ts b/packages/core/src/task-detail/taskCreationApiClient.ts new file mode 100644 index 0000000000..7c00dea6be --- /dev/null +++ b/packages/core/src/task-detail/taskCreationApiClient.ts @@ -0,0 +1,37 @@ +import type { CloudRunSource, PrAuthorshipMode } from "@posthog/shared"; +import type { Task, TaskRun } from "@posthog/shared/domain-types"; + +export interface CreateTaskRunClientOptions { + environment?: "local" | "cloud"; + mode?: "interactive" | "background"; + branch?: string | null; + adapter?: "claude" | "codex"; + model?: string; + reasoningLevel?: string; + sandboxEnvironmentId?: string; + prAuthorshipMode?: PrAuthorshipMode; + runSource?: CloudRunSource; + signalReportId?: string; + initialPermissionMode?: string; +} + +export interface StartTaskRunClientOptions { + pendingUserMessage?: string; + pendingUserArtifactIds?: string[]; +} + +export interface TaskCreationApiClient { + getTask(taskId: string): Promise<Task>; + getTaskRun(taskId: string, runId: string): Promise<TaskRun>; + createTask(options: Record<string, unknown>): Promise<unknown>; + deleteTask(taskId: string): Promise<void>; + createTaskRun( + taskId: string, + options?: CreateTaskRunClientOptions, + ): Promise<TaskRun>; + startTaskRun( + taskId: string, + runId: string, + options?: StartTaskRunClientOptions, + ): Promise<Task>; +} diff --git a/packages/core/src/task-detail/taskCreationEffects.ts b/packages/core/src/task-detail/taskCreationEffects.ts new file mode 100644 index 0000000000..60981a8d4b --- /dev/null +++ b/packages/core/src/task-detail/taskCreationEffects.ts @@ -0,0 +1,12 @@ +import type { TaskCreationInput, TaskCreationOutput } from "@posthog/shared"; + +/** + * Host-side reactions to a successful task-creation: optimistic workspace + * query-cache update, cache invalidation, and the cross-store "last used" + * settings + draft clearing. The renderer adapter wires these to React-Query + * and the zustand stores; core stays free of both. + */ +export interface TaskCreationEffects { + onWorkspaceCreated(output: TaskCreationOutput): void; + onCreateSuccess(output: TaskCreationOutput, input?: TaskCreationInput): void; +} diff --git a/packages/core/src/task-detail/taskCreationHost.ts b/packages/core/src/task-detail/taskCreationHost.ts new file mode 100644 index 0000000000..f3b2f5f783 --- /dev/null +++ b/packages/core/src/task-detail/taskCreationHost.ts @@ -0,0 +1,82 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import type { Workspace, WorkspaceMode } from "@posthog/shared"; +import type { TaskCreationApiClient } from "./taskCreationApiClient"; + +export interface CloudPromptTransport { + filePaths: string[]; + messageText?: string; + promptText: string; +} + +export interface CreateWorkspaceArgs { + taskId: string; + mainRepoPath: string; + folderId: string; + folderPath: string; + mode: WorkspaceMode; + branch?: string; +} + +export interface CreatedWorkspaceInfo { + worktree?: { + worktreePath?: string | null; + worktreeName?: string | null; + branchName?: string | null; + baseBranch?: string | null; + createdAt?: string | null; + } | null; + linkedBranch?: string | null; +} + +export interface TaskFolderInfo { + id: string; + path: string; +} + +export interface DetectedRepo { + organization: string; + repository: string; +} + +export interface TaskEnvironment { + name: string; + setup?: { script?: string | null } | null; +} + +export interface SetupActionDispatch { + taskId: string; + command: string; + cwd: string; + label: string; +} + +export interface ITaskCreationHost { + getAuthenticatedClient(): Promise<TaskCreationApiClient | null>; + getTaskDirectory(taskId: string, repoKey?: string): Promise<string | null>; + getWorkspace(taskId: string): Promise<Workspace | null>; + createWorkspace(args: CreateWorkspaceArgs): Promise<CreatedWorkspaceInfo>; + deleteWorkspace(args: { + taskId: string; + mainRepoPath: string; + }): Promise<void>; + getFolders(): Promise<TaskFolderInfo[]>; + addFolder(args: { folderPath: string }): Promise<TaskFolderInfo>; + getEnvironment(args: { + repoPath: string; + id: string; + }): Promise<TaskEnvironment | null>; + detectRepo(args: { directoryPath: string }): Promise<DetectedRepo | null>; + getCloudPromptTransport( + prompt: string | ContentBlock[], + filePaths?: string[], + ): CloudPromptTransport; + uploadRunAttachments( + client: TaskCreationApiClient, + taskId: string, + runId: string, + filePaths: string[], + ): Promise<string[]>; + setProvisioningActive(taskId: string): void; + clearProvisioning(taskId: string): void; + dispatchSetupAction(args: SetupActionDispatch): void; +} diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts similarity index 80% rename from apps/code/src/renderer/sagas/task/task-creation.test.ts rename to packages/core/src/task-detail/taskCreationSaga.test.ts index d31e22c147..11fa798cee 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -1,68 +1,36 @@ -import type { Task, TaskRun } from "@shared/types"; +import type { SessionService } from "@posthog/core/sessions/sessionService"; +import type { Task, TaskRun } from "@posthog/shared/domain-types"; import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockWorkspaceCreate = vi.hoisted(() => vi.fn()); -const mockWorkspaceDelete = vi.hoisted(() => vi.fn()); -const mockGetTaskDirectory = vi.hoisted(() => vi.fn()); -const mockReadFileAsBase64 = vi.hoisted(() => vi.fn()); -vi.mock("@renderer/trpc", () => ({ - trpcClient: { - workspace: { - create: { mutate: mockWorkspaceCreate }, - delete: { mutate: mockWorkspaceDelete }, - }, - }, +import type { + CloudPromptTransport, + ITaskCreationHost, +} from "./taskCreationHost"; + +const mockHost = vi.hoisted(() => ({ + getAuthenticatedClient: vi.fn(), + getTaskDirectory: vi.fn(), + getWorkspace: vi.fn(), + createWorkspace: vi.fn(), + deleteWorkspace: vi.fn(), + getFolders: vi.fn(), + addFolder: vi.fn(), + getEnvironment: vi.fn(), + detectRepo: vi.fn(), + getCloudPromptTransport: vi.fn(), + uploadRunAttachments: vi.fn(), + setProvisioningActive: vi.fn(), + clearProvisioning: vi.fn(), + dispatchSetupAction: vi.fn(), })); -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - fs: { - readAbsoluteFile: { query: vi.fn() }, - readFileAsBase64: { query: mockReadFileAsBase64 }, - }, - }, -})); +import { TaskCreationSaga } from "./taskCreationSaga"; -vi.mock("@hooks/useRepositoryDirectory", () => ({ - getTaskDirectory: mockGetTaskDirectory, -})); +const host = mockHost as unknown as ITaskCreationHost; -vi.mock("@features/provisioning/stores/provisioningStore", () => ({ - useProvisioningStore: { - getState: () => ({ - setActive: vi.fn(), - clear: vi.fn(), - }), - }, -})); - -vi.mock("@features/panels/store/panelLayoutStore", () => ({ - usePanelLayoutStore: { - getState: () => ({ - addActionTab: vi.fn(), - }), - }, -})); - -vi.mock("@features/sessions/service/service", () => ({ - getSessionService: () => ({ - connectToTask: vi.fn(), - disconnectFromTask: vi.fn(), - }), -})); - -vi.mock("@utils/logger", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - }, -})); - -import { TaskCreationSaga } from "./task-creation"; +const sessionService = { + connectToTask: vi.fn(), + disconnectFromTask: vi.fn(), +} as unknown as SessionService; const createTask = (overrides: Partial<Task> = {}): Task => ({ id: "task-123", @@ -97,10 +65,22 @@ const createRun = (overrides: Partial<TaskRun> = {}): TaskRun => ({ describe("TaskCreationSaga", () => { beforeEach(() => { vi.clearAllMocks(); - mockWorkspaceCreate.mockResolvedValue(undefined); - mockWorkspaceDelete.mockResolvedValue(undefined); - mockGetTaskDirectory.mockResolvedValue(null); - mockReadFileAsBase64.mockResolvedValue(null); + mockHost.createWorkspace.mockResolvedValue({}); + mockHost.deleteWorkspace.mockResolvedValue(undefined); + mockHost.getTaskDirectory.mockResolvedValue(null); + mockHost.getWorkspace.mockResolvedValue(null); + mockHost.getFolders.mockResolvedValue([]); + mockHost.uploadRunAttachments.mockResolvedValue([]); + mockHost.getCloudPromptTransport.mockImplementation( + ( + prompt: string | unknown[], + filePaths: string[] = [], + ): CloudPromptTransport => ({ + filePaths, + messageText: typeof prompt === "string" ? prompt : undefined, + promptText: typeof prompt === "string" ? prompt : "", + }), + ); }); it("waits for the cloud run response before surfacing the task", async () => { @@ -122,6 +102,8 @@ describe("TaskCreationSaga", () => { sendRunCommand: sendRunCommandMock, updateTask: vi.fn(), } as never, + host, + sessionService, onTaskReady, }); @@ -176,39 +158,15 @@ describe("TaskCreationSaga", () => { const createTaskMock = vi.fn().mockResolvedValue(createdTask); const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - const prepareTaskRunArtifactUploadsMock = vi.fn().mockResolvedValue([ - { - id: "artifact-1", - name: "test.txt", - type: "user_attachment", - size: 5, - source: "posthog_code", - content_type: "text/plain", - storage_path: "tasks/artifacts/test.txt", - expires_in: 3600, - presigned_post: { - url: "https://uploads.example.com", - fields: { key: "tasks/artifacts/test.txt" }, - }, - }, - ]); - const finalizeTaskRunArtifactUploadsMock = vi.fn().mockResolvedValue([ - { - id: "artifact-1", - name: "test.txt", - type: "user_attachment", - size: 5, - source: "posthog_code", - content_type: "text/plain", - storage_path: "tasks/artifacts/test.txt", - uploaded_at: "2026-04-16T00:00:00Z", - }, - ]); const sendRunCommandMock = vi.fn(); const onTaskReady = vi.fn(); - mockReadFileAsBase64.mockResolvedValue("aGVsbG8="); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true } as Response)); + mockHost.getCloudPromptTransport.mockReturnValue({ + filePaths: ["/tmp/test.txt"], + messageText: "read this file", + promptText: "read this file\n\nAttached files: test.txt", + }); + mockHost.uploadRunAttachments.mockResolvedValue(["artifact-1"]); const saga = new TaskCreationSaga({ posthogClient: { @@ -217,11 +175,11 @@ describe("TaskCreationSaga", () => { getTask: vi.fn(), createTaskRun: createTaskRunMock, startTaskRun: startTaskRunMock, - prepareTaskRunArtifactUploads: prepareTaskRunArtifactUploadsMock, - finalizeTaskRunArtifactUploads: finalizeTaskRunArtifactUploadsMock, sendRunCommand: sendRunCommandMock, updateTask: vi.fn(), } as never, + host, + sessionService, onTaskReady, }); @@ -260,16 +218,22 @@ describe("TaskCreationSaga", () => { signalReportId: undefined, initialPermissionMode: "auto", }); + expect(mockHost.uploadRunAttachments).toHaveBeenCalledWith( + expect.anything(), + "task-123", + "run-123", + ["/tmp/test.txt"], + ); expect(startTaskRunMock).toHaveBeenCalledWith("task-123", "run-123", { pendingUserMessage: "read this file", pendingUserArtifactIds: ["artifact-1"], }); expect(sendRunCommandMock).not.toHaveBeenCalled(); expect(createTaskRunMock.mock.invocationCallOrder[0]).toBeLessThan( - prepareTaskRunArtifactUploadsMock.mock.invocationCallOrder[0], + mockHost.uploadRunAttachments.mock.invocationCallOrder[0], ); expect( - prepareTaskRunArtifactUploadsMock.mock.invocationCallOrder[0], + mockHost.uploadRunAttachments.mock.invocationCallOrder[0], ).toBeLessThan(startTaskRunMock.mock.invocationCallOrder[0]); expect(startTaskRunMock.mock.invocationCallOrder[0]).toBeLessThan( onTaskReady.mock.invocationCallOrder[0], @@ -295,6 +259,8 @@ describe("TaskCreationSaga", () => { sendRunCommand: vi.fn(), updateTask: vi.fn(), } as never, + host, + sessionService, }); const result = await saga.run({ @@ -342,6 +308,8 @@ describe("TaskCreationSaga", () => { sendRunCommand: vi.fn(), updateTask: vi.fn(), } as never, + host, + sessionService, }); const result = await saga.run({ @@ -388,6 +356,8 @@ describe("TaskCreationSaga", () => { sendRunCommand: vi.fn(), updateTask: vi.fn(), } as never, + host, + sessionService, }); await saga.run({ @@ -422,6 +392,8 @@ describe("TaskCreationSaga", () => { sendRunCommand: vi.fn(), updateTask: vi.fn(), } as never, + host, + sessionService, }); await saga.run({ @@ -462,6 +434,8 @@ describe("TaskCreationSaga", () => { sendRunCommand: vi.fn(), updateTask: vi.fn(), } as never, + host, + sessionService, }); const result = await saga.run({ diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/packages/core/src/task-detail/taskCreationSaga.ts similarity index 65% rename from apps/code/src/renderer/sagas/task/task-creation.ts rename to packages/core/src/task-detail/taskCreationSaga.ts index 3b7ad84d15..a24a3d9924 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -1,73 +1,27 @@ -import { buildPromptBlocks } from "@features/editor/utils/prompt-builder"; -import { DEFAULT_PANEL_IDS } from "@features/panels/constants/panelConstants"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { useProvisioningStore } from "@features/provisioning/stores/provisioningStore"; -import { - type ConnectParams, - getSessionService, -} from "@features/sessions/service/service"; -import { - getCloudPromptTransport, - uploadRunAttachments, -} from "@features/sessions/utils/cloudArtifacts"; -import { getTaskDirectory } from "@hooks/useRepositoryDirectory"; +import { buildPromptBlocks } from "@posthog/core/editor/prompt-builder"; import type { - Workspace, - WorkspaceMode, -} from "@main/services/workspace/schemas"; -import { Saga, type SagaLogger } from "@posthog/shared"; -import type { PostHogAPIClient } from "@renderer/api/posthogClient"; -import { trpcClient } from "@renderer/trpc"; -import { getTaskRepository } from "@renderer/utils/repository"; + ConnectParams, + SessionService, +} from "@posthog/core/sessions/sessionService"; +import { + getTaskRepository, + Saga, + type SagaLogger, + type TaskCreationInput, + type TaskCreationOutput, + type Workspace, +} from "@posthog/shared"; import { - type ExecutionMode, SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP, type Task, -} from "@shared/types"; -import type { CloudRunSource, PrAuthorshipMode } from "@shared/types/cloud"; -import { logger } from "@utils/logger"; - -const log = logger.scope("task-creation-saga"); - -// Adapt our logger to SagaLogger interface -const sagaLogger: SagaLogger = { - info: (message, data) => log.info(message, data), - debug: (message, data) => log.debug(message, data), - error: (message, data) => log.error(message, data), - warn: (message, data) => log.warn(message, data), -}; - -export interface TaskCreationInput { - // For opening existing task - taskId?: string; - // For creating new task (required if no taskId) - content?: string; - taskDescription?: string; - filePaths?: string[]; - repoPath?: string; - repository?: string | null; - workspaceMode?: WorkspaceMode; - branch?: string | null; - githubIntegrationId?: number; - githubUserIntegrationId?: string; - executionMode?: ExecutionMode; - adapter?: "claude" | "codex"; - model?: string; - reasoningLevel?: string; - environmentId?: string; - sandboxEnvironmentId?: string; - cloudPrAuthorshipMode?: PrAuthorshipMode; - cloudRunSource?: CloudRunSource; - signalReportId?: string; -} - -export interface TaskCreationOutput { - task: Task; - workspace: Workspace | null; -} +} from "@posthog/shared/domain-types"; +import type { TaskCreationApiClient } from "./taskCreationApiClient"; +import type { ITaskCreationHost } from "./taskCreationHost"; export interface TaskCreationDeps { - posthogClient: PostHogAPIClient; + posthogClient: TaskCreationApiClient; + host: ITaskCreationHost; + sessionService: SessionService; onTaskReady?: (output: TaskCreationOutput) => void; } @@ -77,16 +31,16 @@ export class TaskCreationSaga extends Saga< > { readonly sagaName = "TaskCreationSaga"; - constructor(private deps: TaskCreationDeps) { - super(sagaLogger); + constructor( + private deps: TaskCreationDeps, + logger?: SagaLogger, + ) { + super(logger); } protected async execute( input: TaskCreationInput, ): Promise<TaskCreationOutput> { - // Step 1: Get or create task - // For new tasks, start folder registration in parallel with task creation - // since folder_registration only needs repoPath (from input), not task.id const taskId = input.taskId; const folderPromise = !taskId && input.repoPath @@ -103,22 +57,20 @@ export class TaskCreationSaga extends Saga< const repoPath = input.repoPath ?? (await this.readOnlyStep("resolve_repo_path", () => - getTaskDirectory(task.id, repoKey ?? undefined), + this.deps.host.getTaskDirectory(task.id, repoKey ?? undefined), )); - // Step 3: Resolve workspaceMode - input takes precedence, then derive from task const workspaceMode = input.workspaceMode ?? (task.latest_run?.environment === "cloud" ? "cloud" : "local"); - // Step 4: Create workspace if we have a directory let workspace: Workspace | null = null; const branch = input.branch ?? task.latest_run?.branch ?? null; const hasProvisioning = workspaceMode === "worktree" && !!repoPath && !input.taskId; if (hasProvisioning) { - useProvisioningStore.getState().setActive(task.id); + this.deps.host.setProvisioningActive(task.id); if (this.deps.onTaskReady) { this.deps.onTaskReady({ task, workspace }); } @@ -134,7 +86,7 @@ export class TaskCreationSaga extends Saga< const workspaceInfo = await this.step({ name: "workspace_creation", execute: async () => { - return trpcClient.workspace.create.mutate({ + return this.deps.host.createWorkspace({ taskId: task.id, mainRepoPath: repoPath, folderId: folder.id, @@ -144,8 +96,10 @@ export class TaskCreationSaga extends Saga< }); }, rollback: async () => { - log.info("Rolling back: deleting workspace", { taskId: task.id }); - await trpcClient.workspace.delete.mutate({ + this.log.info("Rolling back: deleting workspace", { + taskId: task.id, + }); + await this.deps.host.deleteWorkspace({ taskId: task.id, mainRepoPath: repoPath, }); @@ -169,7 +123,7 @@ export class TaskCreationSaga extends Saga< await this.step({ name: "cloud_workspace_creation", execute: async () => { - return trpcClient.workspace.create.mutate({ + return this.deps.host.createWorkspace({ taskId: task.id, mainRepoPath: "", folderId: "", @@ -179,10 +133,10 @@ export class TaskCreationSaga extends Saga< }); }, rollback: async () => { - log.info("Rolling back: deleting cloud workspace", { + this.log.info("Rolling back: deleting cloud workspace", { taskId: task.id, }); - await trpcClient.workspace.delete.mutate({ + await this.deps.host.deleteWorkspace({ taskId: task.id, mainRepoPath: "", }); @@ -210,7 +164,7 @@ export class TaskCreationSaga extends Saga< } if (hasProvisioning) { - useProvisioningStore.getState().clear(task.id); + this.deps.host.clearProvisioning(task.id); } if ( @@ -227,7 +181,6 @@ export class TaskCreationSaga extends Saga< ); } - // Step 5: Start cloud run (only for new cloud tasks) if (shouldStartCloudRun) { task = await this.step({ name: "cloud_run", @@ -237,7 +190,10 @@ export class TaskCreationSaga extends Saga< const transport = (input.content || input.filePaths?.length) && workspaceMode === "cloud" - ? getCloudPromptTransport(input.content ?? "", input.filePaths) + ? this.deps.host.getCloudPromptTransport( + input.content ?? "", + input.filePaths, + ) : null; const taskRun = await this.deps.posthogClient.createTaskRun(task.id, { environment: "cloud", @@ -260,7 +216,7 @@ export class TaskCreationSaga extends Saga< } const pendingUserArtifactIds = transport - ? await uploadRunAttachments( + ? await this.deps.host.uploadRunAttachments( this.deps.posthogClient, task.id, taskRun.id, @@ -277,7 +233,9 @@ export class TaskCreationSaga extends Saga< }); }, rollback: async () => { - log.info("Rolling back: cloud run (no-op)", { taskId: task.id }); + this.log.info("Rolling back: cloud run (no-op)", { + taskId: task.id, + }); }, }); @@ -286,15 +244,10 @@ export class TaskCreationSaga extends Saga< } } - // Step 7: Connect to session - // Cloud create: skip local session — the sandbox handles execution const agentCwd = workspace?.worktreePath ?? workspace?.folderPath ?? repoPath; const isCloudCreate = !input.taskId && workspaceMode === "cloud"; - const shouldConnect = - !isCloudCreate && - (!!input.taskId || // Open: always connect to load chat history - !!agentCwd); // Local create: always connect if we have a cwd + const shouldConnect = !isCloudCreate && (!!input.taskId || !!agentCwd); if (shouldConnect) { const initialPrompt = @@ -311,9 +264,6 @@ export class TaskCreationSaga extends Saga< await this.step({ name: "agent_session", execute: async () => { - // Fire-and-forget for both open and create paths. - // The UI handles "connecting" state with a spinner (TaskLogsPanel), - // so we don't need to block the saga on the full reconnect chain. const connectParams: ConnectParams = { task, repoPath: agentCwd ?? "", @@ -326,12 +276,14 @@ export class TaskCreationSaga extends Saga< if (input.reasoningLevel) connectParams.reasoningLevel = input.reasoningLevel; - getSessionService().connectToTask(connectParams); + this.deps.sessionService.connectToTask(connectParams); return { taskId: task.id }; }, rollback: async ({ taskId }) => { - log.info("Rolling back: disconnecting agent session", { taskId }); - await getSessionService().disconnectFromTask(taskId); + this.log.info("Rolling back: disconnecting agent session", { + taskId, + }); + await this.deps.sessionService.disconnectFromTask(taskId); }, }); } @@ -340,13 +292,11 @@ export class TaskCreationSaga extends Saga< } private async resolveFolder(repoPath: string) { - const folders = await trpcClient.folders.getFolders.query(); + const folders = await this.deps.host.getFolders(); let existingFolder = folders.find((f) => f.path === repoPath); if (!existingFolder) { - existingFolder = await trpcClient.folders.addFolder.mutate({ - folderPath: repoPath, - }); + existingFolder = await this.deps.host.addFolder({ folderPath: repoPath }); } return existingFolder; } @@ -357,23 +307,20 @@ export class TaskCreationSaga extends Saga< repoPath: string, worktreePath: string, ): void { - trpcClient.environment.get - .query({ repoPath, id: environmentId }) + this.deps.host + .getEnvironment({ repoPath, id: environmentId }) .then((env) => { if (!env?.setup?.script) return; - const actionId = `setup-${environmentId}-${Date.now()}`; - usePanelLayoutStore - .getState() - .addActionTab(taskId, DEFAULT_PANEL_IDS.MAIN_PANEL, { - actionId, - command: env.setup.script, - cwd: worktreePath, - label: `Setup: ${env.name}`, - }); + this.deps.host.dispatchSetupAction({ + taskId, + command: env.setup.script, + cwd: worktreePath, + label: `Setup: ${env.name}`, + }); }) .catch((error) => { - log.error("Failed to dispatch environment setup script", { + this.log.error("Failed to dispatch environment setup script", { taskId, environmentId, error, @@ -387,9 +334,7 @@ export class TaskCreationSaga extends Saga< const repoPathForDetection = input.repoPath; if (!repository && repoPathForDetection) { const detected = await this.readOnlyStep("repo_detection", () => - trpcClient.git.detectRepo.query({ - directoryPath: repoPathForDetection, - }), + this.deps.host.detectRepo({ directoryPath: repoPathForDetection }), ); if (detected) { repository = `${detected.organization}/${detected.repository}`; @@ -424,7 +369,9 @@ export class TaskCreationSaga extends Saga< return result as unknown as Task; }, rollback: async (createdTask) => { - log.info("Rolling back: deleting task", { taskId: createdTask.id }); + this.log.info("Rolling back: deleting task", { + taskId: createdTask.id, + }); await this.deps.posthogClient.deleteTask(createdTask.id); }, }); diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts new file mode 100644 index 0000000000..0f5bf8047b --- /dev/null +++ b/packages/core/src/task-detail/taskInput.ts @@ -0,0 +1,64 @@ +import { buildCloudTaskDescription } from "@posthog/core/editor/cloud-prompt"; +import type { TaskCreationInput, WorkspaceMode } from "@posthog/shared"; +import type { ExecutionMode } from "@posthog/shared/domain-types"; + +export interface PrepareTaskInputOptions { + selectedDirectory: string; + selectedRepository?: string | null; + githubIntegrationId?: number; + githubUserIntegrationId?: string; + workspaceMode: WorkspaceMode; + branch?: string | null; + executionMode?: ExecutionMode; + adapter?: "claude" | "codex"; + model?: string; + reasoningLevel?: string; + environmentId?: string | null; + sandboxEnvironmentId?: string; + signalReportId?: string; +} + +export function prepareTaskInput( + serializedContent: string, + filePaths: string[], + options: PrepareTaskInputOptions, +): TaskCreationInput { + const isCloud = options.workspaceMode === "cloud"; + return { + content: serializedContent, + taskDescription: isCloud + ? buildCloudTaskDescription(serializedContent, filePaths) + : undefined, + filePaths, + repoPath: isCloud ? undefined : options.selectedDirectory, + repository: isCloud ? options.selectedRepository : undefined, + githubIntegrationId: options.githubIntegrationId, + githubUserIntegrationId: options.githubUserIntegrationId, + workspaceMode: options.workspaceMode, + branch: options.branch, + executionMode: options.executionMode, + adapter: options.adapter, + model: options.model, + reasoningLevel: options.reasoningLevel, + environmentId: options.environmentId ?? undefined, + sandboxEnvironmentId: options.sandboxEnvironmentId, + cloudPrAuthorshipMode: + options.signalReportId && isCloud ? "user" : undefined, + cloudRunSource: + options.signalReportId && isCloud ? "signal_report" : undefined, + signalReportId: options.signalReportId, + }; +} + +const ERROR_TITLES: Record<string, string> = { + repo_detection: "Failed to detect repository", + task_creation: "Failed to create task", + workspace_creation: "Failed to create workspace", + cloud_prompt_preparation: "Failed to prepare cloud attachments", + cloud_run: "Failed to start cloud execution", + agent_session: "Failed to start agent session", +}; + +export function getErrorTitle(failedStep: string): string { + return ERROR_TITLES[failedStep] ?? "Task creation failed"; +} diff --git a/packages/core/src/task-detail/taskService.ts b/packages/core/src/task-detail/taskService.ts new file mode 100644 index 0000000000..3f17afd934 --- /dev/null +++ b/packages/core/src/task-detail/taskService.ts @@ -0,0 +1,173 @@ +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import type { + SagaResult, + TaskCreationInput, + TaskCreationOutput, +} from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import { inject, injectable } from "inversify"; +import { TASK_CREATION_EFFECTS, TASK_CREATION_HOST } from "./identifiers"; +import type { TaskCreationEffects } from "./taskCreationEffects"; +import type { ITaskCreationHost } from "./taskCreationHost"; +import { TaskCreationSaga } from "./taskCreationSaga"; + +export type { TaskCreationInput, TaskCreationOutput }; +export { TASK_SERVICE } from "./identifiers"; + +export type CreateTaskResult = SagaResult<TaskCreationOutput>; + +@injectable() +export class TaskService { + constructor( + @inject(TASK_CREATION_HOST) + private readonly host: ITaskCreationHost, + @inject(SESSION_SERVICE) + private readonly sessionService: SessionService, + @inject(TASK_CREATION_EFFECTS) + private readonly effects: TaskCreationEffects, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, + ) { + this.log = workbenchLogger.scope("task-service"); + } + + private readonly log: ReturnType<WorkbenchLogger["scope"]>; + + public async createTask( + input: TaskCreationInput, + onTaskReady?: (output: TaskCreationOutput) => void, + ): Promise<CreateTaskResult> { + this.log.info("Creating task", { + workspaceMode: input.workspaceMode, + hasContent: !!input.content, + hasRepo: !!input.repository, + }); + + if (!input.content?.trim()) { + return { + success: false, + error: "Task description cannot be empty", + failedStep: "validation", + }; + } + + const posthogClient = await this.host.getAuthenticatedClient(); + if (!posthogClient) { + return { + success: false, + error: "Not authenticated", + failedStep: "validation", + }; + } + + const saga = new TaskCreationSaga( + { + posthogClient, + host: this.host, + sessionService: this.sessionService, + onTaskReady: onTaskReady + ? (output) => { + this.effects.onWorkspaceCreated(output); + this.effects.onCreateSuccess(output, input); + onTaskReady(output); + } + : undefined, + }, + this.log, + ); + + const result = await saga.run(input); + + if (result.success) { + this.effects.onWorkspaceCreated(result.data); + if (!onTaskReady) { + this.effects.onCreateSuccess(result.data, input); + } + } + + return result; + } + + public async openTask( + taskId: string, + taskRunId?: string, + ): Promise<CreateTaskResult> { + this.log.info("Opening existing task", { taskId, taskRunId }); + + const posthogClient = await this.host.getAuthenticatedClient(); + if (!posthogClient) { + return { + success: false, + error: "Not authenticated", + failedStep: "validation", + }; + } + + const existingWorkspace = await this.host.getWorkspace(taskId); + if (existingWorkspace) { + this.log.info("Workspace already exists, fetching task only", { taskId }); + try { + const task = await posthogClient.getTask(taskId); + + if (taskRunId) { + this.log.info("Fetching specific task run", { taskId, taskRunId }); + const run = await posthogClient.getTaskRun(taskId, taskRunId); + task.latest_run = run; + } + + return { + success: true, + data: { + task: task as unknown as Task, + workspace: existingWorkspace, + }, + }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to fetch task", + failedStep: "fetch_task", + }; + } + } + + const saga = new TaskCreationSaga( + { + posthogClient, + host: this.host, + sessionService: this.sessionService, + }, + this.log, + ); + const result = await saga.run({ taskId }); + + if (result.success) { + this.effects.onWorkspaceCreated(result.data); + this.effects.onCreateSuccess(result.data); + + if (taskRunId && result.data.task) { + try { + this.log.info("Fetching specific task run for new workspace", { + taskId, + taskRunId, + }); + const run = await posthogClient.getTaskRun(taskId, taskRunId); + result.data.task.latest_run = run; + } catch (error) { + this.log.warn("Failed to fetch specific task run, using latest", { + taskId, + taskRunId, + error, + }); + } + } + } + + return result; + } +} diff --git a/packages/core/src/task-detail/workspaceSetupSaga.test.ts b/packages/core/src/task-detail/workspaceSetupSaga.test.ts new file mode 100644 index 0000000000..79bc6971f6 --- /dev/null +++ b/packages/core/src/task-detail/workspaceSetupSaga.test.ts @@ -0,0 +1,79 @@ +import type { WorkbenchLogger } from "@posthog/di/logger"; +import type { Workspace } from "@posthog/shared"; +import { describe, expect, it, vi } from "vitest"; +import { + type WorkspaceSetupExecutor, + WorkspaceSetupSaga, +} from "./workspaceSetupSaga"; + +function makeLogger(): WorkbenchLogger { + const scoped = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + return { ...scoped, scope: () => scoped }; +} + +function makeExecutor( + overrides: Partial<WorkspaceSetupExecutor> = {}, +): WorkspaceSetupExecutor { + return { + addFolder: vi.fn().mockResolvedValue(undefined), + ensureWorkspace: vi.fn().mockResolvedValue({} as Workspace), + ...overrides, + }; +} + +describe("WorkspaceSetupSaga.setupWorkspace", () => { + it("adds the folder then ensures the workspace in order", async () => { + const calls: string[] = []; + const executor = makeExecutor({ + addFolder: vi.fn(async () => { + calls.push("addFolder"); + }), + ensureWorkspace: vi.fn(async () => { + calls.push("ensureWorkspace"); + return {} as Workspace; + }), + }); + const saga = new WorkspaceSetupSaga(makeLogger()); + + const result = await saga.setupWorkspace(executor, "task-1", "/repo"); + + expect(result).toEqual({ success: true }); + expect(calls).toEqual(["addFolder", "ensureWorkspace"]); + expect(executor.ensureWorkspace).toHaveBeenCalledWith( + "task-1", + "/repo", + "worktree", + ); + }); + + it("returns a failure when addFolder throws", async () => { + const executor = makeExecutor({ + addFolder: vi.fn().mockRejectedValue(new Error("boom")), + }); + const saga = new WorkspaceSetupSaga(makeLogger()); + + const result = await saga.setupWorkspace(executor, "task-1", "/repo"); + + expect(result).toEqual({ + success: false, + error: "Failed to set up workspace", + }); + expect(executor.ensureWorkspace).not.toHaveBeenCalled(); + }); + + it("returns a failure when ensureWorkspace throws", async () => { + const executor = makeExecutor({ + ensureWorkspace: vi.fn().mockRejectedValue(new Error("boom")), + }); + const saga = new WorkspaceSetupSaga(makeLogger()); + + const result = await saga.setupWorkspace(executor, "task-1", "/repo"); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/core/src/task-detail/workspaceSetupSaga.ts b/packages/core/src/task-detail/workspaceSetupSaga.ts new file mode 100644 index 0000000000..f4da6819a2 --- /dev/null +++ b/packages/core/src/task-detail/workspaceSetupSaga.ts @@ -0,0 +1,46 @@ +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import type { Workspace, WorkspaceMode } from "@posthog/shared"; +import { inject, injectable } from "inversify"; + +export { WORKSPACE_SETUP_SAGA } from "./identifiers"; + +export interface WorkspaceSetupExecutor { + addFolder(path: string): Promise<unknown>; + ensureWorkspace( + taskId: string, + path: string, + mode: WorkspaceMode, + ): Promise<Workspace | null>; +} + +export type WorkspaceSetupResult = + | { success: true } + | { success: false; error: string }; + +@injectable() +export class WorkspaceSetupSaga { + private readonly log: ReturnType<WorkbenchLogger["scope"]>; + + constructor( + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, + ) { + this.log = workbenchLogger.scope("task-service"); + } + + public async setupWorkspace( + executor: WorkspaceSetupExecutor, + taskId: string, + path: string, + ): Promise<WorkspaceSetupResult> { + try { + await executor.addFolder(path); + await executor.ensureWorkspace(taskId, path, "worktree"); + this.log.info("Workspace setup complete", { taskId, path }); + return { success: true }; + } catch (error) { + this.log.error("Failed to set up workspace", { error }); + return { success: false, error: "Failed to set up workspace" }; + } + } +} diff --git a/packages/core/src/tasks/contextMenuActions.test.ts b/packages/core/src/tasks/contextMenuActions.test.ts new file mode 100644 index 0000000000..1ee3cc38ab --- /dev/null +++ b/packages/core/src/tasks/contextMenuActions.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { + resolveExternalAppPath, + resolveTaskContextMenuIntent, +} from "./contextMenuActions"; + +describe("resolveTaskContextMenuIntent", () => { + it("maps suspend to restore when already suspended", () => { + expect( + resolveTaskContextMenuIntent({ type: "suspend" }, { isSuspended: true }), + ).toEqual({ type: "restore" }); + }); + + it("maps suspend to suspend when not suspended", () => { + expect( + resolveTaskContextMenuIntent({ type: "suspend" }, { isSuspended: false }), + ).toEqual({ type: "suspend" }); + }); + + it("passes through simple actions", () => { + expect(resolveTaskContextMenuIntent({ type: "rename" }, {})).toEqual({ + type: "rename", + }); + expect(resolveTaskContextMenuIntent({ type: "delete" }, {})).toEqual({ + type: "delete", + }); + expect(resolveTaskContextMenuIntent({ type: "archive-prior" }, {})).toEqual( + { type: "archive-prior" }, + ); + }); + + it("carries the external-app action payload", () => { + expect( + resolveTaskContextMenuIntent( + { type: "external-app", action: { type: "copy-path" } }, + {}, + ), + ).toEqual({ + type: "external-app", + action: { type: "copy-path" }, + }); + }); +}); + +describe("resolveExternalAppPath", () => { + it("prefers the worktree path", () => { + expect(resolveExternalAppPath("/wt", "/folder")).toBe("/wt"); + }); + + it("falls back to the folder path", () => { + expect(resolveExternalAppPath(undefined, "/folder")).toBe("/folder"); + }); + + it("returns undefined when neither present", () => { + expect(resolveExternalAppPath(undefined, undefined)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/tasks/contextMenuActions.ts b/packages/core/src/tasks/contextMenuActions.ts new file mode 100644 index 0000000000..cfc0a926f8 --- /dev/null +++ b/packages/core/src/tasks/contextMenuActions.ts @@ -0,0 +1,46 @@ +import type { + ExternalAppAction, + TaskAction, +} from "@posthog/core/context-menu/schemas"; + +export type TaskContextMenuIntent = + | { type: "rename" } + | { type: "pin" } + | { type: "suspend" } + | { type: "restore" } + | { type: "archive" } + | { type: "archive-prior" } + | { type: "delete" } + | { type: "add-to-command-center" } + | { type: "external-app"; action: ExternalAppAction }; + +export function resolveTaskContextMenuIntent( + action: TaskAction, + flags: { isSuspended?: boolean }, +): TaskContextMenuIntent { + switch (action.type) { + case "rename": + return { type: "rename" }; + case "pin": + return { type: "pin" }; + case "suspend": + return flags.isSuspended ? { type: "restore" } : { type: "suspend" }; + case "archive": + return { type: "archive" }; + case "archive-prior": + return { type: "archive-prior" }; + case "delete": + return { type: "delete" }; + case "add-to-command-center": + return { type: "add-to-command-center" }; + case "external-app": + return { type: "external-app", action: action.action }; + } +} + +export function resolveExternalAppPath( + worktreePath: string | undefined, + folderPath: string | undefined, +): string | undefined { + return worktreePath ?? folderPath; +} diff --git a/packages/core/src/tasks/filters.test.ts b/packages/core/src/tasks/filters.test.ts new file mode 100644 index 0000000000..605801caba --- /dev/null +++ b/packages/core/src/tasks/filters.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + type ActiveFilters, + addFilter, + getDefaultOperator, + toggleFilter, + toggleFilterOperator, + toggleOperator, + updateFilter, +} from "./filters"; + +describe("getDefaultOperator", () => { + it("returns after for created_at", () => { + expect(getDefaultOperator("created_at")).toBe("after"); + }); + + it("returns is for other categories", () => { + expect(getDefaultOperator("status")).toBe("is"); + expect(getDefaultOperator("repository")).toBe("is"); + }); +}); + +describe("toggleOperator", () => { + it("flips before/after for created_at", () => { + expect(toggleOperator("created_at", "before")).toBe("after"); + expect(toggleOperator("created_at", "after")).toBe("before"); + }); + + it("flips is/is_not for other categories", () => { + expect(toggleOperator("status", "is")).toBe("is_not"); + expect(toggleOperator("status", "is_not")).toBe("is"); + }); +}); + +describe("toggleFilter", () => { + it("adds a new filter with the default operator", () => { + const next = toggleFilter({}, "status", "queued"); + expect(next.status).toEqual([{ value: "queued", operator: "is" }]); + }); + + it("removes an existing filter and drops the empty category", () => { + const prev: ActiveFilters = { + status: [{ value: "queued", operator: "is" }], + }; + const next = toggleFilter(prev, "status", "queued"); + expect(next.status).toBeUndefined(); + }); + + it("keeps remaining filters when removing one of several", () => { + const prev: ActiveFilters = { + status: [ + { value: "queued", operator: "is" }, + { value: "failed", operator: "is" }, + ], + }; + const next = toggleFilter(prev, "status", "queued"); + expect(next.status).toEqual([{ value: "failed", operator: "is" }]); + }); +}); + +describe("addFilter", () => { + it("appends a filter without dedup", () => { + const prev: ActiveFilters = { + status: [{ value: "queued", operator: "is" }], + }; + const next = addFilter(prev, "status", "failed"); + expect(next.status).toHaveLength(2); + }); +}); + +describe("updateFilter", () => { + it("replaces the matching value", () => { + const prev: ActiveFilters = { + repository: [{ value: "old", operator: "is" }], + }; + const next = updateFilter(prev, "repository", "old", "new"); + expect(next.repository).toEqual([{ value: "new", operator: "is" }]); + }); + + it("returns unchanged when value is missing", () => { + const prev: ActiveFilters = { + repository: [{ value: "old", operator: "is" }], + }; + const next = updateFilter(prev, "repository", "missing", "new"); + expect(next).toBe(prev); + }); +}); + +describe("toggleFilterOperator", () => { + it("flips the operator of the matching value", () => { + const prev: ActiveFilters = { + status: [{ value: "queued", operator: "is" }], + }; + const next = toggleFilterOperator(prev, "status", "queued"); + expect(next.status).toEqual([{ value: "queued", operator: "is_not" }]); + }); + + it("returns unchanged when value is missing", () => { + const prev: ActiveFilters = { + status: [{ value: "queued", operator: "is" }], + }; + const next = toggleFilterOperator(prev, "status", "missing"); + expect(next).toBe(prev); + }); +}); diff --git a/packages/core/src/tasks/filters.ts b/packages/core/src/tasks/filters.ts new file mode 100644 index 0000000000..77db19ec2c --- /dev/null +++ b/packages/core/src/tasks/filters.ts @@ -0,0 +1,144 @@ +export type OrderByField = + | "created_at" + | "status" + | "title" + | "repository" + | "working_directory" + | "source"; + +export type OrderDirection = "asc" | "desc"; + +export type GroupByField = + | "none" + | "status" + | "creator" + | "source" + | "repository"; + +export type FilterCategory = + | "status" + | "source" + | "creator" + | "repository" + | "created_at"; + +export type FilterOperator = "is" | "is_not" | "before" | "after"; + +export interface FilterValue { + value: string; + operator: FilterOperator; +} + +export type ActiveFilters = Partial<Record<FilterCategory, FilterValue[]>>; + +export type FilterMatchMode = "all" | "any"; + +export const TASK_STATUS_ORDER: string[] = [ + "failed", + "in_progress", + "queued", + "completed", + "backlog", +]; + +export function getDefaultOperator(category: FilterCategory): FilterOperator { + return category === "created_at" ? "after" : "is"; +} + +export function toggleOperator( + category: FilterCategory, + operator: FilterOperator, +): FilterOperator { + if (category === "created_at") { + return operator === "before" ? "after" : "before"; + } + return operator === "is" ? "is_not" : "is"; +} + +export function toggleFilter( + prevFilters: ActiveFilters, + category: FilterCategory, + value: string, + operator?: FilterOperator, +): ActiveFilters { + const currentFilters = prevFilters[category] || []; + const existingFilter = currentFilters.find((f) => f.value === value); + + if (existingFilter) { + const newFilters = currentFilters.filter((f) => f.value !== value); + return { + ...prevFilters, + [category]: newFilters.length > 0 ? newFilters : undefined, + }; + } + + return { + ...prevFilters, + [category]: [ + ...currentFilters, + { value, operator: operator ?? getDefaultOperator(category) }, + ], + }; +} + +export function addFilter( + prevFilters: ActiveFilters, + category: FilterCategory, + value: string, + operator?: FilterOperator, +): ActiveFilters { + return { + ...prevFilters, + [category]: [ + ...(prevFilters[category] || []), + { value, operator: operator ?? getDefaultOperator(category) }, + ], + }; +} + +export function updateFilter( + prevFilters: ActiveFilters, + category: FilterCategory, + oldValue: string, + newValue: string, +): ActiveFilters { + const currentFilters = prevFilters[category] || []; + const filterIndex = currentFilters.findIndex((f) => f.value === oldValue); + + if (filterIndex === -1) return prevFilters; + + const updatedFilters = [...currentFilters]; + updatedFilters[filterIndex] = { + ...updatedFilters[filterIndex], + value: newValue, + }; + + return { + ...prevFilters, + [category]: updatedFilters, + }; +} + +export function toggleFilterOperator( + prevFilters: ActiveFilters, + category: FilterCategory, + value: string, +): ActiveFilters { + const currentFilters = prevFilters[category] || []; + const filterIndex = currentFilters.findIndex((f) => f.value === value); + + if (filterIndex === -1) return prevFilters; + + const updatedFilters = [...currentFilters]; + const currentOperator = updatedFilters[filterIndex].operator; + + updatedFilters[filterIndex] = { + ...updatedFilters[filterIndex], + operator: toggleOperator(category, currentOperator), + }; + + return { + ...prevFilters, + [category]: updatedFilters, + }; +} diff --git a/packages/core/src/tasks/identifiers.ts b/packages/core/src/tasks/identifiers.ts new file mode 100644 index 0000000000..c59db8e995 --- /dev/null +++ b/packages/core/src/tasks/identifiers.ts @@ -0,0 +1,38 @@ +export const TASK_DELETION_SERVICE = Symbol.for( + "posthog.core.tasks.deletionService", +); +export const TASK_DELETION_WORKSPACE_CLIENT = Symbol.for( + "posthog.core.tasks.deletionWorkspaceClient", +); +export const TASK_DELETION_HOST = Symbol.for("posthog.core.tasks.deletionHost"); + +export interface TaskWorkspace { + worktreePath?: string | null; + folderPath?: string; +} + +export interface ITaskDeletionWorkspaceClient { + getAll(): Promise<Record<string, TaskWorkspace>>; + delete(input: { taskId: string; mainRepoPath: string }): Promise<unknown>; +} + +export interface TaskDeletionFocusSession { + worktreePath?: string | null; +} + +export interface TaskDeletionView { + type: string; + data?: { id?: string } | null; +} + +export interface ITaskDeletionHost { + getSession(): TaskDeletionFocusSession | null; + disableFocus(): Promise<unknown>; + confirmDeleteTask(input: { + taskTitle: string; + hasWorktree: boolean; + }): Promise<{ confirmed: boolean }>; + unpin(taskId: string): Promise<void>; + getCurrentView(): TaskDeletionView | undefined; + navigateToTaskInput(): void; +} diff --git a/packages/core/src/tasks/taskDelete.test.ts b/packages/core/src/tasks/taskDelete.test.ts new file mode 100644 index 0000000000..a190bcae39 --- /dev/null +++ b/packages/core/src/tasks/taskDelete.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + insertTaskDedup, + removeTaskFromList, + shouldNavigateAwayFromDeletedTask, + shouldUnfocusBeforeDelete, +} from "./taskDelete"; + +describe("shouldUnfocusBeforeDelete", () => { + it("returns false when the workspace has no worktree", () => { + expect( + shouldUnfocusBeforeDelete({ worktreePath: "/a" }, { worktreePath: null }), + ).toBe(false); + }); + + it("returns false when no workspace", () => { + expect(shouldUnfocusBeforeDelete({ worktreePath: "/a" }, null)).toBe(false); + }); + + it("returns true when focus matches the workspace worktree", () => { + expect( + shouldUnfocusBeforeDelete({ worktreePath: "/a" }, { worktreePath: "/a" }), + ).toBe(true); + }); + + it("returns false when focus is on a different worktree", () => { + expect( + shouldUnfocusBeforeDelete({ worktreePath: "/b" }, { worktreePath: "/a" }), + ).toBe(false); + }); + + it("returns false when nothing is focused", () => { + expect(shouldUnfocusBeforeDelete(null, { worktreePath: "/a" })).toBe(false); + }); +}); + +describe("removeTaskFromList", () => { + it("removes the matching task", () => { + const tasks = [{ id: "a" }, { id: "b" }]; + expect(removeTaskFromList(tasks, "a")).toEqual([{ id: "b" }]); + }); + + it("returns undefined when list is undefined", () => { + expect(removeTaskFromList(undefined, "a")).toBeUndefined(); + }); +}); + +describe("insertTaskDedup", () => { + it("prepends a new task", () => { + const tasks = [{ id: "a" }]; + expect(insertTaskDedup(tasks, { id: "b" })).toEqual([ + { id: "b" }, + { id: "a" }, + ]); + }); + + it("skips inserting a duplicate id", () => { + const tasks = [{ id: "a" }]; + expect(insertTaskDedup(tasks, { id: "a" })).toBe(tasks); + }); + + it("returns undefined when list is undefined", () => { + expect(insertTaskDedup(undefined, { id: "a" })).toBeUndefined(); + }); +}); + +describe("shouldNavigateAwayFromDeletedTask", () => { + it("returns true when viewing the deleted task detail", () => { + expect( + shouldNavigateAwayFromDeletedTask( + { type: "task-detail", data: { id: "a" } }, + "a", + ), + ).toBe(true); + }); + + it("returns false for a different detail", () => { + expect( + shouldNavigateAwayFromDeletedTask( + { type: "task-detail", data: { id: "b" } }, + "a", + ), + ).toBe(false); + }); + + it("returns false for other views", () => { + expect(shouldNavigateAwayFromDeletedTask({ type: "inbox" }, "a")).toBe( + false, + ); + }); +}); diff --git a/packages/core/src/tasks/taskDelete.ts b/packages/core/src/tasks/taskDelete.ts new file mode 100644 index 0000000000..1b64a758ec --- /dev/null +++ b/packages/core/src/tasks/taskDelete.ts @@ -0,0 +1,45 @@ +interface IdentifiableTask { + id: string; +} + +interface FocusSessionLike { + worktreePath?: string | null; +} + +interface WorkspaceLike { + worktreePath?: string | null; + folderPath?: string; +} + +export function shouldUnfocusBeforeDelete( + focusSession: FocusSessionLike | null | undefined, + workspace: WorkspaceLike | null | undefined, +): boolean { + if (!workspace?.worktreePath) { + return false; + } + return focusSession?.worktreePath === workspace.worktreePath; +} + +export function removeTaskFromList<T extends IdentifiableTask>( + tasks: T[] | undefined, + taskId: string, +): T[] | undefined { + return tasks?.filter((task) => task.id !== taskId); +} + +export function insertTaskDedup<T extends IdentifiableTask>( + tasks: T[] | undefined, + newTask: T, +): T[] | undefined { + if (!tasks) return tasks; + if (tasks.some((task) => task.id === newTask.id)) return tasks; + return [newTask, ...tasks]; +} + +export function shouldNavigateAwayFromDeletedTask( + view: { type: string; data?: { id?: string } | null } | undefined, + taskId: string, +): boolean { + return view?.type === "task-detail" && view.data?.id === taskId; +} diff --git a/packages/core/src/tasks/taskDeletionService.test.ts b/packages/core/src/tasks/taskDeletionService.test.ts new file mode 100644 index 0000000000..f23395a257 --- /dev/null +++ b/packages/core/src/tasks/taskDeletionService.test.ts @@ -0,0 +1,189 @@ +import type { WorkbenchLogger } from "@posthog/di/logger"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + ITaskDeletionHost, + ITaskDeletionWorkspaceClient, + TaskWorkspace, +} from "./identifiers"; +import { TaskDeletionService } from "./taskDeletionService"; + +function makeDeps(overrides?: { + workspaces?: Record<string, TaskWorkspace>; + focusSession?: { worktreePath?: string | null } | null; + confirmed?: boolean; + view?: { type: string; data?: { id?: string } | null }; +}) { + const workspace: ITaskDeletionWorkspaceClient = { + getAll: vi.fn().mockResolvedValue(overrides?.workspaces ?? {}), + delete: vi.fn().mockResolvedValue(undefined), + }; + const host: ITaskDeletionHost = { + getSession: vi.fn().mockReturnValue(overrides?.focusSession ?? null), + disableFocus: vi.fn().mockResolvedValue(undefined), + confirmDeleteTask: vi + .fn() + .mockResolvedValue({ confirmed: overrides?.confirmed ?? true }), + unpin: vi.fn().mockResolvedValue(undefined), + getCurrentView: vi + .fn() + .mockReturnValue(overrides?.view ?? { type: "inbox" }), + navigateToTaskInput: vi.fn(), + }; + const scoped = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const logger: WorkbenchLogger = { + ...scoped, + scope: vi.fn(() => scoped), + }; + + return { workspace, host, logger, scoped }; +} + +function makeService(deps: ReturnType<typeof makeDeps>) { + return new TaskDeletionService(deps.workspace, deps.host, deps.logger); +} + +describe("TaskDeletionService.deleteTask", () => { + beforeEach(() => vi.clearAllMocks()); + + it("deletes the cloud task when no workspace exists", async () => { + const deps = makeDeps(); + const service = makeService(deps); + const client = { deleteTask: vi.fn().mockResolvedValue("done") }; + + const result = await service.deleteTask(client, "t1"); + + expect(result).toBe("done"); + expect(client.deleteTask).toHaveBeenCalledWith("t1"); + expect(deps.workspace.delete).not.toHaveBeenCalled(); + expect(deps.host.disableFocus).not.toHaveBeenCalled(); + }); + + it("deletes the worktree before the cloud task when a workspace exists", async () => { + const deps = makeDeps({ + workspaces: { t1: { worktreePath: "/wt", folderPath: "/repo" } }, + }); + const service = makeService(deps); + const client = { deleteTask: vi.fn().mockResolvedValue(undefined) }; + + await service.deleteTask(client, "t1"); + + expect(deps.workspace.delete).toHaveBeenCalledWith({ + taskId: "t1", + mainRepoPath: "/repo", + }); + expect(client.deleteTask).toHaveBeenCalledWith("t1"); + }); + + it("unfocuses first when the active focus targets this worktree", async () => { + const deps = makeDeps({ + workspaces: { t1: { worktreePath: "/wt", folderPath: "/repo" } }, + focusSession: { worktreePath: "/wt" }, + }); + const service = makeService(deps); + const client = { deleteTask: vi.fn().mockResolvedValue(undefined) }; + + await service.deleteTask(client, "t1"); + + expect(deps.host.disableFocus).toHaveBeenCalledOnce(); + }); + + it("does not unfocus when focus targets a different worktree", async () => { + const deps = makeDeps({ + workspaces: { t1: { worktreePath: "/wt", folderPath: "/repo" } }, + focusSession: { worktreePath: "/other" }, + }); + const service = makeService(deps); + const client = { deleteTask: vi.fn().mockResolvedValue(undefined) }; + + await service.deleteTask(client, "t1"); + + expect(deps.host.disableFocus).not.toHaveBeenCalled(); + }); + + it("still deletes the cloud task when worktree deletion fails", async () => { + const deps = makeDeps({ + workspaces: { t1: { worktreePath: "/wt", folderPath: "/repo" } }, + }); + deps.workspace.delete = vi.fn().mockRejectedValue(new Error("boom")); + const service = makeService(deps); + const client = { deleteTask: vi.fn().mockResolvedValue("ok") }; + + const result = await service.deleteTask(client, "t1"); + + expect(result).toBe("ok"); + expect(deps.scoped.error).toHaveBeenCalled(); + }); +}); + +describe("TaskDeletionService.confirmAndDelete", () => { + beforeEach(() => vi.clearAllMocks()); + + it("short-circuits without unpinning or deleting when declined", async () => { + const deps = makeDeps({ confirmed: false }); + const service = makeService(deps); + const runDelete = vi.fn(); + + const ok = await service.confirmAndDelete( + { taskId: "t1", taskTitle: "Title", hasWorktree: false }, + runDelete, + ); + + expect(ok).toBe(false); + expect(deps.host.confirmDeleteTask).toHaveBeenCalledWith({ + taskTitle: "Title", + hasWorktree: false, + }); + expect(deps.host.unpin).not.toHaveBeenCalled(); + expect(runDelete).not.toHaveBeenCalled(); + }); + + it("unpins and runs the delete when confirmed", async () => { + const deps = makeDeps({ confirmed: true }); + const service = makeService(deps); + const runDelete = vi.fn().mockResolvedValue(undefined); + + const ok = await service.confirmAndDelete( + { taskId: "t1", taskTitle: "Title", hasWorktree: true }, + runDelete, + ); + + expect(ok).toBe(true); + expect(deps.host.unpin).toHaveBeenCalledWith("t1"); + expect(runDelete).toHaveBeenCalledWith("t1"); + }); + + it("navigates away when viewing the deleted task detail", async () => { + const deps = makeDeps({ + confirmed: true, + view: { type: "task-detail", data: { id: "t1" } }, + }); + const service = makeService(deps); + + await service.confirmAndDelete( + { taskId: "t1", taskTitle: "Title", hasWorktree: false }, + vi.fn().mockResolvedValue(undefined), + ); + + expect(deps.host.navigateToTaskInput).toHaveBeenCalledOnce(); + }); + + it("does not navigate when viewing a different task", async () => { + const deps = makeDeps({ + confirmed: true, + view: { type: "task-detail", data: { id: "other" } }, + }); + const service = makeService(deps); + + await service.confirmAndDelete( + { taskId: "t1", taskTitle: "Title", hasWorktree: false }, + vi.fn().mockResolvedValue(undefined), + ); + + expect(deps.host.navigateToTaskInput).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/tasks/taskDeletionService.ts b/packages/core/src/tasks/taskDeletionService.ts new file mode 100644 index 0000000000..7a24066c20 --- /dev/null +++ b/packages/core/src/tasks/taskDeletionService.ts @@ -0,0 +1,98 @@ +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; +import { + type ITaskDeletionHost, + type ITaskDeletionWorkspaceClient, + TASK_DELETION_HOST, + TASK_DELETION_SERVICE, + TASK_DELETION_WORKSPACE_CLIENT, +} from "./identifiers"; +import { + shouldNavigateAwayFromDeletedTask, + shouldUnfocusBeforeDelete, +} from "./taskDelete"; + +export { TASK_DELETION_SERVICE }; + +export interface TaskCloudDeleteClient { + deleteTask(taskId: string): Promise<unknown>; +} + +export interface ConfirmAndDeleteParams { + taskId: string; + taskTitle: string; + hasWorktree: boolean; +} + +@injectable() +export class TaskDeletionService { + constructor( + @inject(TASK_DELETION_WORKSPACE_CLIENT) + private readonly workspace: ITaskDeletionWorkspaceClient, + @inject(TASK_DELETION_HOST) + private readonly host: ITaskDeletionHost, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, + ) { + this.logger = workbenchLogger.scope("tasks"); + } + + private readonly logger: ScopedLogger; + + async deleteTask( + client: TaskCloudDeleteClient, + taskId: string, + ): Promise<unknown> { + const all = await this.workspace.getAll(); + const workspace = all[taskId] ?? null; + + if (workspace) { + if (shouldUnfocusBeforeDelete(this.host.getSession(), workspace)) { + this.logger.info("Unfocusing workspace before deletion"); + await this.host.disableFocus(); + } + + if (workspace.folderPath) { + try { + await this.workspace.delete({ + taskId, + mainRepoPath: workspace.folderPath, + }); + } catch (error) { + this.logger.error("Failed to delete workspace:", error); + } + } + } + + return client.deleteTask(taskId); + } + + async confirmAndDelete( + params: ConfirmAndDeleteParams, + runDelete: (taskId: string) => Promise<unknown>, + ): Promise<boolean> { + const { taskId, taskTitle, hasWorktree } = params; + + const result = await this.host.confirmDeleteTask({ + taskTitle, + hasWorktree, + }); + if (!result.confirmed) { + return false; + } + + if (shouldNavigateAwayFromDeletedTask(this.host.getCurrentView(), taskId)) { + this.host.navigateToTaskInput(); + } + + await this.host.unpin(taskId); + + await runDelete(taskId); + + return true; + } +} diff --git a/packages/core/src/tasks/taskRename.test.ts b/packages/core/src/tasks/taskRename.test.ts new file mode 100644 index 0000000000..fe82d322db --- /dev/null +++ b/packages/core/src/tasks/taskRename.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { + applyRenameToDetail, + applyRenameToList, + applyRenameToSummaries, + getTaskSummaryTitle, + getTaskTitle, + rollbackDetailData, + rollbackListData, + rollbackSummaryData, + shouldRollbackSessionTitle, +} from "./taskRename"; + +interface TestTask { + id: string; + title: string; + title_manually_set?: boolean; +} + +const TASK_ID = "task-1"; +const OTHER_ID = "task-2"; + +describe("getTaskTitle / getTaskSummaryTitle", () => { + it("finds the title by id", () => { + const tasks: TestTask[] = [{ id: TASK_ID, title: "A" }]; + expect(getTaskTitle(tasks, TASK_ID)).toBe("A"); + expect(getTaskSummaryTitle(tasks, TASK_ID)).toBe("A"); + }); + + it("returns undefined when absent", () => { + expect(getTaskTitle(undefined, TASK_ID)).toBeUndefined(); + expect(getTaskTitle([], TASK_ID)).toBeUndefined(); + }); +}); + +describe("applyRenameToList", () => { + it("renames only the matching task and marks title_manually_set", () => { + const tasks: TestTask[] = [ + { id: TASK_ID, title: "Original" }, + { id: OTHER_ID, title: "Other" }, + ]; + const next = applyRenameToList(tasks, TASK_ID, "Renamed"); + expect(next?.find((t) => t.id === TASK_ID)).toMatchObject({ + title: "Renamed", + title_manually_set: true, + }); + expect(next?.find((t) => t.id === OTHER_ID)).toMatchObject({ + title: "Other", + }); + }); +}); + +describe("applyRenameToSummaries", () => { + it("renames only the matching summary", () => { + const summaries = [ + { id: TASK_ID, title: "Original" }, + { id: OTHER_ID, title: "Other" }, + ]; + const next = applyRenameToSummaries(summaries, TASK_ID, "Renamed"); + expect(next?.find((s) => s.id === TASK_ID)?.title).toBe("Renamed"); + expect(next?.find((s) => s.id === OTHER_ID)?.title).toBe("Other"); + }); +}); + +describe("applyRenameToDetail", () => { + it("sets the new title and title_manually_set", () => { + const detail: TestTask = { id: TASK_ID, title: "Original" }; + expect(applyRenameToDetail(detail, "Renamed")).toMatchObject({ + title: "Renamed", + title_manually_set: true, + }); + }); +}); + +describe("rollbackListData", () => { + const previous: TestTask[] = [{ id: TASK_ID, title: "Original" }]; + + it("restores previous when our rename still matches", () => { + const current: TestTask[] = [{ id: TASK_ID, title: "Renamed" }]; + expect(rollbackListData(current, previous, TASK_ID, "Renamed")).toBe( + previous, + ); + }); + + it("keeps current when a newer rename advanced past ours", () => { + const current: TestTask[] = [{ id: TASK_ID, title: "Second rename" }]; + expect(rollbackListData(current, previous, TASK_ID, "Renamed")).toBe( + current, + ); + }); + + it("uses previous data when current is missing", () => { + expect(rollbackListData(undefined, previous, TASK_ID, "Renamed")).toBe( + previous, + ); + }); +}); + +describe("rollbackSummaryData", () => { + const previous = [{ id: TASK_ID, title: "Original" }]; + + it("restores previous when our rename still matches", () => { + const current = [{ id: TASK_ID, title: "Renamed" }]; + expect(rollbackSummaryData(current, previous, TASK_ID, "Renamed")).toBe( + previous, + ); + }); + + it("keeps current when newer rename won", () => { + const current = [{ id: TASK_ID, title: "Second" }]; + expect(rollbackSummaryData(current, previous, TASK_ID, "Renamed")).toBe( + current, + ); + }); +}); + +describe("rollbackDetailData", () => { + const previous: TestTask = { id: TASK_ID, title: "Original" }; + + it("restores previous when title still matches ours", () => { + const current: TestTask = { id: TASK_ID, title: "Renamed" }; + expect(rollbackDetailData(current, previous, "Renamed")).toBe(previous); + }); + + it("keeps current when newer rename won", () => { + const current: TestTask = { id: TASK_ID, title: "Second" }; + expect(rollbackDetailData(current, previous, "Renamed")).toBe(current); + }); +}); + +describe("shouldRollbackSessionTitle", () => { + it("rolls back when the detail still shows our title", () => { + expect( + shouldRollbackSessionTitle({ + detailTitle: "Renamed", + listTitles: [], + newTitle: "Renamed", + }), + ).toBe(true); + }); + + it("rolls back when any list still shows our title", () => { + expect( + shouldRollbackSessionTitle({ + detailTitle: undefined, + listTitles: [undefined, "Renamed"], + newTitle: "Renamed", + }), + ).toBe(true); + }); + + it("skips rollback when a newer rename advanced past ours", () => { + expect( + shouldRollbackSessionTitle({ + detailTitle: "Second rename", + listTitles: ["Second rename"], + newTitle: "Renamed", + }), + ).toBe(false); + }); +}); diff --git a/packages/core/src/tasks/taskRename.ts b/packages/core/src/tasks/taskRename.ts new file mode 100644 index 0000000000..f050564e06 --- /dev/null +++ b/packages/core/src/tasks/taskRename.ts @@ -0,0 +1,99 @@ +interface TitledTask { + id: string; + title: string; + title_manually_set?: boolean; +} + +interface TitledSummary { + id: string; + title: string; +} + +export function getTaskTitle<T extends TitledTask>( + tasks: T[] | undefined, + taskId: string, +): string | undefined { + return tasks?.find((task) => task.id === taskId)?.title; +} + +export function getTaskSummaryTitle<T extends TitledSummary>( + summaries: T[] | undefined, + taskId: string, +): string | undefined { + return summaries?.find((summary) => summary.id === taskId)?.title; +} + +export function applyRenameToList<T extends TitledTask>( + tasks: T[] | undefined, + taskId: string, + newTitle: string, +): T[] | undefined { + return tasks?.map((task) => + task.id === taskId + ? { ...task, title: newTitle, title_manually_set: true } + : task, + ); +} + +export function applyRenameToSummaries<T extends TitledSummary>( + summaries: T[] | undefined, + taskId: string, + newTitle: string, +): T[] | undefined { + return summaries?.map((summary) => + summary.id === taskId ? { ...summary, title: newTitle } : summary, + ); +} + +export function applyRenameToDetail<T extends TitledTask>( + detail: T, + newTitle: string, +): T { + return { ...detail, title: newTitle, title_manually_set: true }; +} + +export function rollbackListData<T extends TitledTask>( + current: T[] | undefined, + previous: T[], + taskId: string, + newTitle: string, +): T[] { + if (!current) { + return previous; + } + return getTaskTitle(current, taskId) === newTitle ? previous : current; +} + +export function rollbackSummaryData<T extends TitledSummary>( + current: T[] | undefined, + previous: T[], + taskId: string, + newTitle: string, +): T[] { + if (!current) { + return previous; + } + return getTaskSummaryTitle(current, taskId) === newTitle ? previous : current; +} + +export function rollbackDetailData<T extends TitledTask>( + current: T | undefined, + previous: T, + newTitle: string, +): T { + if (!current) { + return previous; + } + return current.title === newTitle ? previous : current; +} + +export function shouldRollbackSessionTitle(args: { + detailTitle: string | undefined; + listTitles: (string | undefined)[]; + newTitle: string; +}): boolean { + const { detailTitle, listTitles, newTitle } = args; + return ( + detailTitle === newTitle || listTitles.some((title) => title === newTitle) + ); +} diff --git a/packages/core/src/tasks/tasks.module.ts b/packages/core/src/tasks/tasks.module.ts new file mode 100644 index 0000000000..a2f5451743 --- /dev/null +++ b/packages/core/src/tasks/tasks.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { TASK_DELETION_SERVICE } from "./identifiers"; +import { TaskDeletionService } from "./taskDeletionService"; + +export const tasksModule = new ContainerModule(({ bind }) => { + bind(TASK_DELETION_SERVICE).to(TaskDeletionService).inSingletonScope(); +}); diff --git a/packages/core/src/terminal/identifiers.ts b/packages/core/src/terminal/identifiers.ts new file mode 100644 index 0000000000..2924a4a774 --- /dev/null +++ b/packages/core/src/terminal/identifiers.ts @@ -0,0 +1,13 @@ +export interface ShellProcessReader { + getProcess(input: { sessionId: string }): Promise<string | null>; +} + +export const SHELL_PROCESS_READER = Symbol.for( + "posthog.core.terminal.shellProcessReader", +); + +export const SHELL_PROCESS_POLLER = Symbol.for( + "posthog.core.terminal.shellProcessPoller", +); + +export const SHELL_PROCESS_POLL_INTERVAL_MS = 500; diff --git a/apps/code/src/renderer/features/terminal/utils/resolveTerminalFontFamily.test.ts b/packages/core/src/terminal/resolveTerminalFontFamily.test.ts similarity index 100% rename from apps/code/src/renderer/features/terminal/utils/resolveTerminalFontFamily.test.ts rename to packages/core/src/terminal/resolveTerminalFontFamily.test.ts diff --git a/apps/code/src/renderer/features/terminal/utils/resolveTerminalFontFamily.ts b/packages/core/src/terminal/resolveTerminalFontFamily.ts similarity index 87% rename from apps/code/src/renderer/features/terminal/utils/resolveTerminalFontFamily.ts rename to packages/core/src/terminal/resolveTerminalFontFamily.ts index 080dbe56d6..64e4c83236 100644 --- a/apps/code/src/renderer/features/terminal/utils/resolveTerminalFontFamily.ts +++ b/packages/core/src/terminal/resolveTerminalFontFamily.ts @@ -1,4 +1,8 @@ -import type { TerminalFont } from "@features/settings/stores/settingsStore"; +export type TerminalFont = + | "berkeley-mono" + | "jetbrains-mono" + | "system" + | "custom"; const FALLBACK = '"Berkeley Mono", "JetBrains Mono", "Consolas", "Monaco", monospace'; diff --git a/packages/core/src/terminal/shellProcessPoller.ts b/packages/core/src/terminal/shellProcessPoller.ts new file mode 100644 index 0000000000..a6462459f0 --- /dev/null +++ b/packages/core/src/terminal/shellProcessPoller.ts @@ -0,0 +1,80 @@ +import { inject, injectable, preDestroy } from "inversify"; +import { + SHELL_PROCESS_POLL_INTERVAL_MS, + SHELL_PROCESS_READER, + type ShellProcessReader, +} from "./identifiers"; + +export type ProcessNameListener = (processName: string | null) => void; + +interface PollerEntry { + intervalId: ReturnType<typeof setInterval>; + sessionId: string; + lastProcessName: string | null; + listener: ProcessNameListener; +} + +@injectable() +export class ShellProcessPoller { + private readonly entries = new Map<string, PollerEntry>(); + + constructor( + @inject(SHELL_PROCESS_READER) + private readonly reader: ShellProcessReader, + ) {} + + start( + key: string, + sessionId: string, + listener: ProcessNameListener, + initialProcessName: string | null = null, + ): void { + if (this.entries.has(key)) return; + + const entry: PollerEntry = { + intervalId: setInterval( + () => void this.poll(key), + SHELL_PROCESS_POLL_INTERVAL_MS, + ), + sessionId, + lastProcessName: initialProcessName, + listener, + }; + this.entries.set(key, entry); + + void this.poll(key); + } + + stop(key: string): void { + const entry = this.entries.get(key); + if (!entry) return; + + clearInterval(entry.intervalId); + this.entries.delete(key); + } + + @preDestroy() + stopAll(): void { + for (const key of this.entries.keys()) { + this.stop(key); + } + } + + private async poll(key: string): Promise<void> { + const entry = this.entries.get(key); + if (!entry) return; + + const processName = await this.reader.getProcess({ + sessionId: entry.sessionId, + }); + + const current = this.entries.get(key); + if (!current) return; + + const next = processName ?? null; + if (next === current.lastProcessName) return; + + current.lastProcessName = next; + current.listener(next); + } +} diff --git a/packages/core/src/terminal/terminal.module.ts b/packages/core/src/terminal/terminal.module.ts new file mode 100644 index 0000000000..cf3c0822d2 --- /dev/null +++ b/packages/core/src/terminal/terminal.module.ts @@ -0,0 +1,8 @@ +import { ContainerModule } from "inversify"; +import { SHELL_PROCESS_POLLER } from "./identifiers"; +import { ShellProcessPoller } from "./shellProcessPoller"; + +export const terminalCoreModule = new ContainerModule(({ bind }) => { + bind(ShellProcessPoller).toSelf().inSingletonScope(); + bind(SHELL_PROCESS_POLLER).toService(ShellProcessPoller); +}); diff --git a/packages/core/src/tour/calculateTooltipPlacement.test.ts b/packages/core/src/tour/calculateTooltipPlacement.test.ts new file mode 100644 index 0000000000..0147003327 --- /dev/null +++ b/packages/core/src/tour/calculateTooltipPlacement.test.ts @@ -0,0 +1,79 @@ +import { + calculateTooltipPlacement, + type Rect, +} from "@posthog/core/tour/calculateTooltipPlacement"; +import { describe, expect, it } from "vitest"; + +function rect(partial: Partial<Rect>): Rect { + return { + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + ...partial, + }; +} + +describe("calculateTooltipPlacement", () => { + it("prefers the right side when there is room", () => { + const target = rect({ + top: 100, + left: 100, + right: 150, + bottom: 150, + width: 50, + height: 50, + }); + const result = calculateTooltipPlacement(target, 200, 100, 1000, 800); + expect(result.placement).toBe("right"); + expect(result.x).toBe(162); + }); + + it("falls back to left when right does not fit", () => { + const target = rect({ + top: 100, + left: 700, + right: 950, + bottom: 150, + width: 250, + height: 50, + }); + const result = calculateTooltipPlacement(target, 200, 100, 1000, 800); + expect(result.placement).toBe("left"); + }); + + it("honours the preferred placement when it fits", () => { + const target = rect({ + top: 400, + left: 400, + right: 450, + bottom: 450, + width: 50, + height: 50, + }); + const result = calculateTooltipPlacement( + target, + 200, + 100, + 1000, + 800, + "bottom", + ); + expect(result.placement).toBe("bottom"); + }); + + it("clamps within the viewport padding", () => { + const target = rect({ + top: 0, + left: 100, + right: 150, + bottom: 20, + width: 50, + height: 20, + }); + const result = calculateTooltipPlacement(target, 200, 100, 1000, 800); + expect(result.y).toBeGreaterThanOrEqual(8); + }); +}); diff --git a/apps/code/src/renderer/features/tour/utils/calculateTooltipPlacement.ts b/packages/core/src/tour/calculateTooltipPlacement.ts similarity index 92% rename from apps/code/src/renderer/features/tour/utils/calculateTooltipPlacement.ts rename to packages/core/src/tour/calculateTooltipPlacement.ts index b7417f9b79..f741b4c06c 100644 --- a/apps/code/src/renderer/features/tour/utils/calculateTooltipPlacement.ts +++ b/packages/core/src/tour/calculateTooltipPlacement.ts @@ -1,10 +1,19 @@ -import type { TooltipPlacement } from "../types"; +import type { TooltipPlacement } from "@posthog/core/tour/types"; const TOOLTIP_MARGIN = 12; const VIEWPORT_PADDING = 8; const DEFAULT_ORDER: TooltipPlacement[] = ["right", "left", "top", "bottom"]; +export interface Rect { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; +} + export interface PlacedTooltip { placement: TooltipPlacement; x: number; @@ -17,14 +26,13 @@ function clamp(value: number, min: number, max: number): number { } export function calculateTooltipPlacement( - targetRect: DOMRect, + targetRect: Rect, tooltipWidth: number, tooltipHeight: number, + vw: number, + vh: number, preferred?: TooltipPlacement, ): PlacedTooltip { - const vw = window.innerWidth; - const vh = window.innerHeight; - const spaceRight = vw - targetRect.right; const spaceLeft = targetRect.left; const spaceAbove = targetRect.top; diff --git a/packages/core/src/tour/tourMachine.test.ts b/packages/core/src/tour/tourMachine.test.ts new file mode 100644 index 0000000000..344916c3f4 --- /dev/null +++ b/packages/core/src/tour/tourMachine.test.ts @@ -0,0 +1,146 @@ +import { + advance, + completeTour, + computeReturningUserMigration, + dismiss, + type GetTour, + startTour, + type TourState, +} from "@posthog/core/tour/tourMachine"; +import type { TourDefinition } from "@posthog/core/tour/types"; +import { describe, expect, it } from "vitest"; + +const tour: TourDefinition = { + id: "demo", + steps: [ + { + id: "s1", + target: "t1", + hogSrc: "", + message: "", + advanceOn: { type: "click" }, + }, + { + id: "s2", + target: "t2", + hogSrc: "", + message: "", + advanceOn: { type: "click" }, + }, + ], +}; + +const getTour: GetTour = (id) => (id === "demo" ? tour : null); + +const initial: TourState = { + completedTourIds: [], + activeTourId: null, + activeStepIndex: 0, +}; + +describe("startTour", () => { + it("activates the tour at step 0 and emits started", () => { + const { state, events } = startTour(initial, "demo", getTour); + expect(state.activeTourId).toBe("demo"); + expect(state.activeStepIndex).toBe(0); + expect(events[0]).toMatchObject({ + tour_id: "demo", + action: "started", + step_id: "s1", + total_steps: 2, + }); + }); + + it("is a no-op when the tour is already completed", () => { + const state = { ...initial, completedTourIds: ["demo"] }; + const result = startTour(state, "demo", getTour); + expect(result.state).toBe(state); + expect(result.events).toHaveLength(0); + }); + + it("is a no-op when the tour is already active", () => { + const state = { ...initial, activeTourId: "demo" }; + const result = startTour(state, "demo", getTour); + expect(result.events).toHaveLength(0); + }); +}); + +describe("advance", () => { + it("moves to the next step when not at the last step", () => { + const state = { ...initial, activeTourId: "demo", activeStepIndex: 0 }; + const { state: next, events } = advance(state, "demo", "s1", getTour); + expect(next.activeStepIndex).toBe(1); + expect(next.activeTourId).toBe("demo"); + expect(events).toHaveLength(1); + expect(events[0].action).toBe("step_advanced"); + }); + + it("completes the tour when advancing the last step", () => { + const state = { ...initial, activeTourId: "demo", activeStepIndex: 1 }; + const { state: next, events } = advance(state, "demo", "s2", getTour); + expect(next.activeTourId).toBeNull(); + expect(next.completedTourIds).toContain("demo"); + expect(events.map((e) => e.action)).toEqual(["step_advanced", "completed"]); + }); + + it("is a no-op when the active tour does not match", () => { + const state = { ...initial, activeTourId: "other", activeStepIndex: 0 }; + const result = advance(state, "demo", "s1", getTour); + expect(result.events).toHaveLength(0); + }); + + it("is a no-op when the step id does not match the current step", () => { + const state = { ...initial, activeTourId: "demo", activeStepIndex: 0 }; + const result = advance(state, "demo", "s2", getTour); + expect(result.events).toHaveLength(0); + expect(result.state.activeStepIndex).toBe(0); + }); +}); + +describe("completeTour", () => { + it("marks the tour complete and clears active state", () => { + const state = { ...initial, activeTourId: "demo", activeStepIndex: 1 }; + const { state: next, events } = completeTour(state, "demo", getTour); + expect(next.completedTourIds).toContain("demo"); + expect(next.activeTourId).toBeNull(); + expect(events[0].action).toBe("completed"); + }); + + it("is a no-op when already complete", () => { + const state = { ...initial, completedTourIds: ["demo"] }; + const result = completeTour(state, "demo", getTour); + expect(result.events).toHaveLength(0); + }); +}); + +describe("dismiss", () => { + it("treats dismissal as completion of the active tour", () => { + const state = { ...initial, activeTourId: "demo", activeStepIndex: 1 }; + const { state: next, events } = dismiss(state, getTour); + expect(next.completedTourIds).toContain("demo"); + expect(next.activeTourId).toBeNull(); + expect(events[0].action).toBe("dismissed"); + expect(events[0].step_index).toBe(1); + }); + + it("is a no-op when no tour is active", () => { + const result = dismiss(initial, getTour); + expect(result.events).toHaveLength(0); + }); +}); + +describe("computeReturningUserMigration", () => { + const tours: TourDefinition[] = [ + { id: "a", steps: [], completeForReturningUsers: true }, + { id: "b", steps: [], completeForReturningUsers: false }, + { id: "c", steps: [] }, + ]; + + it("returns ids flagged for returning users when onboarding is complete", () => { + expect(computeReturningUserMigration(tours, true)).toEqual(["a"]); + }); + + it("returns nothing when onboarding is incomplete", () => { + expect(computeReturningUserMigration(tours, false)).toEqual([]); + }); +}); diff --git a/packages/core/src/tour/tourMachine.ts b/packages/core/src/tour/tourMachine.ts new file mode 100644 index 0000000000..0931467e05 --- /dev/null +++ b/packages/core/src/tour/tourMachine.ts @@ -0,0 +1,160 @@ +import type { TourDefinition } from "@posthog/core/tour/types"; + +export interface TourState { + completedTourIds: string[]; + activeTourId: string | null; + activeStepIndex: number; +} + +export type TourAction = + | "started" + | "step_advanced" + | "completed" + | "dismissed"; + +export interface TourEvent { + tour_id: string; + action: TourAction; + step_id?: string; + step_index?: number; + total_steps?: number; +} + +export interface TourTransition { + state: TourState; + events: TourEvent[]; +} + +export type GetTour = (tourId: string) => TourDefinition | null; + +export function startTour( + state: TourState, + tourId: string, + getTour: GetTour, +): TourTransition { + if ( + state.completedTourIds.includes(tourId) || + state.activeTourId === tourId + ) { + return { state, events: [] }; + } + + const tour = getTour(tourId); + return { + state: { ...state, activeTourId: tourId, activeStepIndex: 0 }, + events: [ + { + tour_id: tourId, + action: "started", + step_id: tour?.steps[0]?.id, + step_index: 0, + total_steps: tour?.steps.length, + }, + ], + }; +} + +export function advance( + state: TourState, + tourId: string, + stepId: string, + getTour: GetTour, +): TourTransition { + if (state.activeTourId !== tourId) return { state, events: [] }; + + const tour = getTour(state.activeTourId); + if (!tour) return { state, events: [] }; + + const currentStep = tour.steps[state.activeStepIndex]; + if (!currentStep || currentStep.id !== stepId) return { state, events: [] }; + + const events: TourEvent[] = [ + { + tour_id: tourId, + action: "step_advanced", + step_id: stepId, + step_index: state.activeStepIndex, + total_steps: tour.steps.length, + }, + ]; + + if (state.activeStepIndex >= tour.steps.length - 1) { + events.push({ + tour_id: tourId, + action: "completed", + total_steps: tour.steps.length, + }); + return { + state: { + ...state, + completedTourIds: [...state.completedTourIds, tourId], + activeTourId: null, + activeStepIndex: 0, + }, + events, + }; + } + + return { + state: { ...state, activeStepIndex: state.activeStepIndex + 1 }, + events, + }; +} + +export function completeTour( + state: TourState, + tourId: string, + getTour: GetTour, +): TourTransition { + if (state.completedTourIds.includes(tourId)) return { state, events: [] }; + + const tour = getTour(tourId); + return { + state: { + ...state, + completedTourIds: [...state.completedTourIds, tourId], + activeTourId: null, + activeStepIndex: 0, + }, + events: [ + { + tour_id: tourId, + action: "completed", + total_steps: tour?.steps.length, + }, + ], + }; +} + +export function dismiss(state: TourState, getTour: GetTour): TourTransition { + if (!state.activeTourId) return { state, events: [] }; + + const tour = getTour(state.activeTourId); + return { + state: { + ...state, + completedTourIds: [...state.completedTourIds, state.activeTourId], + activeTourId: null, + activeStepIndex: 0, + }, + events: [ + { + tour_id: state.activeTourId, + action: "dismissed", + step_id: tour?.steps[state.activeStepIndex]?.id, + step_index: state.activeStepIndex, + total_steps: tour?.steps.length, + }, + ], + }; +} + +export function computeReturningUserMigration( + tours: TourDefinition[], + hasCompletedOnboarding: boolean, +): string[] { + if (!hasCompletedOnboarding) return []; + return tours + .filter((tour) => tour.completeForReturningUsers) + .map((tour) => tour.id); +} diff --git a/packages/core/src/tour/tourRegistry.ts b/packages/core/src/tour/tourRegistry.ts new file mode 100644 index 0000000000..9f90af152b --- /dev/null +++ b/packages/core/src/tour/tourRegistry.ts @@ -0,0 +1,15 @@ +import type { TourDefinition } from "@posthog/core/tour/types"; + +const TOUR_REGISTRY: Record<string, TourDefinition> = {}; + +export function registerTour(tour: TourDefinition): void { + TOUR_REGISTRY[tour.id] = tour; +} + +export function getTour(tourId: string): TourDefinition | null { + return TOUR_REGISTRY[tourId] ?? null; +} + +export function getRegisteredTours(): TourDefinition[] { + return Object.values(TOUR_REGISTRY); +} diff --git a/apps/code/src/renderer/features/tour/types.ts b/packages/core/src/tour/types.ts similarity index 90% rename from apps/code/src/renderer/features/tour/types.ts rename to packages/core/src/tour/types.ts index 939653ba76..b2c2793c45 100644 --- a/apps/code/src/renderer/features/tour/types.ts +++ b/packages/core/src/tour/types.ts @@ -14,4 +14,5 @@ export interface TourStep { export interface TourDefinition { id: string; steps: TourStep[]; + completeForReturningUsers?: boolean; } diff --git a/packages/core/src/ui/identifiers.ts b/packages/core/src/ui/identifiers.ts new file mode 100644 index 0000000000..6e94bf825c --- /dev/null +++ b/packages/core/src/ui/identifiers.ts @@ -0,0 +1,2 @@ +export const UI_SERVICE = Symbol.for("posthog.core.uiService"); +export const UI_AUTH = Symbol.for("posthog.core.uiAuth"); diff --git a/packages/core/src/ui/ports.ts b/packages/core/src/ui/ports.ts new file mode 100644 index 0000000000..a781b1c631 --- /dev/null +++ b/packages/core/src/ui/ports.ts @@ -0,0 +1,3 @@ +export interface UiAuth { + invalidateAccessTokenForTest(): Promise<void>; +} diff --git a/apps/code/src/main/services/ui/schemas.ts b/packages/core/src/ui/schemas.ts similarity index 100% rename from apps/code/src/main/services/ui/schemas.ts rename to packages/core/src/ui/schemas.ts diff --git a/packages/core/src/ui/ui.module.ts b/packages/core/src/ui/ui.module.ts new file mode 100644 index 0000000000..87f8c28636 --- /dev/null +++ b/packages/core/src/ui/ui.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { UI_SERVICE } from "./identifiers"; +import { UIService } from "./ui"; + +export const uiModule = new ContainerModule(({ bind }) => { + bind(UI_SERVICE).to(UIService).inSingletonScope(); +}); diff --git a/packages/core/src/ui/ui.test.ts b/packages/core/src/ui/ui.test.ts new file mode 100644 index 0000000000..8233d31e95 --- /dev/null +++ b/packages/core/src/ui/ui.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import type { UiAuth } from "./ports"; +import { UIServiceEvent } from "./schemas"; +import { UIService } from "./ui"; + +function makeAuth(): UiAuth { + return { invalidateAccessTokenForTest: vi.fn().mockResolvedValue(undefined) }; +} + +describe("UIService signal events", () => { + it.each([ + ["openSettings", UIServiceEvent.OpenSettings], + ["newTask", UIServiceEvent.NewTask], + ["resetLayout", UIServiceEvent.ResetLayout], + ["clearStorage", UIServiceEvent.ClearStorage], + ] as const)("%s emits %s", (method, event) => { + const service = new UIService(makeAuth()); + const listener = vi.fn(); + service.on(event, listener); + + (service[method] as () => void)(); + + expect(listener).toHaveBeenCalledWith(true); + }); +}); + +describe("UIService.invalidateToken", () => { + it("invalidates the access token before emitting the signal", async () => { + const auth = makeAuth(); + const service = new UIService(auth); + const listener = vi.fn(); + service.on(UIServiceEvent.InvalidateToken, listener); + + await service.invalidateToken(); + + expect(auth.invalidateAccessTokenForTest).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(true); + }); +}); diff --git a/apps/code/src/main/services/ui/service.ts b/packages/core/src/ui/ui.ts similarity index 67% rename from apps/code/src/main/services/ui/service.ts rename to packages/core/src/ui/ui.ts index f991d4ea88..1cfe5fe300 100644 --- a/apps/code/src/main/services/ui/service.ts +++ b/packages/core/src/ui/ui.ts @@ -1,14 +1,14 @@ +import { TypedEventEmitter } from "@posthog/shared"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import type { AuthService } from "../auth/service"; +import { UI_AUTH } from "./identifiers"; +import type { UiAuth } from "./ports"; import { UIServiceEvent, type UIServiceEvents } from "./schemas"; @injectable() export class UIService extends TypedEventEmitter<UIServiceEvents> { constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, + @inject(UI_AUTH) + private readonly auth: UiAuth, ) { super(); } @@ -30,7 +30,7 @@ export class UIService extends TypedEventEmitter<UIServiceEvents> { } async invalidateToken(): Promise<void> { - await this.authService.invalidateAccessTokenForTest(); + await this.auth.invalidateAccessTokenForTest(); this.emit(UIServiceEvent.InvalidateToken, true); } } diff --git a/packages/core/src/updates/identifiers.ts b/packages/core/src/updates/identifiers.ts new file mode 100644 index 0000000000..49d5cb9fc9 --- /dev/null +++ b/packages/core/src/updates/identifiers.ts @@ -0,0 +1,11 @@ +export const UPDATES_SERVICE = Symbol.for("posthog.core.updatesService"); + +export interface IUpdateLifecycle { + setQuittingForUpdate(): void; + clearQuittingForUpdate(): void; + shutdownWithoutContainer(): Promise<void>; +} + +export const UPDATE_LIFECYCLE_SERVICE = Symbol.for( + "posthog.core.updateLifecycleService", +); diff --git a/apps/code/src/main/services/updates/schemas.ts b/packages/core/src/updates/schemas.ts similarity index 100% rename from apps/code/src/main/services/updates/schemas.ts rename to packages/core/src/updates/schemas.ts diff --git a/packages/core/src/updates/updateStore.test.ts b/packages/core/src/updates/updateStore.test.ts new file mode 100644 index 0000000000..db1489433d --- /dev/null +++ b/packages/core/src/updates/updateStore.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + deriveUpdateUiStatus, + resolveMenuCheckFromStatus, + resolveMenuCheckResult, +} from "./updateStore"; + +describe("deriveUpdateUiStatus", () => { + it("hydrates an installing update", () => { + expect( + deriveUpdateUiStatus( + { checking: false, updateReady: true, installing: true, version: "v2" }, + "idle", + ), + ).toEqual({ status: "installing", version: "v2" }); + }); + + it("hydrates a ready update", () => { + expect( + deriveUpdateUiStatus( + { checking: false, updateReady: true, version: "v2" }, + "idle", + ), + ).toEqual({ status: "ready", version: "v2" }); + }); + + it("maps checking + downloading to downloading", () => { + expect( + deriveUpdateUiStatus({ checking: true, downloading: true }, "idle"), + ).toEqual({ status: "downloading" }); + }); + + it("maps checking to checking", () => { + expect(deriveUpdateUiStatus({ checking: true }, "idle")).toEqual({ + status: "checking", + }); + }); + + it("resets to idle on upToDate when not ready/installing", () => { + expect( + deriveUpdateUiStatus({ checking: false, upToDate: true }, "checking"), + ).toEqual({ status: "idle" }); + }); + + it("does not reset a ready update on a stale upToDate status", () => { + expect( + deriveUpdateUiStatus({ checking: false, upToDate: true }, "ready"), + ).toBeNull(); + }); + + it("does not reset an installing update on a stale upToDate status", () => { + expect( + deriveUpdateUiStatus({ checking: false, upToDate: true }, "installing"), + ).toBeNull(); + }); +}); + +describe("resolveMenuCheckFromStatus", () => { + it("returns null when no menu check is pending", () => { + expect( + resolveMenuCheckFromStatus({ checking: false, upToDate: true }, false), + ).toBeNull(); + }); + + it("returns a success toast on upToDate", () => { + expect( + resolveMenuCheckFromStatus({ checking: false, upToDate: true }, true), + ).toEqual({ + clearPending: true, + toast: { kind: "success", message: "You're on the latest version" }, + }); + }); + + it("returns an error toast on error", () => { + expect( + resolveMenuCheckFromStatus({ checking: false, error: "boom" }, true), + ).toEqual({ + clearPending: true, + toast: { + kind: "error", + message: "Failed to check for updates", + description: "boom", + }, + }); + }); + + it("suppresses the toast but clears pending when a check finishes with an update", () => { + expect(resolveMenuCheckFromStatus({ checking: false }, true)).toEqual({ + clearPending: true, + }); + }); + + it("keeps pending while still checking", () => { + expect(resolveMenuCheckFromStatus({ checking: true }, true)).toBeNull(); + }); +}); + +describe("resolveMenuCheckResult", () => { + it("returns null on success", () => { + expect(resolveMenuCheckResult({ success: true })).toBeNull(); + }); + + it("clears pending and shows error toast on disabled", () => { + expect( + resolveMenuCheckResult({ + success: false, + errorCode: "disabled", + errorMessage: "Updates only available in packaged builds", + }), + ).toEqual({ + clearPending: true, + toast: { + kind: "error", + message: "Updates only available in packaged builds", + }, + }); + }); + + it("keeps pending on already_checking", () => { + expect( + resolveMenuCheckResult({ success: false, errorCode: "already_checking" }), + ).toBeNull(); + }); + + it("clears pending on unknown error codes", () => { + expect( + resolveMenuCheckResult({ success: false, errorCode: "future" }), + ).toEqual({ clearPending: true }); + }); +}); diff --git a/packages/core/src/updates/updateStore.ts b/packages/core/src/updates/updateStore.ts new file mode 100644 index 0000000000..fa3139141e --- /dev/null +++ b/packages/core/src/updates/updateStore.ts @@ -0,0 +1,148 @@ +import type { UpdatesStatusPayload } from "@posthog/core/updates/schemas"; +import { createStore } from "zustand/vanilla"; + +export type UpdateUiStatus = + | "idle" + | "checking" + | "downloading" + | "ready" + | "installing"; + +interface UpdateState { + status: UpdateUiStatus; + version: string | null; + isEnabled: boolean; + menuCheckPending: boolean; + + setStatus: (status: UpdateUiStatus) => void; + setVersion: (version: string | null) => void; + setEnabled: (isEnabled: boolean) => void; + setMenuCheckPending: (menuCheckPending: boolean) => void; + setReady: (version: string | null) => void; +} + +export const updateStore = createStore<UpdateState>((set) => ({ + status: "idle", + version: null, + isEnabled: false, + menuCheckPending: false, + + setStatus: (status) => set({ status }), + setVersion: (version) => set({ version }), + setEnabled: (isEnabled) => set({ isEnabled }), + setMenuCheckPending: (menuCheckPending) => set({ menuCheckPending }), + setReady: (version) => set({ status: "ready", version }), +})); + +export const getUpdateUiStatus = () => updateStore.getState().status; +export const getUpdateVersion = () => updateStore.getState().version; +export const getMenuCheckPending = () => + updateStore.getState().menuCheckPending; + +export interface UpdateStatusUpdate { + status?: UpdateUiStatus; + version?: string | null; +} + +export function deriveUpdateUiStatus( + payload: UpdatesStatusPayload, + currentStatus: UpdateUiStatus, +): UpdateStatusUpdate | null { + if (payload.installing) { + return { status: "installing", version: payload.version ?? null }; + } + + if (payload.updateReady) { + return { status: "ready", version: payload.version ?? null }; + } + + if (payload.checking && payload.downloading) { + return { status: "downloading" }; + } + + if (payload.checking) { + return { status: "checking" }; + } + + if (payload.upToDate || payload.error) { + if (currentStatus !== "ready" && currentStatus !== "installing") { + return { status: "idle" }; + } + } + + return null; +} + +export interface MenuCheckToast { + kind: "success" | "error"; + message: string; + description?: string; +} + +export interface MenuCheckOutcome { + toast?: MenuCheckToast; + clearPending: boolean; +} + +export function resolveMenuCheckFromStatus( + payload: UpdatesStatusPayload, + menuCheckPending: boolean, +): MenuCheckOutcome | null { + if (!menuCheckPending) { + return null; + } + + if (payload.upToDate) { + return { + clearPending: true, + toast: { kind: "success", message: "You're on the latest version" }, + }; + } + + if (payload.error) { + return { + clearPending: true, + toast: { + kind: "error", + message: "Failed to check for updates", + description: payload.error, + }, + }; + } + + if (payload.checking === false) { + return { clearPending: true }; + } + + return null; +} + +export interface MenuCheckResult { + success: boolean; + errorCode?: string; + errorMessage?: string; +} + +export function resolveMenuCheckResult( + result: MenuCheckResult, +): MenuCheckOutcome | null { + if (result.success) { + return null; + } + + if (result.errorCode === "disabled") { + return { + clearPending: true, + toast: { + kind: "error", + message: result.errorMessage ?? "Updates not available", + }, + }; + } + + if (result.errorCode === "already_checking") { + return null; + } + + return { clearPending: true }; +} diff --git a/packages/core/src/updates/updates.module.ts b/packages/core/src/updates/updates.module.ts new file mode 100644 index 0000000000..705c719f0f --- /dev/null +++ b/packages/core/src/updates/updates.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { UPDATES_SERVICE } from "./identifiers"; +import { UpdatesService } from "./updates"; + +export const updatesCoreModule = new ContainerModule(({ bind }) => { + bind(UPDATES_SERVICE).to(UpdatesService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/updates/service.test.ts b/packages/core/src/updates/updates.test.ts similarity index 91% rename from apps/code/src/main/services/updates/service.test.ts rename to packages/core/src/updates/updates.test.ts index f21cbd874f..247cb90291 100644 --- a/apps/code/src/main/services/updates/service.test.ts +++ b/packages/core/src/updates/updates.test.ts @@ -65,6 +65,8 @@ const { mockAppMeta: { version: "1.0.0", isProduction: true, + platform: "darwin", + arch: "arm64", }, mockMainWindow: { focus: vi.fn(), @@ -91,22 +93,12 @@ const { }; }); -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => mockLog, - }, -})); - -vi.mock("../../utils/env.js", () => ({ - isDevBuild: () => !mockAppMeta.isProduction, -})); - -// Import the service after mocks are set up -import { UpdatesService } from "./service"; +import { UpdatesService } from "./updates"; function injectPorts(service: UpdatesService): void { const s = service as unknown as Record<string, unknown>; - s.lifecycleService = mockLifecycleService; + s.lifecycle = mockLifecycleService; + s.workbenchLogger = { ...mockLog, scope: () => mockLog }; s.updater = mockUpdater; s.appLifecycle = mockAppLifecycle; s.appMeta = mockAppMeta; @@ -136,6 +128,8 @@ describe("UpdatesService", () => { // Reset mocks to default state mockAppMeta.isProduction = true; mockAppMeta.version = "1.0.0"; + mockAppMeta.platform = "darwin"; + mockAppMeta.arch = "arm64"; mockUpdater.isSupported.mockReturnValue(true); mockUpdater.quitAndInstall.mockImplementation(() => undefined); mockLifecycleService.shutdownWithoutContainer.mockImplementation(() => @@ -167,70 +161,23 @@ describe("UpdatesService", () => { }); describe("isEnabled", () => { - it("returns true when app is packaged on macOS", () => { - mockUpdater.isSupported.mockReturnValue(true); - Object.defineProperty(process, "platform", { - value: "darwin", - configurable: true, - }); - - const newService = new UpdatesService(); - injectPorts(newService); - expect(newService.isEnabled).toBe(true); - }); - - it("returns true when app is packaged on Windows", () => { + // Host support gating (packaged, platform allow-list, ELECTRON_DISABLE_AUTO_UPDATE) + // now lives in the platform updater adapter's isSupported(); core just mirrors it. + it("returns true when the platform updater reports supported", () => { mockUpdater.isSupported.mockReturnValue(true); - Object.defineProperty(process, "platform", { - value: "win32", - configurable: true, - }); const newService = new UpdatesService(); injectPorts(newService); expect(newService.isEnabled).toBe(true); }); - it("returns false when app is not packaged", () => { + it("returns false when the platform updater reports unsupported", () => { mockUpdater.isSupported.mockReturnValue(false); const newService = new UpdatesService(); injectPorts(newService); expect(newService.isEnabled).toBe(false); }); - - it("returns false when ELECTRON_DISABLE_AUTO_UPDATE is set", () => { - mockUpdater.isSupported.mockReturnValue(true); - process.env.ELECTRON_DISABLE_AUTO_UPDATE = "1"; - - const newService = new UpdatesService(); - injectPorts(newService); - expect(newService.isEnabled).toBe(false); - }); - - it("returns false on Linux", () => { - mockUpdater.isSupported.mockReturnValue(true); - Object.defineProperty(process, "platform", { - value: "linux", - configurable: true, - }); - - const newService = new UpdatesService(); - injectPorts(newService); - expect(newService.isEnabled).toBe(false); - }); - - it("returns false on unsupported platforms", () => { - mockUpdater.isSupported.mockReturnValue(true); - Object.defineProperty(process, "platform", { - value: "freebsd", - configurable: true, - }); - - const newService = new UpdatesService(); - injectPorts(newService); - expect(newService.isEnabled).toBe(false); - }); }); describe("init", () => { @@ -241,21 +188,8 @@ describe("UpdatesService", () => { expect(mockAppLifecycle.whenReady).toHaveBeenCalled(); }); - it("does not set up auto updater when disabled via env flag", () => { - process.env.ELECTRON_DISABLE_AUTO_UPDATE = "1"; - - const newService = new UpdatesService(); - injectPorts(newService); - newService.init(); - - expect(mockAppLifecycle.whenReady).not.toHaveBeenCalled(); - }); - - it("does not set up auto updater on unsupported platform", () => { - Object.defineProperty(process, "platform", { - value: "linux", - configurable: true, - }); + it("does not set up auto updater when the host reports unsupported", () => { + mockUpdater.isSupported.mockReturnValue(false); const newService = new UpdatesService(); injectPorts(newService); @@ -279,10 +213,8 @@ describe("UpdatesService", () => { describe("feedUrl", () => { it("constructs correct feed URL with platform, arch, and version", async () => { - Object.defineProperty(process, "arch", { - value: "arm64", - configurable: true, - }); + mockAppMeta.platform = "darwin"; + mockAppMeta.arch = "arm64"; mockAppMeta.version = "2.0.0"; await initializeService(service); @@ -315,10 +247,8 @@ describe("UpdatesService", () => { }); it("returns error when updates are disabled (unsupported platform)", () => { - Object.defineProperty(process, "platform", { - value: "linux", - configurable: true, - }); + mockUpdater.isSupported.mockReturnValue(false); + mockAppMeta.isProduction = true; const newService = new UpdatesService(); injectPorts(newService); diff --git a/apps/code/src/main/services/updates/service.ts b/packages/core/src/updates/updates.ts similarity index 78% rename from apps/code/src/main/services/updates/service.ts rename to packages/core/src/updates/updates.ts index 76d1c4c504..795c2534e2 100644 --- a/apps/code/src/main/services/updates/service.ts +++ b/packages/core/src/updates/updates.ts @@ -1,14 +1,21 @@ -import type { IAppLifecycle } from "@posthog/platform/app-lifecycle"; -import type { IAppMeta } from "@posthog/platform/app-meta"; -import type { IMainWindow } from "@posthog/platform/main-window"; -import type { IUpdater } from "@posthog/platform/updater"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { + APP_LIFECYCLE_SERVICE, + type IAppLifecycle, +} from "@posthog/platform/app-lifecycle"; +import { APP_META_SERVICE, type IAppMeta } from "@posthog/platform/app-meta"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { type IUpdater, UPDATER_SERVICE } from "@posthog/platform/updater"; +import { + type SagaLogger, + TypedEventEmitter, + withTimeout, +} from "@posthog/shared"; import { inject, injectable, postConstruct, preDestroy } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { withTimeout } from "../../utils/async"; -import { isDevBuild } from "../../utils/env"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import type { AppLifecycleService } from "../app-lifecycle/service"; +import { type IUpdateLifecycle, UPDATE_LIFECYCLE_SERVICE } from "./identifiers"; import { type CheckForUpdatesOutput, type InstallUpdateOutput, @@ -33,8 +40,6 @@ type TransitionContext = { error?: string; }; -const log = logger.scope("updates"); - @injectable() export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { private static readonly SERVER_HOST = "https://update.electronjs.org"; @@ -43,22 +48,32 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { private static readonly CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour private static readonly CHECK_TIMEOUT_MS = 60 * 1000; // 1 minute timeout for checks private static readonly INSTALL_SHUTDOWN_TIMEOUT_MS = 3000; - private static readonly DISABLE_ENV_FLAG = "ELECTRON_DISABLE_AUTO_UPDATE"; - private static readonly SUPPORTED_PLATFORMS = ["darwin", "win32"]; - @inject(MAIN_TOKENS.AppLifecycleService) - private lifecycleService!: AppLifecycleService; + @inject(UPDATE_LIFECYCLE_SERVICE) + private lifecycle!: IUpdateLifecycle; - @inject(MAIN_TOKENS.Updater) + @inject(WORKBENCH_LOGGER) + private workbenchLogger!: WorkbenchLogger; + + private logScoped: SagaLogger | null = null; + + private get log(): SagaLogger { + if (this.logScoped === null) { + this.logScoped = this.workbenchLogger.scope("updates"); + } + return this.logScoped; + } + + @inject(UPDATER_SERVICE) private updater!: IUpdater; - @inject(MAIN_TOKENS.AppLifecycle) + @inject(APP_LIFECYCLE_SERVICE) private appLifecycle!: IAppLifecycle; - @inject(MAIN_TOKENS.AppMeta) + @inject(APP_META_SERVICE) private appMeta!: IAppMeta; - @inject(MAIN_TOKENS.MainWindow) + @inject(MAIN_WINDOW_SERVICE) private mainWindow!: IMainWindow; private state: UpdateState = "idle"; @@ -80,28 +95,18 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { } get isEnabled(): boolean { - return ( - this.updater.isSupported() && - !process.env[UpdatesService.DISABLE_ENV_FLAG] && - UpdatesService.SUPPORTED_PLATFORMS.includes(process.platform) - ); + return this.updater.isSupported(); } private get feedUrl(): string { const ctor = this.constructor as typeof UpdatesService; - return `${ctor.SERVER_HOST}/${ctor.REPO_OWNER}/${ctor.REPO_NAME}/${process.platform}-${process.arch}/${this.appMeta.version}`; + return `${ctor.SERVER_HOST}/${ctor.REPO_OWNER}/${ctor.REPO_NAME}/${this.appMeta.platform}-${this.appMeta.arch}/${this.appMeta.version}`; } @postConstruct() init(): void { if (!this.isEnabled) { - if (process.env[UpdatesService.DISABLE_ENV_FLAG]) { - log.info("Auto updates disabled via environment flag"); - } else if ( - !UpdatesService.SUPPORTED_PLATFORMS.includes(process.platform) - ) { - log.info("Auto updates only supported on macOS and Windows"); - } + this.log.info("Auto updates not enabled for this host"); return; } @@ -140,7 +145,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { checkForUpdates(source: CheckSource = "user"): CheckForUpdatesOutput { if (!this.isEnabled) { - const reason = isDevBuild() + const reason = !this.appMeta.isProduction ? "Updates only available in packaged builds" : "Auto updates only supported on macOS and Windows"; return { success: false, errorMessage: reason, errorCode: "disabled" }; @@ -187,26 +192,26 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { } if (this.state !== "ready") { - log.warn("installUpdate called but no update is ready", { + this.log.warn("installUpdate called but no update is ready", { state: this.state, }); return { installed: false }; } - log.info("Installing update and restarting...", { + this.log.info("Installing update and restarting...", { downloadedVersion: this.downloadedVersion, }); try { this.transitionTo("installing", { reason: "install requested" }); this.emitStatus(this.stagedStatusPayload()); - this.lifecycleService.setQuittingForUpdate(); + this.lifecycle.setQuittingForUpdate(); const cleanupResult = await withTimeout( - this.lifecycleService.shutdownWithoutContainer(), + this.lifecycle.shutdownWithoutContainer(), UpdatesService.INSTALL_SHUTDOWN_TIMEOUT_MS, ); if (cleanupResult.result === "timeout") { - log.warn("Partial shutdown timed out before update install", { + this.log.warn("Partial shutdown timed out before update install", { timeoutMs: UpdatesService.INSTALL_SHUTDOWN_TIMEOUT_MS, downloadedVersion: this.downloadedVersion, }); @@ -214,8 +219,8 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { this.updater.quitAndInstall(); return { installed: true }; } catch (error) { - log.error("Failed to quit and install update", error); - this.lifecycleService.clearQuittingForUpdate(); + this.log.error("Failed to quit and install update", { error }); + this.lifecycle.clearQuittingForUpdate(); this.transitionTo("ready", { reason: "install handoff failed", error: error instanceof Error ? error.message : String(error), @@ -227,29 +232,29 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { private setupAutoUpdater(): void { if (this.initialized) { - log.warn("setupAutoUpdater called multiple times, ignoring"); + this.log.warn("setupAutoUpdater called multiple times, ignoring"); return; } this.initialized = true; const feedUrl = this.feedUrl; - log.info("Setting up auto updater", { + this.log.info("Setting up auto updater", { feedUrl, currentVersion: this.appMeta.version, - platform: process.platform, - arch: process.arch, + platform: this.appMeta.platform, + arch: this.appMeta.arch, }); try { this.updater.setFeedUrl(feedUrl); } catch (error) { - log.error("Failed to set feed URL", error); + this.log.error("Failed to set feed URL", { error }); return; } this.unsubscribes.push( this.updater.onError((error) => this.handleError(error)), - this.updater.onCheckStart(() => log.info("Checking for updates...")), + this.updater.onCheckStart(() => this.log.info("Checking for updates...")), this.updater.onUpdateAvailable(() => this.handleUpdateAvailable()), this.updater.onNoUpdate(() => this.handleNoUpdate()), this.updater.onUpdateDownloaded((releaseName) => @@ -276,7 +281,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { private handleError(error: Error): void { this.clearCheckTimeout(); - log.error("Auto update error", { + this.log.error("Auto update error", { message: error.message, stack: error.stack, feedUrl: this.feedUrl, @@ -304,7 +309,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { private handleUpdateAvailable(): void { if (this.isUpdateStaged()) { - log.info( + this.log.info( "Ignoring update-available because an update is already staged", { downloadedVersion: this.downloadedVersion, @@ -315,7 +320,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { this.clearCheckTimeout(); this.transitionTo("downloading", { reason: "update available" }); - log.info("Update available, downloading..."); + this.log.info("Update available, downloading..."); this.emitStatus({ checking: true, downloading: true }); } @@ -323,13 +328,15 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { this.clearCheckTimeout(); if (this.isUpdateStaged()) { - log.info("Ignoring update-not-available because update is staged", { + this.log.info("Ignoring update-not-available because update is staged", { downloadedVersion: this.downloadedVersion, }); return; } - log.info("No updates available", { currentVersion: this.appMeta.version }); + this.log.info("No updates available", { + currentVersion: this.appMeta.version, + }); if (this.state === "checking" || this.state === "downloading") { this.transitionTo("idle", { reason: "no update available" }); this.emitStatus({ @@ -344,7 +351,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { this.clearCheckTimeout(); if (this.isUpdateStaged()) { - log.info("Ignoring duplicate update-downloaded event", { + this.log.info("Ignoring duplicate update-downloaded event", { existingVersion: this.downloadedVersion, incomingVersion: releaseName, }); @@ -359,7 +366,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { this.clearCheckInterval(); this.emitStatus(this.stagedStatusPayload()); - log.info("Update downloaded, awaiting user confirmation", { + this.log.info("Update downloaded, awaiting user confirmation", { currentVersion: this.appMeta.version, downloadedVersion: this.downloadedVersion, }); @@ -368,7 +375,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { this.pendingNotification = true; this.flushPendingNotification(); } else { - log.info("Skipping notification - same version already notified", { + this.log.info("Skipping notification - same version already notified", { version: this.downloadedVersion, }); } @@ -376,7 +383,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { private flushPendingNotification(): void { if (this.state === "ready" && this.pendingNotification) { - log.info("Notifying user that update is ready", { + this.log.info("Notifying user that update is ready", { downloadedVersion: this.downloadedVersion, }); this.emit(UpdatesEvent.Ready, { version: this.downloadedVersion }); @@ -396,7 +403,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { if (this.state === "checking" || this.state === "downloading") { const timeoutSeconds = UpdatesService.CHECK_TIMEOUT_MS / 1000; const message = "Update check timed out. Please try again."; - log.warn(`Update check timed out after ${timeoutSeconds} seconds`); + this.log.warn(`Update check timed out after ${timeoutSeconds} seconds`); this.lastError = message; this.transitionTo("error", { error: message }); this.emitStatus({ checking: false, error: message }); @@ -407,7 +414,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { this.updater.check(); } catch (error) { this.clearCheckTimeout(); - log.error("Failed to check for updates", error); + this.log.error("Failed to check for updates", { error }); this.lastError = "Failed to check for updates. Please try again."; this.transitionTo("error", { error: error instanceof Error ? error.message : String(error), @@ -434,7 +441,7 @@ export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { toState: UpdateState, context: TransitionContext = {}, ): void { - log.info("Update state transition", { + this.log.info("Update state transition", { source: context.source, fromState: this.state, toState, diff --git a/packages/core/src/usage/identifiers.ts b/packages/core/src/usage/identifiers.ts new file mode 100644 index 0000000000..1fee3e5d84 --- /dev/null +++ b/packages/core/src/usage/identifiers.ts @@ -0,0 +1,24 @@ +import type { UsageOutput } from "./schemas"; + +export const USAGE_MONITOR_SERVICE = Symbol.for( + "posthog.core.usageMonitorService", +); +export const USAGE_HOST = Symbol.for("posthog.core.usageHost"); + +export interface UsageHost { + fetchUsage(): Promise<UsageOutput>; + + onLlmActivity(listener: () => void): void; + offLlmActivity(listener: () => void): void; + hasActiveSessions(): boolean; + + getThresholdsSeen(): Record<string, string>; + setThresholdsSeen(value: Record<string, string>): void; +} + +export interface UsageLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} diff --git a/apps/code/src/main/services/usage-monitor/schemas.ts b/packages/core/src/usage/monitor-schemas.ts similarity index 87% rename from apps/code/src/main/services/usage-monitor/schemas.ts rename to packages/core/src/usage/monitor-schemas.ts index dbfbde1631..abbdb0f8a4 100644 --- a/apps/code/src/main/services/usage-monitor/schemas.ts +++ b/packages/core/src/usage/monitor-schemas.ts @@ -1,6 +1,5 @@ -import type { UsageOutput } from "@main/services/llm-gateway/schemas"; -import { usageOutput } from "@main/services/llm-gateway/schemas"; import { z } from "zod"; +import { type UsageOutput, usageOutput } from "./schemas"; export const USAGE_THRESHOLDS = [50, 75, 90, 100] as const; export type UsageThreshold = (typeof USAGE_THRESHOLDS)[number]; diff --git a/packages/core/src/usage/schemas.ts b/packages/core/src/usage/schemas.ts new file mode 100644 index 0000000000..7ad2c1db8e --- /dev/null +++ b/packages/core/src/usage/schemas.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +export const usageBucketSchema = z.object({ + used_percent: z.number(), + reset_at: z.string().datetime(), + exceeded: z.boolean(), +}); + +export const usageOutput = z.object({ + product: z.string(), + user_id: z.number(), + sustained: usageBucketSchema, + burst: usageBucketSchema, + is_rate_limited: z.boolean(), + is_pro: z.boolean(), + billing_period_end: z.string().datetime().nullable().optional(), +}); + +export type UsageBucket = z.infer<typeof usageBucketSchema>; +export type UsageOutput = z.infer<typeof usageOutput>; diff --git a/packages/core/src/usage/usage-monitor.module.ts b/packages/core/src/usage/usage-monitor.module.ts new file mode 100644 index 0000000000..7d0808d966 --- /dev/null +++ b/packages/core/src/usage/usage-monitor.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { USAGE_MONITOR_SERVICE } from "./identifiers"; +import { UsageMonitorService } from "./usage-monitor"; + +export const usageMonitorModule = new ContainerModule(({ bind }) => { + bind(USAGE_MONITOR_SERVICE).to(UsageMonitorService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/usage-monitor/service.test.ts b/packages/core/src/usage/usage-monitor.test.ts similarity index 70% rename from apps/code/src/main/services/usage-monitor/service.test.ts rename to packages/core/src/usage/usage-monitor.test.ts index 6132a8851a..6e1c8dad70 100644 --- a/apps/code/src/main/services/usage-monitor/service.test.ts +++ b/packages/core/src/usage/usage-monitor.test.ts @@ -1,40 +1,65 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { UsageOutput } from "../llm-gateway/schemas"; -import { UsageMonitorEvent } from "./schemas"; - -const mockStoreGet = vi.hoisted(() => vi.fn()); -const mockStoreSet = vi.hoisted(() => vi.fn()); - -vi.mock("./store", () => ({ - usageMonitorStore: { - get: mockStoreGet, - set: mockStoreSet, - }, -})); - -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import { AgentServiceEvent } from "../agent/schemas"; -import type { AgentService } from "../agent/service"; -import type { LlmGatewayService } from "../llm-gateway/service"; -import { UsageMonitorService } from "./service"; - -function makeAgentService(opts?: { hasActiveSessions?: boolean }) { - const emitter = new TypedEventEmitter<{ - [AgentServiceEvent.LlmActivity]: undefined; - }>() as unknown as AgentService & { hasActiveSessions: () => boolean }; - emitter.hasActiveSessions = () => opts?.hasActiveSessions ?? false; - return emitter; +import type { UsageHost } from "./identifiers"; +import { UsageMonitorEvent } from "./monitor-schemas"; +import type { UsageOutput } from "./schemas"; +import { UsageMonitorService } from "./usage-monitor"; + +type ActivitySlice = Pick< + UsageHost, + "onLlmActivity" | "offLlmActivity" | "hasActiveSessions" +>; + +interface MockActivityMonitor extends ActivitySlice { + fireLlmActivity(): void; +} + +function makeActivityMonitor(opts?: { + hasActiveSessions?: boolean; +}): MockActivityMonitor { + const listeners = new Set<() => void>(); + return { + onLlmActivity: (l) => listeners.add(l), + offLlmActivity: (l) => listeners.delete(l), + hasActiveSessions: () => opts?.hasActiveSessions ?? false, + fireLlmActivity: () => { + for (const l of [...listeners]) l(); + }, + }; +} + +type ThresholdSlice = Pick< + UsageHost, + "getThresholdsSeen" | "setThresholdsSeen" +>; + +let persisted: Record<string, string> = {}; + +function makeThresholdStore(): ThresholdSlice { + return { + getThresholdsSeen: () => ({ ...persisted }), + setThresholdsSeen: (v) => { + persisted = { ...v }; + }, + }; +} + +function makeLogger() { + const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + return { ...log, scope: () => log }; +} + +type GatewaySlice = Pick<UsageHost, "fetchUsage">; + +function makeService( + gateway: GatewaySlice, + activity: ActivitySlice, +): UsageMonitorService { + const host: UsageHost = { + ...gateway, + ...activity, + ...makeThresholdStore(), + }; + return new UsageMonitorService(host, makeLogger()); } function makeUsage(overrides?: { @@ -67,29 +92,19 @@ function makeUsage(overrides?: { }; } -function mockGateway(usage: UsageOutput | null): LlmGatewayService { +function mockGateway(usage: UsageOutput | null): GatewaySlice { return { fetchUsage: vi.fn().mockResolvedValue(usage), - } as unknown as LlmGatewayService; + } as unknown as GatewaySlice; } describe("UsageMonitorService", () => { let service: UsageMonitorService; - let persisted: Record<string, string>; beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-25T12:00:00.000Z")); persisted = {}; - mockStoreGet.mockImplementation((_key: string, fallback: unknown) => ({ - ...persisted, - ...(fallback as Record<string, string>), - })); - mockStoreSet.mockImplementation( - (_key: string, value: Record<string, string>) => { - persisted = { ...value }; - }, - ); }); afterEach(() => { @@ -100,7 +115,7 @@ describe("UsageMonitorService", () => { it("emits at 75% but not again on the next poll for the same anchor", async () => { const events: unknown[] = []; const gateway = mockGateway(makeUsage({ burstPercent: 78 })); - service = new UsageMonitorService(gateway, makeAgentService()); + service = makeService(gateway, makeActivityMonitor()); service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); await service.fetchOnce(); @@ -118,7 +133,7 @@ describe("UsageMonitorService", () => { it("only emits the highest threshold a bucket has crossed", async () => { const events: unknown[] = []; const gateway = mockGateway(makeUsage({ burstPercent: 95 })); - service = new UsageMonitorService(gateway, makeAgentService()); + service = makeService(gateway, makeActivityMonitor()); service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); await service.fetchOnce(); @@ -129,13 +144,13 @@ describe("UsageMonitorService", () => { it("doesn't re-emit after a relaunch with persisted dedupe", async () => { const events: unknown[] = []; const gateway = mockGateway(makeUsage({ burstPercent: 55 })); - service = new UsageMonitorService(gateway, makeAgentService()); + service = makeService(gateway, makeActivityMonitor()); service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); await service.fetchOnce(); expect(events).toHaveLength(1); service.stop(); - service = new UsageMonitorService(gateway, makeAgentService()); + service = makeService(gateway, makeActivityMonitor()); service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); await service.fetchOnce(); expect(events).toHaveLength(1); @@ -150,7 +165,7 @@ describe("UsageMonitorService", () => { billingPeriodEnd: "2026-06-01T00:00:00.000Z", }), ); - service = new UsageMonitorService(gateway, makeAgentService()); + service = makeService(gateway, makeActivityMonitor()); service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); await service.fetchOnce(); @@ -170,7 +185,7 @@ describe("UsageMonitorService", () => { billingPeriodEnd: "2026-06-01T00:00:00.000Z", }), ); - service = new UsageMonitorService(gateway, makeAgentService()); + service = makeService(gateway, makeActivityMonitor()); service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e as { isPro: boolean }), ); @@ -182,9 +197,9 @@ describe("UsageMonitorService", () => { it("marks events with userIsActive from the agent service", async () => { const events: { userIsActive: boolean }[] = []; const gateway = mockGateway(makeUsage({ burstPercent: 78 })); - service = new UsageMonitorService( + service = makeService( gateway, - makeAgentService({ hasActiveSessions: true }), + makeActivityMonitor({ hasActiveSessions: true }), ); service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e as { userIsActive: boolean }), @@ -198,8 +213,8 @@ describe("UsageMonitorService", () => { const events: unknown[] = []; const gateway = { fetchUsage: vi.fn().mockRejectedValue(new Error("not authenticated")), - } as unknown as LlmGatewayService; - service = new UsageMonitorService(gateway, makeAgentService()); + } as unknown as GatewaySlice; + service = makeService(gateway, makeActivityMonitor()); service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); await expect(service.fetchOnce()).resolves.toBeNull(); @@ -214,8 +229,8 @@ describe("UsageMonitorService", () => { .mockResolvedValueOnce(makeUsage({ burstPercent: 20 })) .mockResolvedValueOnce(makeUsage({ burstPercent: 20 })) .mockResolvedValueOnce(makeUsage({ burstPercent: 35 })), - } as unknown as LlmGatewayService; - service = new UsageMonitorService(gateway, makeAgentService()); + } as unknown as GatewaySlice; + service = makeService(gateway, makeActivityMonitor()); service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); expect(service.getLatest()).toBeNull(); @@ -235,8 +250,8 @@ describe("UsageMonitorService", () => { const updates: UsageOutput[] = []; const gateway = { fetchUsage: vi.fn().mockRejectedValue(new Error("offline")), - } as unknown as LlmGatewayService; - service = new UsageMonitorService(gateway, makeAgentService()); + } as unknown as GatewaySlice; + service = makeService(gateway, makeActivityMonitor()); service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); await service.fetchOnce(); @@ -246,7 +261,7 @@ describe("UsageMonitorService", () => { it("refreshNow triggers a fresh fetch and returns the snapshot", async () => { const gateway = mockGateway(makeUsage({ burstPercent: 42 })); - service = new UsageMonitorService(gateway, makeAgentService()); + service = makeService(gateway, makeActivityMonitor()); const result = await service.refreshNow(); expect(result?.burst.used_percent).toBe(42); @@ -255,16 +270,16 @@ describe("UsageMonitorService", () => { it("collapses bursts of LlmActivity into at most one trailing fetch", async () => { const gateway = mockGateway(makeUsage({ burstPercent: 10 })); - const agent = makeAgentService(); - service = new UsageMonitorService(gateway, agent); + const agent = makeActivityMonitor(); + service = makeService(gateway, agent); service.init(); await vi.advanceTimersByTimeAsync(0); expect(gateway.fetchUsage).toHaveBeenCalledTimes(1); - agent.emit(AgentServiceEvent.LlmActivity, undefined); - agent.emit(AgentServiceEvent.LlmActivity, undefined); - agent.emit(AgentServiceEvent.LlmActivity, undefined); - agent.emit(AgentServiceEvent.LlmActivity, undefined); + agent.fireLlmActivity(); + agent.fireLlmActivity(); + agent.fireLlmActivity(); + agent.fireLlmActivity(); await vi.advanceTimersByTimeAsync(0); expect(gateway.fetchUsage).toHaveBeenCalledTimes(1); @@ -272,22 +287,22 @@ describe("UsageMonitorService", () => { expect(gateway.fetchUsage).toHaveBeenCalledTimes(2); await vi.advanceTimersByTimeAsync(60_000); - agent.emit(AgentServiceEvent.LlmActivity, undefined); + agent.fireLlmActivity(); await vi.advanceTimersByTimeAsync(5_000); expect(gateway.fetchUsage).toHaveBeenCalledTimes(3); }); it("unsubscribes from agent events on stop()", async () => { const gateway = mockGateway(makeUsage({ burstPercent: 10 })); - const agent = makeAgentService(); - service = new UsageMonitorService(gateway, agent); + const agent = makeActivityMonitor(); + service = makeService(gateway, agent); service.init(); await vi.advanceTimersByTimeAsync(0); const baseline = (gateway.fetchUsage as ReturnType<typeof vi.fn>).mock.calls .length; service.stop(); - agent.emit(AgentServiceEvent.LlmActivity, undefined); + agent.fireLlmActivity(); await vi.advanceTimersByTimeAsync(10_000); expect(gateway.fetchUsage).toHaveBeenCalledTimes(baseline); }); diff --git a/apps/code/src/main/services/usage-monitor/service.ts b/packages/core/src/usage/usage-monitor.ts similarity index 83% rename from apps/code/src/main/services/usage-monitor/service.ts rename to packages/core/src/usage/usage-monitor.ts index 41f02bc121..bee75b7d49 100644 --- a/apps/code/src/main/services/usage-monitor/service.ts +++ b/packages/core/src/usage/usage-monitor.ts @@ -1,20 +1,14 @@ +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { TypedEventEmitter } from "@posthog/shared"; import { inject, injectable, postConstruct, preDestroy } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import { AgentServiceEvent } from "../agent/schemas"; -import type { AgentService } from "../agent/service"; -import type { UsageBucket, UsageOutput } from "../llm-gateway/schemas"; -import type { LlmGatewayService } from "../llm-gateway/service"; +import { USAGE_HOST, type UsageHost, type UsageLogger } from "./identifiers"; import { USAGE_THRESHOLDS, UsageMonitorEvent, type UsageMonitorEvents, type UsageThreshold, -} from "./schemas"; -import { usageMonitorStore } from "./store"; - -const log = logger.scope("usage-monitor"); +} from "./monitor-schemas"; +import type { UsageBucket, UsageOutput } from "./schemas"; const COALESCE_INTERVAL_MS = 5_000; // Catches reset-window rollovers and out-of-band plan changes while the app @@ -35,15 +29,18 @@ export class UsageMonitorService extends TypedEventEmitter<UsageMonitorEvents> { private readonly onLlmActivity = (): void => this.requestRefresh(); constructor( - @inject(MAIN_TOKENS.LlmGatewayService) - private readonly llmGateway: LlmGatewayService, - @inject(MAIN_TOKENS.AgentService) - private readonly agentService: AgentService, + @inject(USAGE_HOST) + private readonly host: UsageHost, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, ) { super(); - this.thresholdsSeen = { ...usageMonitorStore.get("thresholdsSeen", {}) }; + this.log = logger.scope("usage-monitor"); + this.thresholdsSeen = { ...this.host.getThresholdsSeen() }; } + private readonly log: UsageLogger; + getLatest(): UsageOutput | null { return this.latestUsage; } @@ -70,14 +67,14 @@ export class UsageMonitorService extends TypedEventEmitter<UsageMonitorEvents> { @postConstruct() init(): void { this.pruneStaleEntries(); - this.agentService.on(AgentServiceEvent.LlmActivity, this.onLlmActivity); + this.host.onLlmActivity(this.onLlmActivity); void this.fetchOnce(); this.scheduleBackstop(); } @preDestroy() stop(): void { - this.agentService.off(AgentServiceEvent.LlmActivity, this.onLlmActivity); + this.host.offLlmActivity(this.onLlmActivity); if (this.backstopTimeoutId) { clearTimeout(this.backstopTimeoutId); this.backstopTimeoutId = null; @@ -99,9 +96,9 @@ export class UsageMonitorService extends TypedEventEmitter<UsageMonitorEvents> { try { let usage: UsageOutput | null = null; try { - usage = await this.llmGateway.fetchUsage(); + usage = await this.host.fetchUsage(); } catch (err) { - log.debug("Usage fetch skipped", { + this.log.debug("Usage fetch skipped", { error: err instanceof Error ? err.message : String(err), }); } @@ -159,9 +156,9 @@ export class UsageMonitorService extends TypedEventEmitter<UsageMonitorEvents> { if (this.thresholdsSeen[key]) return; this.thresholdsSeen[key] = anchor; - usageMonitorStore.set("thresholdsSeen", this.thresholdsSeen); + this.host.setThresholdsSeen(this.thresholdsSeen); - log.info("Usage threshold crossed", { + this.log.info("Usage threshold crossed", { bucket, threshold, usedPercent: status.used_percent, @@ -173,7 +170,7 @@ export class UsageMonitorService extends TypedEventEmitter<UsageMonitorEvents> { usedPercent: status.used_percent, resetAt: status.reset_at, isPro, - userIsActive: this.agentService.hasActiveSessions(), + userIsActive: this.host.hasActiveSessions(), }); } @@ -201,7 +198,7 @@ export class UsageMonitorService extends TypedEventEmitter<UsageMonitorEvents> { } } if (dirty) { - usageMonitorStore.set("thresholdsSeen", this.thresholdsSeen); + this.host.setThresholdsSeen(this.thresholdsSeen); } } } diff --git a/packages/core/src/workspace/WorkspaceSetupService.test.ts b/packages/core/src/workspace/WorkspaceSetupService.test.ts new file mode 100644 index 0000000000..c5683f6ad7 --- /dev/null +++ b/packages/core/src/workspace/WorkspaceSetupService.test.ts @@ -0,0 +1,86 @@ +import type { WorkbenchLogger } from "@posthog/di/logger"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceSetupGitClient } from "./identifiers"; +import type { DetectedRepoFullName } from "./repoMismatch"; +import { WorkspaceSetupService } from "./WorkspaceSetupService"; + +function makeLogger(): WorkbenchLogger { + const scoped = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + return { ...scoped, scope: vi.fn(() => scoped) }; +} + +function makeService( + detectRepo: WorkspaceSetupGitClient["detectRepo"], +): WorkspaceSetupService { + const git: WorkspaceSetupGitClient = { detectRepo }; + return new WorkspaceSetupService(git, makeLogger()); +} + +const detected: DetectedRepoFullName = { + organization: "PostHog", + repository: "posthog", +}; + +describe("WorkspaceSetupService.evaluateFolderSelection", () => { + it("proceeds when task has no linked repository", async () => { + const detectRepo = vi.fn(); + const service = makeService(detectRepo); + + const result = await service.evaluateFolderSelection(null, "/some/path"); + + expect(result).toEqual({ kind: "proceed" }); + expect(detectRepo).not.toHaveBeenCalled(); + }); + + it("proceeds when detected repo matches the linked repository", async () => { + const service = makeService(vi.fn().mockResolvedValue(detected)); + + const result = await service.evaluateFolderSelection( + "posthog/POSTHOG", + "/repo", + ); + + expect(result).toEqual({ kind: "proceed" }); + }); + + it("flags a mismatch when detected repo differs", async () => { + const service = makeService(vi.fn().mockResolvedValue(detected)); + + const result = await service.evaluateFolderSelection( + "PostHog/other", + "/repo", + ); + + expect(result).toEqual({ + kind: "mismatch", + detectedRepo: "PostHog/posthog", + }); + }); + + it("proceeds when no repo could be detected", async () => { + const service = makeService(vi.fn().mockResolvedValue(null)); + + const result = await service.evaluateFolderSelection( + "PostHog/posthog", + "/repo", + ); + + expect(result).toEqual({ kind: "proceed" }); + }); + + it("proceeds when detection throws", async () => { + const service = makeService(vi.fn().mockRejectedValue(new Error("boom"))); + + const result = await service.evaluateFolderSelection( + "PostHog/posthog", + "/repo", + ); + + expect(result).toEqual({ kind: "proceed" }); + }); +}); diff --git a/packages/core/src/workspace/WorkspaceSetupService.ts b/packages/core/src/workspace/WorkspaceSetupService.ts new file mode 100644 index 0000000000..355de32262 --- /dev/null +++ b/packages/core/src/workspace/WorkspaceSetupService.ts @@ -0,0 +1,57 @@ +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; +import { + WORKSPACE_SETUP_GIT_CLIENT, + type WorkspaceSetupGitClient, +} from "./identifiers"; +import { detectRepoFullName, isRepoMismatch } from "./repoMismatch"; + +export type FolderSelectionEvaluation = + | { kind: "mismatch"; detectedRepo: string } + | { kind: "proceed" }; + +@injectable() +export class WorkspaceSetupService { + private readonly log: ScopedLogger; + + constructor( + @inject(WORKSPACE_SETUP_GIT_CLIENT) + private readonly git: WorkspaceSetupGitClient, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, + ) { + this.log = workbenchLogger.scope("workspace-setup-service"); + } + + public async evaluateFolderSelection( + repository: string | null, + path: string, + ): Promise<FolderSelectionEvaluation> { + if (!repository) { + return { kind: "proceed" }; + } + + let detected: Awaited<ReturnType<WorkspaceSetupGitClient["detectRepo"]>> = + null; + try { + detected = await this.git.detectRepo({ directoryPath: path }); + } catch (error) { + this.log.warn("Failed to detect repo for mismatch check", { + error, + path, + }); + return { kind: "proceed" }; + } + + const detectedFullName = detectRepoFullName(detected); + if (detectedFullName && isRepoMismatch(repository, detectedFullName)) { + return { kind: "mismatch", detectedRepo: detectedFullName }; + } + + return { kind: "proceed" }; + } +} diff --git a/packages/core/src/workspace/branchMismatch.test.ts b/packages/core/src/workspace/branchMismatch.test.ts new file mode 100644 index 0000000000..1375513f05 --- /dev/null +++ b/packages/core/src/workspace/branchMismatch.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { isBranchMismatch, shouldWarnBranchMismatch } from "./branchMismatch"; + +describe("isBranchMismatch", () => { + it("is false when linked branch is null", () => { + expect(isBranchMismatch(null, "main")).toBe(false); + }); + + it("is false when current branch is null", () => { + expect(isBranchMismatch("feat/foo", null)).toBe(false); + }); + + it("is false when branches match", () => { + expect(isBranchMismatch("feat/foo", "feat/foo")).toBe(false); + }); + + it("is true when branches differ", () => { + expect(isBranchMismatch("feat/foo", "main")).toBe(true); + }); +}); + +describe("shouldWarnBranchMismatch", () => { + it("is true when mismatched and not dismissed", () => { + expect(shouldWarnBranchMismatch("feat/foo", "main", false)).toBe(true); + }); + + it("is false when dismissed", () => { + expect(shouldWarnBranchMismatch("feat/foo", "main", true)).toBe(false); + }); + + it("is false when branches match", () => { + expect(shouldWarnBranchMismatch("feat/foo", "feat/foo", false)).toBe(false); + }); +}); diff --git a/packages/core/src/workspace/branchMismatch.ts b/packages/core/src/workspace/branchMismatch.ts new file mode 100644 index 0000000000..a5028ec6db --- /dev/null +++ b/packages/core/src/workspace/branchMismatch.ts @@ -0,0 +1,14 @@ +export function isBranchMismatch( + linkedBranch: string | null, + currentBranch: string | null, +): boolean { + return !!linkedBranch && !!currentBranch && linkedBranch !== currentBranch; +} + +export function shouldWarnBranchMismatch( + linkedBranch: string | null, + currentBranch: string | null, + dismissed: boolean, +): boolean { + return isBranchMismatch(linkedBranch, currentBranch) && !dismissed; +} diff --git a/packages/core/src/workspace/branchMismatchDialog.test.ts b/packages/core/src/workspace/branchMismatchDialog.test.ts new file mode 100644 index 0000000000..632fc5d475 --- /dev/null +++ b/packages/core/src/workspace/branchMismatchDialog.test.ts @@ -0,0 +1,89 @@ +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { + buildBranchMismatchAnalyticsEvent, + buildCheckoutBranchRequest, + decideBeforeSubmit, + resolveSwitchErrorMessage, +} from "./branchMismatchDialog"; + +const context = { + taskId: "task-1", + linkedBranch: "feat/foo", + currentBranch: "main", + hasUncommittedChanges: true, +}; + +describe("decideBeforeSubmit", () => { + it("allows submit when not warning", () => { + expect(decideBeforeSubmit(false)).toBe(true); + }); + + it("blocks submit when warning", () => { + expect(decideBeforeSubmit(true)).toBe(false); + }); +}); + +describe("buildBranchMismatchAnalyticsEvent", () => { + it("builds the warning-shown event", () => { + expect(buildBranchMismatchAnalyticsEvent("shown", context)).toEqual({ + event: ANALYTICS_EVENTS.BRANCH_MISMATCH_WARNING_SHOWN, + properties: { + task_id: "task-1", + linked_branch: "feat/foo", + current_branch: "main", + has_uncommitted_changes: true, + }, + }); + }); + + it("builds the action event", () => { + expect(buildBranchMismatchAnalyticsEvent("switch", context)).toEqual({ + event: ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION, + properties: { + task_id: "task-1", + action: "switch", + linked_branch: "feat/foo", + current_branch: "main", + }, + }); + }); + + it("returns null without both branches", () => { + expect( + buildBranchMismatchAnalyticsEvent("cancel", { + ...context, + linkedBranch: null, + }), + ).toBeNull(); + }); +}); + +describe("buildCheckoutBranchRequest", () => { + it("builds the request", () => { + expect(buildCheckoutBranchRequest("/repo", "feat/foo")).toEqual({ + directoryPath: "/repo", + branchName: "feat/foo", + }); + }); + + it("returns null without repo path", () => { + expect(buildCheckoutBranchRequest(null, "feat/foo")).toBeNull(); + }); + + it("returns null without linked branch", () => { + expect(buildCheckoutBranchRequest("/repo", null)).toBeNull(); + }); +}); + +describe("resolveSwitchErrorMessage", () => { + it("uses error message", () => { + expect(resolveSwitchErrorMessage(new Error("dirty worktree"))).toBe( + "dirty worktree", + ); + }); + + it("falls back for non-errors", () => { + expect(resolveSwitchErrorMessage("oops")).toBe("Failed to switch branch"); + }); +}); diff --git a/packages/core/src/workspace/branchMismatchDialog.ts b/packages/core/src/workspace/branchMismatchDialog.ts new file mode 100644 index 0000000000..d92d2388b3 --- /dev/null +++ b/packages/core/src/workspace/branchMismatchDialog.ts @@ -0,0 +1,84 @@ +import { + ANALYTICS_EVENTS, + type BranchMismatchActionProperties, + type BranchMismatchWarningShownProperties, +} from "@posthog/shared"; + +export type BranchMismatchDialogAction = + | "switch" + | "continue" + | "cancel" + | "shown"; + +export interface BranchMismatchContext { + taskId: string; + linkedBranch: string | null; + currentBranch: string | null; + hasUncommittedChanges: boolean; +} + +export interface CheckoutBranchRequest { + directoryPath: string; + branchName: string; +} + +export type BranchMismatchAnalyticsEvent = + | { + event: typeof ANALYTICS_EVENTS.BRANCH_MISMATCH_WARNING_SHOWN; + properties: BranchMismatchWarningShownProperties; + } + | { + event: typeof ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION; + properties: BranchMismatchActionProperties; + }; + +export function decideBeforeSubmit(shouldWarn: boolean): boolean { + return !shouldWarn; +} + +export function buildBranchMismatchAnalyticsEvent( + action: BranchMismatchDialogAction, + context: BranchMismatchContext, +): BranchMismatchAnalyticsEvent | null { + const { taskId, linkedBranch, currentBranch, hasUncommittedChanges } = + context; + if (!linkedBranch || !currentBranch) { + return null; + } + + if (action === "shown") { + return { + event: ANALYTICS_EVENTS.BRANCH_MISMATCH_WARNING_SHOWN, + properties: { + task_id: taskId, + linked_branch: linkedBranch, + current_branch: currentBranch, + has_uncommitted_changes: hasUncommittedChanges, + }, + }; + } + + return { + event: ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION, + properties: { + task_id: taskId, + action, + linked_branch: linkedBranch, + current_branch: currentBranch, + }, + }; +} + +export function buildCheckoutBranchRequest( + repoPath: string | null, + linkedBranch: string | null, +): CheckoutBranchRequest | null { + if (!repoPath || !linkedBranch) { + return null; + } + return { directoryPath: repoPath, branchName: linkedBranch }; +} + +export function resolveSwitchErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Failed to switch branch"; +} diff --git a/packages/core/src/workspace/ensureWorkspace.test.ts b/packages/core/src/workspace/ensureWorkspace.test.ts new file mode 100644 index 0000000000..e65967fd2b --- /dev/null +++ b/packages/core/src/workspace/ensureWorkspace.test.ts @@ -0,0 +1,46 @@ +import type { Workspace } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { + buildCreateWorkspaceRequest, + selectExistingWorkspace, +} from "./ensureWorkspace"; + +describe("buildCreateWorkspaceRequest", () => { + it("defaults to worktree mode and undefined branch", () => { + expect(buildCreateWorkspaceRequest("t1", "/repo")).toEqual({ + taskId: "t1", + mainRepoPath: "/repo", + folderId: "", + folderPath: "/repo", + mode: "worktree", + branch: undefined, + }); + }); + + it("normalizes a null branch to undefined", () => { + expect( + buildCreateWorkspaceRequest("t1", "/repo", "local", null).branch, + ).toBe(undefined); + }); + + it("passes through an explicit branch", () => { + expect( + buildCreateWorkspaceRequest("t1", "/repo", "worktree", "feat/foo").branch, + ).toBe("feat/foo"); + }); +}); + +describe("selectExistingWorkspace", () => { + it("returns the workspace for a task", () => { + const ws = { taskId: "t1" } as unknown as Workspace; + expect(selectExistingWorkspace({ t1: ws }, "t1")).toBe(ws); + }); + + it("returns null when absent", () => { + expect(selectExistingWorkspace({}, "t1")).toBeNull(); + }); + + it("returns null when map is undefined", () => { + expect(selectExistingWorkspace(undefined, "t1")).toBeNull(); + }); +}); diff --git a/packages/core/src/workspace/ensureWorkspace.ts b/packages/core/src/workspace/ensureWorkspace.ts new file mode 100644 index 0000000000..0b036ecec5 --- /dev/null +++ b/packages/core/src/workspace/ensureWorkspace.ts @@ -0,0 +1,33 @@ +import type { Workspace, WorkspaceMode } from "@posthog/shared"; + +export interface CreateWorkspaceRequest { + taskId: string; + mainRepoPath: string; + folderId: string; + folderPath: string; + mode: WorkspaceMode; + branch: string | undefined; +} + +export function buildCreateWorkspaceRequest( + taskId: string, + repoPath: string, + mode: WorkspaceMode = "worktree", + branch?: string | null, +): CreateWorkspaceRequest { + return { + taskId, + mainRepoPath: repoPath, + folderId: "", + folderPath: repoPath, + mode, + branch: branch ?? undefined, + }; +} + +export function selectExistingWorkspace( + workspaces: Record<string, Workspace> | undefined, + taskId: string, +): Workspace | null { + return workspaces?.[taskId] ?? null; +} diff --git a/packages/core/src/workspace/focusWorkspace.test.ts b/packages/core/src/workspace/focusWorkspace.test.ts new file mode 100644 index 0000000000..9046012735 --- /dev/null +++ b/packages/core/src/workspace/focusWorkspace.test.ts @@ -0,0 +1,69 @@ +import type { Workspace } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { + buildEnableFocusParams, + canFocusWorkspace, + focusTerminalKey, +} from "./focusWorkspace"; + +function makeWorkspace(overrides: Partial<Workspace>): Workspace { + return { + taskId: "t1", + folderId: "f1", + folderPath: "/repo", + mode: "worktree", + worktreePath: "/repo/.worktrees/foo", + worktreeName: "foo", + branchName: "feat/foo", + baseBranch: "main", + linkedBranch: "feat/foo", + createdAt: "2024-01-01", + ...overrides, + }; +} + +describe("canFocusWorkspace", () => { + it("is true for a complete worktree workspace", () => { + expect(canFocusWorkspace(makeWorkspace({}))).toBe(true); + }); + + it("is false for non-worktree mode", () => { + expect(canFocusWorkspace(makeWorkspace({ mode: "local" }))).toBe(false); + }); + + it("is false without a branch name", () => { + expect(canFocusWorkspace(makeWorkspace({ branchName: null }))).toBe(false); + }); + + it("is false without a worktree path", () => { + expect(canFocusWorkspace(makeWorkspace({ worktreePath: null }))).toBe( + false, + ); + }); + + it("is false for null workspace", () => { + expect(canFocusWorkspace(null)).toBe(false); + }); +}); + +describe("focusTerminalKey", () => { + it("derives the terminal key", () => { + expect(focusTerminalKey("t1", "feat/foo")).toBe( + "focus-terminal-t1-feat/foo", + ); + }); +}); + +describe("buildEnableFocusParams", () => { + it("builds params from a focusable workspace", () => { + expect(buildEnableFocusParams(makeWorkspace({}))).toEqual({ + mainRepoPath: "/repo", + worktreePath: "/repo/.worktrees/foo", + branch: "feat/foo", + }); + }); + + it("returns null for a non-focusable workspace", () => { + expect(buildEnableFocusParams(makeWorkspace({ mode: "cloud" }))).toBeNull(); + }); +}); diff --git a/packages/core/src/workspace/focusWorkspace.ts b/packages/core/src/workspace/focusWorkspace.ts new file mode 100644 index 0000000000..8e9a012504 --- /dev/null +++ b/packages/core/src/workspace/focusWorkspace.ts @@ -0,0 +1,35 @@ +import type { Workspace } from "@posthog/shared"; + +export interface EnableFocusParams { + mainRepoPath: string; + worktreePath: string; + branch: string; +} + +export function canFocusWorkspace(workspace: Workspace | null): boolean { + return ( + !!workspace && + workspace.mode === "worktree" && + !!workspace.branchName && + !!workspace.worktreePath + ); +} + +export function focusTerminalKey(taskId: string, branch: string): string { + return `focus-terminal-${taskId}-${branch}`; +} + +export function buildEnableFocusParams( + workspace: Workspace | null, +): EnableFocusParams | null { + if (!canFocusWorkspace(workspace) || !workspace) { + return null; + } + return { + mainRepoPath: workspace.folderPath, + // biome-ignore lint/style/noNonNullAssertion: guarded by canFocusWorkspace + worktreePath: workspace.worktreePath!, + // biome-ignore lint/style/noNonNullAssertion: guarded by canFocusWorkspace + branch: workspace.branchName!, + }; +} diff --git a/packages/core/src/workspace/identifiers.ts b/packages/core/src/workspace/identifiers.ts new file mode 100644 index 0000000000..33d764a128 --- /dev/null +++ b/packages/core/src/workspace/identifiers.ts @@ -0,0 +1,14 @@ +import type { DetectedRepoFullName } from "./repoMismatch"; + +export const WORKSPACE_SETUP_SERVICE = Symbol.for( + "posthog.core.workspace.setupService", +); +export const WORKSPACE_SETUP_GIT_CLIENT = Symbol.for( + "posthog.core.workspace.setupGitClient", +); + +export interface WorkspaceSetupGitClient { + detectRepo(args: { + directoryPath: string; + }): Promise<DetectedRepoFullName | null>; +} diff --git a/packages/core/src/workspace/localRepoPath.test.ts b/packages/core/src/workspace/localRepoPath.test.ts new file mode 100644 index 0000000000..ebefe92da2 --- /dev/null +++ b/packages/core/src/workspace/localRepoPath.test.ts @@ -0,0 +1,41 @@ +import type { Workspace } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { resolveLocalRepoPath } from "./localRepoPath"; + +function makeWorkspace(overrides: Partial<Workspace>): Workspace { + return { + taskId: "t1", + folderId: "f1", + folderPath: "/repo", + mode: "worktree", + worktreePath: "/repo/.worktrees/foo", + worktreeName: "foo", + branchName: "feat/foo", + baseBranch: "main", + linkedBranch: "feat/foo", + createdAt: "2024-01-01", + ...overrides, + }; +} + +describe("resolveLocalRepoPath", () => { + it("returns undefined without a workspace", () => { + expect(resolveLocalRepoPath(null, false)).toBeUndefined(); + }); + + it("targets the main repo when focused", () => { + expect(resolveLocalRepoPath(makeWorkspace({}), true)).toBe("/repo"); + }); + + it("targets the worktree when not focused", () => { + expect(resolveLocalRepoPath(makeWorkspace({}), false)).toBe( + "/repo/.worktrees/foo", + ); + }); + + it("falls back to folder path when worktree path is null", () => { + expect( + resolveLocalRepoPath(makeWorkspace({ worktreePath: null }), false), + ).toBe("/repo"); + }); +}); diff --git a/packages/core/src/workspace/localRepoPath.ts b/packages/core/src/workspace/localRepoPath.ts new file mode 100644 index 0000000000..6ae91e34f8 --- /dev/null +++ b/packages/core/src/workspace/localRepoPath.ts @@ -0,0 +1,13 @@ +import type { Workspace } from "@posthog/shared"; + +export function resolveLocalRepoPath( + workspace: Workspace | null, + isFocused: boolean, +): string | undefined { + if (!workspace) { + return undefined; + } + return isFocused + ? workspace.folderPath + : (workspace.worktreePath ?? workspace.folderPath); +} diff --git a/packages/core/src/workspace/repoMismatch.test.ts b/packages/core/src/workspace/repoMismatch.test.ts new file mode 100644 index 0000000000..49d36d8f53 --- /dev/null +++ b/packages/core/src/workspace/repoMismatch.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { detectRepoFullName, isRepoMismatch } from "./repoMismatch"; + +describe("detectRepoFullName", () => { + it("is null when nothing detected", () => { + expect(detectRepoFullName(null)).toBe(null); + }); + + it("joins organization and repository", () => { + expect( + detectRepoFullName({ organization: "PostHog", repository: "posthog" }), + ).toBe("PostHog/posthog"); + }); +}); + +describe("isRepoMismatch", () => { + it("is false when linked repo is null", () => { + expect(isRepoMismatch(null, "PostHog/posthog")).toBe(false); + }); + + it("is false when detected full name is null", () => { + expect(isRepoMismatch("PostHog/posthog", null)).toBe(false); + }); + + it("is false when names match exactly", () => { + expect(isRepoMismatch("PostHog/posthog", "PostHog/posthog")).toBe(false); + }); + + it("is false when names match case-insensitively", () => { + expect(isRepoMismatch("PostHog/posthog", "posthog/POSTHOG")).toBe(false); + }); + + it("is true when names differ", () => { + expect(isRepoMismatch("PostHog/posthog", "PostHog/other")).toBe(true); + }); +}); diff --git a/packages/core/src/workspace/repoMismatch.ts b/packages/core/src/workspace/repoMismatch.ts new file mode 100644 index 0000000000..4d8fd39d90 --- /dev/null +++ b/packages/core/src/workspace/repoMismatch.ts @@ -0,0 +1,23 @@ +export interface DetectedRepoFullName { + organization: string; + repository: string; +} + +export function detectRepoFullName( + detected: DetectedRepoFullName | null, +): string | null { + if (!detected) { + return null; + } + return `${detected.organization}/${detected.repository}`; +} + +export function isRepoMismatch( + linkedRepo: string | null, + detectedFullName: string | null, +): boolean { + if (!linkedRepo || !detectedFullName) { + return false; + } + return detectedFullName.toLowerCase() !== linkedRepo.toLowerCase(); +} diff --git a/packages/core/src/workspace/workspace.module.ts b/packages/core/src/workspace/workspace.module.ts new file mode 100644 index 0000000000..99ebaecb19 --- /dev/null +++ b/packages/core/src/workspace/workspace.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { WORKSPACE_SETUP_SERVICE } from "./identifiers"; +import { WorkspaceSetupService } from "./WorkspaceSetupService"; + +export const workspaceModule = new ContainerModule(({ bind }) => { + bind(WORKSPACE_SETUP_SERVICE).to(WorkspaceSetupService).inSingletonScope(); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 703bc8a1d2..e234dee6da 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,4 +1,8 @@ { "extends": "@posthog/tsconfig/base.json", + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, "include": ["src/**/*"] } diff --git a/packages/di/package.json b/packages/di/package.json new file mode 100644 index 0000000000..84ddb900b2 --- /dev/null +++ b/packages/di/package.json @@ -0,0 +1,34 @@ +{ + "name": "@posthog/di", + "version": "1.0.0", + "description": "Workbench DI primitives. Owns the WorkbenchContribution token + interface, startWorkbench(), the workbench logging port, and the useService React boundary hook. Framework-light: depends only on inversify, with React as a peer for the boundary hook.", + "private": true, + "type": "module", + "exports": { + "./*": [ + "./src/*.ts", + "./src/*.tsx" + ] + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "node ../../scripts/rimraf.mjs .turbo" + }, + "dependencies": { + "inversify": "catalog:" + }, + "peerDependencies": { + "react": "catalog:" + }, + "devDependencies": { + "@posthog/tsconfig": "workspace:*", + "@types/react": "catalog:", + "react": "catalog:", + "typescript": "catalog:", + "vitest": "^2.1.9" + }, + "files": [ + "src/**/*" + ] +} diff --git a/packages/di/src/container.ts b/packages/di/src/container.ts new file mode 100644 index 0000000000..de0ddee809 --- /dev/null +++ b/packages/di/src/container.ts @@ -0,0 +1,40 @@ +import type { Container, ServiceIdentifier } from "inversify"; + +let workbenchContainer: Container | null = null; +const pendingBindings: Array<(container: Container) => void> = []; + +export function setWorkbenchContainer(container: Container): void { + workbenchContainer = container; + for (const bind of pendingBindings) { + bind(container); + } + pendingBindings.length = 0; +} + +export function bindWorkbench(bind: (container: Container) => void): void { + if (workbenchContainer) { + bind(workbenchContainer); + } else { + pendingBindings.push(bind); + } +} + +export function resolveService<T>(serviceIdentifier: ServiceIdentifier<T>): T { + if (!workbenchContainer) { + throw new Error( + "resolveService called before setWorkbenchContainer; the workbench container is not initialized", + ); + } + + return workbenchContainer.get<T>(serviceIdentifier); +} + +export function resolveServiceOptional<T>( + serviceIdentifier: ServiceIdentifier<T>, +): T | null { + if (!workbenchContainer || !workbenchContainer.isBound(serviceIdentifier)) { + return null; + } + + return workbenchContainer.get<T>(serviceIdentifier); +} diff --git a/packages/di/src/contribution.test.ts b/packages/di/src/contribution.test.ts new file mode 100644 index 0000000000..ef61d1e108 --- /dev/null +++ b/packages/di/src/contribution.test.ts @@ -0,0 +1,49 @@ +import { Container } from "inversify"; +import { describe, expect, it } from "vitest"; +import { + startWorkbench, + WORKBENCH_CONTRIBUTION, + type WorkbenchContribution, +} from "./contribution"; + +describe("startWorkbench", () => { + it("resolves nothing when no contribution is bound", async () => { + const container = new Container(); + await expect(startWorkbench(container)).resolves.toBeUndefined(); + }); + + it("starts every bound contribution in binding order", async () => { + const started: string[] = []; + const make = (name: string): WorkbenchContribution => ({ + start() { + started.push(name); + }, + }); + + const container = new Container(); + container.bind(WORKBENCH_CONTRIBUTION).toConstantValue(make("first")); + container.bind(WORKBENCH_CONTRIBUTION).toConstantValue(make("second")); + + await startWorkbench(container); + + expect(started).toEqual(["first", "second"]); + }); + + it("awaits async contributions before resolving", async () => { + const order: string[] = []; + const slow: WorkbenchContribution = { + async start() { + await Promise.resolve(); + order.push("slow-start-done"); + }, + }; + + const container = new Container(); + container.bind(WORKBENCH_CONTRIBUTION).toConstantValue(slow); + + await startWorkbench(container); + order.push("after-start-workbench"); + + expect(order).toEqual(["slow-start-done", "after-start-workbench"]); + }); +}); diff --git a/packages/ui/src/workbench/contribution.ts b/packages/di/src/contribution.ts similarity index 83% rename from packages/ui/src/workbench/contribution.ts rename to packages/di/src/contribution.ts index 89ad053e32..abcd8aa0c4 100644 --- a/packages/ui/src/workbench/contribution.ts +++ b/packages/di/src/contribution.ts @@ -8,9 +8,7 @@ export const WORKBENCH_CONTRIBUTION = Symbol.for( "posthog.workbenchContribution", ); -export async function startWorkbenchContributions( - container: Container, -): Promise<void> { +export async function startWorkbench(container: Container): Promise<void> { if (!container.isBound(WORKBENCH_CONTRIBUTION)) { return; } diff --git a/packages/di/src/logger.ts b/packages/di/src/logger.ts new file mode 100644 index 0000000000..77d2aa6abc --- /dev/null +++ b/packages/di/src/logger.ts @@ -0,0 +1,12 @@ +export interface ScopedLogger { + debug(...args: unknown[]): void; + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; +} + +export interface WorkbenchLogger extends ScopedLogger { + scope(name: string): ScopedLogger; +} + +export const WORKBENCH_LOGGER = Symbol.for("posthog.workbench.logger"); diff --git a/packages/ui/src/workbench/service-context.tsx b/packages/di/src/react.tsx similarity index 66% rename from packages/ui/src/workbench/service-context.tsx rename to packages/di/src/react.tsx index b484a4b7e5..482556dc16 100644 --- a/packages/ui/src/workbench/service-context.tsx +++ b/packages/di/src/react.tsx @@ -4,6 +4,7 @@ import { createContext, useContext, useMemo } from "react"; interface ServiceContainer { get<T>(serviceIdentifier: ServiceIdentifier<T>): T; + isBound(serviceIdentifier: ServiceIdentifier<unknown>): boolean; } const ServiceContext = createContext<ServiceContainer | null>(null); @@ -30,3 +31,18 @@ export function useService<T>(serviceIdentifier: ServiceIdentifier<T>): T { return container.get(serviceIdentifier); } + +export function useServiceOptional<T>( + serviceIdentifier: ServiceIdentifier<T>, +): T | null { + const container = useContext(ServiceContext); + if (!container) { + throw new Error("useServiceOptional must be used within a ServiceProvider"); + } + + if (!container.isBound(serviceIdentifier)) { + return null; + } + + return container.get(serviceIdentifier); +} diff --git a/packages/di/tsconfig.json b/packages/di/tsconfig.json new file mode 100644 index 0000000000..d9b10e2eee --- /dev/null +++ b/packages/di/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@posthog/tsconfig/react-package.json", + "include": ["src/**/*"] +} diff --git a/packages/enricher/package.json b/packages/enricher/package.json index d1c5631014..e61f2158ca 100644 --- a/packages/enricher/package.json +++ b/packages/enricher/package.json @@ -18,6 +18,7 @@ "test": "vitest run" }, "dependencies": { + "@posthog/shared": "workspace:*", "web-tree-sitter": "^0.24.7" }, "devDependencies": { diff --git a/packages/enricher/src/serialize.ts b/packages/enricher/src/serialize.ts index f4214d24bf..5720938f58 100644 --- a/packages/enricher/src/serialize.ts +++ b/packages/enricher/src/serialize.ts @@ -1,59 +1,19 @@ +import type { + SerializedEnrichment, + SerializedEvent, + SerializedFlag, +} from "@posthog/shared"; import type { EnrichedResult } from "./enriched-result.js"; -import type { FlagType, StalenessReason } from "./types.js"; -export interface SerializedFlagOccurrence { - method: string; - line: number; - startCol: number; - endCol: number; -} - -export interface SerializedFlagVariant { - key: string; - rolloutPercentage: number; -} - -export interface SerializedFlagExperiment { - id: number; - name: string; - status: "running" | "complete"; -} - -export interface SerializedFlag { - flagKey: string; - flagId: number | null; - flagType: FlagType; - staleness: StalenessReason | null; - rollout: number | null; - active: boolean; - variants: SerializedFlagVariant[]; - occurrences: SerializedFlagOccurrence[]; - experiment: SerializedFlagExperiment | null; -} - -export interface SerializedEventOccurrence { - line: number; - startCol: number; - endCol: number; - dynamic: boolean; -} - -export interface SerializedEvent { - eventName: string; - definitionId: string | null; - verified: boolean; - description: string | null; - tags: string[]; - lastSeenAt: string | null; - volume: number | null; - uniqueUsers: number | null; - occurrences: SerializedEventOccurrence[]; -} - -export interface SerializedEnrichment { - flags: SerializedFlag[]; - events: SerializedEvent[]; -} +export type { + SerializedEnrichment, + SerializedEvent, + SerializedEventOccurrence, + SerializedFlag, + SerializedFlagExperiment, + SerializedFlagOccurrence, + SerializedFlagVariant, +} from "@posthog/shared"; export function toSerializable(enriched: EnrichedResult): SerializedEnrichment { const flags: SerializedFlag[] = enriched.flags.map((f) => ({ diff --git a/packages/enricher/src/types.ts b/packages/enricher/src/types.ts index f076e71ecd..fb71370261 100644 --- a/packages/enricher/src/types.ts +++ b/packages/enricher/src/types.ts @@ -176,13 +176,8 @@ export interface EventDefinition { // ── Stale flag types ── -export type StalenessReason = - | "fully_rolled_out" - | "inactive" - | "not_in_posthog" - | "experiment_complete"; - -export type FlagType = "boolean" | "multivariate" | "remote_config"; +import type { FlagType, StalenessReason } from "@posthog/shared"; +export type { FlagType, StalenessReason }; // ── Enricher types ── diff --git a/packages/git/src/handoff.ts b/packages/git/src/handoff.ts index 43c21194ce..86d8a74ce2 100644 --- a/packages/git/src/handoff.ts +++ b/packages/git/src/handoff.ts @@ -2,37 +2,23 @@ import { spawn } from "node:child_process"; import { copyFile, mkdtemp, readFile, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import type { SagaLogger } from "@posthog/shared"; +import type { + GitHandoffCheckpoint, + HandoffLocalGitState, + SagaLogger, +} from "@posthog/shared"; import { createGitClient, type GitClient } from "./client"; import { CaptureCheckpointSaga, deleteCheckpoint } from "./sagas/checkpoint"; +export type { + GitHandoffCheckpoint, + HandoffLocalGitState, +} from "@posthog/shared"; + const HANDOFF_HEAD_REF_PREFIX = "refs/posthog-code-handoff/head/"; const CHECKPOINT_REF_PREFIX = "refs/posthog-code-checkpoint/"; const MAX_HANDOFF_FILE_BYTES = 1024 * 1024; -export interface HandoffLocalGitState { - head: string | null; - branch: string | null; - upstreamHead: string | null; - upstreamRemote: string | null; - upstreamMergeRef: string | null; -} - -export interface GitHandoffCheckpoint { - checkpointId: string; - commit: string; - checkpointRef: string; - headRef?: string; - head: string | null; - branch: string | null; - indexTree: string; - worktreeTree: string; - timestamp: string; - upstreamRemote: string | null; - upstreamMergeRef: string | null; - remoteUrl: string | null; -} - export interface GitHandoffArtifactFile { path: string; rawBytes: number; diff --git a/packages/git/src/worktree.test.ts b/packages/git/src/worktree.test.ts index 49bd88a445..117315f637 100644 --- a/packages/git/src/worktree.test.ts +++ b/packages/git/src/worktree.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -130,3 +130,94 @@ describe("WorktreeManager.createWorktree fetchBeforeCreate", () => { expect(worktreeHead).toBe(localTipBefore); }); }); + +async function dirExists(p: string): Promise<boolean> { + try { + await stat(p); + return true; + } catch { + return false; + } +} + +// The git-worktree slice moved the worktree add/list/remove/prune commands into +// ws-server services that consume @posthog/git WorktreeManager. This is the +// real-git headless smoke for that command lifecycle (acceptance: "smoke test +// the moved commands"). +describe("WorktreeManager lifecycle (add / exists / list / remove / prune)", () => { + let remoteDir: string; + let localDir: string; + let worktreeBaseDir: string; + + beforeEach(async () => { + remoteDir = await initBareRemote(); + + const seedDir = await mkdtemp(path.join(tmpdir(), "posthog-code-seed-")); + const seedGit = createGitClient(seedDir); + await seedGit.init(["--initial-branch", "main"]); + await seedGit.addConfig("user.name", "Test"); + await seedGit.addConfig("user.email", "test@example.com"); + await seedGit.addConfig("commit.gpgsign", "false"); + await commit(seedDir, "initial.txt", "initial\n"); + await seedGit.addRemote("origin", remoteDir); + await seedGit.push(["origin", "main"]); + await rm(seedDir, { recursive: true, force: true }); + + // realpath so the paths match what `git worktree list` reports (on macOS + // /tmp is a symlink to /private/tmp); listWorktrees filters by path prefix. + localDir = await realpath(await initLocalClone(remoteDir)); + worktreeBaseDir = await realpath( + await mkdtemp(path.join(tmpdir(), "posthog-code-wts-")), + ); + }); + + afterEach(async () => { + for (const d of [remoteDir, localDir, worktreeBaseDir]) { + await rm(d, { recursive: true, force: true }); + } + }); + + it("adds a worktree on disk and removes it again", async () => { + const manager = new WorktreeManager({ + mainRepoPath: localDir, + worktreeBasePath: worktreeBaseDir, + }); + + const info = await manager.createWorktree({ baseBranch: "main" }); + + expect(await dirExists(info.worktreePath)).toBe(true); + expect(await manager.worktreeExists(info.worktreeName)).toBe(true); + expect(await shaOfBranch(info.worktreePath, "HEAD")).toBe( + await shaOfBranch(localDir, "main"), + ); + + await manager.deleteWorktree(info.worktreePath); + + expect(await dirExists(info.worktreePath)).toBe(false); + expect(await manager.worktreeExists(info.worktreeName)).toBe(false); + }); + + it("lists a branched worktree and prunes it as orphaned", async () => { + await createGitClient(localDir).branch(["feature"]); + + const manager = new WorktreeManager({ + mainRepoPath: localDir, + worktreeBasePath: worktreeBaseDir, + }); + const info = await manager.createWorktreeForExistingBranch("feature"); + + const listed = await manager.listWorktrees(); + expect(listed.map((w) => w.worktreePath)).toContain(info.worktreePath); + expect( + listed.find((w) => w.worktreePath === info.worktreePath)?.branchName, + ).toBe("feature"); + + // Nothing is associated -> the branched worktree is orphaned and pruned. + const { deleted, errors } = await manager.cleanupOrphanedWorktrees([]); + + expect(errors).toEqual([]); + expect(deleted).toContain(info.worktreePath); + expect(await dirExists(info.worktreePath)).toBe(false); + expect(await manager.listWorktrees()).toEqual([]); + }); +}); diff --git a/packages/host-router/package.json b/packages/host-router/package.json new file mode 100644 index 0000000000..6e82ae5519 --- /dev/null +++ b/packages/host-router/package.json @@ -0,0 +1,38 @@ +{ + "name": "@posthog/host-router", + "version": "1.0.0", + "description": "Aggregated Electron main (host) tRPC router. Sits above core + workspace-server: composes their colocated host feature routers into one root HostRouter type, and exposes the renderer useHostTRPC hook. The renderer imports HostRouter type-only (no node code enters the bundle), mirroring how workspace-client consumes workspace-server.", + "private": true, + "type": "module", + "exports": { + "./*": [ + "./src/*.ts", + "./src/*.tsx" + ] + }, + "scripts": { + "typecheck": "tsc --noEmit", + "clean": "node ../../scripts/rimraf.mjs .turbo" + }, + "dependencies": { + "@posthog/core": "workspace:*", + "@posthog/host-trpc": "workspace:*", + "@posthog/platform": "workspace:*", + "@posthog/workspace-client": "workspace:*", + "@posthog/workspace-server": "workspace:*", + "@trpc/client": "catalog:" + }, + "peerDependencies": { + "@tanstack/react-query": "catalog:", + "@trpc/tanstack-react-query": "catalog:", + "react": "catalog:" + }, + "devDependencies": { + "@posthog/tsconfig": "workspace:*", + "@tanstack/react-query": "catalog:", + "@trpc/tanstack-react-query": "catalog:", + "@types/react": "catalog:", + "react": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/host-router/src/client.ts b/packages/host-router/src/client.ts new file mode 100644 index 0000000000..de3e03f2d3 --- /dev/null +++ b/packages/host-router/src/client.ts @@ -0,0 +1,6 @@ +import type { TRPCClient } from "@trpc/client"; +import type { HostRouter } from "./router"; + +export type HostTrpcClient = TRPCClient<HostRouter>; + +export const HOST_TRPC_CLIENT = Symbol.for("posthog.host.trpcClient"); diff --git a/packages/host-router/src/ports/connectivity-client.ts b/packages/host-router/src/ports/connectivity-client.ts new file mode 100644 index 0000000000..cb6c67e5a3 --- /dev/null +++ b/packages/host-router/src/ports/connectivity-client.ts @@ -0,0 +1,9 @@ +import type { WorkspaceClient } from "@posthog/workspace-client/client"; + +export const CONNECTIVITY_CLIENT = Symbol.for( + "posthog.host.connectivityClient", +); + +export interface HostConnectivityClient { + connectivity: WorkspaceClient["connectivity"]; +} diff --git a/packages/host-router/src/ports/environment-client.ts b/packages/host-router/src/ports/environment-client.ts new file mode 100644 index 0000000000..244fbc634b --- /dev/null +++ b/packages/host-router/src/ports/environment-client.ts @@ -0,0 +1,7 @@ +import type { WorkspaceClient } from "@posthog/workspace-client/client"; + +export const ENVIRONMENT_CLIENT = Symbol.for("posthog.host.environmentClient"); + +export interface HostEnvironmentClient { + environment: WorkspaceClient["environment"]; +} diff --git a/packages/host-router/src/ports/file-watcher-control.ts b/packages/host-router/src/ports/file-watcher-control.ts new file mode 100644 index 0000000000..ee1d98f876 --- /dev/null +++ b/packages/host-router/src/ports/file-watcher-control.ts @@ -0,0 +1,8 @@ +export const FILE_WATCHER_CONTROL = Symbol.for( + "posthog.host.fileWatcherControl", +); + +export interface HostFileWatcherControl { + startWatching(repoPath: string): void; + stopWatching(repoPath: string): void; +} diff --git a/packages/host-router/src/ports/git-pr-status.ts b/packages/host-router/src/ports/git-pr-status.ts new file mode 100644 index 0000000000..15ce8810e7 --- /dev/null +++ b/packages/host-router/src/ports/git-pr-status.ts @@ -0,0 +1,12 @@ +import type { TaskPrStatus } from "@posthog/workspace-server/services/workspace/schemas"; + +export const GIT_PR_STATUS_PROVIDER = Symbol.for( + "posthog.host.gitPrStatusProvider", +); + +export interface IGitPrStatus { + getTaskPrStatus( + taskId: string, + cloudPrUrl: string | null, + ): Promise<TaskPrStatus>; +} diff --git a/packages/host-router/src/react.tsx b/packages/host-router/src/react.tsx new file mode 100644 index 0000000000..c769a7484e --- /dev/null +++ b/packages/host-router/src/react.tsx @@ -0,0 +1,8 @@ +import { createTRPCContext } from "@trpc/tanstack-react-query"; +import type { HostRouter } from "./router"; + +export const { + TRPCProvider: HostTRPCProvider, + useTRPC: useHostTRPC, + useTRPCClient: useHostTRPCClient, +} = createTRPCContext<HostRouter>(); diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts new file mode 100644 index 0000000000..4c968219f9 --- /dev/null +++ b/packages/host-router/src/router.ts @@ -0,0 +1,84 @@ +import { router } from "@posthog/host-trpc/trpc"; +import { additionalDirectoriesRouter } from "./routers/additional-directories.router"; +import { agentRouter } from "./routers/agent.router"; +import { analyticsRouter } from "./routers/analytics.router"; +import { archiveRouter } from "./routers/archive.router"; +import { authRouter } from "./routers/auth.router"; +import { cloudTaskRouter } from "./routers/cloud-task.router"; +import { connectivityRouter } from "./routers/connectivity.router"; +import { contextMenuRouter } from "./routers/context-menu.router"; +import { deepLinkRouter } from "./routers/deep-link.router"; +import { enrichmentRouter } from "./routers/enrichment.router"; +import { environmentRouter } from "./routers/environment.router"; +import { externalAppsRouter } from "./routers/external-apps.router"; +import { fileWatcherRouter } from "./routers/file-watcher.router"; +import { focusRouter } from "./routers/focus.router"; +import { foldersRouter } from "./routers/folders.router"; +import { fsRouter } from "./routers/fs.router"; +import { gitRouter } from "./routers/git.router"; +import { githubIntegrationRouter } from "./routers/github-integration.router"; +import { handoffRouter } from "./routers/handoff.router"; +import { linearIntegrationRouter } from "./routers/linear-integration.router"; +import { llmGatewayRouter } from "./routers/llm-gateway.router"; +import { logsRouter } from "./routers/logs.router"; +import { mcpAppsRouter } from "./routers/mcp-apps.router"; +import { mcpCallbackRouter } from "./routers/mcp-callback.router"; +import { notificationRouter } from "./routers/notification.router"; +import { oauthRouter } from "./routers/oauth.router"; +import { osRouter } from "./routers/os.router"; +import { processTrackingRouter } from "./routers/process-tracking.router"; +import { provisioningRouter } from "./routers/provisioning.router"; +import { secureStoreRouter } from "./routers/secure-store.router"; +import { shellRouter } from "./routers/shell.router"; +import { skillsRouter } from "./routers/skills.router"; +import { slackIntegrationRouter } from "./routers/slack-integration.router"; +import { sleepRouter } from "./routers/sleep.router"; +import { suspensionRouter } from "./routers/suspension.router"; +import { uiRouter } from "./routers/ui.router"; +import { updatesRouter } from "./routers/updates.router"; +import { usageMonitorRouter } from "./routers/usage-monitor.router"; +import { workspaceRouter } from "./routers/workspace.router"; + +export const hostRouter = router({ + additionalDirectories: additionalDirectoriesRouter, + agent: agentRouter, + analytics: analyticsRouter, + archive: archiveRouter, + auth: authRouter, + cloudTask: cloudTaskRouter, + connectivity: connectivityRouter, + contextMenu: contextMenuRouter, + deepLink: deepLinkRouter, + enrichment: enrichmentRouter, + environment: environmentRouter, + externalApps: externalAppsRouter, + fileWatcher: fileWatcherRouter, + focus: focusRouter, + folders: foldersRouter, + fs: fsRouter, + git: gitRouter, + handoff: handoffRouter, + githubIntegration: githubIntegrationRouter, + linearIntegration: linearIntegrationRouter, + llmGateway: llmGatewayRouter, + logs: logsRouter, + mcpApps: mcpAppsRouter, + mcpCallback: mcpCallbackRouter, + notification: notificationRouter, + oauth: oauthRouter, + os: osRouter, + processTracking: processTrackingRouter, + provisioning: provisioningRouter, + secureStore: secureStoreRouter, + shell: shellRouter, + skills: skillsRouter, + slackIntegration: slackIntegrationRouter, + sleep: sleepRouter, + suspension: suspensionRouter, + ui: uiRouter, + updates: updatesRouter, + usageMonitor: usageMonitorRouter, + workspace: workspaceRouter, +}); + +export type HostRouter = typeof hostRouter; diff --git a/packages/host-router/src/routers/additional-directories.router.ts b/packages/host-router/src/routers/additional-directories.router.ts new file mode 100644 index 0000000000..630bc58c33 --- /dev/null +++ b/packages/host-router/src/routers/additional-directories.router.ts @@ -0,0 +1,62 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { AdditionalDirectoriesService } from "@posthog/workspace-server/services/additional-directories/additional-directories"; +import { ADDITIONAL_DIRECTORIES_SERVICE } from "@posthog/workspace-server/services/additional-directories/identifiers"; +import { z } from "zod"; + +const pathInput = z.object({ path: z.string().min(1) }); +const taskPathInput = z.object({ + taskId: z.string(), + path: z.string().min(1), +}); +const ok = { ok: true as const }; + +export const additionalDirectoriesRouter = router({ + listDefaults: publicProcedure + .output(z.array(z.string())) + .query(({ ctx }) => + ctx.container + .get<AdditionalDirectoriesService>(ADDITIONAL_DIRECTORIES_SERVICE) + .listDefaults(), + ), + + listForTask: publicProcedure + .input(z.object({ taskId: z.string() })) + .output(z.array(z.string())) + .query(({ ctx, input }) => + ctx.container + .get<AdditionalDirectoriesService>(ADDITIONAL_DIRECTORIES_SERVICE) + .listForTask(input.taskId), + ), + + addDefault: publicProcedure.input(pathInput).mutation(({ ctx, input }) => { + ctx.container + .get<AdditionalDirectoriesService>(ADDITIONAL_DIRECTORIES_SERVICE) + .addDefault(input.path); + return ok; + }), + + removeDefault: publicProcedure.input(pathInput).mutation(({ ctx, input }) => { + ctx.container + .get<AdditionalDirectoriesService>(ADDITIONAL_DIRECTORIES_SERVICE) + .removeDefault(input.path); + return ok; + }), + + addForTask: publicProcedure + .input(taskPathInput) + .mutation(({ ctx, input }) => { + ctx.container + .get<AdditionalDirectoriesService>(ADDITIONAL_DIRECTORIES_SERVICE) + .addForTask(input.taskId, input.path); + return ok; + }), + + removeForTask: publicProcedure + .input(taskPathInput) + .mutation(({ ctx, input }) => { + ctx.container + .get<AdditionalDirectoriesService>(ADDITIONAL_DIRECTORIES_SERVICE) + .removeForTask(input.taskId, input.path); + return ok; + }), +}); diff --git a/packages/host-router/src/routers/agent.router.ts b/packages/host-router/src/routers/agent.router.ts new file mode 100644 index 0000000000..38b0ef98e5 --- /dev/null +++ b/packages/host-router/src/routers/agent.router.ts @@ -0,0 +1,228 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { SLEEP_SERVICE } from "@posthog/core/sleep/identifiers"; +import type { SleepService } from "@posthog/core/sleep/sleep"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { AgentService } from "@posthog/workspace-server/services/agent/agent"; +import { AGENT_SERVICE } from "@posthog/workspace-server/services/agent/identifiers"; +import { + AgentServiceEvent, + cancelPermissionInput, + cancelPromptInput, + cancelSessionInput, + getGatewayModelsInput, + getGatewayModelsOutput, + getPreviewConfigOptionsInput, + getPreviewConfigOptionsOutput, + listSessionsInput, + listSessionsOutput, + notifySessionContextInput, + promptInput, + promptOutput, + reconnectSessionInput, + recordActivityInput, + respondToPermissionInput, + sessionResponseSchema, + setConfigOptionInput, + startSessionInput, + subscribeSessionInput, +} from "@posthog/workspace-server/services/agent/schemas"; +import { PROCESS_TRACKING_SERVICE } from "@posthog/workspace-server/services/process-tracking/identifiers"; +import type { ProcessTrackingService } from "@posthog/workspace-server/services/process-tracking/process-tracking"; +import { SHELL_SERVICE } from "@posthog/workspace-server/services/shell/identifiers"; +import type { ShellService } from "@posthog/workspace-server/services/shell/shell"; + +export const agentRouter = router({ + start: publicProcedure + .input(startSessionInput) + .output(sessionResponseSchema) + .mutation(({ ctx, input }) => + ctx.container.get<AgentService>(AGENT_SERVICE).startSession(input), + ), + + prompt: publicProcedure + .input(promptInput) + .output(promptOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<AgentService>(AGENT_SERVICE) + .prompt(input.sessionId, input.prompt as ContentBlock[]), + ), + + cancel: publicProcedure + .input(cancelSessionInput) + .mutation(({ ctx, input }) => + ctx.container + .get<AgentService>(AGENT_SERVICE) + .cancelSession(input.sessionId), + ), + + cancelPrompt: publicProcedure + .input(cancelPromptInput) + .mutation(({ ctx, input }) => + ctx.container + .get<AgentService>(AGENT_SERVICE) + .cancelPrompt(input.sessionId, input.reason), + ), + + reconnect: publicProcedure + .input(reconnectSessionInput) + .output(sessionResponseSchema.nullable()) + .mutation(({ ctx, input }) => + ctx.container.get<AgentService>(AGENT_SERVICE).reconnectSession(input), + ), + + setConfigOption: publicProcedure + .input(setConfigOptionInput) + .mutation(({ ctx, input }) => + ctx.container + .get<AgentService>(AGENT_SERVICE) + .setSessionConfigOption(input.sessionId, input.configId, input.value), + ), + + onSessionEvent: publicProcedure + .input(subscribeSessionInput) + .subscription(async function* (opts) { + const service = opts.ctx.container.get<AgentService>(AGENT_SERVICE); + const targetTaskRunId = opts.input.taskRunId; + const iterable = service.toIterable(AgentServiceEvent.SessionEvent, { + signal: opts.signal, + }); + + for await (const event of iterable) { + if (event.taskRunId === targetTaskRunId) { + yield event.payload; + } + } + }), + + onPermissionRequest: publicProcedure + .input(subscribeSessionInput) + .subscription(async function* (opts) { + const service = opts.ctx.container.get<AgentService>(AGENT_SERVICE); + const targetTaskRunId = opts.input.taskRunId; + const iterable = service.toIterable(AgentServiceEvent.PermissionRequest, { + signal: opts.signal, + }); + + for await (const event of iterable) { + if (event.taskRunId === targetTaskRunId) { + yield event; + } + } + }), + + respondToPermission: publicProcedure + .input(respondToPermissionInput) + .mutation(({ ctx, input }) => + ctx.container + .get<AgentService>(AGENT_SERVICE) + .respondToPermission( + input.taskRunId, + input.toolCallId, + input.optionId, + input.customInput, + input.answers, + ), + ), + + cancelPermission: publicProcedure + .input(cancelPermissionInput) + .mutation(({ ctx, input }) => + ctx.container + .get<AgentService>(AGENT_SERVICE) + .cancelPermission(input.taskRunId, input.toolCallId), + ), + + listSessions: publicProcedure + .input(listSessionsInput) + .output(listSessionsOutput) + .query(({ ctx, input }) => + ctx.container + .get<AgentService>(AGENT_SERVICE) + .listSessions(input.taskId) + .map((s) => ({ taskRunId: s.taskRunId, repoPath: s.repoPath })), + ), + + notifySessionContext: publicProcedure + .input(notifySessionContextInput) + .mutation(({ ctx, input }) => + ctx.container + .get<AgentService>(AGENT_SERVICE) + .notifySessionContext(input.sessionId, input.context), + ), + + hasActiveSessions: publicProcedure.query(({ ctx }) => + ctx.container.get<AgentService>(AGENT_SERVICE).hasActiveSessions(), + ), + + onSessionsIdle: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<AgentService>(AGENT_SERVICE); + for await (const _ of service.toIterable(AgentServiceEvent.SessionsIdle, { + signal: opts.signal, + })) { + yield true; + } + }), + + resetAll: publicProcedure.mutation(async ({ ctx }) => { + const agentService = ctx.container.get<AgentService>(AGENT_SERVICE); + await agentService.cleanupAll(); + + const shellService = ctx.container.get<ShellService>(SHELL_SERVICE); + shellService.destroyAll(); + + const processTracking = ctx.container.get<ProcessTrackingService>( + PROCESS_TRACKING_SERVICE, + ); + processTracking.killAll(); + + const sleepService = ctx.container.get<SleepService>(SLEEP_SERVICE); + sleepService.cleanup(); + }), + + recordActivity: publicProcedure + .input(recordActivityInput) + .mutation(({ ctx, input }) => + ctx.container + .get<AgentService>(AGENT_SERVICE) + .recordActivity(input.taskRunId), + ), + + onSessionIdleKilled: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<AgentService>(AGENT_SERVICE); + for await (const event of service.toIterable( + AgentServiceEvent.SessionIdleKilled, + { signal: opts.signal }, + )) { + yield event; + } + }), + + onAgentFileActivity: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<AgentService>(AGENT_SERVICE); + for await (const event of service.toIterable( + AgentServiceEvent.AgentFileActivity, + { signal: opts.signal }, + )) { + yield event; + } + }), + + getGatewayModels: publicProcedure + .input(getGatewayModelsInput) + .output(getGatewayModelsOutput) + .query(({ ctx, input }) => + ctx.container + .get<AgentService>(AGENT_SERVICE) + .getGatewayModels(input.apiHost), + ), + + getPreviewConfigOptions: publicProcedure + .input(getPreviewConfigOptionsInput) + .output(getPreviewConfigOptionsOutput) + .query(({ ctx, input }) => + ctx.container + .get<AgentService>(AGENT_SERVICE) + .getPreviewConfigOptions(input.apiHost, input.adapter), + ), +}); diff --git a/packages/host-router/src/routers/analytics.router.ts b/packages/host-router/src/routers/analytics.router.ts new file mode 100644 index 0000000000..3e8425bfe4 --- /dev/null +++ b/packages/host-router/src/routers/analytics.router.ts @@ -0,0 +1,30 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { IAnalytics } from "@posthog/platform/analytics"; +import { ANALYTICS_SERVICE } from "@posthog/platform/analytics"; +import { z } from "zod"; + +export const analyticsRouter = router({ + setUserId: publicProcedure + .input( + z.object({ + userId: z.string(), + properties: z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .optional(), + }), + ) + .mutation(({ ctx, input }) => { + const analytics = ctx.container.get<IAnalytics>(ANALYTICS_SERVICE); + analytics.setCurrentUserId(input.userId); + if (input.properties) { + analytics.identify( + input.userId, + input.properties as Record<string, string | number | boolean>, + ); + } + }), + + resetUser: publicProcedure.mutation(({ ctx }) => { + ctx.container.get<IAnalytics>(ANALYTICS_SERVICE).resetUser(); + }), +}); diff --git a/packages/host-router/src/routers/archive.router.ts b/packages/host-router/src/routers/archive.router.ts new file mode 100644 index 0000000000..e922d8d4ea --- /dev/null +++ b/packages/host-router/src/routers/archive.router.ts @@ -0,0 +1,52 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { ArchiveService } from "@posthog/workspace-server/services/archive/archive"; +import { ARCHIVE_SERVICE } from "@posthog/workspace-server/services/archive/identifiers"; +import { + archivedTaskIdsOutput, + archiveTaskInput, + archiveTaskOutput, + deleteArchivedTaskInput, + deleteArchivedTaskOutput, + listArchivedTasksOutput, + unarchiveTaskInput, + unarchiveTaskOutput, +} from "@posthog/workspace-server/services/archive/schemas"; + +export const archiveRouter = router({ + archive: publicProcedure + .input(archiveTaskInput) + .output(archiveTaskOutput) + .mutation(({ ctx, input }) => + ctx.container.get<ArchiveService>(ARCHIVE_SERVICE).archiveTask(input), + ), + + unarchive: publicProcedure + .input(unarchiveTaskInput) + .output(unarchiveTaskOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ArchiveService>(ARCHIVE_SERVICE) + .unarchiveTask(input.taskId, input.recreateBranch), + ), + + list: publicProcedure + .output(listArchivedTasksOutput) + .query(({ ctx }) => + ctx.container.get<ArchiveService>(ARCHIVE_SERVICE).getArchivedTasks(), + ), + + archivedTaskIds: publicProcedure + .output(archivedTaskIdsOutput) + .query(({ ctx }) => + ctx.container.get<ArchiveService>(ARCHIVE_SERVICE).getArchivedTaskIds(), + ), + + delete: publicProcedure + .input(deleteArchivedTaskInput) + .output(deleteArchivedTaskOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ArchiveService>(ARCHIVE_SERVICE) + .deleteArchivedTask(input.taskId), + ), +}); diff --git a/packages/host-router/src/routers/auth.router.ts b/packages/host-router/src/routers/auth.router.ts new file mode 100644 index 0000000000..f834b3cba5 --- /dev/null +++ b/packages/host-router/src/routers/auth.router.ts @@ -0,0 +1,78 @@ +import type { AuthService } from "@posthog/core/auth/auth"; +import { AUTH_SERVICE } from "@posthog/core/auth/auth.module"; +import { + AuthServiceEvent, + authStateSchema, + loginInput, + loginOutput, + redeemInviteCodeInput, + selectProjectInput, + validAccessTokenOutput, +} from "@posthog/core/auth/schemas"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +export const authRouter = router({ + getState: publicProcedure.output(authStateSchema).query(({ ctx }) => { + return ctx.container.get<AuthService>(AUTH_SERVICE).getState(); + }), + + onStateChanged: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<AuthService>(AUTH_SERVICE); + const iterable = service.toIterable(AuthServiceEvent.StateChanged, { + signal: opts.signal, + }); + for await (const state of iterable) { + yield state; + } + }), + + login: publicProcedure + .input(loginInput) + .output(loginOutput) + .mutation(async ({ ctx, input }) => ({ + state: await ctx.container + .get<AuthService>(AUTH_SERVICE) + .login(input.region), + })), + + signup: publicProcedure + .input(loginInput) + .output(loginOutput) + .mutation(async ({ ctx, input }) => ({ + state: await ctx.container + .get<AuthService>(AUTH_SERVICE) + .signup(input.region), + })), + + getValidAccessToken: publicProcedure + .output(validAccessTokenOutput) + .query(async ({ ctx }) => + ctx.container.get<AuthService>(AUTH_SERVICE).getValidAccessToken(), + ), + + refreshAccessToken: publicProcedure + .output(validAccessTokenOutput) + .mutation(async ({ ctx }) => + ctx.container.get<AuthService>(AUTH_SERVICE).refreshAccessToken(), + ), + + selectProject: publicProcedure + .input(selectProjectInput) + .output(authStateSchema) + .mutation(async ({ ctx, input }) => + ctx.container + .get<AuthService>(AUTH_SERVICE) + .selectProject(input.projectId), + ), + + redeemInviteCode: publicProcedure + .input(redeemInviteCodeInput) + .output(authStateSchema) + .mutation(async ({ ctx, input }) => + ctx.container.get<AuthService>(AUTH_SERVICE).redeemInviteCode(input.code), + ), + + logout: publicProcedure.output(authStateSchema).mutation(async ({ ctx }) => { + return ctx.container.get<AuthService>(AUTH_SERVICE).logout(); + }), +}); diff --git a/packages/host-router/src/routers/cloud-task.router.ts b/packages/host-router/src/routers/cloud-task.router.ts new file mode 100644 index 0000000000..be0b7dd451 --- /dev/null +++ b/packages/host-router/src/routers/cloud-task.router.ts @@ -0,0 +1,66 @@ +import type { CloudTaskService } from "@posthog/core/cloud-task/cloud-task"; +import { CLOUD_TASK_SERVICE } from "@posthog/core/cloud-task/identifiers"; +import { + CloudTaskEvent, + onUpdateInput, + retryInput, + sendCommandInput, + sendCommandOutput, + unwatchInput, + watchInput, +} from "@posthog/core/cloud-task/schemas"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +export const cloudTaskRouter = router({ + watch: publicProcedure + .input(watchInput) + .mutation(({ ctx, input }) => + ctx.container.get<CloudTaskService>(CLOUD_TASK_SERVICE).watch(input), + ), + + unwatch: publicProcedure + .input(unwatchInput) + .mutation(({ ctx, input }) => + ctx.container + .get<CloudTaskService>(CLOUD_TASK_SERVICE) + .unwatch(input.taskId, input.runId), + ), + + retry: publicProcedure + .input(retryInput) + .mutation(({ ctx, input }) => + ctx.container + .get<CloudTaskService>(CLOUD_TASK_SERVICE) + .retry(input.taskId, input.runId), + ), + + sendCommand: publicProcedure + .input(sendCommandInput) + .output(sendCommandOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<CloudTaskService>(CLOUD_TASK_SERVICE) + .sendCommand(input), + ), + + onUpdate: publicProcedure + .input(onUpdateInput) + .subscription(async function* (opts) { + const service = + opts.ctx.container.get<CloudTaskService>(CLOUD_TASK_SERVICE); + try { + for await (const data of service.toIterable(CloudTaskEvent.Update, { + signal: opts.signal, + })) { + if ( + data.taskId === opts.input.taskId && + data.runId === opts.input.runId + ) { + yield data; + } + } + } finally { + service.unwatch(opts.input.taskId, opts.input.runId); + } + }), +}); diff --git a/packages/host-router/src/routers/connectivity.router.ts b/packages/host-router/src/routers/connectivity.router.ts new file mode 100644 index 0000000000..a26f7810a4 --- /dev/null +++ b/packages/host-router/src/routers/connectivity.router.ts @@ -0,0 +1,51 @@ +import type { ServiceResolver } from "@posthog/host-trpc/context"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { + type ConnectivityStatusOutput, + connectivityStatusOutput, +} from "@posthog/workspace-server/services/connectivity/schemas"; +import { + CONNECTIVITY_CLIENT, + type HostConnectivityClient, +} from "../ports/connectivity-client"; + +const ws = (container: ServiceResolver) => + container.get<HostConnectivityClient>(CONNECTIVITY_CLIENT); + +export const connectivityRouter = router({ + getStatus: publicProcedure + .output(connectivityStatusOutput) + .query(({ ctx }) => ws(ctx.container).connectivity.getStatus.query()), + + checkNow: publicProcedure + .output(connectivityStatusOutput) + .mutation(({ ctx }) => ws(ctx.container).connectivity.checkNow.mutate()), + + onStatusChange: publicProcedure.subscription(async function* (opts) { + const queue: ConnectivityStatusOutput[] = []; + let resolve: (() => void) | null = null; + const subscription = ws( + opts.ctx.container, + ).connectivity.onStatusChange.subscribe(undefined, { + onData: (status) => { + queue.push(status); + resolve?.(); + }, + }); + try { + while (!opts.signal?.aborted) { + if (queue.length === 0) { + await new Promise<void>((r) => { + resolve = r; + }); + resolve = null; + } + while (queue.length > 0) { + yield queue.shift() as ConnectivityStatusOutput; + } + } + } finally { + subscription.unsubscribe(); + } + }), +}); diff --git a/packages/host-router/src/routers/context-menu.router.ts b/packages/host-router/src/routers/context-menu.router.ts new file mode 100644 index 0000000000..92ba67848e --- /dev/null +++ b/packages/host-router/src/routers/context-menu.router.ts @@ -0,0 +1,115 @@ +import type { ContextMenuService } from "@posthog/core/context-menu/context-menu"; +import { CONTEXT_MENU_CONTROLLER } from "@posthog/core/context-menu/identifiers"; +import { + archivedTaskContextMenuInput, + archivedTaskContextMenuOutput, + bulkTaskContextMenuInput, + bulkTaskContextMenuOutput, + confirmDeleteArchivedTaskInput, + confirmDeleteArchivedTaskOutput, + confirmDeleteTaskInput, + confirmDeleteTaskOutput, + confirmDeleteWorktreeInput, + confirmDeleteWorktreeOutput, + fileContextMenuInput, + fileContextMenuOutput, + folderContextMenuInput, + folderContextMenuOutput, + splitContextMenuOutput, + tabContextMenuInput, + tabContextMenuOutput, + taskContextMenuInput, + taskContextMenuOutput, +} from "@posthog/core/context-menu/schemas"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +export const contextMenuRouter = router({ + confirmDeleteTask: publicProcedure + .input(confirmDeleteTaskInput) + .output(confirmDeleteTaskOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ContextMenuService>(CONTEXT_MENU_CONTROLLER) + .confirmDeleteTask(input), + ), + + confirmDeleteArchivedTask: publicProcedure + .input(confirmDeleteArchivedTaskInput) + .output(confirmDeleteArchivedTaskOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ContextMenuService>(CONTEXT_MENU_CONTROLLER) + .confirmDeleteArchivedTask(input), + ), + + confirmDeleteWorktree: publicProcedure + .input(confirmDeleteWorktreeInput) + .output(confirmDeleteWorktreeOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ContextMenuService>(CONTEXT_MENU_CONTROLLER) + .confirmDeleteWorktree(input), + ), + + showTaskContextMenu: publicProcedure + .input(taskContextMenuInput) + .output(taskContextMenuOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ContextMenuService>(CONTEXT_MENU_CONTROLLER) + .showTaskContextMenu(input), + ), + + showBulkTaskContextMenu: publicProcedure + .input(bulkTaskContextMenuInput) + .output(bulkTaskContextMenuOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ContextMenuService>(CONTEXT_MENU_CONTROLLER) + .showBulkTaskContextMenu(input), + ), + + showArchivedTaskContextMenu: publicProcedure + .input(archivedTaskContextMenuInput) + .output(archivedTaskContextMenuOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ContextMenuService>(CONTEXT_MENU_CONTROLLER) + .showArchivedTaskContextMenu(input), + ), + + showFolderContextMenu: publicProcedure + .input(folderContextMenuInput) + .output(folderContextMenuOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ContextMenuService>(CONTEXT_MENU_CONTROLLER) + .showFolderContextMenu(input), + ), + + showTabContextMenu: publicProcedure + .input(tabContextMenuInput) + .output(tabContextMenuOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ContextMenuService>(CONTEXT_MENU_CONTROLLER) + .showTabContextMenu(input), + ), + + showSplitContextMenu: publicProcedure + .output(splitContextMenuOutput) + .mutation(({ ctx }) => + ctx.container + .get<ContextMenuService>(CONTEXT_MENU_CONTROLLER) + .showSplitContextMenu(), + ), + + showFileContextMenu: publicProcedure + .input(fileContextMenuInput) + .output(fileContextMenuOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ContextMenuService>(CONTEXT_MENU_CONTROLLER) + .showFileContextMenu(input), + ), +}); diff --git a/packages/host-router/src/routers/deep-link.router.ts b/packages/host-router/src/routers/deep-link.router.ts new file mode 100644 index 0000000000..04a2a3a001 --- /dev/null +++ b/packages/host-router/src/routers/deep-link.router.ts @@ -0,0 +1,80 @@ +import { + INBOX_LINK_SERVICE, + NEW_TASK_LINK_SERVICE, + TASK_LINK_SERVICE, +} from "@posthog/core/links/identifiers"; +import { + InboxLinkEvent, + type InboxLinkService, + type PendingInboxDeepLink, +} from "@posthog/core/links/inbox-link"; +import { + NewTaskLinkEvent, + type NewTaskLinkPayload, + type NewTaskLinkService, +} from "@posthog/core/links/new-task-link"; +import { + type PendingDeepLink, + TaskLinkEvent, + type TaskLinkService, +} from "@posthog/core/links/task-link"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +export const deepLinkRouter = router({ + onOpenTask: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<TaskLinkService>(TASK_LINK_SERVICE); + const iterable = service.toIterable(TaskLinkEvent.OpenTask, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + getPendingDeepLink: publicProcedure.query( + ({ ctx }): PendingDeepLink | null => { + return ctx.container + .get<TaskLinkService>(TASK_LINK_SERVICE) + .consumePendingDeepLink(); + }, + ), + + onOpenReport: publicProcedure.subscription(async function* (opts) { + const service = + opts.ctx.container.get<InboxLinkService>(INBOX_LINK_SERVICE); + const iterable = service.toIterable(InboxLinkEvent.OpenReport, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + getPendingReportLink: publicProcedure.query( + ({ ctx }): PendingInboxDeepLink | null => { + return ctx.container + .get<InboxLinkService>(INBOX_LINK_SERVICE) + .consumePendingDeepLink(); + }, + ), + + onNewTaskAction: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<NewTaskLinkService>( + NEW_TASK_LINK_SERVICE, + ); + const iterable = service.toIterable(NewTaskLinkEvent.Action, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + getPendingNewTaskLink: publicProcedure.query( + ({ ctx }): NewTaskLinkPayload | null => { + return ctx.container + .get<NewTaskLinkService>(NEW_TASK_LINK_SERVICE) + .consumePendingLink(); + }, + ), +}); diff --git a/apps/code/src/main/trpc/routers/enrichment.ts b/packages/host-router/src/routers/enrichment.router.ts similarity index 60% rename from apps/code/src/main/trpc/routers/enrichment.ts rename to packages/host-router/src/routers/enrichment.router.ts index a01d0d67f5..eb1fa2d88e 100644 --- a/apps/code/src/main/trpc/routers/enrichment.ts +++ b/packages/host-router/src/routers/enrichment.router.ts @@ -1,11 +1,7 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { EnrichmentService } from "@posthog/workspace-server/services/enrichment/enrichment"; +import { ENRICHMENT_SERVICE } from "@posthog/workspace-server/services/enrichment/identifiers"; import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { EnrichmentService } from "../../services/enrichment/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get<EnrichmentService>(MAIN_TOKENS.EnrichmentService); const enrichFileInput = z.object({ taskId: z.string(), @@ -45,17 +41,25 @@ const findStaleFlagSuggestionsOutput = z.array( export const enrichmentRouter = router({ enrichFile: publicProcedure .input(enrichFileInput) - .query(({ input }) => getService().enrichFile(input)), + .query(({ ctx, input }) => + ctx.container + .get<EnrichmentService>(ENRICHMENT_SERVICE) + .enrichFile(input), + ), detectPosthogInstallState: publicProcedure .input(detectPosthogInstallStateInput) .output(detectPosthogInstallStateOutput) - .query(({ input }) => - getService().detectPosthogInstallState(input.repoPath), + .query(({ ctx, input }) => + ctx.container + .get<EnrichmentService>(ENRICHMENT_SERVICE) + .detectPosthogInstallState(input.repoPath), ), findStaleFlagSuggestions: publicProcedure .input(findStaleFlagSuggestionsInput) .output(findStaleFlagSuggestionsOutput) - .query(({ input }) => - getService().findStaleFlagSuggestions(input.repoPath), + .query(({ ctx, input }) => + ctx.container + .get<EnrichmentService>(ENRICHMENT_SERVICE) + .findStaleFlagSuggestions(input.repoPath), ), }); diff --git a/packages/host-router/src/routers/environment.router.ts b/packages/host-router/src/routers/environment.router.ts new file mode 100644 index 0000000000..c0ba367fb0 --- /dev/null +++ b/packages/host-router/src/routers/environment.router.ts @@ -0,0 +1,49 @@ +import type { ServiceResolver } from "@posthog/host-trpc/context"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { + createEnvironmentInput, + deleteEnvironmentInput, + environmentSchema, + getEnvironmentInput, + listEnvironmentsInput, + updateEnvironmentInput, +} from "@posthog/workspace-server/services/environment/schemas"; +import { + ENVIRONMENT_CLIENT, + type HostEnvironmentClient, +} from "../ports/environment-client"; + +const ws = (container: ServiceResolver) => + container.get<HostEnvironmentClient>(ENVIRONMENT_CLIENT); + +export const environmentRouter = router({ + list: publicProcedure + .input(listEnvironmentsInput) + .output(environmentSchema.array()) + .query(({ ctx, input }) => ws(ctx.container).environment.list.query(input)), + + get: publicProcedure + .input(getEnvironmentInput) + .output(environmentSchema.nullable()) + .query(({ ctx, input }) => ws(ctx.container).environment.get.query(input)), + + create: publicProcedure + .input(createEnvironmentInput) + .output(environmentSchema) + .mutation(({ ctx, input }) => + ws(ctx.container).environment.create.mutate(input), + ), + + update: publicProcedure + .input(updateEnvironmentInput) + .output(environmentSchema) + .mutation(({ ctx, input }) => + ws(ctx.container).environment.update.mutate(input), + ), + + delete: publicProcedure + .input(deleteEnvironmentInput) + .mutation(({ ctx, input }) => + ws(ctx.container).environment.delete.mutate(input), + ), +}); diff --git a/packages/host-router/src/routers/external-apps.router.ts b/packages/host-router/src/routers/external-apps.router.ts new file mode 100644 index 0000000000..5904740fc2 --- /dev/null +++ b/packages/host-router/src/routers/external-apps.router.ts @@ -0,0 +1,54 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { ExternalAppsService } from "@posthog/workspace-server/services/external-apps/external-apps"; +import { EXTERNAL_APPS_SERVICE } from "@posthog/workspace-server/services/external-apps/identifiers"; +import { + copyPathInput, + getDetectedAppsOutput, + getLastUsedOutput, + openInAppInput, + openInAppOutput, + setLastUsedInput, +} from "@posthog/workspace-server/services/external-apps/schemas"; + +export const externalAppsRouter = router({ + getDetectedApps: publicProcedure + .output(getDetectedAppsOutput) + .query(({ ctx }) => + ctx.container + .get<ExternalAppsService>(EXTERNAL_APPS_SERVICE) + .getDetectedApps(), + ), + + openInApp: publicProcedure + .input(openInAppInput) + .output(openInAppOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ExternalAppsService>(EXTERNAL_APPS_SERVICE) + .openInApp(input.appId, input.targetPath), + ), + + setLastUsed: publicProcedure + .input(setLastUsedInput) + .mutation(({ ctx, input }) => + ctx.container + .get<ExternalAppsService>(EXTERNAL_APPS_SERVICE) + .setLastUsed(input.appId), + ), + + getLastUsed: publicProcedure + .output(getLastUsedOutput) + .query(({ ctx }) => + ctx.container + .get<ExternalAppsService>(EXTERNAL_APPS_SERVICE) + .getLastUsed(), + ), + + copyPath: publicProcedure + .input(copyPathInput) + .mutation(({ ctx, input }) => + ctx.container + .get<ExternalAppsService>(EXTERNAL_APPS_SERVICE) + .copyPath(input.targetPath), + ), +}); diff --git a/packages/host-router/src/routers/file-watcher.router.ts b/packages/host-router/src/routers/file-watcher.router.ts new file mode 100644 index 0000000000..27fe67d73b --- /dev/null +++ b/packages/host-router/src/routers/file-watcher.router.ts @@ -0,0 +1,26 @@ +import type { ServiceResolver } from "@posthog/host-trpc/context"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { z } from "zod"; +import { + FILE_WATCHER_CONTROL, + type HostFileWatcherControl, +} from "../ports/file-watcher-control"; + +const watcherInput = z.object({ repoPath: z.string() }); + +const getControl = (container: ServiceResolver) => + container.get<HostFileWatcherControl>(FILE_WATCHER_CONTROL); + +export const fileWatcherRouter = router({ + start: publicProcedure + .input(watcherInput) + .mutation(({ ctx, input }) => + getControl(ctx.container).startWatching(input.repoPath), + ), + + stop: publicProcedure + .input(watcherInput) + .mutation(({ ctx, input }) => + getControl(ctx.container).stopWatching(input.repoPath), + ), +}); diff --git a/packages/host-router/src/routers/focus.router.ts b/packages/host-router/src/routers/focus.router.ts new file mode 100644 index 0000000000..59e5e7bc94 --- /dev/null +++ b/packages/host-router/src/routers/focus.router.ts @@ -0,0 +1,217 @@ +import { + checkoutInput, + FOCUS_SERVICE, + FocusServiceEvent, + type FocusServiceEvents, + findWorktreeInput, + focusResultSchema, + focusSessionSchema, + type IFocusService, + mainRepoPathInput, + reattachInput, + repoPathInput, + stashInput, + stashResultSchema, + syncInput, + worktreeInput, +} from "@posthog/core/focus/identifiers"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { z } from "zod"; + +function subscribe<K extends keyof FocusServiceEvents>(event: K) { + return publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<IFocusService>(FOCUS_SERVICE); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const focusRouter = router({ + getSession: publicProcedure + .input(mainRepoPathInput) + .output(focusSessionSchema.nullable()) + .query(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .getSession(input.mainRepoPath), + ), + + saveSession: publicProcedure + .input(focusSessionSchema) + .mutation(({ ctx, input }) => + ctx.container.get<IFocusService>(FOCUS_SERVICE).saveSession(input), + ), + + deleteSession: publicProcedure + .input(mainRepoPathInput) + .mutation(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .deleteSession(input.mainRepoPath), + ), + + isFocusActive: publicProcedure + .input(mainRepoPathInput) + .output(z.boolean()) + .query(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .isFocusActive(input.mainRepoPath), + ), + + validateFocusOperation: publicProcedure + .input( + z.object({ + mainRepoPath: z.string(), + currentBranch: z.string().nullable(), + targetBranch: z.string(), + }), + ) + .output(z.string().nullable()) + .query(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .validateFocusOperation(input.currentBranch, input.targetBranch), + ), + + isDirty: publicProcedure + .input(repoPathInput) + .output(z.boolean()) + .query(({ ctx, input }) => + ctx.container.get<IFocusService>(FOCUS_SERVICE).isDirty(input.repoPath), + ), + + getCommitSha: publicProcedure + .input(repoPathInput) + .output(z.string()) + .query(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .getCommitSha(input.repoPath), + ), + + findWorktreeByBranch: publicProcedure + .input(findWorktreeInput) + .output(z.string().nullable()) + .query(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .findWorktreeByBranch(input.mainRepoPath, input.branch), + ), + + toRelativeWorktreePath: publicProcedure + .input(z.object({ absolutePath: z.string(), mainRepoPath: z.string() })) + .output(z.string()) + .query(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .toRelativeWorktreePath(input.absolutePath, input.mainRepoPath), + ), + + toAbsoluteWorktreePath: publicProcedure + .input(z.object({ relativePath: z.string() })) + .output(z.string()) + .query(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .toAbsoluteWorktreePath(input.relativePath), + ), + + worktreeExistsAtPath: publicProcedure + .input(z.object({ relativePath: z.string() })) + .output(z.boolean()) + .query(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .worktreeExistsAtPath(input.relativePath), + ), + + stash: publicProcedure + .input(stashInput) + .output(stashResultSchema) + .mutation(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .stash(input.repoPath, input.message), + ), + + stashPop: publicProcedure + .input(repoPathInput) + .output(focusResultSchema) + .mutation(({ ctx, input }) => + ctx.container.get<IFocusService>(FOCUS_SERVICE).stashPop(input.repoPath), + ), + + stashApply: publicProcedure + .input(z.object({ repoPath: z.string(), stashRef: z.string() })) + .output(focusResultSchema) + .mutation(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .stashApply(input.repoPath, input.stashRef), + ), + + checkout: publicProcedure + .input(checkoutInput) + .output(focusResultSchema) + .mutation(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .checkout(input.repoPath, input.branch), + ), + + detachWorktree: publicProcedure + .input(worktreeInput) + .output(focusResultSchema) + .mutation(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .detachWorktree(input.worktreePath), + ), + + reattachWorktree: publicProcedure + .input(reattachInput) + .output(focusResultSchema) + .mutation(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .reattachWorktree(input.worktreePath, input.branch), + ), + + cleanWorkingTree: publicProcedure + .input(repoPathInput) + .mutation(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .cleanWorkingTree(input.repoPath), + ), + + startSync: publicProcedure + .input(syncInput) + .mutation(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .startSync(input.mainRepoPath, input.worktreePath), + ), + + stopSync: publicProcedure.mutation(({ ctx }) => + ctx.container.get<IFocusService>(FOCUS_SERVICE).stopSync(), + ), + + startWatchingMainRepo: publicProcedure + .input(mainRepoPathInput) + .mutation(({ ctx, input }) => + ctx.container + .get<IFocusService>(FOCUS_SERVICE) + .startWatchingMainRepo(input.mainRepoPath), + ), + + stopWatchingMainRepo: publicProcedure.mutation(({ ctx }) => + ctx.container.get<IFocusService>(FOCUS_SERVICE).stopWatchingMainRepo(), + ), + + onBranchRenamed: subscribe(FocusServiceEvent.BranchRenamed), + onForeignBranchCheckout: subscribe(FocusServiceEvent.ForeignBranchCheckout), +}); diff --git a/packages/host-router/src/routers/folders.router.ts b/packages/host-router/src/routers/folders.router.ts new file mode 100644 index 0000000000..8c0725913c --- /dev/null +++ b/packages/host-router/src/routers/folders.router.ts @@ -0,0 +1,64 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { FoldersService } from "@posthog/workspace-server/services/folders/folders"; +import { FOLDERS_SERVICE } from "@posthog/workspace-server/services/folders/identifiers"; +import { + addFolderInput, + addFolderOutput, + getFoldersOutput, + getRepositoryByRemoteUrlInput, + removeFolderInput, + repositoryLookupResult, + updateFolderAccessedInput, +} from "@posthog/workspace-server/services/folders/schemas"; + +export const foldersRouter = router({ + getFolders: publicProcedure.output(getFoldersOutput).query(({ ctx }) => { + return ctx.container.get<FoldersService>(FOLDERS_SERVICE).getFolders(); + }), + + addFolder: publicProcedure + .input(addFolderInput) + .output(addFolderOutput) + .mutation(({ ctx, input }) => { + return ctx.container + .get<FoldersService>(FOLDERS_SERVICE) + .addFolder(input.folderPath, { remoteUrl: input.remoteUrl }); + }), + + removeFolder: publicProcedure + .input(removeFolderInput) + .mutation(({ ctx, input }) => { + return ctx.container + .get<FoldersService>(FOLDERS_SERVICE) + .removeFolder(input.folderId); + }), + + updateFolderAccessed: publicProcedure + .input(updateFolderAccessedInput) + .mutation(({ ctx, input }) => { + return ctx.container + .get<FoldersService>(FOLDERS_SERVICE) + .updateFolderAccessed(input.folderId); + }), + + clearAllData: publicProcedure.mutation(({ ctx }) => { + return ctx.container.get<FoldersService>(FOLDERS_SERVICE).clearAllData(); + }), + + getRepositoryByRemoteUrl: publicProcedure + .input(getRepositoryByRemoteUrlInput) + .output(repositoryLookupResult) + .query(({ ctx, input }) => { + return ctx.container + .get<FoldersService>(FOLDERS_SERVICE) + .getRepositoryByRemoteUrl(input.remoteUrl); + }), + + getMostRecentlyAccessedRepository: publicProcedure + .output(repositoryLookupResult) + .query(({ ctx }) => { + return ctx.container + .get<FoldersService>(FOLDERS_SERVICE) + .getMostRecentlyAccessedRepository(); + }), +}); diff --git a/packages/host-router/src/routers/fs.router.ts b/packages/host-router/src/routers/fs.router.ts new file mode 100644 index 0000000000..1f1fc731a0 --- /dev/null +++ b/packages/host-router/src/routers/fs.router.ts @@ -0,0 +1,92 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { + FS_SERVICE, + type FsCapability, +} from "@posthog/workspace-server/services/fs/identifiers"; +import { + boundedReadResult, + listRepoFilesInput, + listRepoFilesOutput, + readAbsoluteFileInput, + readRepoFileBoundedInput, + readRepoFileInput, + readRepoFileOutput, + readRepoFilesBoundedInput, + readRepoFilesBoundedOutput, + readRepoFilesInput, + readRepoFilesOutput, + writeRepoFileInput, +} from "@posthog/workspace-server/services/fs/schemas"; + +export const fsRouter = router({ + listRepoFiles: publicProcedure + .input(listRepoFilesInput) + .output(listRepoFilesOutput) + .query(({ ctx, input }) => + ctx.container + .get<FsCapability>(FS_SERVICE) + .listRepoFiles(input.repoPath, input.query, input.limit), + ), + + readRepoFile: publicProcedure + .input(readRepoFileInput) + .output(readRepoFileOutput) + .query(({ ctx, input }) => + ctx.container + .get<FsCapability>(FS_SERVICE) + .readRepoFile(input.repoPath, input.filePath), + ), + + readRepoFiles: publicProcedure + .input(readRepoFilesInput) + .output(readRepoFilesOutput) + .query(({ ctx, input }) => + ctx.container + .get<FsCapability>(FS_SERVICE) + .readRepoFiles(input.repoPath, input.filePaths), + ), + + readRepoFileBounded: publicProcedure + .input(readRepoFileBoundedInput) + .output(boundedReadResult) + .query(({ ctx, input }) => + ctx.container + .get<FsCapability>(FS_SERVICE) + .readRepoFileBounded(input.repoPath, input.filePath, input.maxLines), + ), + + readRepoFilesBounded: publicProcedure + .input(readRepoFilesBoundedInput) + .output(readRepoFilesBoundedOutput) + .query(({ ctx, input }) => + ctx.container + .get<FsCapability>(FS_SERVICE) + .readRepoFilesBounded(input.repoPath, input.filePaths, input.maxLines), + ), + + readAbsoluteFile: publicProcedure + .input(readAbsoluteFileInput) + .output(readRepoFileOutput) + .query(({ ctx, input }) => + ctx.container + .get<FsCapability>(FS_SERVICE) + .readAbsoluteFile(input.filePath), + ), + + readFileAsBase64: publicProcedure + .input(readAbsoluteFileInput) + .output(readRepoFileOutput) + .query(({ ctx, input }) => + ctx.container + .get<FsCapability>(FS_SERVICE) + .readFileAsBase64(input.filePath), + ), + + writeRepoFile: publicProcedure + .input(writeRepoFileInput) + .mutation(({ ctx, input }) => + ctx.container + .get<FsCapability>(FS_SERVICE) + .writeRepoFile(input.repoPath, input.filePath, input.content), + ), +}); diff --git a/packages/host-router/src/routers/git.router.ts b/packages/host-router/src/routers/git.router.ts new file mode 100644 index 0000000000..5412d82e7f --- /dev/null +++ b/packages/host-router/src/routers/git.router.ts @@ -0,0 +1,628 @@ +import { + GitServiceEvent, + type HostGitAgentService, + type HostGitService, + type HostGitWorkspaceClient, +} from "@posthog/core/git/host-git"; +import { + GIT_AGENT_SERVICE, + GIT_SERVICE, + GIT_WORKSPACE_CLIENT, +} from "@posthog/core/git/identifiers"; +import { + checkoutBranchInput, + checkoutBranchOutput, + cloneRepositoryInput, + cloneRepositoryOutput, + commitInput, + commitOutput, + createBranchInput, + createPrInput, + createPrOutput, + detectRepoInput, + detectRepoOutput, + diffInput, + diffOutput, + discardFileChangesInput, + discardFileChangesOutput, + generateCommitMessageInput, + generateCommitMessageOutput, + generatePrTitleAndBodyInput, + generatePrTitleAndBodyOutput, + getAllBranchesInput, + getAllBranchesOutput, + getBranchChangedFilesInput, + getBranchChangedFilesOutput, + getChangedFilesHeadInput, + getChangedFilesHeadOutput, + getCommitConventionsInput, + getCommitConventionsOutput, + getCurrentBranchInput, + getCurrentBranchOutput, + getDiffStatsInput, + getDiffStatsOutput, + getFileAtHeadInput, + getFileAtHeadOutput, + getGitBusyStateInput, + getGitBusyStateOutput, + getGithubIssueInput, + getGithubIssueOutput, + getGithubPullRequestInput, + getGithubPullRequestOutput, + getGitRepoInfoInput, + getGitRepoInfoOutput, + getGitSyncStatusOutput, + getLatestCommitInput, + getLatestCommitOutput, + getLocalBranchChangedFilesInput, + getLocalBranchChangedFilesOutput, + getPrChangedFilesInput, + getPrChangedFilesOutput, + getPrDetailsByUrlInput, + getPrDetailsByUrlOutput, + getPrReviewCommentsInput, + getPrReviewCommentsOutput, + getPrTemplateInput, + getPrTemplateOutput, + getPrUrlForBranchInput, + getPrUrlForBranchOutput, + ghAuthTokenOutput, + ghStatusOutput, + gitStateSnapshotSchema, + gitStatusOutput, + openPrInput, + openPrOutput, + prStatusInput, + prStatusOutput, + publishInput, + publishOutput, + pullInput, + pullOutput, + pushInput, + pushOutput, + replyToPrCommentInput, + replyToPrCommentOutput, + resolveReviewThreadInput, + resolveReviewThreadOutput, + searchGithubRefsInput, + searchGithubRefsOutput, + stageFilesInput, + syncInput, + syncOutput, + updatePrByUrlInput, + updatePrByUrlOutput, + validateRepoInput, + validateRepoOutput, +} from "@posthog/core/git/router-schemas"; +import type { GitPrService } from "@posthog/core/git-pr/git-pr"; +import { GIT_PR_SERVICE } from "@posthog/core/git-pr/identifiers"; +import type { ServiceResolver } from "@posthog/host-trpc/context"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { z } from "zod"; + +const getService = (container: ServiceResolver) => + container.get<HostGitService>(GIT_SERVICE); + +const getGitPrService = (container: ServiceResolver) => + container.get<GitPrService>(GIT_PR_SERVICE); + +const getWorkspaceClient = (container: ServiceResolver) => + container.get<HostGitWorkspaceClient>(GIT_WORKSPACE_CLIENT); + +const getAgentService = (container: ServiceResolver) => + container.get<HostGitAgentService>(GIT_AGENT_SERVICE); + +const resolveSessionEnv = async ( + container: ServiceResolver, + taskId: string | undefined, +): Promise<Record<string, string> | undefined> => { + if (!taskId) return undefined; + try { + const env = await getAgentService(container).getSessionEnvForTask(taskId); + return Object.keys(env).length > 0 ? env : undefined; + } catch { + return undefined; + } +}; + +export const gitRouter = router({ + detectRepo: publicProcedure + .input(detectRepoInput) + .output(detectRepoOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.detectRepo.query({ + directoryPath: input.directoryPath, + }), + ), + + validateRepo: publicProcedure + .input(validateRepoInput) + .output(validateRepoOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.validateRepo.query({ + directoryPath: input.directoryPath, + }), + ), + + cloneRepository: publicProcedure + .input(cloneRepositoryInput) + .output(cloneRepositoryOutput) + .mutation(({ ctx, input }) => + getService(ctx.container).cloneRepository( + input.repoUrl, + input.targetPath, + input.cloneId, + ), + ), + + onCloneProgress: publicProcedure.subscription(async function* (opts) { + const service = getService(opts.ctx.container); + const iterable = service.toIterable(GitServiceEvent.CloneProgress, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + getCurrentBranch: publicProcedure + .input(getCurrentBranchInput) + .output(getCurrentBranchOutput) + .query(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.getCurrentBranch.query( + { directoryPath: input.directoryPath }, + { signal }, + ), + ), + + getAllBranches: publicProcedure + .input(getAllBranchesInput) + .output(getAllBranchesOutput) + .query(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.getAllBranches.query( + { directoryPath: input.directoryPath }, + { signal }, + ), + ), + + getGitBusyState: publicProcedure + .input(getGitBusyStateInput) + .output(getGitBusyStateOutput) + .query(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.getGitBusyState.query( + { directoryPath: input.directoryPath }, + { signal }, + ), + ), + + createBranch: publicProcedure + .input(createBranchInput) + .mutation(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.createBranch.mutate({ + directoryPath: input.directoryPath, + branchName: input.branchName, + }), + ), + + checkoutBranch: publicProcedure + .input(checkoutBranchInput) + .output(checkoutBranchOutput) + .mutation(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.checkoutBranch.mutate({ + directoryPath: input.directoryPath, + branchName: input.branchName, + }), + ), + + getChangedFilesHead: publicProcedure + .input(getChangedFilesHeadInput) + .output(getChangedFilesHeadOutput) + .query(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.getChangedFilesHead.query( + { directoryPath: input.directoryPath }, + { signal }, + ), + ), + + getFileAtHead: publicProcedure + .input(getFileAtHeadInput) + .output(getFileAtHeadOutput) + .query(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.getFileAtHead.query( + { directoryPath: input.directoryPath, filePath: input.filePath }, + { signal }, + ), + ), + + getDiffHead: publicProcedure + .input(diffInput) + .output(diffOutput) + .query(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.getDiffHead.query( + { + directoryPath: input.directoryPath, + ignoreWhitespace: input.ignoreWhitespace, + }, + { signal }, + ), + ), + + getDiffCached: publicProcedure + .input(diffInput) + .output(diffOutput) + .query(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.getDiffCached.query( + { + directoryPath: input.directoryPath, + ignoreWhitespace: input.ignoreWhitespace, + }, + { signal }, + ), + ), + + getDiffUnstaged: publicProcedure + .input(diffInput) + .output(diffOutput) + .query(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.getDiffUnstaged.query( + { + directoryPath: input.directoryPath, + ignoreWhitespace: input.ignoreWhitespace, + }, + { signal }, + ), + ), + + getDiffStats: publicProcedure + .input(getDiffStatsInput) + .output(getDiffStatsOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getDiffStats.query({ + directoryPath: input.directoryPath, + }), + ), + + stageFiles: publicProcedure + .input(stageFilesInput) + .output(gitStateSnapshotSchema) + .mutation(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.stageFiles.mutate({ + directoryPath: input.directoryPath, + paths: input.paths, + }), + ), + + unstageFiles: publicProcedure + .input(stageFilesInput) + .output(gitStateSnapshotSchema) + .mutation(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.unstageFiles.mutate({ + directoryPath: input.directoryPath, + paths: input.paths, + }), + ), + + discardFileChanges: publicProcedure + .input(discardFileChangesInput) + .output(discardFileChangesOutput) + .mutation(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.discardFileChanges.mutate({ + directoryPath: input.directoryPath, + filePath: input.filePath, + fileStatus: input.fileStatus, + }), + ), + + getGitSyncStatus: publicProcedure + .input( + z.object({ + directoryPath: z.string(), + forceRefresh: z.boolean().optional(), + }), + ) + .output(getGitSyncStatusOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getGitSyncStatus.query({ + directoryPath: input.directoryPath, + forceRefresh: input.forceRefresh, + }), + ), + + getLatestCommit: publicProcedure + .input(getLatestCommitInput) + .output(getLatestCommitOutput) + .query(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.getLatestCommit.query( + { directoryPath: input.directoryPath }, + { signal }, + ), + ), + + getGitRepoInfo: publicProcedure + .input(getGitRepoInfoInput) + .output(getGitRepoInfoOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getGitRepoInfo.query({ + directoryPath: input.directoryPath, + }), + ), + + commit: publicProcedure + .input(commitInput) + .output(commitOutput) + .mutation(async ({ ctx, input }) => + getWorkspaceClient(ctx.container).git.commit.mutate({ + directoryPath: input.directoryPath, + message: input.message, + paths: input.paths, + allowEmpty: input.allowEmpty, + stagedOnly: input.stagedOnly, + env: await resolveSessionEnv(ctx.container, input.taskId), + }), + ), + + push: publicProcedure + .input(pushInput) + .output(pushOutput) + .mutation(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.push.mutate( + { + directoryPath: input.directoryPath, + remote: input.remote, + branch: input.branch, + setUpstream: input.setUpstream, + }, + { signal }, + ), + ), + + pull: publicProcedure + .input(pullInput) + .output(pullOutput) + .mutation(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.pull.mutate( + { + directoryPath: input.directoryPath, + remote: input.remote, + branch: input.branch, + }, + { signal }, + ), + ), + + publish: publicProcedure + .input(publishInput) + .output(publishOutput) + .mutation(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.publish.mutate( + { directoryPath: input.directoryPath, remote: input.remote }, + { signal }, + ), + ), + + sync: publicProcedure + .input(syncInput) + .output(syncOutput) + .mutation(({ ctx, input, signal }) => + getWorkspaceClient(ctx.container).git.sync.mutate( + { directoryPath: input.directoryPath, remote: input.remote }, + { signal }, + ), + ), + + getGitStatus: publicProcedure + .output(gitStatusOutput) + .query(({ ctx }) => + getWorkspaceClient(ctx.container).git.getGitStatus.query(), + ), + + getGhStatus: publicProcedure + .output(ghStatusOutput) + .query(({ ctx }) => + getWorkspaceClient(ctx.container).git.getGhStatus.query(), + ), + + getGhAuthToken: publicProcedure + .output(ghAuthTokenOutput) + .query(({ ctx }) => + getWorkspaceClient(ctx.container).git.getGhAuthToken.query(), + ), + + getPrStatus: publicProcedure + .input(prStatusInput) + .output(prStatusOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getPrStatus.query({ + directoryPath: input.directoryPath, + }), + ), + + getPrUrlForBranch: publicProcedure + .input(getPrUrlForBranchInput) + .output(getPrUrlForBranchOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getPrUrlForBranch.query({ + directoryPath: input.directoryPath, + branchName: input.branchName, + }), + ), + + createPr: publicProcedure + .input(createPrInput) + .output(createPrOutput) + .mutation(({ ctx, input }) => getService(ctx.container).createPr(input)), + + openPr: publicProcedure + .input(openPrInput) + .output(openPrOutput) + .mutation(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.openPr.mutate({ + directoryPath: input.directoryPath, + }), + ), + + getPrTemplate: publicProcedure + .input(getPrTemplateInput) + .output(getPrTemplateOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getPrTemplate.query({ + directoryPath: input.directoryPath, + }), + ), + + getCommitConventions: publicProcedure + .input(getCommitConventionsInput) + .output(getCommitConventionsOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getCommitConventions.query({ + directoryPath: input.directoryPath, + sampleSize: input.sampleSize, + }), + ), + + getPrChangedFiles: publicProcedure + .input(getPrChangedFilesInput) + .output(getPrChangedFilesOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getPrChangedFiles.query({ + prUrl: input.prUrl, + }), + ), + + getPrDetailsByUrl: publicProcedure + .input(getPrDetailsByUrlInput) + .output(getPrDetailsByUrlOutput) + .query(async ({ ctx, input }) => { + const result = await getWorkspaceClient( + ctx.container, + ).git.getPrDetailsByUrl.query({ + prUrl: input.prUrl, + }); + return result ?? { state: "unknown", merged: false, draft: false }; + }), + + updatePrByUrl: publicProcedure + .input(updatePrByUrlInput) + .output(updatePrByUrlOutput) + .mutation(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.updatePrByUrl.mutate({ + prUrl: input.prUrl, + action: input.action, + }), + ), + + getPrReviewComments: publicProcedure + .input(getPrReviewCommentsInput) + .output(getPrReviewCommentsOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getPrReviewComments.query({ + prUrl: input.prUrl, + }), + ), + + replyToPrComment: publicProcedure + .input(replyToPrCommentInput) + .output(replyToPrCommentOutput) + .mutation(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.replyToPrComment.mutate({ + prUrl: input.prUrl, + commentId: input.commentId, + body: input.body, + }), + ), + + resolveReviewThread: publicProcedure + .input(resolveReviewThreadInput) + .output(resolveReviewThreadOutput) + .mutation(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.resolveReviewThread.mutate({ + prUrl: input.prUrl, + threadNodeId: input.threadNodeId, + resolved: input.resolved, + }), + ), + + getBranchChangedFiles: publicProcedure + .input(getBranchChangedFilesInput) + .output(getBranchChangedFilesOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getBranchChangedFiles.query({ + repo: input.repo, + branch: input.branch, + }), + ), + + getLocalBranchChangedFiles: publicProcedure + .input(getLocalBranchChangedFilesInput) + .output(getLocalBranchChangedFilesOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getLocalBranchChangedFiles.query({ + directoryPath: input.directoryPath, + branch: input.branch, + }), + ), + + generateCommitMessage: publicProcedure + .input(generateCommitMessageInput) + .output(generateCommitMessageOutput) + .mutation(({ ctx, input }) => + getGitPrService(ctx.container).generateCommitMessage( + input.directoryPath, + input.conversationContext, + ), + ), + + generatePrTitleAndBody: publicProcedure + .input(generatePrTitleAndBodyInput) + .output(generatePrTitleAndBodyOutput) + .mutation(({ ctx, input }) => + getGitPrService(ctx.container).generatePrTitleAndBody( + input.directoryPath, + input.conversationContext, + ), + ), + + searchGithubRefs: publicProcedure + .input(searchGithubRefsInput) + .output(searchGithubRefsOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.searchGithubRefs.query({ + directoryPath: input.directoryPath, + query: input.query, + limit: input.limit, + kinds: input.kinds, + }), + ), + + getGithubIssue: publicProcedure + .input(getGithubIssueInput) + .output(getGithubIssueOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getGithubIssue.query({ + owner: input.owner, + repo: input.repo, + number: input.number, + }), + ), + + getGithubPullRequest: publicProcedure + .input(getGithubPullRequestInput) + .output(getGithubPullRequestOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getGithubPullRequest.query({ + owner: input.owner, + repo: input.repo, + number: input.number, + }), + ), + + onCreatePrProgress: publicProcedure.subscription(async function* (opts) { + const service = getService(opts.ctx.container); + const iterable = service.toIterable(GitServiceEvent.CreatePrProgress, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), +}); diff --git a/packages/host-router/src/routers/github-integration.router.ts b/packages/host-router/src/routers/github-integration.router.ts new file mode 100644 index 0000000000..e3b162d979 --- /dev/null +++ b/packages/host-router/src/routers/github-integration.router.ts @@ -0,0 +1,56 @@ +import { + type FlowTimedOut, + GitHubIntegrationEvent, + type GitHubIntegrationService, + type IntegrationCallback, +} from "@posthog/core/integrations/github"; +import { GITHUB_INTEGRATION_SERVICE } from "@posthog/core/integrations/identifiers"; +import { + startIntegrationFlowInput, + startIntegrationFlowOutput, +} from "@posthog/core/integrations/schemas"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +export const githubIntegrationRouter = router({ + startFlow: publicProcedure + .input(startIntegrationFlowInput) + .output(startIntegrationFlowOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<GitHubIntegrationService>(GITHUB_INTEGRATION_SERVICE) + .startFlow(input.region, input.projectId), + ), + + onCallback: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<GitHubIntegrationService>( + GITHUB_INTEGRATION_SERVICE, + ); + const iterable = service.toIterable(GitHubIntegrationEvent.Callback, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + onFlowTimedOut: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<GitHubIntegrationService>( + GITHUB_INTEGRATION_SERVICE, + ); + const iterable = service.toIterable(GitHubIntegrationEvent.FlowTimedOut, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + consumePendingCallback: publicProcedure.query( + ({ ctx }): IntegrationCallback | null => + ctx.container + .get<GitHubIntegrationService>(GITHUB_INTEGRATION_SERVICE) + .consumePendingCallback(), + ), +}); + +export type { IntegrationCallback, FlowTimedOut }; diff --git a/apps/code/src/main/trpc/routers/handoff.ts b/packages/host-router/src/routers/handoff.router.ts similarity index 57% rename from apps/code/src/main/trpc/routers/handoff.ts rename to packages/host-router/src/routers/handoff.router.ts index d231a70df0..61e7715eef 100644 --- a/apps/code/src/main/trpc/routers/handoff.ts +++ b/packages/host-router/src/routers/handoff.router.ts @@ -1,6 +1,5 @@ -import { z } from "zod"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; +import type { HandoffService } from "@posthog/core/handoff/handoff"; +import { HANDOFF_SERVICE } from "@posthog/core/handoff/identifiers"; import { HandoffEvent, handoffExecuteInput, @@ -11,38 +10,43 @@ import { handoffToCloudExecuteResult, handoffToCloudPreflightInput, handoffToCloudPreflightResult, -} from "../../services/handoff/schemas"; -import type { HandoffService } from "../../services/handoff/service"; -import { publicProcedure, router } from "../trpc"; +} from "@posthog/core/handoff/schemas"; +import type { ServiceResolver } from "@posthog/host-trpc/context"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { z } from "zod"; -const getService = () => - container.get<HandoffService>(MAIN_TOKENS.HandoffService); +const getService = (container: ServiceResolver) => + container.get<HandoffService>(HANDOFF_SERVICE); export const handoffRouter = router({ preflight: publicProcedure .input(handoffPreflightInput) .output(handoffPreflightResult) - .query(({ input }) => getService().preflight(input)), + .query(({ ctx, input }) => getService(ctx.container).preflight(input)), execute: publicProcedure .input(handoffExecuteInput) .output(handoffExecuteResult) - .mutation(({ input }) => getService().execute(input)), + .mutation(({ ctx, input }) => getService(ctx.container).execute(input)), preflightToCloud: publicProcedure .input(handoffToCloudPreflightInput) .output(handoffToCloudPreflightResult) - .query(({ input }) => getService().preflightToCloud(input)), + .query(({ ctx, input }) => + getService(ctx.container).preflightToCloud(input), + ), executeToCloud: publicProcedure .input(handoffToCloudExecuteInput) .output(handoffToCloudExecuteResult) - .mutation(({ input }) => getService().executeToCloud(input)), + .mutation(({ ctx, input }) => + getService(ctx.container).executeToCloud(input), + ), onProgress: publicProcedure .input(z.object({ taskId: z.string() })) .subscription(async function* (opts) { - const service = getService(); + const service = getService(opts.ctx.container); for await (const data of service.toIterable(HandoffEvent.Progress, { signal: opts.signal, })) { diff --git a/packages/host-router/src/routers/linear-integration.router.ts b/packages/host-router/src/routers/linear-integration.router.ts new file mode 100644 index 0000000000..3c2607c1e6 --- /dev/null +++ b/packages/host-router/src/routers/linear-integration.router.ts @@ -0,0 +1,18 @@ +import { LINEAR_INTEGRATION_SERVICE } from "@posthog/core/integrations/identifiers"; +import type { LinearIntegrationService } from "@posthog/core/integrations/linear"; +import { + startIntegrationFlowInput, + startIntegrationFlowOutput, +} from "@posthog/core/integrations/schemas"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +export const linearIntegrationRouter = router({ + startFlow: publicProcedure + .input(startIntegrationFlowInput) + .output(startIntegrationFlowOutput) + .mutation(({ ctx, input }) => { + return ctx.container + .get<LinearIntegrationService>(LINEAR_INTEGRATION_SERVICE) + .startFlow(input.region, input.projectId); + }), +}); diff --git a/packages/host-router/src/routers/llm-gateway.router.ts b/packages/host-router/src/routers/llm-gateway.router.ts new file mode 100644 index 0000000000..006c9cc724 --- /dev/null +++ b/packages/host-router/src/routers/llm-gateway.router.ts @@ -0,0 +1,25 @@ +import { LLM_GATEWAY_SERVICE } from "@posthog/core/llm-gateway/identifiers"; +import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway"; +import { promptInput, promptOutput } from "@posthog/core/llm-gateway/schemas"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +export const llmGatewayRouter = router({ + prompt: publicProcedure + .input(promptInput) + .output(promptOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<LlmGatewayService>(LLM_GATEWAY_SERVICE) + .prompt(input.messages, { + system: input.system, + maxTokens: input.maxTokens, + model: input.model, + }), + ), + + invalidatePlanCache: publicProcedure.mutation(({ ctx }) => + ctx.container + .get<LlmGatewayService>(LLM_GATEWAY_SERVICE) + .invalidatePlanCache(), + ), +}); diff --git a/packages/host-router/src/routers/logs.router.ts b/packages/host-router/src/routers/logs.router.ts new file mode 100644 index 0000000000..f2efdb2245 --- /dev/null +++ b/packages/host-router/src/routers/logs.router.ts @@ -0,0 +1,36 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { ILogsService } from "@posthog/workspace-server/services/local-logs/identifiers"; +import { LOGS_SERVICE } from "@posthog/workspace-server/services/local-logs/identifiers"; +import { + fetchS3LogsInput, + fetchS3LogsOutput, + readLocalLogsInput, + readLocalLogsOutput, + writeLocalLogsInput, +} from "@posthog/workspace-server/services/local-logs/schemas"; + +export const logsRouter = router({ + fetchS3Logs: publicProcedure + .input(fetchS3LogsInput) + .output(fetchS3LogsOutput) + .query(({ ctx, input }) => + ctx.container.get<ILogsService>(LOGS_SERVICE).fetchS3Logs(input.logUrl), + ), + + readLocalLogs: publicProcedure + .input(readLocalLogsInput) + .output(readLocalLogsOutput) + .query(({ ctx, input }) => + ctx.container + .get<ILogsService>(LOGS_SERVICE) + .readLocalLogs(input.taskRunId), + ), + + writeLocalLogs: publicProcedure + .input(writeLocalLogsInput) + .mutation(({ ctx, input }) => + ctx.container + .get<ILogsService>(LOGS_SERVICE) + .writeLocalLogs(input.taskRunId, input.content), + ), +}); diff --git a/apps/code/src/main/trpc/routers/mcp-apps.ts b/packages/host-router/src/routers/mcp-apps.router.ts similarity index 59% rename from apps/code/src/main/trpc/routers/mcp-apps.ts rename to packages/host-router/src/routers/mcp-apps.router.ts index 60d423d435..3afb86b3b1 100644 --- a/apps/code/src/main/trpc/routers/mcp-apps.ts +++ b/packages/host-router/src/routers/mcp-apps.router.ts @@ -1,3 +1,5 @@ +import { MCP_APPS_SERVICE } from "@posthog/core/mcp-apps/identifiers"; +import type { McpAppsService } from "@posthog/core/mcp-apps/mcp-apps"; import { getToolDefinitionInput, getUiResourceInput, @@ -8,49 +10,61 @@ import { openLinkInput, proxyResourceReadInput, proxyToolCallInput, -} from "@shared/types/mcp-apps"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { McpAppsService } from "../../services/mcp-apps/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get<McpAppsService>(MAIN_TOKENS.McpAppsService); +} from "@posthog/core/mcp-apps/schemas"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; export const mcpAppsRouter = router({ getUiResource: publicProcedure .input(getUiResourceInput) .output(mcpUiResourceSchema.nullable()) - .query(({ input }) => getService().getUiResourceForTool(input.toolKey)), + .query(({ ctx, input }) => + ctx.container + .get<McpAppsService>(MCP_APPS_SERVICE) + .getUiResourceForTool(input.toolKey), + ), hasUiForTool: publicProcedure .input(hasUiForToolInput) - .query(({ input }) => getService().hasUiForTool(input.toolKey)), + .query(({ ctx, input }) => + ctx.container + .get<McpAppsService>(MCP_APPS_SERVICE) + .hasUiForTool(input.toolKey), + ), getToolDefinition: publicProcedure .input(getToolDefinitionInput) - .query(({ input }) => getService().getToolDefinition(input.toolKey)), + .query(({ ctx, input }) => + ctx.container + .get<McpAppsService>(MCP_APPS_SERVICE) + .getToolDefinition(input.toolKey), + ), proxyToolCall: publicProcedure .input(proxyToolCallInput) - .mutation(({ input }) => - getService().proxyToolCall(input.serverName, input.toolName, input.args), + .mutation(({ ctx, input }) => + ctx.container + .get<McpAppsService>(MCP_APPS_SERVICE) + .proxyToolCall(input.serverName, input.toolName, input.args), ), proxyResourceRead: publicProcedure .input(proxyResourceReadInput) - .mutation(({ input }) => - getService().proxyResourceRead(input.serverName, input.uri), + .mutation(({ ctx, input }) => + ctx.container + .get<McpAppsService>(MCP_APPS_SERVICE) + .proxyResourceRead(input.serverName, input.uri), ), openLink: publicProcedure .input(openLinkInput) - .mutation(({ input }) => getService().openLink(input.url)), + .mutation(({ ctx, input }) => + ctx.container.get<McpAppsService>(MCP_APPS_SERVICE).openLink(input.url), + ), onToolInput: publicProcedure .input(mcpAppsSubscriptionInput) .subscription(async function* (opts) { - const service = getService(); + const service = opts.ctx.container.get<McpAppsService>(MCP_APPS_SERVICE); const targetToolKey = opts.input.toolKey; for await (const event of service.toIterable( McpAppsServiceEvent.ToolInput, @@ -65,7 +79,7 @@ export const mcpAppsRouter = router({ onToolResult: publicProcedure .input(mcpAppsSubscriptionInput) .subscription(async function* (opts) { - const service = getService(); + const service = opts.ctx.container.get<McpAppsService>(MCP_APPS_SERVICE); const targetToolKey = opts.input.toolKey; for await (const event of service.toIterable( McpAppsServiceEvent.ToolResult, @@ -80,7 +94,7 @@ export const mcpAppsRouter = router({ onToolCancelled: publicProcedure .input(mcpAppsSubscriptionInput) .subscription(async function* (opts) { - const service = getService(); + const service = opts.ctx.container.get<McpAppsService>(MCP_APPS_SERVICE); const targetToolKey = opts.input.toolKey; for await (const event of service.toIterable( McpAppsServiceEvent.ToolCancelled, @@ -93,7 +107,7 @@ export const mcpAppsRouter = router({ }), onDiscoveryComplete: publicProcedure.subscription(async function* (opts) { - const service = getService(); + const service = opts.ctx.container.get<McpAppsService>(MCP_APPS_SERVICE); for await (const event of service.toIterable( McpAppsServiceEvent.DiscoveryComplete, { signal: opts.signal }, diff --git a/apps/code/src/main/trpc/routers/mcp-callback.ts b/packages/host-router/src/routers/mcp-callback.router.ts similarity index 59% rename from apps/code/src/main/trpc/routers/mcp-callback.ts rename to packages/host-router/src/routers/mcp-callback.router.ts index 8bf60be438..d6ba723c7f 100644 --- a/apps/code/src/main/trpc/routers/mcp-callback.ts +++ b/packages/host-router/src/routers/mcp-callback.router.ts @@ -1,16 +1,12 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { MCP_CALLBACK_SERVICE } from "@posthog/workspace-server/services/mcp-callback/identifiers"; +import type { McpCallbackService } from "@posthog/workspace-server/services/mcp-callback/mcp-callback"; import { getCallbackUrlOutput, McpCallbackEvent, openAndWaitInput, openAndWaitOutput, -} from "../../services/mcp-callback/schemas"; -import type { McpCallbackService } from "../../services/mcp-callback/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get<McpCallbackService>(MAIN_TOKENS.McpCallbackService); +} from "@posthog/workspace-server/services/mcp-callback/schemas"; export const mcpCallbackRouter = router({ /** @@ -19,7 +15,11 @@ export const mcpCallbackRouter = router({ */ getCallbackUrl: publicProcedure .output(getCallbackUrlOutput) - .query(() => getService().getCallbackUrl()), + .query(({ ctx }) => + ctx.container + .get<McpCallbackService>(MCP_CALLBACK_SERVICE) + .getCallbackUrl(), + ), /** * Open the OAuth authorization URL in the browser and wait for the callback. @@ -28,8 +28,10 @@ export const mcpCallbackRouter = router({ openAndWaitForCallback: publicProcedure .input(openAndWaitInput) .output(openAndWaitOutput) - .mutation(({ input }) => - getService().openAndWaitForCallback(input.redirectUrl), + .mutation(({ ctx, input }) => + ctx.container + .get<McpCallbackService>(MCP_CALLBACK_SERVICE) + .openAndWaitForCallback(input.redirectUrl), ), /** @@ -37,7 +39,8 @@ export const mcpCallbackRouter = router({ * Useful for refreshing the installations list when a flow completes. */ onOAuthComplete: publicProcedure.subscription(async function* (opts) { - const service = getService(); + const service = + opts.ctx.container.get<McpCallbackService>(MCP_CALLBACK_SERVICE); for await (const data of service.toIterable( McpCallbackEvent.OAuthComplete, { signal: opts.signal }, diff --git a/packages/host-router/src/routers/notification.router.ts b/packages/host-router/src/routers/notification.router.ts new file mode 100644 index 0000000000..bec04c7091 --- /dev/null +++ b/packages/host-router/src/routers/notification.router.ts @@ -0,0 +1,29 @@ +import { NOTIFICATION_SERVICE } from "@posthog/core/notification/identifiers"; +import type { NotificationService } from "@posthog/core/notification/notification"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { z } from "zod"; + +export const notificationRouter = router({ + send: publicProcedure + .input( + z.object({ + title: z.string(), + body: z.string(), + silent: z.boolean(), + taskId: z.string().optional(), + }), + ) + .mutation(({ ctx, input }) => + ctx.container + .get<NotificationService>(NOTIFICATION_SERVICE) + .send(input.title, input.body, input.silent, input.taskId), + ), + showDockBadge: publicProcedure.mutation(({ ctx }) => + ctx.container + .get<NotificationService>(NOTIFICATION_SERVICE) + .showDockBadge(), + ), + bounceDock: publicProcedure.mutation(({ ctx }) => + ctx.container.get<NotificationService>(NOTIFICATION_SERVICE).bounceDock(), + ), +}); diff --git a/packages/host-router/src/routers/oauth.router.ts b/packages/host-router/src/routers/oauth.router.ts new file mode 100644 index 0000000000..ef678cc14a --- /dev/null +++ b/packages/host-router/src/routers/oauth.router.ts @@ -0,0 +1,12 @@ +import { OAUTH_SERVICE } from "@posthog/core/oauth/identifiers"; +import type { OAuthService } from "@posthog/core/oauth/oauth"; +import { cancelFlowOutput } from "@posthog/core/oauth/schemas"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +export const oauthRouter = router({ + cancelFlow: publicProcedure + .output(cancelFlowOutput) + .mutation(({ ctx }) => + ctx.container.get<OAuthService>(OAUTH_SERVICE).cancelFlow(), + ), +}); diff --git a/packages/host-router/src/routers/os.router.ts b/packages/host-router/src/routers/os.router.ts new file mode 100644 index 0000000000..d4d541d158 --- /dev/null +++ b/packages/host-router/src/routers/os.router.ts @@ -0,0 +1,119 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { OS_SERVICE } from "@posthog/workspace-server/services/os/identifiers"; +import type { OsService } from "@posthog/workspace-server/services/os/os"; +import { + checkWriteAccessInput, + claudePermissionsOutput, + downscaleImageFileInput, + openExternalInput, + readFileAsDataUrlInput, + saveClipboardFileInput, + saveClipboardImageInput, + saveClipboardTextInput, + searchDirectoriesInput, + selectAttachmentsInput, + selectAttachmentsOutput, + selectFilesOutput, + showMessageBoxInput, +} from "@posthog/workspace-server/services/os/schemas"; + +export const osRouter = router({ + getClaudePermissions: publicProcedure + .output(claudePermissionsOutput) + .query(({ ctx }) => + ctx.container.get<OsService>(OS_SERVICE).getClaudePermissions(), + ), + + selectDirectory: publicProcedure.query(({ ctx }) => + ctx.container.get<OsService>(OS_SERVICE).selectDirectory(), + ), + + selectFiles: publicProcedure + .output(selectFilesOutput) + .query(({ ctx }) => ctx.container.get<OsService>(OS_SERVICE).selectFiles()), + + selectAttachments: publicProcedure + .input(selectAttachmentsInput) + .output(selectAttachmentsOutput) + .query(({ ctx, input }) => + ctx.container.get<OsService>(OS_SERVICE).selectAttachments(input.mode), + ), + + checkWriteAccess: publicProcedure + .input(checkWriteAccessInput) + .query(({ ctx, input }) => + ctx.container + .get<OsService>(OS_SERVICE) + .checkWriteAccess(input.directoryPath), + ), + + showMessageBox: publicProcedure + .input(showMessageBoxInput) + .mutation(({ ctx, input }) => + ctx.container.get<OsService>(OS_SERVICE).showMessageBox(input.options), + ), + + openExternal: publicProcedure + .input(openExternalInput) + .mutation(({ ctx, input }) => + ctx.container.get<OsService>(OS_SERVICE).openExternal(input.url), + ), + + searchDirectories: publicProcedure + .input(searchDirectoriesInput) + .query(({ ctx, input }) => + ctx.container.get<OsService>(OS_SERVICE).searchDirectories(input.query), + ), + + getAppVersion: publicProcedure.query(({ ctx }) => + ctx.container.get<OsService>(OS_SERVICE).getAppVersion(), + ), + + getWorktreeLocation: publicProcedure.query(({ ctx }) => + ctx.container.get<OsService>(OS_SERVICE).getWorktreeLocation(), + ), + + readFileAsDataUrl: publicProcedure + .input(readFileAsDataUrlInput) + .query(({ ctx, input }) => + ctx.container + .get<OsService>(OS_SERVICE) + .readFileAsDataUrl(input.filePath, input.maxSizeBytes), + ), + + saveClipboardText: publicProcedure + .input(saveClipboardTextInput) + .mutation(({ ctx, input }) => + ctx.container + .get<OsService>(OS_SERVICE) + .saveClipboardText(input.text, input.originalName), + ), + + saveClipboardImage: publicProcedure + .input(saveClipboardImageInput) + .mutation(({ ctx, input }) => + ctx.container + .get<OsService>(OS_SERVICE) + .saveClipboardImage( + input.base64Data, + input.mimeType, + input.originalName, + ), + ), + + downscaleImageFile: publicProcedure + .input(downscaleImageFileInput) + .mutation(({ ctx, input }) => + ctx.container + .get<OsService>(OS_SERVICE) + .downscaleImageFile(input.filePath), + ), + + saveClipboardFile: publicProcedure + .input(saveClipboardFileInput) + .mutation(({ ctx, input }) => + ctx.container + .get<OsService>(OS_SERVICE) + .saveClipboardFile(input.base64Data, input.originalName), + ), +}); diff --git a/packages/host-router/src/routers/process-tracking.router.ts b/packages/host-router/src/routers/process-tracking.router.ts new file mode 100644 index 0000000000..5ed91c9652 --- /dev/null +++ b/packages/host-router/src/routers/process-tracking.router.ts @@ -0,0 +1,62 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { PROCESS_TRACKING_SERVICE } from "@posthog/workspace-server/services/process-tracking/identifiers"; +import type { ProcessTrackingService } from "@posthog/workspace-server/services/process-tracking/process-tracking"; +import { + getSnapshotInput, + killByCategoryInput, + killByPidInput, + killByTaskIdInput, + listByTaskIdInput, +} from "@posthog/workspace-server/services/process-tracking/schemas"; + +export const processTrackingRouter = router({ + getSnapshot: publicProcedure + .input(getSnapshotInput) + .query(({ ctx, input }) => + ctx.container + .get<ProcessTrackingService>(PROCESS_TRACKING_SERVICE) + .getSnapshot(input?.includeDiscovered ?? false), + ), + + list: publicProcedure.query(({ ctx }) => + ctx.container + .get<ProcessTrackingService>(PROCESS_TRACKING_SERVICE) + .getAll(), + ), + + kill: publicProcedure.input(killByPidInput).mutation(({ ctx, input }) => { + ctx.container + .get<ProcessTrackingService>(PROCESS_TRACKING_SERVICE) + .kill(input.pid); + }), + + killByCategory: publicProcedure + .input(killByCategoryInput) + .mutation(({ ctx, input }) => { + ctx.container + .get<ProcessTrackingService>(PROCESS_TRACKING_SERVICE) + .killByCategory(input.category); + }), + + killByTaskId: publicProcedure + .input(killByTaskIdInput) + .mutation(({ ctx, input }) => { + ctx.container + .get<ProcessTrackingService>(PROCESS_TRACKING_SERVICE) + .killByTaskId(input.taskId); + }), + + listByTaskId: publicProcedure + .input(listByTaskIdInput) + .query(({ ctx, input }) => + ctx.container + .get<ProcessTrackingService>(PROCESS_TRACKING_SERVICE) + .getByTaskId(input.taskId), + ), + + killAll: publicProcedure.mutation(({ ctx }) => { + ctx.container + .get<ProcessTrackingService>(PROCESS_TRACKING_SERVICE) + .killAll(); + }), +}); diff --git a/packages/host-router/src/routers/provisioning.router.ts b/packages/host-router/src/routers/provisioning.router.ts new file mode 100644 index 0000000000..e80bf3578d --- /dev/null +++ b/packages/host-router/src/routers/provisioning.router.ts @@ -0,0 +1,18 @@ +import { PROVISIONING_SERVICE } from "@posthog/core/provisioning/identifiers"; +import { + ProvisioningEvent, + type ProvisioningService, +} from "@posthog/core/provisioning/provisioning"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +export const provisioningRouter = router({ + onOutput: publicProcedure.subscription(async function* (opts) { + const service = + opts.ctx.container.get<ProvisioningService>(PROVISIONING_SERVICE); + for await (const data of service.toIterable(ProvisioningEvent.Output, { + signal: opts.signal, + })) { + yield data; + } + }), +}); diff --git a/packages/host-router/src/routers/secure-store.router.ts b/packages/host-router/src/routers/secure-store.router.ts new file mode 100644 index 0000000000..59da0473e2 --- /dev/null +++ b/packages/host-router/src/routers/secure-store.router.ts @@ -0,0 +1,38 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { ISecureStoreService } from "@posthog/workspace-server/services/secure-store/identifiers"; +import { SECURE_STORE_SERVICE } from "@posthog/workspace-server/services/secure-store/identifiers"; +import { + secureStoreGetInput, + secureStoreRemoveInput, + secureStoreSetInput, +} from "@posthog/workspace-server/services/secure-store/schemas"; + +export const secureStoreRouter = router({ + getItem: publicProcedure + .input(secureStoreGetInput) + .query(({ ctx, input }) => + ctx.container + .get<ISecureStoreService>(SECURE_STORE_SERVICE) + .getItem(input.key), + ), + + setItem: publicProcedure + .input(secureStoreSetInput) + .query(({ ctx, input }) => + ctx.container + .get<ISecureStoreService>(SECURE_STORE_SERVICE) + .setItem(input.key, input.value), + ), + + removeItem: publicProcedure + .input(secureStoreRemoveInput) + .query(({ ctx, input }) => + ctx.container + .get<ISecureStoreService>(SECURE_STORE_SERVICE) + .removeItem(input.key), + ), + + clear: publicProcedure.query(({ ctx }) => + ctx.container.get<ISecureStoreService>(SECURE_STORE_SERVICE).clear(), + ), +}); diff --git a/packages/host-router/src/routers/shell.router.ts b/packages/host-router/src/routers/shell.router.ts new file mode 100644 index 0000000000..efcb396f63 --- /dev/null +++ b/packages/host-router/src/routers/shell.router.ts @@ -0,0 +1,99 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { SHELL_SERVICE } from "@posthog/workspace-server/services/shell/identifiers"; +import { + createCommandInput, + createInput, + executeInput, + executeOutput, + resizeInput, + ShellEvent, + type ShellEvents, + sessionIdInput, + writeInput, +} from "@posthog/workspace-server/services/shell/schemas"; +import type { ShellService } from "@posthog/workspace-server/services/shell/shell"; + +function subscribeFiltered<K extends keyof ShellEvents>(event: K) { + return publicProcedure + .input(sessionIdInput) + .subscription(async function* (opts) { + const service = opts.ctx.container.get<ShellService>(SHELL_SERVICE); + const targetSessionId = opts.input.sessionId; + const iterable = service.toIterable(event, { signal: opts.signal }); + + for await (const data of iterable) { + if (data.sessionId === targetSessionId) { + yield data; + } + } + }); +} + +export const shellRouter = router({ + create: publicProcedure + .input(createInput) + .mutation(({ ctx, input }) => + ctx.container + .get<ShellService>(SHELL_SERVICE) + .create(input.sessionId, input.cwd, input.taskId), + ), + + createCommand: publicProcedure + .input(createCommandInput) + .mutation(({ ctx, input }) => + ctx.container.get<ShellService>(SHELL_SERVICE).createCommandSession({ + sessionId: input.sessionId, + command: input.command, + cwd: input.cwd, + taskId: input.taskId, + }), + ), + + write: publicProcedure + .input(writeInput) + .mutation(({ ctx, input }) => + ctx.container + .get<ShellService>(SHELL_SERVICE) + .write(input.sessionId, input.data), + ), + + resize: publicProcedure + .input(resizeInput) + .mutation(({ ctx, input }) => + ctx.container + .get<ShellService>(SHELL_SERVICE) + .resize(input.sessionId, input.cols, input.rows), + ), + + check: publicProcedure + .input(sessionIdInput) + .query(({ ctx, input }) => + ctx.container.get<ShellService>(SHELL_SERVICE).check(input.sessionId), + ), + + destroy: publicProcedure + .input(sessionIdInput) + .mutation(({ ctx, input }) => + ctx.container.get<ShellService>(SHELL_SERVICE).destroy(input.sessionId), + ), + + getProcess: publicProcedure + .input(sessionIdInput) + .query(({ ctx, input }) => + ctx.container + .get<ShellService>(SHELL_SERVICE) + .getProcess(input.sessionId), + ), + + execute: publicProcedure + .input(executeInput) + .output(executeOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<ShellService>(SHELL_SERVICE) + .execute(input.cwd, input.command), + ), + + onData: subscribeFiltered(ShellEvent.Data), + onExit: subscribeFiltered(ShellEvent.Exit), +}); diff --git a/packages/host-router/src/routers/skills.router.ts b/packages/host-router/src/routers/skills.router.ts new file mode 100644 index 0000000000..c5bd7e55af --- /dev/null +++ b/packages/host-router/src/routers/skills.router.ts @@ -0,0 +1,12 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { SKILLS_SERVICE } from "@posthog/workspace-server/services/skills/identifiers"; +import { listSkillsOutput } from "@posthog/workspace-server/services/skills/schemas"; +import type { SkillsService } from "@posthog/workspace-server/services/skills/skills"; + +export const skillsRouter = router({ + list: publicProcedure + .output(listSkillsOutput) + .query(({ ctx }) => + ctx.container.get<SkillsService>(SKILLS_SERVICE).listSkills(), + ), +}); diff --git a/packages/host-router/src/routers/slack-integration.router.ts b/packages/host-router/src/routers/slack-integration.router.ts new file mode 100644 index 0000000000..8671ac8b01 --- /dev/null +++ b/packages/host-router/src/routers/slack-integration.router.ts @@ -0,0 +1,53 @@ +import { SLACK_INTEGRATION_SERVICE } from "@posthog/core/integrations/identifiers"; +import { + startIntegrationFlowInput, + startIntegrationFlowOutput, +} from "@posthog/core/integrations/schemas"; +import { + type SlackIntegrationCallback, + SlackIntegrationEvent, + type SlackIntegrationService, +} from "@posthog/core/integrations/slack"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +export const slackIntegrationRouter = router({ + startFlow: publicProcedure + .input(startIntegrationFlowInput) + .output(startIntegrationFlowOutput) + .mutation(({ ctx, input }) => { + return ctx.container + .get<SlackIntegrationService>(SLACK_INTEGRATION_SERVICE) + .startFlow(input.region, input.projectId); + }), + + onCallback: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<SlackIntegrationService>( + SLACK_INTEGRATION_SERVICE, + ); + const iterable = service.toIterable(SlackIntegrationEvent.Callback, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + onFlowTimedOut: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<SlackIntegrationService>( + SLACK_INTEGRATION_SERVICE, + ); + const iterable = service.toIterable(SlackIntegrationEvent.FlowTimedOut, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + consumePendingCallback: publicProcedure.query( + ({ ctx }): SlackIntegrationCallback | null => + ctx.container + .get<SlackIntegrationService>(SLACK_INTEGRATION_SERVICE) + .consumePendingCallback(), + ), +}); diff --git a/packages/host-router/src/routers/sleep.router.ts b/packages/host-router/src/routers/sleep.router.ts new file mode 100644 index 0000000000..07615f67d0 --- /dev/null +++ b/packages/host-router/src/routers/sleep.router.ts @@ -0,0 +1,18 @@ +import { SLEEP_SERVICE } from "@posthog/core/sleep/identifiers"; +import type { SleepService } from "@posthog/core/sleep/sleep"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { z } from "zod"; + +export const sleepRouter = router({ + getEnabled: publicProcedure + .output(z.boolean()) + .query(({ ctx }) => + ctx.container.get<SleepService>(SLEEP_SERVICE).getEnabled(), + ), + + setEnabled: publicProcedure + .input(z.object({ enabled: z.boolean() })) + .mutation(({ ctx, input }) => { + ctx.container.get<SleepService>(SLEEP_SERVICE).setEnabled(input.enabled); + }), +}); diff --git a/packages/host-router/src/routers/suspension.router.ts b/packages/host-router/src/routers/suspension.router.ts new file mode 100644 index 0000000000..2001023937 --- /dev/null +++ b/packages/host-router/src/routers/suspension.router.ts @@ -0,0 +1,63 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { SUSPENSION_SERVICE } from "@posthog/workspace-server/services/suspension/identifiers"; +import { + listSuspendedTasksOutput, + restoreTaskInput, + restoreTaskOutput, + suspendedTaskIdsOutput, + suspendTaskInput, + suspendTaskOutput, + suspensionSettingsOutput, + updateSuspensionSettingsInput, +} from "@posthog/workspace-server/services/suspension/schemas"; +import type { SuspensionService } from "@posthog/workspace-server/services/suspension/suspension"; + +export const suspensionRouter = router({ + suspend: publicProcedure + .input(suspendTaskInput) + .output(suspendTaskOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<SuspensionService>(SUSPENSION_SERVICE) + .suspendTask(input.taskId, input.reason), + ), + + restore: publicProcedure + .input(restoreTaskInput) + .output(restoreTaskOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<SuspensionService>(SUSPENSION_SERVICE) + .restoreTask(input.taskId, input.recreateBranch), + ), + + list: publicProcedure + .output(listSuspendedTasksOutput) + .query(({ ctx }) => + ctx.container + .get<SuspensionService>(SUSPENSION_SERVICE) + .getSuspendedTasks(), + ), + + suspendedTaskIds: publicProcedure + .output(suspendedTaskIdsOutput) + .query(({ ctx }) => + ctx.container + .get<SuspensionService>(SUSPENSION_SERVICE) + .getSuspendedTaskIds(), + ), + + settings: publicProcedure + .output(suspensionSettingsOutput) + .query(({ ctx }) => + ctx.container.get<SuspensionService>(SUSPENSION_SERVICE).getSettings(), + ), + + updateSettings: publicProcedure + .input(updateSuspensionSettingsInput) + .mutation(({ ctx, input }) => + ctx.container + .get<SuspensionService>(SUSPENSION_SERVICE) + .updateSettings(input), + ), +}); diff --git a/apps/code/src/main/trpc/routers/ui.ts b/packages/host-router/src/routers/ui.router.ts similarity index 61% rename from apps/code/src/main/trpc/routers/ui.ts rename to packages/host-router/src/routers/ui.router.ts index 45830580b2..dd64512190 100644 --- a/apps/code/src/main/trpc/routers/ui.ts +++ b/packages/host-router/src/routers/ui.router.ts @@ -1,17 +1,11 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { - UIServiceEvent, - type UIServiceEvents, -} from "../../services/ui/schemas"; -import type { UIService } from "../../services/ui/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => container.get<UIService>(MAIN_TOKENS.UIService); +import { UI_SERVICE } from "@posthog/core/ui/identifiers"; +import { UIServiceEvent, type UIServiceEvents } from "@posthog/core/ui/schemas"; +import type { UIService } from "@posthog/core/ui/ui"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; function subscribeToUIEvent<K extends keyof UIServiceEvents>(event: K) { return publicProcedure.subscription(async function* (opts) { - const service = getService(); + const service = opts.ctx.container.get<UIService>(UI_SERVICE); const iterable = service.toIterable(event, { signal: opts.signal }); for await (const data of iterable) { yield data; diff --git a/apps/code/src/main/trpc/routers/updates.test.ts b/packages/host-router/src/routers/updates.router.test.ts similarity index 56% rename from apps/code/src/main/trpc/routers/updates.test.ts rename to packages/host-router/src/routers/updates.router.test.ts index b36e223d1a..df505071f6 100644 --- a/apps/code/src/main/trpc/routers/updates.test.ts +++ b/packages/host-router/src/routers/updates.router.test.ts @@ -1,30 +1,24 @@ import { describe, expect, it, vi } from "vitest"; -const { mockUpdatesService } = vi.hoisted(() => ({ - mockUpdatesService: { - isEnabled: true, - checkForUpdates: vi.fn(() => ({ success: true })), - getStatus: vi.fn(() => ({ - checking: false, - updateReady: true, - version: "v2.0.0", - })), - installUpdate: vi.fn(() => Promise.resolve({ installed: true })), - toIterable: vi.fn(), - }, -})); - -vi.mock("../../di/container", () => ({ - container: { - get: vi.fn(() => mockUpdatesService), - }, -})); - -import { updatesRouter } from "./updates"; +const mockUpdatesService = { + isEnabled: true, + checkForUpdates: vi.fn(() => ({ success: true })), + getStatus: vi.fn(() => ({ + checking: false, + updateReady: true, + version: "v2.0.0", + })), + installUpdate: vi.fn(() => Promise.resolve({ installed: true })), + toIterable: vi.fn(), +}; + +import { updatesRouter } from "./updates.router"; + +const resolver = { get: <T>() => mockUpdatesService as T }; describe("updatesRouter", () => { it("returns the current update status snapshot", async () => { - const caller = updatesRouter.createCaller({}); + const caller = updatesRouter.createCaller({ container: resolver }); await expect(caller.getStatus()).resolves.toEqual({ checking: false, @@ -35,20 +29,20 @@ describe("updatesRouter", () => { }); it("delegates menu/user checks to the updates service", async () => { - const caller = updatesRouter.createCaller({}); + const caller = updatesRouter.createCaller({ container: resolver }); await expect(caller.check()).resolves.toEqual({ success: true }); expect(mockUpdatesService.checkForUpdates).toHaveBeenCalled(); }); it("reports whether updates are enabled", async () => { - const caller = updatesRouter.createCaller({}); + const caller = updatesRouter.createCaller({ container: resolver }); await expect(caller.isEnabled()).resolves.toEqual({ enabled: true }); }); it("delegates install to the updates service", async () => { - const caller = updatesRouter.createCaller({}); + const caller = updatesRouter.createCaller({ container: resolver }); await expect(caller.install()).resolves.toEqual({ installed: true }); expect(mockUpdatesService.installUpdate).toHaveBeenCalled(); diff --git a/packages/host-router/src/routers/updates.router.ts b/packages/host-router/src/routers/updates.router.ts new file mode 100644 index 0000000000..b861c100bf --- /dev/null +++ b/packages/host-router/src/routers/updates.router.ts @@ -0,0 +1,47 @@ +import { UPDATES_SERVICE } from "@posthog/core/updates/identifiers"; +import { + checkForUpdatesOutput, + installUpdateOutput, + isEnabledOutput, + UpdatesEvent, + type UpdatesEvents, + updatesStatusOutput, +} from "@posthog/core/updates/schemas"; +import type { UpdatesService } from "@posthog/core/updates/updates"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; + +function subscribe<K extends keyof UpdatesEvents>(event: K) { + return publicProcedure.subscription(async function* ({ ctx, signal }) { + const service = ctx.container.get<UpdatesService>(UPDATES_SERVICE); + const iterable = service.toIterable(event, { signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const updatesRouter = router({ + isEnabled: publicProcedure.output(isEnabledOutput).query(({ ctx }) => { + const service = ctx.container.get<UpdatesService>(UPDATES_SERVICE); + return { enabled: service.isEnabled }; + }), + + check: publicProcedure.output(checkForUpdatesOutput).mutation(({ ctx }) => { + const service = ctx.container.get<UpdatesService>(UPDATES_SERVICE); + return service.checkForUpdates(); + }), + + getStatus: publicProcedure.output(updatesStatusOutput).query(({ ctx }) => { + const service = ctx.container.get<UpdatesService>(UPDATES_SERVICE); + return service.getStatus(); + }), + + install: publicProcedure.output(installUpdateOutput).mutation(({ ctx }) => { + const service = ctx.container.get<UpdatesService>(UPDATES_SERVICE); + return service.installUpdate(); + }), + + onReady: subscribe(UpdatesEvent.Ready), + onStatus: subscribe(UpdatesEvent.Status), + onCheckFromMenu: subscribe(UpdatesEvent.CheckFromMenu), +}); diff --git a/apps/code/src/main/trpc/routers/usage-monitor.ts b/packages/host-router/src/routers/usage-monitor.router.ts similarity index 51% rename from apps/code/src/main/trpc/routers/usage-monitor.ts rename to packages/host-router/src/routers/usage-monitor.router.ts index 6775e57d2f..9f8a1a688d 100644 --- a/apps/code/src/main/trpc/routers/usage-monitor.ts +++ b/packages/host-router/src/routers/usage-monitor.router.ts @@ -1,19 +1,17 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; +import { USAGE_MONITOR_SERVICE } from "@posthog/core/usage/identifiers"; import { UsageMonitorEvent, type UsageMonitorEvents, usageSnapshotOutput, -} from "../../services/usage-monitor/schemas"; -import type { UsageMonitorService } from "../../services/usage-monitor/service"; -import { publicProcedure, router } from "../trpc"; - -const getService = () => - container.get<UsageMonitorService>(MAIN_TOKENS.UsageMonitorService); +} from "@posthog/core/usage/monitor-schemas"; +import type { UsageMonitorService } from "@posthog/core/usage/usage-monitor"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; function subscribe<K extends keyof UsageMonitorEvents>(event: K) { return publicProcedure.subscription(async function* (opts) { - const service = getService(); + const service = opts.ctx.container.get<UsageMonitorService>( + USAGE_MONITOR_SERVICE, + ); const iterable = service.toIterable(event, { signal: opts.signal }); for await (const data of iterable) { yield data; @@ -26,8 +24,14 @@ export const usageMonitorRouter = router({ onUsageUpdated: subscribe(UsageMonitorEvent.UsageUpdated), getLatest: publicProcedure .output(usageSnapshotOutput) - .query(() => getService().getLatest()), + .query(({ ctx }) => + ctx.container.get<UsageMonitorService>(USAGE_MONITOR_SERVICE).getLatest(), + ), refresh: publicProcedure .output(usageSnapshotOutput) - .mutation(() => getService().refreshNow()), + .mutation(({ ctx }) => + ctx.container + .get<UsageMonitorService>(USAGE_MONITOR_SERVICE) + .refreshNow(), + ), }); diff --git a/packages/host-router/src/routers/workspace.router.ts b/packages/host-router/src/routers/workspace.router.ts new file mode 100644 index 0000000000..7e8f28cf7b --- /dev/null +++ b/packages/host-router/src/routers/workspace.router.ts @@ -0,0 +1,221 @@ +import type { ServiceResolver } from "@posthog/host-trpc/context"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { WORKSPACE_SERVICE } from "@posthog/workspace-server/services/workspace/identifiers"; +import { + createWorkspaceInput, + createWorkspaceOutput, + deleteWorkspaceInput, + deleteWorktreeInput, + getAllTaskTimestampsOutput, + getAllWorkspacesOutput, + getLocalTasksInput, + getLocalTasksOutput, + getPinnedTaskIdsOutput, + getTaskTimestampsInput, + getTaskTimestampsOutput, + getWorkspaceInfoInput, + getWorkspaceInfoOutput, + getWorktreeFileUsageInput, + getWorktreeFileUsageOutput, + getWorktreeSizeInput, + getWorktreeSizeOutput, + getWorktreeTasksInput, + getWorktreeTasksOutput, + linkBranchInput, + listGitWorktreesInput, + listGitWorktreesOutput, + markActivityInput, + markViewedInput, + reconcileCloudWorkspacesInput, + reconcileCloudWorkspacesOutput, + taskPrStatusInput, + taskPrStatusOutput, + togglePinInput, + togglePinOutput, + unlinkBranchInput, + verifyWorkspaceInput, + verifyWorkspaceOutput, +} from "@posthog/workspace-server/services/workspace/schemas"; +import { + type WorkspaceService, + WorkspaceServiceEvent, + type WorkspaceServiceEvents, +} from "@posthog/workspace-server/services/workspace/workspace"; +import { WORKSPACE_METADATA_SERVICE } from "@posthog/workspace-server/services/workspace-metadata/identifiers"; +import type { WorkspaceMetadataService } from "@posthog/workspace-server/services/workspace-metadata/workspace-metadata"; +import { + getWorktreeFileUsage, + getWorktreeSize, +} from "@posthog/workspace-server/services/worktree-query/worktree-query"; +import { + GIT_PR_STATUS_PROVIDER, + type IGitPrStatus, +} from "../ports/git-pr-status"; + +const getService = (container: ServiceResolver) => + container.get<WorkspaceService>(WORKSPACE_SERVICE); + +const getGitService = (container: ServiceResolver) => + container.get<IGitPrStatus>(GIT_PR_STATUS_PROVIDER); + +const getMetadata = (container: ServiceResolver) => + container.get<WorkspaceMetadataService>(WORKSPACE_METADATA_SERVICE); + +function subscribe<K extends keyof WorkspaceServiceEvents>(event: K) { + return publicProcedure.subscription(async function* (opts) { + const service = getService(opts.ctx.container); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const workspaceRouter = router({ + create: publicProcedure + .input(createWorkspaceInput) + .output(createWorkspaceOutput) + .mutation(({ ctx, input }) => + getService(ctx.container).createWorkspace(input), + ), + + reconcileCloudWorkspaces: publicProcedure + .input(reconcileCloudWorkspacesInput) + .output(reconcileCloudWorkspacesOutput) + .mutation(({ ctx, input }) => + getService(ctx.container).reconcileCloudWorkspaces(input.taskIds), + ), + + delete: publicProcedure + .input(deleteWorkspaceInput) + .mutation(({ ctx, input }) => + getService(ctx.container).deleteWorkspace( + input.taskId, + input.mainRepoPath, + ), + ), + + verify: publicProcedure + .input(verifyWorkspaceInput) + .output(verifyWorkspaceOutput) + .query(({ ctx, input }) => + getService(ctx.container).verifyWorkspaceExists(input.taskId), + ), + + getInfo: publicProcedure + .input(getWorkspaceInfoInput) + .output(getWorkspaceInfoOutput) + .query(({ ctx, input }) => + getService(ctx.container).getWorkspaceInfo(input.taskId), + ), + + getAll: publicProcedure + .output(getAllWorkspacesOutput) + .query(({ ctx }) => getService(ctx.container).getAllWorkspaces()), + + getLocalTasks: publicProcedure + .input(getLocalTasksInput) + .output(getLocalTasksOutput) + .query(({ ctx, input }) => + getService(ctx.container).getLocalTasksForFolder(input.mainRepoPath), + ), + + getWorktreeTasks: publicProcedure + .input(getWorktreeTasksInput) + .output(getWorktreeTasksOutput) + .query(({ ctx, input }) => + getService(ctx.container).getWorktreeTasks(input.worktreePath), + ), + + listGitWorktrees: publicProcedure + .input(listGitWorktreesInput) + .output(listGitWorktreesOutput) + .query(({ ctx, input }) => + getService(ctx.container).listGitWorktrees(input.mainRepoPath), + ), + + getWorktreeSize: publicProcedure + .input(getWorktreeSizeInput) + .output(getWorktreeSizeOutput) + .query(({ input }) => getWorktreeSize(input.worktreePath)), + + getWorktreeFileUsage: publicProcedure + .input(getWorktreeFileUsageInput) + .output(getWorktreeFileUsageOutput) + .query(({ input }) => getWorktreeFileUsage(input.mainRepoPath)), + + deleteWorktree: publicProcedure + .input(deleteWorktreeInput) + .mutation(({ ctx, input }) => + getService(ctx.container).deleteWorktree( + input.mainRepoPath, + input.worktreePath, + ), + ), + + togglePin: publicProcedure + .input(togglePinInput) + .output(togglePinOutput) + .mutation(({ ctx, input }) => + getMetadata(ctx.container).togglePin(input.taskId), + ), + + markViewed: publicProcedure + .input(markViewedInput) + .mutation(({ ctx, input }) => + getMetadata(ctx.container).markViewed(input.taskId), + ), + + markActivity: publicProcedure + .input(markActivityInput) + .mutation(({ ctx, input }) => + getMetadata(ctx.container).markActivity(input.taskId), + ), + + getPinnedTaskIds: publicProcedure + .output(getPinnedTaskIdsOutput) + .query(({ ctx }) => getMetadata(ctx.container).getPinnedTaskIds()), + + getTaskTimestamps: publicProcedure + .input(getTaskTimestampsInput) + .output(getTaskTimestampsOutput) + .query(({ ctx, input }) => + getMetadata(ctx.container).getTaskTimestamps(input.taskId), + ), + + getAllTaskTimestamps: publicProcedure + .output(getAllTaskTimestampsOutput) + .query(({ ctx }) => getMetadata(ctx.container).getAllTaskTimestamps()), + + linkBranch: publicProcedure + .input(linkBranchInput) + .mutation(({ ctx, input }) => + getService(ctx.container).linkBranch( + input.taskId, + input.branchName, + "user", + ), + ), + + unlinkBranch: publicProcedure + .input(unlinkBranchInput) + .mutation(({ ctx, input }) => + getService(ctx.container).unlinkBranch(input.taskId, "user"), + ), + + getTaskPrStatus: publicProcedure + .input(taskPrStatusInput) + .output(taskPrStatusOutput) + .query(({ ctx, input }) => + getGitService(ctx.container).getTaskPrStatus( + input.taskId, + input.cloudPrUrl, + ), + ), + + onError: subscribe(WorkspaceServiceEvent.Error), + onWarning: subscribe(WorkspaceServiceEvent.Warning), + onPromoted: subscribe(WorkspaceServiceEvent.Promoted), + onBranchChanged: subscribe(WorkspaceServiceEvent.BranchChanged), + onLinkedBranchChanged: subscribe(WorkspaceServiceEvent.LinkedBranchChanged), +}); diff --git a/packages/host-router/tsconfig.json b/packages/host-router/tsconfig.json new file mode 100644 index 0000000000..2bdd7e87dc --- /dev/null +++ b/packages/host-router/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@posthog/tsconfig/react-package.json", + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"] +} diff --git a/packages/host-trpc/package.json b/packages/host-trpc/package.json new file mode 100644 index 0000000000..d567d05c36 --- /dev/null +++ b/packages/host-trpc/package.json @@ -0,0 +1,28 @@ +{ + "name": "@posthog/host-trpc", + "version": "1.0.0", + "description": "Shared tRPC base instance for the Electron main (host) router. One initTRPC instance with a container-bearing context, shared by host feature routers (colocated with their services in core/workspace-server) and the host that serves them over electron-trpc. Leaf package so colocated routers share a base without a dependency cycle.", + "private": true, + "type": "module", + "exports": { + "./*": [ + "./src/*.ts", + "./src/*.tsx" + ] + }, + "scripts": { + "typecheck": "tsc --noEmit", + "clean": "node ../../scripts/rimraf.mjs .turbo" + }, + "dependencies": { + "@trpc/server": "catalog:" + }, + "peerDependencies": { + "inversify": "catalog:" + }, + "devDependencies": { + "@posthog/tsconfig": "workspace:*", + "inversify": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/host-trpc/src/context.ts b/packages/host-trpc/src/context.ts new file mode 100644 index 0000000000..60f1bb5a91 --- /dev/null +++ b/packages/host-trpc/src/context.ts @@ -0,0 +1,9 @@ +import type { ServiceIdentifier } from "inversify"; + +export interface ServiceResolver { + get<T>(serviceIdentifier: ServiceIdentifier<T>): T; +} + +export interface HostContext { + container: ServiceResolver; +} diff --git a/packages/host-trpc/src/trpc.ts b/packages/host-trpc/src/trpc.ts new file mode 100644 index 0000000000..d209a88580 --- /dev/null +++ b/packages/host-trpc/src/trpc.ts @@ -0,0 +1,11 @@ +import { initTRPC } from "@trpc/server"; +import type { HostContext } from "./context"; + +const t = initTRPC.context<HostContext>().create({ + isServer: true, +}); + +export const router = t.router; +export const publicProcedure = t.procedure; +export const middleware = t.middleware; +export const mergeRouters = t.mergeRouters; diff --git a/packages/host-trpc/tsconfig.json b/packages/host-trpc/tsconfig.json new file mode 100644 index 0000000000..d8691e538c --- /dev/null +++ b/packages/host-trpc/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@posthog/tsconfig/node-package.json", + "include": ["src/**/*"] +} diff --git a/packages/platform/package.json b/packages/platform/package.json index 68916d1e20..b2e8f0ce12 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -24,6 +24,10 @@ "types": "./dist/clipboard.d.ts", "import": "./dist/clipboard.js" }, + "./crypto": { + "types": "./dist/crypto.d.ts", + "import": "./dist/crypto.js" + }, "./file-icon": { "types": "./dist/file-icon.d.ts", "import": "./dist/file-icon.js" @@ -52,6 +56,10 @@ "types": "./dist/notifier.d.ts", "import": "./dist/notifier.js" }, + "./notifications": { + "types": "./dist/notifications.d.ts", + "import": "./dist/notifications.js" + }, "./context-menu": { "types": "./dist/context-menu.d.ts", "import": "./dist/context-menu.js" @@ -63,6 +71,18 @@ "./image-processor": { "types": "./dist/image-processor.d.ts", "import": "./dist/image-processor.js" + }, + "./workspace-settings": { + "types": "./dist/workspace-settings.d.ts", + "import": "./dist/workspace-settings.js" + }, + "./analytics": { + "types": "./dist/analytics.d.ts", + "import": "./dist/analytics.js" + }, + "./deep-link": { + "types": "./dist/deep-link.d.ts", + "import": "./dist/deep-link.js" } }, "scripts": { diff --git a/packages/platform/src/analytics.ts b/packages/platform/src/analytics.ts new file mode 100644 index 0000000000..2c5e9d7ecd --- /dev/null +++ b/packages/platform/src/analytics.ts @@ -0,0 +1,18 @@ +export type AnalyticsProperties = Record<string, string | number | boolean>; + +export interface IAnalytics { + initialize(): void; + track(eventName: string, properties?: AnalyticsProperties): void; + identify(userId: string, properties?: AnalyticsProperties): void; + setCurrentUserId(userId: string | null): void; + getCurrentUserId(): string | null; + resetUser(): void; + captureException( + error: unknown, + additionalProperties?: Record<string, unknown>, + ): void; + flush(): Promise<void>; + shutdown(): Promise<void>; +} + +export const ANALYTICS_SERVICE = Symbol.for("posthog.platform.analytics"); diff --git a/packages/platform/src/app-lifecycle.ts b/packages/platform/src/app-lifecycle.ts index 16c133c9b2..7b7befd2ff 100644 --- a/packages/platform/src/app-lifecycle.ts +++ b/packages/platform/src/app-lifecycle.ts @@ -5,3 +5,7 @@ export interface IAppLifecycle { onQuit(handler: () => void | Promise<void>): () => void; registerDeepLinkScheme(scheme: string): void; } + +export const APP_LIFECYCLE_SERVICE = Symbol.for( + "posthog.platform.appLifecycle", +); diff --git a/packages/platform/src/app-meta.ts b/packages/platform/src/app-meta.ts index 2d2c723b95..abd8b42994 100644 --- a/packages/platform/src/app-meta.ts +++ b/packages/platform/src/app-meta.ts @@ -1,4 +1,10 @@ export interface IAppMeta { readonly version: string; readonly isProduction: boolean; + /** Host OS platform (e.g. "darwin", "win32", "linux"). */ + readonly platform: string; + /** Host CPU arch (e.g. "arm64", "x64"). */ + readonly arch: string; } + +export const APP_META_SERVICE = Symbol.for("posthog.platform.appMeta"); diff --git a/packages/platform/src/bundled-resources.ts b/packages/platform/src/bundled-resources.ts index 64750bc2c3..81ee45c8df 100644 --- a/packages/platform/src/bundled-resources.ts +++ b/packages/platform/src/bundled-resources.ts @@ -6,3 +6,7 @@ export interface IBundledResources { */ resolve(relativePath: string): string; } + +export const BUNDLED_RESOURCES_SERVICE = Symbol.for( + "posthog.platform.bundledResources", +); diff --git a/packages/platform/src/clipboard.ts b/packages/platform/src/clipboard.ts index a0bee08e65..3ecb0356bb 100644 --- a/packages/platform/src/clipboard.ts +++ b/packages/platform/src/clipboard.ts @@ -1,3 +1,5 @@ export interface IClipboard { writeText(text: string): Promise<void>; } + +export const CLIPBOARD_SERVICE = Symbol.for("posthog.platform.clipboard"); diff --git a/packages/platform/src/context-menu.ts b/packages/platform/src/context-menu.ts index 1bb9f3909b..5aed4f02fa 100644 --- a/packages/platform/src/context-menu.ts +++ b/packages/platform/src/context-menu.ts @@ -20,3 +20,5 @@ export interface ShowContextMenuOptions { export interface IContextMenu { show(items: ContextMenuItem[], options?: ShowContextMenuOptions): void; } + +export const CONTEXT_MENU_SERVICE = Symbol.for("posthog.platform.contextMenu"); diff --git a/packages/platform/src/crypto.ts b/packages/platform/src/crypto.ts new file mode 100644 index 0000000000..f509be96fa --- /dev/null +++ b/packages/platform/src/crypto.ts @@ -0,0 +1,13 @@ +/** + * Host crypto/random capability. Keeps node:crypto out of core (PKCE, ids, + * hashes). Each host implements it natively (Electron/Node via node:crypto, a + * web host via Web Crypto). + */ +export interface ICrypto { + /** Cryptographically-random bytes, base64url-encoded. */ + randomBase64Url(byteLength: number): string; + /** SHA-256 digest of the input string, base64url-encoded. */ + sha256Base64Url(input: string): string; +} + +export const CRYPTO_SERVICE = Symbol.for("posthog.platform.crypto"); diff --git a/packages/platform/src/deep-link.ts b/packages/platform/src/deep-link.ts new file mode 100644 index 0000000000..70147eed12 --- /dev/null +++ b/packages/platform/src/deep-link.ts @@ -0,0 +1,12 @@ +export type DeepLinkHandler = ( + path: string, + searchParams: URLSearchParams, +) => boolean; + +export interface IDeepLinkRegistry { + registerHandler(key: string, handler: DeepLinkHandler): void; + unregisterHandler(key: string): void; + getProtocol(): string; +} + +export const DEEP_LINK_SERVICE = Symbol.for("posthog.platform.deepLink"); diff --git a/packages/platform/src/dialog.ts b/packages/platform/src/dialog.ts index 2c66df9be3..c547146759 100644 --- a/packages/platform/src/dialog.ts +++ b/packages/platform/src/dialog.ts @@ -22,3 +22,5 @@ export interface IDialog { confirm(options: ConfirmOptions): Promise<number>; pickFile(options: PickFileOptions): Promise<string[]>; } + +export const DIALOG_SERVICE = Symbol.for("posthog.platform.dialog"); diff --git a/packages/platform/src/file-icon.ts b/packages/platform/src/file-icon.ts index e38200ef25..dc9a30d486 100644 --- a/packages/platform/src/file-icon.ts +++ b/packages/platform/src/file-icon.ts @@ -5,3 +5,5 @@ export interface IFileIcon { */ getAsDataUrl(filePath: string): Promise<string | null>; } + +export const FILE_ICON_SERVICE = Symbol.for("posthog.platform.fileIcon"); diff --git a/packages/platform/src/image-processor.ts b/packages/platform/src/image-processor.ts index 7adf4eb078..6d55508c61 100644 --- a/packages/platform/src/image-processor.ts +++ b/packages/platform/src/image-processor.ts @@ -25,3 +25,7 @@ export interface IImageProcessor { options: DownscaleOptions, ): DownscaledImage; } + +export const IMAGE_PROCESSOR_SERVICE = Symbol.for( + "posthog.platform.imageProcessor", +); diff --git a/packages/platform/src/main-window.ts b/packages/platform/src/main-window.ts index b8030e2b01..e5f6f9cbfc 100644 --- a/packages/platform/src/main-window.ts +++ b/packages/platform/src/main-window.ts @@ -5,3 +5,5 @@ export interface IMainWindow { restore(): void; onFocus(handler: () => void): () => void; } + +export const MAIN_WINDOW_SERVICE = Symbol.for("posthog.platform.mainWindow"); diff --git a/packages/platform/src/notifications.ts b/packages/platform/src/notifications.ts new file mode 100644 index 0000000000..288b8a714b --- /dev/null +++ b/packages/platform/src/notifications.ts @@ -0,0 +1,16 @@ +export interface NotificationOptions { + title: string; + body: string; + silent: boolean; + taskId?: string; +} + +export interface INotifications { + notify(options: NotificationOptions): void; + showUnreadIndicator(): void; + requestAttention(): void; +} + +export const NOTIFICATIONS_SERVICE = Symbol.for( + "posthog.platform.notifications", +); diff --git a/packages/platform/src/notifier.ts b/packages/platform/src/notifier.ts index 534af763b2..534bb7cfdf 100644 --- a/packages/platform/src/notifier.ts +++ b/packages/platform/src/notifier.ts @@ -11,3 +11,5 @@ export interface INotifier { setUnreadIndicator(on: boolean): void; requestAttention(): void; } + +export const NOTIFIER_SERVICE = Symbol.for("posthog.platform.notifier"); diff --git a/packages/platform/src/power-manager.ts b/packages/platform/src/power-manager.ts index ffdf949ca7..28ba19e682 100644 --- a/packages/platform/src/power-manager.ts +++ b/packages/platform/src/power-manager.ts @@ -2,3 +2,7 @@ export interface IPowerManager { onResume(handler: () => void): () => void; preventSleep(reason: string): () => void; } + +export const POWER_MANAGER_SERVICE = Symbol.for( + "posthog.platform.powerManager", +); diff --git a/packages/platform/src/secure-storage.ts b/packages/platform/src/secure-storage.ts index d056bb368f..c17d7a8a20 100644 --- a/packages/platform/src/secure-storage.ts +++ b/packages/platform/src/secure-storage.ts @@ -3,3 +3,7 @@ export interface ISecureStorage { encryptString(text: string): Promise<Uint8Array>; decryptString(data: Uint8Array): Promise<string>; } + +export const SECURE_STORAGE_SERVICE = Symbol.for( + "posthog.platform.secureStorage", +); diff --git a/packages/platform/src/storage-paths.ts b/packages/platform/src/storage-paths.ts index 7531652ed8..23e6c9340d 100644 --- a/packages/platform/src/storage-paths.ts +++ b/packages/platform/src/storage-paths.ts @@ -2,3 +2,7 @@ export interface IStoragePaths { readonly appDataPath: string; readonly logsPath: string; } + +export const STORAGE_PATHS_SERVICE = Symbol.for( + "posthog.platform.storagePaths", +); diff --git a/packages/platform/src/updater.ts b/packages/platform/src/updater.ts index 07f4fa0aa7..4f375d0c62 100644 --- a/packages/platform/src/updater.ts +++ b/packages/platform/src/updater.ts @@ -9,3 +9,5 @@ export interface IUpdater { onNoUpdate(handler: () => void): () => void; onError(handler: (error: Error) => void): () => void; } + +export const UPDATER_SERVICE = Symbol.for("posthog.platform.updater"); diff --git a/packages/platform/src/url-launcher.ts b/packages/platform/src/url-launcher.ts index 16edc51421..8bb5924d78 100644 --- a/packages/platform/src/url-launcher.ts +++ b/packages/platform/src/url-launcher.ts @@ -1,3 +1,5 @@ export interface IUrlLauncher { launch(url: string): Promise<void>; } + +export const URL_LAUNCHER_SERVICE = Symbol.for("posthog.platform.urlLauncher"); diff --git a/packages/platform/src/workspace-settings.ts b/packages/platform/src/workspace-settings.ts new file mode 100644 index 0000000000..075b59481c --- /dev/null +++ b/packages/platform/src/workspace-settings.ts @@ -0,0 +1,17 @@ +export interface IWorkspaceSettings { + getWorktreeLocation(): string; + getAllWorktreeLocations(): string[]; + setWorktreeLocation(location: string): void; + getMaxActiveWorktrees(): number; + setMaxActiveWorktrees(value: number): void; + getAutoSuspendEnabled(): boolean; + setAutoSuspendEnabled(value: boolean): void; + getAutoSuspendAfterDays(): number; + setAutoSuspendAfterDays(value: number): void; + getPreventSleepWhileRunning(): boolean; + setPreventSleepWhileRunning(value: boolean): void; +} + +export const WORKSPACE_SETTINGS_SERVICE = Symbol.for( + "posthog.platform.workspaceSettings", +); diff --git a/packages/platform/tsup.config.ts b/packages/platform/tsup.config.ts index 20fd8b4461..718e5e444b 100644 --- a/packages/platform/tsup.config.ts +++ b/packages/platform/tsup.config.ts @@ -14,9 +14,14 @@ export default defineConfig({ "src/power-manager.ts", "src/updater.ts", "src/notifier.ts", + "src/notifications.ts", "src/context-menu.ts", "src/bundled-resources.ts", "src/image-processor.ts", + "src/workspace-settings.ts", + "src/crypto.ts", + "src/analytics.ts", + "src/deep-link.ts", ], format: ["esm"], dts: true, diff --git a/packages/shared/package.json b/packages/shared/package.json index c84f4d79ea..c7da61fcd8 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,18 +7,32 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./analytics-events": { + "types": "./dist/analytics-events.d.ts", + "import": "./dist/analytics-events.js" + }, + "./domain-types": { + "types": "./dist/domain-types.d.ts", + "import": "./dist/domain-types.js" + }, + "./mcp-sandbox-proxy": { + "types": "./dist/mcp-sandbox-proxy.d.ts", + "import": "./dist/mcp-sandbox-proxy.js" } }, "scripts": { "build": "tsup", "dev": "tsup --watch", "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "node ../../scripts/rimraf.mjs dist .turbo" }, "devDependencies": { "@agentclientprotocol/sdk": "0.19.0", "tsup": "^8.5.1", - "typescript": "^5.5.0" + "typescript": "^5.5.0", + "vitest": "^4.0.10" }, "files": [ "dist/**/*", diff --git a/apps/code/src/shared/types/analytics.ts b/packages/shared/src/analytics-events.ts similarity index 98% rename from apps/code/src/shared/types/analytics.ts rename to packages/shared/src/analytics-events.ts index 23053a9b8b..7e0e61b92c 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/packages/shared/src/analytics-events.ts @@ -1,9 +1,16 @@ // Analytics event types and properties -import type { - PromptHistoryOpenedProperties, - PromptHistorySelectedProperties, -} from "@features/message-editor/analytics"; +export interface PromptHistoryOpenedProperties { + entry_count: number; +} + +export interface PromptHistorySelectedProperties { + entry_count: number; + entry_age_seconds: number | null; + had_pending_draft: boolean; + had_search_query: boolean; + prompt_length: number; +} type ExecutionType = "cloud" | "local"; export type RepositoryProvider = "github" | "gitlab" | "local" | "none"; diff --git a/apps/code/src/shared/types/archive.ts b/packages/shared/src/archive-domain.ts similarity index 56% rename from apps/code/src/shared/types/archive.ts rename to packages/shared/src/archive-domain.ts index 64abecd8e3..dd97947839 100644 --- a/apps/code/src/shared/types/archive.ts +++ b/packages/shared/src/archive-domain.ts @@ -1,5 +1,9 @@ import { z } from "zod"; +// Archived-task domain shape. The canonical runtime boundary validator lives in +// the workspace-server archive service (`archivedTaskSchema`); this mirror is +// the host-agnostic domain type consumed by packages/ui for optimistic cache +// writes, so the UI never imports workspace-server. export const archivedTaskSchema = z.object({ taskId: z.string(), archivedAt: z.string(), diff --git a/packages/shared/src/async.ts b/packages/shared/src/async.ts new file mode 100644 index 0000000000..2aa6abdaff --- /dev/null +++ b/packages/shared/src/async.ts @@ -0,0 +1,23 @@ +/** + * Races an operation against a timeout. + * Returns success with the value if the operation completes in time, + * or timeout if the operation takes longer than the specified duration. + */ +export async function withTimeout<T>( + operation: Promise<T>, + timeoutMs: number, +): Promise<{ result: "success"; value: T } | { result: "timeout" }> { + let timeoutHandle!: ReturnType<typeof setTimeout>; + const timeoutPromise = new Promise<{ result: "timeout" }>((resolve) => { + timeoutHandle = setTimeout(() => resolve({ result: "timeout" }), timeoutMs); + }); + const operationPromise = operation.then((value) => ({ + result: "success" as const, + value, + })); + try { + return await Promise.race([operationPromise, timeoutPromise]); + } finally { + clearTimeout(timeoutHandle); + } +} diff --git a/packages/shared/src/backoff.test.ts b/packages/shared/src/backoff.test.ts new file mode 100644 index 0000000000..107096fd96 --- /dev/null +++ b/packages/shared/src/backoff.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getBackoffDelay, sleepWithBackoff } from "./backoff"; + +describe("getBackoffDelay", () => { + it("returns the initial delay for the first attempt", () => { + expect(getBackoffDelay(0, { initialDelayMs: 100 })).toBe(100); + }); + + it("doubles by default on each subsequent attempt", () => { + expect(getBackoffDelay(1, { initialDelayMs: 100 })).toBe(200); + expect(getBackoffDelay(2, { initialDelayMs: 100 })).toBe(400); + expect(getBackoffDelay(3, { initialDelayMs: 100 })).toBe(800); + }); + + it("honours a custom multiplier", () => { + expect(getBackoffDelay(2, { initialDelayMs: 100, multiplier: 3 })).toBe( + 900, + ); + }); + + it("caps the delay at maxDelayMs", () => { + expect(getBackoffDelay(10, { initialDelayMs: 100, maxDelayMs: 1000 })).toBe( + 1000, + ); + }); + + it("does not cap when maxDelayMs is unset", () => { + expect(getBackoffDelay(4, { initialDelayMs: 100 })).toBe(1600); + }); +}); + +describe("sleepWithBackoff", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("resolves after the computed backoff delay", async () => { + vi.useFakeTimers(); + const onResolve = vi.fn(); + + const promise = sleepWithBackoff(2, { + initialDelayMs: 100, + maxDelayMs: 1000, + }).then(onResolve); + + await vi.advanceTimersByTimeAsync(399); + expect(onResolve).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + await promise; + expect(onResolve).toHaveBeenCalled(); + }); +}); diff --git a/apps/code/src/shared/utils/backoff.ts b/packages/shared/src/backoff.ts similarity index 100% rename from apps/code/src/shared/utils/backoff.ts rename to packages/shared/src/backoff.ts diff --git a/apps/code/src/shared/types/cloud.ts b/packages/shared/src/cloud.ts similarity index 100% rename from apps/code/src/shared/types/cloud.ts rename to packages/shared/src/cloud.ts diff --git a/apps/code/src/shared/deeplink.test.ts b/packages/shared/src/deep-links.test.ts similarity index 50% rename from apps/code/src/shared/deeplink.test.ts rename to packages/shared/src/deep-links.test.ts index dfd168f84f..ce26233329 100644 --- a/apps/code/src/shared/deeplink.test.ts +++ b/packages/shared/src/deep-links.test.ts @@ -1,5 +1,31 @@ import { describe, expect, it } from "vitest"; -import { buildInboxDeeplink } from "./deeplink"; +import { + buildInboxDeeplink, + decodePlanBase64, + getDeeplinkProtocol, + isPostHogCodeDeeplink, + parseGitHubIssueUrl, +} from "./deep-links"; + +describe("getDeeplinkProtocol", () => { + it("returns the dev or production scheme", () => { + expect(getDeeplinkProtocol(true)).toBe("posthog-code-dev"); + expect(getDeeplinkProtocol(false)).toBe("posthog-code"); + }); +}); + +describe("isPostHogCodeDeeplink", () => { + it("recognizes production and dev schemes", () => { + expect(isPostHogCodeDeeplink("posthog-code://task/1")).toBe(true); + expect(isPostHogCodeDeeplink("posthog-code-dev://task/1")).toBe(true); + }); + + it("rejects other schemes and undefined", () => { + expect(isPostHogCodeDeeplink("https://example.com")).toBe(false); + expect(isPostHogCodeDeeplink(undefined)).toBe(false); + expect(isPostHogCodeDeeplink("not a url")).toBe(false); + }); +}); describe("buildInboxDeeplink", () => { it("returns just the UUID when no title is given", () => { @@ -69,3 +95,49 @@ describe("buildInboxDeeplink", () => { ).toBe("posthog-code://inbox/abc-123/Hello-world"); }); }); + +describe("decodePlanBase64", () => { + it("decodes standard base64", () => { + const encoded = Buffer.from("hello plan", "utf-8").toString("base64"); + expect(decodePlanBase64(encoded)).toBe("hello plan"); + }); + + it("decodes url-safe base64 (- _ and missing padding)", () => { + const text = "ÿ?ƒplan>>"; // contains chars that produce + / in base64 + const standard = Buffer.from(text, "utf-8").toString("base64"); + const urlSafe = standard + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + expect(decodePlanBase64(urlSafe)).toBe(text); + }); + + it("returns null for non-base64 input", () => { + expect(decodePlanBase64("!!!not base64!!!")).toBeNull(); + }); +}); + +describe("parseGitHubIssueUrl", () => { + it("parses a valid issue URL", () => { + expect( + parseGitHubIssueUrl("https://github.com/PostHog/posthog/issues/123"), + ).toEqual({ owner: "PostHog", repo: "posthog", number: 123 }); + }); + + it("rejects non-github hosts", () => { + expect(parseGitHubIssueUrl("https://gitlab.com/a/b/issues/1")).toBeNull(); + }); + + it("rejects non-issue paths", () => { + expect(parseGitHubIssueUrl("https://github.com/a/b/pull/1")).toBeNull(); + }); + + it("rejects a non-positive or non-numeric issue number", () => { + expect(parseGitHubIssueUrl("https://github.com/a/b/issues/0")).toBeNull(); + expect(parseGitHubIssueUrl("https://github.com/a/b/issues/x")).toBeNull(); + }); + + it("returns null for malformed input", () => { + expect(parseGitHubIssueUrl("not a url")).toBeNull(); + }); +}); diff --git a/packages/shared/src/deep-links.ts b/packages/shared/src/deep-links.ts new file mode 100644 index 0000000000..076dc14531 --- /dev/null +++ b/packages/shared/src/deep-links.ts @@ -0,0 +1,96 @@ +export const DEEPLINK_PROTOCOL_PRODUCTION = "posthog-code"; +export const DEEPLINK_PROTOCOL_DEVELOPMENT = "posthog-code-dev"; + +export function getDeeplinkProtocol(isDevBuild: boolean): string { + return isDevBuild + ? DEEPLINK_PROTOCOL_DEVELOPMENT + : DEEPLINK_PROTOCOL_PRODUCTION; +} + +export function isPostHogCodeDeeplink( + href: string | undefined, +): href is string { + if (!href) return false; + try { + const protocol = new URL(href).protocol; + return ( + protocol === `${DEEPLINK_PROTOCOL_PRODUCTION}:` || + protocol === `${DEEPLINK_PROTOCOL_DEVELOPMENT}:` + ); + } catch { + return false; + } +} + +export function buildInboxDeeplink( + reportId: string, + title: string | null | undefined, + { isDevBuild }: { isDevBuild: boolean }, +): string { + const base = `${getDeeplinkProtocol(isDevBuild)}://inbox/${reportId}`; + const slug = title + ? title + .normalize("NFD") + .replace(/\p{M}/gu, "") + .replace(/[^a-zA-Z0-9_.~]+/g, (run) => + run.includes(":") && /[^:]/.test(run) ? "--" : "-", + ) + .replace(/^-+|-+$/g, "") + : ""; + return slug ? `${base}/${slug}` : base; +} + +export interface GitHubIssueRef { + owner: string; + repo: string; + number: number; +} + +export function decodePlanBase64(encoded: string): string | null { + try { + const normalized = encoded + .replace(/-/g, "+") + .replace(/_/g, "/") + .replace(/ /g, "+"); + const padding = (4 - (normalized.length % 4)) % 4; + const padded = normalized + "=".repeat(padding); + if (!/^[A-Za-z0-9+/]*=*$/.test(padded)) return null; + return Buffer.from(padded, "base64").toString("utf-8"); + } catch { + return null; + } +} + +export function parseGitHubIssueUrl(url: string): GitHubIssueRef | null { + try { + const parsed = new URL(url); + if (parsed.hostname !== "github.com") return null; + + const parts = parsed.pathname.split("/").filter(Boolean); + if (parts.length !== 4 || parts[2] !== "issues") return null; + + const issueNumber = Number.parseInt(parts[3], 10); + if (Number.isNaN(issueNumber) || issueNumber <= 0) return null; + + return { owner: parts[0], repo: parts[1], number: issueNumber }; + } catch { + return null; + } +} + +export interface NewTaskSharedParams { + repo?: string; + mode?: string; + model?: string; +} + +export type NewTaskLinkPayload = + | ({ action: "new"; prompt?: string } & NewTaskSharedParams) + | ({ action: "plan"; plan: string } & NewTaskSharedParams) + | ({ + action: "issue"; + url: string; + owner: string; + issueRepo: string; + issueNumber: number; + } & NewTaskSharedParams); diff --git a/apps/code/src/shared/dismissalReasons.ts b/packages/shared/src/dismissal-reasons.ts similarity index 100% rename from apps/code/src/shared/dismissalReasons.ts rename to packages/shared/src/dismissal-reasons.ts diff --git a/apps/code/src/shared/types.ts b/packages/shared/src/domain-types.ts similarity index 92% rename from apps/code/src/shared/types.ts rename to packages/shared/src/domain-types.ts index 53d4f54f34..640d19d3ea 100644 --- a/apps/code/src/shared/types.ts +++ b/packages/shared/src/domain-types.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { DismissalReasonOptionValue } from "./dismissalReasons"; -import type { StoredLogEntry } from "./types/session-events"; +import type { DismissalReasonOptionValue } from "./dismissal-reasons"; +import type { StoredLogEntry } from "./session-events"; // Execution mode schema and type - shared between main and renderer export const executionModeSchema = z.enum([ @@ -12,7 +12,9 @@ export const executionModeSchema = z.enum([ "read-only", "full-access", ]); -export type ExecutionMode = z.infer<typeof executionModeSchema>; + +import type { ExecutionMode } from "./exec-types"; +export type { ExecutionMode }; // Effort level schema and type - shared between main and renderer export const effortLevelSchema = z.enum([ @@ -211,12 +213,8 @@ export interface MentionItem { } // Git file status types -export type GitFileStatus = - | "modified" - | "added" - | "deleted" - | "renamed" - | "untracked"; +import type { GitFileStatus } from "./git-types"; +export type { GitFileStatus }; export type GitBusyOperation = "rebase" | "merge" | "cherry-pick" | "revert"; @@ -250,15 +248,8 @@ export interface DetectedApplication { icon?: string; // Base64 data URL } -export type SignalReportStatus = - | "potential" - | "candidate" - | "in_progress" - | "ready" - | "failed" - | "pending_input" - | "suppressed" - | "deleted"; +import type { SignalReportStatus } from "./signal-types"; +export type { SignalReportStatus }; /** Actionability priority from the researched report (actionability judgment artefact). */ export type SignalReportPriority = "P0" | "P1" | "P2" | "P3" | "P4"; @@ -404,12 +395,8 @@ export interface SuggestedReviewerUser { last_name: string; } -export interface AvailableSuggestedReviewer { - uuid: string; - name: string; - email: string; - github_login: string; -} +import type { AvailableSuggestedReviewer } from "./inbox-types"; +export type { AvailableSuggestedReviewer }; export interface SuggestedReviewer { github_login: string; @@ -479,12 +466,8 @@ export interface SignalReportArtefactsResponse { | "request_failed"; } -export type SignalReportOrderingField = - | "priority" - | "signal_count" - | "total_weight" - | "created_at" - | "updated_at"; +import type { SignalReportOrderingField } from "./signal-types"; +export type { SignalReportOrderingField }; export interface SignalReportsQueryParams { limit?: number; @@ -565,19 +548,7 @@ export interface SlackChannelsQueryParams { channelId?: string; } -export interface NewTaskSharedParams { - repo?: string; - mode?: string; - model?: string; -} - -export type NewTaskLinkPayload = - | ({ action: "new"; prompt?: string } & NewTaskSharedParams) - | ({ action: "plan"; plan: string } & NewTaskSharedParams) - | ({ - action: "issue"; - url: string; - owner: string; - issueRepo: string; - issueNumber: number; - } & NewTaskSharedParams); +export type { + NewTaskLinkPayload, + NewTaskSharedParams, +} from "./deep-links"; diff --git a/packages/shared/src/enrichment.ts b/packages/shared/src/enrichment.ts new file mode 100644 index 0000000000..660a6c197a --- /dev/null +++ b/packages/shared/src/enrichment.ts @@ -0,0 +1,67 @@ +// PostHog enrichment boundary data types. These are the serialized output of the +// (workspace-server) enrichment scan, consumed by the renderer to render flag/event +// annotations. They live in @posthog/shared so both the renderer (ui) and the +// enricher/ws-server can import them without crossing layer boundaries. +// @posthog/enricher re-exports these for its own consumers. + +export type FlagType = "boolean" | "multivariate" | "remote_config"; + +export type StalenessReason = + | "fully_rolled_out" + | "inactive" + | "not_in_posthog" + | "experiment_complete"; + +export interface SerializedFlagOccurrence { + method: string; + line: number; + startCol: number; + endCol: number; +} + +export interface SerializedFlagVariant { + key: string; + rolloutPercentage: number; +} + +export interface SerializedFlagExperiment { + id: number; + name: string; + status: "running" | "complete"; +} + +export interface SerializedFlag { + flagKey: string; + flagId: number | null; + flagType: FlagType; + staleness: StalenessReason | null; + rollout: number | null; + active: boolean; + variants: SerializedFlagVariant[]; + occurrences: SerializedFlagOccurrence[]; + experiment: SerializedFlagExperiment | null; +} + +export interface SerializedEventOccurrence { + line: number; + startCol: number; + endCol: number; + dynamic: boolean; +} + +export interface SerializedEvent { + eventName: string; + definitionId: string | null; + verified: boolean; + description: string | null; + tags: string[]; + lastSeenAt: string | null; + volume: number | null; + uniqueUsers: number | null; + occurrences: SerializedEventOccurrence[]; +} + +export interface SerializedEnrichment { + flags: SerializedFlag[]; + events: SerializedEvent[]; +} diff --git a/packages/shared/src/errors.test.ts b/packages/shared/src/errors.test.ts new file mode 100644 index 0000000000..75acadd96c --- /dev/null +++ b/packages/shared/src/errors.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + getErrorMessage, + isAuthError, + isFatalSessionError, + isNotAuthenticatedError, + isRateLimitError, + NotAuthenticatedError, +} from "./errors"; + +describe("NotAuthenticatedError", () => { + it("has the expected name and a default message", () => { + const err = new NotAuthenticatedError(); + expect(err.name).toBe("NotAuthenticatedError"); + expect(err.message).toBe("Not authenticated"); + }); + + it("accepts a custom message", () => { + expect(new NotAuthenticatedError("token gone").message).toBe("token gone"); + }); +}); + +describe("isNotAuthenticatedError", () => { + it("recognises a real NotAuthenticatedError", () => { + expect(isNotAuthenticatedError(new NotAuthenticatedError())).toBe(true); + }); + + it("recognises a structurally tagged object", () => { + expect(isNotAuthenticatedError({ name: "NotAuthenticatedError" })).toBe( + true, + ); + }); + + it("rejects a plain Error and non-objects", () => { + expect(isNotAuthenticatedError(new Error("nope"))).toBe(false); + expect(isNotAuthenticatedError(null)).toBe(false); + expect(isNotAuthenticatedError("NotAuthenticatedError")).toBe(false); + }); +}); + +describe("getErrorMessage", () => { + it("reads the message from an Error", () => { + expect(getErrorMessage(new Error("boom"))).toBe("boom"); + }); + + it("reads the message from a message-bearing object", () => { + expect(getErrorMessage({ message: 42 })).toBe("42"); + }); + + it("returns an empty string for valueless inputs", () => { + expect(getErrorMessage(null)).toBe(""); + expect(getErrorMessage("just a string")).toBe(""); + }); +}); + +describe("isAuthError", () => { + it.each([ + "Authentication required", + "Failed to authenticate", + "authentication_error", + "authentication_failed", + "Access token has expired", + ])("matches the auth pattern in %j (case-insensitive)", (message) => { + expect(isAuthError(new Error(message))).toBe(true); + }); + + it("returns false for unrelated and empty errors", () => { + expect(isAuthError(new Error("disk full"))).toBe(false); + expect(isAuthError(null)).toBe(false); + }); +}); + +describe("isRateLimitError", () => { + it("matches rate-limit patterns in the message or the details", () => { + expect(isRateLimitError("Rate limit exceeded")).toBe(true); + expect(isRateLimitError("oops", "rate_limit hit")).toBe(true); + expect(isRateLimitError("server said [429]")).toBe(true); + }); + + it("returns false when neither message nor details match", () => { + expect(isRateLimitError("network down", "timeout")).toBe(false); + }); +}); + +describe("isFatalSessionError", () => { + it.each([ + "internal error", + "process exited", + "session did not end", + "not ready for writing", + "session not found", + ])("treats %j as fatal", (message) => { + expect(isFatalSessionError(message)).toBe(true); + }); + + it("does not treat a rate-limit error as fatal even if a fatal phrase is present", () => { + expect(isFatalSessionError("process exited", "rate limit exceeded")).toBe( + false, + ); + }); + + it("returns false for ordinary recoverable errors", () => { + expect(isFatalSessionError("temporary network blip")).toBe(false); + }); +}); diff --git a/apps/code/src/shared/errors.ts b/packages/shared/src/errors.ts similarity index 100% rename from apps/code/src/shared/errors.ts rename to packages/shared/src/errors.ts diff --git a/packages/shared/src/exec-types.ts b/packages/shared/src/exec-types.ts new file mode 100644 index 0000000000..e8eeff1e22 --- /dev/null +++ b/packages/shared/src/exec-types.ts @@ -0,0 +1,8 @@ +export type ExecutionMode = + | "default" + | "acceptEdits" + | "plan" + | "bypassPermissions" + | "auto" + | "read-only" + | "full-access"; diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts new file mode 100644 index 0000000000..7831f90c2e --- /dev/null +++ b/packages/shared/src/flags.ts @@ -0,0 +1,5 @@ +export const BILLING_FLAG = "posthog-code-billing"; +export const INBOX_GATED_DUE_TO_SCALE_FLAG = "inbox-gated-due-to-scale"; +export const EXPERIMENT_SUGGESTIONS_FLAG = + "posthog-code-experiment-suggestions"; +export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; diff --git a/packages/shared/src/git-domain.ts b/packages/shared/src/git-domain.ts new file mode 100644 index 0000000000..80a0ee885a --- /dev/null +++ b/packages/shared/src/git-domain.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; + +// PR review comment domain types. Shared between the git host service (which +// fetches them via the gh API) and the code-review UI (which renders them). +export const prReviewCommentUserSchema = z.object({ + login: z.string(), + avatar_url: z.string(), +}); + +export const prReviewCommentSchema = z.object({ + id: z.number(), + body: z.string(), + path: z.string(), + line: z.number().nullable(), + original_line: z.number().nullable(), + side: z.enum(["LEFT", "RIGHT"]), + start_line: z.number().nullable(), + start_side: z.enum(["LEFT", "RIGHT"]).nullable(), + diff_hunk: z.string(), + in_reply_to_id: z.number().nullish(), + user: prReviewCommentUserSchema, + created_at: z.string(), + updated_at: z.string(), + subject_type: z.enum(["line", "file"]).nullable(), +}); + +export type PrReviewComment = z.infer<typeof prReviewCommentSchema>; + +export const prReviewThreadSchema = z.object({ + nodeId: z.string(), + isResolved: z.boolean(), + rootId: z.number(), + filePath: z.string(), + comments: z.array(prReviewCommentSchema), +}); +export type PrReviewThread = z.infer<typeof prReviewThreadSchema>; + +// GitHub ref (issue/PR) domain types. Shared between the git host service +// (gh search/lookup) and the message-editor issue chips + sidebar github refs. +export const githubRefKindSchema = z.enum(["issue", "pr"]); +export type GithubRefKind = z.infer<typeof githubRefKindSchema>; + +export const githubRefStateSchema = z.enum(["OPEN", "CLOSED", "MERGED"]); +export type GithubRefState = z.infer<typeof githubRefStateSchema>; + +export const githubRefSchema = z.object({ + kind: githubRefKindSchema, + number: z.number(), + title: z.string(), + state: githubRefStateSchema, + labels: z.array(z.string()), + url: z.string(), + repo: z.string(), + isDraft: z.boolean().optional(), +}); + +export type GithubRef = z.infer<typeof githubRefSchema>; + +// Legacy aliases kept so callers that previously consumed only issues continue to work. +export const githubIssueStateSchema = githubRefStateSchema; +export type GithubIssueState = GithubRefState; +export const githubIssueSchema = githubRefSchema; +export type GitHubIssue = GithubRef; +export type GithubPullRequest = GithubRef; + +// PR action intent. Shared between the git host service (updatePrByUrl) and the +// git-interaction UI (PR status menu actions). +export const prActionTypeSchema = z.enum(["close", "reopen", "ready", "draft"]); +export type PrActionType = z.infer<typeof prActionTypeSchema>; diff --git a/packages/shared/src/git-handoff.ts b/packages/shared/src/git-handoff.ts new file mode 100644 index 0000000000..12fa82fc1a --- /dev/null +++ b/packages/shared/src/git-handoff.ts @@ -0,0 +1,22 @@ +export interface HandoffLocalGitState { + head: string | null; + branch: string | null; + upstreamHead: string | null; + upstreamRemote: string | null; + upstreamMergeRef: string | null; +} + +export interface GitHandoffCheckpoint { + checkpointId: string; + commit: string; + checkpointRef: string; + headRef?: string; + head: string | null; + branch: string | null; + indexTree: string; + worktreeTree: string; + timestamp: string; + upstreamRemote: string | null; + upstreamMergeRef: string | null; + remoteUrl: string | null; +} diff --git a/packages/shared/src/git-naming.ts b/packages/shared/src/git-naming.ts new file mode 100644 index 0000000000..480f9d398b --- /dev/null +++ b/packages/shared/src/git-naming.ts @@ -0,0 +1 @@ +export const BRANCH_PREFIX = "posthog-code/"; diff --git a/packages/shared/src/git-types.ts b/packages/shared/src/git-types.ts new file mode 100644 index 0000000000..33a6298e38 --- /dev/null +++ b/packages/shared/src/git-types.ts @@ -0,0 +1,6 @@ +export type GitFileStatus = + | "modified" + | "added" + | "deleted" + | "renamed" + | "untracked"; diff --git a/packages/shared/src/handoff-host.ts b/packages/shared/src/handoff-host.ts new file mode 100644 index 0000000000..0716a0b904 --- /dev/null +++ b/packages/shared/src/handoff-host.ts @@ -0,0 +1,101 @@ +import type { GitHandoffCheckpoint, HandoffLocalGitState } from "./git-handoff"; +import type { WorkspaceMode } from "./workspace"; + +export interface HandoffApiContext { + apiHost: string; + teamId: number; +} + +export interface HandoffChangedFile { + path: string; + status: "modified" | "added" | "deleted" | "renamed" | "untracked"; + linesAdded?: number; + linesRemoved?: number; +} + +export interface HandoffReconnectParams { + taskId: string; + taskRunId: string; + repoPath: string; + apiHost: string; + projectId: number; + logUrl: string; + sessionId?: string; + adapter?: "claude" | "codex"; +} + +export interface HandoffResumeStateResult { + resumeState: { + conversation: unknown[]; + latestGitCheckpoint: GitHandoffCheckpoint | null; + }; + cloudLogUrl: string | null; +} + +/** + * Host capabilities the core handoff orchestration depends on. The + * implementation lives in workspace-server (agent runtime, workspace/repository + * repos, git, local log cache, divergence dialog); core only orchestrates over + * this port. Declared in shared so core and workspace-server can both reference + * it without importing each other. + */ +export interface HandoffHost { + getChangedFiles(repoPath: string): Promise<readonly HandoffChangedFile[]>; + getLocalGitState(repoPath: string): Promise<HandoffLocalGitState>; + + markRunEnvironmentLocal( + ctx: HandoffApiContext, + taskId: string, + runId: string, + ): Promise<void>; + fetchResumeState( + ctx: HandoffApiContext, + taskId: string, + runId: string, + ): Promise<HandoffResumeStateResult>; + formatConversation(conversation: unknown[]): string; + applyGitCheckpoint( + ctx: HandoffApiContext, + checkpoint: GitHandoffCheckpoint, + repoPath: string, + taskId: string, + runId: string, + localGitState?: HandoffLocalGitState, + ): Promise<void>; + reconnectSession( + params: HandoffReconnectParams, + ): Promise<{ sessionId: string } | null>; + attachWorkspaceToFolder( + taskId: string, + repoPath: string, + ): { revert: () => void }; + seedLocalLogs(runId: string, logUrl: string): Promise<void>; + setPendingContext(taskRunId: string, context: string): void; + killSession(taskRunId: string): Promise<void>; + updateWorkspaceMode(taskId: string, mode: WorkspaceMode): void; + + captureGitCheckpoint( + ctx: HandoffApiContext, + repoPath: string, + taskId: string, + runId: string, + localGitState?: HandoffLocalGitState, + ): Promise<GitHandoffCheckpoint | null>; + persistCheckpointToLog( + ctx: HandoffApiContext, + taskId: string, + runId: string, + checkpoint: GitHandoffCheckpoint, + ): Promise<void>; + countLocalLogEntries(runId: string): Promise<number>; + resumeRunInCloud( + ctx: HandoffApiContext, + taskId: string, + runId: string, + ): Promise<void>; + cleanupLocalAfterCloudHandoff( + repoPath: string, + branchName: string | null, + ): Promise<void>; + deleteLocalLogCache(runId: string): Promise<void>; +} diff --git a/packages/shared/src/inbox-types.ts b/packages/shared/src/inbox-types.ts new file mode 100644 index 0000000000..6e89622bef --- /dev/null +++ b/packages/shared/src/inbox-types.ts @@ -0,0 +1,6 @@ +export interface AvailableSuggestedReviewer { + uuid: string; + name: string; + email: string; + github_login: string; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 752019b25c..84166bfe21 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,11 @@ +export * from "./analytics-events"; +export { type ArchivedTask, archivedTaskSchema } from "./archive-domain"; +export { withTimeout } from "./async"; +export { + type BackoffOptions, + getBackoffDelay, + sleepWithBackoff, +} from "./backoff"; export { ARCHIVE_EXTENSIONS, AUDIO_VIDEO_EXTENSIONS, @@ -7,12 +15,56 @@ export { FONT_EXTENSIONS, isBinaryFile, } from "./binary"; +export type { CloudRunSource, PrAuthorshipMode } from "./cloud"; export { CLOUD_PROMPT_PREFIX, deserializeCloudPrompt, promptBlocksToText, serializeCloudPrompt, } from "./cloud-prompt"; +export { + buildInboxDeeplink, + DEEPLINK_PROTOCOL_DEVELOPMENT, + DEEPLINK_PROTOCOL_PRODUCTION, + decodePlanBase64, + type GitHubIssueRef, + getDeeplinkProtocol, + isPostHogCodeDeeplink, + type NewTaskLinkPayload, + type NewTaskSharedParams, + parseGitHubIssueUrl, +} from "./deep-links"; +export { + DISMISSAL_REASON_OPTIONS, + type DismissalReasonOptionValue, + isDismissalReasonSnooze, +} from "./dismissal-reasons"; +export type { Task } from "./domain-types"; +export * from "./enrichment"; +export { + getErrorMessage, + isAuthError, + isFatalSessionError, + isNotAuthenticatedError, + isRateLimitError, + NotAuthenticatedError, +} from "./errors"; +export type { ExecutionMode } from "./exec-types"; +export * from "./flags"; +export * from "./git-domain"; +export type { + GitHandoffCheckpoint, + HandoffLocalGitState, +} from "./git-handoff"; +export * from "./git-naming"; +export type { GitFileStatus } from "./git-types"; +export type { + HandoffApiContext, + HandoffChangedFile, + HandoffHost, + HandoffReconnectParams, + HandoffResumeStateResult, +} from "./handoff-host"; export { ALLOWED_IMAGE_MIME_TYPES, buildImageDataUrl, @@ -31,9 +83,102 @@ export { parseImageDataUrl, } from "./image"; export { buildDiscussReportPrompt } from "./inbox-prompts"; +export type { AvailableSuggestedReviewer } from "./inbox-types"; +export { EXTERNAL_LINKS } from "./links"; +export { + getOauthClientIdFromRegion, + OAUTH_SCOPE_VERSION, + OAUTH_SCOPES, + POSTHOG_DEV_CLIENT_ID, + POSTHOG_EU_CLIENT_ID, + POSTHOG_US_CLIENT_ID, + TOKEN_REFRESH_BUFFER_MS, + TOKEN_REFRESH_FORCE_MS, +} from "./oauth"; +export { + compactHomePath, + expandTildePath, + getFileExtension, + getFileName, + isAbsolutePath, + pathToFileUri, + toRelativePath, +} from "./path"; +export { + type CloudRegion, + formatRegionBadge, + REGION_LABELS, + type RegionLabel, +} from "./regions"; +export { normalizeRepoKey } from "./repo"; +export { getTaskRepository, parseRepository } from "./repository"; export { Saga, type SagaLogger, type SagaResult, type SagaStep, } from "./saga"; +export { + isProPlan, + PLAN_FREE, + PLAN_PRO, + PLAN_PRO_ALPHA, + SEAT_PRODUCT_KEY, + type SeatData, + type SeatStatus, + seatHasAccess, +} from "./seat"; +export { + type AcpMessage, + isJsonRpcNotification, + isJsonRpcRequest, + isJsonRpcResponse, + type JsonRpcMessage, + type JsonRpcNotification, + type JsonRpcRequest, + type JsonRpcResponse, + type StoredLogEntry, + type UserShellExecuteParams, + type UserShellExecuteResult, +} from "./session-events"; +export { + type Adapter, + type AgentSession, + cycleModeOption, + flattenSelectOptions, + getConfigOptionByCategory, + getCurrentModeFromConfigOptions, + isSelectGroup, + mergeConfigOptions, + type OptimisticItem, + type PermissionRequest, + type QueuedMessage, + type SessionStatus, +} from "./sessions"; +export type { + SignalReportOrderingField, + SignalReportStatus, +} from "./signal-types"; +export type { SkillInfo, SkillSource } from "./skills"; +export type { + ArtifactType, + PostHogAPIConfig, + TaskRun, + TaskRunArtifact, + TaskRunEnvironment, + TaskRunStatus, +} from "./task"; +export type { + TaskCreationInput, + TaskCreationOutput, +} from "./task-creation-domain"; +export { + formatRelativeTimeLong, + formatRelativeTimeShort, + getRelativeDateGroup, +} from "./time"; +export { TypedEventEmitter } from "./typed-event-emitter"; +export { getCloudUrlFromRegion } from "./urls"; +export type { WorkspaceMode } from "./workspace"; +export * from "./workspace-domain"; +export { escapeXmlAttr, unescapeXmlAttr } from "./xml"; diff --git a/apps/code/src/renderer/utils/links.ts b/packages/shared/src/links.ts similarity index 100% rename from apps/code/src/renderer/utils/links.ts rename to packages/shared/src/links.ts diff --git a/apps/code/src/renderer/features/mcp-apps/utils/mcp-app-sandbox-proxy.test.ts b/packages/shared/src/mcp-sandbox-proxy.test.ts similarity index 97% rename from apps/code/src/renderer/features/mcp-apps/utils/mcp-app-sandbox-proxy.test.ts rename to packages/shared/src/mcp-sandbox-proxy.test.ts index 4695f181c2..c2eaa7cd86 100644 --- a/apps/code/src/renderer/features/mcp-apps/utils/mcp-app-sandbox-proxy.test.ts +++ b/packages/shared/src/mcp-sandbox-proxy.test.ts @@ -1,5 +1,5 @@ -import { sandboxProxyHtml } from "@shared/mcp-sandbox-proxy"; import { describe, expect, it } from "vitest"; +import { sandboxProxyHtml } from "./mcp-sandbox-proxy"; // The checks here aren't 100% validating what the code is doing, HOWEVER, // it does validate we're at least considering the different situations diff --git a/packages/shared/src/mcp-sandbox-proxy.ts b/packages/shared/src/mcp-sandbox-proxy.ts new file mode 100644 index 0000000000..b73c22b323 --- /dev/null +++ b/packages/shared/src/mcp-sandbox-proxy.ts @@ -0,0 +1,187 @@ +/** + * Sandbox proxy HTML for MCP Apps. + * + * This is the intermediate layer in the double-iframe architecture: + * + * Host (renderer) → Outer iframe (sandbox proxy) → Inner iframe (MCP App) + * + * The outer iframe is served from the host's custom protocol, giving it an + * isolated origin separate from the renderer. The inner iframe uses + * allow-same-origin so the proxy can write HTML via document.write() — srcdoc + * creates an opaque origin that breaks WebGL canvas operations (toDataURL) and + * cross-origin resource access. + * + * Because the proxy's origin differs from the renderer's origin, the app cannot + * traverse `window.parent.parent` to access the host's DOM, storage, or cookies. + * + * The HTML string itself is portable browser JavaScript with no host APIs; the + * protocol that serves it is the host-specific seam. + * + * @see https://modelcontextprotocol.io/specification/2025-03-26/extensions/mcp-apps + */ + +export const sandboxProxyHtml: string = `<!DOCTYPE html> +<html> + +<head> + <meta charset="utf-8"> + <style> + html, + body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; + } + + iframe { + border: none; + width: 100%; + height: 100%; + } + </style> +</head> + +<body> + <script> + function log(...args) { + console.log("[mcp-sandbox-proxy]", ...args); + } + + function init() { + "use strict"; + + log("Proxy starting", { origin: location.origin, href: location.href }); + + var inner = document.createElement("iframe"); + inner.style.cssText = "width:100%; height:100%; border:none;"; + inner.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); + document.body.appendChild(inner); + + // Build Permission Policy allow attribute from permissions object. + // Maps McpUiResourcePermissions keys to Permission Policy feature names. + // Uses "*" suffix for cross-origin permission delegation. + function buildAllowAttribute(permissions) { + if (!permissions || typeof permissions !== "object") return ""; + + var mapping = { + camera: "camera", + microphone: "microphone", + geolocation: "geolocation", + clipboardWrite: "clipboard-write" + }; + + var features = Object.keys(permissions) + .filter(function (key) { return mapping[key] && permissions[key] != null; }) + .map(function (key) { return mapping[key] + " *"; }); + + return features.join("; "); + } + + // Handle messages from the host (parent) + function handleParentMessage(data) { + if (data && data.method === "ui/notifications/sandbox-resource-ready") { + var params = data.params; + sent = true; // Stop retrying sandbox-proxy-ready + log("Received sandbox-resource-ready", { + hasHtml: !!(params && params.html), + htmlLength: params && params.html ? params.html.length : 0, + hasPermissions: !!(params && params.permissions), + hasCsp: !!(params && params.csp) + }); + + if (params && typeof params.html === "string") { + // Set allow attribute for Permission Policy features + var allowValue = buildAllowAttribute(params.permissions); + if (allowValue) { + inner.setAttribute("allow", allowValue); + } + + // Use document.write() instead of srcdoc to preserve origin. + // srcdoc creates an opaque origin that breaks WebGL canvas operations + // like toDataURL() and cross-origin resource access. + var doc = inner.contentDocument; + log("Writing HTML to inner iframe", { + htmlLength: params.html.length, + hasContentDocument: !!doc + }); + + doc.open(); + doc.write(params.html); + doc.close(); + + log("HTML written to inner iframe"); + } + } else { + // Forward all other messages to inner iframe + log("Forwarding host -> inner", { + method: data.method, + id: data.id, + hasInner: !!(inner && inner.contentWindow), + targetOrigin: location.origin + }); + + if (inner && inner.contentWindow) { + inner.contentWindow.postMessage(data, location.origin); + } + } + } + + // Listen for messages from host (parent) and inner iframe + window.addEventListener("message", function (event) { + var data = event.data; + if (!data || typeof data !== "object") return; + + if (event.source === window.parent) { + log("Message from host", { + method: data.method, + id: data.id, + origin: event.origin + }); + handleParentMessage(data); + } else if (event.source === inner.contentWindow) { + log("Relaying inner -> host", { + method: data.method, + id: data.id, + origin: event.origin + }); + // Relay messages from inner iframe back to host + window.parent.postMessage(data, "*"); + } else { + log("Message from unknown source", { + method: data.method, + origin: event.origin, + isParent: event.source === window.parent, + isInner: event.source === inner.contentWindow + }); + } + }); + + // Notify host that proxy is ready to receive HTML. + // Retry a few times because the host's useEffect listener may not be + // registered yet when src= loads faster than React's effect cycle. + var readyMsg = { + jsonrpc: "2.0", + method: "ui/notifications/sandbox-proxy-ready", + params: {} + }; + var sent = false; + function sendReady() { + if (sent) return; + log("Sending sandbox-proxy-ready to host"); + window.parent.postMessage(readyMsg, "*"); + } + + // The host removes its listener on first receipt, so retries are harmless + sendReady(); + setTimeout(sendReady, 50); + setTimeout(sendReady, 150); + }; + + // Fire and forget, similar to IIFE + init(); + </script> +</body> + +</html>`; diff --git a/apps/code/src/shared/constants/oauth.test.ts b/packages/shared/src/oauth.test.ts similarity index 100% rename from apps/code/src/shared/constants/oauth.test.ts rename to packages/shared/src/oauth.test.ts diff --git a/apps/code/src/shared/constants/oauth.ts b/packages/shared/src/oauth.ts similarity index 93% rename from apps/code/src/shared/constants/oauth.ts rename to packages/shared/src/oauth.ts index f59ce0cca2..447a002cf8 100644 --- a/apps/code/src/shared/constants/oauth.ts +++ b/packages/shared/src/oauth.ts @@ -1,4 +1,4 @@ -import type { CloudRegion } from "@shared/types/regions"; +import type { CloudRegion } from "./regions"; export const POSTHOG_US_CLIENT_ID = "HCWoE0aRFMYxIxFNTTwkOORn5LBjOt2GVDzwSw5W"; export const POSTHOG_EU_CLIENT_ID = "AIvijgMS0dxKEmr5z6odvRd8Pkh5vts3nPTzgzU9"; diff --git a/apps/code/src/renderer/utils/path.test.ts b/packages/shared/src/path.test.ts similarity index 100% rename from apps/code/src/renderer/utils/path.test.ts rename to packages/shared/src/path.test.ts diff --git a/apps/code/src/renderer/utils/path.ts b/packages/shared/src/path.ts similarity index 100% rename from apps/code/src/renderer/utils/path.ts rename to packages/shared/src/path.ts diff --git a/packages/shared/src/regions.test.ts b/packages/shared/src/regions.test.ts new file mode 100644 index 0000000000..f96e1b55c4 --- /dev/null +++ b/packages/shared/src/regions.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + getOauthClientIdFromRegion, + POSTHOG_DEV_CLIENT_ID, + POSTHOG_EU_CLIENT_ID, + POSTHOG_US_CLIENT_ID, +} from "./oauth"; +import { formatRegionBadge, REGION_LABELS } from "./regions"; +import { getCloudUrlFromRegion } from "./urls"; + +describe("getCloudUrlFromRegion", () => { + it("maps each region to its cloud URL", () => { + expect(getCloudUrlFromRegion("us")).toBe("https://us.posthog.com"); + expect(getCloudUrlFromRegion("eu")).toBe("https://eu.posthog.com"); + expect(getCloudUrlFromRegion("dev")).toBe("http://localhost:8010"); + }); +}); + +describe("getOauthClientIdFromRegion", () => { + it("maps each region to its distinct OAuth client id", () => { + expect(getOauthClientIdFromRegion("us")).toBe(POSTHOG_US_CLIENT_ID); + expect(getOauthClientIdFromRegion("eu")).toBe(POSTHOG_EU_CLIENT_ID); + expect(getOauthClientIdFromRegion("dev")).toBe(POSTHOG_DEV_CLIENT_ID); + }); + + it("uses a different client id per region", () => { + const ids = new Set([ + getOauthClientIdFromRegion("us"), + getOauthClientIdFromRegion("eu"), + getOauthClientIdFromRegion("dev"), + ]); + expect(ids.size).toBe(3); + }); +}); + +describe("formatRegionBadge", () => { + it("combines the flag and label for a region", () => { + expect(formatRegionBadge("us")).toBe( + `${REGION_LABELS.us.flag} ${REGION_LABELS.us.label}`, + ); + }); + + it("formats every known region without throwing", () => { + for (const region of ["us", "eu", "dev"] as const) { + expect(formatRegionBadge(region)).toContain(REGION_LABELS[region].label); + } + }); +}); diff --git a/apps/code/src/shared/types/regions.ts b/packages/shared/src/regions.ts similarity index 100% rename from apps/code/src/shared/types/regions.ts rename to packages/shared/src/regions.ts diff --git a/apps/code/src/shared/utils/repo.ts b/packages/shared/src/repo.ts similarity index 100% rename from apps/code/src/shared/utils/repo.ts rename to packages/shared/src/repo.ts diff --git a/apps/code/src/renderer/utils/repository.ts b/packages/shared/src/repository.ts similarity index 100% rename from apps/code/src/renderer/utils/repository.ts rename to packages/shared/src/repository.ts diff --git a/apps/code/src/shared/types/seat.ts b/packages/shared/src/seat.ts similarity index 100% rename from apps/code/src/shared/types/seat.ts rename to packages/shared/src/seat.ts diff --git a/apps/code/src/shared/types/session-events.ts b/packages/shared/src/session-events.ts similarity index 100% rename from apps/code/src/shared/types/session-events.ts rename to packages/shared/src/session-events.ts diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts new file mode 100644 index 0000000000..536b71fe3b --- /dev/null +++ b/packages/shared/src/sessions.ts @@ -0,0 +1,162 @@ +import type { + ContentBlock, + RequestPermissionRequest, + SessionConfigOption, + SessionConfigSelectGroup, + SessionConfigSelectOption, + SessionConfigSelectOptions, +} from "@agentclientprotocol/sdk"; +import type { SkillButtonId } from "./analytics-events"; +import type { ExecutionMode } from "./exec-types"; +import type { AcpMessage } from "./session-events"; +import type { TaskRunStatus } from "./task"; + +export type Adapter = "claude" | "codex"; + +export type PermissionRequest = Omit<RequestPermissionRequest, "sessionId"> & { + taskRunId: string; + receivedAt: number; +}; + +export interface QueuedMessage { + id: string; + content: string; + rawPrompt?: string | ContentBlock[]; + queuedAt: number; +} + +export type OptimisticItem = + | { + type: "user_message"; + id: string; + content: string; + timestamp: number; + pinToTop?: boolean; + } + | { + type: "skill_button_action"; + id: string; + buttonId: SkillButtonId; + }; + +export type SessionStatus = + | "connecting" + | "connected" + | "disconnected" + | "error"; + +export interface AgentSession { + taskRunId: string; + taskId: string; + taskTitle: string; + channel: string; + events: AcpMessage[]; + startedAt: number; + status: SessionStatus; + errorTitle?: string; + errorMessage?: string; + isPromptPending: boolean; + isCompacting: boolean; + promptStartedAt: number | null; + currentPromptId?: number | null; + logUrl?: string; + processedLineCount?: number; + framework?: "claude"; + adapter?: Adapter; + configOptions?: SessionConfigOption[]; + pendingPermissions: Map<string, PermissionRequest>; + pausedDurationMs: number; + messageQueue: QueuedMessage[]; + isCloud?: boolean; + cloudStatus?: TaskRunStatus; + cloudStage?: string | null; + cloudOutput?: Record<string, unknown> | null; + cloudErrorMessage?: string | null; + initialPrompt?: ContentBlock[]; + cloudBranch?: string | null; + handoffInProgress?: boolean; + skipPolledPromptCount?: number; + optimisticItems: OptimisticItem[]; + contextUsed?: number; + contextSize?: number; + conversationSummary?: string; + idleKilled?: boolean; + agentVersion?: string; + agentIdleForRunId?: string; +} + +export function isSelectGroup( + options: SessionConfigSelectOptions, +): options is SessionConfigSelectGroup[] { + return ( + options.length > 0 && + typeof options[0] === "object" && + "options" in options[0] + ); +} + +export function flattenSelectOptions( + options: SessionConfigSelectOptions, +): SessionConfigSelectOption[] { + if (!options.length) return []; + if (isSelectGroup(options)) { + return options.flatMap((group) => group.options); + } + return options as SessionConfigSelectOption[]; +} + +export function mergeConfigOptions( + live: SessionConfigOption[], + persisted: SessionConfigOption[], +): SessionConfigOption[] { + const persistedMap = new Map(persisted.map((opt) => [opt.id, opt])); + + return live.map((liveOpt) => { + const persistedOpt = persistedMap.get(liveOpt.id); + if (persistedOpt) { + return { + ...liveOpt, + currentValue: persistedOpt.currentValue, + } as SessionConfigOption; + } + return liveOpt; + }); +} + +export function getConfigOptionByCategory( + configOptions: SessionConfigOption[] | undefined, + category: string, +): SessionConfigOption | undefined { + return configOptions?.find((opt) => opt.category === category); +} + +export function cycleModeOption( + modeOption: SessionConfigOption | undefined, + options?: { allowBypassPermissions?: boolean }, +): string | undefined { + if (!modeOption || modeOption.type !== "select") return undefined; + + const allOptions = flattenSelectOptions(modeOption.options); + const filtered = options?.allowBypassPermissions + ? allOptions + : allOptions.filter( + (opt) => + opt.value !== "bypassPermissions" && opt.value !== "full-access", + ); + if (filtered.length === 0) return undefined; + + const currentIndex = filtered.findIndex( + (opt) => opt.value === modeOption.currentValue, + ); + if (currentIndex === -1) return filtered[0]?.value; + + const nextIndex = (currentIndex + 1) % filtered.length; + return filtered[nextIndex]?.value; +} + +export function getCurrentModeFromConfigOptions( + configOptions: SessionConfigOption[] | undefined, +): ExecutionMode | undefined { + const modeOption = getConfigOptionByCategory(configOptions, "mode"); + return modeOption?.currentValue as ExecutionMode | undefined; +} diff --git a/packages/shared/src/signal-types.ts b/packages/shared/src/signal-types.ts new file mode 100644 index 0000000000..b7cb8e38d6 --- /dev/null +++ b/packages/shared/src/signal-types.ts @@ -0,0 +1,16 @@ +export type SignalReportStatus = + | "potential" + | "candidate" + | "in_progress" + | "ready" + | "failed" + | "pending_input" + | "suppressed" + | "deleted"; + +export type SignalReportOrderingField = + | "priority" + | "signal_count" + | "total_weight" + | "created_at" + | "updated_at"; diff --git a/apps/code/src/shared/types/skills.ts b/packages/shared/src/skills.ts similarity index 100% rename from apps/code/src/shared/types/skills.ts rename to packages/shared/src/skills.ts diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts new file mode 100644 index 0000000000..e8dd579e73 --- /dev/null +++ b/packages/shared/src/task-creation-domain.ts @@ -0,0 +1,39 @@ +import type { CloudRunSource, PrAuthorshipMode } from "./cloud"; +import type { Task } from "./domain-types"; +import type { ExecutionMode } from "./exec-types"; +import type { WorkspaceMode } from "./workspace"; +import type { Workspace } from "./workspace-domain"; + +// Host-agnostic input/output for the task-creation flow. The renderer +// TaskCreationSaga owns the orchestration; these are the plain data shapes its +// consumers (inbox direct-create hooks, deep-link open, task-input) pass and +// receive. Lives in shared so packages/ui can consume them without importing +// the renderer saga. +export interface TaskCreationInput { + // For opening existing task + taskId?: string; + // For creating new task (required if no taskId) + content?: string; + taskDescription?: string; + filePaths?: string[]; + repoPath?: string; + repository?: string | null; + workspaceMode?: WorkspaceMode; + branch?: string | null; + githubIntegrationId?: number; + githubUserIntegrationId?: string; + executionMode?: ExecutionMode; + adapter?: "claude" | "codex"; + model?: string; + reasoningLevel?: string; + environmentId?: string; + sandboxEnvironmentId?: string; + cloudPrAuthorshipMode?: PrAuthorshipMode; + cloudRunSource?: CloudRunSource; + signalReportId?: string; +} + +export interface TaskCreationOutput { + task: Task; + workspace: Workspace | null; +} diff --git a/packages/shared/src/task.ts b/packages/shared/src/task.ts new file mode 100644 index 0000000000..89091a4036 --- /dev/null +++ b/packages/shared/src/task.ts @@ -0,0 +1,87 @@ +// PostHog Task model (matches PostHog Code's OpenAPI schema) +export interface Task { + id: string; + task_number?: number; + slug?: string; + title: string; + description: string; + origin_product: + | "error_tracking" + | "eval_clusters" + | "user_created" + | "support_queue" + | "session_summaries" + | "signal_report" + | "slack"; + signal_report?: string | null; // Inbox report UUID when origin_product is "signal_report" + github_integration?: number | null; + repository: string; // Format: "organization/repository" (e.g., "posthog/posthog-js") + json_schema?: Record<string, unknown> | null; // JSON schema for task output validation + internal?: boolean; + created_at: string; + updated_at: string; + created_by?: { + id: number; + uuid: string; + distinct_id: string; + first_name: string; + email: string; + }; + latest_run?: TaskRun; +} + +export type ArtifactType = + | "plan" + | "context" + | "reference" + | "output" + | "artifact" + | "user_attachment"; + +export interface TaskRunArtifact { + id?: string; + name: string; + type: ArtifactType; + source?: string; + size?: number; + content_type?: string; + storage_path?: string; + uploaded_at?: string; +} + +export type TaskRunStatus = + | "not_started" + | "queued" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + +export type TaskRunEnvironment = "local" | "cloud"; + +// TaskRun model - represents individual execution runs of tasks +export interface TaskRun { + id: string; + task: string; // Task ID + team: number; + branch: string | null; + stage: string | null; // Current stage (e.g., 'research', 'plan', 'build') + environment: TaskRunEnvironment; + status: TaskRunStatus; + log_url: string; + error_message: string | null; + output: Record<string, unknown> | null; // Structured output (PR URL, commit SHA, etc.) + state: Record<string, unknown>; // Intermediate run state (defaults to {}, never null) + artifacts?: TaskRunArtifact[]; + created_at: string; + updated_at: string; + completed_at: string | null; +} + +export interface PostHogAPIConfig { + apiUrl: string; + getApiKey: () => string | Promise<string>; + refreshApiKey?: () => string | Promise<string>; + projectId: number; + userAgent?: string; +} diff --git a/packages/shared/src/time.test.ts b/packages/shared/src/time.test.ts new file mode 100644 index 0000000000..4772f871c4 --- /dev/null +++ b/packages/shared/src/time.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + formatRelativeTimeLong, + formatRelativeTimeShort, + getRelativeDateGroup, +} from "./time"; + +const NOW = new Date("2026-06-15T12:00:00.000Z").getTime(); +const MINUTE = 60_000; +const HOUR = 3_600_000; +const DAY = 86_400_000; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("formatRelativeTimeShort", () => { + it("returns 'now' for sub-minute differences", () => { + expect(formatRelativeTimeShort(NOW - 30_000)).toBe("now"); + }); + + it.each([ + [5 * MINUTE, "5m"], + [2 * HOUR, "2h"], + [3 * DAY, "3d"], + [8 * DAY, "1w"], + [35 * DAY, "1mo"], + [400 * DAY, "1y"], + ])("formats a difference of %dms as %s", (ago, expected) => { + expect(formatRelativeTimeShort(NOW - ago)).toBe(expected); + }); + + it("accepts an ISO string timestamp", () => { + expect( + formatRelativeTimeShort(new Date(NOW - 5 * MINUTE).toISOString()), + ).toBe("5m"); + }); +}); + +describe("formatRelativeTimeLong", () => { + it("returns 'just now' under a minute", () => { + expect(formatRelativeTimeLong(NOW - 30_000)).toBe("just now"); + }); + + it("uses singular and plural minute phrasing", () => { + expect(formatRelativeTimeLong(NOW - MINUTE)).toBe("1 minute ago"); + expect(formatRelativeTimeLong(NOW - 5 * MINUTE)).toBe("5 minutes ago"); + }); + + it("uses singular and plural hour phrasing", () => { + expect(formatRelativeTimeLong(NOW - HOUR)).toBe("1 hour ago"); + expect(formatRelativeTimeLong(NOW - 3 * HOUR)).toBe("3 hours ago"); + }); + + it("uses singular and plural day phrasing within a week", () => { + expect(formatRelativeTimeLong(NOW - DAY)).toBe("1 day ago"); + expect(formatRelativeTimeLong(NOW - 3 * DAY)).toBe("3 days ago"); + }); + + it("falls back to a locale date older than a week", () => { + expect(formatRelativeTimeLong(NOW - 400 * DAY)).toContain("2025"); + }); +}); + +describe("getRelativeDateGroup", () => { + it("returns null for today", () => { + expect(getRelativeDateGroup(NOW - 2 * HOUR)).toBeNull(); + }); + + it("groups one calendar day back as Yesterday", () => { + expect(getRelativeDateGroup(NOW - DAY)).toBe("Yesterday"); + }); + + it("groups a few days back as This week", () => { + expect(getRelativeDateGroup(NOW - 3 * DAY)).toBe("This week"); + }); + + it("groups within the month as This month", () => { + expect(getRelativeDateGroup(NOW - 10 * DAY)).toBe("This month"); + }); + + it("groups older dates as Earlier", () => { + expect(getRelativeDateGroup(NOW - 40 * DAY)).toBe("Earlier"); + }); +}); diff --git a/apps/code/src/renderer/utils/time.ts b/packages/shared/src/time.ts similarity index 100% rename from apps/code/src/renderer/utils/time.ts rename to packages/shared/src/time.ts diff --git a/packages/shared/src/typed-event-emitter.test.ts b/packages/shared/src/typed-event-emitter.test.ts new file mode 100644 index 0000000000..7160e94a6c --- /dev/null +++ b/packages/shared/src/typed-event-emitter.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from "vitest"; +import { TypedEventEmitter } from "./typed-event-emitter"; + +interface Events { + data: { value: number }; + done: undefined; +} + +function collect<T>(iterable: AsyncIterable<T>, count: number): Promise<T[]> { + return (async () => { + const out: T[] = []; + for await (const item of iterable) { + out.push(item); + if (out.length >= count) break; + } + return out; + })(); +} + +describe("TypedEventEmitter", () => { + it("calls on() listeners in registration order with the payload", () => { + const e = new TypedEventEmitter<Events>(); + const calls: number[] = []; + e.on("data", (p) => calls.push(p.value * 1)); + e.on("data", (p) => calls.push(p.value * 10)); + const had = e.emit("data", { value: 2 }); + expect(had).toBe(true); + expect(calls).toEqual([2, 20]); + }); + + it("emit returns false when there are no listeners", () => { + const e = new TypedEventEmitter<Events>(); + expect(e.emit("data", { value: 1 })).toBe(false); + }); + + it("once() fires exactly once", () => { + const e = new TypedEventEmitter<Events>(); + const fn = vi.fn(); + e.once("data", fn); + e.emit("data", { value: 1 }); + e.emit("data", { value: 2 }); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith({ value: 1 }); + expect(e.listenerCount("data")).toBe(0); + }); + + it("off() removes a listener; removeListener matches once-wrappers by original", () => { + const e = new TypedEventEmitter<Events>(); + const fn = vi.fn(); + e.on("data", fn); + e.off("data", fn); + e.emit("data", { value: 1 }); + expect(fn).not.toHaveBeenCalled(); + + const onceFn = vi.fn(); + e.once("data", onceFn); + e.removeListener("data", onceFn); + e.emit("data", { value: 1 }); + expect(onceFn).not.toHaveBeenCalled(); + expect(e.listenerCount("data")).toBe(0); + }); + + it("prependListener / prependOnceListener run before existing listeners", () => { + const e = new TypedEventEmitter<Events>(); + const order: string[] = []; + e.on("data", () => order.push("a")); + e.prependListener("data", () => order.push("pre")); + e.emit("data", { value: 1 }); + expect(order).toEqual(["pre", "a"]); + }); + + it("removeAllListeners clears one event or all events", () => { + const e = new TypedEventEmitter<Events>(); + e.on("data", () => {}); + e.on("done", () => {}); + e.removeAllListeners("data"); + expect(e.listenerCount("data")).toBe(0); + expect(e.listenerCount("done")).toBe(1); + e.removeAllListeners(); + expect(e.eventNames()).toEqual([]); + }); + + it("listeners() returns originals, rawListeners() returns once-wrappers", () => { + const e = new TypedEventEmitter<Events>(); + const fn = () => {}; + e.once("data", fn); + expect(e.listeners("data")).toEqual([fn]); + expect(e.rawListeners("data")[0]).not.toBe(fn); + }); + + it("eventNames lists events with listeners; get/setMaxListeners round-trip", () => { + const e = new TypedEventEmitter<Events>(); + e.on("data", () => {}); + expect(e.eventNames()).toEqual(["data"]); + e.setMaxListeners(99); + expect(e.getMaxListeners()).toBe(99); + }); + + it("a listener removed mid-emit still does not fire again within the same emit", () => { + const e = new TypedEventEmitter<Events>(); + const seen: string[] = []; + const b = () => seen.push("b"); + e.on("data", () => { + seen.push("a"); + e.off("data", b); + }); + e.on("data", b); + e.emit("data", { value: 1 }); + // snapshot semantics: b was already scheduled in this emit + expect(seen).toEqual(["a", "b"]); + e.emit("data", { value: 2 }); + expect(seen).toEqual(["a", "b", "a"]); + }); + + it("toIterable yields events that arrive while awaiting", async () => { + const e = new TypedEventEmitter<Events>(); + const result = collect(e.toIterable("data"), 2); + await Promise.resolve(); + e.emit("data", { value: 1 }); + e.emit("data", { value: 2 }); + expect(await result).toEqual([{ value: 1 }, { value: 2 }]); + }); + + it("toIterable buffers events that arrive between iterations (no drops)", async () => { + const e = new TypedEventEmitter<Events>(); + // Emit a burst before the consumer pulls the second item. + const received: number[] = []; + const iterable = e.toIterable("data"); + const iterator = iterable[Symbol.asyncIterator](); + + const first = iterator.next(); + await Promise.resolve(); + e.emit("data", { value: 1 }); + e.emit("data", { value: 2 }); + e.emit("data", { value: 3 }); + received.push((await first).value?.value); + received.push((await iterator.next()).value?.value); + received.push((await iterator.next()).value?.value); + expect(received).toEqual([1, 2, 3]); + }); + + it("toIterable stops cleanly when the abort signal fires and removes its listener", async () => { + const e = new TypedEventEmitter<Events>(); + const controller = new AbortController(); + const done = (async () => { + const out: number[] = []; + for await (const item of e.toIterable("data", { + signal: controller.signal, + })) { + out.push(item.value); + } + return out; + })(); + await Promise.resolve(); + e.emit("data", { value: 1 }); + await Promise.resolve(); + controller.abort(); + expect(await done).toEqual([1]); + expect(e.listenerCount("data")).toBe(0); + }); + + it("toIterable returns immediately if the signal is already aborted", async () => { + const e = new TypedEventEmitter<Events>(); + const controller = new AbortController(); + controller.abort(); + const out: number[] = []; + for await (const item of e.toIterable("data", { + signal: controller.signal, + })) { + out.push(item.value); + } + expect(out).toEqual([]); + expect(e.listenerCount("data")).toBe(0); + }); +}); diff --git a/packages/shared/src/typed-event-emitter.ts b/packages/shared/src/typed-event-emitter.ts new file mode 100644 index 0000000000..333964ace9 --- /dev/null +++ b/packages/shared/src/typed-event-emitter.ts @@ -0,0 +1,255 @@ +type AnyListener = (payload: unknown) => void; + +interface ListenerRecord { + fn: AnyListener; + original: AnyListener; + once: boolean; +} + +/** + * Browser-safe, dependency-free EventEmitter with a typed event map and an + * async-iterable bridge. Drop-in for the node:events-based emitter used across + * the main process and workspace-server, but importable from packages/core + * (and therefore web/mobile hosts) because it touches no Node builtins. + * + * `toIterable` buffers events that arrive between iterations so a slow consumer + * never silently drops events — matching node:events `on()` semantics that the + * tRPC subscription routers depend on. + */ +export class TypedEventEmitter<TEvents> { + private readonly registry = new Map<string, ListenerRecord[]>(); + private maxListeners = 50; + + private add( + event: string, + original: AnyListener, + fn: AnyListener, + once: boolean, + prepend: boolean, + ): this { + let records = this.registry.get(event); + if (!records) { + records = []; + this.registry.set(event, records); + } + const record: ListenerRecord = { fn, original, once }; + if (prepend) { + records.unshift(record); + } else { + records.push(record); + } + return this; + } + + on<K extends keyof TEvents & string>( + event: K, + listener: (payload: TEvents[K]) => void, + ): this { + return this.add( + event, + listener as AnyListener, + listener as AnyListener, + false, + false, + ); + } + + addListener<K extends keyof TEvents & string>( + event: K, + listener: (payload: TEvents[K]) => void, + ): this { + return this.on(event, listener); + } + + prependListener<K extends keyof TEvents & string>( + event: K, + listener: (payload: TEvents[K]) => void, + ): this { + return this.add( + event, + listener as AnyListener, + listener as AnyListener, + false, + true, + ); + } + + private addOnce<K extends keyof TEvents & string>( + event: K, + listener: (payload: TEvents[K]) => void, + prepend: boolean, + ): this { + const original = listener as AnyListener; + const wrapper: AnyListener = (payload) => { + this.removeRecord(event, original, true); + original(payload); + }; + return this.add(event, original, wrapper, true, prepend); + } + + once<K extends keyof TEvents & string>( + event: K, + listener: (payload: TEvents[K]) => void, + ): this { + return this.addOnce(event, listener, false); + } + + prependOnceListener<K extends keyof TEvents & string>( + event: K, + listener: (payload: TEvents[K]) => void, + ): this { + return this.addOnce(event, listener, true); + } + + private removeRecord( + event: string, + original: AnyListener, + onlyOnce: boolean, + ): void { + const records = this.registry.get(event); + if (!records) { + return; + } + for (let i = records.length - 1; i >= 0; i--) { + const record = records[i]; + if (record.original === original && (!onlyOnce || record.once)) { + records.splice(i, 1); + break; + } + } + if (records.length === 0) { + this.registry.delete(event); + } + } + + off<K extends keyof TEvents & string>( + event: K, + listener: (payload: TEvents[K]) => void, + ): this { + this.removeRecord(event, listener as AnyListener, false); + return this; + } + + removeListener<K extends keyof TEvents & string>( + event: K, + listener: (payload: TEvents[K]) => void, + ): this { + return this.off(event, listener); + } + + removeAllListeners<K extends keyof TEvents & string>(event?: K): this { + if (event === undefined) { + this.registry.clear(); + } else { + this.registry.delete(event); + } + return this; + } + + emit<K extends keyof TEvents & string>( + event: K, + payload: TEvents[K], + ): boolean { + const records = this.registry.get(event); + if (!records || records.length === 0) { + return false; + } + for (const record of [...records]) { + record.fn(payload); + } + return true; + } + + listeners<K extends keyof TEvents & string>( + event: K, + ): ((payload: TEvents[K]) => void)[] { + return (this.registry.get(event) ?? []).map( + (record) => record.original as (payload: TEvents[K]) => void, + ); + } + + rawListeners<K extends keyof TEvents & string>( + event: K, + ): ((payload: TEvents[K]) => void)[] { + return (this.registry.get(event) ?? []).map( + (record) => record.fn as (payload: TEvents[K]) => void, + ); + } + + listenerCount<K extends keyof TEvents & string>(event: K): number { + return this.registry.get(event)?.length ?? 0; + } + + eventNames(): (keyof TEvents & string)[] { + return [...this.registry.keys()] as (keyof TEvents & string)[]; + } + + setMaxListeners(max: number): this { + this.maxListeners = max; + return this; + } + + getMaxListeners(): number { + return this.maxListeners; + } + + async *toIterable<K extends keyof TEvents & string>( + event: K, + opts?: { signal?: AbortSignal }, + ): AsyncIterableIterator<TEvents[K]> { + const signal = opts?.signal; + if (signal?.aborted) { + return; + } + + const queue: TEvents[K][] = []; + let pending: ((result: IteratorResult<TEvents[K]>) => void) | null = null; + let ended = false; + + const listener = (payload: TEvents[K]) => { + if (pending) { + const resolve = pending; + pending = null; + resolve({ value: payload, done: false }); + } else { + queue.push(payload); + } + }; + + const end = () => { + ended = true; + if (pending) { + const resolve = pending; + pending = null; + resolve({ value: undefined as never, done: true }); + } + }; + + this.on(event, listener); + signal?.addEventListener("abort", end, { once: true }); + + try { + while (true) { + if (queue.length > 0) { + yield queue.shift() as TEvents[K]; + continue; + } + if (ended) { + return; + } + const result = await new Promise<IteratorResult<TEvents[K]>>( + (resolve) => { + pending = resolve; + }, + ); + if (result.done) { + return; + } + yield result.value; + } + } finally { + this.off(event, listener); + signal?.removeEventListener("abort", end); + } + } +} diff --git a/apps/code/src/shared/utils/urls.ts b/packages/shared/src/urls.ts similarity index 81% rename from apps/code/src/shared/utils/urls.ts rename to packages/shared/src/urls.ts index 71b3e29ea6..f41f6e58be 100644 --- a/apps/code/src/shared/utils/urls.ts +++ b/packages/shared/src/urls.ts @@ -1,4 +1,4 @@ -import type { CloudRegion } from "@shared/types/regions"; +import type { CloudRegion } from "./regions"; export function getCloudUrlFromRegion(region: CloudRegion): string { switch (region) { diff --git a/packages/shared/src/workspace-domain.ts b/packages/shared/src/workspace-domain.ts new file mode 100644 index 0000000000..4235be5f46 --- /dev/null +++ b/packages/shared/src/workspace-domain.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +// Workspace projection/boundary schemas. Shared between the workspace-server +// host service (which produces them) and the renderer/UI (which renders them). +// Note: "root" is deprecated, migrated to "local" on read. +export const workspaceModeSchema = z + .enum(["worktree", "local", "cloud", "root"]) + .transform((val) => (val === "root" ? "local" : val)); + +export const worktreeInfoSchema = z.object({ + worktreePath: z.string(), + worktreeName: z.string(), + branchName: z.string().nullable(), + baseBranch: z.string(), + createdAt: z.string(), + output: z.string().optional(), +}); + +export const workspaceInfoSchema = z.object({ + taskId: z.string(), + mode: workspaceModeSchema, + worktree: worktreeInfoSchema.nullable(), + branchName: z.string().nullable(), + linkedBranch: z.string().nullable(), +}); + +export const workspaceSchema = z.object({ + taskId: z.string(), + folderId: z.string(), + folderPath: z.string(), + mode: workspaceModeSchema, + worktreePath: z.string().nullable(), + worktreeName: z.string().nullable(), + branchName: z.string().nullable(), + baseBranch: z.string().nullable(), + linkedBranch: z.string().nullable(), + createdAt: z.string(), +}); + +export type WorktreeInfo = z.infer<typeof worktreeInfoSchema>; +export type WorkspaceInfo = z.infer<typeof workspaceInfoSchema>; +export type Workspace = z.infer<typeof workspaceSchema>; diff --git a/packages/shared/src/workspace.ts b/packages/shared/src/workspace.ts new file mode 100644 index 0000000000..cd08dd4e04 --- /dev/null +++ b/packages/shared/src/workspace.ts @@ -0,0 +1 @@ +export type WorkspaceMode = "cloud" | "local" | "worktree"; diff --git a/packages/shared/src/xml.test.ts b/packages/shared/src/xml.test.ts new file mode 100644 index 0000000000..77edd72eb0 --- /dev/null +++ b/packages/shared/src/xml.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { escapeXmlAttr, unescapeXmlAttr } from "./xml"; + +describe("escapeXmlAttr", () => { + it("escapes the five XML attribute metacharacters", () => { + expect(escapeXmlAttr(`&<>"'`)).toBe("&<>"'"); + }); + + it("escapes ampersands before other entities so output is not double-escaped on reverse", () => { + expect(escapeXmlAttr("a & b")).toBe("a & b"); + }); + + it("leaves plain text untouched", () => { + expect(escapeXmlAttr("hello world")).toBe("hello world"); + }); +}); + +describe("unescapeXmlAttr", () => { + it("reverses the five entities", () => { + expect(unescapeXmlAttr("&<>"'")).toBe(`&<>"'`); + }); +}); + +describe("escape/unescape round-trip", () => { + it.each([ + `&<>"'`, + `tag <a href="x">y</a>`, + "literal & entity", + "ampersands & < mixed > with \" quotes ' and more", + "plain", + ])("round-trips %j", (input) => { + expect(unescapeXmlAttr(escapeXmlAttr(input))).toBe(input); + }); +}); diff --git a/apps/code/src/renderer/utils/xml.ts b/packages/shared/src/xml.ts similarity index 100% rename from apps/code/src/renderer/utils/xml.ts rename to packages/shared/src/xml.ts diff --git a/packages/shared/tsup.config.ts b/packages/shared/tsup.config.ts index 6f5cfd93a5..e4bbc2152a 100644 --- a/packages/shared/tsup.config.ts +++ b/packages/shared/tsup.config.ts @@ -1,7 +1,12 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: [ + "src/index.ts", + "src/analytics-events.ts", + "src/domain-types.ts", + "src/mcp-sandbox-proxy.ts", + ], format: ["esm"], dts: true, sourcemap: true, diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000000..5e398e4eaf --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index e04adb1d5a..5e5f545e92 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,15 +12,92 @@ }, "scripts": { "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "node ../../scripts/rimraf.mjs dist .turbo" }, "dependencies": { + "@agentclientprotocol/sdk": "0.22.1", + "@base-ui/react": "^1.3.0", + "@codemirror/lang-angular": "^0.1.4", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-go": "^6.0.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-jinja": "^6.0.0", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-liquid": "^6.3.0", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sass": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-vue": "^0.1.3", + "@codemirror/lang-wast": "^6.0.2", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/language": "^6.12.2", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.17", + "@dnd-kit/dom": "^0.1.21", + "@dnd-kit/react": "^0.1.21", + "@lezer/common": "^1.5.1", + "@lezer/highlight": "^1.2.3", + "@modelcontextprotocol/ext-apps": "^1.1.2", + "@modelcontextprotocol/sdk": "^1.12.1", + "@pierre/diffs": "^1.1.21", + "@posthog/agent": "workspace:*", "@posthog/api-client": "workspace:*", "@posthog/core": "workspace:*", + "@posthog/di": "workspace:*", + "@posthog/host-router": "workspace:*", "@posthog/platform": "workspace:*", + "@posthog/shared": "workspace:*", "@posthog/workspace-client": "workspace:*", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-tooltip": "^1.2.8", + "@tiptap/core": "^3.13.0", + "@tiptap/extension-mention": "^3.13.0", + "@tiptap/extension-placeholder": "^3.13.0", + "@tiptap/pm": "^3.13.0", + "@tiptap/react": "^3.13.0", + "@tiptap/starter-kit": "^3.13.0", + "@tiptap/suggestion": "^3.13.0", + "@trpc/tanstack-react-query": "catalog:", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-serialize": "^0.13.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/xterm": "^5.5.0", + "canvas-confetti": "^1.9.4", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "framer-motion": "^12.26.2", + "fuse.js": "^7.1.0", + "fzf": "^0.5.2", "inversify": "catalog:", - "reflect-metadata": "catalog:" + "lucide-react": "^1.7.0", + "posthog-js": "^1.283.0", + "react-hotkeys-hook": "^4.4.4", + "react-markdown": "^10.1.0", + "react-resizable-panels": "^3.0.6", + "reflect-metadata": "catalog:", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "semver": "^7.6.0", + "sonner": "^2.0.7", + "tippy.js": "^6.3.7", + "unified": "^11.0.5", + "radix-themes-tw": "0.2.3", + "tailwindcss": "^4.2.2", + "tailwindcss-scroll-mask": "^0.0.3", + "virtua": "^0.48.6", + "vscode-icons-js": "^11.6.1", + "zustand": "^4.5.0" }, "peerDependencies": { "@phosphor-icons/react": "catalog:", @@ -36,11 +113,19 @@ "@posthog/tsconfig": "workspace:*", "@radix-ui/themes": "catalog:", "@tanstack/react-query": "catalog:", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/canvas-confetti": "^1.9.0", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@types/semver": "^7.7.1", + "@vitejs/plugin-react": "^4.2.1", + "jsdom": "^26.0.0", "react": "catalog:", "react-dom": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^4.0.10" }, "files": [ "dist/**/*", diff --git a/packages/ui/src/assets.d.ts b/packages/ui/src/assets.d.ts new file mode 100644 index 0000000000..e821a9efaf --- /dev/null +++ b/packages/ui/src/assets.d.ts @@ -0,0 +1,14 @@ +declare module "*.svg" { + const src: string; + export default src; +} + +declare module "*.png" { + const src: string; + export default src; +} + +declare module "*.mp3" { + const src: string; + export default src; +} diff --git a/apps/code/src/renderer/assets/file-icons/default_file.svg b/packages/ui/src/assets/file-icons/default_file.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/default_file.svg rename to packages/ui/src/assets/file-icons/default_file.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_access.svg b/packages/ui/src/assets/file-icons/file_type_access.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_access.svg rename to packages/ui/src/assets/file-icons/file_type_access.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_actionscript.svg b/packages/ui/src/assets/file-icons/file_type_actionscript.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_actionscript.svg rename to packages/ui/src/assets/file-icons/file_type_actionscript.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ai.svg b/packages/ui/src/assets/file-icons/file_type_ai.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ai.svg rename to packages/ui/src/assets/file-icons/file_type_ai.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ai2.svg b/packages/ui/src/assets/file-icons/file_type_ai2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ai2.svg rename to packages/ui/src/assets/file-icons/file_type_ai2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_al.svg b/packages/ui/src/assets/file-icons/file_type_al.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_al.svg rename to packages/ui/src/assets/file-icons/file_type_al.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_angular.svg b/packages/ui/src/assets/file-icons/file_type_angular.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_angular.svg rename to packages/ui/src/assets/file-icons/file_type_angular.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ansible.svg b/packages/ui/src/assets/file-icons/file_type_ansible.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ansible.svg rename to packages/ui/src/assets/file-icons/file_type_ansible.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_antlr.svg b/packages/ui/src/assets/file-icons/file_type_antlr.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_antlr.svg rename to packages/ui/src/assets/file-icons/file_type_antlr.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_anyscript.svg b/packages/ui/src/assets/file-icons/file_type_anyscript.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_anyscript.svg rename to packages/ui/src/assets/file-icons/file_type_anyscript.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_apache.svg b/packages/ui/src/assets/file-icons/file_type_apache.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_apache.svg rename to packages/ui/src/assets/file-icons/file_type_apache.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_apex.svg b/packages/ui/src/assets/file-icons/file_type_apex.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_apex.svg rename to packages/ui/src/assets/file-icons/file_type_apex.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_apib.svg b/packages/ui/src/assets/file-icons/file_type_apib.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_apib.svg rename to packages/ui/src/assets/file-icons/file_type_apib.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_apib2.svg b/packages/ui/src/assets/file-icons/file_type_apib2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_apib2.svg rename to packages/ui/src/assets/file-icons/file_type_apib2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_applescript.svg b/packages/ui/src/assets/file-icons/file_type_applescript.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_applescript.svg rename to packages/ui/src/assets/file-icons/file_type_applescript.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_appveyor.svg b/packages/ui/src/assets/file-icons/file_type_appveyor.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_appveyor.svg rename to packages/ui/src/assets/file-icons/file_type_appveyor.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_arduino.svg b/packages/ui/src/assets/file-icons/file_type_arduino.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_arduino.svg rename to packages/ui/src/assets/file-icons/file_type_arduino.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_asp.svg b/packages/ui/src/assets/file-icons/file_type_asp.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_asp.svg rename to packages/ui/src/assets/file-icons/file_type_asp.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_aspx.svg b/packages/ui/src/assets/file-icons/file_type_aspx.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_aspx.svg rename to packages/ui/src/assets/file-icons/file_type_aspx.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_assembly.svg b/packages/ui/src/assets/file-icons/file_type_assembly.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_assembly.svg rename to packages/ui/src/assets/file-icons/file_type_assembly.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_astro.svg b/packages/ui/src/assets/file-icons/file_type_astro.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_astro.svg rename to packages/ui/src/assets/file-icons/file_type_astro.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_audio.svg b/packages/ui/src/assets/file-icons/file_type_audio.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_audio.svg rename to packages/ui/src/assets/file-icons/file_type_audio.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_aurelia.svg b/packages/ui/src/assets/file-icons/file_type_aurelia.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_aurelia.svg rename to packages/ui/src/assets/file-icons/file_type_aurelia.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_autohotkey.svg b/packages/ui/src/assets/file-icons/file_type_autohotkey.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_autohotkey.svg rename to packages/ui/src/assets/file-icons/file_type_autohotkey.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_autoit.svg b/packages/ui/src/assets/file-icons/file_type_autoit.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_autoit.svg rename to packages/ui/src/assets/file-icons/file_type_autoit.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_avro.svg b/packages/ui/src/assets/file-icons/file_type_avro.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_avro.svg rename to packages/ui/src/assets/file-icons/file_type_avro.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_aws.svg b/packages/ui/src/assets/file-icons/file_type_aws.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_aws.svg rename to packages/ui/src/assets/file-icons/file_type_aws.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_azure.svg b/packages/ui/src/assets/file-icons/file_type_azure.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_azure.svg rename to packages/ui/src/assets/file-icons/file_type_azure.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_babel.svg b/packages/ui/src/assets/file-icons/file_type_babel.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_babel.svg rename to packages/ui/src/assets/file-icons/file_type_babel.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_babel2.svg b/packages/ui/src/assets/file-icons/file_type_babel2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_babel2.svg rename to packages/ui/src/assets/file-icons/file_type_babel2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_bat.svg b/packages/ui/src/assets/file-icons/file_type_bat.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_bat.svg rename to packages/ui/src/assets/file-icons/file_type_bat.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_bazaar.svg b/packages/ui/src/assets/file-icons/file_type_bazaar.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_bazaar.svg rename to packages/ui/src/assets/file-icons/file_type_bazaar.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_bazel.svg b/packages/ui/src/assets/file-icons/file_type_bazel.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_bazel.svg rename to packages/ui/src/assets/file-icons/file_type_bazel.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_binary.svg b/packages/ui/src/assets/file-icons/file_type_binary.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_binary.svg rename to packages/ui/src/assets/file-icons/file_type_binary.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_bithound.svg b/packages/ui/src/assets/file-icons/file_type_bithound.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_bithound.svg rename to packages/ui/src/assets/file-icons/file_type_bithound.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_blade.svg b/packages/ui/src/assets/file-icons/file_type_blade.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_blade.svg rename to packages/ui/src/assets/file-icons/file_type_blade.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_bolt.svg b/packages/ui/src/assets/file-icons/file_type_bolt.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_bolt.svg rename to packages/ui/src/assets/file-icons/file_type_bolt.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_bower.svg b/packages/ui/src/assets/file-icons/file_type_bower.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_bower.svg rename to packages/ui/src/assets/file-icons/file_type_bower.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_bower2.svg b/packages/ui/src/assets/file-icons/file_type_bower2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_bower2.svg rename to packages/ui/src/assets/file-icons/file_type_bower2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_buckbuild.svg b/packages/ui/src/assets/file-icons/file_type_buckbuild.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_buckbuild.svg rename to packages/ui/src/assets/file-icons/file_type_buckbuild.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_bun.svg b/packages/ui/src/assets/file-icons/file_type_bun.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_bun.svg rename to packages/ui/src/assets/file-icons/file_type_bun.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_bundler.svg b/packages/ui/src/assets/file-icons/file_type_bundler.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_bundler.svg rename to packages/ui/src/assets/file-icons/file_type_bundler.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_c.svg b/packages/ui/src/assets/file-icons/file_type_c.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_c.svg rename to packages/ui/src/assets/file-icons/file_type_c.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_c2.svg b/packages/ui/src/assets/file-icons/file_type_c2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_c2.svg rename to packages/ui/src/assets/file-icons/file_type_c2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_c_al.svg b/packages/ui/src/assets/file-icons/file_type_c_al.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_c_al.svg rename to packages/ui/src/assets/file-icons/file_type_c_al.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cabal.svg b/packages/ui/src/assets/file-icons/file_type_cabal.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cabal.svg rename to packages/ui/src/assets/file-icons/file_type_cabal.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cake.svg b/packages/ui/src/assets/file-icons/file_type_cake.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cake.svg rename to packages/ui/src/assets/file-icons/file_type_cake.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cakephp.svg b/packages/ui/src/assets/file-icons/file_type_cakephp.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cakephp.svg rename to packages/ui/src/assets/file-icons/file_type_cakephp.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cargo.svg b/packages/ui/src/assets/file-icons/file_type_cargo.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cargo.svg rename to packages/ui/src/assets/file-icons/file_type_cargo.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cert.svg b/packages/ui/src/assets/file-icons/file_type_cert.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cert.svg rename to packages/ui/src/assets/file-icons/file_type_cert.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cf.svg b/packages/ui/src/assets/file-icons/file_type_cf.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cf.svg rename to packages/ui/src/assets/file-icons/file_type_cf.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cf2.svg b/packages/ui/src/assets/file-icons/file_type_cf2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cf2.svg rename to packages/ui/src/assets/file-icons/file_type_cf2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cfc.svg b/packages/ui/src/assets/file-icons/file_type_cfc.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cfc.svg rename to packages/ui/src/assets/file-icons/file_type_cfc.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cfc2.svg b/packages/ui/src/assets/file-icons/file_type_cfc2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cfc2.svg rename to packages/ui/src/assets/file-icons/file_type_cfc2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cfm.svg b/packages/ui/src/assets/file-icons/file_type_cfm.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cfm.svg rename to packages/ui/src/assets/file-icons/file_type_cfm.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cfm2.svg b/packages/ui/src/assets/file-icons/file_type_cfm2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cfm2.svg rename to packages/ui/src/assets/file-icons/file_type_cfm2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cheader.svg b/packages/ui/src/assets/file-icons/file_type_cheader.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cheader.svg rename to packages/ui/src/assets/file-icons/file_type_cheader.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_chef.svg b/packages/ui/src/assets/file-icons/file_type_chef.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_chef.svg rename to packages/ui/src/assets/file-icons/file_type_chef.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_circleci.svg b/packages/ui/src/assets/file-icons/file_type_circleci.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_circleci.svg rename to packages/ui/src/assets/file-icons/file_type_circleci.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_class.svg b/packages/ui/src/assets/file-icons/file_type_class.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_class.svg rename to packages/ui/src/assets/file-icons/file_type_class.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_clojure.svg b/packages/ui/src/assets/file-icons/file_type_clojure.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_clojure.svg rename to packages/ui/src/assets/file-icons/file_type_clojure.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cloudfoundry.svg b/packages/ui/src/assets/file-icons/file_type_cloudfoundry.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cloudfoundry.svg rename to packages/ui/src/assets/file-icons/file_type_cloudfoundry.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cmake.svg b/packages/ui/src/assets/file-icons/file_type_cmake.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cmake.svg rename to packages/ui/src/assets/file-icons/file_type_cmake.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cobol.svg b/packages/ui/src/assets/file-icons/file_type_cobol.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cobol.svg rename to packages/ui/src/assets/file-icons/file_type_cobol.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_codeclimate.svg b/packages/ui/src/assets/file-icons/file_type_codeclimate.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_codeclimate.svg rename to packages/ui/src/assets/file-icons/file_type_codeclimate.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_codecov.svg b/packages/ui/src/assets/file-icons/file_type_codecov.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_codecov.svg rename to packages/ui/src/assets/file-icons/file_type_codecov.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_codekit.svg b/packages/ui/src/assets/file-icons/file_type_codekit.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_codekit.svg rename to packages/ui/src/assets/file-icons/file_type_codekit.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_codeowners.svg b/packages/ui/src/assets/file-icons/file_type_codeowners.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_codeowners.svg rename to packages/ui/src/assets/file-icons/file_type_codeowners.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_coffeelint.svg b/packages/ui/src/assets/file-icons/file_type_coffeelint.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_coffeelint.svg rename to packages/ui/src/assets/file-icons/file_type_coffeelint.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_coffeescript.svg b/packages/ui/src/assets/file-icons/file_type_coffeescript.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_coffeescript.svg rename to packages/ui/src/assets/file-icons/file_type_coffeescript.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_compass.svg b/packages/ui/src/assets/file-icons/file_type_compass.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_compass.svg rename to packages/ui/src/assets/file-icons/file_type_compass.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_composer.svg b/packages/ui/src/assets/file-icons/file_type_composer.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_composer.svg rename to packages/ui/src/assets/file-icons/file_type_composer.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_conan.svg b/packages/ui/src/assets/file-icons/file_type_conan.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_conan.svg rename to packages/ui/src/assets/file-icons/file_type_conan.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_config.svg b/packages/ui/src/assets/file-icons/file_type_config.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_config.svg rename to packages/ui/src/assets/file-icons/file_type_config.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_coveralls.svg b/packages/ui/src/assets/file-icons/file_type_coveralls.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_coveralls.svg rename to packages/ui/src/assets/file-icons/file_type_coveralls.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cpp.svg b/packages/ui/src/assets/file-icons/file_type_cpp.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cpp.svg rename to packages/ui/src/assets/file-icons/file_type_cpp.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cpp2.svg b/packages/ui/src/assets/file-icons/file_type_cpp2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cpp2.svg rename to packages/ui/src/assets/file-icons/file_type_cpp2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cppheader.svg b/packages/ui/src/assets/file-icons/file_type_cppheader.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cppheader.svg rename to packages/ui/src/assets/file-icons/file_type_cppheader.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_crowdin.svg b/packages/ui/src/assets/file-icons/file_type_crowdin.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_crowdin.svg rename to packages/ui/src/assets/file-icons/file_type_crowdin.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_crystal.svg b/packages/ui/src/assets/file-icons/file_type_crystal.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_crystal.svg rename to packages/ui/src/assets/file-icons/file_type_crystal.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_csharp.svg b/packages/ui/src/assets/file-icons/file_type_csharp.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_csharp.svg rename to packages/ui/src/assets/file-icons/file_type_csharp.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_csproj.svg b/packages/ui/src/assets/file-icons/file_type_csproj.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_csproj.svg rename to packages/ui/src/assets/file-icons/file_type_csproj.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_css.svg b/packages/ui/src/assets/file-icons/file_type_css.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_css.svg rename to packages/ui/src/assets/file-icons/file_type_css.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_csslint.svg b/packages/ui/src/assets/file-icons/file_type_csslint.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_csslint.svg rename to packages/ui/src/assets/file-icons/file_type_csslint.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cssmap.svg b/packages/ui/src/assets/file-icons/file_type_cssmap.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cssmap.svg rename to packages/ui/src/assets/file-icons/file_type_cssmap.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cucumber.svg b/packages/ui/src/assets/file-icons/file_type_cucumber.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cucumber.svg rename to packages/ui/src/assets/file-icons/file_type_cucumber.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cvs.svg b/packages/ui/src/assets/file-icons/file_type_cvs.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cvs.svg rename to packages/ui/src/assets/file-icons/file_type_cvs.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_cypress.svg b/packages/ui/src/assets/file-icons/file_type_cypress.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_cypress.svg rename to packages/ui/src/assets/file-icons/file_type_cypress.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_dal.svg b/packages/ui/src/assets/file-icons/file_type_dal.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_dal.svg rename to packages/ui/src/assets/file-icons/file_type_dal.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_darcs.svg b/packages/ui/src/assets/file-icons/file_type_darcs.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_darcs.svg rename to packages/ui/src/assets/file-icons/file_type_darcs.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_dartlang.svg b/packages/ui/src/assets/file-icons/file_type_dartlang.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_dartlang.svg rename to packages/ui/src/assets/file-icons/file_type_dartlang.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_db.svg b/packages/ui/src/assets/file-icons/file_type_db.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_db.svg rename to packages/ui/src/assets/file-icons/file_type_db.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_delphi.svg b/packages/ui/src/assets/file-icons/file_type_delphi.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_delphi.svg rename to packages/ui/src/assets/file-icons/file_type_delphi.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_deno.svg b/packages/ui/src/assets/file-icons/file_type_deno.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_deno.svg rename to packages/ui/src/assets/file-icons/file_type_deno.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_dependencies.svg b/packages/ui/src/assets/file-icons/file_type_dependencies.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_dependencies.svg rename to packages/ui/src/assets/file-icons/file_type_dependencies.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_diff.svg b/packages/ui/src/assets/file-icons/file_type_diff.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_diff.svg rename to packages/ui/src/assets/file-icons/file_type_diff.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_django.svg b/packages/ui/src/assets/file-icons/file_type_django.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_django.svg rename to packages/ui/src/assets/file-icons/file_type_django.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_dlang.svg b/packages/ui/src/assets/file-icons/file_type_dlang.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_dlang.svg rename to packages/ui/src/assets/file-icons/file_type_dlang.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_docker.svg b/packages/ui/src/assets/file-icons/file_type_docker.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_docker.svg rename to packages/ui/src/assets/file-icons/file_type_docker.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_docker2.svg b/packages/ui/src/assets/file-icons/file_type_docker2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_docker2.svg rename to packages/ui/src/assets/file-icons/file_type_docker2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_dockertest.svg b/packages/ui/src/assets/file-icons/file_type_dockertest.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_dockertest.svg rename to packages/ui/src/assets/file-icons/file_type_dockertest.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_dockertest2.svg b/packages/ui/src/assets/file-icons/file_type_dockertest2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_dockertest2.svg rename to packages/ui/src/assets/file-icons/file_type_dockertest2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_docpad.svg b/packages/ui/src/assets/file-icons/file_type_docpad.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_docpad.svg rename to packages/ui/src/assets/file-icons/file_type_docpad.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_dotenv.svg b/packages/ui/src/assets/file-icons/file_type_dotenv.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_dotenv.svg rename to packages/ui/src/assets/file-icons/file_type_dotenv.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_doxygen.svg b/packages/ui/src/assets/file-icons/file_type_doxygen.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_doxygen.svg rename to packages/ui/src/assets/file-icons/file_type_doxygen.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_drone.svg b/packages/ui/src/assets/file-icons/file_type_drone.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_drone.svg rename to packages/ui/src/assets/file-icons/file_type_drone.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_drools.svg b/packages/ui/src/assets/file-icons/file_type_drools.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_drools.svg rename to packages/ui/src/assets/file-icons/file_type_drools.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_dustjs.svg b/packages/ui/src/assets/file-icons/file_type_dustjs.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_dustjs.svg rename to packages/ui/src/assets/file-icons/file_type_dustjs.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_dylan.svg b/packages/ui/src/assets/file-icons/file_type_dylan.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_dylan.svg rename to packages/ui/src/assets/file-icons/file_type_dylan.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_edge.svg b/packages/ui/src/assets/file-icons/file_type_edge.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_edge.svg rename to packages/ui/src/assets/file-icons/file_type_edge.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_edge2.svg b/packages/ui/src/assets/file-icons/file_type_edge2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_edge2.svg rename to packages/ui/src/assets/file-icons/file_type_edge2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_editorconfig.svg b/packages/ui/src/assets/file-icons/file_type_editorconfig.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_editorconfig.svg rename to packages/ui/src/assets/file-icons/file_type_editorconfig.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_eex.svg b/packages/ui/src/assets/file-icons/file_type_eex.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_eex.svg rename to packages/ui/src/assets/file-icons/file_type_eex.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ejs.svg b/packages/ui/src/assets/file-icons/file_type_ejs.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ejs.svg rename to packages/ui/src/assets/file-icons/file_type_ejs.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_elastic.svg b/packages/ui/src/assets/file-icons/file_type_elastic.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_elastic.svg rename to packages/ui/src/assets/file-icons/file_type_elastic.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_elasticbeanstalk.svg b/packages/ui/src/assets/file-icons/file_type_elasticbeanstalk.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_elasticbeanstalk.svg rename to packages/ui/src/assets/file-icons/file_type_elasticbeanstalk.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_elixir.svg b/packages/ui/src/assets/file-icons/file_type_elixir.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_elixir.svg rename to packages/ui/src/assets/file-icons/file_type_elixir.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_elm.svg b/packages/ui/src/assets/file-icons/file_type_elm.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_elm.svg rename to packages/ui/src/assets/file-icons/file_type_elm.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_elm2.svg b/packages/ui/src/assets/file-icons/file_type_elm2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_elm2.svg rename to packages/ui/src/assets/file-icons/file_type_elm2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_emacs.svg b/packages/ui/src/assets/file-icons/file_type_emacs.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_emacs.svg rename to packages/ui/src/assets/file-icons/file_type_emacs.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ember.svg b/packages/ui/src/assets/file-icons/file_type_ember.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ember.svg rename to packages/ui/src/assets/file-icons/file_type_ember.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ensime.svg b/packages/ui/src/assets/file-icons/file_type_ensime.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ensime.svg rename to packages/ui/src/assets/file-icons/file_type_ensime.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_eps.svg b/packages/ui/src/assets/file-icons/file_type_eps.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_eps.svg rename to packages/ui/src/assets/file-icons/file_type_eps.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_erb.svg b/packages/ui/src/assets/file-icons/file_type_erb.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_erb.svg rename to packages/ui/src/assets/file-icons/file_type_erb.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_erlang.svg b/packages/ui/src/assets/file-icons/file_type_erlang.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_erlang.svg rename to packages/ui/src/assets/file-icons/file_type_erlang.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_erlang2.svg b/packages/ui/src/assets/file-icons/file_type_erlang2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_erlang2.svg rename to packages/ui/src/assets/file-icons/file_type_erlang2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_esbuild.svg b/packages/ui/src/assets/file-icons/file_type_esbuild.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_esbuild.svg rename to packages/ui/src/assets/file-icons/file_type_esbuild.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_eslint.svg b/packages/ui/src/assets/file-icons/file_type_eslint.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_eslint.svg rename to packages/ui/src/assets/file-icons/file_type_eslint.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_eslint2.svg b/packages/ui/src/assets/file-icons/file_type_eslint2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_eslint2.svg rename to packages/ui/src/assets/file-icons/file_type_eslint2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_excel.svg b/packages/ui/src/assets/file-icons/file_type_excel.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_excel.svg rename to packages/ui/src/assets/file-icons/file_type_excel.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_favicon.svg b/packages/ui/src/assets/file-icons/file_type_favicon.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_favicon.svg rename to packages/ui/src/assets/file-icons/file_type_favicon.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_fbx.svg b/packages/ui/src/assets/file-icons/file_type_fbx.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_fbx.svg rename to packages/ui/src/assets/file-icons/file_type_fbx.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_firebase.svg b/packages/ui/src/assets/file-icons/file_type_firebase.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_firebase.svg rename to packages/ui/src/assets/file-icons/file_type_firebase.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_flash.svg b/packages/ui/src/assets/file-icons/file_type_flash.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_flash.svg rename to packages/ui/src/assets/file-icons/file_type_flash.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_floobits.svg b/packages/ui/src/assets/file-icons/file_type_floobits.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_floobits.svg rename to packages/ui/src/assets/file-icons/file_type_floobits.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_flow.svg b/packages/ui/src/assets/file-icons/file_type_flow.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_flow.svg rename to packages/ui/src/assets/file-icons/file_type_flow.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_font.svg b/packages/ui/src/assets/file-icons/file_type_font.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_font.svg rename to packages/ui/src/assets/file-icons/file_type_font.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_fortran.svg b/packages/ui/src/assets/file-icons/file_type_fortran.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_fortran.svg rename to packages/ui/src/assets/file-icons/file_type_fortran.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_fossil.svg b/packages/ui/src/assets/file-icons/file_type_fossil.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_fossil.svg rename to packages/ui/src/assets/file-icons/file_type_fossil.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_freemarker.svg b/packages/ui/src/assets/file-icons/file_type_freemarker.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_freemarker.svg rename to packages/ui/src/assets/file-icons/file_type_freemarker.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_fsharp.svg b/packages/ui/src/assets/file-icons/file_type_fsharp.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_fsharp.svg rename to packages/ui/src/assets/file-icons/file_type_fsharp.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_fsharp2.svg b/packages/ui/src/assets/file-icons/file_type_fsharp2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_fsharp2.svg rename to packages/ui/src/assets/file-icons/file_type_fsharp2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_fsproj.svg b/packages/ui/src/assets/file-icons/file_type_fsproj.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_fsproj.svg rename to packages/ui/src/assets/file-icons/file_type_fsproj.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_fusebox.svg b/packages/ui/src/assets/file-icons/file_type_fusebox.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_fusebox.svg rename to packages/ui/src/assets/file-icons/file_type_fusebox.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_galen.svg b/packages/ui/src/assets/file-icons/file_type_galen.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_galen.svg rename to packages/ui/src/assets/file-icons/file_type_galen.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_galen2.svg b/packages/ui/src/assets/file-icons/file_type_galen2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_galen2.svg rename to packages/ui/src/assets/file-icons/file_type_galen2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_gamemaker.svg b/packages/ui/src/assets/file-icons/file_type_gamemaker.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_gamemaker.svg rename to packages/ui/src/assets/file-icons/file_type_gamemaker.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_gamemaker2.svg b/packages/ui/src/assets/file-icons/file_type_gamemaker2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_gamemaker2.svg rename to packages/ui/src/assets/file-icons/file_type_gamemaker2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_gamemaker81.svg b/packages/ui/src/assets/file-icons/file_type_gamemaker81.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_gamemaker81.svg rename to packages/ui/src/assets/file-icons/file_type_gamemaker81.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_git.svg b/packages/ui/src/assets/file-icons/file_type_git.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_git.svg rename to packages/ui/src/assets/file-icons/file_type_git.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_git2.svg b/packages/ui/src/assets/file-icons/file_type_git2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_git2.svg rename to packages/ui/src/assets/file-icons/file_type_git2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_gitlab.svg b/packages/ui/src/assets/file-icons/file_type_gitlab.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_gitlab.svg rename to packages/ui/src/assets/file-icons/file_type_gitlab.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_glsl.svg b/packages/ui/src/assets/file-icons/file_type_glsl.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_glsl.svg rename to packages/ui/src/assets/file-icons/file_type_glsl.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_go.svg b/packages/ui/src/assets/file-icons/file_type_go.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_go.svg rename to packages/ui/src/assets/file-icons/file_type_go.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_godot.svg b/packages/ui/src/assets/file-icons/file_type_godot.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_godot.svg rename to packages/ui/src/assets/file-icons/file_type_godot.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_gradle.svg b/packages/ui/src/assets/file-icons/file_type_gradle.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_gradle.svg rename to packages/ui/src/assets/file-icons/file_type_gradle.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_graphql.svg b/packages/ui/src/assets/file-icons/file_type_graphql.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_graphql.svg rename to packages/ui/src/assets/file-icons/file_type_graphql.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_graphviz.svg b/packages/ui/src/assets/file-icons/file_type_graphviz.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_graphviz.svg rename to packages/ui/src/assets/file-icons/file_type_graphviz.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_groovy.svg b/packages/ui/src/assets/file-icons/file_type_groovy.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_groovy.svg rename to packages/ui/src/assets/file-icons/file_type_groovy.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_groovy2.svg b/packages/ui/src/assets/file-icons/file_type_groovy2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_groovy2.svg rename to packages/ui/src/assets/file-icons/file_type_groovy2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_grunt.svg b/packages/ui/src/assets/file-icons/file_type_grunt.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_grunt.svg rename to packages/ui/src/assets/file-icons/file_type_grunt.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_gulp.svg b/packages/ui/src/assets/file-icons/file_type_gulp.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_gulp.svg rename to packages/ui/src/assets/file-icons/file_type_gulp.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_haml.svg b/packages/ui/src/assets/file-icons/file_type_haml.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_haml.svg rename to packages/ui/src/assets/file-icons/file_type_haml.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_handlebars.svg b/packages/ui/src/assets/file-icons/file_type_handlebars.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_handlebars.svg rename to packages/ui/src/assets/file-icons/file_type_handlebars.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_handlebars2.svg b/packages/ui/src/assets/file-icons/file_type_handlebars2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_handlebars2.svg rename to packages/ui/src/assets/file-icons/file_type_handlebars2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_harbour.svg b/packages/ui/src/assets/file-icons/file_type_harbour.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_harbour.svg rename to packages/ui/src/assets/file-icons/file_type_harbour.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_hardhat.svg b/packages/ui/src/assets/file-icons/file_type_hardhat.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_hardhat.svg rename to packages/ui/src/assets/file-icons/file_type_hardhat.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_haskell.svg b/packages/ui/src/assets/file-icons/file_type_haskell.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_haskell.svg rename to packages/ui/src/assets/file-icons/file_type_haskell.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_haskell2.svg b/packages/ui/src/assets/file-icons/file_type_haskell2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_haskell2.svg rename to packages/ui/src/assets/file-icons/file_type_haskell2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_haxe.svg b/packages/ui/src/assets/file-icons/file_type_haxe.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_haxe.svg rename to packages/ui/src/assets/file-icons/file_type_haxe.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_haxecheckstyle.svg b/packages/ui/src/assets/file-icons/file_type_haxecheckstyle.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_haxecheckstyle.svg rename to packages/ui/src/assets/file-icons/file_type_haxecheckstyle.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_haxedevelop.svg b/packages/ui/src/assets/file-icons/file_type_haxedevelop.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_haxedevelop.svg rename to packages/ui/src/assets/file-icons/file_type_haxedevelop.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_helix.svg b/packages/ui/src/assets/file-icons/file_type_helix.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_helix.svg rename to packages/ui/src/assets/file-icons/file_type_helix.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_helm.svg b/packages/ui/src/assets/file-icons/file_type_helm.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_helm.svg rename to packages/ui/src/assets/file-icons/file_type_helm.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_hlsl.svg b/packages/ui/src/assets/file-icons/file_type_hlsl.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_hlsl.svg rename to packages/ui/src/assets/file-icons/file_type_hlsl.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_host.svg b/packages/ui/src/assets/file-icons/file_type_host.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_host.svg rename to packages/ui/src/assets/file-icons/file_type_host.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_html.svg b/packages/ui/src/assets/file-icons/file_type_html.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_html.svg rename to packages/ui/src/assets/file-icons/file_type_html.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_htmlhint.svg b/packages/ui/src/assets/file-icons/file_type_htmlhint.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_htmlhint.svg rename to packages/ui/src/assets/file-icons/file_type_htmlhint.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_http.svg b/packages/ui/src/assets/file-icons/file_type_http.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_http.svg rename to packages/ui/src/assets/file-icons/file_type_http.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_husky.svg b/packages/ui/src/assets/file-icons/file_type_husky.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_husky.svg rename to packages/ui/src/assets/file-icons/file_type_husky.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_idris.svg b/packages/ui/src/assets/file-icons/file_type_idris.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_idris.svg rename to packages/ui/src/assets/file-icons/file_type_idris.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_idrisbin.svg b/packages/ui/src/assets/file-icons/file_type_idrisbin.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_idrisbin.svg rename to packages/ui/src/assets/file-icons/file_type_idrisbin.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_idrispkg.svg b/packages/ui/src/assets/file-icons/file_type_idrispkg.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_idrispkg.svg rename to packages/ui/src/assets/file-icons/file_type_idrispkg.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_image.svg b/packages/ui/src/assets/file-icons/file_type_image.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_image.svg rename to packages/ui/src/assets/file-icons/file_type_image.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_infopath.svg b/packages/ui/src/assets/file-icons/file_type_infopath.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_infopath.svg rename to packages/ui/src/assets/file-icons/file_type_infopath.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ini.svg b/packages/ui/src/assets/file-icons/file_type_ini.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ini.svg rename to packages/ui/src/assets/file-icons/file_type_ini.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_io.svg b/packages/ui/src/assets/file-icons/file_type_io.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_io.svg rename to packages/ui/src/assets/file-icons/file_type_io.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_iodine.svg b/packages/ui/src/assets/file-icons/file_type_iodine.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_iodine.svg rename to packages/ui/src/assets/file-icons/file_type_iodine.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ionic.svg b/packages/ui/src/assets/file-icons/file_type_ionic.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ionic.svg rename to packages/ui/src/assets/file-icons/file_type_ionic.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jar.svg b/packages/ui/src/assets/file-icons/file_type_jar.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jar.svg rename to packages/ui/src/assets/file-icons/file_type_jar.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_java.svg b/packages/ui/src/assets/file-icons/file_type_java.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_java.svg rename to packages/ui/src/assets/file-icons/file_type_java.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jbuilder.svg b/packages/ui/src/assets/file-icons/file_type_jbuilder.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jbuilder.svg rename to packages/ui/src/assets/file-icons/file_type_jbuilder.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jekyll.svg b/packages/ui/src/assets/file-icons/file_type_jekyll.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jekyll.svg rename to packages/ui/src/assets/file-icons/file_type_jekyll.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jenkins.svg b/packages/ui/src/assets/file-icons/file_type_jenkins.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jenkins.svg rename to packages/ui/src/assets/file-icons/file_type_jenkins.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jest.svg b/packages/ui/src/assets/file-icons/file_type_jest.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jest.svg rename to packages/ui/src/assets/file-icons/file_type_jest.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jinja.svg b/packages/ui/src/assets/file-icons/file_type_jinja.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jinja.svg rename to packages/ui/src/assets/file-icons/file_type_jinja.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jpm.svg b/packages/ui/src/assets/file-icons/file_type_jpm.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jpm.svg rename to packages/ui/src/assets/file-icons/file_type_jpm.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_js.svg b/packages/ui/src/assets/file-icons/file_type_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_js.svg rename to packages/ui/src/assets/file-icons/file_type_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_js_official.svg b/packages/ui/src/assets/file-icons/file_type_js_official.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_js_official.svg rename to packages/ui/src/assets/file-icons/file_type_js_official.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jsbeautify.svg b/packages/ui/src/assets/file-icons/file_type_jsbeautify.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jsbeautify.svg rename to packages/ui/src/assets/file-icons/file_type_jsbeautify.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jsconfig.svg b/packages/ui/src/assets/file-icons/file_type_jsconfig.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jsconfig.svg rename to packages/ui/src/assets/file-icons/file_type_jsconfig.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jshint.svg b/packages/ui/src/assets/file-icons/file_type_jshint.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jshint.svg rename to packages/ui/src/assets/file-icons/file_type_jshint.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jsmap.svg b/packages/ui/src/assets/file-icons/file_type_jsmap.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jsmap.svg rename to packages/ui/src/assets/file-icons/file_type_jsmap.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_json.svg b/packages/ui/src/assets/file-icons/file_type_json.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_json.svg rename to packages/ui/src/assets/file-icons/file_type_json.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_json2.svg b/packages/ui/src/assets/file-icons/file_type_json2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_json2.svg rename to packages/ui/src/assets/file-icons/file_type_json2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_json5.svg b/packages/ui/src/assets/file-icons/file_type_json5.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_json5.svg rename to packages/ui/src/assets/file-icons/file_type_json5.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_json_official.svg b/packages/ui/src/assets/file-icons/file_type_json_official.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_json_official.svg rename to packages/ui/src/assets/file-icons/file_type_json_official.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jsonld.svg b/packages/ui/src/assets/file-icons/file_type_jsonld.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jsonld.svg rename to packages/ui/src/assets/file-icons/file_type_jsonld.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jsp.svg b/packages/ui/src/assets/file-icons/file_type_jsp.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jsp.svg rename to packages/ui/src/assets/file-icons/file_type_jsp.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_julia.svg b/packages/ui/src/assets/file-icons/file_type_julia.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_julia.svg rename to packages/ui/src/assets/file-icons/file_type_julia.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_julia2.svg b/packages/ui/src/assets/file-icons/file_type_julia2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_julia2.svg rename to packages/ui/src/assets/file-icons/file_type_julia2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_jupyter.svg b/packages/ui/src/assets/file-icons/file_type_jupyter.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_jupyter.svg rename to packages/ui/src/assets/file-icons/file_type_jupyter.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_karma.svg b/packages/ui/src/assets/file-icons/file_type_karma.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_karma.svg rename to packages/ui/src/assets/file-icons/file_type_karma.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_key.svg b/packages/ui/src/assets/file-icons/file_type_key.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_key.svg rename to packages/ui/src/assets/file-icons/file_type_key.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_kitchenci.svg b/packages/ui/src/assets/file-icons/file_type_kitchenci.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_kitchenci.svg rename to packages/ui/src/assets/file-icons/file_type_kitchenci.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_kite.svg b/packages/ui/src/assets/file-icons/file_type_kite.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_kite.svg rename to packages/ui/src/assets/file-icons/file_type_kite.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_kivy.svg b/packages/ui/src/assets/file-icons/file_type_kivy.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_kivy.svg rename to packages/ui/src/assets/file-icons/file_type_kivy.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_kos.svg b/packages/ui/src/assets/file-icons/file_type_kos.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_kos.svg rename to packages/ui/src/assets/file-icons/file_type_kos.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_kotlin.svg b/packages/ui/src/assets/file-icons/file_type_kotlin.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_kotlin.svg rename to packages/ui/src/assets/file-icons/file_type_kotlin.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_layout.svg b/packages/ui/src/assets/file-icons/file_type_layout.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_layout.svg rename to packages/ui/src/assets/file-icons/file_type_layout.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_lerna.svg b/packages/ui/src/assets/file-icons/file_type_lerna.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_lerna.svg rename to packages/ui/src/assets/file-icons/file_type_lerna.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_less.svg b/packages/ui/src/assets/file-icons/file_type_less.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_less.svg rename to packages/ui/src/assets/file-icons/file_type_less.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_license.svg b/packages/ui/src/assets/file-icons/file_type_license.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_license.svg rename to packages/ui/src/assets/file-icons/file_type_license.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_babel.svg b/packages/ui/src/assets/file-icons/file_type_light_babel.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_babel.svg rename to packages/ui/src/assets/file-icons/file_type_light_babel.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_babel2.svg b/packages/ui/src/assets/file-icons/file_type_light_babel2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_babel2.svg rename to packages/ui/src/assets/file-icons/file_type_light_babel2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_cabal.svg b/packages/ui/src/assets/file-icons/file_type_light_cabal.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_cabal.svg rename to packages/ui/src/assets/file-icons/file_type_light_cabal.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_circleci.svg b/packages/ui/src/assets/file-icons/file_type_light_circleci.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_circleci.svg rename to packages/ui/src/assets/file-icons/file_type_light_circleci.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_cloudfoundry.svg b/packages/ui/src/assets/file-icons/file_type_light_cloudfoundry.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_cloudfoundry.svg rename to packages/ui/src/assets/file-icons/file_type_light_cloudfoundry.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_codeclimate.svg b/packages/ui/src/assets/file-icons/file_type_light_codeclimate.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_codeclimate.svg rename to packages/ui/src/assets/file-icons/file_type_light_codeclimate.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_config.svg b/packages/ui/src/assets/file-icons/file_type_light_config.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_config.svg rename to packages/ui/src/assets/file-icons/file_type_light_config.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_db.svg b/packages/ui/src/assets/file-icons/file_type_light_db.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_db.svg rename to packages/ui/src/assets/file-icons/file_type_light_db.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_docpad.svg b/packages/ui/src/assets/file-icons/file_type_light_docpad.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_docpad.svg rename to packages/ui/src/assets/file-icons/file_type_light_docpad.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_drone.svg b/packages/ui/src/assets/file-icons/file_type_light_drone.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_drone.svg rename to packages/ui/src/assets/file-icons/file_type_light_drone.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_font.svg b/packages/ui/src/assets/file-icons/file_type_light_font.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_font.svg rename to packages/ui/src/assets/file-icons/file_type_light_font.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_gamemaker2.svg b/packages/ui/src/assets/file-icons/file_type_light_gamemaker2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_gamemaker2.svg rename to packages/ui/src/assets/file-icons/file_type_light_gamemaker2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_ini.svg b/packages/ui/src/assets/file-icons/file_type_light_ini.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_ini.svg rename to packages/ui/src/assets/file-icons/file_type_light_ini.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_io.svg b/packages/ui/src/assets/file-icons/file_type_light_io.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_io.svg rename to packages/ui/src/assets/file-icons/file_type_light_io.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_js.svg b/packages/ui/src/assets/file-icons/file_type_light_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_js.svg rename to packages/ui/src/assets/file-icons/file_type_light_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_jsconfig.svg b/packages/ui/src/assets/file-icons/file_type_light_jsconfig.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_jsconfig.svg rename to packages/ui/src/assets/file-icons/file_type_light_jsconfig.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_jsmap.svg b/packages/ui/src/assets/file-icons/file_type_light_jsmap.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_jsmap.svg rename to packages/ui/src/assets/file-icons/file_type_light_jsmap.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_json.svg b/packages/ui/src/assets/file-icons/file_type_light_json.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_json.svg rename to packages/ui/src/assets/file-icons/file_type_light_json.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_json5.svg b/packages/ui/src/assets/file-icons/file_type_light_json5.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_json5.svg rename to packages/ui/src/assets/file-icons/file_type_light_json5.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_jsonld.svg b/packages/ui/src/assets/file-icons/file_type_light_jsonld.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_jsonld.svg rename to packages/ui/src/assets/file-icons/file_type_light_jsonld.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_kite.svg b/packages/ui/src/assets/file-icons/file_type_light_kite.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_kite.svg rename to packages/ui/src/assets/file-icons/file_type_light_kite.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_lerna.svg b/packages/ui/src/assets/file-icons/file_type_light_lerna.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_lerna.svg rename to packages/ui/src/assets/file-icons/file_type_light_lerna.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_mlang.svg b/packages/ui/src/assets/file-icons/file_type_light_mlang.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_mlang.svg rename to packages/ui/src/assets/file-icons/file_type_light_mlang.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_mustache.svg b/packages/ui/src/assets/file-icons/file_type_light_mustache.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_mustache.svg rename to packages/ui/src/assets/file-icons/file_type_light_mustache.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_pcl.svg b/packages/ui/src/assets/file-icons/file_type_light_pcl.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_pcl.svg rename to packages/ui/src/assets/file-icons/file_type_light_pcl.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_prettier.svg b/packages/ui/src/assets/file-icons/file_type_light_prettier.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_prettier.svg rename to packages/ui/src/assets/file-icons/file_type_light_prettier.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_purescript.svg b/packages/ui/src/assets/file-icons/file_type_light_purescript.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_purescript.svg rename to packages/ui/src/assets/file-icons/file_type_light_purescript.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_rubocop.svg b/packages/ui/src/assets/file-icons/file_type_light_rubocop.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_rubocop.svg rename to packages/ui/src/assets/file-icons/file_type_light_rubocop.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_shaderlab.svg b/packages/ui/src/assets/file-icons/file_type_light_shaderlab.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_shaderlab.svg rename to packages/ui/src/assets/file-icons/file_type_light_shaderlab.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_solidity.svg b/packages/ui/src/assets/file-icons/file_type_light_solidity.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_solidity.svg rename to packages/ui/src/assets/file-icons/file_type_light_solidity.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_stylelint.svg b/packages/ui/src/assets/file-icons/file_type_light_stylelint.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_stylelint.svg rename to packages/ui/src/assets/file-icons/file_type_light_stylelint.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_stylus.svg b/packages/ui/src/assets/file-icons/file_type_light_stylus.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_stylus.svg rename to packages/ui/src/assets/file-icons/file_type_light_stylus.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_systemverilog.svg b/packages/ui/src/assets/file-icons/file_type_light_systemverilog.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_systemverilog.svg rename to packages/ui/src/assets/file-icons/file_type_light_systemverilog.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_testjs.svg b/packages/ui/src/assets/file-icons/file_type_light_testjs.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_testjs.svg rename to packages/ui/src/assets/file-icons/file_type_light_testjs.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_tex.svg b/packages/ui/src/assets/file-icons/file_type_light_tex.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_tex.svg rename to packages/ui/src/assets/file-icons/file_type_light_tex.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_todo.svg b/packages/ui/src/assets/file-icons/file_type_light_todo.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_todo.svg rename to packages/ui/src/assets/file-icons/file_type_light_todo.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_vash.svg b/packages/ui/src/assets/file-icons/file_type_light_vash.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_vash.svg rename to packages/ui/src/assets/file-icons/file_type_light_vash.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_vsix.svg b/packages/ui/src/assets/file-icons/file_type_light_vsix.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_vsix.svg rename to packages/ui/src/assets/file-icons/file_type_light_vsix.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_light_yaml.svg b/packages/ui/src/assets/file-icons/file_type_light_yaml.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_light_yaml.svg rename to packages/ui/src/assets/file-icons/file_type_light_yaml.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_lime.svg b/packages/ui/src/assets/file-icons/file_type_lime.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_lime.svg rename to packages/ui/src/assets/file-icons/file_type_lime.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_liquid.svg b/packages/ui/src/assets/file-icons/file_type_liquid.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_liquid.svg rename to packages/ui/src/assets/file-icons/file_type_liquid.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_lisp.svg b/packages/ui/src/assets/file-icons/file_type_lisp.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_lisp.svg rename to packages/ui/src/assets/file-icons/file_type_lisp.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_livescript.svg b/packages/ui/src/assets/file-icons/file_type_livescript.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_livescript.svg rename to packages/ui/src/assets/file-icons/file_type_livescript.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_locale.svg b/packages/ui/src/assets/file-icons/file_type_locale.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_locale.svg rename to packages/ui/src/assets/file-icons/file_type_locale.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_log.svg b/packages/ui/src/assets/file-icons/file_type_log.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_log.svg rename to packages/ui/src/assets/file-icons/file_type_log.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_lolcode.svg b/packages/ui/src/assets/file-icons/file_type_lolcode.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_lolcode.svg rename to packages/ui/src/assets/file-icons/file_type_lolcode.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_lsl.svg b/packages/ui/src/assets/file-icons/file_type_lsl.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_lsl.svg rename to packages/ui/src/assets/file-icons/file_type_lsl.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_lua.svg b/packages/ui/src/assets/file-icons/file_type_lua.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_lua.svg rename to packages/ui/src/assets/file-icons/file_type_lua.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_lync.svg b/packages/ui/src/assets/file-icons/file_type_lync.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_lync.svg rename to packages/ui/src/assets/file-icons/file_type_lync.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_manifest.svg b/packages/ui/src/assets/file-icons/file_type_manifest.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_manifest.svg rename to packages/ui/src/assets/file-icons/file_type_manifest.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_manifest_bak.svg b/packages/ui/src/assets/file-icons/file_type_manifest_bak.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_manifest_bak.svg rename to packages/ui/src/assets/file-icons/file_type_manifest_bak.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_manifest_skip.svg b/packages/ui/src/assets/file-icons/file_type_manifest_skip.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_manifest_skip.svg rename to packages/ui/src/assets/file-icons/file_type_manifest_skip.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_map.svg b/packages/ui/src/assets/file-icons/file_type_map.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_map.svg rename to packages/ui/src/assets/file-icons/file_type_map.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_markdown.svg b/packages/ui/src/assets/file-icons/file_type_markdown.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_markdown.svg rename to packages/ui/src/assets/file-icons/file_type_markdown.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_markdownlint.svg b/packages/ui/src/assets/file-icons/file_type_markdownlint.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_markdownlint.svg rename to packages/ui/src/assets/file-icons/file_type_markdownlint.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_marko.svg b/packages/ui/src/assets/file-icons/file_type_marko.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_marko.svg rename to packages/ui/src/assets/file-icons/file_type_marko.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_markojs.svg b/packages/ui/src/assets/file-icons/file_type_markojs.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_markojs.svg rename to packages/ui/src/assets/file-icons/file_type_markojs.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_maxscript.svg b/packages/ui/src/assets/file-icons/file_type_maxscript.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_maxscript.svg rename to packages/ui/src/assets/file-icons/file_type_maxscript.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_mdx.svg b/packages/ui/src/assets/file-icons/file_type_mdx.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_mdx.svg rename to packages/ui/src/assets/file-icons/file_type_mdx.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_mediawiki.svg b/packages/ui/src/assets/file-icons/file_type_mediawiki.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_mediawiki.svg rename to packages/ui/src/assets/file-icons/file_type_mediawiki.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_mercurial.svg b/packages/ui/src/assets/file-icons/file_type_mercurial.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_mercurial.svg rename to packages/ui/src/assets/file-icons/file_type_mercurial.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_meteor.svg b/packages/ui/src/assets/file-icons/file_type_meteor.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_meteor.svg rename to packages/ui/src/assets/file-icons/file_type_meteor.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_mjml.svg b/packages/ui/src/assets/file-icons/file_type_mjml.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_mjml.svg rename to packages/ui/src/assets/file-icons/file_type_mjml.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_mlang.svg b/packages/ui/src/assets/file-icons/file_type_mlang.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_mlang.svg rename to packages/ui/src/assets/file-icons/file_type_mlang.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_mocha.svg b/packages/ui/src/assets/file-icons/file_type_mocha.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_mocha.svg rename to packages/ui/src/assets/file-icons/file_type_mocha.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_mojolicious.svg b/packages/ui/src/assets/file-icons/file_type_mojolicious.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_mojolicious.svg rename to packages/ui/src/assets/file-icons/file_type_mojolicious.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_mongo.svg b/packages/ui/src/assets/file-icons/file_type_mongo.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_mongo.svg rename to packages/ui/src/assets/file-icons/file_type_mongo.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_monotone.svg b/packages/ui/src/assets/file-icons/file_type_monotone.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_monotone.svg rename to packages/ui/src/assets/file-icons/file_type_monotone.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_mson.svg b/packages/ui/src/assets/file-icons/file_type_mson.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_mson.svg rename to packages/ui/src/assets/file-icons/file_type_mson.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_mustache.svg b/packages/ui/src/assets/file-icons/file_type_mustache.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_mustache.svg rename to packages/ui/src/assets/file-icons/file_type_mustache.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_netlify.svg b/packages/ui/src/assets/file-icons/file_type_netlify.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_netlify.svg rename to packages/ui/src/assets/file-icons/file_type_netlify.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_next.svg b/packages/ui/src/assets/file-icons/file_type_next.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_next.svg rename to packages/ui/src/assets/file-icons/file_type_next.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_component_css.svg b/packages/ui/src/assets/file-icons/file_type_ng_component_css.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_component_css.svg rename to packages/ui/src/assets/file-icons/file_type_ng_component_css.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_component_html.svg b/packages/ui/src/assets/file-icons/file_type_ng_component_html.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_component_html.svg rename to packages/ui/src/assets/file-icons/file_type_ng_component_html.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_component_js.svg b/packages/ui/src/assets/file-icons/file_type_ng_component_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_component_js.svg rename to packages/ui/src/assets/file-icons/file_type_ng_component_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_component_js2.svg b/packages/ui/src/assets/file-icons/file_type_ng_component_js2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_component_js2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_component_js2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_component_less.svg b/packages/ui/src/assets/file-icons/file_type_ng_component_less.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_component_less.svg rename to packages/ui/src/assets/file-icons/file_type_ng_component_less.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_component_sass.svg b/packages/ui/src/assets/file-icons/file_type_ng_component_sass.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_component_sass.svg rename to packages/ui/src/assets/file-icons/file_type_ng_component_sass.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_component_scss.svg b/packages/ui/src/assets/file-icons/file_type_ng_component_scss.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_component_scss.svg rename to packages/ui/src/assets/file-icons/file_type_ng_component_scss.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_component_ts.svg b/packages/ui/src/assets/file-icons/file_type_ng_component_ts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_component_ts.svg rename to packages/ui/src/assets/file-icons/file_type_ng_component_ts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_component_ts2.svg b/packages/ui/src/assets/file-icons/file_type_ng_component_ts2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_component_ts2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_component_ts2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_controller_js.svg b/packages/ui/src/assets/file-icons/file_type_ng_controller_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_controller_js.svg rename to packages/ui/src/assets/file-icons/file_type_ng_controller_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_controller_ts.svg b/packages/ui/src/assets/file-icons/file_type_ng_controller_ts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_controller_ts.svg rename to packages/ui/src/assets/file-icons/file_type_ng_controller_ts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_directive_js.svg b/packages/ui/src/assets/file-icons/file_type_ng_directive_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_directive_js.svg rename to packages/ui/src/assets/file-icons/file_type_ng_directive_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_directive_js2.svg b/packages/ui/src/assets/file-icons/file_type_ng_directive_js2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_directive_js2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_directive_js2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_directive_ts.svg b/packages/ui/src/assets/file-icons/file_type_ng_directive_ts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_directive_ts.svg rename to packages/ui/src/assets/file-icons/file_type_ng_directive_ts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_directive_ts2.svg b/packages/ui/src/assets/file-icons/file_type_ng_directive_ts2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_directive_ts2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_directive_ts2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_guard_js.svg b/packages/ui/src/assets/file-icons/file_type_ng_guard_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_guard_js.svg rename to packages/ui/src/assets/file-icons/file_type_ng_guard_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_guard_ts.svg b/packages/ui/src/assets/file-icons/file_type_ng_guard_ts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_guard_ts.svg rename to packages/ui/src/assets/file-icons/file_type_ng_guard_ts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_interceptor_js.svg b/packages/ui/src/assets/file-icons/file_type_ng_interceptor_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_interceptor_js.svg rename to packages/ui/src/assets/file-icons/file_type_ng_interceptor_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_interceptor_ts.svg b/packages/ui/src/assets/file-icons/file_type_ng_interceptor_ts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_interceptor_ts.svg rename to packages/ui/src/assets/file-icons/file_type_ng_interceptor_ts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_module_js.svg b/packages/ui/src/assets/file-icons/file_type_ng_module_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_module_js.svg rename to packages/ui/src/assets/file-icons/file_type_ng_module_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_module_js2.svg b/packages/ui/src/assets/file-icons/file_type_ng_module_js2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_module_js2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_module_js2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_module_ts.svg b/packages/ui/src/assets/file-icons/file_type_ng_module_ts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_module_ts.svg rename to packages/ui/src/assets/file-icons/file_type_ng_module_ts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_module_ts2.svg b/packages/ui/src/assets/file-icons/file_type_ng_module_ts2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_module_ts2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_module_ts2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_pipe_js.svg b/packages/ui/src/assets/file-icons/file_type_ng_pipe_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_pipe_js.svg rename to packages/ui/src/assets/file-icons/file_type_ng_pipe_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_pipe_js2.svg b/packages/ui/src/assets/file-icons/file_type_ng_pipe_js2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_pipe_js2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_pipe_js2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_pipe_ts.svg b/packages/ui/src/assets/file-icons/file_type_ng_pipe_ts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_pipe_ts.svg rename to packages/ui/src/assets/file-icons/file_type_ng_pipe_ts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_pipe_ts2.svg b/packages/ui/src/assets/file-icons/file_type_ng_pipe_ts2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_pipe_ts2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_pipe_ts2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_routing_js.svg b/packages/ui/src/assets/file-icons/file_type_ng_routing_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_routing_js.svg rename to packages/ui/src/assets/file-icons/file_type_ng_routing_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_routing_js2.svg b/packages/ui/src/assets/file-icons/file_type_ng_routing_js2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_routing_js2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_routing_js2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_routing_ts.svg b/packages/ui/src/assets/file-icons/file_type_ng_routing_ts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_routing_ts.svg rename to packages/ui/src/assets/file-icons/file_type_ng_routing_ts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_routing_ts2.svg b/packages/ui/src/assets/file-icons/file_type_ng_routing_ts2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_routing_ts2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_routing_ts2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_service_js.svg b/packages/ui/src/assets/file-icons/file_type_ng_service_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_service_js.svg rename to packages/ui/src/assets/file-icons/file_type_ng_service_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_service_js2.svg b/packages/ui/src/assets/file-icons/file_type_ng_service_js2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_service_js2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_service_js2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_service_ts.svg b/packages/ui/src/assets/file-icons/file_type_ng_service_ts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_service_ts.svg rename to packages/ui/src/assets/file-icons/file_type_ng_service_ts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_service_ts2.svg b/packages/ui/src/assets/file-icons/file_type_ng_service_ts2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_service_ts2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_service_ts2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_smart_component_js.svg b/packages/ui/src/assets/file-icons/file_type_ng_smart_component_js.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_smart_component_js.svg rename to packages/ui/src/assets/file-icons/file_type_ng_smart_component_js.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_smart_component_js2.svg b/packages/ui/src/assets/file-icons/file_type_ng_smart_component_js2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_smart_component_js2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_smart_component_js2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_smart_component_ts.svg b/packages/ui/src/assets/file-icons/file_type_ng_smart_component_ts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_smart_component_ts.svg rename to packages/ui/src/assets/file-icons/file_type_ng_smart_component_ts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ng_smart_component_ts2.svg b/packages/ui/src/assets/file-icons/file_type_ng_smart_component_ts2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ng_smart_component_ts2.svg rename to packages/ui/src/assets/file-icons/file_type_ng_smart_component_ts2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_nginx.svg b/packages/ui/src/assets/file-icons/file_type_nginx.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_nginx.svg rename to packages/ui/src/assets/file-icons/file_type_nginx.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_nim.svg b/packages/ui/src/assets/file-icons/file_type_nim.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_nim.svg rename to packages/ui/src/assets/file-icons/file_type_nim.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_njsproj.svg b/packages/ui/src/assets/file-icons/file_type_njsproj.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_njsproj.svg rename to packages/ui/src/assets/file-icons/file_type_njsproj.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_node.svg b/packages/ui/src/assets/file-icons/file_type_node.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_node.svg rename to packages/ui/src/assets/file-icons/file_type_node.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_node2.svg b/packages/ui/src/assets/file-icons/file_type_node2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_node2.svg rename to packages/ui/src/assets/file-icons/file_type_node2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_nodemon.svg b/packages/ui/src/assets/file-icons/file_type_nodemon.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_nodemon.svg rename to packages/ui/src/assets/file-icons/file_type_nodemon.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_npm.svg b/packages/ui/src/assets/file-icons/file_type_npm.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_npm.svg rename to packages/ui/src/assets/file-icons/file_type_npm.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_nsi.svg b/packages/ui/src/assets/file-icons/file_type_nsi.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_nsi.svg rename to packages/ui/src/assets/file-icons/file_type_nsi.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_nuget.svg b/packages/ui/src/assets/file-icons/file_type_nuget.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_nuget.svg rename to packages/ui/src/assets/file-icons/file_type_nuget.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_nunjucks.svg b/packages/ui/src/assets/file-icons/file_type_nunjucks.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_nunjucks.svg rename to packages/ui/src/assets/file-icons/file_type_nunjucks.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_nuxt.svg b/packages/ui/src/assets/file-icons/file_type_nuxt.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_nuxt.svg rename to packages/ui/src/assets/file-icons/file_type_nuxt.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_nx.svg b/packages/ui/src/assets/file-icons/file_type_nx.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_nx.svg rename to packages/ui/src/assets/file-icons/file_type_nx.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_nyc.svg b/packages/ui/src/assets/file-icons/file_type_nyc.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_nyc.svg rename to packages/ui/src/assets/file-icons/file_type_nyc.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_objectivec.svg b/packages/ui/src/assets/file-icons/file_type_objectivec.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_objectivec.svg rename to packages/ui/src/assets/file-icons/file_type_objectivec.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_objectivecpp.svg b/packages/ui/src/assets/file-icons/file_type_objectivecpp.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_objectivecpp.svg rename to packages/ui/src/assets/file-icons/file_type_objectivecpp.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ocaml.svg b/packages/ui/src/assets/file-icons/file_type_ocaml.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ocaml.svg rename to packages/ui/src/assets/file-icons/file_type_ocaml.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_onenote.svg b/packages/ui/src/assets/file-icons/file_type_onenote.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_onenote.svg rename to packages/ui/src/assets/file-icons/file_type_onenote.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_opencl.svg b/packages/ui/src/assets/file-icons/file_type_opencl.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_opencl.svg rename to packages/ui/src/assets/file-icons/file_type_opencl.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_org.svg b/packages/ui/src/assets/file-icons/file_type_org.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_org.svg rename to packages/ui/src/assets/file-icons/file_type_org.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_outlook.svg b/packages/ui/src/assets/file-icons/file_type_outlook.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_outlook.svg rename to packages/ui/src/assets/file-icons/file_type_outlook.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_package.svg b/packages/ui/src/assets/file-icons/file_type_package.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_package.svg rename to packages/ui/src/assets/file-icons/file_type_package.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_paket.svg b/packages/ui/src/assets/file-icons/file_type_paket.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_paket.svg rename to packages/ui/src/assets/file-icons/file_type_paket.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_patch.svg b/packages/ui/src/assets/file-icons/file_type_patch.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_patch.svg rename to packages/ui/src/assets/file-icons/file_type_patch.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_pcl.svg b/packages/ui/src/assets/file-icons/file_type_pcl.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_pcl.svg rename to packages/ui/src/assets/file-icons/file_type_pcl.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_pdf.svg b/packages/ui/src/assets/file-icons/file_type_pdf.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_pdf.svg rename to packages/ui/src/assets/file-icons/file_type_pdf.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_pdf2.svg b/packages/ui/src/assets/file-icons/file_type_pdf2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_pdf2.svg rename to packages/ui/src/assets/file-icons/file_type_pdf2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_perl.svg b/packages/ui/src/assets/file-icons/file_type_perl.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_perl.svg rename to packages/ui/src/assets/file-icons/file_type_perl.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_perl2.svg b/packages/ui/src/assets/file-icons/file_type_perl2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_perl2.svg rename to packages/ui/src/assets/file-icons/file_type_perl2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_perl6.svg b/packages/ui/src/assets/file-icons/file_type_perl6.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_perl6.svg rename to packages/ui/src/assets/file-icons/file_type_perl6.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_photoshop.svg b/packages/ui/src/assets/file-icons/file_type_photoshop.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_photoshop.svg rename to packages/ui/src/assets/file-icons/file_type_photoshop.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_photoshop2.svg b/packages/ui/src/assets/file-icons/file_type_photoshop2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_photoshop2.svg rename to packages/ui/src/assets/file-icons/file_type_photoshop2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_php.svg b/packages/ui/src/assets/file-icons/file_type_php.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_php.svg rename to packages/ui/src/assets/file-icons/file_type_php.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_php2.svg b/packages/ui/src/assets/file-icons/file_type_php2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_php2.svg rename to packages/ui/src/assets/file-icons/file_type_php2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_php3.svg b/packages/ui/src/assets/file-icons/file_type_php3.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_php3.svg rename to packages/ui/src/assets/file-icons/file_type_php3.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_phpunit.svg b/packages/ui/src/assets/file-icons/file_type_phpunit.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_phpunit.svg rename to packages/ui/src/assets/file-icons/file_type_phpunit.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_phraseapp.svg b/packages/ui/src/assets/file-icons/file_type_phraseapp.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_phraseapp.svg rename to packages/ui/src/assets/file-icons/file_type_phraseapp.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_pip.svg b/packages/ui/src/assets/file-icons/file_type_pip.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_pip.svg rename to packages/ui/src/assets/file-icons/file_type_pip.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_plantuml.svg b/packages/ui/src/assets/file-icons/file_type_plantuml.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_plantuml.svg rename to packages/ui/src/assets/file-icons/file_type_plantuml.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_playwright.svg b/packages/ui/src/assets/file-icons/file_type_playwright.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_playwright.svg rename to packages/ui/src/assets/file-icons/file_type_playwright.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_plsql.svg b/packages/ui/src/assets/file-icons/file_type_plsql.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_plsql.svg rename to packages/ui/src/assets/file-icons/file_type_plsql.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_plsql_package.svg b/packages/ui/src/assets/file-icons/file_type_plsql_package.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_plsql_package.svg rename to packages/ui/src/assets/file-icons/file_type_plsql_package.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_plsql_package_body.svg b/packages/ui/src/assets/file-icons/file_type_plsql_package_body.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_plsql_package_body.svg rename to packages/ui/src/assets/file-icons/file_type_plsql_package_body.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_plsql_package_header.svg b/packages/ui/src/assets/file-icons/file_type_plsql_package_header.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_plsql_package_header.svg rename to packages/ui/src/assets/file-icons/file_type_plsql_package_header.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_plsql_package_spec.svg b/packages/ui/src/assets/file-icons/file_type_plsql_package_spec.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_plsql_package_spec.svg rename to packages/ui/src/assets/file-icons/file_type_plsql_package_spec.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_pnpm.svg b/packages/ui/src/assets/file-icons/file_type_pnpm.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_pnpm.svg rename to packages/ui/src/assets/file-icons/file_type_pnpm.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_poedit.svg b/packages/ui/src/assets/file-icons/file_type_poedit.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_poedit.svg rename to packages/ui/src/assets/file-icons/file_type_poedit.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_polymer.svg b/packages/ui/src/assets/file-icons/file_type_polymer.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_polymer.svg rename to packages/ui/src/assets/file-icons/file_type_polymer.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_postcss.svg b/packages/ui/src/assets/file-icons/file_type_postcss.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_postcss.svg rename to packages/ui/src/assets/file-icons/file_type_postcss.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_powerpoint.svg b/packages/ui/src/assets/file-icons/file_type_powerpoint.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_powerpoint.svg rename to packages/ui/src/assets/file-icons/file_type_powerpoint.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_powershell.svg b/packages/ui/src/assets/file-icons/file_type_powershell.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_powershell.svg rename to packages/ui/src/assets/file-icons/file_type_powershell.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_prettier.svg b/packages/ui/src/assets/file-icons/file_type_prettier.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_prettier.svg rename to packages/ui/src/assets/file-icons/file_type_prettier.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_prisma.svg b/packages/ui/src/assets/file-icons/file_type_prisma.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_prisma.svg rename to packages/ui/src/assets/file-icons/file_type_prisma.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_processinglang.svg b/packages/ui/src/assets/file-icons/file_type_processinglang.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_processinglang.svg rename to packages/ui/src/assets/file-icons/file_type_processinglang.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_procfile.svg b/packages/ui/src/assets/file-icons/file_type_procfile.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_procfile.svg rename to packages/ui/src/assets/file-icons/file_type_procfile.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_progress.svg b/packages/ui/src/assets/file-icons/file_type_progress.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_progress.svg rename to packages/ui/src/assets/file-icons/file_type_progress.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_prolog.svg b/packages/ui/src/assets/file-icons/file_type_prolog.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_prolog.svg rename to packages/ui/src/assets/file-icons/file_type_prolog.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_prometheus.svg b/packages/ui/src/assets/file-icons/file_type_prometheus.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_prometheus.svg rename to packages/ui/src/assets/file-icons/file_type_prometheus.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_protobuf.svg b/packages/ui/src/assets/file-icons/file_type_protobuf.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_protobuf.svg rename to packages/ui/src/assets/file-icons/file_type_protobuf.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_protractor.svg b/packages/ui/src/assets/file-icons/file_type_protractor.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_protractor.svg rename to packages/ui/src/assets/file-icons/file_type_protractor.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_publisher.svg b/packages/ui/src/assets/file-icons/file_type_publisher.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_publisher.svg rename to packages/ui/src/assets/file-icons/file_type_publisher.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_pug.svg b/packages/ui/src/assets/file-icons/file_type_pug.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_pug.svg rename to packages/ui/src/assets/file-icons/file_type_pug.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_puppet.svg b/packages/ui/src/assets/file-icons/file_type_puppet.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_puppet.svg rename to packages/ui/src/assets/file-icons/file_type_puppet.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_purescript.svg b/packages/ui/src/assets/file-icons/file_type_purescript.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_purescript.svg rename to packages/ui/src/assets/file-icons/file_type_purescript.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_python.svg b/packages/ui/src/assets/file-icons/file_type_python.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_python.svg rename to packages/ui/src/assets/file-icons/file_type_python.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_q.svg b/packages/ui/src/assets/file-icons/file_type_q.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_q.svg rename to packages/ui/src/assets/file-icons/file_type_q.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_qlikview.svg b/packages/ui/src/assets/file-icons/file_type_qlikview.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_qlikview.svg rename to packages/ui/src/assets/file-icons/file_type_qlikview.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_r.svg b/packages/ui/src/assets/file-icons/file_type_r.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_r.svg rename to packages/ui/src/assets/file-icons/file_type_r.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_racket.svg b/packages/ui/src/assets/file-icons/file_type_racket.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_racket.svg rename to packages/ui/src/assets/file-icons/file_type_racket.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_rails.svg b/packages/ui/src/assets/file-icons/file_type_rails.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_rails.svg rename to packages/ui/src/assets/file-icons/file_type_rails.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_rake.svg b/packages/ui/src/assets/file-icons/file_type_rake.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_rake.svg rename to packages/ui/src/assets/file-icons/file_type_rake.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_raml.svg b/packages/ui/src/assets/file-icons/file_type_raml.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_raml.svg rename to packages/ui/src/assets/file-icons/file_type_raml.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_razor.svg b/packages/ui/src/assets/file-icons/file_type_razor.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_razor.svg rename to packages/ui/src/assets/file-icons/file_type_razor.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_reactjs.svg b/packages/ui/src/assets/file-icons/file_type_reactjs.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_reactjs.svg rename to packages/ui/src/assets/file-icons/file_type_reactjs.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_reacttemplate.svg b/packages/ui/src/assets/file-icons/file_type_reacttemplate.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_reacttemplate.svg rename to packages/ui/src/assets/file-icons/file_type_reacttemplate.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_reactts.svg b/packages/ui/src/assets/file-icons/file_type_reactts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_reactts.svg rename to packages/ui/src/assets/file-icons/file_type_reactts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_reason.svg b/packages/ui/src/assets/file-icons/file_type_reason.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_reason.svg rename to packages/ui/src/assets/file-icons/file_type_reason.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_registry.svg b/packages/ui/src/assets/file-icons/file_type_registry.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_registry.svg rename to packages/ui/src/assets/file-icons/file_type_registry.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_rest.svg b/packages/ui/src/assets/file-icons/file_type_rest.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_rest.svg rename to packages/ui/src/assets/file-icons/file_type_rest.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_riot.svg b/packages/ui/src/assets/file-icons/file_type_riot.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_riot.svg rename to packages/ui/src/assets/file-icons/file_type_riot.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_robotframework.svg b/packages/ui/src/assets/file-icons/file_type_robotframework.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_robotframework.svg rename to packages/ui/src/assets/file-icons/file_type_robotframework.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_robots.svg b/packages/ui/src/assets/file-icons/file_type_robots.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_robots.svg rename to packages/ui/src/assets/file-icons/file_type_robots.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_rollup.svg b/packages/ui/src/assets/file-icons/file_type_rollup.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_rollup.svg rename to packages/ui/src/assets/file-icons/file_type_rollup.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_rspec.svg b/packages/ui/src/assets/file-icons/file_type_rspec.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_rspec.svg rename to packages/ui/src/assets/file-icons/file_type_rspec.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_rubocop.svg b/packages/ui/src/assets/file-icons/file_type_rubocop.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_rubocop.svg rename to packages/ui/src/assets/file-icons/file_type_rubocop.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_ruby.svg b/packages/ui/src/assets/file-icons/file_type_ruby.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_ruby.svg rename to packages/ui/src/assets/file-icons/file_type_ruby.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_rust.svg b/packages/ui/src/assets/file-icons/file_type_rust.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_rust.svg rename to packages/ui/src/assets/file-icons/file_type_rust.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_saltstack.svg b/packages/ui/src/assets/file-icons/file_type_saltstack.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_saltstack.svg rename to packages/ui/src/assets/file-icons/file_type_saltstack.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_sass.svg b/packages/ui/src/assets/file-icons/file_type_sass.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_sass.svg rename to packages/ui/src/assets/file-icons/file_type_sass.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_sbt.svg b/packages/ui/src/assets/file-icons/file_type_sbt.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_sbt.svg rename to packages/ui/src/assets/file-icons/file_type_sbt.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_scala.svg b/packages/ui/src/assets/file-icons/file_type_scala.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_scala.svg rename to packages/ui/src/assets/file-icons/file_type_scala.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_scilab.svg b/packages/ui/src/assets/file-icons/file_type_scilab.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_scilab.svg rename to packages/ui/src/assets/file-icons/file_type_scilab.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_script.svg b/packages/ui/src/assets/file-icons/file_type_script.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_script.svg rename to packages/ui/src/assets/file-icons/file_type_script.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_scss.svg b/packages/ui/src/assets/file-icons/file_type_scss.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_scss.svg rename to packages/ui/src/assets/file-icons/file_type_scss.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_scss2.svg b/packages/ui/src/assets/file-icons/file_type_scss2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_scss2.svg rename to packages/ui/src/assets/file-icons/file_type_scss2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_sdlang.svg b/packages/ui/src/assets/file-icons/file_type_sdlang.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_sdlang.svg rename to packages/ui/src/assets/file-icons/file_type_sdlang.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_sequelize.svg b/packages/ui/src/assets/file-icons/file_type_sequelize.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_sequelize.svg rename to packages/ui/src/assets/file-icons/file_type_sequelize.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_shaderlab.svg b/packages/ui/src/assets/file-icons/file_type_shaderlab.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_shaderlab.svg rename to packages/ui/src/assets/file-icons/file_type_shaderlab.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_shell.svg b/packages/ui/src/assets/file-icons/file_type_shell.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_shell.svg rename to packages/ui/src/assets/file-icons/file_type_shell.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_silverstripe.svg b/packages/ui/src/assets/file-icons/file_type_silverstripe.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_silverstripe.svg rename to packages/ui/src/assets/file-icons/file_type_silverstripe.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_sketch.svg b/packages/ui/src/assets/file-icons/file_type_sketch.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_sketch.svg rename to packages/ui/src/assets/file-icons/file_type_sketch.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_skipper.svg b/packages/ui/src/assets/file-icons/file_type_skipper.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_skipper.svg rename to packages/ui/src/assets/file-icons/file_type_skipper.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_slice.svg b/packages/ui/src/assets/file-icons/file_type_slice.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_slice.svg rename to packages/ui/src/assets/file-icons/file_type_slice.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_slim.svg b/packages/ui/src/assets/file-icons/file_type_slim.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_slim.svg rename to packages/ui/src/assets/file-icons/file_type_slim.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_sln.svg b/packages/ui/src/assets/file-icons/file_type_sln.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_sln.svg rename to packages/ui/src/assets/file-icons/file_type_sln.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_smarty.svg b/packages/ui/src/assets/file-icons/file_type_smarty.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_smarty.svg rename to packages/ui/src/assets/file-icons/file_type_smarty.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_snort.svg b/packages/ui/src/assets/file-icons/file_type_snort.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_snort.svg rename to packages/ui/src/assets/file-icons/file_type_snort.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_snyk.svg b/packages/ui/src/assets/file-icons/file_type_snyk.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_snyk.svg rename to packages/ui/src/assets/file-icons/file_type_snyk.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_solidarity.svg b/packages/ui/src/assets/file-icons/file_type_solidarity.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_solidarity.svg rename to packages/ui/src/assets/file-icons/file_type_solidarity.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_solidity.svg b/packages/ui/src/assets/file-icons/file_type_solidity.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_solidity.svg rename to packages/ui/src/assets/file-icons/file_type_solidity.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_source.svg b/packages/ui/src/assets/file-icons/file_type_source.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_source.svg rename to packages/ui/src/assets/file-icons/file_type_source.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_sqf.svg b/packages/ui/src/assets/file-icons/file_type_sqf.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_sqf.svg rename to packages/ui/src/assets/file-icons/file_type_sqf.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_sql.svg b/packages/ui/src/assets/file-icons/file_type_sql.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_sql.svg rename to packages/ui/src/assets/file-icons/file_type_sql.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_sqlite.svg b/packages/ui/src/assets/file-icons/file_type_sqlite.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_sqlite.svg rename to packages/ui/src/assets/file-icons/file_type_sqlite.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_squirrel.svg b/packages/ui/src/assets/file-icons/file_type_squirrel.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_squirrel.svg rename to packages/ui/src/assets/file-icons/file_type_squirrel.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_sss.svg b/packages/ui/src/assets/file-icons/file_type_sss.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_sss.svg rename to packages/ui/src/assets/file-icons/file_type_sss.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_stata.svg b/packages/ui/src/assets/file-icons/file_type_stata.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_stata.svg rename to packages/ui/src/assets/file-icons/file_type_stata.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_storyboard.svg b/packages/ui/src/assets/file-icons/file_type_storyboard.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_storyboard.svg rename to packages/ui/src/assets/file-icons/file_type_storyboard.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_storybook.svg b/packages/ui/src/assets/file-icons/file_type_storybook.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_storybook.svg rename to packages/ui/src/assets/file-icons/file_type_storybook.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_stylable.svg b/packages/ui/src/assets/file-icons/file_type_stylable.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_stylable.svg rename to packages/ui/src/assets/file-icons/file_type_stylable.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_style.svg b/packages/ui/src/assets/file-icons/file_type_style.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_style.svg rename to packages/ui/src/assets/file-icons/file_type_style.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_stylelint.svg b/packages/ui/src/assets/file-icons/file_type_stylelint.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_stylelint.svg rename to packages/ui/src/assets/file-icons/file_type_stylelint.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_stylus.svg b/packages/ui/src/assets/file-icons/file_type_stylus.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_stylus.svg rename to packages/ui/src/assets/file-icons/file_type_stylus.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_subversion.svg b/packages/ui/src/assets/file-icons/file_type_subversion.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_subversion.svg rename to packages/ui/src/assets/file-icons/file_type_subversion.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_svelte.svg b/packages/ui/src/assets/file-icons/file_type_svelte.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_svelte.svg rename to packages/ui/src/assets/file-icons/file_type_svelte.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_svg.svg b/packages/ui/src/assets/file-icons/file_type_svg.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_svg.svg rename to packages/ui/src/assets/file-icons/file_type_svg.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_swagger.svg b/packages/ui/src/assets/file-icons/file_type_swagger.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_swagger.svg rename to packages/ui/src/assets/file-icons/file_type_swagger.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_swift.svg b/packages/ui/src/assets/file-icons/file_type_swift.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_swift.svg rename to packages/ui/src/assets/file-icons/file_type_swift.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_systemverilog.svg b/packages/ui/src/assets/file-icons/file_type_systemverilog.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_systemverilog.svg rename to packages/ui/src/assets/file-icons/file_type_systemverilog.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_tailwind.svg b/packages/ui/src/assets/file-icons/file_type_tailwind.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_tailwind.svg rename to packages/ui/src/assets/file-icons/file_type_tailwind.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_tcl.svg b/packages/ui/src/assets/file-icons/file_type_tcl.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_tcl.svg rename to packages/ui/src/assets/file-icons/file_type_tcl.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_terraform.svg b/packages/ui/src/assets/file-icons/file_type_terraform.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_terraform.svg rename to packages/ui/src/assets/file-icons/file_type_terraform.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_test.svg b/packages/ui/src/assets/file-icons/file_type_test.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_test.svg rename to packages/ui/src/assets/file-icons/file_type_test.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_testjs.svg b/packages/ui/src/assets/file-icons/file_type_testjs.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_testjs.svg rename to packages/ui/src/assets/file-icons/file_type_testjs.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_testts.svg b/packages/ui/src/assets/file-icons/file_type_testts.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_testts.svg rename to packages/ui/src/assets/file-icons/file_type_testts.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_tex.svg b/packages/ui/src/assets/file-icons/file_type_tex.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_tex.svg rename to packages/ui/src/assets/file-icons/file_type_tex.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_text.svg b/packages/ui/src/assets/file-icons/file_type_text.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_text.svg rename to packages/ui/src/assets/file-icons/file_type_text.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_textile.svg b/packages/ui/src/assets/file-icons/file_type_textile.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_textile.svg rename to packages/ui/src/assets/file-icons/file_type_textile.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_tfs.svg b/packages/ui/src/assets/file-icons/file_type_tfs.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_tfs.svg rename to packages/ui/src/assets/file-icons/file_type_tfs.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_todo.svg b/packages/ui/src/assets/file-icons/file_type_todo.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_todo.svg rename to packages/ui/src/assets/file-icons/file_type_todo.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_toml.svg b/packages/ui/src/assets/file-icons/file_type_toml.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_toml.svg rename to packages/ui/src/assets/file-icons/file_type_toml.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_travis.svg b/packages/ui/src/assets/file-icons/file_type_travis.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_travis.svg rename to packages/ui/src/assets/file-icons/file_type_travis.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_tsconfig.svg b/packages/ui/src/assets/file-icons/file_type_tsconfig.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_tsconfig.svg rename to packages/ui/src/assets/file-icons/file_type_tsconfig.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_tslint.svg b/packages/ui/src/assets/file-icons/file_type_tslint.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_tslint.svg rename to packages/ui/src/assets/file-icons/file_type_tslint.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_turbo.svg b/packages/ui/src/assets/file-icons/file_type_turbo.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_turbo.svg rename to packages/ui/src/assets/file-icons/file_type_turbo.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_twig.svg b/packages/ui/src/assets/file-icons/file_type_twig.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_twig.svg rename to packages/ui/src/assets/file-icons/file_type_twig.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_typescript.svg b/packages/ui/src/assets/file-icons/file_type_typescript.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_typescript.svg rename to packages/ui/src/assets/file-icons/file_type_typescript.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_typescript_official.svg b/packages/ui/src/assets/file-icons/file_type_typescript_official.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_typescript_official.svg rename to packages/ui/src/assets/file-icons/file_type_typescript_official.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_typescriptdef.svg b/packages/ui/src/assets/file-icons/file_type_typescriptdef.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_typescriptdef.svg rename to packages/ui/src/assets/file-icons/file_type_typescriptdef.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_typescriptdef_official.svg b/packages/ui/src/assets/file-icons/file_type_typescriptdef_official.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_typescriptdef_official.svg rename to packages/ui/src/assets/file-icons/file_type_typescriptdef_official.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vagrant.svg b/packages/ui/src/assets/file-icons/file_type_vagrant.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vagrant.svg rename to packages/ui/src/assets/file-icons/file_type_vagrant.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vash.svg b/packages/ui/src/assets/file-icons/file_type_vash.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vash.svg rename to packages/ui/src/assets/file-icons/file_type_vash.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vb.svg b/packages/ui/src/assets/file-icons/file_type_vb.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vb.svg rename to packages/ui/src/assets/file-icons/file_type_vb.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vba.svg b/packages/ui/src/assets/file-icons/file_type_vba.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vba.svg rename to packages/ui/src/assets/file-icons/file_type_vba.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vbhtml.svg b/packages/ui/src/assets/file-icons/file_type_vbhtml.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vbhtml.svg rename to packages/ui/src/assets/file-icons/file_type_vbhtml.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vbproj.svg b/packages/ui/src/assets/file-icons/file_type_vbproj.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vbproj.svg rename to packages/ui/src/assets/file-icons/file_type_vbproj.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vcxproj.svg b/packages/ui/src/assets/file-icons/file_type_vcxproj.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vcxproj.svg rename to packages/ui/src/assets/file-icons/file_type_vcxproj.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_velocity.svg b/packages/ui/src/assets/file-icons/file_type_velocity.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_velocity.svg rename to packages/ui/src/assets/file-icons/file_type_velocity.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vercel.svg b/packages/ui/src/assets/file-icons/file_type_vercel.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vercel.svg rename to packages/ui/src/assets/file-icons/file_type_vercel.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_verilog.svg b/packages/ui/src/assets/file-icons/file_type_verilog.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_verilog.svg rename to packages/ui/src/assets/file-icons/file_type_verilog.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vhdl.svg b/packages/ui/src/assets/file-icons/file_type_vhdl.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vhdl.svg rename to packages/ui/src/assets/file-icons/file_type_vhdl.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_video.svg b/packages/ui/src/assets/file-icons/file_type_video.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_video.svg rename to packages/ui/src/assets/file-icons/file_type_video.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_view.svg b/packages/ui/src/assets/file-icons/file_type_view.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_view.svg rename to packages/ui/src/assets/file-icons/file_type_view.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vim.svg b/packages/ui/src/assets/file-icons/file_type_vim.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vim.svg rename to packages/ui/src/assets/file-icons/file_type_vim.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vite.svg b/packages/ui/src/assets/file-icons/file_type_vite.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vite.svg rename to packages/ui/src/assets/file-icons/file_type_vite.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vitest.svg b/packages/ui/src/assets/file-icons/file_type_vitest.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vitest.svg rename to packages/ui/src/assets/file-icons/file_type_vitest.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_volt.svg b/packages/ui/src/assets/file-icons/file_type_volt.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_volt.svg rename to packages/ui/src/assets/file-icons/file_type_volt.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vscode.svg b/packages/ui/src/assets/file-icons/file_type_vscode.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vscode.svg rename to packages/ui/src/assets/file-icons/file_type_vscode.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vscode2.svg b/packages/ui/src/assets/file-icons/file_type_vscode2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vscode2.svg rename to packages/ui/src/assets/file-icons/file_type_vscode2.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vsix.svg b/packages/ui/src/assets/file-icons/file_type_vsix.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vsix.svg rename to packages/ui/src/assets/file-icons/file_type_vsix.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_vue.svg b/packages/ui/src/assets/file-icons/file_type_vue.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_vue.svg rename to packages/ui/src/assets/file-icons/file_type_vue.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_wasm.svg b/packages/ui/src/assets/file-icons/file_type_wasm.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_wasm.svg rename to packages/ui/src/assets/file-icons/file_type_wasm.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_watchmanconfig.svg b/packages/ui/src/assets/file-icons/file_type_watchmanconfig.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_watchmanconfig.svg rename to packages/ui/src/assets/file-icons/file_type_watchmanconfig.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_webpack.svg b/packages/ui/src/assets/file-icons/file_type_webpack.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_webpack.svg rename to packages/ui/src/assets/file-icons/file_type_webpack.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_wercker.svg b/packages/ui/src/assets/file-icons/file_type_wercker.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_wercker.svg rename to packages/ui/src/assets/file-icons/file_type_wercker.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_wolfram.svg b/packages/ui/src/assets/file-icons/file_type_wolfram.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_wolfram.svg rename to packages/ui/src/assets/file-icons/file_type_wolfram.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_word.svg b/packages/ui/src/assets/file-icons/file_type_word.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_word.svg rename to packages/ui/src/assets/file-icons/file_type_word.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_wxml.svg b/packages/ui/src/assets/file-icons/file_type_wxml.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_wxml.svg rename to packages/ui/src/assets/file-icons/file_type_wxml.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_wxss.svg b/packages/ui/src/assets/file-icons/file_type_wxss.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_wxss.svg rename to packages/ui/src/assets/file-icons/file_type_wxss.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_xcode.svg b/packages/ui/src/assets/file-icons/file_type_xcode.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_xcode.svg rename to packages/ui/src/assets/file-icons/file_type_xcode.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_xib.svg b/packages/ui/src/assets/file-icons/file_type_xib.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_xib.svg rename to packages/ui/src/assets/file-icons/file_type_xib.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_xliff.svg b/packages/ui/src/assets/file-icons/file_type_xliff.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_xliff.svg rename to packages/ui/src/assets/file-icons/file_type_xliff.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_xml.svg b/packages/ui/src/assets/file-icons/file_type_xml.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_xml.svg rename to packages/ui/src/assets/file-icons/file_type_xml.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_xsl.svg b/packages/ui/src/assets/file-icons/file_type_xsl.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_xsl.svg rename to packages/ui/src/assets/file-icons/file_type_xsl.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_yaml.svg b/packages/ui/src/assets/file-icons/file_type_yaml.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_yaml.svg rename to packages/ui/src/assets/file-icons/file_type_yaml.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_yang.svg b/packages/ui/src/assets/file-icons/file_type_yang.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_yang.svg rename to packages/ui/src/assets/file-icons/file_type_yang.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_yarn.svg b/packages/ui/src/assets/file-icons/file_type_yarn.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_yarn.svg rename to packages/ui/src/assets/file-icons/file_type_yarn.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_yeoman.svg b/packages/ui/src/assets/file-icons/file_type_yeoman.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_yeoman.svg rename to packages/ui/src/assets/file-icons/file_type_yeoman.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_zig.svg b/packages/ui/src/assets/file-icons/file_type_zig.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_zig.svg rename to packages/ui/src/assets/file-icons/file_type_zig.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_zip.svg b/packages/ui/src/assets/file-icons/file_type_zip.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_zip.svg rename to packages/ui/src/assets/file-icons/file_type_zip.svg diff --git a/apps/code/src/renderer/assets/file-icons/file_type_zip2.svg b/packages/ui/src/assets/file-icons/file_type_zip2.svg similarity index 100% rename from apps/code/src/renderer/assets/file-icons/file_type_zip2.svg rename to packages/ui/src/assets/file-icons/file_type_zip2.svg diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Bold.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Bold.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Bold.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Bold.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-BoldItalic.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-BoldItalic.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-BoldItalic.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-BoldItalic.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraBold.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraBold.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraBold.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraBold.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraBoldItalic.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraBoldItalic.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraBoldItalic.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraBoldItalic.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraLight.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraLight.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraLight.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraLight.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraLightItalic.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraLightItalic.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraLightItalic.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-ExtraLightItalic.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Italic.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Italic.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Italic.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Italic.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Light.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Light.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Light.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Light.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-LightItalic.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-LightItalic.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-LightItalic.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-LightItalic.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Medium.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Medium.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Medium.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Medium.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-MediumItalic.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-MediumItalic.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-MediumItalic.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-MediumItalic.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Regular.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Regular.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Regular.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Regular.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBold.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBold.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBold.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBold.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBoldItalic.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBoldItalic.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBoldItalic.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBoldItalic.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Thin.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Thin.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-Thin.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-Thin.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-ThinItalic.woff2 b/packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-ThinItalic.woff2 similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/JetBrainsMono-ThinItalic.woff2 rename to packages/ui/src/assets/fonts/JetBrainsMono/JetBrainsMono-ThinItalic.woff2 diff --git a/apps/code/assets/fonts/JetBrainsMono/OFL.txt b/packages/ui/src/assets/fonts/JetBrainsMono/OFL.txt similarity index 100% rename from apps/code/assets/fonts/JetBrainsMono/OFL.txt rename to packages/ui/src/assets/fonts/JetBrainsMono/OFL.txt diff --git a/apps/code/assets/fonts/OpenRunde/OpenRunde-Bold.woff b/packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Bold.woff similarity index 100% rename from apps/code/assets/fonts/OpenRunde/OpenRunde-Bold.woff rename to packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Bold.woff diff --git a/apps/code/assets/fonts/OpenRunde/OpenRunde-Bold.woff2 b/packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Bold.woff2 similarity index 100% rename from apps/code/assets/fonts/OpenRunde/OpenRunde-Bold.woff2 rename to packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Bold.woff2 diff --git a/apps/code/assets/fonts/OpenRunde/OpenRunde-Medium.woff b/packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Medium.woff similarity index 100% rename from apps/code/assets/fonts/OpenRunde/OpenRunde-Medium.woff rename to packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Medium.woff diff --git a/apps/code/assets/fonts/OpenRunde/OpenRunde-Medium.woff2 b/packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Medium.woff2 similarity index 100% rename from apps/code/assets/fonts/OpenRunde/OpenRunde-Medium.woff2 rename to packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Medium.woff2 diff --git a/apps/code/assets/fonts/OpenRunde/OpenRunde-Regular.woff b/packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Regular.woff similarity index 100% rename from apps/code/assets/fonts/OpenRunde/OpenRunde-Regular.woff rename to packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Regular.woff diff --git a/apps/code/assets/fonts/OpenRunde/OpenRunde-Regular.woff2 b/packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Regular.woff2 similarity index 100% rename from apps/code/assets/fonts/OpenRunde/OpenRunde-Regular.woff2 rename to packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Regular.woff2 diff --git a/apps/code/assets/fonts/OpenRunde/OpenRunde-Semibold.woff b/packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Semibold.woff similarity index 100% rename from apps/code/assets/fonts/OpenRunde/OpenRunde-Semibold.woff rename to packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Semibold.woff diff --git a/apps/code/assets/fonts/OpenRunde/OpenRunde-Semibold.woff2 b/packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Semibold.woff2 similarity index 100% rename from apps/code/assets/fonts/OpenRunde/OpenRunde-Semibold.woff2 rename to packages/ui/src/assets/fonts/OpenRunde/OpenRunde-Semibold.woff2 diff --git a/packages/ui/src/assets/hedgehogs.ts b/packages/ui/src/assets/hedgehogs.ts new file mode 100644 index 0000000000..4de99207db --- /dev/null +++ b/packages/ui/src/assets/hedgehogs.ts @@ -0,0 +1,3 @@ +export { default as builderHog } from "./hedgehogs/builder-hog-03.png"; +export { default as explorerHog } from "./hedgehogs/explorer-hog.png"; +export { default as happyHog } from "./hedgehogs/happy-hog.png"; diff --git a/apps/code/src/renderer/assets/images/hedgehogs/builder-hog-03.png b/packages/ui/src/assets/hedgehogs/builder-hog-03.png similarity index 100% rename from apps/code/src/renderer/assets/images/hedgehogs/builder-hog-03.png rename to packages/ui/src/assets/hedgehogs/builder-hog-03.png diff --git a/apps/code/src/renderer/assets/images/hedgehogs/explorer-hog.png b/packages/ui/src/assets/hedgehogs/explorer-hog.png similarity index 100% rename from apps/code/src/renderer/assets/images/hedgehogs/explorer-hog.png rename to packages/ui/src/assets/hedgehogs/explorer-hog.png diff --git a/apps/code/src/renderer/assets/images/hedgehogs/happy-hog.png b/packages/ui/src/assets/hedgehogs/happy-hog.png similarity index 100% rename from apps/code/src/renderer/assets/images/hedgehogs/happy-hog.png rename to packages/ui/src/assets/hedgehogs/happy-hog.png diff --git a/apps/code/src/renderer/assets/images/mail-hog.png b/packages/ui/src/assets/images/mail-hog.png similarity index 100% rename from apps/code/src/renderer/assets/images/mail-hog.png rename to packages/ui/src/assets/images/mail-hog.png diff --git a/apps/code/src/renderer/assets/images/robo-zen.png b/packages/ui/src/assets/images/robo-zen.png similarity index 100% rename from apps/code/src/renderer/assets/images/robo-zen.png rename to packages/ui/src/assets/images/robo-zen.png diff --git a/apps/code/src/renderer/assets/images/zen.png b/packages/ui/src/assets/images/zen.png similarity index 100% rename from apps/code/src/renderer/assets/images/zen.png rename to packages/ui/src/assets/images/zen.png diff --git a/apps/code/src/renderer/assets/services/airops.png b/packages/ui/src/assets/services/airops.png similarity index 100% rename from apps/code/src/renderer/assets/services/airops.png rename to packages/ui/src/assets/services/airops.png diff --git a/apps/code/src/renderer/assets/services/atlassian.svg b/packages/ui/src/assets/services/atlassian.svg similarity index 100% rename from apps/code/src/renderer/assets/services/atlassian.svg rename to packages/ui/src/assets/services/atlassian.svg diff --git a/apps/code/src/renderer/assets/services/attio.png b/packages/ui/src/assets/services/attio.png similarity index 100% rename from apps/code/src/renderer/assets/services/attio.png rename to packages/ui/src/assets/services/attio.png diff --git a/apps/code/src/renderer/assets/services/box.svg b/packages/ui/src/assets/services/box.svg similarity index 100% rename from apps/code/src/renderer/assets/services/box.svg rename to packages/ui/src/assets/services/box.svg diff --git a/apps/code/src/renderer/assets/services/browserbase.svg b/packages/ui/src/assets/services/browserbase.svg similarity index 100% rename from apps/code/src/renderer/assets/services/browserbase.svg rename to packages/ui/src/assets/services/browserbase.svg diff --git a/apps/code/src/renderer/assets/services/canva.svg b/packages/ui/src/assets/services/canva.svg similarity index 100% rename from apps/code/src/renderer/assets/services/canva.svg rename to packages/ui/src/assets/services/canva.svg diff --git a/apps/code/src/renderer/assets/services/circle.png b/packages/ui/src/assets/services/circle.png similarity index 100% rename from apps/code/src/renderer/assets/services/circle.png rename to packages/ui/src/assets/services/circle.png diff --git a/apps/code/src/renderer/assets/services/cisco_thousandeyes.png b/packages/ui/src/assets/services/cisco_thousandeyes.png similarity index 100% rename from apps/code/src/renderer/assets/services/cisco_thousandeyes.png rename to packages/ui/src/assets/services/cisco_thousandeyes.png diff --git a/apps/code/src/renderer/assets/services/clerk.svg b/packages/ui/src/assets/services/clerk.svg similarity index 100% rename from apps/code/src/renderer/assets/services/clerk.svg rename to packages/ui/src/assets/services/clerk.svg diff --git a/apps/code/src/renderer/assets/services/clickhouse.svg b/packages/ui/src/assets/services/clickhouse.svg similarity index 100% rename from apps/code/src/renderer/assets/services/clickhouse.svg rename to packages/ui/src/assets/services/clickhouse.svg diff --git a/apps/code/src/renderer/assets/services/cloudflare.svg b/packages/ui/src/assets/services/cloudflare.svg similarity index 100% rename from apps/code/src/renderer/assets/services/cloudflare.svg rename to packages/ui/src/assets/services/cloudflare.svg diff --git a/apps/code/src/renderer/assets/services/context7.svg b/packages/ui/src/assets/services/context7.svg similarity index 100% rename from apps/code/src/renderer/assets/services/context7.svg rename to packages/ui/src/assets/services/context7.svg diff --git a/apps/code/src/renderer/assets/services/datadog.svg b/packages/ui/src/assets/services/datadog.svg similarity index 100% rename from apps/code/src/renderer/assets/services/datadog.svg rename to packages/ui/src/assets/services/datadog.svg diff --git a/apps/code/src/renderer/assets/services/figma.svg b/packages/ui/src/assets/services/figma.svg similarity index 100% rename from apps/code/src/renderer/assets/services/figma.svg rename to packages/ui/src/assets/services/figma.svg diff --git a/apps/code/src/renderer/assets/services/firetiger.svg b/packages/ui/src/assets/services/firetiger.svg similarity index 100% rename from apps/code/src/renderer/assets/services/firetiger.svg rename to packages/ui/src/assets/services/firetiger.svg diff --git a/apps/code/src/renderer/assets/services/github.svg b/packages/ui/src/assets/services/github.svg similarity index 100% rename from apps/code/src/renderer/assets/services/github.svg rename to packages/ui/src/assets/services/github.svg diff --git a/apps/code/src/renderer/assets/services/gitlab.svg b/packages/ui/src/assets/services/gitlab.svg similarity index 100% rename from apps/code/src/renderer/assets/services/gitlab.svg rename to packages/ui/src/assets/services/gitlab.svg diff --git a/apps/code/src/renderer/assets/services/hex.svg b/packages/ui/src/assets/services/hex.svg similarity index 100% rename from apps/code/src/renderer/assets/services/hex.svg rename to packages/ui/src/assets/services/hex.svg diff --git a/apps/code/src/renderer/assets/services/hubspot.svg b/packages/ui/src/assets/services/hubspot.svg similarity index 100% rename from apps/code/src/renderer/assets/services/hubspot.svg rename to packages/ui/src/assets/services/hubspot.svg diff --git a/apps/code/src/renderer/assets/services/launchdarkly.png b/packages/ui/src/assets/services/launchdarkly.png similarity index 100% rename from apps/code/src/renderer/assets/services/launchdarkly.png rename to packages/ui/src/assets/services/launchdarkly.png diff --git a/apps/code/src/renderer/assets/services/linear.svg b/packages/ui/src/assets/services/linear.svg similarity index 100% rename from apps/code/src/renderer/assets/services/linear.svg rename to packages/ui/src/assets/services/linear.svg diff --git a/apps/code/src/renderer/assets/services/monday.svg b/packages/ui/src/assets/services/monday.svg similarity index 100% rename from apps/code/src/renderer/assets/services/monday.svg rename to packages/ui/src/assets/services/monday.svg diff --git a/apps/code/src/renderer/assets/services/neon.svg b/packages/ui/src/assets/services/neon.svg similarity index 100% rename from apps/code/src/renderer/assets/services/neon.svg rename to packages/ui/src/assets/services/neon.svg diff --git a/apps/code/src/renderer/assets/services/notion.svg b/packages/ui/src/assets/services/notion.svg similarity index 100% rename from apps/code/src/renderer/assets/services/notion.svg rename to packages/ui/src/assets/services/notion.svg diff --git a/apps/code/src/renderer/assets/services/pagerduty.svg b/packages/ui/src/assets/services/pagerduty.svg similarity index 100% rename from apps/code/src/renderer/assets/services/pagerduty.svg rename to packages/ui/src/assets/services/pagerduty.svg diff --git a/apps/code/src/renderer/assets/services/planetscale.svg b/packages/ui/src/assets/services/planetscale.svg similarity index 100% rename from apps/code/src/renderer/assets/services/planetscale.svg rename to packages/ui/src/assets/services/planetscale.svg diff --git a/apps/code/src/renderer/assets/services/postman.svg b/packages/ui/src/assets/services/postman.svg similarity index 100% rename from apps/code/src/renderer/assets/services/postman.svg rename to packages/ui/src/assets/services/postman.svg diff --git a/apps/code/src/renderer/assets/services/prisma.svg b/packages/ui/src/assets/services/prisma.svg similarity index 100% rename from apps/code/src/renderer/assets/services/prisma.svg rename to packages/ui/src/assets/services/prisma.svg diff --git a/apps/code/src/renderer/assets/services/render.svg b/packages/ui/src/assets/services/render.svg similarity index 100% rename from apps/code/src/renderer/assets/services/render.svg rename to packages/ui/src/assets/services/render.svg diff --git a/apps/code/src/renderer/assets/services/sanity.svg b/packages/ui/src/assets/services/sanity.svg similarity index 100% rename from apps/code/src/renderer/assets/services/sanity.svg rename to packages/ui/src/assets/services/sanity.svg diff --git a/apps/code/src/renderer/assets/services/sentry.svg b/packages/ui/src/assets/services/sentry.svg similarity index 100% rename from apps/code/src/renderer/assets/services/sentry.svg rename to packages/ui/src/assets/services/sentry.svg diff --git a/apps/code/src/renderer/assets/services/slack.png b/packages/ui/src/assets/services/slack.png similarity index 100% rename from apps/code/src/renderer/assets/services/slack.png rename to packages/ui/src/assets/services/slack.png diff --git a/apps/code/src/renderer/assets/services/stripe.png b/packages/ui/src/assets/services/stripe.png similarity index 100% rename from apps/code/src/renderer/assets/services/stripe.png rename to packages/ui/src/assets/services/stripe.png diff --git a/apps/code/src/renderer/assets/services/supabase.svg b/packages/ui/src/assets/services/supabase.svg similarity index 100% rename from apps/code/src/renderer/assets/services/supabase.svg rename to packages/ui/src/assets/services/supabase.svg diff --git a/apps/code/src/renderer/assets/services/svelte.png b/packages/ui/src/assets/services/svelte.png similarity index 100% rename from apps/code/src/renderer/assets/services/svelte.png rename to packages/ui/src/assets/services/svelte.png diff --git a/apps/code/src/renderer/assets/services/wix.png b/packages/ui/src/assets/services/wix.png similarity index 100% rename from apps/code/src/renderer/assets/services/wix.png rename to packages/ui/src/assets/services/wix.png diff --git a/apps/code/src/renderer/assets/sounds/bubbles.mp3 b/packages/ui/src/assets/sounds/bubbles.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/bubbles.mp3 rename to packages/ui/src/assets/sounds/bubbles.mp3 diff --git a/apps/code/src/renderer/assets/sounds/danilo.mp3 b/packages/ui/src/assets/sounds/danilo.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/danilo.mp3 rename to packages/ui/src/assets/sounds/danilo.mp3 diff --git a/apps/code/src/renderer/assets/sounds/drop.mp3 b/packages/ui/src/assets/sounds/drop.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/drop.mp3 rename to packages/ui/src/assets/sounds/drop.mp3 diff --git a/apps/code/src/renderer/assets/sounds/guitar.mp3 b/packages/ui/src/assets/sounds/guitar.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/guitar.mp3 rename to packages/ui/src/assets/sounds/guitar.mp3 diff --git a/apps/code/src/renderer/assets/sounds/knock.mp3 b/packages/ui/src/assets/sounds/knock.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/knock.mp3 rename to packages/ui/src/assets/sounds/knock.mp3 diff --git a/apps/code/src/renderer/assets/sounds/meep-smol.mp3 b/packages/ui/src/assets/sounds/meep-smol.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/meep-smol.mp3 rename to packages/ui/src/assets/sounds/meep-smol.mp3 diff --git a/apps/code/src/renderer/assets/sounds/meep.mp3 b/packages/ui/src/assets/sounds/meep.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/meep.mp3 rename to packages/ui/src/assets/sounds/meep.mp3 diff --git a/apps/code/src/renderer/assets/sounds/revi.mp3 b/packages/ui/src/assets/sounds/revi.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/revi.mp3 rename to packages/ui/src/assets/sounds/revi.mp3 diff --git a/apps/code/src/renderer/assets/sounds/ring.mp3 b/packages/ui/src/assets/sounds/ring.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/ring.mp3 rename to packages/ui/src/assets/sounds/ring.mp3 diff --git a/apps/code/src/renderer/assets/sounds/shoot.mp3 b/packages/ui/src/assets/sounds/shoot.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/shoot.mp3 rename to packages/ui/src/assets/sounds/shoot.mp3 diff --git a/apps/code/src/renderer/assets/sounds/slide.mp3 b/packages/ui/src/assets/sounds/slide.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/slide.mp3 rename to packages/ui/src/assets/sounds/slide.mp3 diff --git a/apps/code/src/renderer/assets/sounds/switch.mp3 b/packages/ui/src/assets/sounds/switch.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/switch.mp3 rename to packages/ui/src/assets/sounds/switch.mp3 diff --git a/apps/code/src/renderer/assets/sounds/wilhelm.mp3 b/packages/ui/src/assets/sounds/wilhelm.mp3 similarity index 100% rename from apps/code/src/renderer/assets/sounds/wilhelm.mp3 rename to packages/ui/src/assets/sounds/wilhelm.mp3 diff --git a/apps/code/src/renderer/features/actions/components/ActionTabIcon.tsx b/packages/ui/src/features/actions/ActionTabIcon.tsx similarity index 79% rename from apps/code/src/renderer/features/actions/components/ActionTabIcon.tsx rename to packages/ui/src/features/actions/ActionTabIcon.tsx index 2e2a2572e2..ab361a3f2a 100644 --- a/apps/code/src/renderer/features/actions/components/ActionTabIcon.tsx +++ b/packages/ui/src/features/actions/ActionTabIcon.tsx @@ -1,12 +1,16 @@ -import { Tooltip } from "@components/ui/Tooltip"; +import { ArrowClockwise, Check, X } from "@phosphor-icons/react"; +import { useService } from "@posthog/di/react"; import { getActionSessionId, useActionStore, -} from "@features/actions/stores/actionStore"; -import { terminalManager } from "@features/terminal/services/TerminalManager"; -import { ArrowClockwise, Check, X } from "@phosphor-icons/react"; +} from "@posthog/ui/features/actions/actionStore"; +import { + SHELL_CLIENT, + type ShellClient, +} from "@posthog/ui/features/terminal/shellClient"; +import { terminalManager } from "@posthog/ui/features/terminal/TerminalManager"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { Spinner } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; import { useCallback, useState } from "react"; interface ActionTabIconProps { @@ -15,6 +19,7 @@ interface ActionTabIconProps { export function ActionTabIcon({ actionId }: ActionTabIconProps) { const [hovered, setHovered] = useState(false); + const shellClient = useService<ShellClient>(SHELL_CLIENT); const status = useActionStore((state) => state.statuses[actionId]); const generation = useActionStore( (state) => state.generations[actionId] ?? 0, @@ -24,9 +29,9 @@ export function ActionTabIcon({ actionId }: ActionTabIconProps) { const triggerRerun = useCallback(() => { const sessionId = getActionSessionId(actionId, generation); terminalManager.destroy(sessionId); - trpcClient.shell.destroy.mutate({ sessionId }); + shellClient.destroy({ sessionId }); rerun(actionId); - }, [actionId, generation, rerun]); + }, [actionId, generation, rerun, shellClient]); const handleClick = useCallback( (e: React.MouseEvent) => { diff --git a/apps/code/src/renderer/features/actions/stores/actionStore.ts b/packages/ui/src/features/actions/actionStore.ts similarity index 100% rename from apps/code/src/renderer/features/actions/stores/actionStore.ts rename to packages/ui/src/features/actions/actionStore.ts diff --git a/packages/ui/src/features/agent/agent-events.contribution.ts b/packages/ui/src/features/agent/agent-events.contribution.ts new file mode 100644 index 0000000000..6b7b195202 --- /dev/null +++ b/packages/ui/src/features/agent/agent-events.contribution.ts @@ -0,0 +1,33 @@ +import type { WorkbenchContribution } from "@posthog/di/contribution"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { inject, injectable } from "inversify"; +import { track } from "../../workbench/analytics"; + +/** + * Boots the global agent-event listeners once at startup (formerly an inline + * useSubscription side effect in App.tsx). Reports agent file-activity to + * analytics so worktree write activity is tracked regardless of which view is + * open. + */ +@injectable() +export class AgentEventsContribution implements WorkbenchContribution { + constructor( + @inject(HOST_TRPC_CLIENT) + private readonly hostClient: HostTrpcClient, + ) {} + + start(): void { + this.hostClient.agent.onAgentFileActivity.subscribe(undefined, { + onData: (data) => { + track(ANALYTICS_EVENTS.AGENT_FILE_ACTIVITY, { + task_id: data.taskId, + branch_name: data.branchName, + }); + }, + }); + } +} diff --git a/packages/ui/src/features/agent/agent.module.ts b/packages/ui/src/features/agent/agent.module.ts new file mode 100644 index 0000000000..b9ee2f26a4 --- /dev/null +++ b/packages/ui/src/features/agent/agent.module.ts @@ -0,0 +1,7 @@ +import { WORKBENCH_CONTRIBUTION } from "@posthog/di/contribution"; +import { ContainerModule } from "inversify"; +import { AgentEventsContribution } from "./agent-events.contribution"; + +export const agentUiModule = new ContainerModule(({ bind }) => { + bind(WORKBENCH_CONTRIBUTION).to(AgentEventsContribution).inSingletonScope(); +}); diff --git a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx b/packages/ui/src/features/ai-approval/AiApprovalScreen.tsx similarity index 81% rename from apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx rename to packages/ui/src/features/ai-approval/AiApprovalScreen.tsx index 2dfce464d4..1de63c0b35 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx +++ b/packages/ui/src/features/ai-approval/AiApprovalScreen.tsx @@ -1,8 +1,3 @@ -import { FullScreenLayout } from "@components/FullScreenLayout"; -import { useLogoutMutation } from "@features/auth/hooks/authMutations"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { SettingsDialog } from "@features/settings/components/SettingsDialog"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { ArrowSquareOut, GearSix, @@ -10,22 +5,35 @@ import { SignOut, WarningCircle, } from "@phosphor-icons/react"; +import { getCloudUrlFromRegion } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useLogoutMutation } from "@posthog/ui/features/auth/useAuthMutations"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useSettingsDialogStore } from "@posthog/ui/features/settings/settingsDialogStore"; +import { FullScreenLayout } from "@posthog/ui/primitives/FullScreenLayout"; +import { track } from "@posthog/ui/workbench/analytics"; +import { openExternalUrl } from "@posthog/ui/workbench/openExternal"; import { Button, Callout, Flex, Text } from "@radix-ui/themes"; -import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; -import { trpcClient } from "@renderer/trpc/client"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { track } from "@utils/analytics"; import { motion } from "framer-motion"; -import { useEffect } from "react"; +import { type ReactNode, useEffect } from "react"; import { useHotkeys } from "react-hotkeys-hook"; interface AiApprovalScreenProps { orgName: string | null; isAdmin: boolean; + banner?: ReactNode; + onOpenSupport?: () => void; + settingsDialog?: ReactNode; } -export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { +export function AiApprovalScreen({ + orgName, + isAdmin, + banner, + onOpenSupport, + settingsDialog, +}: AiApprovalScreenProps) { const logoutMutation = useLogoutMutation(); const openSettings = useSettingsDialogStore((s) => s.open); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); @@ -46,7 +54,7 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { const openApproval = () => { if (!approvalUrl) return; - void trpcClient.os.openExternal.mutate({ url: approvalUrl }); + openExternalUrl(approvalUrl); }; const footerLeft = ( @@ -77,7 +85,12 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { return ( <> - <FullScreenLayout footerLeft={footerLeft} footerRight={footerRight}> + <FullScreenLayout + footerLeft={footerLeft} + footerRight={footerRight} + banner={banner} + onOpenSupport={onOpenSupport} + > <Flex align="center" justify="center" height="100%" px="8"> <Flex direction="column" @@ -163,7 +176,7 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { </Flex> </Flex> </FullScreenLayout> - <SettingsDialog /> + {settingsDialog} </> ); } diff --git a/apps/code/src/renderer/features/archive/components/ArchivedTasksView.stories.tsx b/packages/ui/src/features/archive/ArchivedTasksView.stories.tsx similarity index 95% rename from apps/code/src/renderer/features/archive/components/ArchivedTasksView.stories.tsx rename to packages/ui/src/features/archive/ArchivedTasksView.stories.tsx index 6c35ea99f7..4e0c364d05 100644 --- a/apps/code/src/renderer/features/archive/components/ArchivedTasksView.stories.tsx +++ b/packages/ui/src/features/archive/ArchivedTasksView.stories.tsx @@ -1,11 +1,11 @@ -import { Box } from "@radix-ui/themes"; -import type { Task } from "@shared/types"; -import type { ArchivedTask } from "@shared/types/archive"; -import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ArchivedTask } from "@posthog/shared/archive-domain"; +import type { Task } from "@posthog/shared/domain-types"; import { ArchivedTasksViewPresentation, type ArchivedTaskWithDetails, -} from "./ArchivedTasksView"; +} from "@posthog/ui/features/archive/ArchivedTasksView"; +import { Box } from "@radix-ui/themes"; +import type { Meta, StoryObj } from "@storybook/react-vite"; function createArchivedTask(id: string, daysAgo: number): ArchivedTask { return { diff --git a/apps/code/src/renderer/features/archive/components/ArchivedTasksView.tsx b/packages/ui/src/features/archive/ArchivedTasksView.tsx similarity index 70% rename from apps/code/src/renderer/features/archive/components/ArchivedTasksView.tsx rename to packages/ui/src/features/archive/ArchivedTasksView.tsx index 1dc7f35316..51b8ebfb08 100644 --- a/apps/code/src/renderer/features/archive/components/ArchivedTasksView.tsx +++ b/packages/ui/src/features/archive/ArchivedTasksView.tsx @@ -1,8 +1,3 @@ -import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; -import { Tooltip } from "@components/ui/Tooltip"; -import { useTasks } from "@features/tasks/hooks/useTasks"; -import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; -import type { WorkspaceMode } from "@main/services/workspace/schemas"; import { CaretDown, CaretUp, @@ -12,6 +7,19 @@ import { Laptop as LaptopIcon, MagnifyingGlass, } from "@phosphor-icons/react"; +import type { RestoreOutcome } from "@posthog/core/archive/archivedTasksController"; +import { + type ArchivedTaskWithDetails, + deriveUniqueRepos, + filterAndSortArchivedTasks, + formatRelativeDate, + mergeArchivedWithTasks, + type ArchiveSortColumn as SortColumn, + type ArchiveSortState as SortState, + withRepoNames, +} from "@posthog/core/archive/archiveListView"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { WorkspaceMode } from "@posthog/shared"; import { AlertDialog, Box, @@ -23,25 +31,15 @@ import { Text, TextField, } from "@radix-ui/themes"; -import { trpcClient, useTRPC } from "@renderer/trpc"; -import type { Task } from "@shared/types"; -import type { ArchivedTask } from "@shared/types/archive"; -import { useNavigationStore } from "@stores/navigationStore"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { formatRelativeTimeLong } from "@utils/time"; -import { toast } from "@utils/toast"; +import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; - -const BRANCH_NOT_FOUND_PATTERN = /Branch '(.+)' does not exist/; - -function formatRelativeDate(isoDate: string | undefined): string { - if (!isoDate) return "—"; - return formatRelativeTimeLong(isoDate); -} - -function getRepoName(repository: string | null | undefined): string { - return repository?.split("/").pop() ?? "—"; -} +import { useSetHeaderContent } from "../../hooks/useSetHeaderContent"; +import { DotsCircleSpinner } from "../../primitives/DotsCircleSpinner"; +import { Tooltip } from "../../primitives/Tooltip"; +import { toast } from "../../primitives/toast"; +import { useNavigationStore } from "../navigation/store"; +import { useTasks } from "../tasks/useTasks"; +import { useUnarchiveTask } from "./useUnarchiveTask"; const ICON_SIZE = 12; @@ -73,14 +71,6 @@ function ModeIcon({ mode }: { mode: WorkspaceMode }) { ); } -type SortColumn = "created" | "archived"; -type SortDirection = "asc" | "desc"; - -interface SortState { - column: SortColumn; - direction: SortDirection; -} - function SortableColumnHeader({ column, label, @@ -186,10 +176,7 @@ interface BranchNotFoundPrompt { branchName: string; } -export interface ArchivedTaskWithDetails { - archived: ArchivedTask; - task: Task | null; -} +export type { ArchivedTaskWithDetails }; export interface ArchivedTasksViewPresentationProps { items: ArchivedTaskWithDetails[]; @@ -228,55 +215,22 @@ export function ArchivedTasksViewPresentation({ ); }; - const itemsWithRepo = useMemo( - () => - items.map((item) => ({ - ...item, - repoName: getRepoName(item.task?.repository), - })), - [items], - ); - - const uniqueRepos = useMemo(() => { - const repos = new Set<string>(); - for (const item of itemsWithRepo) { - if (item.repoName !== "—") repos.add(item.repoName); - } - return [...repos].sort((a, b) => a.localeCompare(b)); - }, [itemsWithRepo]); - - const filteredItems = useMemo(() => { - let result = itemsWithRepo; - - const query = searchQuery.trim().toLowerCase(); - if (query) { - result = result.filter((item) => - (item.task?.title?.toLowerCase() ?? "").includes(query), - ); - } + const itemsWithRepo = useMemo(() => withRepoNames(items), [items]); - if (repoFilter) { - result = result.filter((item) => item.repoName === repoFilter); - } - - const dir = sort.direction === "asc" ? 1 : -1; + const uniqueRepos = useMemo( + () => deriveUniqueRepos(itemsWithRepo), + [itemsWithRepo], + ); - return [...result].sort((a, b) => { - const aTime = - sort.column === "created" - ? a.task?.created_at - ? new Date(a.task.created_at).getTime() - : 0 - : new Date(a.archived.archivedAt).getTime(); - const bTime = - sort.column === "created" - ? b.task?.created_at - ? new Date(b.task.created_at).getTime() - : 0 - : new Date(b.archived.archivedAt).getTime(); - return dir * (aTime - bTime); - }); - }, [itemsWithRepo, searchQuery, repoFilter, sort]); + const filteredItems = useMemo( + () => + filterAndSortArchivedTasks(itemsWithRepo, { + searchQuery, + repoFilter, + sort, + }), + [itemsWithRepo, searchQuery, repoFilter, sort], + ); return ( <Flex direction="column" height="100%"> @@ -479,12 +433,12 @@ export function ArchivedTasksViewPresentation({ } export function ArchivedTasksView() { - const trpcReact = useTRPC(); + const trpc = useHostTRPC(); const { data: archivedTasks = [], isLoading: isLoadingArchived } = useQuery( - trpcReact.archive.list.queryOptions(), + trpc.archive.list.queryOptions(), ); const { data: tasks = [], isLoading: isLoadingTasks } = useTasks(); - const queryClient = useQueryClient(); + const { restore, remove, runContextMenuAction } = useUnarchiveTask(); useSetHeaderContent( <Text className="font-medium text-[13px]">Archived tasks</Text>, @@ -493,33 +447,20 @@ export function ArchivedTasksView() { const [branchNotFound, setBranchNotFound] = useState<BranchNotFoundPrompt | null>(null); - const items = useMemo(() => { - const taskMap = new Map(tasks.map((t) => [t.id, t])); - return archivedTasks.map((archived) => ({ - archived, - task: taskMap.get(archived.taskId) ?? null, - })); - }, [archivedTasks, tasks]); + const items = useMemo( + () => mergeArchivedWithTasks(archivedTasks, tasks), + [archivedTasks, tasks], + ); const isLoading = isLoadingArchived || isLoadingTasks; - const invalidateArchiveQueries = async () => { - await Promise.all([ - queryClient.invalidateQueries(trpcReact.archive.pathFilter()), - queryClient.refetchQueries({ queryKey: ["tasks"] }), - ]); - }; - - const handleUnarchive = async (taskId: string) => { - const item = items.find((i) => i.archived.taskId === taskId); - const task = item?.task; - - try { - await trpcClient.archive.unarchive.mutate({ taskId }); - await queryClient.invalidateQueries( - trpcReact.workspace.getAll.pathFilter(), - ); - await invalidateArchiveQueries(); + const applyRestoreOutcome = (taskId: string, outcome: RestoreOutcome) => { + if (outcome.kind === "restored") { + const task = + outcome.navigateToTaskId === null + ? null + : (items.find((i) => i.archived.taskId === outcome.navigateToTaskId) + ?.task ?? null); toast.success("Task unarchived", { action: task ? { @@ -528,28 +469,31 @@ export function ArchivedTasksView() { } : undefined, }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const match = message.match(BRANCH_NOT_FOUND_PATTERN); - if (match) { - setBranchNotFound({ taskId, branchName: match[1] }); - } else { - toast.error(`Failed to unarchive task: ${message}`); - } + } else if (outcome.kind === "branch-not-found") { + setBranchNotFound({ taskId, branchName: outcome.branchName }); + } else { + toast.error(`Failed to unarchive task: ${outcome.message}`); } }; - const executeDelete = async (taskId: string) => { - try { - await trpcClient.archive.delete.mutate({ taskId }); - await invalidateArchiveQueries(); + const applyDeleteOutcome = (outcome: { kind: string; message?: string }) => { + if (outcome.kind === "deleted") { toast.success("Task deleted"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`Failed to delete task: ${message}`); + } else { + toast.error(`Failed to delete task: ${outcome.message}`); } }; + const onUnarchive = async (taskId: string) => { + const hasTask = + items.find((i) => i.archived.taskId === taskId)?.task != null; + applyRestoreOutcome(taskId, await restore(taskId, hasTask)); + }; + + const onDelete = async (taskId: string) => { + applyDeleteOutcome(await remove(taskId)); + }; + const handleContextMenu = async ( item: ArchivedTaskWithDetails, e: React.MouseEvent, @@ -557,57 +501,30 @@ export function ArchivedTasksView() { e.preventDefault(); e.stopPropagation(); - const taskTitle = item.task?.title ?? "Unknown task"; - - try { - const result = - await trpcClient.contextMenu.showArchivedTaskContextMenu.mutate({ - taskTitle, - }); - - if (!result.action) return; - - switch (result.action.type) { - case "restore": - await handleUnarchive(item.archived.taskId); - break; - case "delete": - await executeDelete(item.archived.taskId); - break; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`Context menu error: ${message}`); + const outcome = await runContextMenuAction( + item.archived.taskId, + item.task?.title ?? "Unknown task", + item.task != null, + ); + if (outcome.kind === "menu-error") { + toast.error(`Context menu error: ${outcome.message}`); + } else if (outcome.kind === "restore") { + applyRestoreOutcome(item.archived.taskId, outcome.outcome); + } else if (outcome.kind === "delete") { + applyDeleteOutcome(outcome.outcome); } }; const handleRecreateBranch = async () => { if (!branchNotFound) return; const { taskId } = branchNotFound; - const item = items.find((i) => i.archived.taskId === taskId); - const task = item?.task; setBranchNotFound(null); - try { - await trpcClient.archive.unarchive.mutate({ - taskId, - recreateBranch: true, - }); - await queryClient.invalidateQueries( - trpcReact.workspace.getAll.pathFilter(), - ); - await invalidateArchiveQueries(); - toast.success("Task unarchived", { - action: task - ? { - label: "View task", - onClick: () => useNavigationStore.getState().navigateToTask(task), - } - : undefined, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`Failed to unarchive task: ${message}`); - } + const hasTask = + items.find((i) => i.archived.taskId === taskId)?.task != null; + applyRestoreOutcome( + taskId, + await restore(taskId, hasTask, { recreateBranch: true }), + ); }; return ( @@ -615,8 +532,8 @@ export function ArchivedTasksView() { items={items} isLoading={isLoading} branchNotFound={branchNotFound} - onUnarchive={handleUnarchive} - onDelete={executeDelete} + onUnarchive={onUnarchive} + onDelete={onDelete} onContextMenu={handleContextMenu} onBranchNotFoundClose={() => setBranchNotFound(null)} onRecreateBranch={handleRecreateBranch} diff --git a/packages/ui/src/features/archive/useArchiveTask.ts b/packages/ui/src/features/archive/useArchiveTask.ts new file mode 100644 index 0000000000..98abb961c3 --- /dev/null +++ b/packages/ui/src/features/archive/useArchiveTask.ts @@ -0,0 +1,181 @@ +import { + type ArchiveCacheWriter, + type ArchiveOrchestrationDeps, + type ArchiveTasksResult, + archiveTask, + archiveTasks, + shouldNavigateAwayForBulkArchive, +} from "@posthog/core/archive/archiveOrchestration"; +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; +import { useFocusStore } from "@posthog/ui/features/focus/focusStore"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { pinnedTasksApi } from "@posthog/ui/features/sidebar/taskMetaApi"; +import { + type TerminalState, + useTerminalStore, +} from "@posthog/ui/features/terminal/terminalStore"; +import { toast } from "@posthog/ui/primitives/toast"; +import { logger } from "@posthog/ui/workbench/logger"; +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; + +const log = logger.scope("archive-task"); + +export interface ArchiveCacheKeys { + archivedTaskIdsQueryKey: readonly unknown[]; + archiveListQueryKey: readonly unknown[]; + archivePathFilterKey: readonly unknown[]; +} + +export function useArchiveCacheKeys(): ArchiveCacheKeys { + const trpc = useHostTRPC(); + return useMemo( + () => ({ + archivedTaskIdsQueryKey: trpc.archive.archivedTaskIds.queryKey(), + archiveListQueryKey: trpc.archive.list.queryKey(), + archivePathFilterKey: trpc.archive.pathFilter().queryKey, + }), + [trpc], + ); +} + +function makeCacheWriter( + queryClient: QueryClient, + keys: ArchiveCacheKeys, +): ArchiveCacheWriter { + return { + cancelPathFilter: () => + queryClient.cancelQueries({ queryKey: keys.archivePathFilterKey }), + invalidatePathFilter: () => { + queryClient.invalidateQueries({ queryKey: keys.archivePathFilterKey }); + }, + setArchivedTaskIds: (updater) => + queryClient.setQueryData(keys.archivedTaskIdsQueryKey, updater), + setArchiveList: (updater) => + queryClient.setQueryData(keys.archiveListQueryKey, updater), + }; +} + +function makeOrchestrationDeps( + queryClient: QueryClient, + keys: ArchiveCacheKeys, + options?: { skipNavigate?: boolean }, +): ArchiveOrchestrationDeps { + const hostClient = resolveService<HostTrpcClient>(HOST_TRPC_CLIENT); + return { + async getWorkspace(taskId) { + const all = await hostClient.workspace.getAll.query(); + return all[taskId] ?? null; + }, + getPinnedTaskIds: () => pinnedTasksApi.getPinnedTaskIds(), + unpin: (taskId) => pinnedTasksApi.unpin(taskId), + togglePin: async (taskId) => { + await pinnedTasksApi.togglePin(taskId); + }, + navigateAwayFromTaskIfActive: (taskId) => { + if (options?.skipNavigate) return; + const nav = useNavigationStore.getState(); + if (nav.view.type === "task-detail" && nav.view.data?.id === taskId) { + nav.navigateToTaskInput(); + } + }, + snapshotTerminalStates: (taskId) => + Object.fromEntries( + Object.entries(useTerminalStore.getState().terminalStates).filter( + ([key]) => key === taskId || key.startsWith(`${taskId}-`), + ), + ), + clearTerminalStates: (taskId) => + useTerminalStore.getState().clearTerminalStatesForTask(taskId), + restoreTerminalStates: (states) => { + useTerminalStore.setState((s) => ({ + terminalStates: { + ...s.terminalStates, + ...(states as Record<string, TerminalState>), + }, + })); + }, + snapshotCommandCenter: (taskId) => { + const state = useCommandCenterStore.getState(); + return { + index: state.cells.indexOf(taskId), + wasActive: state.activeTaskId === taskId, + }; + }, + removeFromCommandCenter: (taskId) => + useCommandCenterStore.getState().removeTaskById(taskId), + restoreCommandCenter: (taskId, snapshot) => { + useCommandCenterStore.setState((s) => { + const cells = [...s.cells]; + cells[snapshot.index] = taskId; + return snapshot.wasActive ? { cells, activeTaskId: taskId } : { cells }; + }); + }, + getFocusedWorktreePath: () => + useFocusStore.getState().session?.worktreePath, + disableFocus: async () => { + log.info("Unfocusing workspace before archiving"); + await useFocusStore.getState().disableFocus(); + }, + disconnectFromTask: (taskId) => + resolveService<SessionService>(SESSION_SERVICE).disconnectFromTask( + taskId, + ), + archive: (taskId) => + hostClient.archive.archive.mutate({ taskId }).then(() => undefined), + logError: (message, error) => log.error(message, error), + cache: makeCacheWriter(queryClient, keys), + }; +} + +export async function archiveTaskImperative( + taskId: string, + queryClient: QueryClient, + keys: ArchiveCacheKeys, + options?: { skipNavigate?: boolean }, +): Promise<void> { + await archiveTask( + taskId, + makeOrchestrationDeps(queryClient, keys, options), + options, + ); +} + +export async function archiveTasksImperative( + taskIds: string[], + queryClient: QueryClient, + keys: ArchiveCacheKeys, +): Promise<ArchiveTasksResult> { + const nav = useNavigationStore.getState(); + const activeTaskId = + nav.view.type === "task-detail" ? (nav.view.data?.id ?? null) : null; + if (shouldNavigateAwayForBulkArchive(taskIds, activeTaskId)) { + nav.navigateToTaskInput(); + } + return archiveTasks( + taskIds, + makeOrchestrationDeps(queryClient, keys, { skipNavigate: true }), + ); +} + +export function useArchiveTask() { + const queryClient = useQueryClient(); + const keys = useArchiveCacheKeys(); + + const archiveTask = async ({ taskId }: { taskId: string }) => { + await archiveTaskImperative(taskId, queryClient, keys); + toast.success("Task archived"); + }; + + return { archiveTask }; +} diff --git a/apps/code/src/renderer/features/archive/hooks/useArchivedTaskIds.ts b/packages/ui/src/features/archive/useArchivedTaskIds.ts similarity index 54% rename from apps/code/src/renderer/features/archive/hooks/useArchivedTaskIds.ts rename to packages/ui/src/features/archive/useArchivedTaskIds.ts index 1b81d802fb..9e10d586bb 100644 --- a/apps/code/src/renderer/features/archive/hooks/useArchivedTaskIds.ts +++ b/packages/ui/src/features/archive/useArchivedTaskIds.ts @@ -1,9 +1,9 @@ -import { useTRPC } from "@renderer/trpc"; +import { useHostTRPC } from "@posthog/host-router/react"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; export function useArchivedTaskIds(): Set<string> { - const trpcReact = useTRPC(); - const { data } = useQuery(trpcReact.archive.archivedTaskIds.queryOptions()); + const trpc = useHostTRPC(); + const { data } = useQuery(trpc.archive.archivedTaskIds.queryOptions()); return useMemo(() => new Set(data ?? []), [data]); } diff --git a/packages/ui/src/features/archive/useUnarchiveTask.ts b/packages/ui/src/features/archive/useUnarchiveTask.ts new file mode 100644 index 0000000000..48518af5c1 --- /dev/null +++ b/packages/ui/src/features/archive/useUnarchiveTask.ts @@ -0,0 +1,94 @@ +import { + ARCHIVED_TASKS_CONTROLLER, + type ArchivedTasksController, + type ContextMenuOutcome, + type DeleteOutcome, + type RestoreOutcome, +} from "@posthog/core/archive/archivedTasksController"; +import { useService } from "@posthog/di/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { WORKSPACE_QUERY_KEY } from "@posthog/ui/features/workspace/identifiers"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +export interface UseUnarchiveTask { + restore( + taskId: string, + hasTask: boolean, + options?: { recreateBranch?: boolean }, + ): Promise<RestoreOutcome>; + remove(taskId: string): Promise<DeleteOutcome>; + runContextMenuAction( + taskId: string, + taskTitle: string, + hasTask: boolean, + ): Promise<ContextMenuOutcome>; +} + +export function useUnarchiveTask(): UseUnarchiveTask { + const controller = useService<ArchivedTasksController>( + ARCHIVED_TASKS_CONTROLLER, + ); + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + + const invalidateArchiveQueries = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries(trpc.archive.pathFilter()), + queryClient.refetchQueries({ queryKey: ["tasks"] }), + ]); + }, [queryClient, trpc]); + + const invalidateOnRestore = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: WORKSPACE_QUERY_KEY }); + await invalidateArchiveQueries(); + }, [queryClient, invalidateArchiveQueries]); + + const restore = useCallback( + async ( + taskId: string, + hasTask: boolean, + options?: { recreateBranch?: boolean }, + ) => { + const outcome = await controller.restore(taskId, hasTask, options); + if (outcome.kind === "restored") { + await invalidateOnRestore(); + } + return outcome; + }, + [controller, invalidateOnRestore], + ); + + const remove = useCallback( + async (taskId: string) => { + const outcome = await controller.remove(taskId); + if (outcome.kind === "deleted") { + await invalidateArchiveQueries(); + } + return outcome; + }, + [controller, invalidateArchiveQueries], + ); + + const runContextMenuAction = useCallback( + async (taskId: string, taskTitle: string, hasTask: boolean) => { + const outcome = await controller.runContextMenuAction( + taskId, + taskTitle, + hasTask, + ); + if (outcome.kind === "restore" && outcome.outcome.kind === "restored") { + await invalidateOnRestore(); + } else if ( + outcome.kind === "delete" && + outcome.outcome.kind === "deleted" + ) { + await invalidateArchiveQueries(); + } + return outcome; + }, + [controller, invalidateOnRestore, invalidateArchiveQueries], + ); + + return { restore, remove, runContextMenuAction }; +} diff --git a/apps/code/src/renderer/features/auth/components/OAuthControls.tsx b/packages/ui/src/features/auth/OAuthControls.tsx similarity index 85% rename from apps/code/src/renderer/features/auth/components/OAuthControls.tsx rename to packages/ui/src/features/auth/OAuthControls.tsx index 2655834c3a..2376b69f80 100644 --- a/apps/code/src/renderer/features/auth/components/OAuthControls.tsx +++ b/packages/ui/src/features/auth/OAuthControls.tsx @@ -1,14 +1,18 @@ -import { useOAuthFlow } from "@features/auth/hooks/useOAuthFlow"; +import type { CloudRegion } from "@posthog/shared"; import { Callout, Flex, Spinner } from "@radix-ui/themes"; -import posthogIcon from "@renderer/assets/images/posthog-icon.svg"; -import type { CloudRegion } from "@shared/types/regions"; +import posthogIcon from "./assets/posthog-icon.svg"; import { RegionSelect } from "./RegionSelect"; +import { useOAuthFlow } from "./useOAuthFlow"; interface OAuthControlsProps { onAuthInitiated?: (region: CloudRegion) => void; + includeDevRegion?: boolean; } -export function OAuthControls({ onAuthInitiated }: OAuthControlsProps = {}) { +export function OAuthControls({ + onAuthInitiated, + includeDevRegion = false, +}: OAuthControlsProps = {}) { const { region, handleAuth, @@ -33,6 +37,7 @@ export function OAuthControls({ onAuthInitiated }: OAuthControlsProps = {}) { region={region} onRegionChange={handleRegionChange} disabled={isPending} + includeDevRegion={includeDevRegion} /> {errorMessage && ( diff --git a/apps/code/src/renderer/features/auth/components/RegionSelect.tsx b/packages/ui/src/features/auth/RegionSelect.tsx similarity index 90% rename from apps/code/src/renderer/features/auth/components/RegionSelect.tsx rename to packages/ui/src/features/auth/RegionSelect.tsx index ee00c1497d..591a4adad0 100644 --- a/apps/code/src/renderer/features/auth/components/RegionSelect.tsx +++ b/packages/ui/src/features/auth/RegionSelect.tsx @@ -1,11 +1,12 @@ +import { type CloudRegion, REGION_LABELS } from "@posthog/shared"; import { Flex, Text } from "@radix-ui/themes"; -import { IS_DEV } from "@shared/constants/environment"; -import { type CloudRegion, REGION_LABELS } from "@shared/types/regions"; interface RegionSelectProps { region: CloudRegion; onRegionChange: (region: CloudRegion) => void; disabled?: boolean; + /** Host decides whether the local "dev" region is offered (e.g. dev builds). */ + includeDevRegion?: boolean; } const LOGIN_GRID_REGIONS: CloudRegion[] = ["us", "eu"]; @@ -14,6 +15,7 @@ export function RegionSelect({ region, onRegionChange, disabled = false, + includeDevRegion = false, }: RegionSelectProps) { return ( <Flex direction="column" gap="2" className="w-full"> @@ -36,7 +38,7 @@ export function RegionSelect({ /> ))} </div> - {IS_DEV && ( + {includeDevRegion && ( <RegionPickerOptionButton regionKey="dev" selected={region === "dev"} diff --git a/apps/code/src/renderer/features/auth/components/SignInCard.tsx b/packages/ui/src/features/auth/SignInCard.tsx similarity index 70% rename from apps/code/src/renderer/features/auth/components/SignInCard.tsx rename to packages/ui/src/features/auth/SignInCard.tsx index c88147556c..b6820d07ae 100644 --- a/apps/code/src/renderer/features/auth/components/SignInCard.tsx +++ b/packages/ui/src/features/auth/SignInCard.tsx @@ -1,6 +1,6 @@ -import { OnboardingHogTip } from "@features/onboarding/components/OnboardingHogTip"; +import type { CloudRegion } from "@posthog/shared"; +import { OnboardingHogTip } from "@posthog/ui/primitives/OnboardingHogTip"; import { Flex, Text } from "@radix-ui/themes"; -import type { CloudRegion } from "@shared/types/regions"; import { OAuthControls } from "./OAuthControls"; interface SignInCardProps { @@ -8,6 +8,7 @@ interface SignInCardProps { hogMessage: string; subtitle: string; onAuthInitiated?: (region: CloudRegion) => void; + includeDevRegion?: boolean; } export function SignInCard({ @@ -15,6 +16,7 @@ export function SignInCard({ hogMessage, subtitle, onAuthInitiated, + includeDevRegion = false, }: SignInCardProps) { return ( <Flex direction="column" gap="4"> @@ -24,7 +26,10 @@ export function SignInCard({ </Text> <Text className="text-(--gray-11) text-sm">{subtitle}</Text> </Flex> - <OAuthControls onAuthInitiated={onAuthInitiated} /> + <OAuthControls + onAuthInitiated={onAuthInitiated} + includeDevRegion={includeDevRegion} + /> <OnboardingHogTip hogSrc={hogSrc} message={hogMessage} /> </Flex> ); diff --git a/apps/code/src/renderer/assets/images/posthog-icon.svg b/packages/ui/src/features/auth/assets/posthog-icon.svg similarity index 100% rename from apps/code/src/renderer/assets/images/posthog-icon.svg rename to packages/ui/src/features/auth/assets/posthog-icon.svg diff --git a/packages/ui/src/features/auth/auth.contribution.ts b/packages/ui/src/features/auth/auth.contribution.ts new file mode 100644 index 0000000000..37dbc247aa --- /dev/null +++ b/packages/ui/src/features/auth/auth.contribution.ts @@ -0,0 +1,24 @@ +import type { WorkbenchContribution } from "@posthog/di/contribution"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { inject, injectable } from "inversify"; +import { useAuthStore } from "./store"; + +@injectable() +export class AuthContribution implements WorkbenchContribution { + constructor( + @inject(HOST_TRPC_CLIENT) + private readonly hostClient: HostTrpcClient, + ) {} + + async start(): Promise<void> { + this.hostClient.auth.onStateChanged.subscribe(undefined, { + onData: (state) => useAuthStore.getState().setAuthState(state), + }); + + const initial = await this.hostClient.auth.getState.query(); + useAuthStore.getState().setAuthState(initial); + } +} diff --git a/packages/ui/src/features/auth/auth.module.ts b/packages/ui/src/features/auth/auth.module.ts new file mode 100644 index 0000000000..625c69c55d --- /dev/null +++ b/packages/ui/src/features/auth/auth.module.ts @@ -0,0 +1,7 @@ +import { WORKBENCH_CONTRIBUTION } from "@posthog/di/contribution"; +import { ContainerModule } from "inversify"; +import { AuthContribution } from "./auth.contribution"; + +export const authUiModule = new ContainerModule(({ bind }) => { + bind(WORKBENCH_CONTRIBUTION).to(AuthContribution).inSingletonScope(); +}); diff --git a/packages/ui/src/features/auth/authClient.ts b/packages/ui/src/features/auth/authClient.ts new file mode 100644 index 0000000000..d58f392d27 --- /dev/null +++ b/packages/ui/src/features/auth/authClient.ts @@ -0,0 +1,70 @@ +import { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import type { AuthState } from "@posthog/core/auth/schemas"; +import type { HostTrpcClient } from "@posthog/host-router/client"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { getCloudUrlFromRegion, NotAuthenticatedError } from "@posthog/shared"; +import { useMemo } from "react"; +import { useAuthStateValue } from "./store"; + +export function createAuthenticatedClient( + authState: AuthState | null | undefined, + getValidAccessToken: () => Promise<string>, + refreshAccessToken: () => Promise<string>, +): PostHogAPIClient | null { + if (authState?.status !== "authenticated" || !authState.cloudRegion) { + return null; + } + + const client = new PostHogAPIClient( + getCloudUrlFromRegion(authState.cloudRegion), + getValidAccessToken, + refreshAccessToken, + authState.projectId ?? undefined, + ); + + if (authState.projectId) { + client.setTeamId(authState.projectId); + } + + return client; +} + +function tokenAccessors(hostClient: HostTrpcClient) { + return { + getValidAccessToken: () => + hostClient.auth.getValidAccessToken.query().then((r) => r.accessToken), + refreshAccessToken: () => + hostClient.auth.refreshAccessToken.mutate().then((r) => r.accessToken), + }; +} + +export function useOptionalAuthenticatedClient(): PostHogAPIClient | null { + const hostClient = useHostTRPCClient(); + const authState = useAuthStateValue((state) => state); + + return useMemo(() => { + const { getValidAccessToken, refreshAccessToken } = + tokenAccessors(hostClient); + return createAuthenticatedClient( + authState, + getValidAccessToken, + refreshAccessToken, + ); + }, [ + authState.cloudRegion, + authState.projectId, + authState.status, + authState, + hostClient, + ]); +} + +export function useAuthenticatedClient(): PostHogAPIClient { + const client = useOptionalAuthenticatedClient(); + + if (!client) { + throw new NotAuthenticatedError(); + } + + return client; +} diff --git a/packages/ui/src/features/auth/authClientImperative.ts b/packages/ui/src/features/auth/authClientImperative.ts new file mode 100644 index 0000000000..f305157c13 --- /dev/null +++ b/packages/ui/src/features/auth/authClientImperative.ts @@ -0,0 +1,32 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { createAuthenticatedClient as createClient } from "./authClient"; +import { type AuthState, fetchAuthState } from "./authQueries"; + +function hostClient(): HostTrpcClient { + return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT); +} + +async function getValidAccessToken(): Promise<string> { + const { accessToken } = await hostClient().auth.getValidAccessToken.query(); + return accessToken; +} + +async function refreshAccessToken(): Promise<string> { + const { accessToken } = await hostClient().auth.refreshAccessToken.mutate(); + return accessToken; +} + +export function createAuthenticatedClient( + authState: AuthState | null | undefined, +): PostHogAPIClient | null { + return createClient(authState, getValidAccessToken, refreshAccessToken); +} + +export async function getAuthenticatedClient(): Promise<PostHogAPIClient | null> { + return createAuthenticatedClient(await fetchAuthState()); +} diff --git a/packages/ui/src/features/auth/authQueries.ts b/packages/ui/src/features/auth/authQueries.ts new file mode 100644 index 0000000000..02489e2cde --- /dev/null +++ b/packages/ui/src/features/auth/authQueries.ts @@ -0,0 +1,48 @@ +import type { AuthState } from "@posthog/core/auth/schemas"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "@posthog/ui/workbench/queryClient"; +import { ANONYMOUS_AUTH_STATE, getAuthIdentity, useAuthStore } from "./store"; + +export type { AuthState }; +export { ANONYMOUS_AUTH_STATE, getAuthIdentity }; + +export { useAuthState, useAuthStateFetched, useAuthStateValue } from "./store"; +export { + AUTH_SCOPED_QUERY_META, + authKeys, + useCurrentUser, +} from "./useCurrentUser"; + +function hostClient(): HostTrpcClient { + return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT); +} + +function queryClient(): ImperativeQueryClient { + return resolveService<ImperativeQueryClient>(IMPERATIVE_QUERY_CLIENT); +} + +export async function fetchAuthState(): Promise<AuthState> { + return await hostClient().auth.getState.query(); +} + +export function getCachedAuthState(): AuthState { + return useAuthStore.getState().authState; +} + +export async function refreshAuthStateQuery(): Promise<void> { + const state = await fetchAuthState(); + useAuthStore.getState().setAuthState(state); +} + +export function clearAuthScopedQueries(): void { + queryClient().removeQueries({ + predicate: (query) => query.meta?.authScoped === true, + }); +} diff --git a/apps/code/src/renderer/features/auth/stores/authUiStateStore.ts b/packages/ui/src/features/auth/authUiStateStore.ts similarity index 94% rename from apps/code/src/renderer/features/auth/stores/authUiStateStore.ts rename to packages/ui/src/features/auth/authUiStateStore.ts index f546befbec..4bec9f32ca 100644 --- a/apps/code/src/renderer/features/auth/stores/authUiStateStore.ts +++ b/packages/ui/src/features/auth/authUiStateStore.ts @@ -1,4 +1,4 @@ -import type { CloudRegion } from "@shared/types/regions"; +import type { CloudRegion } from "@posthog/shared"; import { create } from "zustand"; interface AuthUiStateStoreState { diff --git a/apps/code/src/renderer/features/auth/components/AuthScreen.tsx b/packages/ui/src/features/auth/components/AuthScreen.tsx similarity index 88% rename from apps/code/src/renderer/features/auth/components/AuthScreen.tsx rename to packages/ui/src/features/auth/components/AuthScreen.tsx index 1c3eba4e2e..1f6dc856f7 100644 --- a/apps/code/src/renderer/features/auth/components/AuthScreen.tsx +++ b/packages/ui/src/features/auth/components/AuthScreen.tsx @@ -1,6 +1,6 @@ -import { FullScreenLayout } from "@components/FullScreenLayout"; +import { happyHog } from "@posthog/ui/assets/hedgehogs"; +import { FullScreenLayout } from "@posthog/ui/primitives/FullScreenLayout"; import { Flex } from "@radix-ui/themes"; -import happyHog from "@renderer/assets/images/hedgehogs/happy-hog.png"; import { SignInCard } from "./SignInCard"; export function AuthScreen() { diff --git a/apps/code/src/renderer/features/auth/components/InviteCodeScreen.tsx b/packages/ui/src/features/auth/components/InviteCodeScreen.tsx similarity index 93% rename from apps/code/src/renderer/features/auth/components/InviteCodeScreen.tsx rename to packages/ui/src/features/auth/components/InviteCodeScreen.tsx index c74fe1ca4d..cbfc9e34e2 100644 --- a/apps/code/src/renderer/features/auth/components/InviteCodeScreen.tsx +++ b/packages/ui/src/features/auth/components/InviteCodeScreen.tsx @@ -1,14 +1,14 @@ -import { FullScreenLayout } from "@components/FullScreenLayout"; -import { OnboardingHogTip } from "@features/onboarding/components/OnboardingHogTip"; import { SignOut } from "@phosphor-icons/react"; +import { happyHog } from "@posthog/ui/assets/hedgehogs"; +import { FullScreenLayout } from "@posthog/ui/primitives/FullScreenLayout"; +import { OnboardingHogTip } from "@posthog/ui/primitives/OnboardingHogTip"; import { Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; -import happyHog from "@renderer/assets/images/hedgehogs/happy-hog.png"; import { motion } from "framer-motion"; +import { useAuthUiStateStore } from "../authUiStateStore"; import { useLogoutMutation, useRedeemInviteCodeMutation, -} from "../hooks/authMutations"; -import { useAuthUiStateStore } from "../stores/authUiStateStore"; +} from "../useAuthMutations"; export function InviteCodeScreen() { const code = useAuthUiStateStore((state) => state.inviteCode); diff --git a/apps/code/src/renderer/components/ScopeReauthPrompt.test.tsx b/packages/ui/src/features/auth/components/ScopeReauthPrompt.test.tsx similarity index 95% rename from apps/code/src/renderer/components/ScopeReauthPrompt.test.tsx rename to packages/ui/src/features/auth/components/ScopeReauthPrompt.test.tsx index 0cb091af0e..c524abc4ed 100644 --- a/apps/code/src/renderer/components/ScopeReauthPrompt.test.tsx +++ b/packages/ui/src/features/auth/components/ScopeReauthPrompt.test.tsx @@ -22,12 +22,12 @@ const mockLogoutMutate = vi.fn(() => { authState.cloudRegion = null; }); -vi.mock("@features/auth/hooks/authQueries", () => ({ +vi.mock("../store", () => ({ useAuthStateValue: (selector: (state: typeof authState) => unknown) => selector(authState), })); -vi.mock("@features/auth/hooks/authMutations", () => ({ +vi.mock("../useAuthMutations", () => ({ useLoginMutation: () => ({ mutateAsync: mockLoginMutateAsync, isPending: false, @@ -37,7 +37,7 @@ vi.mock("@features/auth/hooks/authMutations", () => ({ }), })); -vi.mock("@utils/logger", () => ({ +vi.mock("../../../workbench/logger", () => ({ logger: { scope: () => ({ info: vi.fn(), diff --git a/apps/code/src/renderer/components/ScopeReauthPrompt.tsx b/packages/ui/src/features/auth/components/ScopeReauthPrompt.tsx similarity index 91% rename from apps/code/src/renderer/components/ScopeReauthPrompt.tsx rename to packages/ui/src/features/auth/components/ScopeReauthPrompt.tsx index 0a326a0449..b9a00cefcd 100644 --- a/apps/code/src/renderer/components/ScopeReauthPrompt.tsx +++ b/packages/ui/src/features/auth/components/ScopeReauthPrompt.tsx @@ -1,11 +1,8 @@ -import { - useLoginMutation, - useLogoutMutation, -} from "@features/auth/hooks/authMutations"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { ShieldWarning } from "@phosphor-icons/react"; import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; -import { logger } from "@utils/logger"; +import { logger } from "../../../workbench/logger"; +import { useAuthStateValue } from "../store"; +import { useLoginMutation, useLogoutMutation } from "../useAuthMutations"; const log = logger.scope("scope-reauth-prompt"); diff --git a/packages/ui/src/features/auth/components/SignInCard.tsx b/packages/ui/src/features/auth/components/SignInCard.tsx new file mode 100644 index 0000000000..57fb8637ad --- /dev/null +++ b/packages/ui/src/features/auth/components/SignInCard.tsx @@ -0,0 +1,13 @@ +import type { CloudRegion } from "@posthog/shared"; +import { SignInCard as BaseSignInCard } from "../SignInCard"; + +interface SignInCardProps { + hogSrc: string; + hogMessage: string; + subtitle: string; + onAuthInitiated?: (region: CloudRegion) => void; +} + +export function SignInCard(props: SignInCardProps) { + return <BaseSignInCard {...props} includeDevRegion={import.meta.env.DEV} />; +} diff --git a/packages/ui/src/features/auth/identifiers.ts b/packages/ui/src/features/auth/identifiers.ts new file mode 100644 index 0000000000..2c4624e8d1 --- /dev/null +++ b/packages/ui/src/features/auth/identifiers.ts @@ -0,0 +1,17 @@ +import type { CloudRegion } from "@posthog/shared"; + +/** + * Host-side cross-feature coordination triggered by auth mutations (query-cache + * invalidation, navigation, onboarding/session resets, analytics). These live + * outside packages/ui because they reach other app features; the desktop binds + * an adapter. Move each effect into the owning feature's contribution as those + * features migrate, then shrink this port. + */ +export interface IAuthSideEffects { + onAuthSuccess(region: CloudRegion, projectId: number | null): void; + beforeProjectSwitch(): void; + onProjectSelected(): void; + onLogout(previousRegion: CloudRegion | null): void; +} + +export const AUTH_SIDE_EFFECTS = Symbol.for("posthog.ui.auth.sideEffects"); diff --git a/packages/ui/src/features/auth/store.ts b/packages/ui/src/features/auth/store.ts new file mode 100644 index 0000000000..47b358605d --- /dev/null +++ b/packages/ui/src/features/auth/store.ts @@ -0,0 +1,38 @@ +import { getAuthIdentity } from "@posthog/core/auth/authIdentity"; +import type { AuthState } from "@posthog/core/auth/schemas"; +import { create } from "zustand"; + +export { getAuthIdentity }; + +export const ANONYMOUS_AUTH_STATE: AuthState = { + status: "anonymous", + bootstrapComplete: false, + cloudRegion: null, + projectId: null, + availableProjectIds: [], + availableOrgIds: [], + hasCodeAccess: null, + needsScopeReauth: false, +}; + +interface AuthStoreState { + authState: AuthState; + setAuthState: (state: AuthState) => void; +} + +export const useAuthStore = create<AuthStoreState>((set) => ({ + authState: ANONYMOUS_AUTH_STATE, + setAuthState: (authState) => set({ authState }), +})); + +export function useAuthState(): AuthState { + return useAuthStore((s) => s.authState); +} + +export function useAuthStateValue<T>(selector: (state: AuthState) => T): T { + return useAuthStore((s) => selector(s.authState)); +} + +export function useAuthStateFetched(): boolean { + return useAuthStore((s) => s.authState.bootstrapComplete); +} diff --git a/packages/ui/src/features/auth/useAuthMutations.ts b/packages/ui/src/features/auth/useAuthMutations.ts new file mode 100644 index 0000000000..d85cce4ac1 --- /dev/null +++ b/packages/ui/src/features/auth/useAuthMutations.ts @@ -0,0 +1,58 @@ +import { useService } from "@posthog/di/react"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import type { CloudRegion } from "@posthog/shared"; +import { useMutation } from "@tanstack/react-query"; +import { AUTH_SIDE_EFFECTS, type IAuthSideEffects } from "./identifiers"; + +export function useLoginMutation() { + const hostClient = useHostTRPCClient(); + const fx = useService<IAuthSideEffects>(AUTH_SIDE_EFFECTS); + return useMutation({ + mutationFn: (region: CloudRegion) => + hostClient.auth.login.mutate({ region }).then((r) => r.state), + onSuccess: (state, region) => fx.onAuthSuccess(region, state.projectId), + }); +} + +export function useSignupMutation() { + const hostClient = useHostTRPCClient(); + const fx = useService<IAuthSideEffects>(AUTH_SIDE_EFFECTS); + return useMutation({ + mutationFn: (region: CloudRegion) => + hostClient.auth.signup.mutate({ region }).then((r) => r.state), + onSuccess: (state, region) => fx.onAuthSuccess(region, state.projectId), + }); +} + +export function useSelectProjectMutation() { + const hostClient = useHostTRPCClient(); + const fx = useService<IAuthSideEffects>(AUTH_SIDE_EFFECTS); + return useMutation({ + mutationFn: (projectId: number) => { + fx.beforeProjectSwitch(); + return hostClient.auth.selectProject.mutate({ projectId }); + }, + onSuccess: () => fx.onProjectSelected(), + }); +} + +export function useRedeemInviteCodeMutation() { + const hostClient = useHostTRPCClient(); + return useMutation({ + mutationFn: (code: string) => + hostClient.auth.redeemInviteCode.mutate({ code }), + }); +} + +export function useLogoutMutation() { + const hostClient = useHostTRPCClient(); + const fx = useService<IAuthSideEffects>(AUTH_SIDE_EFFECTS); + return useMutation({ + mutationFn: async () => { + const previous = await hostClient.auth.getState.query(); + await hostClient.auth.logout.mutate(); + return previous; + }, + onSuccess: (previous) => fx.onLogout(previous.cloudRegion), + }); +} diff --git a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts b/packages/ui/src/features/auth/useAuthSession.ts similarity index 69% rename from apps/code/src/renderer/features/auth/hooks/useAuthSession.ts rename to packages/ui/src/features/auth/useAuthSession.ts index f3b946ce93..2ea43f6c3d 100644 --- a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts +++ b/packages/ui/src/features/auth/useAuthSession.ts @@ -1,4 +1,16 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { BILLING_FLAG } from "@posthog/shared"; +import { useSeatStore } from "@posthog/ui/features/billing/seatStore"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { + identifyUser, + resetUser, + setUserGroups, +} from "@posthog/ui/workbench/analytics"; +import { logger } from "@posthog/ui/workbench/logger"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { useOptionalAuthenticatedClient } from "./authClient"; import { type AuthState, clearAuthScopedQueries, @@ -6,22 +18,15 @@ import { refreshAuthStateQuery, useAuthStateValue, useCurrentUser, -} from "@features/auth/hooks/authQueries"; -import { useAuthUiStateStore } from "@features/auth/stores/authUiStateStore"; -import { useSeatStore } from "@features/billing/stores/seatStore"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; -import { trpcClient } from "@renderer/trpc/client"; -import { BILLING_FLAG } from "@shared/constants"; -import { identifyUser, resetUser, setUserGroups } from "@utils/analytics"; -import { logger } from "@utils/logger"; -import { queryClient } from "@utils/queryClient"; -import { useEffect } from "react"; +} from "./authQueries"; +import { useAuthUiStateStore } from "./authUiStateStore"; const log = logger.scope("auth-session"); function useAuthSubscriptionSync(): void { + const hostClient = useHostTRPCClient(); useEffect(() => { - const subscription = trpcClient.auth.onStateChanged.subscribe(undefined, { + const subscription = hostClient.auth.onStateChanged.subscribe(undefined, { onData: () => { void refreshAuthStateQuery(); }, @@ -31,17 +36,18 @@ function useAuthSubscriptionSync(): void { }); return () => subscription.unsubscribe(); - }, []); + }, [hostClient]); } function useAuthIdentitySync( authIdentity: string | null, cloudRegion: "us" | "eu" | "dev" | null, ): void { + const hostClient = useHostTRPCClient(); useEffect(() => { if (!authIdentity) { resetUser(); - void trpcClient.analytics.resetUser.mutate(); + void hostClient.analytics.resetUser.mutate(); clearAuthScopedQueries(); if (cloudRegion) { useAuthUiStateStore.getState().setStaleRegion(cloudRegion); @@ -50,7 +56,7 @@ function useAuthIdentitySync( } useAuthUiStateStore.getState().clearStaleRegion(); - }, [authIdentity, cloudRegion]); + }, [authIdentity, cloudRegion, hostClient]); } function useAuthAnalyticsIdentity( @@ -58,6 +64,7 @@ function useAuthAnalyticsIdentity( authState: AuthState, currentUser: ReturnType<typeof useCurrentUser>["data"], ): void { + const hostClient = useHostTRPCClient(); useEffect(() => { if (!authIdentity || !currentUser) { return; @@ -74,7 +81,7 @@ function useAuthAnalyticsIdentity( setUserGroups(currentUser); - void trpcClient.analytics.setUserId.mutate({ + void hostClient.analytics.setUserId.mutate({ userId: distinctId, properties: { email: currentUser.email, @@ -83,13 +90,20 @@ function useAuthAnalyticsIdentity( region: authState.cloudRegion ?? "", }, }); - }, [authIdentity, authState.cloudRegion, authState.projectId, currentUser]); + }, [ + authIdentity, + authState.cloudRegion, + authState.projectId, + currentUser, + hostClient, + ]); } function useSeatSync( authIdentity: string | null, billingEnabled: boolean, ): void { + const queryClient = useQueryClient(); useEffect(() => { if (!authIdentity || !billingEnabled) { useSeatStore.getState().reset(); @@ -98,7 +112,7 @@ function useSeatSync( void useSeatStore.getState().fetchSeat({ autoProvision: true }); void queryClient.invalidateQueries({ queryKey: [["llmGateway"]] }); - }, [authIdentity, billingEnabled]); + }, [authIdentity, billingEnabled, queryClient]); } export function useAuthSession() { diff --git a/packages/ui/src/features/auth/useCurrentUser.ts b/packages/ui/src/features/auth/useCurrentUser.ts new file mode 100644 index 0000000000..952ded1c20 --- /dev/null +++ b/packages/ui/src/features/auth/useCurrentUser.ts @@ -0,0 +1,39 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { getAuthIdentity } from "@posthog/core/auth/authIdentity"; +import { useQuery } from "@tanstack/react-query"; +import { useAuthStateValue } from "./store"; + +export const AUTH_SCOPED_QUERY_META = { + authScoped: true, +} as const; + +export const authKeys = { + currentUsers: () => ["auth", "current-user"] as const, + currentUser: (identity: string | null) => + [...authKeys.currentUsers(), identity ?? "anonymous"] as const, +}; + +export function useCurrentUser(options?: { + enabled?: boolean; + client?: PostHogAPIClient | null; + refetchOnWindowFocus?: boolean | "always"; +}) { + const authState = useAuthStateValue((state) => state); + const client = options?.client ?? null; + const authIdentity = getAuthIdentity(authState); + + return useQuery({ + queryKey: authKeys.currentUser(authIdentity), + queryFn: async () => { + if (!client) { + throw new Error("Not authenticated"); + } + + return await client.getCurrentUser(); + }, + enabled: !!client && !!authIdentity && (options?.enabled ?? true), + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: options?.refetchOnWindowFocus, + meta: AUTH_SCOPED_QUERY_META, + }); +} diff --git a/apps/code/src/renderer/hooks/useMeQuery.ts b/packages/ui/src/features/auth/useMeQuery.ts similarity index 72% rename from apps/code/src/renderer/hooks/useMeQuery.ts rename to packages/ui/src/features/auth/useMeQuery.ts index 6496e0ab07..9184f75a92 100644 --- a/apps/code/src/renderer/hooks/useMeQuery.ts +++ b/packages/ui/src/features/auth/useMeQuery.ts @@ -1,4 +1,4 @@ -import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; export function useMeQuery() { return useAuthenticatedQuery( diff --git a/packages/ui/src/features/auth/useOAuthFlow.ts b/packages/ui/src/features/auth/useOAuthFlow.ts new file mode 100644 index 0000000000..60bbb35d78 --- /dev/null +++ b/packages/ui/src/features/auth/useOAuthFlow.ts @@ -0,0 +1,36 @@ +import { mapAuthErrorMessage } from "@posthog/core/auth/authErrors"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import type { CloudRegion } from "@posthog/shared"; +import { useState } from "react"; +import { useAuthUiStateStore } from "./authUiStateStore"; +import { useLoginMutation } from "./useAuthMutations"; + +export function useOAuthFlow() { + const hostClient = useHostTRPCClient(); + const staleRegion = useAuthUiStateStore((s) => s.staleRegion); + const [region, setRegion] = useState<CloudRegion>(staleRegion ?? "us"); + const loginMutation = useLoginMutation(); + + const handleAuth = () => { + loginMutation.mutate(region); + }; + + const handleRegionChange = (value: CloudRegion) => { + setRegion(value); + loginMutation.reset(); + }; + + const handleCancel = async () => { + loginMutation.reset(); + await hostClient.oauth.cancelFlow.mutate(); + }; + + return { + region, + handleAuth, + handleRegionChange, + handleCancel, + isPending: loginMutation.isPending, + errorMessage: mapAuthErrorMessage(loginMutation.error), + }; +} diff --git a/apps/code/src/renderer/features/auth/hooks/useOrgRole.ts b/packages/ui/src/features/auth/useOrgRole.ts similarity index 71% rename from apps/code/src/renderer/features/auth/hooks/useOrgRole.ts rename to packages/ui/src/features/auth/useOrgRole.ts index 09ece7ac44..67b7c17280 100644 --- a/apps/code/src/renderer/features/auth/hooks/useOrgRole.ts +++ b/packages/ui/src/features/auth/useOrgRole.ts @@ -1,5 +1,5 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useCurrentUser } from "@features/auth/hooks/authQueries"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; export const ORGANIZATION_ADMIN_LEVEL = 8; diff --git a/packages/ui/src/features/auth/userInitials.ts b/packages/ui/src/features/auth/userInitials.ts new file mode 100644 index 0000000000..4dd3472801 --- /dev/null +++ b/packages/ui/src/features/auth/userInitials.ts @@ -0,0 +1 @@ +export { getUserInitials } from "@posthog/core/auth/userInitials"; diff --git a/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx b/packages/ui/src/features/billing/SidebarUsageBar.tsx similarity index 83% rename from apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx rename to packages/ui/src/features/billing/SidebarUsageBar.tsx index ec1f27bfb9..2efb7564fd 100644 --- a/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx +++ b/packages/ui/src/features/billing/SidebarUsageBar.tsx @@ -1,11 +1,14 @@ -import { useFreeUsage } from "@features/billing/hooks/useFreeUsage"; -import { formatResetTime, isUsageExceeded } from "@features/billing/utils"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { Circle } from "@phosphor-icons/react"; -import { BILLING_FLAG } from "@shared/constants"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; +import { + formatResetTime, + isUsageExceeded, +} from "@posthog/core/billing/usageDisplay"; +import { BILLING_FLAG } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { track } from "../../workbench/analytics"; +import { useFeatureFlag } from "../feature-flags/useFeatureFlag"; +import { useSettingsDialogStore } from "../settings/settingsDialogStore"; +import { useFreeUsage } from "./useFreeUsage"; export function SidebarUsageBar() { const billingEnabled = useFeatureFlag(BILLING_FLAG); diff --git a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx b/packages/ui/src/features/billing/TokenSpendAnalysisBanner.tsx similarity index 80% rename from apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx rename to packages/ui/src/features/billing/TokenSpendAnalysisBanner.tsx index 66c5c5e082..5216351740 100644 --- a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx +++ b/packages/ui/src/features/billing/TokenSpendAnalysisBanner.tsx @@ -1,17 +1,3 @@ -import { useSpendAnalysis } from "@features/billing/hooks/useSpendAnalysis"; -import type { - SpendAnalysisModelRow, - SpendAnalysisProductRow, - SpendAnalysisResponse, - SpendAnalysisToolRow, -} from "@features/billing/types/spend-analysis"; -import { - formatTokens, - formatUsd, - formatWindow, -} from "@features/billing/utils/spendAnalysisFormat"; -import { buildAnalysisPrompt } from "@features/billing/utils/spendAnalysisPrompt"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { ArrowSquareOut, ChartLine, @@ -19,57 +5,29 @@ import { Sparkle, WarningCircle, } from "@phosphor-icons/react"; +import { + formatTokens, + formatUsd, + formatWindow, + windowDays, +} from "@posthog/core/billing/spendAnalysisFormat"; +import { buildAnalysisPrompt } from "@posthog/core/billing/spendAnalysisPrompt"; +import type { + SpendAnalysisModelRow, + SpendAnalysisProductRow, + SpendAnalysisResponse, + SpendAnalysisToolRow, +} from "@posthog/core/billing/spendAnalysisTypes"; +import { deriveSpendSuggestions } from "@posthog/core/billing/spendSuggestions"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useSpendAnalysis } from "@posthog/ui/features/billing/useSpendAnalysis"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { useSettingsDialogStore } from "@posthog/ui/features/settings/settingsDialogStore"; +import { track } from "@posthog/ui/workbench/analytics"; import { Button, Callout, Flex, Spinner, Table, Text } from "@radix-ui/themes"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; -import { track } from "@utils/analytics"; const DOCS_URL = "https://posthog.com/docs/llm-analytics"; -function generateSuggestions(data: SpendAnalysisResponse): string[] { - const suggestions: string[] = []; - const { summary } = data; - const toolItems = data.by_tool.items; - - if (summary.total_cost_usd === 0) { - return ["No LLM spend in the selected window."]; - } - - const codeShare = - summary.scoped_cost_usd / Math.max(summary.total_cost_usd, 0.0001); - if (codeShare > 0.7) { - suggestions.push( - `PostHog Code is ${Math.round(codeShare * 100)}% of your spend. Other AI products (background agents, posthog_ai) are minor here.`, - ); - } - - const codeTotal = summary.scoped_cost_usd; - // codeTotal is the scoped spend (PostHog Code, since the banner always - // requests `product=posthog_code`). - if (codeTotal > 0 && toolItems.length > 0) { - const top = toolItems[0]; - if (top.share_of_scoped > 0.35 && top.tool) { - suggestions.push( - `${top.tool} drives ${Math.round(top.share_of_scoped * 100)}% of your PostHog Code spend — averaging ${formatTokens(top.avg_input_tokens)} input tokens per call.`, - ); - } - const noToolRow = toolItems.find((r) => r.tool === null); - if (noToolRow && noToolRow.share_of_scoped > 0.1) { - suggestions.push( - `${Math.round(noToolRow.share_of_scoped * 100)}% is spent on generations that take no tool action — pure text replies. Consider tighter prompts or stopping the agent earlier.`, - ); - } - } - - if (suggestions.length === 0) { - suggestions.push( - "Your spend is fairly evenly distributed across tools — no single hotspot stands out.", - ); - } - - return suggestions; -} - function SummaryRow({ data }: { data: SpendAnalysisResponse }) { const { summary } = data; const codeShare = @@ -229,14 +187,7 @@ function FooterLinks({ data }: { data: SpendAnalysisResponse }) { total_cost_usd: data.summary.total_cost_usd, scoped_cost_usd: data.summary.scoped_cost_usd, scoped_event_count: data.summary.scoped_event_count, - window_days: Math.max( - 1, - Math.round( - (new Date(data.summary.date_to).getTime() - - new Date(data.summary.date_from).getTime()) / - (1000 * 60 * 60 * 24), - ), - ), + window_days: windowDays(data.summary.date_from, data.summary.date_to), tool_row_count: Math.min(data.by_tool.items.length, 10), model_row_count: data.by_model.items.length, }); @@ -283,7 +234,7 @@ export function TokenSpendAnalysisBanner() { }; if (data) { - const suggestions = generateSuggestions(data); + const suggestions = deriveSpendSuggestions(data); return ( <Flex direction="column" gap="4"> <Flex diff --git a/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx b/packages/ui/src/features/billing/UsageLimitModal.tsx similarity index 87% rename from apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx rename to packages/ui/src/features/billing/UsageLimitModal.tsx index 81e9ab8c33..aa82c9e150 100644 --- a/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx +++ b/packages/ui/src/features/billing/UsageLimitModal.tsx @@ -1,13 +1,13 @@ -import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; -import { formatResetTime } from "@features/billing/utils"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { useSeat } from "@hooks/useSeat"; import { WarningCircle } from "@phosphor-icons/react"; +import { formatResetTime } from "@posthog/core/billing/usageDisplay"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; import { useEffect } from "react"; +import { track } from "../../workbench/analytics"; +import { openExternalUrl } from "../../workbench/openExternal"; +import { useSettingsDialogStore } from "../settings/settingsDialogStore"; +import { useUsageLimitStore } from "./usageLimitStore"; +import { useSeat } from "./useSeat"; const SUPPORT_MAILTO = "mailto:charles@posthog.com?subject=PostHog%20Code%20%E2%80%94%20Pro%20usage%20limit"; @@ -38,7 +38,7 @@ export function UsageLimitModal() { }; const handleSupport = () => { - void trpcClient.os.openExternal.mutate({ url: SUPPORT_MAILTO }); + openExternalUrl(SUPPORT_MAILTO); }; const isDaily = bucket === "burst"; diff --git a/apps/code/src/renderer/features/billing/subscriptions.ts b/packages/ui/src/features/billing/billing.contribution.ts similarity index 53% rename from apps/code/src/renderer/features/billing/subscriptions.ts rename to packages/ui/src/features/billing/billing.contribution.ts index 94efa1bb59..6a714fb885 100644 --- a/apps/code/src/renderer/features/billing/subscriptions.ts +++ b/packages/ui/src/features/billing/billing.contribution.ts @@ -1,20 +1,30 @@ -import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; -import { formatResetTime } from "@features/billing/utils"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { trpcClient } from "@renderer/trpc/client"; -import { logger } from "@utils/logger"; -import { toast } from "@utils/toast"; - -const log = logger.scope("billing-subscriptions"); +import { formatResetTime } from "@posthog/core/billing/usageDisplay"; +import type { WorkbenchContribution } from "@posthog/di/contribution"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { inject, injectable } from "inversify"; +import { toast } from "../../primitives/toast"; +import { useSettingsDialogStore } from "../settings/settingsDialogStore"; +import { useUsageLimitStore } from "./usageLimitStore"; const openPlanUsage = () => { useSettingsDialogStore.getState().open("plan-usage"); }; -export function registerBillingSubscriptions() { - const subscription = trpcClient.usageMonitor.onThresholdCrossed.subscribe( - undefined, - { +@injectable() +export class BillingContribution implements WorkbenchContribution { + constructor( + @inject(WORKBENCH_LOGGER) + private readonly logger: WorkbenchLogger, + @inject(HOST_TRPC_CLIENT) + private readonly hostClient: HostTrpcClient, + ) {} + + start(): void { + this.hostClient.usageMonitor.onThresholdCrossed.subscribe(undefined, { onData: (event) => { const resetLabel = formatResetTime(event.resetAt); @@ -47,12 +57,8 @@ export function registerBillingSubscriptions() { ); }, onError: (error) => { - log.error("Usage threshold subscription error", { error }); + this.logger.error("Usage threshold subscription error", { error }); }, - }, - ); - - return () => { - subscription.unsubscribe(); - }; + }); + } } diff --git a/packages/ui/src/features/billing/billing.module.ts b/packages/ui/src/features/billing/billing.module.ts new file mode 100644 index 0000000000..c5e8289aba --- /dev/null +++ b/packages/ui/src/features/billing/billing.module.ts @@ -0,0 +1,10 @@ +import { SEAT_CLIENT } from "@posthog/core/billing/identifiers"; +import { WORKBENCH_CONTRIBUTION } from "@posthog/di/contribution"; +import { ContainerModule } from "inversify"; +import { BillingContribution } from "./billing.contribution"; +import { UiSeatClient } from "./seatClient"; + +export const billingUiModule = new ContainerModule(({ bind }) => { + bind(WORKBENCH_CONTRIBUTION).to(BillingContribution).inSingletonScope(); + bind(SEAT_CLIENT).to(UiSeatClient).inSingletonScope(); +}); diff --git a/packages/ui/src/features/billing/seatClient.ts b/packages/ui/src/features/billing/seatClient.ts new file mode 100644 index 0000000000..90ae7b15c7 --- /dev/null +++ b/packages/ui/src/features/billing/seatClient.ts @@ -0,0 +1,68 @@ +import type { + SeatClient, + SubscriptionEventProps, +} from "@posthog/core/billing/identifiers"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import type { SeatData } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { inject, injectable } from "inversify"; +import { track } from "../../workbench/analytics"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "../../workbench/queryClient"; +import { getAuthenticatedClient } from "../auth/authClientImperative"; + +async function authedClient() { + const client = await getAuthenticatedClient(); + if (!client) { + throw new Error("Not authenticated"); + } + return client; +} + +@injectable() +export class UiSeatClient implements SeatClient { + constructor( + @inject(HOST_TRPC_CLIENT) + private readonly hostClient: HostTrpcClient, + @inject(IMPERATIVE_QUERY_CLIENT) + private readonly queryClient: ImperativeQueryClient, + ) {} + + async getMySeat(options?: { best?: boolean }): Promise<SeatData | null> { + return (await authedClient()).getMySeat(options); + } + + async createSeat(planKey: string): Promise<SeatData> { + return (await authedClient()).createSeat(planKey); + } + + async upgradeSeat(planKey: string): Promise<SeatData> { + return (await authedClient()).upgradeSeat(planKey); + } + + async cancelSeat(): Promise<void> { + await (await authedClient()).cancelSeat(); + } + + async reactivateSeat(): Promise<SeatData> { + return (await authedClient()).reactivateSeat(); + } + + invalidatePlanCache(): void { + this.hostClient.llmGateway.invalidatePlanCache.mutate().catch(() => {}); + void this.queryClient.invalidateQueries({ queryKey: [["llmGateway"]] }); + } + + trackSubscriptionStarted(props: SubscriptionEventProps): void { + track(ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, props); + } + + trackSubscriptionCancelled(props: SubscriptionEventProps): void { + track(ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED, props); + } +} diff --git a/packages/ui/src/features/billing/seatStore.test.ts b/packages/ui/src/features/billing/seatStore.test.ts new file mode 100644 index 0000000000..36608904b5 --- /dev/null +++ b/packages/ui/src/features/billing/seatStore.test.ts @@ -0,0 +1,134 @@ +import type { SeatOperationResult } from "@posthog/core/billing/seatService"; +import { PLAN_PRO, type SeatData } from "@posthog/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useSeatStore } from "./seatStore"; + +const serviceRef = vi.hoisted( + () => ({ current: null }) as { current: unknown }, +); + +vi.mock("@posthog/di/container", () => ({ + resolveService: () => serviceRef.current, +})); + +function makeSeat(overrides: Partial<SeatData> = {}): SeatData { + return { + id: 1, + user_distinct_id: "user-123", + product_key: "posthog_code", + plan_key: PLAN_PRO, + status: "active", + end_reason: null, + created_at: 1_700_000_000_000, + active_until: null, + active_from: 1_700_000_000_000, + organization_id: "org-1", + ...overrides, + }; +} + +function mockService(result: SeatOperationResult) { + const service = { + fetchSeat: vi.fn().mockResolvedValue(result), + provisionFreeSeat: vi.fn().mockResolvedValue(result), + upgradeToPro: vi.fn().mockResolvedValue(result), + cancelSeat: vi.fn().mockResolvedValue(result), + reactivateSeat: vi.fn().mockResolvedValue(result), + }; + serviceRef.current = service; + return service; +} + +function okResult(seat: SeatData): SeatOperationResult { + return { + seat, + orgSeat: seat, + billingOrgId: seat.organization_id ?? null, + error: null, + redirectUrl: null, + }; +} + +describe("seatStore (thin)", () => { + beforeEach(() => { + vi.clearAllMocks(); + useSeatStore.getState().reset(); + }); + + it("fetchSeat delegates to the service and applies the result", async () => { + const seat = makeSeat(); + const service = mockService(okResult(seat)); + + await useSeatStore.getState().fetchSeat({ autoProvision: true }); + + expect(service.fetchSeat).toHaveBeenCalledWith({ + autoProvision: true, + currentSeat: null, + }); + const state = useSeatStore.getState(); + expect(state.seat).toEqual(seat); + expect(state.billingOrgId).toBe("org-1"); + expect(state.isLoading).toBe(false); + }); + + it("applies a classified error from the service", async () => { + mockService({ + seat: null, + orgSeat: null, + billingOrgId: null, + error: "Billing subscription required", + redirectUrl: "/organization/billing", + }); + + await useSeatStore.getState().fetchSeat(); + + const state = useSeatStore.getState(); + expect(state.error).toBe("Billing subscription required"); + expect(state.redirectUrl).toBe("/organization/billing"); + }); + + it("keeps existing seat when service signals keepExisting", async () => { + const seat = makeSeat(); + useSeatStore.setState({ seat }); + mockService({ + seat, + orgSeat: null, + billingOrgId: "org-1", + error: null, + redirectUrl: null, + keepExisting: true, + }); + + await useSeatStore.getState().fetchSeat(); + + expect(useSeatStore.getState().seat).toEqual(seat); + expect(useSeatStore.getState().isLoading).toBe(false); + }); + + it("cancelSeat passes the current plan_key to the service", async () => { + const seat = makeSeat({ plan_key: PLAN_PRO }); + useSeatStore.setState({ seat }); + const service = mockService(okResult(seat)); + + await useSeatStore.getState().cancelSeat(); + + expect(service.cancelSeat).toHaveBeenCalledWith(PLAN_PRO); + }); + + it("reset clears all state", () => { + useSeatStore.setState({ + seat: makeSeat(), + isLoading: true, + error: "some error", + redirectUrl: "https://example.com", + }); + + useSeatStore.getState().reset(); + + const state = useSeatStore.getState(); + expect(state.seat).toBeNull(); + expect(state.isLoading).toBe(false); + expect(state.error).toBeNull(); + expect(state.redirectUrl).toBeNull(); + }); +}); diff --git a/packages/ui/src/features/billing/seatStore.ts b/packages/ui/src/features/billing/seatStore.ts new file mode 100644 index 0000000000..dde6133a84 --- /dev/null +++ b/packages/ui/src/features/billing/seatStore.ts @@ -0,0 +1,103 @@ +import { SEAT_SERVICE } from "@posthog/core/billing/identifiers"; +import type { + SeatOperationResult, + SeatService, +} from "@posthog/core/billing/seatService"; +import { resolveService } from "@posthog/di/container"; +import type { SeatData } from "@posthog/shared"; +import { create } from "zustand"; + +interface SeatStoreState { + seat: SeatData | null; + orgSeat: SeatData | null; + isLoading: boolean; + error: string | null; + redirectUrl: string | null; + billingOrgId: string | null; +} + +interface SeatStoreActions { + fetchSeat: (options?: { autoProvision?: boolean }) => Promise<void>; + provisionFreeSeat: () => Promise<void>; + upgradeToPro: () => Promise<void>; + cancelSeat: () => Promise<void>; + reactivateSeat: () => Promise<void>; + clearError: () => void; + reset: () => void; +} + +type SeatStore = SeatStoreState & SeatStoreActions; + +const initialState: SeatStoreState = { + seat: null, + orgSeat: null, + isLoading: false, + error: null, + redirectUrl: null, + billingOrgId: null, +}; + +function applyResult( + set: (state: Partial<SeatStoreState>) => void, + result: SeatOperationResult, +): void { + if (result.keepExisting) { + set({ isLoading: false }); + return; + } + set({ + seat: result.seat, + billingOrgId: result.billingOrgId, + error: result.error, + redirectUrl: result.redirectUrl, + isLoading: false, + ...(result.orgSeatUnchanged ? {} : { orgSeat: result.orgSeat }), + }); +} + +export const useSeatStore = create<SeatStore>()((set, get) => ({ + ...initialState, + + fetchSeat: async (options?: { autoProvision?: boolean }) => { + set({ isLoading: true, error: null, redirectUrl: null }); + const service = resolveService<SeatService>(SEAT_SERVICE); + const result = await service.fetchSeat({ + autoProvision: options?.autoProvision, + currentSeat: get().seat, + }); + applyResult(set, result); + }, + + provisionFreeSeat: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + const result = + await resolveService<SeatService>(SEAT_SERVICE).provisionFreeSeat(); + applyResult(set, result); + }, + + upgradeToPro: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + const result = + await resolveService<SeatService>(SEAT_SERVICE).upgradeToPro(); + applyResult(set, result); + }, + + cancelSeat: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + const result = await resolveService<SeatService>(SEAT_SERVICE).cancelSeat( + get().seat?.plan_key, + ); + applyResult(set, result); + }, + + reactivateSeat: async () => { + set({ isLoading: true, error: null, redirectUrl: null }); + const result = + await resolveService<SeatService>(SEAT_SERVICE).reactivateSeat(); + applyResult(set, result); + }, + + clearError: () => set({ error: null, redirectUrl: null }), + + reset: () => set(initialState), +})); diff --git a/apps/code/src/renderer/features/billing/stores/usageLimitStore.test.ts b/packages/ui/src/features/billing/usageLimitStore.test.ts similarity index 100% rename from apps/code/src/renderer/features/billing/stores/usageLimitStore.test.ts rename to packages/ui/src/features/billing/usageLimitStore.test.ts diff --git a/apps/code/src/renderer/features/billing/stores/usageLimitStore.ts b/packages/ui/src/features/billing/usageLimitStore.ts similarity index 100% rename from apps/code/src/renderer/features/billing/stores/usageLimitStore.ts rename to packages/ui/src/features/billing/usageLimitStore.ts diff --git a/apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts b/packages/ui/src/features/billing/useFreeUsage.ts similarity index 85% rename from apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts rename to packages/ui/src/features/billing/useFreeUsage.ts index bfcf56a802..edbe80516f 100644 --- a/apps/code/src/renderer/features/billing/hooks/useFreeUsage.ts +++ b/packages/ui/src/features/billing/useFreeUsage.ts @@ -1,5 +1,5 @@ -import { useSeat } from "@hooks/useSeat"; -import type { UsageOutput } from "@main/services/llm-gateway/schemas"; +import type { UsageOutput } from "@posthog/core/usage/schemas"; +import { useSeat } from "./useSeat"; import { useUsage } from "./useUsage"; export interface FreeUsageResult { diff --git a/packages/ui/src/features/billing/useSeat.ts b/packages/ui/src/features/billing/useSeat.ts new file mode 100644 index 0000000000..7067c88ea4 --- /dev/null +++ b/packages/ui/src/features/billing/useSeat.ts @@ -0,0 +1,23 @@ +import { deriveSeatView } from "@posthog/core/billing/seatView"; +import { useSeatStore } from "./seatStore"; + +export function useSeat() { + const seat = useSeatStore((s) => s.seat); + const orgSeat = useSeatStore((s) => s.orgSeat); + const isLoading = useSeatStore((s) => s.isLoading); + const error = useSeatStore((s) => s.error); + const redirectUrl = useSeatStore((s) => s.redirectUrl); + const billingOrgId = useSeatStore((s) => s.billingOrgId); + + const view = deriveSeatView(seat, orgSeat); + + return { + seat, + orgSeat, + isLoading, + error, + redirectUrl, + billingOrgId, + ...view, + }; +} diff --git a/packages/ui/src/features/billing/useSpendAnalysis.ts b/packages/ui/src/features/billing/useSpendAnalysis.ts new file mode 100644 index 0000000000..df338afe8c --- /dev/null +++ b/packages/ui/src/features/billing/useSpendAnalysis.ts @@ -0,0 +1,50 @@ +import type { SpendAnalysisResponse } from "@posthog/api-client/spend-analysis"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { logger } from "@posthog/ui/workbench/logger"; +import { useCallback, useState } from "react"; + +const log = logger.scope("spend-analysis"); + +interface RunOptions { + dateFrom?: string; + dateTo?: string; + product?: string; +} + +interface UseSpendAnalysisReturn { + data: SpendAnalysisResponse | null; + isLoading: boolean; + error: string | null; + run: (options?: RunOptions) => Promise<void>; +} + +export function useSpendAnalysis(): UseSpendAnalysisReturn { + const client = useOptionalAuthenticatedClient(); + const [data, setData] = useState<SpendAnalysisResponse | null>(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + const run = useCallback( + async (options: RunOptions = {}) => { + setIsLoading(true); + setError(null); + try { + if (!client) { + throw new Error("Not authenticated"); + } + const result = await client.getPersonalSpendAnalysis(options); + setData(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + log.warn("Failed to fetch spend analysis", { error: message }); + setData(null); + setError(message); + } finally { + setIsLoading(false); + } + }, + [client], + ); + + return { data, isLoading, error, run }; +} diff --git a/packages/ui/src/features/billing/useUsage.ts b/packages/ui/src/features/billing/useUsage.ts new file mode 100644 index 0000000000..0689b82503 --- /dev/null +++ b/packages/ui/src/features/billing/useUsage.ts @@ -0,0 +1,42 @@ +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect } from "react"; + +const USAGE_QUERY_KEY = ["billing", "usage", "latest"] as const; + +export function useUsage({ enabled = true }: { enabled?: boolean } = {}) { + const client = useHostTRPCClient(); + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: USAGE_QUERY_KEY, + queryFn: () => client.usageMonitor.getLatest.query(), + enabled, + }); + const { mutateAsync: refreshUsage } = useMutation({ + mutationFn: () => client.usageMonitor.refresh.mutate(), + }); + + useEffect(() => { + if (!enabled) return; + const sub = client.usageMonitor.onUsageUpdated.subscribe(undefined, { + onData: (data) => { + queryClient.setQueryData(USAGE_QUERY_KEY, data); + }, + }); + return () => sub.unsubscribe(); + }, [enabled, client, queryClient]); + + const refetch = useCallback(async () => { + const fresh = await refreshUsage(); + if (fresh) { + queryClient.setQueryData(USAGE_QUERY_KEY, fresh); + } + return fresh; + }, [refreshUsage, queryClient]); + + return { + usage: query.data ?? null, + isLoading: query.isLoading, + refetch, + }; +} diff --git a/packages/ui/src/features/clone/clone.contribution.ts b/packages/ui/src/features/clone/clone.contribution.ts new file mode 100644 index 0000000000..7da2b47129 --- /dev/null +++ b/packages/ui/src/features/clone/clone.contribution.ts @@ -0,0 +1,54 @@ +import { removalDelayMsForStatus } from "@posthog/core/clone/cloneRemovalDelay"; +import type { WorkbenchContribution } from "@posthog/di/contribution"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { cloneStore } from "@posthog/ui/features/clone/cloneStore"; +import { inject, injectable } from "inversify"; + +/** + * Owns the single clone-progress subscription and the auto-dismiss lifecycle. + * + * The store stays a pure projection of progress events; the timer that hides a + * finished clone card lives here, in the boot contribution, not in the store + * (AGENTS.md forbids stores owning subscriptions or domain-cleanup timers). + */ +@injectable() +export class CloneContribution implements WorkbenchContribution { + private readonly pendingRemovals = new Map< + string, + ReturnType<typeof setTimeout> + >(); + + constructor( + @inject(HOST_TRPC_CLIENT) + private readonly hostClient: HostTrpcClient, + ) {} + + start(): void { + this.hostClient.git.onCloneProgress.subscribe(undefined, { + onData: (event) => { + cloneStore.getState().applyProgress(event); + + const delayMs = removalDelayMsForStatus(event.status); + if (delayMs !== null) { + this.scheduleRemoval(event.cloneId, delayMs); + } + }, + }); + } + + private scheduleRemoval(cloneId: string, delayMs: number): void { + const existing = this.pendingRemovals.get(cloneId); + if (existing) clearTimeout(existing); + + this.pendingRemovals.set( + cloneId, + setTimeout(() => { + this.pendingRemovals.delete(cloneId); + cloneStore.getState().removeClone(cloneId); + }, delayMs), + ); + } +} diff --git a/packages/ui/src/features/clone/clone.module.ts b/packages/ui/src/features/clone/clone.module.ts new file mode 100644 index 0000000000..6ff1e1686b --- /dev/null +++ b/packages/ui/src/features/clone/clone.module.ts @@ -0,0 +1,7 @@ +import { WORKBENCH_CONTRIBUTION } from "@posthog/di/contribution"; +import { ContainerModule } from "inversify"; +import { CloneContribution } from "./clone.contribution"; + +export const cloneUiModule = new ContainerModule(({ bind }) => { + bind(WORKBENCH_CONTRIBUTION).to(CloneContribution).inSingletonScope(); +}); diff --git a/packages/ui/src/features/clone/cloneActions.ts b/packages/ui/src/features/clone/cloneActions.ts new file mode 100644 index 0000000000..a6c9e32bc1 --- /dev/null +++ b/packages/ui/src/features/clone/cloneActions.ts @@ -0,0 +1,29 @@ +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { cloneStore } from "@posthog/ui/features/clone/cloneStore"; + +/** + * Start a clone operation. Registers it in the store and kicks off the host + * clone. Progress and terminal status (complete/error) arrive via the + * onCloneProgress subscription owned by CloneContribution, which also schedules + * removal once the operation finishes — this action never owns timers. + */ +export function startClone( + cloneId: string, + repository: string, + targetPath: string, +): void { + cloneStore.getState().beginClone(cloneId, repository, targetPath); + + resolveService<HostTrpcClient>(HOST_TRPC_CLIENT) + .git.cloneRepository.mutate({ repoUrl: repository, targetPath, cloneId }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : "Clone failed"; + cloneStore + .getState() + .applyProgress({ cloneId, status: "error", message }); + }); +} diff --git a/packages/ui/src/features/clone/cloneStore.test.ts b/packages/ui/src/features/clone/cloneStore.test.ts new file mode 100644 index 0000000000..f8175a4f61 --- /dev/null +++ b/packages/ui/src/features/clone/cloneStore.test.ts @@ -0,0 +1,58 @@ +import { cloneStore } from "@posthog/ui/features/clone/cloneStore"; +import { beforeEach, describe, expect, it } from "vitest"; + +const reset = () => cloneStore.setState({ operations: {} }); + +describe("cloneStore", () => { + beforeEach(reset); + + it("registers a cloning operation with beginClone", () => { + cloneStore.getState().beginClone("c1", "owner/repo", "/tmp/repo"); + + const op = cloneStore.getState().operations.c1; + expect(op).toMatchObject({ + cloneId: "c1", + repository: "owner/repo", + targetPath: "/tmp/repo", + status: "cloning", + }); + expect(op.latestMessage).toContain("owner/repo"); + }); + + it("updates status and message from progress events", () => { + cloneStore.getState().beginClone("c1", "owner/repo", "/tmp/repo"); + cloneStore + .getState() + .applyProgress({ cloneId: "c1", status: "cloning", message: "50%" }); + + const op = cloneStore.getState().operations.c1; + expect(op.status).toBe("cloning"); + expect(op.latestMessage).toBe("50%"); + }); + + it("records the error message on an error event", () => { + cloneStore.getState().beginClone("c1", "owner/repo", "/tmp/repo"); + cloneStore + .getState() + .applyProgress({ cloneId: "c1", status: "error", message: "boom" }); + + const op = cloneStore.getState().operations.c1; + expect(op.status).toBe("error"); + expect(op.error).toBe("boom"); + }); + + it("ignores progress for an unknown cloneId", () => { + cloneStore + .getState() + .applyProgress({ cloneId: "ghost", status: "complete", message: "done" }); + + expect(cloneStore.getState().operations.ghost).toBeUndefined(); + }); + + it("removes an operation with removeClone", () => { + cloneStore.getState().beginClone("c1", "owner/repo", "/tmp/repo"); + cloneStore.getState().removeClone("c1"); + + expect(cloneStore.getState().operations.c1).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/features/clone/cloneStore.ts b/packages/ui/src/features/clone/cloneStore.ts new file mode 100644 index 0000000000..1615a2d080 --- /dev/null +++ b/packages/ui/src/features/clone/cloneStore.ts @@ -0,0 +1,64 @@ +import type { + CloneOperation, + CloneProgressEvent, +} from "@posthog/core/clone/cloneTypes"; +import { create } from "zustand"; + +export type { + CloneOperation, + CloneProgressEvent, + CloneRepositoryInput, + CloneStatus, +} from "@posthog/core/clone/cloneTypes"; + +interface CloneStore { + operations: Record<string, CloneOperation>; + beginClone: (cloneId: string, repository: string, targetPath: string) => void; + applyProgress: (event: CloneProgressEvent) => void; + removeClone: (cloneId: string) => void; +} + +export const cloneStore = create<CloneStore>((set) => ({ + operations: {}, + + beginClone: (cloneId, repository, targetPath) => { + set((state) => ({ + operations: { + ...state.operations, + [cloneId]: { + cloneId, + repository, + targetPath, + status: "cloning", + latestMessage: `Cloning ${repository}...`, + }, + }, + })); + }, + + applyProgress: (event) => { + set((state) => { + const operation = state.operations[event.cloneId]; + if (!operation) return state; + + return { + operations: { + ...state.operations, + [event.cloneId]: { + ...operation, + status: event.status, + latestMessage: event.message, + error: event.status === "error" ? event.message : operation.error, + }, + }, + }; + }); + }, + + removeClone: (cloneId) => { + set((state) => { + const { [cloneId]: _removed, ...remainingOps } = state.operations; + return { operations: remainingOps }; + }); + }, +})); diff --git a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx b/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx similarity index 71% rename from apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx rename to packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx index aa7b1f7bca..dc71990e01 100644 --- a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx +++ b/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx @@ -1,31 +1,39 @@ -import { PanelMessage } from "@components/ui/PanelMessage"; -import { SafeImagePreview } from "@components/ui/SafeImagePreview"; -import { Tooltip } from "@components/ui/Tooltip"; -import { CodeMirrorEditor } from "@features/code-editor/components/CodeMirrorEditor"; -import { EnrichmentPopover } from "@features/code-editor/components/EnrichmentPopover"; -import { useCloudFileContent } from "@features/code-editor/hooks/useCloudFileContent"; -import { useFileEnrichment } from "@features/code-editor/hooks/useFileEnrichment"; -import { isMarkdownFile } from "@features/code-editor/utils/markdownUtils"; -import { getRelativePath } from "@features/code-editor/utils/pathUtils"; -import { usePanelLayoutStore } from "@features/panels"; -import { useFileTreeStore } from "@features/right-sidebar/stores/fileTreeStore"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; import { Check, Copy } from "@phosphor-icons/react"; +import { isMarkdownFile } from "@posthog/core/code-editor/fileKind"; +import { + collapseFileState, + resolveMarkdownLink, + selectFileSource, +} from "@posthog/core/code-editor/fileSource"; +import { getRelativePath } from "@posthog/core/code-editor/pathUtils"; import { getImageMimeType, isRasterImageFile, parseImageDataUrl, } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; - -import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { PanelMessage } from "../../../primitives/PanelMessage"; +import { SafeImagePreview } from "../../../primitives/SafeImagePreview"; +import { Tooltip } from "../../../primitives/Tooltip"; +import { openExternalUrl } from "../../../workbench/openExternal"; +import { usePanelLayoutStore } from "../../panels/panelLayoutStore"; +import { useFileTreeStore } from "../../right-sidebar/fileTreeStore"; +import { useCwd } from "../../sidebar/useCwd"; +import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace"; +import { useCloudFileContent } from "../hooks/useCloudFileContent"; +import { + useAbsoluteFileContent, + useFileAsBase64, + useRepoFileContent, +} from "../hooks/useFileContent"; +import { useFileEnrichment } from "../hooks/useFileEnrichment"; +import { CodeMirrorEditor } from "./CodeMirrorEditor"; +import { EnrichmentPopover } from "./EnrichmentPopover"; interface CodeEditorPanelProps { taskId: string; @@ -72,7 +80,6 @@ export function CodeEditorPanel({ task: _task, absolutePath, }: CodeEditorPanelProps) { - const trpcReact = useTRPC(); const repoPath = useCwd(taskId); const isInsideRepo = !!repoPath && absolutePath.startsWith(repoPath); const filePath = getRelativePath(absolutePath, repoPath); @@ -85,19 +92,17 @@ export function CodeEditorPanel({ const handleMarkdownLinkClick = useCallback( (e: React.MouseEvent<HTMLAnchorElement>, href: string) => { e.preventDefault(); - if (href.startsWith("http://") || href.startsWith("https://")) { - trpcClient.os.openExternal.mutate({ url: href }); + const link = resolveMarkdownLink(href, filePath, repoPath); + if (link.kind === "external") { + openExternalUrl(link.href); return; } - const cleanHref = href.replace(/^\.\//, ""); - const dir = filePath.includes("/") - ? filePath.slice(0, filePath.lastIndexOf("/")) - : ""; - const resolved = dir ? `${dir}/${cleanHref}` : cleanHref; - if (repoPath) { - expandToFile(taskId, `${repoPath}/${resolved}`); + if (link.absolutePath) { + expandToFile(taskId, link.absolutePath); + } + if (link.relativePath) { + openFileInSplit(taskId, link.relativePath); } - openFileInSplit(taskId, resolved); }, [filePath, taskId, repoPath, openFileInSplit, expandToFile], ); @@ -120,40 +125,34 @@ export function CodeEditorPanel({ ); const isCloudRun = useIsWorkspaceCloudRun(taskId); - const cloudFile = useCloudFileContent( - taskId, - filePath, - isCloudRun && !isImage, - ); + const source = selectFileSource({ isInsideRepo, isCloudRun, isImage }); - const repoQuery = useQuery( - trpcReact.fs.readRepoFile.queryOptions( - { repoPath: repoPath ?? "", filePath }, - { enabled: isInsideRepo && !isImage && !isCloudRun, staleTime: Infinity }, - ), - ); - - const absoluteQuery = useQuery( - trpcReact.fs.readAbsoluteFile.queryOptions( - { filePath: absolutePath }, - { - enabled: !isInsideRepo && !isImage && !isCloudRun, - staleTime: Infinity, - }, - ), + const cloudFile = useCloudFileContent(taskId, filePath, source.cloudEnabled); + const repoQuery = useRepoFileContent( + repoPath ?? "", + filePath, + source.repoEnabled, ); - - const imageQuery = useQuery( - trpcReact.fs.readFileAsBase64.queryOptions( - { filePath: absolutePath }, - { enabled: isImage && !isCloudRun, staleTime: Infinity }, - ), + const absoluteQuery = useAbsoluteFileContent( + absolutePath, + source.absoluteEnabled, ); + const imageQuery = useFileAsBase64(absolutePath, source.imageEnabled); const localQuery = isInsideRepo ? repoQuery : absoluteQuery; - const fileContent = isCloudRun ? cloudFile.content : localQuery.data; - const isLoading = isCloudRun ? cloudFile.isLoading : localQuery.isLoading; - const error = isCloudRun ? null : localQuery.error; + const { + content: fileContent, + isLoading, + error, + } = collapseFileState({ + cloudFile: { content: cloudFile.content, isLoading: cloudFile.isLoading }, + localQuery: { + content: localQuery.data, + isLoading: localQuery.isLoading, + error: localQuery.error, + }, + isCloudRun, + }); const enrichment = useFileEnrichment({ taskId, diff --git a/apps/code/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx b/packages/ui/src/features/code-editor/components/CodeMirrorEditor.tsx similarity index 96% rename from apps/code/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx rename to packages/ui/src/features/code-editor/components/CodeMirrorEditor.tsx index bddbdbcfc6..d5ea22a1af 100644 --- a/apps/code/src/renderer/features/code-editor/components/CodeMirrorEditor.tsx +++ b/packages/ui/src/features/code-editor/components/CodeMirrorEditor.tsx @@ -1,12 +1,12 @@ import { openSearchPanel } from "@codemirror/search"; import { EditorView } from "@codemirror/view"; -import type { SerializedEnrichment } from "@posthog/enricher"; +import type { SerializedEnrichment } from "@posthog/shared"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useEffect, useMemo } from "react"; import { setEnrichmentEffect } from "../extensions/postHogEnrichment"; import { useCodeMirror } from "../hooks/useCodeMirror"; import { useEditorExtensions } from "../hooks/useEditorExtensions"; -import { usePendingScrollStore } from "../stores/pendingScrollStore"; +import { usePendingScrollStore } from "../pendingScrollStore"; interface CodeMirrorEditorProps { content: string; diff --git a/apps/code/src/renderer/features/code-editor/components/EnrichmentPopover.tsx b/packages/ui/src/features/code-editor/components/EnrichmentPopover.tsx similarity index 84% rename from apps/code/src/renderer/features/code-editor/components/EnrichmentPopover.tsx rename to packages/ui/src/features/code-editor/components/EnrichmentPopover.tsx index b837f4e49c..46dc4e4e2e 100644 --- a/apps/code/src/renderer/features/code-editor/components/EnrichmentPopover.tsx +++ b/packages/ui/src/features/code-editor/components/EnrichmentPopover.tsx @@ -1,47 +1,29 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { ArrowSquareOut } from "@phosphor-icons/react"; -import type { SerializedEvent, SerializedFlag } from "@posthog/enricher"; +import { + compactNumber, + relativeTime, + stalenessLabel, +} from "@posthog/core/code-editor/enrichmentPresenters"; import { Badge, Button, Card } from "@posthog/quill"; -import { trpcClient } from "@renderer/trpc/client"; +import type { SerializedEvent, SerializedFlag } from "@posthog/shared"; import { eventDefinitionUrl, experimentUrl, flagUrl, flagUrlByKey, type LinkOverrides, -} from "@utils/posthogLinks"; +} from "@posthog/ui/utils/posthogLinks"; import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; +import { openExternalUrl } from "../../../workbench/openExternal"; +import { useAuthStateValue } from "../../auth/store"; import { useEnrichmentPopoverStore } from "../stores/enrichmentPopoverStore"; const POPOVER_WIDTH = 320; const GAP = 8; -function compactNumber(n: number): string { - if (n < 1000) return `${n}`; - if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`; - return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; -} - -function relativeTime(iso: string | null): string | null { - if (!iso) return null; - const then = Date.parse(iso); - if (Number.isNaN(then)) return null; - const diffSec = Math.max(0, Math.round((Date.now() - then) / 1000)); - if (diffSec < 60) return `${diffSec}s ago`; - const diffMin = Math.round(diffSec / 60); - if (diffMin < 60) return `${diffMin}m ago`; - const diffHr = Math.round(diffMin / 60); - if (diffHr < 24) return `${diffHr}h ago`; - const diffDay = Math.round(diffHr / 24); - if (diffDay < 30) return `${diffDay}d ago`; - const diffMon = Math.round(diffDay / 30); - if (diffMon < 12) return `${diffMon}mo ago`; - return `${Math.round(diffMon / 12)}y ago`; -} - function openExternal(url: string) { - void trpcClient.os.openExternal.mutate({ url }); + openExternalUrl(url); } function FlagBody({ @@ -59,13 +41,6 @@ function FlagBody({ ? experimentUrl(flag.experiment.id, linkOverrides) : null; - const stalenessLabel: Record<NonNullable<typeof flag.staleness>, string> = { - fully_rolled_out: "Fully rolled out", - inactive: "Inactive", - not_in_posthog: "Not in PostHog", - experiment_complete: "Experiment complete", - }; - return ( <div className="flex flex-col gap-2 px-3 py-2"> <div className="flex items-center justify-between gap-2"> @@ -83,7 +58,7 @@ function FlagBody({ <Badge variant={flag.staleness === "inactive" ? "destructive" : "warning"} > - {stalenessLabel[flag.staleness]} + {stalenessLabel(flag.staleness)} </Badge> </div> )} diff --git a/apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts b/packages/ui/src/features/code-editor/diffViewerStore.ts similarity index 95% rename from apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts rename to packages/ui/src/features/code-editor/diffViewerStore.ts index 4a5a49442f..482b20d0af 100644 --- a/apps/code/src/renderer/features/code-editor/stores/diffViewerStore.ts +++ b/packages/ui/src/features/code-editor/diffViewerStore.ts @@ -1,5 +1,5 @@ -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { track } from "@posthog/ui/workbench/analytics"; import { create } from "zustand"; import { persist } from "zustand/middleware"; diff --git a/apps/code/src/renderer/features/code-editor/extensions/postHogEnrichment.ts b/packages/ui/src/features/code-editor/extensions/postHogEnrichment.ts similarity index 72% rename from apps/code/src/renderer/features/code-editor/extensions/postHogEnrichment.ts rename to packages/ui/src/features/code-editor/extensions/postHogEnrichment.ts index 4810af263c..58e58902f0 100644 --- a/apps/code/src/renderer/features/code-editor/extensions/postHogEnrichment.ts +++ b/packages/ui/src/features/code-editor/extensions/postHogEnrichment.ts @@ -11,64 +11,22 @@ import { ViewPlugin, type ViewUpdate, } from "@codemirror/view"; -import type { SerializedEnrichment } from "@posthog/enricher"; import { - type EnrichmentPopoverEntry, - useEnrichmentPopoverStore, -} from "../stores/enrichmentPopoverStore"; + buildEnrichmentOccurrences, + type EnrichmentOccurrence, +} from "@posthog/core/code-editor/buildEnrichmentOccurrences"; +import type { SerializedEnrichment } from "@posthog/shared"; +import { useEnrichmentPopoverStore } from "../stores/enrichmentPopoverStore"; export const setEnrichmentEffect = StateEffect.define<SerializedEnrichment | null>(); -interface Occurrence { - /** 1-indexed CodeMirror line number. */ - line: number; - startCol: number; - endCol: number; - entry: EnrichmentPopoverEntry; - summary: string; -} - -function buildOccurrences(data: SerializedEnrichment | null): Occurrence[] { - if (!data) return []; - const out: Occurrence[] = []; - - for (const flag of data.flags) { - for (const occ of flag.occurrences) { - out.push({ - line: occ.line + 1, - startCol: occ.startCol, - endCol: occ.endCol, - entry: { kind: "flag", data: flag }, - summary: `Flag: ${flag.flagKey}`, - }); - } - } - for (const event of data.events) { - for (const occ of event.occurrences) { - out.push({ - line: occ.line + 1, - startCol: occ.startCol, - endCol: occ.endCol, - entry: { kind: "event", data: event }, - summary: `Event: ${event.eventName}`, - }); - } - } - - // RangeSetBuilder requires ranges in document order. - out.sort((a, b) => - a.line !== b.line ? a.line - b.line : a.startCol - b.startCol, - ); - return out; -} - -const enrichmentField = StateField.define<Occurrence[]>({ +const enrichmentField = StateField.define<EnrichmentOccurrence[]>({ create: () => [], update(value, tr) { for (const effect of tr.effects) { if (effect.is(setEnrichmentEffect)) { - return buildOccurrences(effect.value); + return buildEnrichmentOccurrences(effect.value); } } return value; @@ -92,7 +50,10 @@ const pillStyles = EditorView.baseTheme({ }, }); -function openPopoverFor(view: EditorView, occurrence: Occurrence): void { +function openPopoverFor( + view: EditorView, + occurrence: EnrichmentOccurrence, +): void { const line = view.state.doc.line(occurrence.line); const from = Math.min(line.from + occurrence.startCol, line.to); const to = Math.min(line.from + occurrence.endCol, line.to); diff --git a/apps/code/src/renderer/features/code-editor/hooks/useCloudFileContent.ts b/packages/ui/src/features/code-editor/hooks/useCloudFileContent.ts similarity index 82% rename from apps/code/src/renderer/features/code-editor/hooks/useCloudFileContent.ts rename to packages/ui/src/features/code-editor/hooks/useCloudFileContent.ts index 34169b1dcb..b95b86db2f 100644 --- a/apps/code/src/renderer/features/code-editor/hooks/useCloudFileContent.ts +++ b/packages/ui/src/features/code-editor/hooks/useCloudFileContent.ts @@ -1,9 +1,9 @@ -import { useCloudEventSummary } from "@features/task-detail/hooks/useCloudEventSummary"; import { type CloudFileContent, extractCloudFileContent, -} from "@features/task-detail/utils/cloudToolChanges"; +} from "@posthog/core/task-detail/cloudToolChanges"; import { useMemo } from "react"; +import { useCloudEventSummary } from "../../task-detail/hooks/useCloudEventSummary"; export type CloudFileResult = CloudFileContent & { isLoading: boolean }; diff --git a/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts b/packages/ui/src/features/code-editor/hooks/useCodeMirror.ts similarity index 57% rename from apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts rename to packages/ui/src/features/code-editor/hooks/useCodeMirror.ts index 4dd162a5d6..13ce407f5b 100644 --- a/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts +++ b/packages/ui/src/features/code-editor/hooks/useCodeMirror.ts @@ -1,9 +1,8 @@ import { EditorState, type Extension } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; -import { trpcClient } from "@renderer/trpc/client"; -import { handleExternalAppAction } from "@utils/handleExternalAppAction"; +import { useHostTRPCClient } from "@posthog/host-router/react"; import { useEffect, useRef } from "react"; +import { useFileContextMenu } from "../../sessions/components/useFileContextMenu"; interface UseCodeMirrorOptions { doc: string; @@ -14,6 +13,8 @@ interface UseCodeMirrorOptions { export function useCodeMirror(options: UseCodeMirrorOptions) { const containerRef = useRef<HTMLDivElement>(null); const instanceRef = useRef<EditorView | null>(null); + const { openForFile } = useFileContextMenu(); + const hostClient = useHostTRPCClient(); useEffect(() => { if (!containerRef.current) return; @@ -43,33 +44,22 @@ export function useCodeMirror(options: UseCodeMirrorOptions) { const handleContextMenu = async (e: MouseEvent) => { e.preventDefault(); - const result = await trpcClient.contextMenu.showFileContextMenu.mutate({ - filePath, - }); - - if (!result.action) return; - - if (result.action.type === "external-app") { - const fileName = filePath.split("/").pop() || "file"; - const workspaces = await workspaceApi.getAll(); - const workspace = - Object.values(workspaces).find( - (ws) => - (ws?.worktreePath && filePath.startsWith(ws.worktreePath)) || - (ws?.folderPath && filePath.startsWith(ws.folderPath)), - ) ?? null; - - await handleExternalAppAction( - result.action.action, - filePath, - fileName, - { - workspace, - mainRepoPath: workspace?.folderPath, - }, - ); - } + const filename = filePath.split("/").pop() || "file"; + const workspaces = await hostClient.workspace.getAll.query(); + const workspace = + Object.values(workspaces).find( + (ws) => + (ws?.worktreePath && filePath.startsWith(ws.worktreePath)) || + (ws?.folderPath && filePath.startsWith(ws.folderPath)), + ) ?? null; + + await openForFile({ + absolutePath: filePath, + filename, + workspace, + mainRepoPath: workspace?.folderPath, + }); }; domElement.addEventListener("contextmenu", handleContextMenu); @@ -77,7 +67,7 @@ export function useCodeMirror(options: UseCodeMirrorOptions) { return () => { domElement.removeEventListener("contextmenu", handleContextMenu); }; - }, [options.filePath]); + }, [options.filePath, openForFile, hostClient]); return { containerRef, instanceRef }; } diff --git a/apps/code/src/renderer/features/code-editor/hooks/useEditorExtensions.ts b/packages/ui/src/features/code-editor/hooks/useEditorExtensions.ts similarity index 82% rename from apps/code/src/renderer/features/code-editor/hooks/useEditorExtensions.ts rename to packages/ui/src/features/code-editor/hooks/useEditorExtensions.ts index 8bb2ace3fd..0e1f245e4c 100644 --- a/apps/code/src/renderer/features/code-editor/hooks/useEditorExtensions.ts +++ b/packages/ui/src/features/code-editor/hooks/useEditorExtensions.ts @@ -10,11 +10,14 @@ import { keymap, lineNumbers, } from "@codemirror/view"; -import { useThemeStore } from "@stores/themeStore"; +import { + oneDark, + oneLight, +} from "@posthog/ui/features/code-editor/theme/editorTheme"; +import { getLanguageExtension } from "@posthog/ui/features/code-editor/utils/languages"; +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; import { useMemo } from "react"; import { postHogEnrichmentExtension } from "../extensions/postHogEnrichment"; -import { oneDark, oneLight } from "../theme/editorTheme"; -import { getLanguageExtension } from "../utils/languages"; export function useEditorExtensions( filePath?: string, diff --git a/packages/ui/src/features/code-editor/hooks/useFileContent.ts b/packages/ui/src/features/code-editor/hooks/useFileContent.ts new file mode 100644 index 0000000000..a13018806c --- /dev/null +++ b/packages/ui/src/features/code-editor/hooks/useFileContent.ts @@ -0,0 +1,45 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; + +export function useRepoFileContent( + repoPath: string, + filePath: string, + enabled: boolean, +) { + const trpc = useHostTRPC(); + return useQuery( + trpc.fs.readRepoFile.queryOptions( + { repoPath, filePath }, + { + enabled, + staleTime: Number.POSITIVE_INFINITY, + }, + ), + ); +} + +export function useAbsoluteFileContent(filePath: string, enabled: boolean) { + const trpc = useHostTRPC(); + return useQuery( + trpc.fs.readAbsoluteFile.queryOptions( + { filePath }, + { + enabled, + staleTime: Number.POSITIVE_INFINITY, + }, + ), + ); +} + +export function useFileAsBase64(filePath: string, enabled: boolean) { + const trpc = useHostTRPC(); + return useQuery( + trpc.fs.readFileAsBase64.queryOptions( + { filePath }, + { + enabled, + staleTime: Number.POSITIVE_INFINITY, + }, + ), + ); +} diff --git a/packages/ui/src/features/code-editor/hooks/useFileEnrichment.ts b/packages/ui/src/features/code-editor/hooks/useFileEnrichment.ts new file mode 100644 index 0000000000..0e138f5c8f --- /dev/null +++ b/packages/ui/src/features/code-editor/hooks/useFileEnrichment.ts @@ -0,0 +1,43 @@ +import { isEnrichmentEligible } from "@posthog/core/code-editor/enrichmentEligibility"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { SerializedEnrichment } from "@posthog/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useAuthStateValue } from "../../auth/store"; + +interface UseFileEnrichmentOptions { + taskId: string; + filePath: string; + absolutePath?: string; + content: string | null | undefined; +} + +export function useFileEnrichment({ + taskId, + filePath, + absolutePath, + content, +}: UseFileEnrichmentOptions): SerializedEnrichment | null { + const trpc = useHostTRPC(); + const isAuthenticated = useAuthStateValue( + (s) => s.status === "authenticated", + ); + + const eligible = isEnrichmentEligible(filePath, content); + + const query = useQuery( + trpc.enrichment.enrichFile.queryOptions( + { + taskId, + filePath, + absolutePath, + content: content ?? "", + }, + { + enabled: eligible && isAuthenticated, + staleTime: Number.POSITIVE_INFINITY, + }, + ), + ); + + return query.data ?? null; +} diff --git a/apps/code/src/renderer/features/code-editor/stores/pendingScrollStore.ts b/packages/ui/src/features/code-editor/pendingScrollStore.ts similarity index 100% rename from apps/code/src/renderer/features/code-editor/stores/pendingScrollStore.ts rename to packages/ui/src/features/code-editor/pendingScrollStore.ts diff --git a/apps/code/src/renderer/features/code-editor/stores/enrichmentPopoverStore.ts b/packages/ui/src/features/code-editor/stores/enrichmentPopoverStore.ts similarity index 76% rename from apps/code/src/renderer/features/code-editor/stores/enrichmentPopoverStore.ts rename to packages/ui/src/features/code-editor/stores/enrichmentPopoverStore.ts index e12b50278d..99c20e4f7c 100644 --- a/apps/code/src/renderer/features/code-editor/stores/enrichmentPopoverStore.ts +++ b/packages/ui/src/features/code-editor/stores/enrichmentPopoverStore.ts @@ -1,9 +1,7 @@ -import type { SerializedEvent, SerializedFlag } from "@posthog/enricher"; +import type { EnrichmentPopoverEntry } from "@posthog/core/code-editor/buildEnrichmentOccurrences"; import { create } from "zustand"; -export type EnrichmentPopoverEntry = - | { kind: "flag"; data: SerializedFlag } - | { kind: "event"; data: SerializedEvent }; +export type { EnrichmentPopoverEntry }; export interface PopoverAnchorRect { top: number; diff --git a/apps/code/src/renderer/features/code-editor/theme/editorTheme.ts b/packages/ui/src/features/code-editor/theme/editorTheme.ts similarity index 100% rename from apps/code/src/renderer/features/code-editor/theme/editorTheme.ts rename to packages/ui/src/features/code-editor/theme/editorTheme.ts diff --git a/apps/code/src/renderer/features/code-editor/utils/languages.ts b/packages/ui/src/features/code-editor/utils/languages.ts similarity index 100% rename from apps/code/src/renderer/features/code-editor/utils/languages.ts rename to packages/ui/src/features/code-editor/utils/languages.ts diff --git a/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx b/packages/ui/src/features/code-review/components/CloudReviewPage.tsx similarity index 74% rename from apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx rename to packages/ui/src/features/code-review/components/CloudReviewPage.tsx index 3bbb8d910a..5f92127f31 100644 --- a/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx +++ b/packages/ui/src/features/code-review/components/CloudReviewPage.tsx @@ -1,11 +1,13 @@ -import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; -import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails"; -import { useCloudChangedFiles } from "@features/task-detail/hooks/useCloudChangedFiles"; -import { extractCloudFileDiff } from "@features/task-detail/utils/cloudToolChanges"; +import { buildToolCallFallbacks } from "@posthog/core/code-review/buildToolCallFallbacks"; +import { buildGithubFileUrl } from "@posthog/core/code-review/reviewItemKeys"; +import { extractCloudFileDiff } from "@posthog/core/task-detail/cloudToolChanges"; +import type { Task } from "@posthog/shared/domain-types"; import { Flex, Spinner, Text } from "@radix-ui/themes"; -import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; -import type { Task } from "@shared/types"; import { useMemo } from "react"; +import { useDiffViewerStore } from "../../code-editor/diffViewerStore"; +import { usePrDetails } from "../../git-interaction/usePrDetails"; +import { useCloudChangedFiles } from "../../task-detail/hooks/useCloudChangedFiles"; +import { useReviewNavigationStore } from "../reviewNavigationStore"; import { PatchedFileDiff } from "./PatchedFileDiff"; import { buildItemIndex, @@ -50,25 +52,20 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) { uncollapseFile, } = useReviewState(reviewFiles, allPaths); - const toolCallFallbacks = useMemo(() => { - if (remoteFiles.length > 0) return undefined; - const diffs = new Map< - string, - { oldText: string | null; newText: string | null } - >(); - for (const file of reviewFiles) { - const diff = extractCloudFileDiff(toolCalls, file.path); - if (diff) diffs.set(file.path, diff); - } - return diffs; - }, [remoteFiles.length, toolCalls, reviewFiles]); + const toolCallFallbacks = useMemo( + () => + buildToolCallFallbacks( + remoteFiles.length > 0, + reviewFiles.map((f) => f.path), + (path) => extractCloudFileDiff(toolCalls, path) ?? undefined, + ), + [remoteFiles.length, toolCalls, reviewFiles], + ); const items = useMemo<ReviewListItem[]>(() => { return reviewFiles.map((file) => { const isCollapsed = collapsedFiles.has(file.path); - const githubFileUrl = prUrl - ? `${prUrl}/files#diff-${file.path.replaceAll("/", "-")}` - : undefined; + const githubFileUrl = buildGithubFileUrl(prUrl, file.path); return { key: file.path, diff --git a/apps/code/src/renderer/features/code-review/components/CommentAnnotation.tsx b/packages/ui/src/features/code-review/components/CommentAnnotation.tsx similarity index 94% rename from apps/code/src/renderer/features/code-review/components/CommentAnnotation.tsx rename to packages/ui/src/features/code-review/components/CommentAnnotation.tsx index 4886291c90..02dc8c14a7 100644 --- a/apps/code/src/renderer/features/code-review/components/CommentAnnotation.tsx +++ b/packages/ui/src/features/code-review/components/CommentAnnotation.tsx @@ -1,6 +1,6 @@ -import { sendPromptToAgent } from "@features/sessions/utils/sendPromptToAgent"; import { ArrowUp, Trash } from "@phosphor-icons/react"; import type { AnnotationSide } from "@pierre/diffs"; +import { buildInlineCommentPrompt } from "@posthog/core/code-review/reviewPrompts"; import { Checkbox, InputGroup, @@ -9,10 +9,10 @@ import { InputGroupTextarea, } from "@posthog/quill"; import { Text, Tooltip } from "@radix-ui/themes"; -import { isSendMessageSubmitKey } from "@utils/sendMessageKey"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useReviewDraftsStore } from "../stores/reviewDraftsStore"; -import { buildInlineCommentPrompt } from "../utils/reviewPrompts"; +import { isSendMessageSubmitKey } from "../../../utils/sendMessageKey"; +import { sendPromptToAgent } from "../../sessions/sendPromptToAgent"; +import { useReviewDraftsStore } from "../reviewDraftsStore"; interface CommentAnnotationProps { taskId: string; diff --git a/apps/code/src/renderer/features/code-review/components/DiffSettingsMenu.tsx b/packages/ui/src/features/code-review/components/DiffSettingsMenu.tsx similarity index 95% rename from apps/code/src/renderer/features/code-review/components/DiffSettingsMenu.tsx rename to packages/ui/src/features/code-review/components/DiffSettingsMenu.tsx index ffbdfe7940..3fca6ae674 100644 --- a/apps/code/src/renderer/features/code-review/components/DiffSettingsMenu.tsx +++ b/packages/ui/src/features/code-review/components/DiffSettingsMenu.tsx @@ -7,7 +7,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@posthog/quill"; -import { useDiffViewerStore } from "@renderer/features/code-editor/stores/diffViewerStore"; +import { useDiffViewerStore } from "@posthog/ui/features/code-editor/diffViewerStore"; export function DiffSettingsMenu() { const wordWrap = useDiffViewerStore((s) => s.wordWrap); diff --git a/apps/code/src/renderer/features/code-review/components/DiffSourceSelector.tsx b/packages/ui/src/features/code-review/components/DiffSourceSelector.tsx similarity index 92% rename from apps/code/src/renderer/features/code-review/components/DiffSourceSelector.tsx rename to packages/ui/src/features/code-review/components/DiffSourceSelector.tsx index f9335ad4ba..66db1921fa 100644 --- a/apps/code/src/renderer/features/code-review/components/DiffSourceSelector.tsx +++ b/packages/ui/src/features/code-review/components/DiffSourceSelector.tsx @@ -4,6 +4,7 @@ import { GitPullRequest, HardDrives, } from "@phosphor-icons/react"; +import type { ResolvedDiffSource } from "@posthog/core/code-review/resolveDiffSource"; import { Button, DropdownMenu, @@ -11,8 +12,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@posthog/quill"; -import { useDiffViewerStore } from "@renderer/features/code-editor/stores/diffViewerStore"; -import type { ResolvedDiffSource } from "../utils/resolveDiffSource"; +import { useDiffViewerStore } from "@posthog/ui/features/code-editor/diffViewerStore"; interface DiffSourceSelectorProps { taskId: string; diff --git a/apps/code/src/renderer/features/code-review/components/DraftCommentAnnotation.tsx b/packages/ui/src/features/code-review/components/DraftCommentAnnotation.tsx similarity index 95% rename from apps/code/src/renderer/features/code-review/components/DraftCommentAnnotation.tsx rename to packages/ui/src/features/code-review/components/DraftCommentAnnotation.tsx index d14cd6d59e..4923adec30 100644 --- a/apps/code/src/renderer/features/code-review/components/DraftCommentAnnotation.tsx +++ b/packages/ui/src/features/code-review/components/DraftCommentAnnotation.tsx @@ -1,6 +1,6 @@ import { PencilSimple, Trash } from "@phosphor-icons/react"; +import { useReviewDraftsStore } from "@posthog/ui/features/code-review/reviewDraftsStore"; import { Badge, Flex, IconButton, Text } from "@radix-ui/themes"; -import { useReviewDraftsStore } from "../stores/reviewDraftsStore"; interface DraftCommentAnnotationProps { taskId: string; diff --git a/apps/code/src/renderer/features/code-review/components/InteractiveFileDiff.tsx b/packages/ui/src/features/code-review/components/InteractiveFileDiff.tsx similarity index 84% rename from apps/code/src/renderer/features/code-review/components/InteractiveFileDiff.tsx rename to packages/ui/src/features/code-review/components/InteractiveFileDiff.tsx index 25bb5c0e4b..57d3630d99 100644 --- a/apps/code/src/renderer/features/code-review/components/InteractiveFileDiff.tsx +++ b/packages/ui/src/features/code-review/components/InteractiveFileDiff.tsx @@ -1,33 +1,28 @@ import { ArrowCounterClockwise } from "@phosphor-icons/react"; -import { - type DiffLineAnnotation, - diffAcceptRejectHunk, - parseDiffFromFile, -} from "@pierre/diffs"; +import type { DiffLineAnnotation } from "@pierre/diffs"; import { FileDiff, MultiFileDiff } from "@pierre/diffs/react"; -import { useInView } from "@renderer/hooks/useInView"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { useQueryClient } from "@tanstack/react-query"; +import { + buildCommentMergedOptions, + buildDraftAnnotations, + buildHunkAnnotations, +} from "@posthog/core/code-review/diffAnnotations"; +import { buildFileAnnotations } from "@posthog/core/code-review/prCommentAnnotations"; import { useCallback, useMemo, useRef, useState } from "react"; +import { useInView } from "../../../primitives/hooks/useInView"; import { DIFF_METRICS, REVIEW_PREFETCH_ROOT_MARGIN } from "../constants"; import { type CommentEditSeed, useCommentState, } from "../hooks/useCommentState"; import { useExpandableFileDiff } from "../hooks/useExpandableFileDiff"; -import { useReviewDraftsStore } from "../stores/reviewDraftsStore"; +import { useRevertHunk } from "../hooks/useRevertHunk"; +import { useReviewDraftsStore } from "../reviewDraftsStore"; import type { AnnotationMetadata, FilesDiffProps, InteractiveFileDiffProps, PatchDiffProps, } from "../types"; -import { - buildCommentMergedOptions, - buildDraftAnnotations, - buildHunkAnnotations, -} from "../utils/diffAnnotations"; -import { buildFileAnnotations } from "../utils/prCommentAnnotations"; import { CommentAnnotation } from "./CommentAnnotation"; import { DraftCommentAnnotation } from "./DraftCommentAnnotation"; import { PrCommentThread } from "./PrCommentThread"; @@ -177,8 +172,7 @@ function PatchDiffView({ prUrl, commentThreads, }: PatchDiffProps) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); + const revertHunk = useRevertHunk(); const [containerRef, inView] = useInView<HTMLDivElement>({ rootMargin: REVIEW_PREFETCH_ROOT_MARGIN, once: true, @@ -252,51 +246,22 @@ function PatchDiffView({ if (!filePath || !repoPath) return; setRevertingHunks((prev) => new Set(prev).add(hunkIndex)); - setFileDiff((prev) => diffAcceptRejectHunk(prev, hunkIndex, "reject")); - - try { - const [originalContent, modifiedContent] = await Promise.all([ - trpcClient.git.getFileAtHead.query({ - directoryPath: repoPath, - filePath, - }), - trpcClient.fs.readRepoFile.query({ - repoPath, - filePath, - }), - ]); - - const fullDiff = parseDiffFromFile( - { name: filePath, contents: originalContent ?? "" }, - { name: filePath, contents: modifiedContent ?? "" }, - ); - - const reverted = diffAcceptRejectHunk(fullDiff, hunkIndex, "reject"); - const newContent = reverted.additionLines.join(""); - - await trpcClient.fs.writeRepoFile.mutate({ - repoPath, - filePath, - content: newContent, - }); - queryClient.invalidateQueries( - trpc.git.getDiffHead.queryFilter({ directoryPath: repoPath }), - ); - queryClient.invalidateQueries( - trpc.git.getChangedFilesHead.queryFilter({ directoryPath: repoPath }), - ); - } catch { - setFileDiff(initialFileDiff); - } finally { - setRevertingHunks((prev) => { - const next = new Set(prev); - next.delete(hunkIndex); - return next; - }); - } + await revertHunk( + { repoPath, filePath, hunkIndex, fileDiff }, + { + onOptimisticApply: setFileDiff, + onRollback: () => setFileDiff(initialFileDiff), + }, + ); + + setRevertingHunks((prev) => { + const next = new Set(prev); + next.delete(hunkIndex); + return next; + }); }, - [repoPath, initialFileDiff, queryClient, trpc], + [repoPath, fileDiff, initialFileDiff, revertHunk], ); const renderAnnotation = useCallback( diff --git a/apps/code/src/renderer/features/code-review/components/PatchedFileDiff.tsx b/packages/ui/src/features/code-review/components/PatchedFileDiff.tsx similarity index 89% rename from apps/code/src/renderer/features/code-review/components/PatchedFileDiff.tsx rename to packages/ui/src/features/code-review/components/PatchedFileDiff.tsx index 53b393c85c..cb93df86c6 100644 --- a/apps/code/src/renderer/features/code-review/components/PatchedFileDiff.tsx +++ b/packages/ui/src/features/code-review/components/PatchedFileDiff.tsx @@ -1,10 +1,10 @@ import { type FileDiffMetadata, processFile } from "@pierre/diffs"; -import type { ChangedFile } from "@shared/types"; +import type { PrCommentThread } from "@posthog/core/code-review/types"; +import type { ChangedFile } from "@posthog/shared/domain-types"; import { useMemo } from "react"; +import { DeferredDiffPlaceholder, DiffFileHeader } from "../reviewShellParts"; import type { DiffOptions } from "../types"; -import type { PrCommentThread } from "../utils/prCommentAnnotations"; import { InteractiveFileDiff } from "./InteractiveFileDiff"; -import { DeferredDiffPlaceholder, DiffFileHeader } from "./ReviewShell"; interface PatchedFileDiffProps { file: ChangedFile; diff --git a/apps/code/src/renderer/features/code-review/components/PendingReviewBar.tsx b/packages/ui/src/features/code-review/components/PendingReviewBar.tsx similarity index 85% rename from apps/code/src/renderer/features/code-review/components/PendingReviewBar.tsx rename to packages/ui/src/features/code-review/components/PendingReviewBar.tsx index 2e28eb99f6..104480c07a 100644 --- a/apps/code/src/renderer/features/code-review/components/PendingReviewBar.tsx +++ b/packages/ui/src/features/code-review/components/PendingReviewBar.tsx @@ -1,9 +1,9 @@ -import { sendPromptToAgent } from "@features/sessions/utils/sendPromptToAgent"; import { PaperPlaneTilt } from "@phosphor-icons/react"; +import { buildBatchedInlineCommentsPrompt } from "@posthog/core/code-review/reviewPrompts"; import { Button } from "@posthog/quill"; import { Badge, Flex } from "@radix-ui/themes"; -import { useReviewDraftsStore } from "../stores/reviewDraftsStore"; -import { buildBatchedInlineCommentsPrompt } from "../utils/reviewPrompts"; +import { sendPromptToAgent } from "../../sessions/sendPromptToAgent"; +import { useReviewDraftsStore } from "../reviewDraftsStore"; interface PendingReviewBarProps { taskId: string; diff --git a/apps/code/src/renderer/features/code-review/components/PrCommentThread.tsx b/packages/ui/src/features/code-review/components/PrCommentThread.tsx similarity index 96% rename from apps/code/src/renderer/features/code-review/components/PrCommentThread.tsx rename to packages/ui/src/features/code-review/components/PrCommentThread.tsx index d6ff8259bf..27c1cf949c 100644 --- a/apps/code/src/renderer/features/code-review/components/PrCommentThread.tsx +++ b/packages/ui/src/features/code-review/components/PrCommentThread.tsx @@ -1,6 +1,3 @@ -import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; -import { sendPromptToAgent } from "@features/sessions/utils/sendPromptToAgent"; -import type { PrReviewComment } from "@main/services/git/schemas"; import { ArrowCounterClockwise, CaretDown, @@ -12,20 +9,23 @@ import { WarningCircle, X, } from "@phosphor-icons/react"; +import { + buildAskAboutPrCommentPrompt, + buildFixPrCommentPrompt, +} from "@posthog/core/code-review/reviewPrompts"; import { Button } from "@posthog/quill"; +import type { PrReviewComment } from "@posthog/shared"; +import { formatRelativeTimeShort } from "@posthog/shared"; import { Avatar, Badge, Box, Flex, Text } from "@radix-ui/themes"; -import { isSendMessageSubmitKey } from "@utils/sendMessageKey"; -import { formatRelativeTimeShort } from "@utils/time"; import { useCallback, useEffect, useRef, useState } from "react"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import type { PluggableList } from "unified"; +import { isSendMessageSubmitKey } from "../../../utils/sendMessageKey"; +import { MarkdownRenderer } from "../../editor/components/MarkdownRenderer"; +import { sendPromptToAgent } from "../../sessions/sendPromptToAgent"; import { usePrCommentActions } from "../hooks/usePrCommentActions"; import type { PrCommentMetadata } from "../types"; -import { - buildAskAboutPrCommentPrompt, - buildFixPrCommentPrompt, -} from "../utils/reviewPrompts"; const ghRehypePlugins: PluggableList = [ rehypeRaw, diff --git a/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx b/packages/ui/src/features/code-review/components/ReviewPage.tsx similarity index 90% rename from apps/code/src/renderer/features/code-review/components/ReviewPage.tsx rename to packages/ui/src/features/code-review/components/ReviewPage.tsx index 39814158b8..caacf02bd4 100644 --- a/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx +++ b/packages/ui/src/features/code-review/components/ReviewPage.tsx @@ -1,24 +1,24 @@ -import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; -import { - useLocalBranchChangedFiles, - usePrChangedFiles, -} from "@features/git-interaction/hooks/useGitQueries"; -import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; import type { parsePatchFiles } from "@pierre/diffs"; +import type { ResolvedDiffSource } from "@posthog/core/code-review/resolveDiffSource"; +import type { PrCommentThread } from "@posthog/core/code-review/types"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { ChangedFile, Task } from "@posthog/shared/domain-types"; import { Flex, Text } from "@radix-ui/themes"; -import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; -import { trpc, useTRPC } from "@renderer/trpc/client"; -import type { ChangedFile, Task } from "@shared/types"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo } from "react"; +import { useDiffViewerStore } from "../../code-editor/diffViewerStore"; +import { + useLocalBranchChangedFiles, + usePrChangedFiles, +} from "../../git-interaction/useGitQueries"; +import { usePrDetails } from "../../git-interaction/usePrDetails"; +import { usePanelLayoutStore } from "../../panels/panelLayoutStore"; +import { useCwd } from "../../sidebar/useCwd"; import { REVIEW_FILE_CACHE_TIME_MS, REVIEW_MAX_FILE_LINES } from "../constants"; import { useEffectiveDiffSource } from "../hooks/useEffectiveDiffSource"; import { useReviewDiffs } from "../hooks/useReviewDiffs"; +import { useReviewNavigationStore } from "../reviewNavigationStore"; import type { DiffOptions } from "../types"; -import type { PrCommentThread } from "../utils/prCommentAnnotations"; -import type { ResolvedDiffSource } from "../utils/resolveDiffSource"; import { buildItemIndex, type ReviewListItem, @@ -38,7 +38,7 @@ function usePrefetchUntrackedFileContents( files: ChangedFile[], enabled: boolean, ) { - const trpcClient = useTRPC(); + const trpc = useHostTRPC(); const queryClient = useQueryClient(); const filePaths = useMemo( () => [...new Set(files.map((file) => file.path))], @@ -51,25 +51,18 @@ function usePrefetchUntrackedFileContents( let cancelled = false; const run = async () => { - const batchResult = await queryClient.fetchQuery({ - ...trpc.fs.readRepoFilesBounded.queryOptions( - { - repoPath, - filePaths, - maxLines: REVIEW_MAX_FILE_LINES, - }, - { - staleTime: 30_000, - gcTime: REVIEW_FILE_CACHE_TIME_MS, - }, + const batchResult = await queryClient.fetchQuery( + trpc.fs.readRepoFilesBounded.queryOptions( + { repoPath, filePaths, maxLines: REVIEW_MAX_FILE_LINES }, + { staleTime: 30_000, gcTime: REVIEW_FILE_CACHE_TIME_MS }, ), - }); + ); if (cancelled) return; for (const [filePath, result] of Object.entries(batchResult)) { queryClient.setQueryData( - trpcClient.fs.readRepoFileBounded.queryKey({ + trpc.fs.readRepoFileBounded.queryKey({ repoPath, filePath, maxLines: REVIEW_MAX_FILE_LINES, @@ -84,7 +77,7 @@ function usePrefetchUntrackedFileContents( return () => { cancelled = true; }; - }, [enabled, filePaths, queryClient, repoPath, trpcClient]); + }, [enabled, filePaths, queryClient, repoPath, trpc]); } interface ReviewPageProps { diff --git a/apps/code/src/renderer/features/code-review/components/ReviewRows.tsx b/packages/ui/src/features/code-review/components/ReviewRows.tsx similarity index 94% rename from apps/code/src/renderer/features/code-review/components/ReviewRows.tsx rename to packages/ui/src/features/code-review/components/ReviewRows.tsx index d1f4020286..aa5b6e8b5f 100644 --- a/apps/code/src/renderer/features/code-review/components/ReviewRows.tsx +++ b/packages/ui/src/features/code-review/components/ReviewRows.tsx @@ -1,20 +1,20 @@ import type { parsePatchFiles } from "@pierre/diffs"; -import { useInView } from "@renderer/hooks/useInView"; -import type { ChangedFile } from "@shared/types"; +import { contentHash } from "@posthog/core/code-review/contentHash"; +import type { PrCommentThread } from "@posthog/core/code-review/types"; +import type { ChangedFile } from "@posthog/shared/domain-types"; import { memo, useCallback, useMemo } from "react"; +import { useInView } from "../../../primitives/hooks/useInView"; import { REVIEW_PREFETCH_ROOT_MARGIN } from "../constants"; import { useReadRepoFileBounded } from "../hooks/useReadRepoFileBounded"; -import type { DiffOptions } from "../types"; -import { contentHash } from "../utils/contentHash"; -import type { PrCommentThread } from "../utils/prCommentAnnotations"; -import { InteractiveFileDiff } from "./InteractiveFileDiff"; -import { PatchedFileDiff } from "./PatchedFileDiff"; import { DeferredDiffPlaceholder, DiffFileHeader, FileHeaderRow, splitFilePath, -} from "./ReviewShell"; +} from "../reviewShellParts"; +import type { DiffOptions } from "../types"; +import { InteractiveFileDiff } from "./InteractiveFileDiff"; +import { PatchedFileDiff } from "./PatchedFileDiff"; interface PatchRowProps { itemKey: string; diff --git a/packages/ui/src/features/code-review/components/ReviewShell.tsx b/packages/ui/src/features/code-review/components/ReviewShell.tsx new file mode 100644 index 0000000000..66e1a4263e --- /dev/null +++ b/packages/ui/src/features/code-review/components/ReviewShell.tsx @@ -0,0 +1,269 @@ +import { WorkerPoolContextProvider } from "@pierre/diffs/react"; +import { useService } from "@posthog/di/react"; +import type { Task } from "@posthog/shared/domain-types"; +import { Flex, Spinner, Text } from "@radix-ui/themes"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { VList, type VListHandle } from "virtua"; +import { + REVIEW_LIST_BUFFER_PX, + REVIEW_LIST_ESTIMATED_ITEM_SIZE, +} from "../constants"; +import { useReviewDraftsStore } from "../reviewDraftsStore"; +import { REVIEW_HOST, type ReviewHost } from "../reviewHost"; +import { useReviewNavigationStore } from "../reviewNavigationStore"; +import type { ReviewListItem, ReviewShellProps } from "../reviewShellParts"; +import { PendingReviewBar } from "./PendingReviewBar"; +import { ReviewToolbar } from "./ReviewToolbar"; + +// Pure helpers, hooks, types, and presentational sub-components live in +// ../reviewShellParts. Re-exported here so consumers can import everything +// (ReviewShell + useReviewState + buildItemIndex + ReviewListItem) from a +// single "./ReviewShell" specifier. +export * from "../reviewShellParts"; + +const SIDEBAR_MIN_WIDTH = 200; +const SIDEBAR_MAX_WIDTH = 500; +const SIDEBAR_DEFAULT_WIDTH = 280; + +function ExpandedSidebar({ task }: { task: Task }) { + const reviewHost = useService<ReviewHost>(REVIEW_HOST); + const [width, setWidth] = useState(SIDEBAR_DEFAULT_WIDTH); + const isDragging = useRef(false); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + isDragging.current = true; + const startX = e.clientX; + const startWidth = width; + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging.current) return; + const delta = startX - e.clientX; + const newWidth = Math.min( + SIDEBAR_MAX_WIDTH, + Math.max(SIDEBAR_MIN_WIDTH, startWidth + delta), + ); + setWidth(newWidth); + }; + + const handleMouseUp = () => { + isDragging.current = false; + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, + [width], + ); + + return ( + <Flex direction="row" className="shrink-0"> + <button + type="button" + aria-label="Resize sidebar" + onMouseDown={handleMouseDown} + style={{ transition: "background 0.1s" }} + onMouseEnter={(e) => { + e.currentTarget.style.background = "var(--accent-8)"; + }} + onMouseLeave={(e) => { + if (!isDragging.current) { + e.currentTarget.style.background = "transparent"; + } + }} + className="w-[4px] shrink-0 cursor-col-resize border-l border-l-(--gray-6) bg-transparent p-0" + /> + <Flex + direction="column" + style={{ + width: `${width}px`, + minWidth: `${SIDEBAR_MIN_WIDTH}px`, + }} + className="shrink-0 bg-(--color-background)" + > + {reviewHost.renderExpandedSidebar(task)} + </Flex> + </Flex> + ); +} + +export function ReviewShell({ + task, + fileCount, + linesAdded, + linesRemoved, + isLoading, + isEmpty, + items, + itemIndexByFilePath, + onUncollapseFile, + allExpanded, + onExpandAll, + onCollapseAll, + onRefresh, + effectiveSource, + branchSourceAvailable, + prSourceAvailable, + defaultBranch, +}: ReviewShellProps) { + const reviewHost = useService<ReviewHost>(REVIEW_HOST); + const taskId = task.id; + const listRef = useRef<VListHandle | null>(null); + + const workerFactory = useCallback( + () => reviewHost.diffWorkerFactory(), + [reviewHost], + ); + + const reviewMode = useReviewNavigationStore( + (s) => s.reviewModes[taskId] ?? "closed", + ); + const isExpanded = reviewMode === "expanded"; + + const scrollRequest = useReviewNavigationStore( + (s) => s.scrollRequests[taskId] ?? null, + ); + const clearScrollRequest = useReviewNavigationStore( + (s) => s.clearScrollRequest, + ); + const setActiveFilePath = useReviewNavigationStore( + (s) => s.setActiveFilePath, + ); + const clearTask = useReviewNavigationStore((s) => s.clearTask); + + useEffect(() => { + return () => { + clearTask(taskId); + useReviewDraftsStore.getState().clearDrafts(taskId); + }; + }, [taskId, clearTask]); + + useEffect(() => { + if (!scrollRequest) return; + const targetIndex = itemIndexByFilePath.get(scrollRequest); + if (targetIndex === undefined) return; + + onUncollapseFile?.(scrollRequest); + requestAnimationFrame(() => { + listRef.current?.scrollToIndex(targetIndex, { align: "start" }); + setActiveFilePath(taskId, scrollRequest); + clearScrollRequest(taskId); + }); + }, [ + clearScrollRequest, + itemIndexByFilePath, + onUncollapseFile, + scrollRequest, + setActiveFilePath, + taskId, + ]); + + const lastActiveRef = useRef<string | null>(null); + const handleScroll = useCallback( + (offset: number) => { + const handle = listRef.current; + if (!handle) return; + const index = handle.findItemIndex(offset); + const item = items[index]; + const scrollKey = item?.scrollKey; + if (!scrollKey || scrollKey === lastActiveRef.current) return; + lastActiveRef.current = scrollKey; + setActiveFilePath(taskId, scrollKey); + }, + [items, setActiveFilePath, taskId], + ); + + const renderItem = useCallback( + (item: ReviewListItem) => ( + <div + key={item.key} + data-scroll-key={item.scrollKey} + className="pb-2 last:pb-0" + > + {item.node} + </div> + ), + [], + ); + + return ( + <WorkerPoolContextProvider + poolOptions={{ workerFactory }} + highlighterOptions={{ + theme: { dark: "github-dark", light: "github-light" }, + langs: [ + "typescript", + "tsx", + "javascript", + "jsx", + "json", + "css", + "html", + "markdown", + "python", + "ruby", + "go", + "rust", + "shell", + "yaml", + "sql", + ], + }} + > + <Flex direction="column" height="100%" id="review-shell"> + <ReviewToolbar + taskId={taskId} + fileCount={fileCount} + linesAdded={linesAdded} + linesRemoved={linesRemoved} + allExpanded={allExpanded} + onExpandAll={onExpandAll} + onCollapseAll={onCollapseAll} + onRefresh={onRefresh} + effectiveSource={effectiveSource} + branchSourceAvailable={branchSourceAvailable} + prSourceAvailable={prSourceAvailable} + defaultBranch={defaultBranch} + /> + <Flex className="min-h-0 flex-1"> + <Flex direction="column" className="min-w-0 flex-1"> + {isLoading ? ( + <Flex align="center" justify="center" className="min-h-0 flex-1"> + <Spinner size="2" /> + </Flex> + ) : isEmpty ? ( + <Flex align="center" justify="center" className="min-h-0 flex-1"> + <Text color="gray" className="text-sm"> + No file changes to review + </Text> + </Flex> + ) : ( + <VList + ref={listRef} + bufferSize={REVIEW_LIST_BUFFER_PX} + itemSize={REVIEW_LIST_ESTIMATED_ITEM_SIZE} + className="pierre-scroll-root scrollbar-overlay-y min-h-0 flex-1 overflow-auto bg-(--gray-2)" + shift={false} + style={{ scrollbarGutter: "stable" }} + onScroll={handleScroll} + data={items} + > + {renderItem} + </VList> + )} + <PendingReviewBar taskId={taskId} /> + </Flex> + + {isExpanded && <ExpandedSidebar task={task} />} + </Flex> + </Flex> + </WorkerPoolContextProvider> + ); +} diff --git a/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx b/packages/ui/src/features/code-review/components/ReviewToolbar.tsx similarity index 89% rename from apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx rename to packages/ui/src/features/code-review/components/ReviewToolbar.tsx index 8d146aa9b9..68c104a0b1 100644 --- a/apps/code/src/renderer/features/code-review/components/ReviewToolbar.tsx +++ b/packages/ui/src/features/code-review/components/ReviewToolbar.tsx @@ -1,17 +1,17 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; import { ArrowsClockwise, Columns, Rows, X } from "@phosphor-icons/react"; +import type { ResolvedDiffSource } from "@posthog/core/code-review/resolveDiffSource"; import { Button } from "@posthog/quill"; -import { Flex, Separator, Text } from "@radix-ui/themes"; -import { DiffSettingsMenu } from "@renderer/features/code-review/components/DiffSettingsMenu"; -import { DiffSourceSelector } from "@renderer/features/code-review/components/DiffSourceSelector"; +import { useDiffViewerStore } from "@posthog/ui/features/code-editor/diffViewerStore"; import { type ReviewMode, useReviewNavigationStore, -} from "@renderer/features/code-review/stores/reviewNavigationStore"; -import type { ResolvedDiffSource } from "@renderer/features/code-review/utils/resolveDiffSource"; +} from "@posthog/ui/features/code-review/reviewNavigationStore"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { Flex, Separator, Text } from "@radix-ui/themes"; import { FoldVertical, Maximize, Minimize, UnfoldVertical } from "lucide-react"; import { memo } from "react"; +import { DiffSettingsMenu } from "./DiffSettingsMenu"; +import { DiffSourceSelector } from "./DiffSourceSelector"; interface ReviewToolbarProps { taskId: string; diff --git a/apps/code/src/renderer/features/code-review/components/reviewItemBuilders.tsx b/packages/ui/src/features/code-review/components/reviewItemBuilders.tsx similarity index 85% rename from apps/code/src/renderer/features/code-review/components/reviewItemBuilders.tsx rename to packages/ui/src/features/code-review/components/reviewItemBuilders.tsx index 6a54f55c9c..3300530329 100644 --- a/apps/code/src/renderer/features/code-review/components/reviewItemBuilders.tsx +++ b/packages/ui/src/features/code-review/components/reviewItemBuilders.tsx @@ -1,10 +1,14 @@ -import { makeFileKey } from "@features/git-interaction/utils/fileKey"; import type { parsePatchFiles } from "@pierre/diffs"; -import type { ChangedFile } from "@shared/types"; +import { + buildGithubFileUrl, + computeSkipExpansion, +} from "@posthog/core/code-review/reviewItemKeys"; +import type { PrCommentThread } from "@posthog/core/code-review/types"; +import type { ChangedFile } from "@posthog/shared/domain-types"; +import { makeFileKey } from "../../git-interaction/utils/fileKey"; +import type { ReviewListItem } from "../reviewShellParts"; import type { DiffOptions } from "../types"; -import type { PrCommentThread } from "../utils/prCommentAnnotations"; import { PatchRow, RemoteRow, UntrackedRow } from "./ReviewRows"; -import type { ReviewListItem } from "./ReviewShell"; interface BuildPatchReviewItemsArgs { files: ReturnType<typeof parsePatchFiles>[number]["files"]; @@ -37,7 +41,11 @@ export function buildPatchReviewItems({ const filePath = fileDiff.name ?? fileDiff.prevName ?? ""; const key = makeFileKey(staged, filePath); const isCollapsed = collapsedFiles.has(key); - const skipExpansion = staged || (alsoStagedPaths?.has(filePath) ?? false); + const skipExpansion = computeSkipExpansion( + staged, + filePath, + alsoStagedPaths, + ); return { key, @@ -122,9 +130,7 @@ export function buildRemoteReviewItems({ }: BuildRemoteReviewItemsArgs): ReviewListItem[] { return files.map((file) => { const isCollapsed = collapsedFiles.has(file.path); - const githubFileUrl = prUrl - ? `${prUrl}/files#diff-${file.path.replaceAll("/", "-")}` - : undefined; + const githubFileUrl = buildGithubFileUrl(prUrl, file.path); return { key: file.path, diff --git a/apps/code/src/renderer/features/code-review/constants.ts b/packages/ui/src/features/code-review/constants.ts similarity index 100% rename from apps/code/src/renderer/features/code-review/constants.ts rename to packages/ui/src/features/code-review/constants.ts diff --git a/apps/code/src/renderer/features/code-review/hooks/useCommentState.ts b/packages/ui/src/features/code-review/hooks/useCommentState.ts similarity index 96% rename from apps/code/src/renderer/features/code-review/hooks/useCommentState.ts rename to packages/ui/src/features/code-review/hooks/useCommentState.ts index eeef9cf5e5..f4c5aa79cb 100644 --- a/apps/code/src/renderer/features/code-review/hooks/useCommentState.ts +++ b/packages/ui/src/features/code-review/hooks/useCommentState.ts @@ -3,8 +3,8 @@ import type { DiffLineAnnotation, SelectedLineRange, } from "@pierre/diffs"; +import type { AnnotationMetadata } from "@posthog/ui/features/code-review/types"; import { useCallback, useState } from "react"; -import type { AnnotationMetadata } from "../types"; export interface CommentEditSeed { draftId: string; diff --git a/apps/code/src/renderer/features/code-review/hooks/useDiffStatsToggle.ts b/packages/ui/src/features/code-review/hooks/useDiffStatsToggle.ts similarity index 82% rename from apps/code/src/renderer/features/code-review/hooks/useDiffStatsToggle.ts rename to packages/ui/src/features/code-review/hooks/useDiffStatsToggle.ts index b15b3aac79..7b6bc6cce8 100644 --- a/apps/code/src/renderer/features/code-review/hooks/useDiffStatsToggle.ts +++ b/packages/ui/src/features/code-review/hooks/useDiffStatsToggle.ts @@ -1,7 +1,7 @@ -import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; -import type { Task } from "@shared/types"; +import type { Task } from "@posthog/shared/domain-types"; import { useCallback } from "react"; -import type { ReviewMode } from "../stores/reviewNavigationStore"; +import type { ReviewMode } from "../reviewNavigationStore"; +import { useReviewNavigationStore } from "../reviewNavigationStore"; import { useTaskDiffSummaryStats } from "./useTaskDiffSummaryStats"; interface DiffStatsToggleResult { diff --git a/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts b/packages/ui/src/features/code-review/hooks/useEffectiveDiffSource.ts similarity index 74% rename from apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts rename to packages/ui/src/features/code-review/hooks/useEffectiveDiffSource.ts index 788466c384..9ba7458cc7 100644 --- a/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts +++ b/packages/ui/src/features/code-review/hooks/useEffectiveDiffSource.ts @@ -1,15 +1,15 @@ -import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; -import { useLinkedBranchPrUrl } from "@features/git-interaction/hooks/useLinkedBranchPrUrl"; -import type { DiffStats } from "@features/git-interaction/utils/diffStats"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; -import { useDiffStats } from "@posthog/ui/features/diff-stats/useDiffStats"; -import { useTRPC } from "@renderer/trpc"; -import { useQuery } from "@tanstack/react-query"; import { type ResolvedDiffSource, resolveDiffSource, -} from "../utils/resolveDiffSource"; +} from "@posthog/core/code-review/resolveDiffSource"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; +import { useDiffViewerStore } from "../../code-editor/diffViewerStore"; +import { useDiffStats } from "../../diff-stats/useDiffStats"; +import { useLinkedBranchPrUrl } from "../../git-interaction/useLinkedBranchPrUrl"; +import type { DiffStats } from "../../git-interaction/utils/diffStats"; +import { useCwd } from "../../sidebar/useCwd"; +import { useWorkspace } from "../../workspace/useWorkspace"; export interface EffectiveDiffSource { effectiveSource: ResolvedDiffSource; @@ -23,7 +23,7 @@ export interface EffectiveDiffSource { } export function useEffectiveDiffSource(taskId: string): EffectiveDiffSource { - const trpc = useTRPC(); + const trpc = useHostTRPC(); const repoPath = useCwd(taskId); const workspace = useWorkspace(taskId); const linkedBranch = workspace?.linkedBranch ?? null; @@ -40,20 +40,14 @@ export function useEffectiveDiffSource(taskId: string): EffectiveDiffSource { const { data: syncStatus } = useQuery( trpc.git.getGitSyncStatus.queryOptions( { directoryPath: repoPath as string }, - { - enabled, - staleTime: 30_000, - }, + { enabled, staleTime: 30_000 }, ), ); const { data: repoInfo } = useQuery( trpc.git.getGitRepoInfo.queryOptions( { directoryPath: repoPath as string }, - { - enabled, - staleTime: 60_000, - }, + { enabled, staleTime: 60_000 }, ), ); diff --git a/apps/code/src/renderer/features/code-review/hooks/useExpandableFileDiff.ts b/packages/ui/src/features/code-review/hooks/useExpandableFileDiff.ts similarity index 93% rename from apps/code/src/renderer/features/code-review/hooks/useExpandableFileDiff.ts rename to packages/ui/src/features/code-review/hooks/useExpandableFileDiff.ts index 8eb1063704..3c126b00f2 100644 --- a/apps/code/src/renderer/features/code-review/hooks/useExpandableFileDiff.ts +++ b/packages/ui/src/features/code-review/hooks/useExpandableFileDiff.ts @@ -1,12 +1,12 @@ import type { FileDiffMetadata } from "@pierre/diffs"; -import { useTRPC } from "@renderer/trpc/client"; -import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; -import { REVIEW_FILE_CACHE_TIME_MS } from "../constants"; import { buildExpandedFileDiff, canExpandFileDiff, -} from "../utils/fileDiffExpansion"; +} from "@posthog/core/code-review/fileDiffExpansion"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { REVIEW_FILE_CACHE_TIME_MS } from "../constants"; import { useReadRepoFileBounded } from "./useReadRepoFileBounded"; export interface ExpandableFileDiffResult { @@ -21,7 +21,7 @@ export function useExpandableFileDiff( skip: boolean, inView = true, ): ExpandableFileDiffResult { - const trpc = useTRPC(); + const trpc = useHostTRPC(); const filePath = patchFileDiff.name ?? patchFileDiff.prevName ?? ""; const prevPath = patchFileDiff.prevName ?? filePath; const canExpand = canExpandFileDiff(patchFileDiff, repoPath, skip); diff --git a/apps/code/src/renderer/features/code-review/hooks/usePrCommentActions.ts b/packages/ui/src/features/code-review/hooks/usePrCommentActions.ts similarity index 67% rename from apps/code/src/renderer/features/code-review/hooks/usePrCommentActions.ts rename to packages/ui/src/features/code-review/hooks/usePrCommentActions.ts index 365f1a03ca..c5e9f6b565 100644 --- a/apps/code/src/renderer/features/code-review/hooks/usePrCommentActions.ts +++ b/packages/ui/src/features/code-review/hooks/usePrCommentActions.ts @@ -1,18 +1,24 @@ -import { useTRPC } from "@renderer/trpc"; -import { trpcClient } from "@renderer/trpc/client"; -import { useQueryClient } from "@tanstack/react-query"; +import { useService } from "@posthog/di/react"; +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { useCallback } from "react"; -import { toast } from "sonner"; +import { toast } from "../../../primitives/toast"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "../../../workbench/queryClient"; export function usePrCommentActions(prUrl: string | null) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); + const trpc = useHostTRPC(); + const client = useHostTRPCClient(); + const queryClient = useService<ImperativeQueryClient>( + IMPERATIVE_QUERY_CLIENT, + ); const reply = useCallback( async (commentId: number, body: string): Promise<boolean> => { if (!prUrl) return false; try { - const result = await trpcClient.git.replyToPrComment.mutate({ + const result = await client.git.replyToPrComment.mutate({ prUrl, commentId, body, @@ -30,7 +36,7 @@ export function usePrCommentActions(prUrl: string | null) { return false; } }, - [prUrl, queryClient, trpc], + [prUrl, client, trpc, queryClient], ); const resolve = useCallback( @@ -40,7 +46,7 @@ export function usePrCommentActions(prUrl: string | null) { ? "Failed to resolve thread" : "Failed to unresolve thread"; try { - const result = await trpcClient.git.resolveReviewThread.mutate({ + const result = await client.git.resolveReviewThread.mutate({ prUrl, threadNodeId, resolved, @@ -58,7 +64,7 @@ export function usePrCommentActions(prUrl: string | null) { return false; } }, - [prUrl, queryClient, trpc], + [prUrl, client, trpc, queryClient], ); return { reply, resolve }; diff --git a/apps/code/src/renderer/features/code-review/hooks/useReadRepoFileBounded.ts b/packages/ui/src/features/code-review/hooks/useReadRepoFileBounded.ts similarity index 84% rename from apps/code/src/renderer/features/code-review/hooks/useReadRepoFileBounded.ts rename to packages/ui/src/features/code-review/hooks/useReadRepoFileBounded.ts index 28d44b229c..8218e79add 100644 --- a/apps/code/src/renderer/features/code-review/hooks/useReadRepoFileBounded.ts +++ b/packages/ui/src/features/code-review/hooks/useReadRepoFileBounded.ts @@ -1,4 +1,4 @@ -import { useTRPC } from "@renderer/trpc/client"; +import { useHostTRPC } from "@posthog/host-router/react"; import { useQuery } from "@tanstack/react-query"; import { REVIEW_FILE_CACHE_TIME_MS, REVIEW_MAX_FILE_LINES } from "../constants"; @@ -7,7 +7,7 @@ export function useReadRepoFileBounded( filePath: string, enabled: boolean, ) { - const trpc = useTRPC(); + const trpc = useHostTRPC(); return useQuery( trpc.fs.readRepoFileBounded.queryOptions( { repoPath, filePath, maxLines: REVIEW_MAX_FILE_LINES }, diff --git a/packages/ui/src/features/code-review/hooks/useRevertHunk.ts b/packages/ui/src/features/code-review/hooks/useRevertHunk.ts new file mode 100644 index 0000000000..2bdd6e1c02 --- /dev/null +++ b/packages/ui/src/features/code-review/hooks/useRevertHunk.ts @@ -0,0 +1,44 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import { + type OptimisticRevertCallbacks, + REVERT_HUNK_SERVICE, + type RevertHunkService, +} from "@posthog/core/code-review/revertHunkService"; +import { useService } from "@posthog/di/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +interface OptimisticRevertArgs { + repoPath: string; + filePath: string; + hunkIndex: number; + fileDiff: FileDiffMetadata; +} + +export function useRevertHunk() { + const service = useService<RevertHunkService>(REVERT_HUNK_SERVICE); + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + + return useCallback( + async ( + args: OptimisticRevertArgs, + callbacks: OptimisticRevertCallbacks, + ) => { + const reverted = await service.revertHunkOptimistic(args, callbacks); + + queryClient.invalidateQueries( + trpc.git.getDiffHead.queryFilter({ directoryPath: args.repoPath }), + ); + queryClient.invalidateQueries( + trpc.git.getChangedFilesHead.queryFilter({ + directoryPath: args.repoPath, + }), + ); + + return reverted; + }, + [service, trpc, queryClient], + ); +} diff --git a/apps/code/src/renderer/features/code-review/hooks/useReviewDiffs.ts b/packages/ui/src/features/code-review/hooks/useReviewDiffs.ts similarity index 80% rename from apps/code/src/renderer/features/code-review/hooks/useReviewDiffs.ts rename to packages/ui/src/features/code-review/hooks/useReviewDiffs.ts index 52bd4df431..957622473e 100644 --- a/apps/code/src/renderer/features/code-review/hooks/useReviewDiffs.ts +++ b/packages/ui/src/features/code-review/hooks/useReviewDiffs.ts @@ -1,18 +1,18 @@ -import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; -import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; -import { makeFileKey } from "@features/git-interaction/utils/fileKey"; -import { invalidateGitWorkingTreeQueries } from "@features/git-interaction/utils/gitCacheKeys"; import { parsePatchFiles } from "@pierre/diffs"; -import { useTRPC } from "@renderer/trpc/client"; +import { contentHash } from "@posthog/core/code-review/contentHash"; +import { useHostTRPC } from "@posthog/host-router/react"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; -import { contentHash } from "../utils/contentHash"; +import { useDiffViewerStore } from "../../code-editor/diffViewerStore"; +import { invalidateGitWorkingTreeQueries } from "../../git-interaction/gitCacheKeys"; +import { useGitQueries } from "../../git-interaction/useGitQueries"; +import { makeFileKey } from "../../git-interaction/utils/fileKey"; export function useReviewDiffs( repoPath: string | undefined, isActive: boolean, ) { - const trpc = useTRPC(); + const trpc = useHostTRPC(); const { changedFiles, changesLoading } = useGitQueries(repoPath, { enabled: isActive, }); @@ -29,7 +29,10 @@ export function useReviewDiffs( refetch: refetchDiffCached, } = useQuery( trpc.git.getDiffCached.queryOptions( - { directoryPath: repoPath as string, ignoreWhitespace: hideWhitespace }, + { + directoryPath: repoPath as string, + ignoreWhitespace: hideWhitespace, + }, { enabled: isActive && !!repoPath, staleTime: 30_000, @@ -45,7 +48,10 @@ export function useReviewDiffs( refetch: refetchDiffUnstaged, } = useQuery( trpc.git.getDiffUnstaged.queryOptions( - { directoryPath: repoPath as string, ignoreWhitespace: hideWhitespace }, + { + directoryPath: repoPath as string, + ignoreWhitespace: hideWhitespace, + }, { enabled: isActive && !!repoPath, staleTime: 30_000, diff --git a/packages/ui/src/features/code-review/hooks/useTaskDiffSummaryStats.ts b/packages/ui/src/features/code-review/hooks/useTaskDiffSummaryStats.ts new file mode 100644 index 0000000000..32384a4a6e --- /dev/null +++ b/packages/ui/src/features/code-review/hooks/useTaskDiffSummaryStats.ts @@ -0,0 +1,61 @@ +import { + type DiffStats, + deriveIsCloud, + selectTaskDiffStats, +} from "@posthog/core/code-review/selectTaskDiffStats"; +import type { Task } from "@posthog/shared/domain-types"; +import { useMemo } from "react"; +import { + useLocalBranchChangedFiles, + usePrChangedFiles, +} from "../../git-interaction/useGitQueries"; +import { computeDiffStats } from "../../git-interaction/utils/diffStats"; +import { useCwd } from "../../sidebar/useCwd"; +import { useCloudChangedFiles } from "../../task-detail/hooks/useCloudChangedFiles"; +import { useWorkspace } from "../../workspace/useWorkspace"; +import { useEffectiveDiffSource } from "./useEffectiveDiffSource"; + +export function useTaskDiffSummaryStats(task: Task): DiffStats { + const taskId = task.id; + const workspace = useWorkspace(taskId); + const isCloud = deriveIsCloud(workspace?.mode, task.latest_run?.environment); + + const { reviewFiles } = useCloudChangedFiles(taskId, task, isCloud); + + const repoPath = useCwd(taskId); + const { + effectiveSource, + linkedBranch, + prUrl, + diffStats: localDiffStats, + } = useEffectiveDiffSource(taskId); + + const { data: branchFiles } = useLocalBranchChangedFiles( + !isCloud && effectiveSource === "branch" ? (repoPath ?? null) : null, + !isCloud && effectiveSource === "branch" ? linkedBranch : null, + ); + const { data: prFiles } = usePrChangedFiles( + !isCloud && effectiveSource === "pr" ? prUrl : null, + ); + + return useMemo<DiffStats>( + () => + selectTaskDiffStats({ + isCloud, + effectiveSource, + reviewFiles, + branchFiles, + prFiles, + localDiffStats, + computeStats: computeDiffStats, + }), + [ + isCloud, + reviewFiles, + effectiveSource, + branchFiles, + prFiles, + localDiffStats, + ], + ); +} diff --git a/packages/ui/src/features/code-review/prCommentAnnotations.ts b/packages/ui/src/features/code-review/prCommentAnnotations.ts new file mode 100644 index 0000000000..e538fe58e7 --- /dev/null +++ b/packages/ui/src/features/code-review/prCommentAnnotations.ts @@ -0,0 +1,2 @@ +export { buildFileAnnotations } from "@posthog/core/code-review/prCommentAnnotations"; +export type { PrCommentThread } from "@posthog/core/code-review/types"; diff --git a/apps/code/src/renderer/features/code-review/stores/reviewDraftsStore.test.ts b/packages/ui/src/features/code-review/reviewDraftsStore.test.ts similarity index 100% rename from apps/code/src/renderer/features/code-review/stores/reviewDraftsStore.test.ts rename to packages/ui/src/features/code-review/reviewDraftsStore.test.ts diff --git a/apps/code/src/renderer/features/code-review/stores/reviewDraftsStore.ts b/packages/ui/src/features/code-review/reviewDraftsStore.ts similarity index 92% rename from apps/code/src/renderer/features/code-review/stores/reviewDraftsStore.ts rename to packages/ui/src/features/code-review/reviewDraftsStore.ts index a7d1141c1a..de413869eb 100644 --- a/apps/code/src/renderer/features/code-review/stores/reviewDraftsStore.ts +++ b/packages/ui/src/features/code-review/reviewDraftsStore.ts @@ -1,16 +1,7 @@ -import type { AnnotationSide } from "@pierre/diffs"; +import type { DraftComment } from "@posthog/core/code-review/types"; import { create } from "zustand"; -export interface DraftComment { - id: string; - taskId: string; - filePath: string; - startLine: number; - endLine: number; - side: AnnotationSide; - text: string; - createdAt: number; -} +export type { DraftComment } from "@posthog/core/code-review/types"; interface ReviewDraftsStoreState { drafts: Record<string, DraftComment[]>; diff --git a/packages/ui/src/features/code-review/reviewHost.ts b/packages/ui/src/features/code-review/reviewHost.ts new file mode 100644 index 0000000000..425e7f3edf --- /dev/null +++ b/packages/ui/src/features/code-review/reviewHost.ts @@ -0,0 +1,9 @@ +import type { Task } from "@posthog/shared/domain-types"; +import type { ReactNode } from "react"; + +export interface ReviewHost { + diffWorkerFactory(): Worker; + renderExpandedSidebar(task: Task): ReactNode; +} + +export const REVIEW_HOST = Symbol.for("posthog.ui.ReviewHost"); diff --git a/apps/code/src/renderer/features/code-review/stores/reviewNavigationStore.ts b/packages/ui/src/features/code-review/reviewNavigationStore.ts similarity index 100% rename from apps/code/src/renderer/features/code-review/stores/reviewNavigationStore.ts rename to packages/ui/src/features/code-review/reviewNavigationStore.ts diff --git a/apps/code/src/renderer/features/code-review/components/ReviewShell.test.tsx b/packages/ui/src/features/code-review/reviewShellParts.test.tsx similarity index 76% rename from apps/code/src/renderer/features/code-review/components/ReviewShell.test.tsx rename to packages/ui/src/features/code-review/reviewShellParts.test.tsx index c25b21d30c..f4dc68fd55 100644 --- a/apps/code/src/renderer/features/code-review/components/ReviewShell.test.tsx +++ b/packages/ui/src/features/code-review/reviewShellParts.test.tsx @@ -1,38 +1,20 @@ import { render } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -vi.mock("@renderer/features/code-review/stores/reviewNavigationStore", () => ({ - useReviewNavigationStore: vi.fn(), -})); -vi.mock("@features/code-editor/stores/diffViewerStore", () => ({ +vi.mock("../code-editor/diffViewerStore", () => ({ useDiffViewerStore: vi.fn(), })); -vi.mock("@features/task-detail/components/ChangesPanel", () => ({ - ChangesPanel: () => null, -})); -vi.mock("@features/git-interaction/utils/diffStats", () => ({ +vi.mock("../git-interaction/utils/diffStats", () => ({ computeDiffStats: () => ({ linesAdded: 0, linesRemoved: 0 }), })); -vi.mock("@stores/themeStore", () => ({ +vi.mock("../../workbench/themeStore", () => ({ useThemeStore: vi.fn(() => ({ isDarkMode: false })), })); -vi.mock("@pierre/diffs/react", () => ({ - WorkerPoolContextProvider: ({ children }: { children: React.ReactNode }) => - children, -})); -vi.mock("@pierre/diffs/worker/worker.js?worker&url", () => ({ default: "" })); -vi.mock("@components/ui/FileIcon", () => ({ +vi.mock("../../primitives/FileIcon", () => ({ FileIcon: () => <span data-testid="file-icon" />, })); -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: {}, - useTRPC: vi.fn(), -})); -vi.mock("@features/sessions/service/service", () => ({ - getSessionService: vi.fn(), -})); -import { DeferredDiffPlaceholder, DiffFileHeader } from "./ReviewShell"; +import { DeferredDiffPlaceholder, DiffFileHeader } from "./reviewShellParts"; type FileDiffMetadata = import("@pierre/diffs/react").FileDiffMetadata; diff --git a/packages/ui/src/features/code-review/reviewShellParts.tsx b/packages/ui/src/features/code-review/reviewShellParts.tsx new file mode 100644 index 0000000000..087a0a2238 --- /dev/null +++ b/packages/ui/src/features/code-review/reviewShellParts.tsx @@ -0,0 +1,294 @@ +import { ArrowSquareOut, CaretDown } from "@phosphor-icons/react"; +import type { FileDiffMetadata } from "@pierre/diffs/react"; +import type { ResolvedDiffSource } from "@posthog/core/code-review/resolveDiffSource"; +import { + type DeferredReason, + getDeferredMessage, + splitFilePath, + sumHunkStats, +} from "@posthog/core/code-review/reviewShellGeometry"; +import type { ChangedFile, Task } from "@posthog/shared/domain-types"; +import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { FileIcon } from "../../primitives/FileIcon"; +import { useThemeStore } from "../../workbench/themeStore"; +import { useDiffViewerStore } from "../code-editor/diffViewerStore"; +import { computeDiffStats } from "../git-interaction/utils/diffStats"; + +export type { DeferredReason } from "@posthog/core/code-review/reviewShellGeometry"; +export { + buildItemIndex, + splitFilePath, +} from "@posthog/core/code-review/reviewShellGeometry"; + +const STICKY_HEADER_CSS = `[data-diffs-header] { position: sticky; top: 0; z-index: 1; background: var(--gray-2); }`; + +function useDiffOptions() { + const viewMode = useDiffViewerStore((s) => s.viewMode); + const wordWrap = useDiffViewerStore((s) => s.wordWrap); + const loadFullFiles = useDiffViewerStore((s) => s.loadFullFiles); + const wordDiffs = useDiffViewerStore((s) => s.wordDiffs); + const isDarkMode = useThemeStore((s) => s.isDarkMode); + + return useMemo( + () => ({ + diffStyle: viewMode as "split" | "unified", + overflow: (wordWrap ? "wrap" : "scroll") as "wrap" | "scroll", + expandUnchanged: loadFullFiles, + lineDiffType: (wordDiffs ? "word-alt" : "none") as "word-alt" | "none", + themeType: (isDarkMode ? "dark" : "light") as "dark" | "light", + theme: { dark: "github-dark" as const, light: "github-light" as const }, + unsafeCSS: STICKY_HEADER_CSS, + }), + [viewMode, wordWrap, loadFullFiles, wordDiffs, isDarkMode], + ); +} + +export function useReviewState( + changedFiles: ChangedFile[], + allPaths: string[], +) { + const diffOptions = useDiffOptions(); + + const { linesAdded, linesRemoved } = useMemo( + () => computeDiffStats(changedFiles), + [changedFiles], + ); + + const collapseState = useCollapseState(allPaths); + + return { diffOptions, linesAdded, linesRemoved, ...collapseState }; +} + +function useCollapseState(filePaths: string[]) { + const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>( + () => new Set(), + ); + + const toggleFile = useCallback((filePath: string) => { + setCollapsedFiles((prev) => { + const next = new Set(prev); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }); + }, []); + + const uncollapseFile = useCallback((filePath: string) => { + setCollapsedFiles((prev) => { + if (!prev.has(filePath)) return prev; + const next = new Set(prev); + next.delete(filePath); + return next; + }); + }, []); + + const expandAll = useCallback(() => setCollapsedFiles(new Set()), []); + + const collapseAll = useCallback( + () => setCollapsedFiles(new Set(filePaths)), + [filePaths], + ); + + return { + collapsedFiles, + toggleFile, + uncollapseFile, + expandAll, + collapseAll, + }; +} + +export interface ReviewShellProps { + task: Task; + fileCount: number; + linesAdded: number; + linesRemoved: number; + isLoading: boolean; + isEmpty: boolean; + items: ReviewListItem[]; + itemIndexByFilePath: Map<string, number>; + onUncollapseFile?: (filePath: string) => void; + allExpanded: boolean; + onExpandAll: () => void; + onCollapseAll: () => void; + onRefresh?: () => void; + effectiveSource?: ResolvedDiffSource; + branchSourceAvailable?: boolean; + prSourceAvailable?: boolean; + defaultBranch?: string | null; +} + +export interface ReviewListItem { + key: string; + scrollKey?: string; + node: ReactNode; +} + +export function FileHeaderRow({ + dirPath, + fileName, + additions, + deletions, + collapsed, + onToggle, + trailing, +}: { + dirPath: string; + fileName: string; + additions: number; + deletions: number; + collapsed: boolean; + onToggle: () => void; + trailing?: ReactNode; +}) { + return ( + <button + type="button" + onClick={onToggle} + className="flex w-full cursor-pointer items-center gap-[6px] border-0 border-b border-b-(--gray-5) bg-transparent px-[12px] py-[6px] text-left font-[var(--code-font-family)] text-xs" + > + <CaretDown + size={12} + color="var(--gray-9)" + style={{ + transform: collapsed ? "rotate(-90deg)" : "rotate(0deg)", + transition: "transform 0.15s", + }} + className="shrink-0" + /> + <FileIcon filename={fileName} size={14} /> + <span + title={dirPath + fileName} + className="flex min-w-0 flex-1 gap-[6px]" + > + <span className="shrink-0 whitespace-nowrap font-semibold"> + {fileName} + </span> + <span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-(--gray-9)"> + {dirPath} + </span> + </span> + <span className="font-mono text-[10px]"> + {additions > 0 && ( + <span className="mr-[2px] text-(--green-9)">+{additions}</span> + )} + {deletions > 0 && <span className="text-(--red-9)">-{deletions}</span>} + </span> + {trailing} + </button> + ); +} + +export function DiffFileHeader({ + fileDiff, + collapsed, + onToggle, + onOpenFile, +}: { + fileDiff: FileDiffMetadata; + collapsed: boolean; + onToggle: () => void; + onOpenFile?: () => void; +}) { + const fullPath = + fileDiff.prevName && fileDiff.prevName !== fileDiff.name + ? `${fileDiff.prevName} → ${fileDiff.name}` + : fileDiff.name; + const { dirPath, fileName } = splitFilePath(fullPath ?? ""); + const { additions, deletions } = sumHunkStats(fileDiff.hunks); + + return ( + <FileHeaderRow + dirPath={dirPath} + fileName={fileName} + additions={additions} + deletions={deletions} + collapsed={collapsed} + onToggle={onToggle} + trailing={ + onOpenFile && ( + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + onOpenFile(); + }} + className="ml-auto inline-flex cursor-pointer rounded-[3px] border-0 bg-transparent p-[2px] text-(--gray-9) hover:bg-gray-4" + > + <ArrowSquareOut size={14} /> + </button> + ) + } + /> + ); +} + +export function DeferredDiffPlaceholder({ + filePath, + linesAdded, + linesRemoved, + reason, + collapsed, + onToggle, + onShow, + externalUrl, +}: { + filePath: string; + linesAdded: number; + linesRemoved: number; + reason: DeferredReason; + collapsed: boolean; + onToggle: () => void; + onShow?: () => void; + externalUrl?: string; +}) { + const { dirPath, fileName } = splitFilePath(filePath); + + return ( + <div> + <FileHeaderRow + dirPath={dirPath} + fileName={fileName} + additions={linesAdded} + deletions={linesRemoved} + collapsed={collapsed} + onToggle={onToggle} + /> + {!collapsed && ( + <div className="w-full border-b border-b-(--gray-5) bg-(--gray-2) p-[16px] text-center text-(--gray-9) text-xs"> + {getDeferredMessage(reason)} + {onShow ? ( + <> + {" "} + <button + type="button" + onClick={onShow} + style={{ + fontSize: "inherit", + }} + className="cursor-pointer border-0 bg-transparent p-0 text-(--accent-9) underline" + > + Load diff + </button> + </> + ) : externalUrl ? ( + <> + {" "} + <a + href={externalUrl} + target="_blank" + rel="noopener noreferrer" + style={{ + fontSize: "inherit", + }} + className="text-(--accent-9) underline" + > + View on GitHub + </a> + </> + ) : null} + </div> + )} + </div> + ); +} diff --git a/packages/ui/src/features/code-review/types.ts b/packages/ui/src/features/code-review/types.ts new file mode 100644 index 0000000000..9c2b8f7699 --- /dev/null +++ b/packages/ui/src/features/code-review/types.ts @@ -0,0 +1,31 @@ +import type { FileDiffProps, MultiFileDiffProps } from "@pierre/diffs/react"; +import type { + AnnotationMetadata, + PrCommentThread, +} from "@posthog/core/code-review/types"; + +export type { + AnnotationMetadata, + CommentMetadata, + DiffOptions, + DraftCommentMetadata, + HunkRevertMetadata, + PrCommentMetadata, +} from "@posthog/core/code-review/types"; + +interface PrCommentProps { + taskId?: string; + prUrl?: string | null; + commentThreads?: Map<number, PrCommentThread>; +} + +export type PatchDiffProps = FileDiffProps<AnnotationMetadata> & + PrCommentProps & { + repoPath?: string; + skipExpansion?: boolean; + }; + +export type FilesDiffProps = MultiFileDiffProps<AnnotationMetadata> & + PrCommentProps; + +export type InteractiveFileDiffProps = PatchDiffProps | FilesDiffProps; diff --git a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts b/packages/ui/src/features/command-center/commandCenterStore.test.ts similarity index 100% rename from apps/code/src/renderer/features/command-center/stores/commandCenterStore.test.ts rename to packages/ui/src/features/command-center/commandCenterStore.test.ts diff --git a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts b/packages/ui/src/features/command-center/commandCenterStore.ts similarity index 82% rename from apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts rename to packages/ui/src/features/command-center/commandCenterStore.ts index a600745479..550240552f 100644 --- a/apps/code/src/renderer/features/command-center/stores/commandCenterStore.ts +++ b/packages/ui/src/features/command-center/commandCenterStore.ts @@ -1,23 +1,19 @@ -import { electronStorage } from "@utils/electronStorage"; +import { + clampZoom, + getCellCount, + type LayoutPreset, + resizeCells, + ZOOM_STEP, +} from "@posthog/core/command-center/grid"; +import { electronStorage } from "@posthog/ui/workbench/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -export type LayoutPreset = "1x1" | "2x1" | "1x2" | "2x2" | "3x2" | "3x3"; - -interface GridDimensions { - cols: number; - rows: number; -} - -export function getGridDimensions(preset: LayoutPreset): GridDimensions { - const [cols, rows] = preset.split("x").map(Number); - return { cols, rows }; -} - -function getCellCount(preset: LayoutPreset): number { - const { cols, rows } = getGridDimensions(preset); - return cols * rows; -} +export type { LayoutPreset } from "@posthog/core/command-center/grid"; +export { + getCellSessionId, + getGridDimensions, +} from "@posthog/core/command-center/grid"; interface CommandCenterStoreState { layout: LayoutPreset; @@ -55,27 +51,6 @@ export const COMMAND_CENTER_INITIAL_STATE: CommandCenterStoreState = { type CommandCenterStore = CommandCenterStoreState & CommandCenterStoreActions; -function resizeCells( - current: (string | null)[], - newCount: number, -): (string | null)[] { - if (current.length === newCount) return current; - if (current.length > newCount) return current.slice(0, newCount); - return [...current, ...Array(newCount - current.length).fill(null)]; -} - -const ZOOM_MIN = 0.5; -const ZOOM_MAX = 1.5; -const ZOOM_STEP = 0.1; - -function clampZoom(value: number): number { - return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, value)) * 10) / 10; -} - -export function getCellSessionId(cellIndex: number): string { - return `cc-cell-${cellIndex}`; -} - export const useCommandCenterStore = create<CommandCenterStore>()( persist( (set) => ({ diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterGrid.tsx b/packages/ui/src/features/command-center/components/CommandCenterGrid.tsx similarity index 98% rename from apps/code/src/renderer/features/command-center/components/CommandCenterGrid.tsx rename to packages/ui/src/features/command-center/components/CommandCenterGrid.tsx index ad61b6daf3..6870f522c9 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterGrid.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterGrid.tsx @@ -1,11 +1,11 @@ -import { FOCUSABLE_SELECTOR } from "@utils/overlay"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { CommandCenterCellData } from "../hooks/useCommandCenterData"; +import { FOCUSABLE_SELECTOR } from "../../../utils/overlay"; import { getGridDimensions, type LayoutPreset, useCommandCenterStore, -} from "../stores/commandCenterStore"; +} from "../commandCenterStore"; +import type { CommandCenterCellData } from "../hooks/useCommandCenterData"; import { CommandCenterPanel } from "./CommandCenterPanel"; interface CommandCenterGridProps { diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterPRButton.tsx b/packages/ui/src/features/command-center/components/CommandCenterPRButton.tsx similarity index 70% rename from apps/code/src/renderer/features/command-center/components/CommandCenterPRButton.tsx rename to packages/ui/src/features/command-center/components/CommandCenterPRButton.tsx index 53039e7562..a1d8452909 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterPRButton.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterPRButton.tsx @@ -1,7 +1,7 @@ -import { PRBadgeLink } from "@features/git-interaction/components/PRBadgeLink"; -import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails"; -import { useTaskPrUrl } from "@features/git-interaction/hooks/useTaskPrUrl"; -import type { WorkspaceMode } from "@main/services/workspace/schemas"; +import type { WorkspaceMode } from "@posthog/shared"; +import { PRBadgeLink } from "../../git-interaction/components/PRBadgeLink"; +import { usePrDetails } from "../../git-interaction/usePrDetails"; +import { useTaskPrUrl } from "../../git-interaction/useTaskPrUrl"; interface CommandCenterPRButtonProps { taskId: string; diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterPanel.tsx b/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx similarity index 92% rename from apps/code/src/renderer/features/command-center/components/CommandCenterPanel.tsx rename to packages/ui/src/features/command-center/components/CommandCenterPanel.tsx index 6a5fa61aa8..0b98bd2380 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterPanel.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterPanel.tsx @@ -1,9 +1,3 @@ -import { useCloudPrUrl } from "@features/git-interaction/hooks/useCloudPrUrl"; -import { useDraftStore } from "@features/message-editor/stores/draftStore"; -import { TaskIcon } from "@features/sidebar/components/items/TaskIcon"; -import { useTaskPrStatus } from "@features/sidebar/hooks/useTaskPrStatus"; -import { TaskInput } from "@features/task-detail/components/TaskInput"; -import type { WorkspaceMode } from "@main/services/workspace/schemas"; import { ArrowsOut, Cloud, @@ -13,18 +7,21 @@ import { Plus, X, } from "@phosphor-icons/react"; +import type { WorkspaceMode } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; import { Flex, Text } from "@radix-ui/themes"; -import type { Task } from "@shared/types"; -import { useNavigationStore } from "@stores/navigationStore"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useCloudPrUrl } from "../../git-interaction/useCloudPrUrl"; +import { useDraftStore } from "../../message-editor/draftStore"; +import { useNavigationStore } from "../../navigation/store"; +import { TaskIcon } from "../../sidebar/components/items/TaskIcon"; +import { useTaskPrStatus } from "../../sidebar/useTaskPrStatus"; +import { TaskInput } from "../../task-detail/components/TaskInput"; +import { getCellSessionId, useCommandCenterStore } from "../commandCenterStore"; import type { CellStatus, CommandCenterCellData, } from "../hooks/useCommandCenterData"; -import { - getCellSessionId, - useCommandCenterStore, -} from "../stores/commandCenterStore"; import { CommandCenterPRButton } from "./CommandCenterPRButton"; import { CommandCenterSessionView } from "./CommandCenterSessionView"; import { TaskSelector } from "./TaskSelector"; diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx b/packages/ui/src/features/command-center/components/CommandCenterSessionView.tsx similarity index 80% rename from apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx rename to packages/ui/src/features/command-center/components/CommandCenterSessionView.tsx index 44791e68b3..1cfb5c6551 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterSessionView.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterSessionView.tsx @@ -1,11 +1,11 @@ -import { useDraftStore } from "@features/message-editor/stores/draftStore"; -import { SessionView } from "@features/sessions/components/SessionView"; -import { useSessionCallbacks } from "@features/sessions/hooks/useSessionCallbacks"; -import { useSessionConnection } from "@features/sessions/hooks/useSessionConnection"; -import { useSessionViewState } from "@features/sessions/hooks/useSessionViewState"; +import type { Task } from "@posthog/shared/domain-types"; import { Flex } from "@radix-ui/themes"; -import type { Task } from "@shared/types"; import { useEffect } from "react"; +import { useDraftStore } from "../../message-editor/draftStore"; +import { SessionView } from "../../sessions/components/SessionView"; +import { useSessionCallbacks } from "../../sessions/hooks/useSessionCallbacks"; +import { useSessionConnection } from "../../sessions/hooks/useSessionConnection"; +import { useSessionViewState } from "../../sessions/hooks/useSessionViewState"; interface CommandCenterSessionViewProps { taskId: string; diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterToolbar.tsx b/packages/ui/src/features/command-center/components/CommandCenterToolbar.tsx similarity index 92% rename from apps/code/src/renderer/features/command-center/components/CommandCenterToolbar.tsx rename to packages/ui/src/features/command-center/components/CommandCenterToolbar.tsx index a6bd878d18..f704fcf14c 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterToolbar.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterToolbar.tsx @@ -1,19 +1,24 @@ -import { getSessionService } from "@features/sessions/service/service"; import { MagnifyingGlassMinus, MagnifyingGlassPlus, Stop, Trash, } from "@phosphor-icons/react"; +import { selectStoppableTaskIds } from "@posthog/core/command-center/stopAll"; +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { useService } from "@posthog/di/react"; import { Flex, Select, Text } from "@radix-ui/themes"; +import { + type LayoutPreset, + useCommandCenterStore, +} from "../commandCenterStore"; import type { CommandCenterCellData, StatusSummary, } from "../hooks/useCommandCenterData"; -import { - type LayoutPreset, - useCommandCenterStore, -} from "../stores/commandCenterStore"; function LayoutIcon({ cols, rows }: { cols: number; rows: number }) { const size = 14; @@ -95,18 +100,13 @@ export function CommandCenterToolbar({ const zoom = useCommandCenterStore((s) => s.zoom); const zoomIn = useCommandCenterStore((s) => s.zoomIn); const zoomOut = useCommandCenterStore((s) => s.zoomOut); + const sessionService = useService<SessionService>(SESSION_SERVICE); const hasActiveAgents = summary.running > 0 || summary.waiting > 0; const stopAll = () => { - const service = getSessionService(); - for (const cell of cells) { - if ( - cell.taskId && - (cell.status === "running" || cell.status === "waiting") - ) { - service.cancelPrompt(cell.taskId); - } + for (const taskId of selectStoppableTaskIds(cells)) { + sessionService.cancelPrompt(taskId); } }; diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterView.tsx b/packages/ui/src/features/command-center/components/CommandCenterView.tsx similarity index 88% rename from apps/code/src/renderer/features/command-center/components/CommandCenterView.tsx rename to packages/ui/src/features/command-center/components/CommandCenterView.tsx index 0e844a523e..efddff34a0 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterView.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterView.tsx @@ -1,11 +1,11 @@ -import { useTaskViewed } from "@features/sidebar/hooks/useTaskViewed"; -import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import { Lightning } from "@phosphor-icons/react"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useEffect, useMemo } from "react"; +import { useSetHeaderContent } from "../../../hooks/useSetHeaderContent"; +import { useTaskViewed } from "../../sidebar/useTaskViewed"; +import { useCommandCenterStore } from "../commandCenterStore"; import { useAutofillCommandCenter } from "../hooks/useAutofillCommandCenter"; import { useCommandCenterData } from "../hooks/useCommandCenterData"; -import { useCommandCenterStore } from "../stores/commandCenterStore"; import { CommandCenterGrid } from "./CommandCenterGrid"; import { CommandCenterToolbar } from "./CommandCenterToolbar"; diff --git a/apps/code/src/renderer/features/command-center/components/TaskSelector.tsx b/packages/ui/src/features/command-center/components/TaskSelector.tsx similarity index 92% rename from apps/code/src/renderer/features/command-center/components/TaskSelector.tsx rename to packages/ui/src/features/command-center/components/TaskSelector.tsx index 5cd09d1fe4..ee4533421a 100644 --- a/apps/code/src/renderer/features/command-center/components/TaskSelector.tsx +++ b/packages/ui/src/features/command-center/components/TaskSelector.tsx @@ -1,10 +1,10 @@ -import { Combobox } from "@components/ui/combobox/Combobox"; import { Plus } from "@phosphor-icons/react"; import { Popover } from "@radix-ui/themes"; -import { useNavigationStore } from "@stores/navigationStore"; import { type ReactNode, useCallback } from "react"; +import { Combobox } from "../../../primitives/combobox/Combobox"; +import { useNavigationStore } from "../../navigation/store"; +import { useCommandCenterStore } from "../commandCenterStore"; import { useAvailableTasks } from "../hooks/useAvailableTasks"; -import { useCommandCenterStore } from "../stores/commandCenterStore"; interface TaskSelectorProps { cellIndex: number; diff --git a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts b/packages/ui/src/features/command-center/hooks/useAutofillCommandCenter.test.ts similarity index 95% rename from apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts rename to packages/ui/src/features/command-center/hooks/useAutofillCommandCenter.test.ts index d399bf0980..5210c545ac 100644 --- a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.test.ts +++ b/packages/ui/src/features/command-center/hooks/useAutofillCommandCenter.test.ts @@ -1,9 +1,9 @@ -import type { Workspace } from "@main/services/workspace/schemas"; -import type { Task } from "@shared/types"; +import type { Workspace } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; import { renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@utils/electronStorage", () => ({ +vi.mock("@posthog/ui/workbench/rendererStorage", () => ({ electronStorage: { getItem: () => null, setItem: () => {}, @@ -15,22 +15,22 @@ const mockUseTasks = vi.hoisted(() => vi.fn()); const mockUseWorkspaces = vi.hoisted(() => vi.fn()); const mockUseArchivedTaskIds = vi.hoisted(() => vi.fn()); -vi.mock("@features/tasks/hooks/useTasks", () => ({ +vi.mock("../../tasks/useTasks", () => ({ useTasks: mockUseTasks, })); -vi.mock("@features/workspace/hooks/useWorkspace", () => ({ +vi.mock("../../workspace/useWorkspace", () => ({ useWorkspaces: mockUseWorkspaces, })); -vi.mock("@features/archive/hooks/useArchivedTaskIds", () => ({ +vi.mock("../../archive/useArchivedTaskIds", () => ({ useArchivedTaskIds: mockUseArchivedTaskIds, })); import { COMMAND_CENTER_INITIAL_STATE, useCommandCenterStore, -} from "../stores/commandCenterStore"; +} from "../commandCenterStore"; import { useAutofillCommandCenter } from "./useAutofillCommandCenter"; const NOW = new Date("2026-02-27T12:00:00Z").getTime(); diff --git a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts b/packages/ui/src/features/command-center/hooks/useAutofillCommandCenter.ts similarity index 51% rename from apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts rename to packages/ui/src/features/command-center/hooks/useAutofillCommandCenter.ts index 83cb67a3df..5215817685 100644 --- a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts +++ b/packages/ui/src/features/command-center/hooks/useAutofillCommandCenter.ts @@ -1,22 +1,10 @@ -import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; -import { useTasks } from "@features/tasks/hooks/useTasks"; -import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; -import type { Task } from "@shared/types"; +import { selectAutofillCandidates } from "@posthog/core/command-center/autofill"; +import { workspaceIdSet } from "@posthog/core/command-center/eligibility"; import { useEffect, useRef } from "react"; -import { useCommandCenterStore } from "../stores/commandCenterStore"; - -// Window for "still in the current working session". Tasks last touched -// within this window are eligible to autofill empty cells when the -// Command Center mounts. -const RECENT_WINDOW_MS = 2 * 60 * 60 * 1000; - -function getLastActivity(task: Task): number { - const taskTime = new Date(task.updated_at).getTime(); - const runTime = task.latest_run?.updated_at - ? new Date(task.latest_run.updated_at).getTime() - : 0; - return Math.max(taskTime, runTime); -} +import { useArchivedTaskIds } from "../../archive/useArchivedTaskIds"; +import { useTasks } from "../../tasks/useTasks"; +import { useWorkspaces } from "../../workspace/useWorkspace"; +import { useCommandCenterStore } from "../commandCenterStore"; export function useAutofillCommandCenter(): void { const { data: tasks = [], isFetched: tasksFetched } = useTasks(); @@ -43,18 +31,13 @@ export function useAutofillCommandCenter(): void { } const assignedIds = new Set(cells.filter((id): id is string => id != null)); - const cutoff = Date.now() - RECENT_WINDOW_MS; - const candidates = tasks - .filter( - (task) => - !assignedIds.has(task.id) && - !archivedTaskIds.has(task.id) && - !!workspaces[task.id] && - getLastActivity(task) >= cutoff, - ) - .sort((a, b) => getLastActivity(b) - getLastActivity(a)) - .slice(0, emptySlots) - .map((task) => task.id); + const candidates = selectAutofillCandidates(tasks, { + assignedIds, + archivedIds: archivedTaskIds, + workspaceIds: workspaceIdSet(workspaces), + emptySlots, + nowMs: Date.now(), + }); if (candidates.length > 0) { autofillCells(candidates); diff --git a/packages/ui/src/features/command-center/hooks/useAvailableTasks.ts b/packages/ui/src/features/command-center/hooks/useAvailableTasks.ts new file mode 100644 index 0000000000..a9a1dcd6a4 --- /dev/null +++ b/packages/ui/src/features/command-center/hooks/useAvailableTasks.ts @@ -0,0 +1,26 @@ +import { + selectAvailableTasks, + workspaceIdSet, +} from "@posthog/core/command-center/eligibility"; +import type { Task } from "@posthog/shared/domain-types"; +import { useMemo } from "react"; +import { useArchivedTaskIds } from "../../archive/useArchivedTaskIds"; +import { useTasks } from "../../tasks/useTasks"; +import { useWorkspaces } from "../../workspace/useWorkspace"; +import { useCommandCenterStore } from "../commandCenterStore"; + +export function useAvailableTasks(): Task[] { + const { data: tasks = [] } = useTasks(); + const cells = useCommandCenterStore((s) => s.cells); + const archivedTaskIds = useArchivedTaskIds(); + const { data: workspaces } = useWorkspaces(); + + return useMemo(() => { + const assignedIds = new Set(cells.filter((id): id is string => id != null)); + return selectAvailableTasks(tasks, { + assignedIds, + archivedIds: archivedTaskIds, + workspaceIds: workspaceIdSet(workspaces), + }); + }, [tasks, cells, archivedTaskIds, workspaces]); +} diff --git a/packages/ui/src/features/command-center/hooks/useCommandCenterData.ts b/packages/ui/src/features/command-center/hooks/useCommandCenterData.ts new file mode 100644 index 0000000000..372053b391 --- /dev/null +++ b/packages/ui/src/features/command-center/hooks/useCommandCenterData.ts @@ -0,0 +1,61 @@ +import { + buildCommandCenterCells, + type CommandCenterCellData, +} from "@posthog/core/command-center/cells"; +import { + buildStatusSummary, + type CellStatus, + type StatusSummary, +} from "@posthog/core/command-center/status"; +import type { Task } from "@posthog/shared/domain-types"; +import { useMemo } from "react"; +import type { AgentSession } from "../../sessions/sessionStore"; +import { useSessions } from "../../sessions/useSession"; +import { useTasks } from "../../tasks/useTasks"; +import { useWorkspaces } from "../../workspace/useWorkspace"; +import { useCommandCenterStore } from "../commandCenterStore"; + +export type { CellStatus, StatusSummary, CommandCenterCellData }; +export { deriveStatus } from "@posthog/core/command-center/status"; + +export function useCommandCenterData(): { + cells: CommandCenterCellData[]; + summary: StatusSummary; +} { + const storeCells = useCommandCenterStore((s) => s.cells); + const { data: tasks = [] } = useTasks(); + const sessions = useSessions(); + const { data: workspaces } = useWorkspaces(); + + const taskById = useMemo(() => { + const map = new Map<string, Task>(); + for (const task of tasks) { + map.set(task.id, task); + } + return map; + }, [tasks]); + + const sessionByTaskId = useMemo(() => { + const map = new Map<string, AgentSession>(); + for (const session of Object.values(sessions)) { + if (session.taskId) { + map.set(session.taskId, session); + } + } + return map; + }, [sessions]); + + const cells = useMemo( + () => + buildCommandCenterCells(storeCells, { + taskById, + sessionByTaskId, + workspaces, + }), + [storeCells, taskById, sessionByTaskId, workspaces], + ); + + const summary = useMemo(() => buildStatusSummary(cells), [cells]); + + return { cells, summary }; +} diff --git a/apps/code/src/renderer/features/command/components/CommandKeyHints.tsx b/packages/ui/src/features/command/CommandKeyHints.tsx similarity index 100% rename from apps/code/src/renderer/features/command/components/CommandKeyHints.tsx rename to packages/ui/src/features/command/CommandKeyHints.tsx diff --git a/apps/code/src/renderer/features/command/components/CommandMenu.tsx b/packages/ui/src/features/command/CommandMenu.tsx similarity index 91% rename from apps/code/src/renderer/features/command/components/CommandMenu.tsx rename to packages/ui/src/features/command/CommandMenu.tsx index daf431fe54..df5ab3c5b7 100644 --- a/apps/code/src/renderer/features/command/components/CommandMenu.tsx +++ b/packages/ui/src/features/command/CommandMenu.tsx @@ -1,11 +1,3 @@ -import { useReviewNavigationStore } from "@features/code-review/stores/reviewNavigationStore"; -import { CommandKeyHints } from "@features/command/components/CommandKeyHints"; -import { useFolders } from "@features/folders/hooks/useFolders"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { TaskIcon } from "@features/sidebar/components/items/TaskIcon"; -import { useTaskPrStatus } from "@features/sidebar/hooks/useTaskPrStatus"; -import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; -import { useTasks } from "@features/tasks/hooks/useTasks"; import { Autocomplete, AutocompleteCollection, @@ -18,6 +10,22 @@ import { Dialog, DialogContent, } from "@posthog/quill"; +import { + ANALYTICS_EVENTS, + type CommandMenuAction, +} from "@posthog/shared/analytics-events"; +import type { Task } from "@posthog/shared/domain-types"; +import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; +import { CommandKeyHints } from "@posthog/ui/features/command/CommandKeyHints"; +import { useFolders } from "@posthog/ui/features/folders/useFolders"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { useSettingsDialogStore } from "@posthog/ui/features/settings/settingsDialogStore"; +import { TaskIcon } from "@posthog/ui/features/sidebar/components/items/TaskIcon"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { useTaskPrStatus } from "@posthog/ui/features/sidebar/useTaskPrStatus"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { track } from "@posthog/ui/workbench/analytics"; +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; import { DesktopIcon, FileTextIcon, @@ -27,14 +35,6 @@ import { SunIcon, ViewVerticalIcon, } from "@radix-ui/react-icons"; -import type { Task } from "@shared/types"; -import { - ANALYTICS_EVENTS, - type CommandMenuAction, -} from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; -import { useThemeStore } from "@stores/themeStore"; -import { track } from "@utils/analytics"; import { useCallback, useEffect, useMemo, useState } from "react"; interface CommandMenuProps { diff --git a/apps/code/src/renderer/features/command/components/FilePicker.tsx b/packages/ui/src/features/command/FilePicker.tsx similarity index 94% rename from apps/code/src/renderer/features/command/components/FilePicker.tsx rename to packages/ui/src/features/command/FilePicker.tsx index c9da87672f..de272b431e 100644 --- a/apps/code/src/renderer/features/command/components/FilePicker.tsx +++ b/packages/ui/src/features/command/FilePicker.tsx @@ -1,12 +1,3 @@ -import { FileIcon } from "@components/ui/FileIcon"; -import { CommandKeyHints } from "@features/command/components/CommandKeyHints"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { - type FileItem, - pathToFileItem, - searchFiles, - useRepoFiles, -} from "@hooks/useRepoFiles"; import { Autocomplete, AutocompleteCollection, @@ -19,6 +10,15 @@ import { Dialog, DialogContent, } from "@posthog/quill"; +import { CommandKeyHints } from "@posthog/ui/features/command/CommandKeyHints"; +import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; +import { + type FileItem, + pathToFileItem, + searchFiles, + useRepoFiles, +} from "@posthog/ui/features/repo-files/useRepoFiles"; +import { FileIcon } from "@posthog/ui/primitives/FileIcon"; import { useCallback, useMemo, useState } from "react"; interface FilePickerProps { diff --git a/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx new file mode 100644 index 0000000000..fd919b5081 --- /dev/null +++ b/packages/ui/src/features/command/KeyboardShortcutsSheet.tsx @@ -0,0 +1,201 @@ +import { + CATEGORY_LABELS, + formatHotkeyParts, + getShortcutsByCategory, + type ShortcutCategory, +} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { Box, Dialog, Flex, Text } from "@radix-ui/themes"; +import { useMemo, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +function Keycap({ label, size = "md" }: { label: string; size?: "sm" | "md" }) { + const [pressed, setPressed] = useState(false); + const isSmall = size === "sm"; + const minW = isSmall ? "22px" : "28px"; + const h = isSmall ? "22px" : "28px"; + const fontSize = isSmall ? "11px" : "13px"; + const shadowSize = isSmall ? "2px" : "3px"; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: cosmetic press animation + <span + role="presentation" + onMouseDown={() => setPressed(true)} + onMouseUp={() => setPressed(false)} + onMouseLeave={() => setPressed(false)} + style={{ + minWidth: minW, + height: h, + fontSize, + fontFamily: "system-ui, -apple-system, sans-serif", + lineHeight: 1, + borderBottomWidth: pressed ? "1px" : shadowSize, + borderBottomColor: "var(--gray-7)", + transform: pressed + ? `translateY(${isSmall ? "1px" : "2px"})` + : "translateY(0)", + transition: + "transform 80ms ease-out, border-bottom-width 80ms ease-out", + }} + className="box-border inline-flex cursor-pointer select-none items-center justify-center rounded-[6px] border border-(--gray-5) bg-(--gray-3) px-[6px] py-0 font-medium text-(--gray-11)" + > + {label} + </span> + ); +} + +interface KeyboardShortcutsSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function KeyboardShortcutsSheet({ + open, + onOpenChange, +}: KeyboardShortcutsSheetProps) { + useHotkeys("escape", () => onOpenChange(false), { + enabled: open, + enableOnContentEditable: true, + enableOnFormTags: true, + preventDefault: true, + }); + + return ( + <Dialog.Root open={open} onOpenChange={onOpenChange}> + <Dialog.Content + maxWidth="600px" + onEscapeKeyDown={(e) => e.preventDefault()} + className="max-h-[80vh] overflow-hidden" + > + <Flex align="start" justify="between" className="relative"> + <ShortcutsHeader /> + <button + type="button" + onClick={() => onOpenChange(false)} + className="shrink-0 cursor-pointer [all:unset]" + > + <Keycap label="Esc" size="sm" /> + </button> + </Flex> + + <Box className="max-h-[calc(80vh-120px)] overflow-y-auto pr-[8px]"> + <KeyboardShortcutsList /> + </Box> + </Dialog.Content> + </Dialog.Root> + ); +} + +function ShortcutsHeader() { + const triggerParts = formatHotkeyParts("mod+/"); + + return ( + <Box mb="4"> + <Flex align="center" gap="3" mb="1"> + <Dialog.Title mb="0" className="text-2xl leading-[1.2]"> + Keyboard Combos + </Dialog.Title> + <Flex gap="1" align="center"> + {triggerParts.map((part) => ( + <Keycap key={part} label={part} /> + ))} + </Flex> + </Flex> + <Text color="gray" className="text-sm"> + Your cheat codes for shipping faster + </Text> + </Box> + ); +} + +export function KeyboardShortcutsList() { + const shortcutsByCategory = useMemo(() => getShortcutsByCategory(), []); + + const categoryOrder: ShortcutCategory[] = [ + "general", + "navigation", + "panels", + "editor", + ]; + + return ( + <Flex direction="column" gap="5"> + {categoryOrder.map((category) => { + const shortcuts = shortcutsByCategory[category]; + if (shortcuts.length === 0) return null; + + const uniqueShortcuts = shortcuts.reduce( + (acc, shortcut) => { + const existing = acc.find( + (s) => s.description === shortcut.description, + ); + if (!existing) { + acc.push(shortcut); + } + return acc; + }, + [] as typeof shortcuts, + ); + + return ( + <Flex key={category} direction="column" gap="2"> + <Text color="gray" className="font-bold text-base"> + {CATEGORY_LABELS[category]} + </Text> + <Box className="overflow-hidden rounded-(--radius-2) border border-(--gray-5)"> + {uniqueShortcuts.map((shortcut) => ( + <Flex + key={shortcut.id} + align="center" + justify="between" + px="3" + className="border-b border-b-(--gray-4) pt-[6px] pb-[6px] last:border-b-0 odd:bg-(--gray-2) even:bg-(--gray-1)" + > + <Text className="text-sm">{shortcut.description}</Text> + <ShortcutKeys + keys={shortcut.keys} + alternateKeys={shortcut.alternateKeys} + /> + </Flex> + ))} + </Box> + </Flex> + ); + })} + </Flex> + ); +} + +function SingleShortcutKeys({ keys }: { keys: string }) { + const parts = formatHotkeyParts(keys); + + return ( + <Flex gap="1" align="center"> + {parts.map((part) => ( + <Keycap key={part} label={part} /> + ))} + </Flex> + ); +} + +function ShortcutKeys({ + keys, + alternateKeys, +}: { + keys: string; + alternateKeys?: string; +}) { + if (!alternateKeys) { + return <SingleShortcutKeys keys={keys} />; + } + + return ( + <Flex gap="1" align="center"> + <SingleShortcutKeys keys={keys} /> + <Text color="gray" className="text-[13px]"> + or + </Text> + <SingleShortcutKeys keys={alternateKeys} /> + </Flex> + ); +} diff --git a/apps/code/src/renderer/constants/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts similarity index 99% rename from apps/code/src/renderer/constants/keyboard-shortcuts.ts rename to packages/ui/src/features/command/keyboard-shortcuts.ts index b162013bbc..499f88b651 100644 --- a/apps/code/src/renderer/constants/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -1,4 +1,4 @@ -import { isMac } from "@utils/platform"; +import { isMac } from "@posthog/ui/utils/platform"; export const SHORTCUTS = { COMMAND_MENU: "mod+k", diff --git a/packages/ui/src/features/connectivity/connectivity-events.contribution.ts b/packages/ui/src/features/connectivity/connectivity-events.contribution.ts new file mode 100644 index 0000000000..7ca75fd989 --- /dev/null +++ b/packages/ui/src/features/connectivity/connectivity-events.contribution.ts @@ -0,0 +1,37 @@ +import { connectivityStore } from "@posthog/core/connectivity/connectivityStore"; +import type { WorkbenchContribution } from "@posthog/di/contribution"; +import { inject, injectable } from "inversify"; +import { + CONNECTIVITY_CLIENT, + type ConnectivityClient, +} from "./connectivityClient"; +import { initializeConnectivityToast } from "./connectivityToast"; + +/** + * Boots connectivity once at startup (formerly the platform-adapters/connectivity + * side-effect module): seeds the domain online store from the host's current + * status, keeps it in sync via the status-change subscription, and starts the + * debounced offline toast. + */ +@injectable() +export class ConnectivityEventsContribution implements WorkbenchContribution { + constructor( + @inject(CONNECTIVITY_CLIENT) + private readonly client: ConnectivityClient, + ) {} + + start(): void { + const { setOnline } = connectivityStore.getState(); + + void this.client + .getStatus() + .then((status) => setOnline(status.isOnline)) + .catch(() => undefined); + + this.client.onStatusChange({ + onData: (status) => setOnline(status.isOnline), + }); + + initializeConnectivityToast(); + } +} diff --git a/packages/ui/src/features/connectivity/connectivity.module.ts b/packages/ui/src/features/connectivity/connectivity.module.ts new file mode 100644 index 0000000000..8b6be9bce2 --- /dev/null +++ b/packages/ui/src/features/connectivity/connectivity.module.ts @@ -0,0 +1,9 @@ +import { WORKBENCH_CONTRIBUTION } from "@posthog/di/contribution"; +import { ContainerModule } from "inversify"; +import { ConnectivityEventsContribution } from "./connectivity-events.contribution"; + +export const connectivityUiModule = new ContainerModule(({ bind }) => { + bind(WORKBENCH_CONTRIBUTION) + .to(ConnectivityEventsContribution) + .inSingletonScope(); +}); diff --git a/packages/ui/src/features/connectivity/connectivityClient.ts b/packages/ui/src/features/connectivity/connectivityClient.ts new file mode 100644 index 0000000000..239d513d5b --- /dev/null +++ b/packages/ui/src/features/connectivity/connectivityClient.ts @@ -0,0 +1,17 @@ +interface Subscriber<T> { + onData: (data: T) => void; + onError?: (error: unknown) => void; +} + +export interface ConnectivityStatusPayload { + isOnline: boolean; +} + +export interface ConnectivityClient { + getStatus(): Promise<ConnectivityStatusPayload>; + onStatusChange(sub: Subscriber<ConnectivityStatusPayload>): { + unsubscribe: () => void; + }; +} + +export const CONNECTIVITY_CLIENT = Symbol.for("posthog.ui.ConnectivityClient"); diff --git a/packages/ui/src/features/connectivity/connectivityToast.ts b/packages/ui/src/features/connectivity/connectivityToast.ts new file mode 100644 index 0000000000..2f85f641df --- /dev/null +++ b/packages/ui/src/features/connectivity/connectivityToast.ts @@ -0,0 +1,50 @@ +import { connectivityStore } from "@posthog/core/connectivity/connectivityStore"; +import { toast as sonnerToast } from "sonner"; +import { toast } from "../../primitives/toast"; + +const TOAST_ID = "connectivity-offline"; +const OFFLINE_DEBOUNCE_MS = 5_000; + +export function showOfflineToast() { + toast.error("No internet connection", { + id: TOAST_ID, + duration: Number.POSITIVE_INFINITY, + description: + "PostHog Code features that need the network are paused until you reconnect.", + }); +} + +// Debounces flaky transitions: only surfaces a toast when continuously offline +// for OFFLINE_DEBOUNCE_MS. The stable id guarantees the toast never stacks. +export function initializeConnectivityToast() { + let pendingTimer: ReturnType<typeof setTimeout> | null = null; + let wasOnline = connectivityStore.getState().isOnline; + + const clearPending = () => { + if (pendingTimer) { + clearTimeout(pendingTimer); + pendingTimer = null; + } + }; + + const unsubscribe = connectivityStore.subscribe((state) => { + if (state.isOnline === wasOnline) return; + wasOnline = state.isOnline; + + if (!state.isOnline) { + clearPending(); + pendingTimer = setTimeout(() => { + pendingTimer = null; + showOfflineToast(); + }, OFFLINE_DEBOUNCE_MS); + } else { + clearPending(); + sonnerToast.dismiss(TOAST_ID); + } + }); + + return () => { + clearPending(); + unsubscribe(); + }; +} diff --git a/packages/ui/src/features/deep-links/useNewTaskDeepLink.ts b/packages/ui/src/features/deep-links/useNewTaskDeepLink.ts new file mode 100644 index 0000000000..e8e29a0eca --- /dev/null +++ b/packages/ui/src/features/deep-links/useNewTaskDeepLink.ts @@ -0,0 +1,102 @@ +import type { NewTaskLinkAnalytics } from "@posthog/core/deep-links/identifiers"; +import { + NEW_TASK_LINK_RESOLVER, + type NewTaskLinkResolver, +} from "@posthog/core/deep-links/newTaskLinkResolver"; +import { useService } from "@posthog/di/react"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import type { NewTaskLinkPayload } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { toast } from "@posthog/ui/primitives/toast"; +import { track } from "@posthog/ui/workbench/analytics"; +import { logger } from "@posthog/ui/workbench/logger"; +import { useCallback, useEffect, useRef } from "react"; + +const log = logger.scope("new-task-deep-link"); + +function trackResolution(analytics: NewTaskLinkAnalytics) { + switch (analytics.event) { + case ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK: + return track(analytics.event, analytics.properties); + case ANALYTICS_EVENTS.DEEP_LINK_PLAN: + return track(analytics.event, analytics.properties); + case ANALYTICS_EVENTS.DEEP_LINK_ISSUE: + return track(analytics.event, analytics.properties); + case ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED: + return track(analytics.event, analytics.properties); + } +} + +export function useNewTaskDeepLink() { + const client = useHostTRPCClient(); + const resolver = useService<NewTaskLinkResolver>(NEW_TASK_LINK_RESOLVER); + const navigateToTaskInput = useNavigationStore( + (state) => state.navigateToTaskInput, + ); + const clearTaskInputReportAssociation = useNavigationStore( + (state) => state.clearTaskInputReportAssociation, + ); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + const hasFetchedPending = useRef(false); + + const handleAction = useCallback( + async (payload: NewTaskLinkPayload) => { + log.info(`Handling deep link action: ${payload.action}`); + clearTaskInputReportAssociation(); + + const result = await resolver.resolve(payload); + trackResolution(result.analytics); + + if (result.kind === "navigate") { + navigateToTaskInput(result.navigation); + return; + } + + toast.error(result.title, { description: result.description }); + log.warn(result.title, result.description); + }, + [navigateToTaskInput, clearTaskInputReportAssociation, resolver], + ); + + useEffect(() => { + if (!isAuthenticated) { + hasFetchedPending.current = false; + return; + } + if (hasFetchedPending.current) return; + + const fetchPending = async () => { + hasFetchedPending.current = true; + try { + const pending = await client.deepLink.getPendingNewTaskLink.query(); + if (pending) { + log.info(`Found pending new task link: action=${pending.action}`); + handleAction(pending).catch((error) => { + log.error("Failed to handle pending new task link:", error); + }); + } + } catch (error) { + hasFetchedPending.current = false; + log.error("Failed to check for pending new task link:", error); + } + }; + + fetchPending(); + }, [isAuthenticated, handleAction, client]); + + useEffect(() => { + const subscription = client.deepLink.onNewTaskAction.subscribe(undefined, { + onData: (data) => { + log.info(`Received new task link event: action=${data.action}`); + handleAction(data).catch((error) => { + log.error("Failed to handle new task link action:", error); + }); + }, + }); + return () => subscription.unsubscribe(); + }, [client, handleAction]); +} diff --git a/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx b/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx new file mode 100644 index 0000000000..d057c9d79c --- /dev/null +++ b/packages/ui/src/features/deep-links/useTaskDeepLink.test.tsx @@ -0,0 +1,78 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const openTask = vi.hoisted(() => + vi.fn().mockResolvedValue({ + success: true, + data: { task: { id: "t1" }, workspace: null }, + }), +); +const getPendingDeepLink = vi.hoisted(() => vi.fn().mockResolvedValue(null)); +const onOpenTask = vi.hoisted(() => vi.fn(() => ({ unsubscribe: vi.fn() }))); +const navigateToTask = vi.hoisted(() => vi.fn()); +const markAsViewed = vi.hoisted(() => vi.fn()); + +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPCClient: () => ({ + deepLink: { + getPendingDeepLink: { query: getPendingDeepLink }, + onOpenTask: { subscribe: onOpenTask }, + }, + }), +})); +vi.mock("@posthog/ui/features/auth/store", () => ({ + useAuthStateValue: (sel: (s: { status: string }) => unknown) => + sel({ status: "authenticated" }), +})); +vi.mock("@posthog/ui/features/navigation/store", () => ({ + useNavigationStore: (sel: (s: { navigateToTask: unknown }) => unknown) => + sel({ navigateToTask }), +})); +vi.mock("@posthog/ui/features/sidebar/useTaskViewed", () => ({ + useTaskViewed: () => ({ markAsViewed }), +})); +vi.mock("@posthog/di/react", () => ({ + useService: () => ({ openTask }), +})); +vi.mock("@posthog/ui/workbench/logger", () => ({ + logger: { scope: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() }) }, +})); +vi.mock("@posthog/ui/primitives/toast", () => ({ + toast: { error: vi.fn() }, +})); + +import { useTaskDeepLink } from "./useTaskDeepLink"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + ); +} + +describe("useTaskDeepLink", () => { + beforeEach(() => { + vi.clearAllMocks(); + getPendingDeepLink.mockResolvedValue(null); + }); + + it("opens a pending cold-start deep link through the bridge and navigates", async () => { + getPendingDeepLink.mockResolvedValue({ taskId: "t1" }); + renderHook(() => useTaskDeepLink(), { wrapper }); + + await waitFor(() => expect(openTask).toHaveBeenCalledWith("t1", undefined)); + await waitFor(() => + expect(navigateToTask).toHaveBeenCalledWith({ id: "t1" }), + ); + expect(markAsViewed).toHaveBeenCalledWith("t1"); + }); + + it("subscribes to warm-start open-task events", () => { + renderHook(() => useTaskDeepLink(), { wrapper }); + expect(onOpenTask).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/code/src/renderer/hooks/useTaskDeepLink.ts b/packages/ui/src/features/deep-links/useTaskDeepLink.ts similarity index 66% rename from apps/code/src/renderer/hooks/useTaskDeepLink.ts rename to packages/ui/src/features/deep-links/useTaskDeepLink.ts index 73c0b101d7..39ef26b50d 100644 --- a/apps/code/src/renderer/hooks/useTaskDeepLink.ts +++ b/packages/ui/src/features/deep-links/useTaskDeepLink.ts @@ -1,32 +1,29 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useTaskViewed } from "@features/sidebar/hooks/useTaskViewed"; -import type { TaskService } from "@features/task-detail/service/service"; -import { get } from "@renderer/di/container"; -import { RENDERER_TOKENS } from "@renderer/di/tokens"; -import { trpcClient, useTRPC } from "@renderer/trpc"; -import type { Task } from "@shared/types"; -import { useNavigationStore } from "@stores/navigationStore"; +import { + TASK_SERVICE, + type TaskService, +} from "@posthog/core/task-detail/taskService"; +import { useService } from "@posthog/di/react"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import type { Task } from "@posthog/shared/domain-types"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; +import { taskKeys } from "@posthog/ui/features/tasks/taskKeys"; +import { toast } from "@posthog/ui/primitives/toast"; +import { logger } from "@posthog/ui/workbench/logger"; import { useQueryClient } from "@tanstack/react-query"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { logger } from "@utils/logger"; import { useCallback, useEffect, useRef } from "react"; -import { toast } from "sonner"; const log = logger.scope("task-deep-link"); -const taskKeys = { - all: ["tasks"] as const, - lists: () => [...taskKeys.all, "list"] as const, - list: (filters?: { repository?: string }) => - [...taskKeys.lists(), filters] as const, -}; - /** - * Hook that subscribes to deep link events and handles opening tasks. - * Uses TaskService to fetch task and set up workspace via the saga pattern. + * Subscribes to open-existing-task deep link events and opens the task. Uses + * the TASK_SERVICE bridge (createTask/openTask) to provision the workspace via + * the saga pattern, so this hook no longer depends on the renderer TaskService. */ export function useTaskDeepLink() { - const trpcReact = useTRPC(); + const client = useHostTRPCClient(); + const taskService = useService<TaskService>(TASK_SERVICE); const navigateToTask = useNavigationStore((state) => state.navigateToTask); const { markAsViewed } = useTaskViewed(); const queryClient = useQueryClient(); @@ -42,7 +39,6 @@ export function useTaskDeepLink() { ); try { - const taskService = get<TaskService>(RENDERER_TOKENS.TaskService); const result = await taskService.openTask(taskId, taskRunId); if (!result.success) { @@ -58,7 +54,6 @@ export function useTaskDeepLink() { const { task } = result.data; - // Add task to query cache so it shows in sidebar queryClient.setQueryData<Task[]>(taskKeys.list(), (old) => { if (!old) return [task]; const existingIndex = old.findIndex((t) => t.id === task.id); @@ -70,7 +65,6 @@ export function useTaskDeepLink() { return [task, ...old]; }); - // Invalidate to ensure sync with server queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); markAsViewed(taskId); @@ -84,7 +78,7 @@ export function useTaskDeepLink() { toast.error("Failed to open task"); } }, - [navigateToTask, markAsViewed, queryClient], + [navigateToTask, markAsViewed, queryClient, taskService], ); // Check for pending deep link on mount (for cold start via deep link) @@ -94,7 +88,7 @@ export function useTaskDeepLink() { const fetchPending = async () => { hasFetchedPending.current = true; try { - const pending = await trpcClient.deepLink.getPendingDeepLink.query(); + const pending = await client.deepLink.getPendingDeepLink.query(); if (pending) { log.info( `Found pending deep link: taskId=${pending.taskId}, taskRunId=${pending.taskRunId ?? "none"}`, @@ -107,11 +101,11 @@ export function useTaskDeepLink() { }; fetchPending(); - }, [isAuthenticated, handleOpenTask]); + }, [isAuthenticated, handleOpenTask, client]); // Subscribe to deep link events (for warm start via deep link) - useSubscription( - trpcReact.deepLink.onOpenTask.subscriptionOptions(undefined, { + useEffect(() => { + const subscription = client.deepLink.onOpenTask.subscribe(undefined, { onData: (data) => { log.info( `Received deep link event: taskId=${data.taskId}, taskRunId=${data.taskRunId ?? "none"}`, @@ -119,6 +113,7 @@ export function useTaskDeepLink() { if (!data?.taskId) return; handleOpenTask(data.taskId, data.taskRunId); }, - }), - ); + }); + return () => subscription.unsubscribe(); + }, [client, handleOpenTask]); } diff --git a/apps/code/src/renderer/features/editor/components/GithubRefChip.tsx b/packages/ui/src/features/editor/components/GithubRefChip.tsx similarity index 100% rename from apps/code/src/renderer/features/editor/components/GithubRefChip.tsx rename to packages/ui/src/features/editor/components/GithubRefChip.tsx diff --git a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx similarity index 91% rename from apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx rename to packages/ui/src/features/editor/components/MarkdownRenderer.tsx index 050e10e003..406ad62e4f 100644 --- a/apps/code/src/renderer/features/editor/components/MarkdownRenderer.tsx +++ b/packages/ui/src/features/editor/components/MarkdownRenderer.tsx @@ -1,17 +1,17 @@ -import { CodeBlock } from "@components/CodeBlock"; -import { Divider } from "@components/Divider"; -import { HighlightedCode } from "@components/HighlightedCode"; -import { List, ListItem } from "@components/List"; -import { parseGithubIssueUrl } from "@features/message-editor/utils/githubIssueUrl"; +import { isPostHogCodeDeeplink } from "@posthog/shared"; +import { GithubRefChip } from "@posthog/ui/features/editor/components/GithubRefChip"; +import { parseGithubIssueUrl } from "@posthog/ui/features/message-editor/githubIssueUrl"; +import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; +import { Divider } from "@posthog/ui/primitives/Divider"; +import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode"; +import { List, ListItem } from "@posthog/ui/primitives/List"; import { Blockquote, Checkbox, Code, Kbd, Text } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import { isPostHogCodeDeeplink } from "@shared/deeplink"; import { memo, useMemo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; import type { PluggableList } from "unified"; -import { GithubRefChip } from "./GithubRefChip"; +import { openExternalUrl } from "../../../workbench/openExternal"; interface MarkdownRendererProps { content: string; @@ -106,7 +106,7 @@ export const baseComponents: Components = { onClick={(event) => { if (!isDeeplink || !href) return; event.preventDefault(); - void trpcClient.os.openExternal.mutate({ url: href }); + openExternalUrl(href); }} target="_blank" rel="noopener noreferrer" diff --git a/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx b/packages/ui/src/features/environments/EnvironmentSelector.tsx similarity index 89% rename from apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx rename to packages/ui/src/features/environments/EnvironmentSelector.tsx index 389f450cde..8f61735daa 100644 --- a/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx +++ b/packages/ui/src/features/environments/EnvironmentSelector.tsx @@ -1,4 +1,3 @@ -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { CaretDown, HardDrives, Plus } from "@phosphor-icons/react"; import { Button, @@ -11,15 +10,15 @@ import { ComboboxListFooter, ComboboxTrigger, } from "@posthog/quill"; -import { useTRPC } from "@renderer/trpc/client"; -import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; +import { useEnvironments } from "./useEnvironments"; interface EnvironmentSelectorProps { repoPath: string | null; value: string | null; onChange: (environmentId: string | null) => void; disabled?: boolean; + onCreateEnvironment?: () => void; } const NONE_VALUE = "__none__"; @@ -29,15 +28,12 @@ export function EnvironmentSelector({ value, onChange, disabled = false, + onCreateEnvironment, }: EnvironmentSelectorProps) { const [open, setOpen] = useState(false); const anchorRef = useRef<HTMLDivElement>(null); - const trpc = useTRPC(); - const { data: environments = [] } = useQuery({ - ...trpc.environment.list.queryOptions({ repoPath: repoPath ?? "" }), - enabled: !!repoPath, - }); + const { data: environments = [] } = useEnvironments(repoPath); useEffect(() => { if (value === null && environments.length > 0) { @@ -59,9 +55,7 @@ export function EnvironmentSelector({ const handleOpenSettings = () => { setOpen(false); - useSettingsDialogStore - .getState() - .open("environments", { repoPath: repoPath ?? undefined }); + onCreateEnvironment?.(); }; const isDisabled = disabled || !repoPath; @@ -70,7 +64,7 @@ export function EnvironmentSelector({ const allItems = [ NONE_VALUE, ...environments.map((env) => env.id), - CREATE_ENV_ACTION, + ...(onCreateEnvironment ? [CREATE_ENV_ACTION] : []), ]; return ( diff --git a/packages/ui/src/features/environments/useEnvironments.ts b/packages/ui/src/features/environments/useEnvironments.ts new file mode 100644 index 0000000000..d145b85420 --- /dev/null +++ b/packages/ui/src/features/environments/useEnvironments.ts @@ -0,0 +1,10 @@ +import { useWorkspaceTRPC } from "@posthog/workspace-client/trpc"; +import { useQuery } from "@tanstack/react-query"; + +export function useEnvironments(repoPath: string | null) { + const trpc = useWorkspaceTRPC(); + return useQuery({ + ...trpc.environment.list.queryOptions({ repoPath: repoPath ?? "" }), + enabled: !!repoPath, + }); +} diff --git a/packages/ui/src/features/external-apps/focusCoordinator.ts b/packages/ui/src/features/external-apps/focusCoordinator.ts new file mode 100644 index 0000000000..afd1af02db --- /dev/null +++ b/packages/ui/src/features/external-apps/focusCoordinator.ts @@ -0,0 +1,20 @@ +import type { + ExternalAppsFocusCoordinator, + ExternalAppsFocusParams, + ExternalAppsFocusSession, +} from "@posthog/core/external-apps/identifiers"; +import type { FocusSagaResult } from "@posthog/core/focus/service"; +import { injectable } from "inversify"; +import { useFocusStore } from "../focus/focusStore"; + +@injectable() +export class FocusStoreCoordinator implements ExternalAppsFocusCoordinator { + getSession(): ExternalAppsFocusSession | null { + const session = useFocusStore.getState().session; + return session ? { worktreePath: session.worktreePath } : null; + } + + enableFocus(params: ExternalAppsFocusParams): Promise<FocusSagaResult> { + return useFocusStore.getState().enableFocus(params); + } +} diff --git a/packages/ui/src/features/external-apps/useExternalAppAction.ts b/packages/ui/src/features/external-apps/useExternalAppAction.ts new file mode 100644 index 0000000000..6cb6280ca0 --- /dev/null +++ b/packages/ui/src/features/external-apps/useExternalAppAction.ts @@ -0,0 +1,60 @@ +import type { ExternalAppAction } from "@posthog/core/context-menu/schemas"; +import type { + ExternalAppService, + ExternalAppWorkspaceContext, +} from "@posthog/core/external-apps/externalAppService"; +import { EXTERNAL_APPS_SERVICE } from "@posthog/core/external-apps/identifiers"; +import { useService } from "@posthog/di/react"; +import { useCallback } from "react"; +import { toast } from "../../primitives/toast"; +import { showFocusSuccessToast } from "../focus/focusToast"; + +export function useExternalAppAction() { + const service = useService<ExternalAppService>(EXTERNAL_APPS_SERVICE); + + return useCallback( + async ( + action: ExternalAppAction, + filePath: string, + displayName: string, + workspaceContext?: ExternalAppWorkspaceContext, + ): Promise<void> => { + const outcome = await service.openExternalApp( + action, + filePath, + displayName, + workspaceContext, + ); + + switch (outcome.kind) { + case "opened": + if (outcome.focus) { + showFocusSuccessToast( + outcome.focus.branchName, + outcome.focus.result, + ); + } + toast.success(`Opening in ${outcome.appName}`, { + description: outcome.displayName, + }); + return; + case "open-failed": + toast.error("Failed to open in external app", { + description: outcome.error, + }); + return; + case "focus-failed": + toast.error("Could not edit workspace", { + description: outcome.error, + }); + return; + case "copied": + toast.success("Path copied to clipboard", { + description: outcome.filePath, + }); + return; + } + }, + [service], + ); +} diff --git a/packages/ui/src/features/external-apps/useExternalApps.test.tsx b/packages/ui/src/features/external-apps/useExternalApps.test.tsx new file mode 100644 index 0000000000..546ab5441d --- /dev/null +++ b/packages/ui/src/features/external-apps/useExternalApps.test.tsx @@ -0,0 +1,70 @@ +import type { DetectedApplication } from "@posthog/shared/domain-types"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockClient = vi.hoisted(() => ({ + externalApps: { + getDetectedApps: { query: vi.fn() }, + getLastUsed: { query: vi.fn() }, + setLastUsed: { mutate: vi.fn() }, + }, +})); +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPCClient: () => mockClient, +})); + +import { useExternalApps } from "./useExternalApps"; + +const apps = [ + { id: "vscode", name: "VS Code" }, + { id: "cursor", name: "Cursor" }, +] as unknown as DetectedApplication[]; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + ); +} + +describe("useExternalApps", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockClient.externalApps.getDetectedApps.query.mockResolvedValue(apps); + mockClient.externalApps.getLastUsed.query.mockResolvedValue({ + lastUsedApp: undefined, + }); + mockClient.externalApps.setLastUsed.mutate.mockResolvedValue(undefined); + }); + + it("defaults to the first detected app when none was last used", async () => { + const { result } = renderHook(() => useExternalApps(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.detectedApps).toEqual(apps); + expect(result.current.defaultApp?.id).toBe("vscode"); + }); + + it("prefers the last-used app as the default", async () => { + mockClient.externalApps.getLastUsed.query.mockResolvedValue({ + lastUsedApp: "cursor", + }); + const { result } = renderHook(() => useExternalApps(), { wrapper }); + await waitFor(() => expect(result.current.lastUsedAppId).toBe("cursor")); + expect(result.current.defaultApp?.id).toBe("cursor"); + }); + + it("setLastUsedApp forwards to the client", async () => { + const { result } = renderHook(() => useExternalApps(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await act(async () => { + await result.current.setLastUsedApp("cursor"); + }); + expect(mockClient.externalApps.setLastUsed.mutate).toHaveBeenCalledWith({ + appId: "cursor", + }); + }); +}); diff --git a/packages/ui/src/features/external-apps/useExternalApps.ts b/packages/ui/src/features/external-apps/useExternalApps.ts new file mode 100644 index 0000000000..af8f240684 --- /dev/null +++ b/packages/ui/src/features/external-apps/useExternalApps.ts @@ -0,0 +1,57 @@ +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; + +const DETECTED_APPS_KEY = ["external-apps", "detected"] as const; +const LAST_USED_KEY = ["external-apps", "last-used"] as const; + +export function useExternalApps() { + const client = useHostTRPCClient(); + const queryClient = useQueryClient(); + + const { data: detectedApps = [], isLoading: appsLoading } = useQuery({ + queryKey: DETECTED_APPS_KEY, + queryFn: () => client.externalApps.getDetectedApps.query(), + staleTime: 60_000, + }); + + const { data: lastUsedAppId, isLoading: lastUsedLoading } = useQuery({ + queryKey: LAST_USED_KEY, + queryFn: async () => + (await client.externalApps.getLastUsed.query()).lastUsedApp, + staleTime: 60_000, + }); + + const setLastUsedMutation = useMutation({ + mutationFn: (appId: string) => + client.externalApps.setLastUsed.mutate({ appId }), + onSuccess: (_, appId) => { + queryClient.setQueryData(LAST_USED_KEY, appId); + }, + }); + + const isLoading = appsLoading || lastUsedLoading; + + const defaultApp = useMemo(() => { + if (lastUsedAppId) { + const app = detectedApps.find((a) => a.id === lastUsedAppId); + if (app) return app; + } + return detectedApps[0] || null; + }, [detectedApps, lastUsedAppId]); + + const setLastUsedApp = useCallback( + async (appId: string) => { + await setLastUsedMutation.mutateAsync(appId); + }, + [setLastUsedMutation], + ); + + return { + detectedApps, + lastUsedAppId, + defaultApp, + isLoading, + setLastUsedApp, + }; +} diff --git a/packages/ui/src/features/feature-flags/identifiers.ts b/packages/ui/src/features/feature-flags/identifiers.ts new file mode 100644 index 0000000000..cb1e9411e4 --- /dev/null +++ b/packages/ui/src/features/feature-flags/identifiers.ts @@ -0,0 +1,11 @@ +/** + * Renderer feature-flag access. Desktop adapter wraps the host analytics/ + * posthog-js feature flags; resolved via useService so packages/ui stays + * host-agnostic. + */ +export interface FeatureFlags { + isEnabled(flagKey: string): boolean; + onFlagsLoaded(handler: () => void): () => void; +} + +export const FEATURE_FLAGS = Symbol.for("posthog.ui.featureFlags"); diff --git a/packages/ui/src/features/feature-flags/useFeatureFlag.ts b/packages/ui/src/features/feature-flags/useFeatureFlag.ts new file mode 100644 index 0000000000..6028ac63ab --- /dev/null +++ b/packages/ui/src/features/feature-flags/useFeatureFlag.ts @@ -0,0 +1,20 @@ +import { useService } from "@posthog/di/react"; +import { useEffect, useState } from "react"; +import { FEATURE_FLAGS, type FeatureFlags } from "./identifiers"; + +export function useFeatureFlag(flagKey: string, defaultValue = false): boolean { + const flags = useService<FeatureFlags>(FEATURE_FLAGS); + const [enabled, setEnabled] = useState( + () => flags.isEnabled(flagKey) || defaultValue, + ); + + useEffect(() => { + setEnabled(flags.isEnabled(flagKey) || defaultValue); + + return flags.onFlagsLoaded(() => { + setEnabled(flags.isEnabled(flagKey) || defaultValue); + }); + }, [flags, flagKey, defaultValue]); + + return enabled; +} diff --git a/packages/ui/src/features/file-watcher/file-watcher.contribution.ts b/packages/ui/src/features/file-watcher/file-watcher.contribution.ts new file mode 100644 index 0000000000..6947b7837f --- /dev/null +++ b/packages/ui/src/features/file-watcher/file-watcher.contribution.ts @@ -0,0 +1,15 @@ +import type { WorkbenchContribution } from "@posthog/di/contribution"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; + +@injectable() +export class FileWatcherContribution implements WorkbenchContribution { + constructor( + @inject(WORKBENCH_LOGGER) + private readonly logger: WorkbenchLogger, + ) {} + + start(): void { + this.logger.info("file-watcher feature ready"); + } +} diff --git a/packages/ui/src/features/file-watcher/file-watcher.module.ts b/packages/ui/src/features/file-watcher/file-watcher.module.ts new file mode 100644 index 0000000000..0553358262 --- /dev/null +++ b/packages/ui/src/features/file-watcher/file-watcher.module.ts @@ -0,0 +1,7 @@ +import { WORKBENCH_CONTRIBUTION } from "@posthog/di/contribution"; +import { ContainerModule } from "inversify"; +import { FileWatcherContribution } from "./file-watcher.contribution"; + +export const fileWatcherUiModule = new ContainerModule(({ bind }) => { + bind(WORKBENCH_CONTRIBUTION).to(FileWatcherContribution).inSingletonScope(); +}); diff --git a/packages/ui/src/features/file-watcher/identifiers.ts b/packages/ui/src/features/file-watcher/identifiers.ts new file mode 100644 index 0000000000..54b0b7c1cc --- /dev/null +++ b/packages/ui/src/features/file-watcher/identifiers.ts @@ -0,0 +1,6 @@ +export interface FileWatcherClient { + start(repoPath: string): Promise<void>; + stop(repoPath: string): Promise<void>; +} + +export const FILE_WATCHER_CLIENT = Symbol.for("posthog.ui.fileWatcher.client"); diff --git a/packages/ui/src/features/file-watcher/useRepoFileWatcher.ts b/packages/ui/src/features/file-watcher/useRepoFileWatcher.ts new file mode 100644 index 0000000000..b88b2a2309 --- /dev/null +++ b/packages/ui/src/features/file-watcher/useRepoFileWatcher.ts @@ -0,0 +1,80 @@ +import { useService } from "@posthog/di/react"; +import { toRelativePath } from "@posthog/shared"; +import type { FileWatcherEvent } from "@posthog/workspace-client/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect } from "react"; +import { logger } from "../../workbench/logger"; +import { + invalidateGitBranchQueries, + invalidateGitWorkingTreeQueries, +} from "../git-interaction/gitCacheKeys"; +import { + GIT_CACHE_KEY_PROVIDER, + type GitCacheKeyProvider, +} from "../git-interaction/gitCacheProvider"; +import { usePanelLayoutStore } from "../panels/panelLayoutStore"; +import { FILE_WATCHER_CLIENT, type FileWatcherClient } from "./identifiers"; +import { useFileWatcher } from "./useFileWatcher"; + +const log = logger.scope("file-watcher"); + +/** + * Drives the host file watcher for a repo: starts/stops the main-side watcher + * and reacts to its events (invalidate fs reads + git caches, close tabs for + * deleted files). Was the renderer-only `@hooks/useFileWatcher`; now host + * access flows through FILE_WATCHER_CLIENT + the fs/git cache-key providers. + */ +export function useRepoFileWatcher(repoPath: string | null, taskId?: string) { + const control = useService<FileWatcherClient>(FILE_WATCHER_CLIENT); + const cacheKeys = useService<GitCacheKeyProvider>(GIT_CACHE_KEY_PROVIDER); + const queryClient = useQueryClient(); + const closeTabsForFile = usePanelLayoutStore((s) => s.closeTabsForFile); + + useEffect(() => { + if (!repoPath) return; + control.start(repoPath).catch((error) => { + log.error("Failed to start main-side file watcher:", error); + }); + return () => { + void control.stop(repoPath); + }; + }, [repoPath, control]); + + const onEvent = useCallback( + (event: FileWatcherEvent) => { + if (!repoPath) return; + switch (event.kind) { + case "file-changed": { + const relativePath = toRelativePath(event.filePath, repoPath); + queryClient.invalidateQueries({ + queryKey: cacheKeys.fsQueryKey("readRepoFile", { + repoPath, + filePath: relativePath, + }), + }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.fsQueryKey("readRepoFileBounded", { + repoPath, + filePath: relativePath, + }), + }); + return; + } + case "file-deleted": { + if (!taskId) return; + closeTabsForFile(taskId, toRelativePath(event.filePath, repoPath)); + return; + } + case "git-state-changed": + invalidateGitBranchQueries(repoPath); + return; + case "working-tree-changed": + invalidateGitWorkingTreeQueries(repoPath); + return; + } + }, + [repoPath, taskId, queryClient, closeTabsForFile, cacheKeys], + ); + + useFileWatcher(repoPath, onEvent); +} diff --git a/packages/ui/src/features/focus/focus-events.contribution.ts b/packages/ui/src/features/focus/focus-events.contribution.ts new file mode 100644 index 0000000000..2dbc91538b --- /dev/null +++ b/packages/ui/src/features/focus/focus-events.contribution.ts @@ -0,0 +1,57 @@ +import type { WorkbenchContribution } from "@posthog/di/contribution"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { inject, injectable } from "inversify"; +import { toast } from "../../primitives/toast"; +import { logger } from "../../workbench/logger"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "../../workbench/queryClient"; +import { WORKSPACE_QUERY_KEY } from "../workspace/identifiers"; +import { useFocusStore } from "./focusStore"; + +const log = logger.scope("focus-events"); + +/** + * Boots the global focus-event listeners once at startup (formerly inline + * useSubscription side effects in App.tsx). A host-side branch rename keeps the + * focus session's branch in sync and refreshes the workspace query; a foreign + * branch checkout out from under a focused worktree auto-unfocuses. + */ +@injectable() +export class FocusEventsContribution implements WorkbenchContribution { + constructor( + @inject(HOST_TRPC_CLIENT) + private readonly hostClient: HostTrpcClient, + @inject(IMPERATIVE_QUERY_CLIENT) + private readonly queryClient: ImperativeQueryClient, + ) {} + + start(): void { + this.hostClient.focus.onBranchRenamed.subscribe(undefined, { + onData: ({ worktreePath, newBranch }) => { + useFocusStore.getState().updateSessionBranch(worktreePath, newBranch); + void this.queryClient.invalidateQueries({ + queryKey: WORKSPACE_QUERY_KEY, + }); + }, + }); + + this.hostClient.focus.onForeignBranchCheckout.subscribe(undefined, { + onData: async ({ focusedBranch, foreignBranch }) => { + log.warn( + `Foreign branch checkout detected: ${focusedBranch} -> ${foreignBranch}. Auto-unfocusing.`, + ); + const result = await useFocusStore.getState().disableFocus(); + if (!result.success && result.error) { + toast.error("Could not unfocus workspace", { + description: result.error, + }); + } + }, + }); + } +} diff --git a/packages/ui/src/features/focus/focus.module.ts b/packages/ui/src/features/focus/focus.module.ts new file mode 100644 index 0000000000..ee48403331 --- /dev/null +++ b/packages/ui/src/features/focus/focus.module.ts @@ -0,0 +1,7 @@ +import { WORKBENCH_CONTRIBUTION } from "@posthog/di/contribution"; +import { ContainerModule } from "inversify"; +import { FocusEventsContribution } from "./focus-events.contribution"; + +export const focusUiModule = new ContainerModule(({ bind }) => { + bind(WORKBENCH_CONTRIBUTION).to(FocusEventsContribution).inSingletonScope(); +}); diff --git a/packages/ui/src/features/focus/focusAdapter.ts b/packages/ui/src/features/focus/focusAdapter.ts new file mode 100644 index 0000000000..a8b5685187 --- /dev/null +++ b/packages/ui/src/features/focus/focusAdapter.ts @@ -0,0 +1,65 @@ +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import type { FocusControllerDeps } from "./focusClient"; + +function host(): HostTrpcClient { + return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT); +} + +export const focusDeps: FocusControllerDeps = { + cancelSessionPrompt: async (sessionId, reason) => { + await host().agent.cancelPrompt.mutate({ sessionId, reason }); + }, + checkout: (repoPath, branch) => + host().focus.checkout.mutate({ repoPath, branch }), + cleanWorkingTree: (repoPath) => + host().focus.cleanWorkingTree.mutate({ repoPath }), + deleteSession: (mainRepoPath) => + host().focus.deleteSession.mutate({ mainRepoPath }), + detachWorktree: (worktreePath) => + host().focus.detachWorktree.mutate({ worktreePath }), + getCommitSha: (repoPath) => host().focus.getCommitSha.query({ repoPath }), + getCurrentBranch: async (mainRepoPath) => + await host().git.getCurrentBranch.query({ + directoryPath: mainRepoPath, + }), + getSession: (mainRepoPath) => host().focus.getSession.query({ mainRepoPath }), + isDirty: (repoPath) => host().focus.isDirty.query({ repoPath }), + listLocalTaskIds: async (mainRepoPath) => + (await host().workspace.getLocalTasks.query({ mainRepoPath })).map( + ({ taskId }) => taskId, + ), + listSessionIds: async (taskId) => + (await host().agent.listSessions.query({ taskId })).map( + ({ taskRunId }) => taskRunId, + ), + listWorktreeTaskIds: async (worktreePath) => + (await host().workspace.getWorktreeTasks.query({ worktreePath })).map( + ({ taskId }) => taskId, + ), + notifySessionContext: (sessionId, context) => + host().agent.notifySessionContext.mutate({ sessionId, context }), + reattachWorktree: (worktreePath, branch) => + host().focus.reattachWorktree.mutate({ worktreePath, branch }), + saveSession: (session) => host().focus.saveSession.mutate(session), + stash: (repoPath, message) => + host().focus.stash.mutate({ repoPath, message }), + stashApply: (repoPath, stashRef) => + host().focus.stashApply.mutate({ repoPath, stashRef }), + startSync: (mainRepoPath, worktreePath) => + host().focus.startSync.mutate({ mainRepoPath, worktreePath }), + startWatchingMainRepo: (mainRepoPath) => + host().focus.startWatchingMainRepo.mutate({ mainRepoPath }), + stopSync: () => host().focus.stopSync.mutate(), + stopWatchingMainRepo: () => host().focus.stopWatchingMainRepo.mutate(), + toRelativeWorktreePath: (absolutePath, mainRepoPath) => + host().focus.toRelativeWorktreePath.query({ + absolutePath, + mainRepoPath, + }), + worktreeExistsAtPath: (relativePath) => + host().focus.worktreeExistsAtPath.query({ relativePath }), +}; diff --git a/packages/ui/src/features/focus/focusClient.ts b/packages/ui/src/features/focus/focusClient.ts new file mode 100644 index 0000000000..51653ce5c8 --- /dev/null +++ b/packages/ui/src/features/focus/focusClient.ts @@ -0,0 +1,7 @@ +import type { FocusControllerDeps } from "@posthog/core/focus/service"; + +export type { FocusControllerDeps }; + +export const FOCUS_CONTROLLER_DEPS = Symbol.for( + "posthog.ui.FocusControllerDeps", +); diff --git a/packages/ui/src/features/focus/focusStore.ts b/packages/ui/src/features/focus/focusStore.ts new file mode 100644 index 0000000000..503c866cd6 --- /dev/null +++ b/packages/ui/src/features/focus/focusStore.ts @@ -0,0 +1,90 @@ +import { + type EnableFocusParams, + FocusController, + type FocusSagaResult, +} from "@posthog/core/focus/service"; +import { resolveService } from "@posthog/di/container"; +import type { SagaLogger } from "@posthog/shared"; +import { logger } from "@posthog/ui/workbench/logger"; +import type { + FocusResult, + FocusSession, +} from "@posthog/workspace-client/types"; +import { create } from "zustand"; +import { invalidateGitBranchQueries } from "../git-interaction/gitCacheKeys"; +import { FOCUS_CONTROLLER_DEPS, type FocusControllerDeps } from "./focusClient"; + +const log = logger.scope("focus-store"); + +const sagaLogger: SagaLogger = { + info: (message, data) => log.info(message, data), + debug: (message, data) => log.debug(message, data), + error: (message, data) => log.error(message, data), + warn: (message, data) => log.warn(message, data), +}; + +let focusControllerInstance: FocusController | null = null; + +function focusController(): FocusController { + focusControllerInstance ??= new FocusController( + resolveService<FocusControllerDeps>(FOCUS_CONTROLLER_DEPS), + sagaLogger, + ); + return focusControllerInstance; +} + +export type { FocusSagaResult }; + +interface FocusState { + session: FocusSession | null; + isLoading: boolean; + enableFocus: (params: EnableFocusParams) => Promise<FocusSagaResult>; + disableFocus: () => Promise<FocusResult>; + restore: (mainRepoPath: string) => Promise<void>; + updateSessionBranch: (worktreePath: string, newBranch: string) => void; +} + +export const useFocusStore = create<FocusState>()((set, get) => ({ + session: null, + isLoading: false, + + enableFocus: async (params) => { + set({ isLoading: true }); + const result = await focusController().enableFocus(params, get().session); + set({ + isLoading: false, + session: result.success ? result.session : get().session, + }); + if (result.success) invalidateGitBranchQueries(params.mainRepoPath); + return result; + }, + + disableFocus: async () => { + const { session } = get(); + if (!session) return { success: false, error: "No active focus session" }; + + set({ isLoading: true }); + const result = await focusController().disableFocus(session); + set({ isLoading: false, session: result.success ? null : session }); + if (result.success) invalidateGitBranchQueries(session.mainRepoPath); + return result; + }, + + restore: async (mainRepoPath) => { + const session = await focusController().restore(mainRepoPath); + if (session) set({ session }); + }, + + updateSessionBranch: (worktreePath, newBranch) => { + const { session } = get(); + if (session?.worktreePath === worktreePath) { + set({ session: { ...session, branch: newBranch } }); + } + }, +})); + +export const selectIsLoading = (state: FocusState) => state.isLoading; + +export const selectIsFocusedOnWorktree = + (worktreePath: string) => (state: FocusState) => + state.session?.worktreePath === worktreePath; diff --git a/apps/code/src/renderer/utils/focusToast.tsx b/packages/ui/src/features/focus/focusToast.tsx similarity index 82% rename from apps/code/src/renderer/utils/focusToast.tsx rename to packages/ui/src/features/focus/focusToast.tsx index 63b67b4b24..e8d153d865 100644 --- a/apps/code/src/renderer/utils/focusToast.tsx +++ b/packages/ui/src/features/focus/focusToast.tsx @@ -1,6 +1,6 @@ import { Text } from "@radix-ui/themes"; -import type { FocusSagaResult } from "@stores/focusStore"; -import { toast } from "@utils/toast"; +import { toast } from "../../primitives/toast"; +import type { FocusSagaResult } from "./focusStore"; export function showFocusSuccessToast( branchName: string, diff --git a/apps/code/src/renderer/features/folder-picker/components/AddDirectoryDialog.tsx b/packages/ui/src/features/folder-picker/AddDirectoryDialog.tsx similarity index 89% rename from apps/code/src/renderer/features/folder-picker/components/AddDirectoryDialog.tsx rename to packages/ui/src/features/folder-picker/AddDirectoryDialog.tsx index 833dfd0512..d663b14e70 100644 --- a/apps/code/src/renderer/features/folder-picker/components/AddDirectoryDialog.tsx +++ b/packages/ui/src/features/folder-picker/AddDirectoryDialog.tsx @@ -1,4 +1,7 @@ import { Folder } from "@phosphor-icons/react"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { useService } from "@posthog/di/react"; +import { useHostTRPCClient } from "@posthog/host-router/react"; import { Button, Dialog, @@ -8,14 +11,12 @@ import { DialogHeader, DialogTitle, } from "@posthog/quill"; -import { trpcClient } from "@renderer/trpc/client"; -import { logger } from "@utils/logger"; +import { useAddDirectoryDialogStore } from "@posthog/ui/features/folder-picker/addDirectoryDialogStore"; import { useEffect, useRef } from "react"; -import { useAddDirectoryDialogStore } from "../stores/addDirectoryDialogStore"; - -const log = logger.scope("add-directory-dialog"); export function AddDirectoryDialog() { + const trpcClient = useHostTRPCClient(); + const log = useService<WorkbenchLogger>(WORKBENCH_LOGGER); const open = useAddDirectoryDialogStore((s) => s.open); const taskId = useAddDirectoryDialogStore((s) => s.taskId); const path = useAddDirectoryDialogStore((s) => s.path); diff --git a/apps/code/src/renderer/features/folder-picker/components/FolderPicker.tsx b/packages/ui/src/features/folder-picker/FolderPicker.tsx similarity index 90% rename from apps/code/src/renderer/features/folder-picker/components/FolderPicker.tsx rename to packages/ui/src/features/folder-picker/FolderPicker.tsx index 095d7c32f7..50f6dc6245 100644 --- a/apps/code/src/renderer/features/folder-picker/components/FolderPicker.tsx +++ b/packages/ui/src/features/folder-picker/FolderPicker.tsx @@ -1,10 +1,12 @@ -import { useFolders } from "@features/folders/hooks/useFolders"; import { CaretDown, Folder as FolderIcon, FolderOpen, GitBranch, } from "@phosphor-icons/react"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { useService } from "@posthog/di/react"; +import { useHostTRPCClient } from "@posthog/host-router/react"; import { Button, DropdownMenu, @@ -14,14 +16,11 @@ import { DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; +import { useFolders } from "@posthog/ui/features/folders/useFolders"; +import { FIELD_TRIGGER_CLASS } from "@posthog/ui/styles/fieldTrigger"; import { Flex, Text } from "@radix-ui/themes"; -import { FIELD_TRIGGER_CLASS } from "@renderer/styles/fieldTrigger"; -import { trpcClient } from "@renderer/trpc"; -import { logger } from "@utils/logger"; import type { RefObject } from "react"; -const log = logger.scope("folder-picker"); - interface FolderPickerProps { value: string; onChange: (path: string) => void; @@ -37,6 +36,8 @@ export function FolderPicker({ variant = "compact", anchor, }: FolderPickerProps) { + const trpcClient = useHostTRPCClient(); + const log = useService<WorkbenchLogger>(WORKBENCH_LOGGER); const { getRecentFolders, getFolderDisplayName, diff --git a/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx b/packages/ui/src/features/folder-picker/GitHubRepoPicker.tsx similarity index 99% rename from apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx rename to packages/ui/src/features/folder-picker/GitHubRepoPicker.tsx index 2a34c33041..65e477ba25 100644 --- a/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx +++ b/packages/ui/src/features/folder-picker/GitHubRepoPicker.tsx @@ -1,4 +1,3 @@ -import { Tooltip } from "@components/ui/Tooltip"; import { ArrowClockwise, GithubLogo } from "@phosphor-icons/react"; import { Button, @@ -11,6 +10,7 @@ import { ComboboxListFooter, ComboboxTrigger, } from "@posthog/quill"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { defaultFilter } from "cmdk"; import { type RefObject, useEffect, useMemo, useRef, useState } from "react"; diff --git a/apps/code/src/renderer/features/folder-picker/stores/addDirectoryDialogStore.ts b/packages/ui/src/features/folder-picker/addDirectoryDialogStore.ts similarity index 100% rename from apps/code/src/renderer/features/folder-picker/stores/addDirectoryDialogStore.ts rename to packages/ui/src/features/folder-picker/addDirectoryDialogStore.ts diff --git a/packages/ui/src/features/folders/types.ts b/packages/ui/src/features/folders/types.ts new file mode 100644 index 0000000000..e445bde9da --- /dev/null +++ b/packages/ui/src/features/folders/types.ts @@ -0,0 +1,9 @@ +export interface RegisteredFolder { + id: string; + path: string; + name: string; + remoteUrl: string | null; + lastAccessed: string; + createdAt: string; + exists?: boolean; +} diff --git a/packages/ui/src/features/folders/useFolders.ts b/packages/ui/src/features/folders/useFolders.ts new file mode 100644 index 0000000000..b8977a8026 --- /dev/null +++ b/packages/ui/src/features/folders/useFolders.ts @@ -0,0 +1,92 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; + +export function useFolders() { + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + + const foldersQueryKey = trpc.folders.getFolders.queryKey(); + + const { data: folders = [], isLoading } = useQuery( + trpc.folders.getFolders.queryOptions(undefined, { staleTime: 30_000 }), + ); + + const existingFolders = useMemo( + () => folders.filter((f) => f.exists !== false), + [folders], + ); + + const invalidate = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: foldersQueryKey }); + }, [queryClient, foldersQueryKey]); + + const addFolderMutation = useMutation( + trpc.folders.addFolder.mutationOptions({ onSuccess: invalidate }), + ); + + const removeFolderMutation = useMutation( + trpc.folders.removeFolder.mutationOptions({ onSuccess: invalidate }), + ); + + const updateAccessedMutation = useMutation( + trpc.folders.updateFolderAccessed.mutationOptions(), + ); + + const addFolder = useCallback( + (folderPath: string) => addFolderMutation.mutateAsync({ folderPath }), + [addFolderMutation], + ); + + const removeFolder = useCallback( + (folderId: string) => removeFolderMutation.mutateAsync({ folderId }), + [removeFolderMutation], + ); + + const updateLastAccessed = useCallback( + (folderId: string) => { + updateAccessedMutation.mutate({ folderId }); + }, + [updateAccessedMutation], + ); + + const getFolderByPath = useCallback( + (path: string) => existingFolders.find((f) => f.path === path), + [existingFolders], + ); + + const getRecentFolders = useCallback( + (limit = 5) => + [...existingFolders] + .sort( + (a, b) => + new Date(b.lastAccessed).getTime() - + new Date(a.lastAccessed).getTime(), + ) + .slice(0, limit), + [existingFolders], + ); + + const getFolderDisplayName = useCallback( + (path: string) => { + if (!path) return null; + const folder = existingFolders.find((f) => f.path === path); + return folder?.name ?? path.split("/").pop() ?? null; + }, + [existingFolders], + ); + + const loadFolders = useCallback(() => invalidate(), [invalidate]); + + return { + folders: existingFolders, + isLoaded: !isLoading, + addFolder, + removeFolder, + updateLastAccessed, + getFolderByPath, + getRecentFolders, + getFolderDisplayName, + loadFolders, + }; +} diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.test.ts b/packages/ui/src/features/git-interaction/cloudPrUrl.test.ts similarity index 81% rename from apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.test.ts rename to packages/ui/src/features/git-interaction/cloudPrUrl.test.ts index 4499882cfb..d65a745cf9 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.test.ts +++ b/packages/ui/src/features/git-interaction/cloudPrUrl.test.ts @@ -1,16 +1,7 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@features/sessions/hooks/useSession", () => ({ - useSessionForTask: vi.fn(), -})); - -vi.mock("@features/tasks/hooks/useTasks", () => ({ - useTasks: vi.fn(() => ({ data: [] })), -})); - -import type { AgentSession } from "@features/sessions/stores/sessionStore"; -import type { Task } from "@shared/types"; -import { resolveCloudPrUrl } from "./useCloudPrUrl"; +import type { Task } from "@posthog/shared/domain-types"; +import type { AgentSession } from "@posthog/ui/features/sessions/sessionStore"; +import { describe, expect, it } from "vitest"; +import { resolveCloudPrUrl } from "./cloudPrUrl"; function makeTask(prUrl?: unknown): Task { return { diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.ts b/packages/ui/src/features/git-interaction/cloudPrUrl.ts similarity index 51% rename from apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.ts rename to packages/ui/src/features/git-interaction/cloudPrUrl.ts index 972e338619..11e659a921 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.ts +++ b/packages/ui/src/features/git-interaction/cloudPrUrl.ts @@ -1,7 +1,5 @@ -import { useSessionForTask } from "@features/sessions/hooks/useSession"; -import type { AgentSession } from "@features/sessions/stores/sessionStore"; -import { useTasks } from "@features/tasks/hooks/useTasks"; -import type { Task } from "@shared/types"; +import type { Task } from "@posthog/shared/domain-types"; +import type { AgentSession } from "@posthog/ui/features/sessions/sessionStore"; /** * Extracts the PR URL from a task and/or session. The URL can arrive via the @@ -19,11 +17,3 @@ export function resolveCloudPrUrl( if (typeof sessionPrUrl === "string" && sessionPrUrl) return sessionPrUrl; return null; } - -/** Hook wrapper for components that don't already have the task/session. */ -export function useCloudPrUrl(taskId: string): string | null { - const { data: tasks = [] } = useTasks(); - const task = tasks.find((t) => t.id === taskId); - const session = useSessionForTask(taskId); - return resolveCloudPrUrl(task, session); -} diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.test.tsx b/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx similarity index 87% rename from apps/code/src/renderer/features/git-interaction/components/BranchSelector.test.tsx rename to packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx index b76f10229b..e91afa371a 100644 --- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.test.tsx +++ b/packages/ui/src/features/git-interaction/components/BranchSelector.test.tsx @@ -3,28 +3,38 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -vi.mock("@features/git-interaction/state/gitInteractionStore", () => ({ +vi.mock("../state/gitInteractionStore", () => ({ useGitInteractionStore: () => ({ actions: { openBranch: vi.fn() } }), })); -vi.mock("@features/git-interaction/utils/getSuggestedBranchName", () => ({ +vi.mock("../utils/getSuggestedBranchName", () => ({ getSuggestedBranchName: vi.fn(() => null), })); -vi.mock("@features/git-interaction/utils/gitCacheKeys", () => ({ +vi.mock("../gitCacheKeys", () => ({ invalidateGitBranchQueries: vi.fn(), })); -vi.mock("@renderer/trpc", () => ({ - useTRPC: () => ({ +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPC: () => ({ git: { - getAllBranches: { queryOptions: () => ({ queryKey: ["mock"] }) }, + getAllBranches: { queryOptions: () => ({}) }, checkoutBranch: { mutationOptions: () => ({}) }, }, }), })); -vi.mock("@renderer/utils/toast", () => ({ +vi.mock("@posthog/di/react", () => ({ + useService: () => ({ + gitQueryKey: () => [], + gitQueryFilter: () => ({}), + gitPathFilter: () => ({}), + fsPathFilter: () => ({}), + fsQueryKey: () => [], + }), +})); + +vi.mock("../../../primitives/toast", () => ({ toast: { error: vi.fn() }, })); @@ -32,6 +42,10 @@ const mutateMock = vi.fn(); vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: [], isLoading: false }), useMutation: () => ({ mutate: mutateMock }), + useQueryClient: () => ({ + getQueriesData: () => [], + getQueryData: () => undefined, + }), })); import { BranchSelector } from "./BranchSelector"; diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx b/packages/ui/src/features/git-interaction/components/BranchSelector.tsx similarity index 88% rename from apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx rename to packages/ui/src/features/git-interaction/components/BranchSelector.tsx index 0f00fce188..10586d57f1 100644 --- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx +++ b/packages/ui/src/features/git-interaction/components/BranchSelector.tsx @@ -1,7 +1,3 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; -import { getSuggestedBranchName } from "@features/git-interaction/utils/getSuggestedBranchName"; -import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; import { ArrowClockwise, CaretDown, @@ -10,6 +6,8 @@ import { Plus, Spinner, } from "@phosphor-icons/react"; +import { useService } from "@posthog/di/react"; +import { useHostTRPC } from "@posthog/host-router/react"; import { Button, Combobox, @@ -23,11 +21,21 @@ import { InputGroupAddon, InputGroupButton, } from "@posthog/quill"; -import { useTRPC } from "@renderer/trpc"; -import { toast } from "@renderer/utils/toast"; -import type { GitBusyOperation, GitBusyState } from "@shared/types"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import type { + GitBusyOperation, + GitBusyState, +} from "@posthog/shared/domain-types"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { type RefObject, useEffect, useRef, useState } from "react"; +import { Tooltip } from "../../../primitives/Tooltip"; +import { toast } from "../../../primitives/toast"; +import { invalidateGitBranchQueries } from "../gitCacheKeys"; +import { + GIT_CACHE_KEY_PROVIDER, + type GitCacheKeyProvider, +} from "../gitCacheProvider"; +import { useGitInteractionStore } from "../state/gitInteractionStore"; +import { getSuggestedBranchName } from "../utils/getSuggestedBranchName"; const COMBOBOX_LIMIT = 50; @@ -120,7 +128,11 @@ export function BranchSelector({ const [hovered, setHovered] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const localAnchorRef = useRef<HTMLButtonElement>(null); - const trpc = useTRPC(); + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + const cacheKeyProvider = useService<GitCacheKeyProvider>( + GIT_CACHE_KEY_PROVIDER, + ); const { actions } = useGitInteractionStore(); const isCloudMode = workspaceMode === "cloud"; @@ -134,12 +146,13 @@ export function BranchSelector({ }, [isSelectionOnly, defaultBranch, selectedBranch, onBranchSelect]); const { data: localBranches = [], isLoading: localBranchesLoading } = - useQuery( - trpc.git.getAllBranches.queryOptions( - { directoryPath: repoPath as string }, - { enabled: !isCloudMode && !!repoPath, staleTime: 60_000 }, - ), - ); + useQuery({ + ...trpc.git.getAllBranches.queryOptions({ + directoryPath: repoPath as string, + }), + enabled: !isCloudMode && !!repoPath, + staleTime: 60_000, + }); const branches = isCloudMode ? (cloudBranches ?? []) : localBranches; const effectiveLoading = loading || (isCloudMode && cloudBranchesLoading); @@ -147,27 +160,26 @@ export function BranchSelector({ ? !!cloudBranchesLoading : localBranchesLoading; - const checkoutMutation = useMutation( - trpc.git.checkoutBranch.mutationOptions({ - onSuccess: () => { - if (repoPath) invalidateGitBranchQueries(repoPath); - }, - onError: (error, { branchName }) => { - const message = - error instanceof Error ? error.message : "Unknown error occurred"; - if (/would be overwritten by checkout/i.test(message)) { - toast.error(`Can't switch to ${branchName}`, { - description: - "You have uncommitted changes that would be overwritten. Commit or stash them first.", - }); - return; - } - toast.error(`Failed to checkout ${branchName}`, { - description: message, + const checkoutMutation = useMutation({ + ...trpc.git.checkoutBranch.mutationOptions(), + onSuccess: () => { + if (repoPath) invalidateGitBranchQueries(repoPath); + }, + onError: (error, { branchName }) => { + const message = + error instanceof Error ? error.message : "Unknown error occurred"; + if (/would be overwritten by checkout/i.test(message)) { + toast.error(`Can't switch to ${branchName}`, { + description: + "You have uncommitted changes that would be overwritten. Commit or stash them first.", }); - }, - }), - ); + return; + } + toast.error(`Failed to checkout ${branchName}`, { + description: message, + }); + }, + }); // In local mode, surface in-progress git operations (rebase/merge/etc.) so the // user understands why there's no current branch and why we won't let them @@ -209,7 +221,12 @@ export function BranchSelector({ setOpen(false); actions.openBranch( taskId - ? getSuggestedBranchName(taskId, repoPath ?? undefined) + ? getSuggestedBranchName( + queryClient, + cacheKeyProvider, + taskId, + repoPath ?? undefined, + ) : undefined, ); return; diff --git a/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx b/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx similarity index 72% rename from apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx rename to packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx index b8b358155b..8c252dc00d 100644 --- a/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx +++ b/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx @@ -1,20 +1,27 @@ -import { - GitBranchDialog, - GitCommitDialog, -} from "@features/git-interaction/components/GitInteractionDialogs"; -import { useGitInteraction } from "@features/git-interaction/hooks/useGitInteraction"; -import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; -import { getSuggestedBranchName } from "@features/git-interaction/utils/getSuggestedBranchName"; -import { DirtyTreeDialog } from "@features/sessions/components/DirtyTreeDialog"; -import { HandoffConfirmDialog } from "@features/sessions/components/HandoffConfirmDialog"; -import { useSessionForTask } from "@features/sessions/hooks/useSession"; -import { getLocalHandoffService } from "@features/sessions/service/localHandoffService"; -import { useHandoffDialogStore } from "@features/sessions/stores/handoffDialogStore"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { Laptop, Spinner } from "@phosphor-icons/react"; +import type { ContinueAfterDirtyTreeStep } from "@posthog/core/sessions/localHandoffService"; +import { useService } from "@posthog/di/react"; import { Button as QuillButton } from "@posthog/quill"; -import type { Task } from "@shared/types"; +import type { Task } from "@posthog/shared/domain-types"; +import { + LOCAL_HANDOFF_SERVICE, + type LocalHandoffService, +} from "@posthog/ui/features/sessions/localHandoffService"; +import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import { useFeatureFlag } from "../../feature-flags/useFeatureFlag"; +import { DirtyTreeDialog } from "../../sessions/components/DirtyTreeDialog"; +import { HandoffConfirmDialog } from "../../sessions/components/HandoffConfirmDialog"; +import { useHandoffDialogStore } from "../../sessions/handoffDialogStore"; +import { useSessionForTask } from "../../sessions/useSession"; +import { + GIT_CACHE_KEY_PROVIDER, + type GitCacheKeyProvider, +} from "../gitCacheProvider"; +import { useGitInteractionStore } from "../state/gitInteractionStore"; +import { useGitInteraction } from "../useGitInteraction"; +import { getSuggestedBranchName } from "../utils/getSuggestedBranchName"; +import { GitBranchDialog, GitCommitDialog } from "./GitInteractionDialogs"; const CLOUD_HANDOFF_FLAG = "phc-cloud-handoff"; @@ -28,7 +35,11 @@ export function CloudGitInteractionHeader({ task, }: CloudGitInteractionHeaderProps) { const session = useSessionForTask(taskId); - const localHandoff = getLocalHandoffService(); + const queryClient = useQueryClient(); + const cacheKeyProvider = useService<GitCacheKeyProvider>( + GIT_CACHE_KEY_PROVIDER, + ); + const localHandoff = useService<LocalHandoffService>(LOCAL_HANDOFF_SERVICE); const cloudHandoffEnabled = useFeatureFlag(CLOUD_HANDOFF_FLAG) || import.meta.env.DEV; @@ -60,28 +71,39 @@ export function CloudGitInteractionHeader({ } }; - const handleCommitAndContinue = async () => { - localHandoff.hideDirtyTree(); - if (git.state.isFeatureBranch) { - useGitInteractionStore.getState().actions.openCommit("commit"); + const applyStep = (step: ContinueAfterDirtyTreeStep) => { + const actions = useGitInteractionStore.getState().actions; + if (step.step === "open-commit") { + actions.openCommit("commit"); return; } + actions.openBranch(step.suggestedName); + }; - useGitInteractionStore - .getState() - .actions.openBranch(getSuggestedBranchName(taskId, commitRepoPath)); + const handleCommitAndContinue = async () => { + applyStep( + localHandoff.continueAfterDirtyTree({ + isFeatureBranch: git.state.isFeatureBranch, + suggestedBranchName: getSuggestedBranchName( + queryClient, + cacheKeyProvider, + taskId, + commitRepoPath, + ), + }), + ); }; const handleBranchConfirm = async () => { const branchCreated = await git.actions.runBranch(); if (!branchCreated) return; - useGitInteractionStore.getState().actions.openCommit("commit"); + applyStep(localHandoff.afterBranchCreated()); }; const handleCommitConfirm = async () => { const committed = await git.actions.runCommit(); if (!committed) return; - await localHandoff.resumePending(); + await localHandoff.afterCommit(); }; if (!cloudHandoffEnabled) return null; diff --git a/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.stories.tsx b/packages/ui/src/features/git-interaction/components/CreatePrDialog.stories.tsx similarity index 96% rename from apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.stories.tsx rename to packages/ui/src/features/git-interaction/components/CreatePrDialog.stories.tsx index 306d4f1eba..4a6bedde22 100644 --- a/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.stories.tsx +++ b/packages/ui/src/features/git-interaction/components/CreatePrDialog.stories.tsx @@ -1,7 +1,7 @@ -import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; -import type { CreatePrStep } from "@features/git-interaction/types"; +import { CreatePrDialog } from "@posthog/ui/features/git-interaction/components/CreatePrDialog"; +import { useGitInteractionStore } from "@posthog/ui/features/git-interaction/state/gitInteractionStore"; +import type { CreatePrStep } from "@posthog/ui/features/git-interaction/types"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { CreatePrDialog } from "./CreatePrDialog"; function setStoreState(overrides: { branchName?: string; diff --git a/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx b/packages/ui/src/features/git-interaction/components/CreatePrDialog.tsx similarity index 94% rename from apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx rename to packages/ui/src/features/git-interaction/components/CreatePrDialog.tsx index be82def71e..63d2c1ea01 100644 --- a/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx +++ b/packages/ui/src/features/git-interaction/components/CreatePrDialog.tsx @@ -1,18 +1,9 @@ -import { StepList, type StepStatus } from "@components/ui/StepList"; -import { - CommitAllToggle, - ErrorContainer, - GenerateButton, -} from "@features/git-interaction/components/GitInteractionDialogs"; -import { useFixWithAgent } from "@features/git-interaction/hooks/useFixWithAgent"; -import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; -import type { CreatePrStep } from "@features/git-interaction/types"; +import { GitPullRequest } from "@phosphor-icons/react"; import { type DiffStats, formatFileCountLabel, -} from "@features/git-interaction/utils/diffStats"; -import { buildCreatePrFlowErrorPrompt } from "@features/git-interaction/utils/errorPrompts"; -import { GitPullRequest } from "@phosphor-icons/react"; +} from "@posthog/core/git-interaction/diffStats"; +import { buildCreatePrFlowErrorPrompt } from "@posthog/core/git-interaction/errorPrompts"; import { Button, Checkbox, @@ -22,6 +13,15 @@ import { TextArea, TextField, } from "@radix-ui/themes"; +import { StepList, type StepStatus } from "../../../primitives/StepList"; +import { useGitInteractionStore } from "../state/gitInteractionStore"; +import type { CreatePrStep } from "../types"; +import { useFixWithAgent } from "../useFixWithAgent"; +import { + CommitAllToggle, + ErrorContainer, + GenerateButton, +} from "./GitInteractionDialogs"; const ICON_SIZE = 14; diff --git a/apps/code/src/renderer/features/git-interaction/components/GitInteractionDialogs.stories.tsx b/packages/ui/src/features/git-interaction/components/GitInteractionDialogs.stories.tsx similarity index 98% rename from apps/code/src/renderer/features/git-interaction/components/GitInteractionDialogs.stories.tsx rename to packages/ui/src/features/git-interaction/components/GitInteractionDialogs.stories.tsx index e358f99981..aa03a3c044 100644 --- a/apps/code/src/renderer/features/git-interaction/components/GitInteractionDialogs.stories.tsx +++ b/packages/ui/src/features/git-interaction/components/GitInteractionDialogs.stories.tsx @@ -1,6 +1,9 @@ +import { + GitCommitDialog, + GitPushDialog, +} from "@posthog/ui/features/git-interaction/components/GitInteractionDialogs"; import { Flex } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { GitCommitDialog, GitPushDialog } from "./GitInteractionDialogs"; function DialogShowcase() { return <Flex direction="column" gap="4" />; diff --git a/apps/code/src/renderer/features/git-interaction/components/GitInteractionDialogs.tsx b/packages/ui/src/features/git-interaction/components/GitInteractionDialogs.tsx similarity index 99% rename from apps/code/src/renderer/features/git-interaction/components/GitInteractionDialogs.tsx rename to packages/ui/src/features/git-interaction/components/GitInteractionDialogs.tsx index 0b6bab3243..85d7fa0ab5 100644 --- a/apps/code/src/renderer/features/git-interaction/components/GitInteractionDialogs.tsx +++ b/packages/ui/src/features/git-interaction/components/GitInteractionDialogs.tsx @@ -1,8 +1,3 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import { - type DiffStats, - formatFileCountLabel, -} from "@features/git-interaction/utils/diffStats"; import { CheckCircle, CloudArrowUp, @@ -12,6 +7,10 @@ import { GitFork, Sparkle, } from "@phosphor-icons/react"; +import { + type DiffStats, + formatFileCountLabel, +} from "@posthog/core/git-interaction/diffStats"; import { CheckIcon } from "@radix-ui/react-icons"; import { Box, @@ -27,6 +26,7 @@ import { } from "@radix-ui/themes"; import type { ReactNode } from "react"; import { useState } from "react"; +import { Tooltip } from "../../../primitives/Tooltip"; const ICON_SIZE = 14; diff --git a/apps/code/src/renderer/features/git-interaction/components/PRBadgeLink.tsx b/packages/ui/src/features/git-interaction/components/PRBadgeLink.tsx similarity index 91% rename from apps/code/src/renderer/features/git-interaction/components/PRBadgeLink.tsx rename to packages/ui/src/features/git-interaction/components/PRBadgeLink.tsx index 6a0a3e78b2..9893e21a67 100644 --- a/apps/code/src/renderer/features/git-interaction/components/PRBadgeLink.tsx +++ b/packages/ui/src/features/git-interaction/components/PRBadgeLink.tsx @@ -2,8 +2,9 @@ import { getPrVisualConfig, type PrVisualConfig, parsePrNumber, -} from "@features/git-interaction/utils/prStatus"; +} from "@posthog/core/git-interaction/prStatus"; import { Button, Flex, Spinner, Text } from "@radix-ui/themes"; +import { getPrVisualIcon } from "../prIcon"; interface PRBadgeLinkProps { prUrl: string; @@ -47,6 +48,7 @@ export function PRBadgeLink({ compact = false, }: PRBadgeLinkProps) { const config = getPrVisualConfig(prState, merged, draft); + const PrIcon = getPrVisualIcon(config.icon); const prNumber = parsePrNumber(prUrl); if (compact) { @@ -61,7 +63,7 @@ export function PRBadgeLink({ {isPrPending ? ( <Spinner size="1" /> ) : ( - <config.Icon size={10} weight="bold" /> + <PrIcon size={10} weight="bold" /> )} <span> {config.label} @@ -89,7 +91,7 @@ export function PRBadgeLink({ {isPrPending ? ( <Spinner size="1" /> ) : ( - <config.Icon size={12} weight="bold" /> + <PrIcon size={12} weight="bold" /> )} <Text size="1"> {config.label} diff --git a/apps/code/src/renderer/features/git-interaction/components/TaskActionsMenu.tsx b/packages/ui/src/features/git-interaction/components/TaskActionsMenu.tsx similarity index 93% rename from apps/code/src/renderer/features/git-interaction/components/TaskActionsMenu.tsx rename to packages/ui/src/features/git-interaction/components/TaskActionsMenu.tsx index e418528ac4..00c5938e58 100644 --- a/apps/code/src/renderer/features/git-interaction/components/TaskActionsMenu.tsx +++ b/packages/ui/src/features/git-interaction/components/TaskActionsMenu.tsx @@ -1,25 +1,3 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import { CreatePrDialog } from "@features/git-interaction/components/CreatePrDialog"; -import { - GitBranchDialog, - GitCommitDialog, - GitPushDialog, -} from "@features/git-interaction/components/GitInteractionDialogs"; -import { PRBadgeLink } from "@features/git-interaction/components/PRBadgeLink"; -import { - type GitMenuAction, - type GitMenuActionId, - useGitInteraction, -} from "@features/git-interaction/hooks/useGitInteraction"; -import { usePrActions } from "@features/git-interaction/hooks/usePrActions"; -import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails"; -import { useTaskPrUrl } from "@features/git-interaction/hooks/useTaskPrUrl"; -import { - getPrActionIcon, - getPrVisualConfig, -} from "@features/git-interaction/utils/prStatus"; -import { useLocalRepoPath } from "@features/workspace/hooks/useLocalRepoPath"; -import type { PrActionType } from "@main/services/git/schemas"; import { ArrowsClockwise, CloudArrowUp, @@ -29,6 +7,7 @@ import { GitFork, GitPullRequest, } from "@phosphor-icons/react"; +import { getPrVisualConfig } from "@posthog/core/git-interaction/prStatus"; import { ButtonGroup, DropdownMenuContent, @@ -37,9 +16,28 @@ import { DropdownMenu as QDropdownMenu, DropdownMenuItem as QDropdownMenuItem, } from "@posthog/quill"; +import type { PrActionType } from "@posthog/shared"; import { ChevronDownIcon } from "@radix-ui/react-icons"; import { Button, DropdownMenu, Flex, Spinner, Text } from "@radix-ui/themes"; import { ChevronDown } from "lucide-react"; +import { Tooltip } from "../../../primitives/Tooltip"; +import { useLocalRepoPath } from "../../workspace/useLocalRepoPath"; +import { getPrActionIcon } from "../prIcon"; +import { + type GitMenuAction, + type GitMenuActionId, + useGitInteraction, +} from "../useGitInteraction"; +import { usePrActions } from "../usePrActions"; +import { usePrDetails } from "../usePrDetails"; +import { useTaskPrUrl } from "../useTaskPrUrl"; +import { CreatePrDialog } from "./CreatePrDialog"; +import { + GitBranchDialog, + GitCommitDialog, + GitPushDialog, +} from "./GitInteractionDialogs"; +import { PRBadgeLink } from "./PRBadgeLink"; interface TaskActionsMenuProps { taskId: string; diff --git a/packages/ui/src/features/git-interaction/gitCacheKeys.ts b/packages/ui/src/features/git-interaction/gitCacheKeys.ts new file mode 100644 index 0000000000..9d1d04db25 --- /dev/null +++ b/packages/ui/src/features/git-interaction/gitCacheKeys.ts @@ -0,0 +1,75 @@ +import { resolveService } from "@posthog/di/container"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "../../workbench/queryClient"; +import { + GIT_CACHE_KEY_PROVIDER, + type GitCacheKeyProvider, +} from "./gitCacheProvider"; + +export function invalidateGitWorkingTreeQueries(repoPath: string) { + const queryClient = resolveService<ImperativeQueryClient>( + IMPERATIVE_QUERY_CLIENT, + ); + const provider = resolveService<GitCacheKeyProvider>(GIT_CACHE_KEY_PROVIDER); + const input = { directoryPath: repoPath }; + queryClient.invalidateQueries( + provider.gitQueryFilter("getChangedFilesHead", input), + ); + queryClient.invalidateQueries(provider.gitQueryFilter("getDiffStats", input)); + queryClient.invalidateQueries(provider.gitPathFilter("getDiffCached")); + queryClient.invalidateQueries(provider.gitPathFilter("getDiffUnstaged")); +} + +export function invalidateGitBranchQueries(repoPath: string) { + const queryClient = resolveService<ImperativeQueryClient>( + IMPERATIVE_QUERY_CLIENT, + ); + const provider = resolveService<GitCacheKeyProvider>(GIT_CACHE_KEY_PROVIDER); + const input = { directoryPath: repoPath }; + queryClient.invalidateQueries( + provider.gitQueryFilter("getCurrentBranch", input), + ); + queryClient.invalidateQueries( + provider.gitQueryFilter("getAllBranches", input), + ); + queryClient.invalidateQueries( + provider.gitQueryFilter("getGitBusyState", input), + ); + queryClient.invalidateQueries( + provider.gitQueryFilter("getGitSyncStatus", input), + ); + queryClient.invalidateQueries( + provider.gitQueryFilter("getChangedFilesHead", input), + ); + queryClient.invalidateQueries(provider.gitQueryFilter("getDiffStats", input)); + queryClient.invalidateQueries( + provider.gitQueryFilter("getLatestCommit", input), + ); + queryClient.invalidateQueries(provider.gitQueryFilter("getPrStatus", input)); + queryClient.invalidateQueries(provider.gitPathFilter("getFileAtHead")); + queryClient.invalidateQueries( + provider.gitPathFilter("getLocalBranchChangedFiles"), + ); +} + +export function clearGitReviewQueries() { + const queryClient = resolveService<ImperativeQueryClient>( + IMPERATIVE_QUERY_CLIENT, + ); + const provider = resolveService<GitCacheKeyProvider>(GIT_CACHE_KEY_PROVIDER); + queryClient.removeQueries(provider.gitPathFilter("getDiffCached")); + queryClient.removeQueries(provider.gitPathFilter("getDiffUnstaged")); + queryClient.removeQueries(provider.gitPathFilter("getFileAtHead")); + queryClient.removeQueries(provider.fsPathFilter("readRepoFile")); + queryClient.removeQueries(provider.fsPathFilter("readRepoFiles")); + queryClient.removeQueries(provider.fsPathFilter("readRepoFileBounded")); + queryClient.removeQueries(provider.fsPathFilter("readRepoFilesBounded")); + queryClient.removeQueries( + provider.gitPathFilter("getLocalBranchChangedFiles"), + ); + queryClient.removeQueries(provider.gitPathFilter("getPrChangedFiles")); + queryClient.removeQueries(provider.gitPathFilter("getPrDetailsByUrl")); + queryClient.removeQueries(provider.gitPathFilter("getPrReviewComments")); +} diff --git a/packages/ui/src/features/git-interaction/gitCacheProvider.ts b/packages/ui/src/features/git-interaction/gitCacheProvider.ts new file mode 100644 index 0000000000..c423bf082a --- /dev/null +++ b/packages/ui/src/features/git-interaction/gitCacheProvider.ts @@ -0,0 +1,21 @@ +import type { QueryFilters } from "@tanstack/react-query"; + +export interface GitCacheKeyProvider { + /** `trpc.git.<proc>.queryFilter(input)` */ + gitQueryFilter(proc: string, input: Record<string, unknown>): QueryFilters; + /** `trpc.git.<proc>.pathFilter()` */ + gitPathFilter(proc: string): QueryFilters; + /** `trpc.fs.<proc>.pathFilter()` */ + fsPathFilter(proc: string): QueryFilters; + /** `trpc.git.<proc>.queryKey(input)` */ + gitQueryKey( + proc: string, + input?: Record<string, unknown>, + ): readonly unknown[]; + /** `trpc.fs.<proc>.queryKey(input)` */ + fsQueryKey(proc: string, input?: Record<string, unknown>): readonly unknown[]; +} + +export const GIT_CACHE_KEY_PROVIDER = Symbol.for( + "posthog.ui.GitCacheKeyProvider", +); diff --git a/packages/ui/src/features/git-interaction/gitInteractionAdapter.ts b/packages/ui/src/features/git-interaction/gitInteractionAdapter.ts new file mode 100644 index 0000000000..fe68e74e56 --- /dev/null +++ b/packages/ui/src/features/git-interaction/gitInteractionAdapter.ts @@ -0,0 +1,96 @@ +import type { + GitInteractionEffects, + IGitWriteClient, +} from "@posthog/core/git-interaction/gitInteractionService"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { useSessionStore } from "@posthog/ui/features/sessions/sessionStore"; +import { celebrate } from "@posthog/ui/primitives/confetti"; +import { track } from "@posthog/ui/workbench/analytics"; +import { logger } from "@posthog/ui/workbench/logger"; +import { openExternalUrl } from "@posthog/ui/workbench/openExternal"; + +const log = logger.scope("git-interaction"); + +function host(): HostTrpcClient { + return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT); +} + +export const gitWriteClient: IGitWriteClient = { + commit: (input) => host().git.commit.mutate(input), + push: (directoryPath, signal) => + host().git.push.mutate({ directoryPath }, { signal }), + sync: (directoryPath, signal) => + host().git.sync.mutate({ directoryPath }, { signal }), + publish: (directoryPath, signal) => + host().git.publish.mutate({ directoryPath }, { signal }), + createBranch: async (directoryPath, branchName) => { + await host().git.createBranch.mutate({ directoryPath, branchName }); + }, + createPr: (input) => host().git.createPr.mutate(input), + openPr: (directoryPath) => host().git.openPr.mutate({ directoryPath }), + generateCommitMessage: (input) => + host().git.generateCommitMessage.mutate(input), + generatePrTitleAndBody: (input) => + host().git.generatePrTitleAndBody.mutate(input), + linkBranch: async (taskId, branchName) => { + await host().workspace.linkBranch.mutate({ taskId, branchName }); + }, + onCreatePrProgress: (flowId, onStep) => { + const subscription = host().git.onCreatePrProgress.subscribe(undefined, { + onData: (data) => { + if (data.flowId !== flowId) return; + onStep(data.step); + }, + }); + return () => subscription.unsubscribe(); + }, +}; + +function getConversationContext(taskId: string): string | undefined { + const state = useSessionStore.getState(); + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return undefined; + return state.sessions[taskRunId]?.conversationSummary; +} + +function attachPrUrlToTask(taskId: string, prUrl: string): void { + const taskRunId = useSessionStore.getState().taskIdIndex[taskId]; + if (!taskRunId) return; + void getAuthenticatedClient().then((client) => { + if (!client) return; + client + .updateTaskRun(taskId, taskRunId, { output: { pr_url: prUrl } }) + .catch((err) => + log.warn("Failed to attach PR URL to task", { taskId, prUrl, err }), + ); + }); +} + +export const gitInteractionEffects: GitInteractionEffects = { + trackGitAction: (taskId, actionType, success, stagingContext) => { + track(ANALYTICS_EVENTS.GIT_ACTION_EXECUTED, { + action_type: actionType, + success, + task_id: taskId, + ...stagingContext, + }); + }, + trackPrCreated: (taskId, success) => { + track(ANALYTICS_EVENTS.PR_CREATED, { task_id: taskId, success }); + }, + hasShippedFirstPr: () => useOnboardingStore.getState().hasShippedFirstPr, + markFirstPrShipped: () => useOnboardingStore.getState().markFirstPrShipped(), + celebrate: () => celebrate(), + openExternalUrl: (url) => openExternalUrl(url), + attachPrUrlToTask, + getConversationContext, + logError: (message, error) => log.error(message, error), + logWarn: (message, context) => log.warn(message, context), +}; diff --git a/packages/ui/src/features/git-interaction/prIcon.tsx b/packages/ui/src/features/git-interaction/prIcon.tsx new file mode 100644 index 0000000000..1e18cc4311 --- /dev/null +++ b/packages/ui/src/features/git-interaction/prIcon.tsx @@ -0,0 +1,32 @@ +import { + Check, + GitMerge, + GitPullRequest, + type Icon, + PencilSimple, + X, +} from "@phosphor-icons/react"; +import type { PrVisualIcon } from "@posthog/core/git-interaction/prStatus"; +import type { PrActionType } from "@posthog/shared"; + +export function getPrVisualIcon(icon: PrVisualIcon): Icon { + switch (icon) { + case "merged": + return GitMerge; + case "pull-request": + return GitPullRequest; + } +} + +export function getPrActionIcon(action: PrActionType): React.ReactNode { + switch (action) { + case "close": + return <X size={12} weight="bold" />; + case "reopen": + return <GitPullRequest size={12} weight="bold" />; + case "ready": + return <Check size={12} weight="bold" />; + case "draft": + return <PencilSimple size={12} weight="bold" />; + } +} diff --git a/apps/code/src/renderer/features/git-interaction/state/gitInteractionStore.test.ts b/packages/ui/src/features/git-interaction/state/gitInteractionStore.test.ts similarity index 99% rename from apps/code/src/renderer/features/git-interaction/state/gitInteractionStore.test.ts rename to packages/ui/src/features/git-interaction/state/gitInteractionStore.test.ts index 7b31e6a5d0..6f36631fa1 100644 --- a/apps/code/src/renderer/features/git-interaction/state/gitInteractionStore.test.ts +++ b/packages/ui/src/features/git-interaction/state/gitInteractionStore.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@utils/electronStorage", () => ({ +vi.mock("@posthog/ui/workbench/rendererStorage", () => ({ electronStorage: { getItem: () => null, setItem: () => {}, diff --git a/apps/code/src/renderer/features/git-interaction/state/gitInteractionStore.ts b/packages/ui/src/features/git-interaction/state/gitInteractionStore.ts similarity index 98% rename from apps/code/src/renderer/features/git-interaction/state/gitInteractionStore.ts rename to packages/ui/src/features/git-interaction/state/gitInteractionStore.ts index cc70d77c77..5a08bef77a 100644 --- a/apps/code/src/renderer/features/git-interaction/state/gitInteractionStore.ts +++ b/packages/ui/src/features/git-interaction/state/gitInteractionStore.ts @@ -1,13 +1,13 @@ +import { electronStorage } from "@posthog/ui/workbench/rendererStorage"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; import type { CommitNextStep, CreatePrStep, GitMenuActionId, PushMode, PushState, -} from "@features/git-interaction/types"; -import { electronStorage } from "@utils/electronStorage"; -import { create } from "zustand"; -import { persist } from "zustand/middleware"; +} from "../types"; export type { CommitNextStep, PushMode, PushState }; diff --git a/packages/ui/src/features/git-interaction/types.ts b/packages/ui/src/features/git-interaction/types.ts new file mode 100644 index 0000000000..292f76adf7 --- /dev/null +++ b/packages/ui/src/features/git-interaction/types.ts @@ -0,0 +1,28 @@ +export type GitMenuActionId = + | "commit" + | "push" + | "sync" + | "publish" + | "create-pr" + | "view-pr" + | "branch-here"; + +export interface GitMenuAction { + id: GitMenuActionId; + label: string; + enabled: boolean; + disabledReason: string | null; +} + +export type CommitNextStep = "commit" | "commit-push"; +export type PushMode = "push" | "sync" | "publish"; +export type PushState = "idle" | "success" | "error"; + +export type CreatePrStep = + | "idle" + | "creating-branch" + | "committing" + | "pushing" + | "creating-pr" + | "complete" + | "error"; diff --git a/packages/ui/src/features/git-interaction/useCloudPrUrl.ts b/packages/ui/src/features/git-interaction/useCloudPrUrl.ts new file mode 100644 index 0000000000..8bf705cf7b --- /dev/null +++ b/packages/ui/src/features/git-interaction/useCloudPrUrl.ts @@ -0,0 +1,13 @@ +import { useSessionForTask } from "../sessions/useSession"; +import { useTasks } from "../tasks/useTasks"; +import { resolveCloudPrUrl } from "./cloudPrUrl"; + +export { resolveCloudPrUrl }; + +/** Hook wrapper for components that don't already have the task/session. */ +export function useCloudPrUrl(taskId: string): string | null { + const { data: tasks = [] } = useTasks(); + const task = tasks.find((t) => t.id === taskId); + const session = useSessionForTask(taskId); + return resolveCloudPrUrl(task, session); +} diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useFixWithAgent.ts b/packages/ui/src/features/git-interaction/useFixWithAgent.ts similarity index 79% rename from apps/code/src/renderer/features/git-interaction/hooks/useFixWithAgent.ts rename to packages/ui/src/features/git-interaction/useFixWithAgent.ts index a9e1939214..83bd6b4cfa 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useFixWithAgent.ts +++ b/packages/ui/src/features/git-interaction/useFixWithAgent.ts @@ -1,8 +1,8 @@ -import { useSessionForTask } from "@features/sessions/stores/sessionStore"; -import { sendPromptToAgent } from "@features/sessions/utils/sendPromptToAgent"; -import { useNavigationStore } from "@stores/navigationStore"; +import type { FixWithAgentPrompt } from "@posthog/core/git-interaction/errorPrompts"; import { useCallback } from "react"; -import type { FixWithAgentPrompt } from "../utils/errorPrompts"; +import { useNavigationStore } from "../navigation/store"; +import { sendPromptToAgent } from "../sessions/sendPromptToAgent"; +import { useSessionForTask } from "../sessions/useSession"; /** * Hook that sends a structured error prompt to the active agent session. diff --git a/packages/ui/src/features/git-interaction/useGitInteraction.ts b/packages/ui/src/features/git-interaction/useGitInteraction.ts new file mode 100644 index 0000000000..4bae9fb753 --- /dev/null +++ b/packages/ui/src/features/git-interaction/useGitInteraction.ts @@ -0,0 +1,477 @@ +import { sanitizeBranchName } from "@posthog/core/git-interaction/branchName"; +import type { DiffStats } from "@posthog/core/git-interaction/diffStats"; +import { partitionByStaged } from "@posthog/core/git-interaction/diffStats"; +import { computeGitInteractionState } from "@posthog/core/git-interaction/gitInteractionLogic"; +import type { GitInteractionService } from "@posthog/core/git-interaction/gitInteractionService"; +import { GIT_INTERACTION_SERVICE } from "@posthog/core/git-interaction/identifiers"; +import { + deriveCreatePrPlan, + deriveStagingPlan, +} from "@posthog/core/git-interaction/stagingPlan"; +import { useService } from "@posthog/di/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { ChangedFile } from "@posthog/shared/domain-types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useMemo, useRef } from "react"; +import { WORKSPACE_QUERY_KEY } from "../workspace/identifiers"; +import { invalidateGitBranchQueries } from "./gitCacheKeys"; +import { + GIT_CACHE_KEY_PROVIDER, + type GitCacheKeyProvider, +} from "./gitCacheProvider"; +import { + type GitInteractionStore, + useGitInteractionStore, +} from "./state/gitInteractionStore"; +import type { + CommitNextStep, + GitMenuAction, + GitMenuActionId, + PushMode, +} from "./types"; +import { useGitQueries } from "./useGitQueries"; +import { getBranchNameInputState } from "./utils/branchCreation"; +import { getSuggestedBranchName } from "./utils/getSuggestedBranchName"; +import { updateGitCacheFromSnapshot } from "./utils/updateGitCache"; + +export type { GitMenuAction, GitMenuActionId }; + +interface GitInteractionState { + primaryAction: GitMenuAction; + actions: GitMenuAction[]; + hasChanges: boolean; + aheadOfRemote: number; + behind: number; + currentBranch: string | null; + defaultBranch: string | null; + isFeatureBranch: boolean; + prBaseBranch: string | null; + prHeadBranch: string | null; + diffStats: DiffStats; + prUrl: string | null; + pushDisabledReason: string | null; + isLoading: boolean; + stagedFiles: ChangedFile[]; + unstagedFiles: ChangedFile[]; +} + +interface GitInteractionActions { + openAction: (actionId: GitMenuActionId) => void; + closeCommit: () => void; + closePush: () => void; + closeBranch: () => void; + setCommitMessage: (value: string) => void; + setCommitNextStep: (value: CommitNextStep) => void; + setCommitAll: (value: boolean) => void; + setPrTitle: (value: string) => void; + setPrBody: (value: string) => void; + setBranchName: (value: string) => void; + runCommit: () => Promise<boolean>; + runPush: (mode?: PushMode) => Promise<void>; + runBranch: () => Promise<boolean>; + runCreatePr: () => Promise<void>; + generateCommitMessage: () => Promise<void>; + generatePrTitleAndBody: () => Promise<void>; + closeCreatePr: () => void; + setCreatePrBranchName: (value: string) => void; + setCreatePrDraft: (value: boolean) => void; +} + +export function useGitInteraction( + taskId: string, + repoPath?: string, +): { + state: GitInteractionState; + modals: GitInteractionStore; + actions: GitInteractionActions; +} { + const queryClient = useQueryClient(); + const cacheKeyProvider = useService<GitCacheKeyProvider>( + GIT_CACHE_KEY_PROVIDER, + ); + const service = useService<GitInteractionService>(GIT_INTERACTION_SERVICE); + const trpc = useHostTRPC(); + const store = useGitInteractionStore(); + const { actions: modal } = store; + const pushAbortRef = useRef<AbortController | null>(null); + + const git = useGitQueries(repoPath); + + const computed = useMemo( + () => + computeGitInteractionState({ + repoPath, + isRepo: git.isRepo, + isRepoLoading: git.isRepoLoading, + hasChanges: git.hasChanges, + aheadOfRemote: git.aheadOfRemote, + behind: git.behind, + aheadOfDefault: git.aheadOfDefault, + hasRemote: git.hasRemote, + isFeatureBranch: git.isFeatureBranch, + currentBranch: git.currentBranch, + defaultBranch: git.defaultBranch, + ghStatus: git.ghStatus ?? null, + repoInfo: git.repoInfo ?? null, + prStatus: git.prStatus ?? null, + }), + [ + repoPath, + git.isRepo, + git.isRepoLoading, + git.hasChanges, + git.aheadOfRemote, + git.behind, + git.aheadOfDefault, + git.hasRemote, + git.isFeatureBranch, + git.currentBranch, + git.defaultBranch, + git.ghStatus, + git.repoInfo, + git.prStatus, + ], + ); + + const { stagedFiles, unstagedFiles } = useMemo( + () => partitionByStaged(git.changedFiles), + [git.changedFiles], + ); + + const { stagingContext, stagedOnly } = deriveStagingPlan( + stagedFiles, + unstagedFiles, + store.commitAll, + ); + + const createPrDraftKey = `${taskId}:${repoPath ?? ""}`; + + const openCreatePr = () => { + const plan = deriveCreatePrPlan({ + isFeatureBranch: git.isFeatureBranch, + prExists: git.prStatus?.prExists ?? false, + hasChanges: git.hasChanges, + stagedFileCount: stagedFiles.length, + unstagedFileCount: unstagedFiles.length, + }); + modal.setCommitAll(plan.commitAll); + modal.openCreatePr({ + needsBranch: plan.needsBranch, + needsCommit: plan.needsCommit, + baseBranch: git.currentBranch, + suggestedBranchName: plan.needsBranch + ? getSuggestedBranchName( + queryClient, + cacheKeyProvider, + taskId, + repoPath, + ) + : undefined, + draftKey: createPrDraftKey, + }); + }; + + const runCreatePr = async () => { + if (!repoPath) return; + + if (store.createPrNeedsBranch && !store.branchName.trim()) { + modal.setCreatePrError("Branch name is required."); + return; + } + + modal.setIsSubmitting(true); + modal.setCreatePrError(null); + modal.setCreatePrStep("idle"); + modal.setCreatePrFailedStep(null); + + const flowId = crypto.randomUUID(); + + try { + const result = await service.runCreatePr({ + repoPath, + taskId, + flowId, + needsBranch: store.createPrNeedsBranch, + branchName: store.branchName, + currentBranch: git.currentBranch, + commitMessage: store.commitMessage, + prTitle: store.prTitle, + prBody: store.prBody, + draft: store.createPrDraft, + stagedOnly, + stagingContext, + onStep: (step) => { + if (useGitInteractionStore.getState().createPrStep === step) return; + modal.setCreatePrStep(step); + }, + }); + + if (result.outcome === "error") { + useGitInteractionStore.setState({ + createPrError: result.message, + createPrFailedStep: result.failedStep, + createPrStep: "error", + }); + return; + } + + if (result.snapshot) { + updateGitCacheFromSnapshot(queryClient, repoPath, result.snapshot); + } + if (result.branchInvalidated) { + invalidateGitBranchQueries(repoPath); + } + if (result.prUrl && result.linkedBranchName) { + queryClient.setQueryData( + trpc.git.getPrUrlForBranch.queryKey({ + directoryPath: repoPath, + branchName: result.linkedBranchName, + }), + result.prUrl, + ); + } + + modal.clearCreatePrDraft(createPrDraftKey); + modal.closeCreatePr(); + } finally { + modal.setIsSubmitting(false); + } + }; + + const viewPr = async () => { + if (!repoPath) return; + await service.viewPr(repoPath); + }; + + const openAction = (id: GitMenuActionId) => { + const actionMap: Record<GitMenuActionId, () => void> = { + commit: () => { + modal.setCommitAll( + !(stagedFiles.length > 0 && unstagedFiles.length > 0), + ); + modal.openCommit("commit"); + }, + push: () => modal.openPush("push"), + sync: () => modal.openPush("sync"), + publish: () => modal.openPush("publish"), + "view-pr": () => viewPr(), + "create-pr": () => openCreatePr(), + "branch-here": () => + modal.openBranch( + getSuggestedBranchName( + queryClient, + cacheKeyProvider, + taskId, + repoPath, + ), + ), + }; + actionMap[id](); + }; + + const runCommit = async (): Promise<boolean> => { + if (!repoPath) return false; + + modal.setIsSubmitting(true); + modal.setCommitError(null); + + try { + const result = await service.runCommit({ + repoPath, + taskId, + message: store.commitMessage.trim(), + stagedOnly, + stagingContext, + hasRemote: git.hasRemote, + pushDisabledReason: computed.pushDisabledReason, + commitPush: store.commitNextStep === "commit-push", + }); + + if (result.outcome !== "committed") { + modal.setCommitError(result.message); + return false; + } + + if (result.generatedMessage) { + modal.setCommitMessage(result.generatedMessage); + } + if (result.snapshot) { + updateGitCacheFromSnapshot(queryClient, repoPath, result.snapshot); + } + + modal.setCommitMessage(""); + modal.closeCommit(); + + if (result.next) { + modal.openPush(result.next.mode); + applyPushResult(result.next.result); + } + return true; + } finally { + modal.setIsSubmitting(false); + } + }; + + const applyPushResult = ( + result: Awaited<ReturnType<GitInteractionService["runPush"]>>, + ) => { + if (!repoPath) return; + if (result.outcome === "aborted") return; + if (result.outcome === "error") { + modal.setPushError(result.message); + modal.setPushState("error"); + return; + } + if (result.snapshot) { + updateGitCacheFromSnapshot(queryClient, repoPath, result.snapshot); + } + modal.setPushState("success"); + }; + + const runPush = async (mode?: PushMode) => { + if (!repoPath) return; + + const pushMode = mode ?? useGitInteractionStore.getState().pushMode; + + pushAbortRef.current?.abort(); + const controller = new AbortController(); + pushAbortRef.current = controller; + + modal.setIsSubmitting(true); + modal.setPushError(null); + + try { + const result = await service.runPush({ + repoPath, + taskId, + mode: pushMode, + signal: controller.signal, + }); + applyPushResult(result); + } finally { + if (pushAbortRef.current === controller) { + pushAbortRef.current = null; + } + modal.setIsSubmitting(false); + } + }; + + const closePush = () => { + pushAbortRef.current?.abort(); + pushAbortRef.current = null; + modal.closePush(); + }; + + const generateCommitMessage = async () => { + if (!repoPath) return; + + modal.setIsGeneratingCommitMessage(true); + modal.setCommitError(null); + + try { + const result = await service.generateCommitMessage(repoPath, taskId); + if ("message" in result) { + modal.setCommitMessage(result.message); + } else { + modal.setCommitError(result.error); + } + } finally { + modal.setIsGeneratingCommitMessage(false); + } + }; + + const generatePrTitleAndBody = async () => { + if (!repoPath) return; + + modal.setIsGeneratingPr(true); + modal.setCreatePrError(null); + + try { + const result = await service.generatePrTitleAndBody(repoPath, taskId); + if ("error" in result) { + modal.setCreatePrError(result.error); + } else { + modal.setPrTitle(result.title); + modal.setPrBody(result.body); + } + } finally { + modal.setIsGeneratingPr(false); + } + }; + + const runBranch = async (): Promise<boolean> => { + if (!repoPath) return false; + + modal.setIsSubmitting(true); + modal.setBranchError(null); + + try { + const result = await service.runBranch({ + repoPath, + taskId, + rawBranchName: store.branchName, + }); + + if (result.outcome === "error") { + modal.setBranchError(result.message); + return false; + } + + await queryClient.invalidateQueries({ queryKey: WORKSPACE_QUERY_KEY }); + modal.closeBranch(); + return true; + } finally { + modal.setIsSubmitting(false); + } + }; + + return { + state: { + primaryAction: computed.primaryAction, + actions: computed.actions, + hasChanges: git.hasChanges, + aheadOfRemote: git.aheadOfRemote, + behind: git.behind, + currentBranch: git.currentBranch, + defaultBranch: git.defaultBranch, + isFeatureBranch: git.isFeatureBranch, + prBaseBranch: computed.prBaseBranch, + prHeadBranch: computed.prHeadBranch, + diffStats: git.diffStats, + prUrl: computed.prUrl, + pushDisabledReason: computed.pushDisabledReason, + isLoading: git.isLoading, + stagedFiles, + unstagedFiles, + }, + modals: store, + actions: { + openAction, + closeCommit: modal.closeCommit, + closePush, + closeBranch: modal.closeBranch, + setCommitMessage: modal.setCommitMessage, + setCommitNextStep: modal.setCommitNextStep, + setCommitAll: modal.setCommitAll, + setPrTitle: modal.setPrTitle, + setPrBody: modal.setPrBody, + setBranchName: (value: string) => { + const { sanitized, error } = getBranchNameInputState(value); + modal.setBranchName(sanitized); + modal.setBranchError(error); + }, + runCommit, + runPush, + runBranch, + runCreatePr, + generateCommitMessage, + generatePrTitleAndBody, + closeCreatePr: modal.closeCreatePr, + setCreatePrBranchName: (value: string) => { + const sanitized = sanitizeBranchName(value); + modal.setBranchName(sanitized); + }, + setCreatePrDraft: modal.setCreatePrDraft, + }, + }; +} diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useGitQueries.ts b/packages/ui/src/features/git-interaction/useGitQueries.ts similarity index 58% rename from apps/code/src/renderer/features/git-interaction/hooks/useGitQueries.ts rename to packages/ui/src/features/git-interaction/useGitQueries.ts index 7675687401..d686ef2080 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useGitQueries.ts +++ b/packages/ui/src/features/git-interaction/useGitQueries.ts @@ -1,4 +1,4 @@ -import { useTRPC } from "@renderer/trpc"; +import { useHostTRPC } from "@posthog/host-router/react"; import { useQuery } from "@tanstack/react-query"; const EMPTY_DIFF_STATS = { filesChanged: 0, linesAdded: 0, linesRemoved: 0 }; @@ -16,14 +16,15 @@ export function useGitQueries( repoPath?: string, options?: UseGitQueriesOptions, ) { - const trpc = useTRPC(); + const trpc = useHostTRPC(); const enabled = !!repoPath && (options?.enabled ?? true); + const input = { directoryPath: repoPath as string }; const { data: isRepo = false, isLoading: isRepoLoading } = useQuery( - trpc.git.validateRepo.queryOptions( - { directoryPath: repoPath as string }, - { enabled, ...GIT_QUERY_DEFAULTS }, - ), + trpc.git.validateRepo.queryOptions(input, { + enabled, + ...GIT_QUERY_DEFAULTS, + }), ); const repoEnabled = enabled && isRepo; @@ -32,71 +33,53 @@ export function useGitQueries( data: changedFiles = EMPTY_CHANGED_FILES, isLoading: changesLoading, } = useQuery( - trpc.git.getChangedFilesHead.queryOptions( - { directoryPath: repoPath as string }, - { - enabled: repoEnabled, - ...GIT_QUERY_DEFAULTS, - refetchOnMount: "always", - placeholderData: (prev) => prev, - }, - ), + trpc.git.getChangedFilesHead.queryOptions(input, { + enabled: repoEnabled, + ...GIT_QUERY_DEFAULTS, + refetchOnMount: "always", + placeholderData: (prev) => prev, + }), ); const { data: diffStats = EMPTY_DIFF_STATS } = useQuery( - trpc.git.getDiffStats.queryOptions( - { directoryPath: repoPath as string }, - { - enabled: repoEnabled, - ...GIT_QUERY_DEFAULTS, - placeholderData: (prev) => prev ?? EMPTY_DIFF_STATS, - }, - ), + trpc.git.getDiffStats.queryOptions(input, { + enabled: repoEnabled, + ...GIT_QUERY_DEFAULTS, + placeholderData: (prev) => prev ?? EMPTY_DIFF_STATS, + }), ); const { data: currentBranchData, isLoading: branchLoading } = useQuery( - trpc.git.getCurrentBranch.queryOptions( - { directoryPath: repoPath as string }, - { - enabled: repoEnabled, - staleTime: 10_000, - placeholderData: (prev) => prev, - }, - ), + trpc.git.getCurrentBranch.queryOptions(input, { + enabled: repoEnabled, + staleTime: 10_000, + placeholderData: (prev) => prev, + }), ); const { data: busyState } = useQuery( - trpc.git.getGitBusyState.queryOptions( - { directoryPath: repoPath as string }, - { - enabled: repoEnabled, - staleTime: 5_000, - refetchInterval: 30_000, - placeholderData: (prev) => prev, - }, - ), + trpc.git.getGitBusyState.queryOptions(input, { + enabled: repoEnabled, + staleTime: 5_000, + refetchInterval: 30_000, + placeholderData: (prev) => prev, + }), ); const { data: syncStatus, isLoading: syncLoading } = useQuery( - trpc.git.getGitSyncStatus.queryOptions( - { directoryPath: repoPath as string }, - { - enabled: repoEnabled, - ...GIT_QUERY_DEFAULTS, - refetchInterval: 60_000, - }, - ), + trpc.git.getGitSyncStatus.queryOptions(input, { + enabled: repoEnabled, + ...GIT_QUERY_DEFAULTS, + refetchInterval: 60_000, + }), ); const { data: repoInfo } = useQuery( - trpc.git.getGitRepoInfo.queryOptions( - { directoryPath: repoPath as string }, - { - enabled: repoEnabled, - ...GIT_QUERY_DEFAULTS, - staleTime: 60_000, - }, - ), + trpc.git.getGitRepoInfo.queryOptions(input, { + enabled: repoEnabled, + ...GIT_QUERY_DEFAULTS, + staleTime: 60_000, + }), ); const { data: ghStatus } = useQuery( @@ -110,34 +93,25 @@ export function useGitQueries( const currentBranch = currentBranchData ?? syncStatus?.currentBranch ?? null; const { data: prStatus } = useQuery( - trpc.git.getPrStatus.queryOptions( - { directoryPath: repoPath as string }, - { - enabled: repoEnabled && !!ghStatus?.installed && !!currentBranch, - ...GIT_QUERY_DEFAULTS, - }, - ), + trpc.git.getPrStatus.queryOptions(input, { + enabled: repoEnabled && !!ghStatus?.installed && !!currentBranch, + ...GIT_QUERY_DEFAULTS, + }), ); const { data: latestCommit } = useQuery( - trpc.git.getLatestCommit.queryOptions( - { directoryPath: repoPath as string }, - { - enabled: repoEnabled, - ...GIT_QUERY_DEFAULTS, - }, - ), + trpc.git.getLatestCommit.queryOptions(input, { + enabled: repoEnabled, + ...GIT_QUERY_DEFAULTS, + }), ); useQuery( - trpc.git.getAllBranches.queryOptions( - { directoryPath: repoPath as string }, - { - enabled: repoEnabled, - ...GIT_QUERY_DEFAULTS, - staleTime: 60_000, - }, - ), + trpc.git.getAllBranches.queryOptions(input, { + enabled: repoEnabled, + ...GIT_QUERY_DEFAULTS, + staleTime: 60_000, + }), ); const hasChanges = changedFiles.length > 0; @@ -175,7 +149,7 @@ export function useGitQueries( } export function usePrChangedFiles(prUrl: string | null, pollFast?: boolean) { - const trpc = useTRPC(); + const trpc = useHostTRPC(); return useQuery( trpc.git.getPrChangedFiles.queryOptions( { prUrl: prUrl as string }, @@ -194,7 +168,7 @@ export function useBranchChangedFiles( branch: string | null, pollFast?: boolean, ) { - const trpc = useTRPC(); + const trpc = useHostTRPC(); return useQuery( trpc.git.getBranchChangedFiles.queryOptions( { repo: repo as string, branch: branch as string }, @@ -212,13 +186,10 @@ export function useLocalBranchChangedFiles( directoryPath: string | null, branch: string | null, ) { - const trpc = useTRPC(); + const trpc = useHostTRPC(); return useQuery( trpc.git.getLocalBranchChangedFiles.queryOptions( - { - directoryPath: directoryPath as string, - branch: branch as string, - }, + { directoryPath: directoryPath as string, branch: branch as string }, { enabled: !!directoryPath && !!branch, staleTime: 30_000, diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useLinkedBranchPrUrl.ts b/packages/ui/src/features/git-interaction/useLinkedBranchPrUrl.ts similarity index 55% rename from apps/code/src/renderer/features/git-interaction/hooks/useLinkedBranchPrUrl.ts rename to packages/ui/src/features/git-interaction/useLinkedBranchPrUrl.ts index 7f7540a3e1..5904420162 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useLinkedBranchPrUrl.ts +++ b/packages/ui/src/features/git-interaction/useLinkedBranchPrUrl.ts @@ -1,4 +1,4 @@ -import { useTRPC } from "@renderer/trpc/client"; +import { useHostTRPC } from "@posthog/host-router/react"; import { useQuery } from "@tanstack/react-query"; interface UseLinkedBranchPrUrlArgs { @@ -15,21 +15,17 @@ export function useLinkedBranchPrUrl({ linkedBranch, folderPath, }: UseLinkedBranchPrUrlArgs): string | null { - const trpc = useTRPC(); - const { data } = useQuery( - trpc.git.getPrUrlForBranch.queryOptions( - { - directoryPath: folderPath as string, - branchName: linkedBranch as string, - }, - { - enabled: !!folderPath && !!linkedBranch, - staleTime: 60_000, - refetchInterval: 5 * 60_000, - retry: 1, - }, - ), - ); + const trpc = useHostTRPC(); + const { data } = useQuery({ + ...trpc.git.getPrUrlForBranch.queryOptions({ + directoryPath: folderPath as string, + branchName: linkedBranch as string, + }), + enabled: !!folderPath && !!linkedBranch, + staleTime: 60_000, + refetchInterval: 5 * 60_000, + retry: 1, + }); return data ?? null; } diff --git a/packages/ui/src/features/git-interaction/usePrActions.ts b/packages/ui/src/features/git-interaction/usePrActions.ts new file mode 100644 index 0000000000..e26a248bd8 --- /dev/null +++ b/packages/ui/src/features/git-interaction/usePrActions.ts @@ -0,0 +1,41 @@ +import { + getOptimisticPrState, + PR_ACTION_LABELS, +} from "@posthog/core/git-interaction/prStatus"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { PrActionType } from "@posthog/shared"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "../../primitives/toast"; + +export function usePrActions(prUrl: string | null) { + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + ...trpc.git.updatePrByUrl.mutationOptions(), + onSuccess: (data, variables) => { + if (data.success) { + toast.success(PR_ACTION_LABELS[variables.action]); + queryClient.setQueryData( + trpc.git.getPrDetailsByUrl.queryKey({ prUrl: variables.prUrl }), + getOptimisticPrState(variables.action), + ); + } else { + toast.error("Failed to update PR", { description: data.message }); + } + }, + onError: (error) => { + toast.error("Failed to update PR", { + description: error instanceof Error ? error.message : "Unknown error", + }); + }, + }); + + return { + execute: (action: PrActionType) => { + if (!prUrl) return; + mutation.mutate({ prUrl, action }); + }, + isPending: mutation.isPending, + }; +} diff --git a/apps/code/src/renderer/features/git-interaction/hooks/usePrDetails.ts b/packages/ui/src/features/git-interaction/usePrDetails.ts similarity index 58% rename from apps/code/src/renderer/features/git-interaction/hooks/usePrDetails.ts rename to packages/ui/src/features/git-interaction/usePrDetails.ts index 8ea85d8db6..760b41e612 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/usePrDetails.ts +++ b/packages/ui/src/features/git-interaction/usePrDetails.ts @@ -1,8 +1,8 @@ -import type { PrReviewThread } from "@main/services/git/schemas"; -import type { PrCommentThread } from "@renderer/features/code-review/utils/prCommentAnnotations"; -import { useTRPC } from "@renderer/trpc"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { PrReviewThread } from "@posthog/shared"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; +import type { PrCommentThread } from "../code-review/prCommentAnnotations"; interface UsePrDetailsOptions { includeComments?: boolean; @@ -27,31 +27,23 @@ export function usePrDetails( options?: UsePrDetailsOptions, ) { const { includeComments = false } = options ?? {}; - const trpc = useTRPC(); + const trpc = useHostTRPC(); - const metaQuery = useQuery( - trpc.git.getPrDetailsByUrl.queryOptions( - { prUrl: prUrl as string }, - { - enabled: !!prUrl, - staleTime: 60_000, - retry: 1, - }, - ), - ); + const metaQuery = useQuery({ + ...trpc.git.getPrDetailsByUrl.queryOptions({ prUrl: prUrl as string }), + enabled: !!prUrl, + staleTime: 60_000, + retry: 1, + }); - const commentsQuery = useQuery( - trpc.git.getPrReviewComments.queryOptions( - { prUrl: prUrl as string }, - { - enabled: !!prUrl && includeComments, - staleTime: 30_000, - refetchInterval: 30_000, - retry: 1, - structuralSharing: true, - }, - ), - ); + const commentsQuery = useQuery({ + ...trpc.git.getPrReviewComments.queryOptions({ prUrl: prUrl as string }), + enabled: !!prUrl && includeComments, + staleTime: 30_000, + refetchInterval: 30_000, + retry: 1, + structuralSharing: true, + }); const commentThreads = useMemo( () => threadsToMap(commentsQuery.data ?? []), diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts b/packages/ui/src/features/git-interaction/useTaskPrUrl.ts similarity index 58% rename from apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts rename to packages/ui/src/features/git-interaction/useTaskPrUrl.ts index d68b0d57d3..c57dcf3fb3 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts +++ b/packages/ui/src/features/git-interaction/useTaskPrUrl.ts @@ -1,9 +1,9 @@ -import { useCloudPrUrl } from "@features/git-interaction/hooks/useCloudPrUrl"; -import { useLinkedBranchPrUrl } from "@features/git-interaction/hooks/useLinkedBranchPrUrl"; -import { useLocalRepoPath } from "@features/workspace/hooks/useLocalRepoPath"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; -import { useTRPC } from "@renderer/trpc/client"; +import { useHostTRPC } from "@posthog/host-router/react"; import { useQuery } from "@tanstack/react-query"; +import { useLocalRepoPath } from "../workspace/useLocalRepoPath"; +import { useWorkspace } from "../workspace/useWorkspace"; +import { useCloudPrUrl } from "./useCloudPrUrl"; +import { useLinkedBranchPrUrl } from "./useLinkedBranchPrUrl"; /** * Resolves the PR URL for a task across all task kinds: @@ -24,16 +24,14 @@ export function useTaskPrUrl(taskId: string, isCloud: boolean): string | null { }); const localRepoPath = useLocalRepoPath(taskId); - const trpc = useTRPC(); - const { data: prStatus } = useQuery( - trpc.git.getPrStatus.queryOptions( - { directoryPath: localRepoPath ?? "" }, - { - enabled: !isCloud && !!localRepoPath, - staleTime: 30_000, - }, - ), - ); + const trpc = useHostTRPC(); + const { data: prStatus } = useQuery({ + ...trpc.git.getPrStatus.queryOptions({ + directoryPath: localRepoPath ?? "", + }), + enabled: !isCloud && !!localRepoPath, + staleTime: 30_000, + }); if (isCloud) return cloudPrUrl; return linkedPrUrl ?? prStatus?.prUrl ?? null; diff --git a/packages/ui/src/features/git-interaction/utils/branchCreation.ts b/packages/ui/src/features/git-interaction/utils/branchCreation.ts new file mode 100644 index 0000000000..ba67cec994 --- /dev/null +++ b/packages/ui/src/features/git-interaction/utils/branchCreation.ts @@ -0,0 +1,31 @@ +import { + type CreateBranchResult, + createBranch as createBranchCore, +} from "@posthog/core/git-interaction/branchCreation"; +import { invalidateGitBranchQueries } from "../gitCacheKeys"; + +export { + type BranchNameInputState, + type CreateBranchResult, + getBranchNameInputState, +} from "@posthog/core/git-interaction/branchCreation"; + +interface BranchCreator { + createBranch(repoPath: string, branchName: string): Promise<void>; +} + +interface CreateBranchInput { + writeClient: BranchCreator; + repoPath?: string; + rawBranchName: string; +} + +export async function createBranch( + input: CreateBranchInput, +): Promise<CreateBranchResult> { + const result = await createBranchCore(input); + if (result.success && input.repoPath) { + invalidateGitBranchQueries(input.repoPath); + } + return result; +} diff --git a/packages/ui/src/features/git-interaction/utils/diffStats.ts b/packages/ui/src/features/git-interaction/utils/diffStats.ts new file mode 100644 index 0000000000..bd8473f2a3 --- /dev/null +++ b/packages/ui/src/features/git-interaction/utils/diffStats.ts @@ -0,0 +1,6 @@ +export { + computeDiffStats, + type DiffStats, + formatFileCountLabel, + partitionByStaged, +} from "@posthog/core/git-interaction/diffStats"; diff --git a/apps/code/src/renderer/features/git-interaction/utils/fileKey.ts b/packages/ui/src/features/git-interaction/utils/fileKey.ts similarity index 100% rename from apps/code/src/renderer/features/git-interaction/utils/fileKey.ts rename to packages/ui/src/features/git-interaction/utils/fileKey.ts diff --git a/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts b/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts new file mode 100644 index 0000000000..5940e5ce99 --- /dev/null +++ b/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts @@ -0,0 +1,35 @@ +import { + deriveBranchName, + suggestBranchName, +} from "@posthog/core/git-interaction/branchName"; +import type { Task } from "@posthog/shared/domain-types"; +import type { QueryClient } from "@tanstack/react-query"; +import type { GitCacheKeyProvider } from "../gitCacheProvider"; + +export function getSuggestedBranchName( + queryClient: QueryClient, + provider: GitCacheKeyProvider, + taskId: string, + repoPath?: string, +): string { + const queries = queryClient.getQueriesData<Task[]>({ + queryKey: ["tasks", "list"], + }); + let task: Task | undefined; + for (const [, tasks] of queries) { + task = tasks?.find((t) => t.id === taskId); + if (task) break; + } + const fallbackId = task?.task_number + ? String(task.task_number) + : (task?.slug ?? taskId); + + if (!repoPath) return deriveBranchName(task?.title ?? "", fallbackId); + + const cached = + queryClient.getQueryData<string[]>( + provider.gitQueryKey("getAllBranches", { directoryPath: repoPath }), + ) ?? []; + + return suggestBranchName(task?.title ?? "", fallbackId, cached); +} diff --git a/packages/ui/src/features/git-interaction/utils/gitStatusUtils.ts b/packages/ui/src/features/git-interaction/utils/gitStatusUtils.ts new file mode 100644 index 0000000000..0c4d403ae4 --- /dev/null +++ b/packages/ui/src/features/git-interaction/utils/gitStatusUtils.ts @@ -0,0 +1,5 @@ +export { + getStatusIndicator, + type StatusColor, + type StatusIndicator, +} from "@posthog/core/git-interaction/gitStatusUtils"; diff --git a/packages/ui/src/features/git-interaction/utils/partitionByStaged.ts b/packages/ui/src/features/git-interaction/utils/partitionByStaged.ts new file mode 100644 index 0000000000..909e97cf5a --- /dev/null +++ b/packages/ui/src/features/git-interaction/utils/partitionByStaged.ts @@ -0,0 +1 @@ +export { partitionByStaged } from "@posthog/core/git-interaction/diffStats"; diff --git a/apps/code/src/renderer/features/git-interaction/utils/updateGitCache.ts b/packages/ui/src/features/git-interaction/utils/updateGitCache.ts similarity index 60% rename from apps/code/src/renderer/features/git-interaction/utils/updateGitCache.ts rename to packages/ui/src/features/git-interaction/utils/updateGitCache.ts index 9eca5382ee..96f4067f9f 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/updateGitCache.ts +++ b/packages/ui/src/features/git-interaction/utils/updateGitCache.ts @@ -1,36 +1,41 @@ -import type { GitStateSnapshot } from "@main/services/git/schemas"; -import { trpc } from "@renderer/trpc"; +import type { GitStateSnapshot } from "@posthog/core/git/router-schemas"; +import { resolveService } from "@posthog/di/container"; import type { QueryClient } from "@tanstack/react-query"; +import { + GIT_CACHE_KEY_PROVIDER, + type GitCacheKeyProvider, +} from "../gitCacheProvider"; export function updateGitCacheFromSnapshot( queryClient: QueryClient, repoPath: string, snapshot: GitStateSnapshot, ): void { + const provider = resolveService<GitCacheKeyProvider>(GIT_CACHE_KEY_PROVIDER); const input = { directoryPath: repoPath }; if (snapshot.changedFiles !== undefined) { queryClient.setQueryData( - trpc.git.getChangedFilesHead.queryKey(input), + provider.gitQueryKey("getChangedFilesHead", input), snapshot.changedFiles, ); } if (snapshot.diffStats !== undefined) { queryClient.setQueryData( - trpc.git.getDiffStats.queryKey(input), + provider.gitQueryKey("getDiffStats", input), snapshot.diffStats, ); } if (snapshot.syncStatus !== undefined) { queryClient.setQueryData( - trpc.git.getGitSyncStatus.queryKey(input), + provider.gitQueryKey("getGitSyncStatus", input), snapshot.syncStatus, ); if (snapshot.syncStatus.currentBranch !== undefined) { queryClient.setQueryData( - trpc.git.getCurrentBranch.queryKey(input), + provider.gitQueryKey("getCurrentBranch", input), snapshot.syncStatus.currentBranch, ); } @@ -38,14 +43,14 @@ export function updateGitCacheFromSnapshot( if (snapshot.latestCommit !== undefined) { queryClient.setQueryData( - trpc.git.getLatestCommit.queryKey(input), + provider.gitQueryKey("getLatestCommit", input), snapshot.latestCommit, ); } if (snapshot.prStatus !== undefined) { queryClient.setQueryData( - trpc.git.getPrStatus.queryKey(input), + provider.gitQueryKey("getPrStatus", input), snapshot.prStatus, ); } diff --git a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx similarity index 77% rename from apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx rename to packages/ui/src/features/inbox/components/DataSourceSetup.tsx index 3f7dfeb9e2..dc735f05d6 100644 --- a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx @@ -1,40 +1,24 @@ -import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker"; +import type { DataSourceService } from "@posthog/core/inbox/dataSourceService"; +import { DATA_SOURCE_SERVICE } from "@posthog/core/inbox/identifiers"; +import { useService } from "@posthog/di/react"; +import { Button } from "@posthog/quill"; +import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { GitHubRepoPicker } from "@posthog/ui/features/folder-picker/GitHubRepoPicker"; import { describeGithubConnectError, useGithubConnect, -} from "@features/integrations/hooks/useGithubUserConnect"; +} from "@posthog/ui/features/integrations/useGithubUserConnect"; import { useGithubRepositories, useRepositoryIntegration, -} from "@hooks/useIntegrations"; -import { Button } from "@posthog/quill"; +} from "@posthog/ui/features/integrations/useIntegrations"; +import { toast } from "@posthog/ui/primitives/toast"; import { Box, Flex, Text, TextField } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc"; import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; type DataSourceType = "github" | "linear" | "zendesk" | "pganalyze"; -const REQUIRED_SCHEMAS: Record<DataSourceType, string[]> = { - github: ["issues"], - linear: ["issues"], - zendesk: ["tickets"], - pganalyze: ["issues", "servers"], -}; - -/** PostHog DWH: full table replication (non-incremental); API enum value `full_refresh`. */ -const FULL_TABLE_REPLICATION = "full_refresh" as const; - -function schemasPayload(source: DataSourceType) { - return REQUIRED_SCHEMAS[source].map((name) => ({ - name, - should_sync: true, - sync_type: FULL_TABLE_REPLICATION, - })); -} - interface DataSourceSetupProps { source: DataSourceType; onComplete: () => void; @@ -66,6 +50,7 @@ interface SetupFormProps { function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { const projectId = useAuthStateValue((state) => state.projectId); const client = useAuthenticatedClient(); + const dataSourceService = useService<DataSourceService>(DATA_SOURCE_SERVICE); const { repositories, getIntegrationIdForRepo, @@ -118,16 +103,9 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { setLoading(true); try { - await client.createExternalDataSource(projectId, { - source_type: "Github", - payload: { - repository: repo, - auth_method: { - selection: "oauth", - github_integration_id: selectedIntegrationId, - }, - schemas: schemasPayload("github"), - }, + await dataSourceService.createGithubDataSource(client, projectId, { + repository: repo, + githubIntegrationId: selectedIntegrationId, }); toast.success("GitHub data source created"); onComplete(); @@ -138,7 +116,14 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { } finally { setLoading(false); } - }, [projectId, client, onComplete, repo, selectedIntegrationId]); + }, [ + projectId, + client, + onComplete, + repo, + selectedIntegrationId, + dataSourceService, + ]); const handleRefreshRepositories = useCallback(() => { void refreshRepositories() @@ -260,92 +245,63 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { ); } -const POLL_INTERVAL_MS = 3_000; -const POLL_TIMEOUT_MS = 300_000; // 5 minutes - function LinearSetup({ onComplete }: SetupFormProps) { const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const projectId = useAuthStateValue((state) => state.projectId); const client = useAuthenticatedClient(); + const dataSourceService = useService<DataSourceService>(DATA_SOURCE_SERVICE); const [loading, setLoading] = useState(false); const [oauthConnected, setOauthConnected] = useState(false); const [linearIntegrationId, setLinearIntegrationId] = useState< number | string | null >(null); const [pollError, setPollError] = useState<string | null>(null); - const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); - const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const pollAbortRef = useRef<AbortController | null>(null); - const stopPolling = useCallback(() => { - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } - if (pollTimeoutRef.current) { - clearTimeout(pollTimeoutRef.current); - pollTimeoutRef.current = null; - } - }, []); - - // Cleanup on unmount - useEffect(() => stopPolling, [stopPolling]); + useEffect( + () => () => { + pollAbortRef.current?.abort(); + }, + [], + ); const handleOAuthConnect = useCallback(async () => { if (!cloudRegion || !projectId || !client) return; setLoading(true); setPollError(null); + const controller = new AbortController(); + pollAbortRef.current = controller; try { - await trpcClient.linearIntegration.startFlow.mutate({ - region: cloudRegion, - projectId, - }); - - // Poll for the new Linear integration - pollTimerRef.current = setInterval(async () => { - try { - const integrations = - await client.getIntegrationsForProject(projectId); - const linearIntegration = integrations.find( - (i: { kind: string }) => i.kind === "linear", - ) as { id: number | string } | undefined; - if (linearIntegration) { - stopPolling(); - setLoading(false); - setOauthConnected(true); - setLinearIntegrationId(linearIntegration.id); - toast.success("Linear connected"); - } - } catch { - // Ignore individual poll failures - } - }, POLL_INTERVAL_MS); - - // Timeout after 5 minutes - pollTimeoutRef.current = setTimeout(() => { - stopPolling(); - setLoading(false); - setPollError("Connection timed out. Please try again."); - }, POLL_TIMEOUT_MS); + const integrationId = + await dataSourceService.connectLinearAndAwaitIntegration( + client, + cloudRegion, + projectId, + controller.signal, + ); + setLoading(false); + setOauthConnected(true); + setLinearIntegrationId(integrationId); + toast.success("Linear connected"); } catch (error) { + if (controller.signal.aborted) return; setLoading(false); - toast.error( + setPollError( error instanceof Error ? error.message : "Failed to connect Linear", ); } - }, [cloudRegion, projectId, client, stopPolling]); + }, [cloudRegion, projectId, client, dataSourceService]); const handleSubmit = useCallback(async () => { if (!projectId || !client || !linearIntegrationId) return; setLoading(true); try { - await client.createExternalDataSource(projectId, { - source_type: "Linear", - payload: { - linear_integration_id: linearIntegrationId, - schemas: schemasPayload("linear"), - }, - }); + await dataSourceService.createLinearDataSource( + client, + projectId, + linearIntegrationId, + ); toast.success("Linear data source created"); onComplete(); } catch (error) { @@ -355,7 +311,7 @@ function LinearSetup({ onComplete }: SetupFormProps) { } finally { setLoading(false); } - }, [projectId, client, linearIntegrationId, onComplete]); + }, [projectId, client, linearIntegrationId, onComplete, dataSourceService]); return ( <SetupFormContainer title="Connect Linear"> @@ -397,6 +353,7 @@ function LinearSetup({ onComplete }: SetupFormProps) { function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { const projectId = useAuthStateValue((state) => state.projectId); const client = useAuthenticatedClient(); + const dataSourceService = useService<DataSourceService>(DATA_SOURCE_SERVICE); const [subdomain, setSubdomain] = useState(""); const [apiKey, setApiKey] = useState(""); const [email, setEmail] = useState(""); @@ -411,14 +368,10 @@ function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { setLoading(true); try { - await client.createExternalDataSource(projectId, { - source_type: "Zendesk", - payload: { - subdomain: subdomain.trim(), - api_key: apiKey.trim(), - email_address: email.trim(), - schemas: schemasPayload("zendesk"), - }, + await dataSourceService.createZendeskDataSource(client, projectId, { + subdomain: subdomain.trim(), + apiKey: apiKey.trim(), + email: email.trim(), }); toast.success("Zendesk data source created"); onComplete(); @@ -429,7 +382,15 @@ function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { } finally { setLoading(false); } - }, [projectId, client, subdomain, apiKey, email, onComplete]); + }, [ + projectId, + client, + subdomain, + apiKey, + email, + onComplete, + dataSourceService, + ]); const canSubmit = subdomain.trim() && apiKey.trim() && email.trim(); @@ -482,6 +443,7 @@ function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { function PgAnalyzeSetup({ onComplete, onCancel }: SetupFormProps) { const projectId = useAuthStateValue((state) => state.projectId); const client = useAuthenticatedClient(); + const dataSourceService = useService<DataSourceService>(DATA_SOURCE_SERVICE); const [apiKey, setApiKey] = useState(""); const [organizationSlug, setOrganizationSlug] = useState(""); const [loading, setLoading] = useState(false); @@ -495,13 +457,9 @@ function PgAnalyzeSetup({ onComplete, onCancel }: SetupFormProps) { setLoading(true); try { - await client.createExternalDataSource(projectId, { - source_type: "PgAnalyze", - payload: { - api_key: apiKey.trim(), - organization_slug: organizationSlug.trim(), - schemas: schemasPayload("pganalyze"), - }, + await dataSourceService.createPgAnalyzeDataSource(client, projectId, { + apiKey: apiKey.trim(), + organizationSlug: organizationSlug.trim(), }); toast.success("pganalyze data source created"); onComplete(); @@ -512,7 +470,14 @@ function PgAnalyzeSetup({ onComplete, onCancel }: SetupFormProps) { } finally { setLoading(false); } - }, [projectId, client, apiKey, organizationSlug, onComplete]); + }, [ + projectId, + client, + apiKey, + organizationSlug, + onComplete, + dataSourceService, + ]); const canSubmit = apiKey.trim() && organizationSlug.trim(); diff --git a/apps/code/src/renderer/features/inbox/components/DismissReportDialog.tsx b/packages/ui/src/features/inbox/components/DismissReportDialog.tsx similarity index 95% rename from apps/code/src/renderer/features/inbox/components/DismissReportDialog.tsx rename to packages/ui/src/features/inbox/components/DismissReportDialog.tsx index d9c77775bb..9e48cf6673 100644 --- a/apps/code/src/renderer/features/inbox/components/DismissReportDialog.tsx +++ b/packages/ui/src/features/inbox/components/DismissReportDialog.tsx @@ -1,8 +1,9 @@ -import { Button } from "@components/ui/Button"; import { - ExplainedPauseLabel, - ExplainedSuppressLabel, -} from "@features/inbox/components/utils/ExplainedDismissOptionLabels"; + DISMISSAL_REASON_OPTIONS, + type DismissalReasonOptionValue, + isDismissalReasonSnooze, +} from "@posthog/shared"; +import type { SignalReport } from "@posthog/shared/domain-types"; import { AlertDialog, Flex, @@ -10,13 +11,12 @@ import { Text, TextArea, } from "@radix-ui/themes"; -import { - DISMISSAL_REASON_OPTIONS, - type DismissalReasonOptionValue, - isDismissalReasonSnooze, -} from "@shared/dismissalReasons"; -import type { SignalReport } from "@shared/types"; import { useEffect, useState } from "react"; +import { Button } from "../../../primitives/Button"; +import { + ExplainedPauseLabel, + ExplainedSuppressLabel, +} from "./utils/ExplainedDismissOptionLabels"; export interface DismissReportDialogResult { reason: DismissalReasonOptionValue; diff --git a/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx b/packages/ui/src/features/inbox/components/InboxEmptyStates.tsx similarity index 91% rename from apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx rename to packages/ui/src/features/inbox/components/InboxEmptyStates.tsx index 1aeddadf31..a62fbd5034 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx +++ b/packages/ui/src/features/inbox/components/InboxEmptyStates.tsx @@ -1,13 +1,12 @@ -import { AnimatedEllipsis } from "@features/inbox/components/utils/AnimatedEllipsis"; -import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; import { CheckCircleIcon } from "@phosphor-icons/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { builderHog, explorerHog } from "@posthog/ui/assets/hedgehogs"; +import { AnimatedEllipsis } from "@posthog/ui/features/inbox/components/utils/AnimatedEllipsis"; +import { SOURCE_PRODUCT_META } from "@posthog/ui/features/inbox/components/utils/source-product-icons"; +import { track } from "@posthog/ui/workbench/analytics"; import { Box, Button, Flex, Text, Tooltip } from "@radix-ui/themes"; -import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; -import explorerHog from "@renderer/assets/images/hedgehogs/explorer-hog.png"; -import mailHog from "@renderer/assets/images/mail-hog.png"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; import { useState } from "react"; +import mailHog from "../../../assets/images/mail-hog.png"; // ── Full-width empty states ───────────────────────────────────────────────── diff --git a/apps/code/src/renderer/features/inbox/components/InboxSetupPane.tsx b/packages/ui/src/features/inbox/components/InboxSetupPane.tsx similarity index 94% rename from apps/code/src/renderer/features/inbox/components/InboxSetupPane.tsx rename to packages/ui/src/features/inbox/components/InboxSetupPane.tsx index aee373636a..4869f86a00 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSetupPane.tsx +++ b/packages/ui/src/features/inbox/components/InboxSetupPane.tsx @@ -1,8 +1,8 @@ -import { SignalSourcesSettings } from "@features/settings/components/sections/SignalSourcesSettings"; import { ArrowRightIcon } from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { motion } from "framer-motion"; +import { SignalSourcesSettings } from "../../settings/sections/SignalSourcesSettings"; interface InboxSetupPaneProps { hasSignalSources: boolean; diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/packages/ui/src/features/inbox/components/InboxSignalsTab.tsx similarity index 91% rename from apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx rename to packages/ui/src/features/inbox/components/InboxSignalsTab.tsx index 955db61c9d..3c532cf748 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/packages/ui/src/features/inbox/components/InboxSignalsTab.tsx @@ -1,61 +1,64 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useCurrentUser } from "@features/auth/hooks/authQueries"; +import { + buildSignalReportListOrdering, + buildStatusFilterParam, + buildSuggestedReviewerFilterParam, + countUpForReview, + deriveEnabledProducts, + filterReportsBySearch, +} from "@posthog/core/inbox/reportFilters"; +import { ANALYTICS_EVENTS, isDismissalReasonSnooze } from "@posthog/shared"; +import type { + SignalReport, + SignalReportsQueryParams, +} from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; +import { + DismissReportDialog, + type DismissReportDialogResult, +} from "@posthog/ui/features/inbox/components/DismissReportDialog"; +import { MultiSelectStack } from "@posthog/ui/features/inbox/components/detail/MultiSelectStack"; import { SelectReportPane, SkeletonBackdrop, WarmingUpPane, -} from "@features/inbox/components/InboxEmptyStates"; -import { InboxSetupPane } from "@features/inbox/components/InboxSetupPane"; -import { InboxSourcesDialog } from "@features/inbox/components/InboxSourcesDialog"; +} from "@posthog/ui/features/inbox/components/InboxEmptyStates"; +import { InboxSetupPane } from "@posthog/ui/features/inbox/components/InboxSetupPane"; +import { InboxSourcesDialog } from "@posthog/ui/features/inbox/components/InboxSourcesDialog"; +import { GitHubConnectionBanner } from "@posthog/ui/features/inbox/components/list/GitHubConnectionBanner"; +import { ReportListPane } from "@posthog/ui/features/inbox/components/list/ReportListPane"; +import { SignalsToolbar } from "@posthog/ui/features/inbox/components/list/SignalsToolbar"; import { inboxBulkSnoozeDisabledReason, inboxBulkSuppressDisabledReason, useInboxBulkActions, -} from "@features/inbox/hooks/useInboxBulkActions"; -import { useInboxDeepLinkListSync } from "@features/inbox/hooks/useInboxDeepLinkListSync"; -import { useInboxEngagementTracker } from "@features/inbox/hooks/useInboxEngagementTracker"; +} from "@posthog/ui/features/inbox/hooks/useInboxBulkActions"; +import { useInboxDeepLinkListSync } from "@posthog/ui/features/inbox/hooks/useInboxDeepLinkListSync"; +import { useInboxEngagementTracker } from "@posthog/ui/features/inbox/hooks/useInboxEngagementTracker"; import { useInboxAvailableSuggestedReviewers, useInboxReportsInfinite, useInboxSignalProcessingState, -} from "@features/inbox/hooks/useInboxReports"; -import { useSeedSuggestedReviewerFilter } from "@features/inbox/hooks/useSeedSuggestedReviewerFilter"; -import { useSignalSourceConfigs } from "@features/inbox/hooks/useSignalSourceConfigs"; -import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; -import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; -import { useInboxSignalsSidebarStore } from "@features/inbox/stores/inboxSignalsSidebarStore"; -import { useInboxSourcesDialogStore } from "@features/inbox/stores/inboxSourcesDialogStore"; -import { - buildSignalReportListOrdering, - buildStatusFilterParam, - buildSuggestedReviewerFilterParam, - filterReportsBySearch, - isReportUpForReview, -} from "@features/inbox/utils/filterReports"; -import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; -import { setPendingInboxOpenMethod } from "@features/inbox/utils/pendingInboxOpenMethod"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; +} from "@posthog/ui/features/inbox/hooks/useInboxReports"; +import { useSeedSuggestedReviewerFilter } from "@posthog/ui/features/inbox/hooks/useSeedSuggestedReviewerFilter"; +import { useSignalSourceConfigs } from "@posthog/ui/features/inbox/hooks/useSignalSourceConfigs"; +import { useInboxReportSelectionStore } from "@posthog/ui/features/inbox/inboxReportSelectionStore"; +import { useInboxSignalsFilterStore } from "@posthog/ui/features/inbox/inboxSignalsFilterStore"; +import { useInboxSourcesDialogStore } from "@posthog/ui/features/inbox/inboxSourcesDialogStore"; +import { useInboxSignalsSidebarStore } from "@posthog/ui/features/inbox/stores/inboxSignalsSidebarStore"; +import { INBOX_REFETCH_INTERVAL_MS } from "@posthog/ui/features/inbox/utils/inboxConstants"; +import { setPendingInboxOpenMethod } from "@posthog/ui/features/inbox/utils/pendingInboxOpenMethod"; import { useIntegrations, useRepositoryIntegration, -} from "@hooks/useIntegrations"; +} from "@posthog/ui/features/integrations/useIntegrations"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { track } from "@posthog/ui/workbench/analytics"; +import { useRendererWindowFocusStore } from "@posthog/ui/workbench/rendererWindowFocusStore"; import { Box, Flex, ScrollArea } from "@radix-ui/themes"; -import { isDismissalReasonSnooze } from "@shared/dismissalReasons"; -import type { SignalReport, SignalReportsQueryParams } from "@shared/types"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; -import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; -import { track } from "@utils/analytics"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - DismissReportDialog, - type DismissReportDialogResult, -} from "./DismissReportDialog"; -import { MultiSelectStack } from "./detail/MultiSelectStack"; import { ReportDetailPane } from "./detail/ReportDetailPane"; -import { GitHubConnectionBanner } from "./list/GitHubConnectionBanner"; -import { ReportListPane } from "./list/ReportListPane"; -import { SignalsToolbar } from "./list/SignalsToolbar"; // ── Main component ────────────────────────────────────────────────────────── @@ -102,17 +105,10 @@ export function InboxSignalsTab() { [integrationsData], ); const hasSignalSources = signalSourceConfigs?.some((c) => c.enabled) ?? false; - const enabledProducts = useMemo(() => { - const seen = new Set<string>(); - return (signalSourceConfigs ?? []) - .filter( - (c) => - c.enabled && - !seen.has(c.source_product) && - seen.add(c.source_product), - ) - .map((c) => c.source_product); - }, [signalSourceConfigs]); + const enabledProducts = useMemo( + () => deriveEnabledProducts(signalSourceConfigs ?? []), + [signalSourceConfigs], + ); // ── Sources dialog ────────────────────────────────────────────────────── const sourcesDialogOpen = useInboxSourcesDialogStore((s) => s.open); @@ -215,10 +211,7 @@ export function InboxSignalsTab() { staleTime: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : 12_000, }); - const readyCount = useMemo( - () => allReports.filter(isReportUpForReview).length, - [allReports], - ); + const readyCount = useMemo(() => countUpForReview(allReports), [allReports]); const processingCount = useMemo( () => allReports.filter((r) => r.status !== "ready").length, [allReports], diff --git a/apps/code/src/renderer/features/inbox/components/InboxSourcesDialog.tsx b/packages/ui/src/features/inbox/components/InboxSourcesDialog.tsx similarity index 95% rename from apps/code/src/renderer/features/inbox/components/InboxSourcesDialog.tsx rename to packages/ui/src/features/inbox/components/InboxSourcesDialog.tsx index 47dbbb9316..b46faaa751 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSourcesDialog.tsx +++ b/packages/ui/src/features/inbox/components/InboxSourcesDialog.tsx @@ -1,6 +1,6 @@ -import { SignalSourcesSettings } from "@features/settings/components/sections/SignalSourcesSettings"; import { XIcon } from "@phosphor-icons/react"; import { Button, Dialog, Flex, Tooltip } from "@radix-ui/themes"; +import { SignalSourcesSettings } from "../../settings/sections/SignalSourcesSettings"; /** Portaled Quill popups are outside Dialog.Content; ignore outside-dismiss for them. */ function isQuillPortalEventTarget(target: EventTarget | null): boolean { diff --git a/apps/code/src/renderer/features/inbox/components/InboxView.tsx b/packages/ui/src/features/inbox/components/InboxView.tsx similarity index 83% rename from apps/code/src/renderer/features/inbox/components/InboxView.tsx rename to packages/ui/src/features/inbox/components/InboxView.tsx index 428b3d90d1..20a6f61303 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxView.tsx +++ b/packages/ui/src/features/inbox/components/InboxView.tsx @@ -1,12 +1,14 @@ -import { useFeatureFlag } from "@hooks/useFeatureFlag"; -import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import { EnvelopeSimpleIcon } from "@phosphor-icons/react"; +import { + ANALYTICS_EVENTS, + INBOX_GATED_DUE_TO_SCALE_FLAG, +} from "@posthog/shared"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { GatedDueToScalePane } from "@posthog/ui/features/inbox/components/InboxEmptyStates"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { track } from "@posthog/ui/workbench/analytics"; import { Flex, Text } from "@radix-ui/themes"; -import { INBOX_GATED_DUE_TO_SCALE_FLAG } from "@shared/constants"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; import { useEffect, useMemo, useRef } from "react"; -import { GatedDueToScalePane } from "./InboxEmptyStates"; import { InboxSignalsTab } from "./InboxSignalsTab"; export function InboxView() { diff --git a/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx b/packages/ui/src/features/inbox/components/SignalSourceToggles.tsx similarity index 97% rename from apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx rename to packages/ui/src/features/inbox/components/SignalSourceToggles.tsx index db1a72a98e..8a2cf393a5 100644 --- a/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx +++ b/packages/ui/src/features/inbox/components/SignalSourceToggles.tsx @@ -1,5 +1,3 @@ -import { Badge } from "@components/ui/Badge"; -import { PgAnalyzeIcon } from "@features/inbox/components/utils/PgAnalyzeIcon"; import { ArrowSquareOutIcon, BrainIcon, @@ -11,20 +9,15 @@ import { TicketIcon, VideoIcon, } from "@phosphor-icons/react"; +import type { SignalSourceConfig } from "@posthog/api-client/posthog-client"; +import type { SignalSourceValues } from "@posthog/core/inbox/signalSourceService"; import { Button } from "@posthog/quill"; +import { PgAnalyzeIcon } from "@posthog/ui/features/inbox/components/utils/PgAnalyzeIcon"; +import { Badge } from "@posthog/ui/primitives/Badge"; import { Box, Flex, Spinner, Switch, Text, Tooltip } from "@radix-ui/themes"; -import type { SignalSourceConfig } from "@renderer/api/posthogClient"; import { memo, useCallback } from "react"; -export interface SignalSourceValues { - session_replay: boolean; - error_tracking: boolean; - github: boolean; - linear: boolean; - zendesk: boolean; - conversations: boolean; - pganalyze: boolean; -} +export type { SignalSourceValues }; interface SignalSourceToggleCardProps { icon: React.ReactNode; diff --git a/apps/code/src/renderer/features/inbox/components/detail/MultiSelectStack.tsx b/packages/ui/src/features/inbox/components/detail/MultiSelectStack.tsx similarity index 97% rename from apps/code/src/renderer/features/inbox/components/detail/MultiSelectStack.tsx rename to packages/ui/src/features/inbox/components/detail/MultiSelectStack.tsx index da2be5b9bc..314d6e3525 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/MultiSelectStack.tsx +++ b/packages/ui/src/features/inbox/components/detail/MultiSelectStack.tsx @@ -1,6 +1,6 @@ -import { ReportCardContent } from "@features/inbox/components/utils/ReportCardContent"; +import type { SignalReport } from "@posthog/shared/domain-types"; +import { ReportCardContent } from "@posthog/ui/features/inbox/components/utils/ReportCardContent"; import { Flex, Text } from "@radix-ui/themes"; -import type { SignalReport } from "@shared/types"; import { AnimatePresence, motion } from "framer-motion"; import { useCallback, useState } from "react"; diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/packages/ui/src/features/inbox/components/detail/ReportDetailPane.tsx similarity index 84% rename from apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx rename to packages/ui/src/features/inbox/components/detail/ReportDetailPane.tsx index bdc2be529b..25f075fb1f 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/packages/ui/src/features/inbox/components/detail/ReportDetailPane.tsx @@ -1,16 +1,3 @@ -import { Badge } from "@components/ui/Badge"; -import { Button } from "@components/ui/Button"; -import { - useInboxReportArtefacts, - useInboxReportSignals, -} from "@features/inbox/hooks/useInboxReports"; -import { - getTaskPrUrl, - useReportTasks, -} from "@features/inbox/hooks/useReportTasks"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import { useDetectedCloudRepository } from "@hooks/useDetectedCloudRepository"; -import { useMeQuery } from "@hooks/useMeQuery"; import { ArrowSquareOutIcon, CaretDownIcon, @@ -23,7 +10,32 @@ import { WarningIcon, XIcon, } from "@phosphor-icons/react"; +import { + buildDetailActionEvent, + type DetailActionExtra, +} from "@posthog/core/inbox/reportActionEvents"; +import { + canCreateImplementationPr as canCreateImplementationPrRule, + resolveHeaderImplementationPrUrl, +} from "@posthog/core/inbox/reportActionRules"; +import { + buildSignalFindingMap, + selectActionabilityJudgment, + selectPriorityExplanation, + selectSuggestedReviewers, +} from "@posthog/core/inbox/reportArtefacts"; +import { resolveReportRepository } from "@posthog/core/inbox/reportRepository"; +import { partitionSessionProblemSignals } from "@posthog/core/inbox/reportSignals"; +import { useHostTRPC } from "@posthog/host-router/react"; import { Kbd } from "@posthog/quill"; +import type { InboxReportActionProperties } from "@posthog/shared"; +import { buildInboxDeeplink, EXTERNAL_LINKS } from "@posthog/shared"; +import type { + Signal, + SignalReport, + SuggestedReviewer, + Task, +} from "@posthog/shared/domain-types"; import { Box, Flex, @@ -34,24 +46,7 @@ import { TextArea, Tooltip, } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc"; -import { EXTERNAL_LINKS } from "@renderer/utils/links"; -import { buildInboxDeeplink } from "@shared/deeplink"; -import type { - ActionabilityJudgmentArtefact, - ActionabilityJudgmentContent, - PriorityJudgmentArtefact, - Signal, - SignalFindingArtefact, - SignalReport, - SignalReportTask, - SuggestedReviewer, - SuggestedReviewersArtefact, - Task, -} from "@shared/types"; -import type { InboxReportActionProperties } from "@shared/types/analytics"; import { useQuery } from "@tanstack/react-query"; -import { isMac } from "@utils/platform"; import { type FormEvent, type ReactNode, @@ -62,8 +57,19 @@ import { useState, } from "react"; import { toast } from "sonner"; +import { useAuthenticatedQuery } from "../../../../hooks/useAuthenticatedQuery"; +import { Badge } from "../../../../primitives/Badge"; +import { Button } from "../../../../primitives/Button"; +import { isMac } from "../../../../utils/platform"; +import { useMeQuery } from "../../../auth/useMeQuery"; +import { useDetectedCloudRepository } from "../../../repo-files/useDetectedCloudRepository"; import { useCreatePrReport } from "../../hooks/useCreatePrReport"; import { useDiscussReport } from "../../hooks/useDiscussReport"; +import { + useInboxReportArtefacts, + useInboxReportSignals, +} from "../../hooks/useInboxReports"; +import { useReportTasks } from "../../hooks/useReportTasks"; import { ReportImplementationPrLink } from "../utils/ReportImplementationPrLink"; import { SignalReportActionabilityBadge } from "../utils/SignalReportActionabilityBadge"; import { SignalReportPriorityBadge } from "../utils/SignalReportPriorityBadge"; @@ -80,33 +86,16 @@ function isSuggestedReviewerRowMe( return !!reviewer.user?.uuid && !!meUuid && meUuid === reviewer.user.uuid; } -const REPOSITORY_SOURCE_RELATIONSHIPS: SignalReportTask["relationship"][] = [ - "repo_selection", - "research", - "implementation", -]; - function useReportRepository(reportId: string) { return useAuthenticatedQuery<string | null>( ["inbox", "report-repository", reportId], async (client) => { const reportTasks = await client.getSignalReportTasks(reportId); - - for (const relationship of REPOSITORY_SOURCE_RELATIONSHIPS) { - const reportTask = reportTasks.find( - (task) => task.relationship === relationship, - ); - if (!reportTask) continue; - - const task = (await client.getTask( - reportTask.task_id, - )) as unknown as Task | null; - if (task?.repository) { - return task.repository.toLowerCase(); - } - } - - return null; + return resolveReportRepository( + reportTasks, + async (taskId) => + (await client.getTask(taskId)) as unknown as Task | null, + ); }, { enabled: !!reportId, staleTime: 30_000 }, ); @@ -207,42 +196,25 @@ export function ReportDetailPane({ }); const allArtefacts = artefactsQuery.data?.results ?? []; - const suggestedReviewers = useMemo(() => { - const reviewerArtefact = allArtefacts.find( - (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", - ); - return reviewerArtefact?.content ?? []; - }, [allArtefacts]); - - const signalFindings = useMemo(() => { - const map = new Map<string, SignalFindingArtefact["content"]>(); - for (const a of allArtefacts) { - if (a.type === "signal_finding") { - const finding = a as SignalFindingArtefact; - map.set(finding.content.signal_id, finding.content); - } - } - return map; - }, [allArtefacts]); - - const actionabilityJudgment = - useMemo((): ActionabilityJudgmentContent | null => { - for (const a of allArtefacts) { - if (a.type === "actionability_judgment") { - return (a as ActionabilityJudgmentArtefact).content; - } - } - return null; - }, [allArtefacts]); + const suggestedReviewers = useMemo( + () => selectSuggestedReviewers(allArtefacts), + [allArtefacts], + ); - const priorityExplanation = useMemo((): string | null => { - for (const a of allArtefacts) { - if (a.type === "priority_judgment") { - return (a as PriorityJudgmentArtefact).content.explanation || null; - } - } - return null; - }, [allArtefacts]); + const signalFindings = useMemo( + () => buildSignalFindingMap(allArtefacts), + [allArtefacts], + ); + + const actionabilityJudgment = useMemo( + () => selectActionabilityJudgment(allArtefacts), + [allArtefacts], + ); + + const priorityExplanation = useMemo( + () => selectPriorityExplanation(allArtefacts), + [allArtefacts], + ); const artefactsUnavailableReason = artefactsQuery.data?.unavailableReason; void artefactsUnavailableReason; // TODO: wire up unavailable UI @@ -251,24 +223,16 @@ export function ReportDetailPane({ enabled: true, }); const allSignals = signalsQuery.data?.signals ?? []; - const sessionProblemSignals = allSignals.filter( - (s) => - s.source_product === "session_replay" && - s.source_type === "session_problem", - ); - const signals = allSignals.filter( - (s) => - !( - s.source_product === "session_replay" && - s.source_type === "session_problem" - ), + const { evidence: sessionProblemSignals, signals } = useMemo( + () => partitionSessionProblemSignals(allSignals), + [allSignals], ); // ── Task creation ─────────────────────────────────────────────────────── const { data: reportRepository } = useReportRepository(report.id); - const trpcReact = useTRPC(); + const trpc = useHostTRPC(); const { data: mostRecentRepo } = useQuery( - trpcReact.folders.getMostRecentlyAccessedRepository.queryOptions(), + trpc.folders.getMostRecentlyAccessedRepository.queryOptions(), ); const detectedFallbackRepo = useDetectedCloudRepository( !reportRepository ? mostRecentRepo?.path : null, @@ -279,64 +243,21 @@ export function ReportDetailPane({ const implementationTaskFromHook = reportTasksData?.find((t) => t.relationship === "implementation")?.task ?? null; - const implementationPrFromTask = implementationTaskFromHook - ? getTaskPrUrl(implementationTaskFromHook) - : null; - const headerImplementationPrUrl = - implementationPrFromTask ?? report.implementation_pr_url ?? null; - - /** True when the report is waiting on user input before implementation can proceed. - * Covers the `pending_input` status and the `ready + requires_human_input` combination - * (the actionability badge shows "Needs input" in that case). */ - const isAwaitingInput = - report.status === "pending_input" || - (report.status === "ready" && - report.actionability === "requires_human_input"); - - /** Matches server autostart rules: ready + immediately actionable + not already fixed. - * When the report is awaiting input we also surface the action so the user can provide it. */ - const canCreateImplementationPr = - isAwaitingInput || - (report.status === "ready" && - report.actionability === "immediately_actionable" && - report.already_addressed !== true); - - // Centralized helper for detail-pane action analytics — fills boilerplate (surface, is_bulk, - // bulk_size) and report-scoped context (title, age) so call sites only pass action-specific extras. + const headerImplementationPrUrl = resolveHeaderImplementationPrUrl( + report, + implementationTaskFromHook, + ); + + const canCreateImplementationPr = canCreateImplementationPrRule(report); + const fireDetailAction = useCallback( ( actionType: InboxReportActionProperties["action_type"], - extra?: Partial< - Omit< - InboxReportActionProperties, - | "report_id" - | "report_title" - | "report_age_hours" - | "action_type" - | "surface" - | "is_bulk" - | "bulk_size" - | "rank" - | "list_size" - > - >, + extra?: DetailActionExtra, ) => { - const ageMs = Date.now() - new Date(report.created_at).getTime(); - const reportAgeHours = Number.isFinite(ageMs) - ? Math.max(0, Math.round((ageMs / 3_600_000) * 10) / 10) - : 0; - onReportAction?.({ - report_id: report.id, - report_title: report.title, - report_age_hours: reportAgeHours, - action_type: actionType, - surface: "detail_pane", - is_bulk: false, - bulk_size: 1, - ...extra, - }); + onReportAction?.(buildDetailActionEvent(report, actionType, extra)); }, - [onReportAction, report.id, report.title, report.created_at], + [onReportAction, report], ); // Build the signal-card interaction handler used by both signal lists (signals + session-problem evidence). diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx b/packages/ui/src/features/inbox/components/detail/ReportTaskLogs.tsx similarity index 97% rename from apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx rename to packages/ui/src/features/inbox/components/detail/ReportTaskLogs.tsx index c1b3529e4b..cb47bd3cd5 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx +++ b/packages/ui/src/features/inbox/components/detail/ReportTaskLogs.tsx @@ -1,8 +1,3 @@ -import { - getTaskPrUrl, - useReportTasks, -} from "@features/inbox/hooks/useReportTasks"; -import { TaskLogsPanel } from "@features/task-detail/components/TaskLogsPanel"; import { CaretUpIcon, CheckCircleIcon, @@ -10,9 +5,15 @@ import { DotOutlineIcon, XCircleIcon, } from "@phosphor-icons/react"; +import type { + SignalReportStatus, + SignalReportTask, + Task, +} from "@posthog/shared/domain-types"; import { Spinner, Text, Tooltip } from "@radix-ui/themes"; -import type { SignalReportStatus, SignalReportTask, Task } from "@shared/types"; import { useState } from "react"; +import { TaskLogsPanel } from "../../../task-detail/components/TaskLogsPanel"; +import { getTaskPrUrl, useReportTasks } from "../../hooks/useReportTasks"; type Relationship = SignalReportTask["relationship"]; diff --git a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx b/packages/ui/src/features/inbox/components/detail/SignalCard.tsx similarity index 97% rename from apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx rename to packages/ui/src/features/inbox/components/detail/SignalCard.tsx index 47fca1fc6b..48610ea1fc 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx +++ b/packages/ui/src/features/inbox/components/detail/SignalCard.tsx @@ -1,8 +1,3 @@ -import { RelativeTimestamp } from "@components/ui/RelativeTimestamp"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; -import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import { ArrowSquareOutIcon, CaretDownIcon, @@ -10,15 +5,23 @@ import { CheckCircleIcon, TagIcon, } from "@phosphor-icons/react"; -import { Badge, Box, Flex, Text } from "@radix-ui/themes"; -import type { Signal, SignalFindingContent } from "@shared/types"; -import { errorTrackingIssueUrl } from "@utils/posthogLinks"; -import { useCallback, useMemo, useRef, useState } from "react"; +import type { + Signal, + SignalFindingContent, +} from "@posthog/shared/domain-types"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { type SignalInteractionAction, SignalInteractionContext, useSignalInteraction, -} from "./signalInteractionContext"; +} from "@posthog/ui/features/inbox/components/detail/signalInteractionContext"; +import { SOURCE_PRODUCT_META } from "@posthog/ui/features/inbox/components/utils/source-product-icons"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { errorTrackingIssueUrl } from "@posthog/ui/utils/posthogLinks"; +import { Badge, Box, Flex, Text } from "@radix-ui/themes"; +import { useCallback, useMemo, useRef, useState } from "react"; const COLLAPSE_THRESHOLD = 300; diff --git a/apps/code/src/renderer/features/inbox/components/detail/signalInteractionContext.ts b/packages/ui/src/features/inbox/components/detail/signalInteractionContext.ts similarity index 91% rename from apps/code/src/renderer/features/inbox/components/detail/signalInteractionContext.ts rename to packages/ui/src/features/inbox/components/detail/signalInteractionContext.ts index 49812e063b..bc96a9ab34 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/signalInteractionContext.ts +++ b/packages/ui/src/features/inbox/components/detail/signalInteractionContext.ts @@ -1,4 +1,4 @@ -import type { Signal } from "@shared/types"; +import type { Signal } from "@posthog/shared/domain-types"; import { createContext, useContext } from "react"; export type SignalInteractionAction = diff --git a/apps/code/src/renderer/features/inbox/components/list/FilterSortMenu.tsx b/packages/ui/src/features/inbox/components/list/FilterSortMenu.tsx similarity index 96% rename from apps/code/src/renderer/features/inbox/components/list/FilterSortMenu.tsx rename to packages/ui/src/features/inbox/components/list/FilterSortMenu.tsx index ccc8800e58..354025a19d 100644 --- a/apps/code/src/renderer/features/inbox/components/list/FilterSortMenu.tsx +++ b/packages/ui/src/features/inbox/components/list/FilterSortMenu.tsx @@ -1,12 +1,3 @@ -import { PgAnalyzeIcon } from "@features/inbox/components/utils/PgAnalyzeIcon"; -import { - type SourceProduct, - useInboxSignalsFilterStore, -} from "@features/inbox/stores/inboxSignalsFilterStore"; -import { - inboxStatusAccentCss, - inboxStatusLabel, -} from "@features/inbox/utils/inboxSort"; import { BrainIcon, BugIcon, @@ -22,13 +13,20 @@ import { TrendUp, VideoIcon, } from "@phosphor-icons/react"; -import { Box, Flex, Popover, Text } from "@radix-ui/themes"; +import { inboxStatusLabel } from "@posthog/core/inbox/statusLabels"; import type { SignalReportOrderingField, SignalReportStatus, -} from "@shared/types"; +} from "@posthog/shared/domain-types"; +import { Box, Flex, Popover, Text } from "@radix-ui/themes"; import type React from "react"; import type { KeyboardEvent } from "react"; +import { + type SourceProduct, + useInboxSignalsFilterStore, +} from "../../inboxSignalsFilterStore"; +import { inboxStatusAccentCss } from "../../utils/inboxSort"; +import { PgAnalyzeIcon } from "../utils/PgAnalyzeIcon"; type SortOption = { label: string; diff --git a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx b/packages/ui/src/features/inbox/components/list/GitHubConnectionBanner.tsx similarity index 91% rename from apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx rename to packages/ui/src/features/inbox/components/list/GitHubConnectionBanner.tsx index 4db9a02da7..5122005896 100644 --- a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx +++ b/packages/ui/src/features/inbox/components/list/GitHubConnectionBanner.tsx @@ -1,19 +1,19 @@ -import { Button } from "@components/ui/Button"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { + ArrowSquareOutIcon, + GithubLogoIcon, + InfoIcon, +} from "@phosphor-icons/react"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { describeGithubConnectError, useGithubConnect, -} from "@features/integrations/hooks/useGithubUserConnect"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; +} from "@posthog/ui/features/integrations/useGithubUserConnect"; import { useRepositoryIntegration, useUserRepositoryIntegration, -} from "@hooks/useIntegrations"; -import { - ArrowSquareOutIcon, - GithubLogoIcon, - InfoIcon, -} from "@phosphor-icons/react"; +} from "@posthog/ui/features/integrations/useIntegrations"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { Button } from "@posthog/ui/primitives/Button"; import { Spinner } from "@radix-ui/themes"; export function GitHubConnectionBanner() { diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx b/packages/ui/src/features/inbox/components/list/ReportListPane.tsx similarity index 97% rename from apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx rename to packages/ui/src/features/inbox/components/list/ReportListPane.tsx index 8800c8701c..2c14773564 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx +++ b/packages/ui/src/features/inbox/components/list/ReportListPane.tsx @@ -3,10 +3,10 @@ import { CircleNotchIcon, WarningIcon, } from "@phosphor-icons/react"; +import type { SignalReport } from "@posthog/shared/domain-types"; +import { ReportListRow } from "@posthog/ui/features/inbox/components/list/ReportListRow"; import { Box, Button, Flex, Text } from "@radix-ui/themes"; -import type { SignalReport } from "@shared/types"; import { useEffect, useRef } from "react"; -import { ReportListRow } from "./ReportListRow"; // ── LoadMoreTrigger (intersection observer for infinite scroll) ────────────── diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx b/packages/ui/src/features/inbox/components/list/ReportListRow.tsx similarity index 94% rename from apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx rename to packages/ui/src/features/inbox/components/list/ReportListRow.tsx index 21a0462827..629554b65b 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx +++ b/packages/ui/src/features/inbox/components/list/ReportListRow.tsx @@ -1,8 +1,8 @@ -import { ReportCardContent } from "@features/inbox/components/utils/ReportCardContent"; -import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; import { FileTextIcon } from "@phosphor-icons/react"; +import type { SignalReport } from "@posthog/shared/domain-types"; +import { ReportCardContent } from "@posthog/ui/features/inbox/components/utils/ReportCardContent"; +import { SOURCE_PRODUCT_META } from "@posthog/ui/features/inbox/components/utils/source-product-icons"; import { Checkbox, Flex, Tooltip } from "@radix-ui/themes"; -import type { SignalReport } from "@shared/types"; import { motion } from "framer-motion"; import type { KeyboardEvent, MouseEvent, ReactNode } from "react"; diff --git a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx b/packages/ui/src/features/inbox/components/list/SignalsToolbar.tsx similarity index 89% rename from apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx rename to packages/ui/src/features/inbox/components/list/SignalsToolbar.tsx index ad66c46a23..8f799bfff8 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx +++ b/packages/ui/src/features/inbox/components/list/SignalsToolbar.tsx @@ -1,8 +1,3 @@ -import { Button, type ButtonProps } from "@components/ui/Button"; -import { Tooltip as ActionTooltip } from "@components/ui/Tooltip"; -import { useInboxBulkActions } from "@features/inbox/hooks/useInboxBulkActions"; -import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; -import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; import { ArrowClockwiseIcon, DotsThree, @@ -13,12 +8,25 @@ import { ThumbsDownIcon, TrashIcon, } from "@phosphor-icons/react"; +import { + buildBulkActionEvents, + type ReportListSnapshot, + snapshotReportList, +} from "@posthog/core/inbox/reportActionEvents"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@posthog/quill"; +import type { InboxReportActionProperties } from "@posthog/shared/analytics-events"; +import type { SignalReport } from "@posthog/shared/domain-types"; +import { FilterSortMenu } from "@posthog/ui/features/inbox/components/list/FilterSortMenu"; +import { useInboxBulkActions } from "@posthog/ui/features/inbox/hooks/useInboxBulkActions"; +import { useInboxSignalsFilterStore } from "@posthog/ui/features/inbox/inboxSignalsFilterStore"; +import { INBOX_REFETCH_INTERVAL_MS } from "@posthog/ui/features/inbox/utils/inboxConstants"; +import { Button, type ButtonProps } from "@posthog/ui/primitives/Button"; +import { Tooltip as ActionTooltip } from "@posthog/ui/primitives/Tooltip"; import { AlertDialog, Box, @@ -29,11 +37,8 @@ import { TextField, Tooltip, } from "@radix-ui/themes"; -import type { SignalReport } from "@shared/types"; -import type { InboxReportActionProperties } from "@shared/types/analytics"; import type { ReactNode } from "react"; import { useState } from "react"; -import { FilterSortMenu } from "./FilterSortMenu"; import { SuggestedReviewerFilterMenu } from "./SuggestedReviewerFilterMenu"; interface SignalsToolbarProps { @@ -343,76 +348,24 @@ export function SignalsToolbar({ ? "Permanently delete these reports and their signals?" : "Permanently delete this report and its signals?"; - /** - * Snapshot of the visible list captured at action-confirm time, so analytics - * record rank/list_size/priority/actionability as the user saw them — not the - * post-mutation refetch (by then the affected reports are gone). - */ - type ListSnapshotEntry = { - rank: number; - title: string | null; - createdAt: string | null; - priority: string | null; - actionability: string | null; - }; - type ListSnapshot = { - byId: Map<string, ListSnapshotEntry>; - listSize: number; - }; - const snapshotList = (): ListSnapshot => ({ - byId: new Map( - reports.map( - (r, i) => - [ - r.id, - { - rank: i, - title: r.title, - createdAt: r.created_at, - priority: r.priority ?? null, - actionability: r.actionability ?? null, - } satisfies ListSnapshotEntry, - ] as const, - ), - ), - listSize: reports.length, - }); - const fireBulkAction = ( actionType: InboxReportActionProperties["action_type"], targetIds: string[], - snapshot: ListSnapshot, + snapshot: ReportListSnapshot, ) => { if (!onReportAction) return; - const isBulk = targetIds.length > 1; - for (const reportId of targetIds) { - const entry = snapshot.byId.get(reportId); - const createdAt = entry?.createdAt; - const ageMs = createdAt - ? Date.now() - new Date(createdAt).getTime() - : Number.NaN; - const reportAgeHours = Number.isFinite(ageMs) - ? Math.max(0, Math.round((ageMs / 3_600_000) * 10) / 10) - : 0; - onReportAction({ - report_id: reportId, - report_title: entry?.title ?? null, - report_age_hours: reportAgeHours, - action_type: actionType, - surface: "toolbar", - is_bulk: isBulk, - bulk_size: targetIds.length, - rank: entry?.rank ?? -1, - list_size: snapshot.listSize, - priority: entry?.priority ?? null, - actionability: entry?.actionability ?? null, - }); + for (const event of buildBulkActionEvents( + actionType, + targetIds, + snapshot, + )) { + onReportAction(event); } }; const handleConfirmDelete = async () => { const targetIds = [...effectiveBulkIds]; - const snapshot = snapshotList(); + const snapshot = snapshotReportList(reports); const ok = await deleteSelected(); if (ok) { fireBulkAction("delete", targetIds, snapshot); @@ -422,7 +375,7 @@ export function SignalsToolbar({ const handleConfirmSnooze = async () => { const targetIds = [...effectiveBulkIds]; - const snapshot = snapshotList(); + const snapshot = snapshotReportList(reports); const ok = await snoozeSelected(); if (ok) { fireBulkAction("snooze", targetIds, snapshot); @@ -432,7 +385,7 @@ export function SignalsToolbar({ const handleConfirmBulkSuppress = async () => { const targetIds = [...effectiveBulkIds]; - const snapshot = snapshotList(); + const snapshot = snapshotReportList(reports); const ok = await suppressSelected(); if (ok) { fireBulkAction("dismiss", targetIds, snapshot); @@ -442,7 +395,7 @@ export function SignalsToolbar({ const handleReingest = async () => { const targetIds = [...effectiveBulkIds]; - const snapshot = snapshotList(); + const snapshot = snapshotReportList(reports); const ok = await reingestSelected(); if (ok) { fireBulkAction("reingest", targetIds, snapshot); diff --git a/apps/code/src/renderer/features/inbox/components/list/SuggestedReviewerFilterMenu.tsx b/packages/ui/src/features/inbox/components/list/SuggestedReviewerFilterMenu.tsx similarity index 94% rename from apps/code/src/renderer/features/inbox/components/list/SuggestedReviewerFilterMenu.tsx rename to packages/ui/src/features/inbox/components/list/SuggestedReviewerFilterMenu.tsx index 7382b4d8d1..76c4c225a0 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SuggestedReviewerFilterMenu.tsx +++ b/packages/ui/src/features/inbox/components/list/SuggestedReviewerFilterMenu.tsx @@ -1,12 +1,12 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useCurrentUser } from "@features/auth/hooks/authQueries"; -import { useInboxAvailableSuggestedReviewers } from "@features/inbox/hooks/useInboxReports"; -import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; +import { Check, MagnifyingGlass, UsersThree } from "@phosphor-icons/react"; import { buildSuggestedReviewerFilterOptions, getSuggestedReviewerDisplayName, -} from "@features/inbox/utils/suggestedReviewerFilters"; -import { Check, MagnifyingGlass, UsersThree } from "@phosphor-icons/react"; +} from "@posthog/core/inbox/suggestedReviewers"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; +import { useInboxAvailableSuggestedReviewers } from "@posthog/ui/features/inbox/hooks/useInboxReports"; +import { useInboxSignalsFilterStore } from "@posthog/ui/features/inbox/inboxSignalsFilterStore"; import { Box, Flex, Popover, Separator, Spinner, Text } from "@radix-ui/themes"; import { useDeferredValue, useMemo, useState } from "react"; diff --git a/apps/code/src/renderer/features/inbox/components/utils/AnimatedEllipsis.tsx b/packages/ui/src/features/inbox/components/utils/AnimatedEllipsis.tsx similarity index 100% rename from apps/code/src/renderer/features/inbox/components/utils/AnimatedEllipsis.tsx rename to packages/ui/src/features/inbox/components/utils/AnimatedEllipsis.tsx diff --git a/apps/code/src/renderer/features/inbox/components/utils/ExplainedDismissOptionLabels.tsx b/packages/ui/src/features/inbox/components/utils/ExplainedDismissOptionLabels.tsx similarity index 97% rename from apps/code/src/renderer/features/inbox/components/utils/ExplainedDismissOptionLabels.tsx rename to packages/ui/src/features/inbox/components/utils/ExplainedDismissOptionLabels.tsx index 2d7971bf80..5c9ef09153 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/ExplainedDismissOptionLabels.tsx +++ b/packages/ui/src/features/inbox/components/utils/ExplainedDismissOptionLabels.tsx @@ -1,6 +1,6 @@ import { EyeSlashIcon, Pause } from "@phosphor-icons/react"; +import type { DismissalReasonOptionValue } from "@posthog/shared"; import { RadioGroup, Tooltip } from "@radix-ui/themes"; -import type { DismissalReasonOptionValue } from "@shared/dismissalReasons"; import type { ReactNode } from "react"; const PAUSE_OPTION_TOOLTIP = diff --git a/apps/code/src/renderer/features/inbox/components/utils/PgAnalyzeIcon.tsx b/packages/ui/src/features/inbox/components/utils/PgAnalyzeIcon.tsx similarity index 100% rename from apps/code/src/renderer/features/inbox/components/utils/PgAnalyzeIcon.tsx rename to packages/ui/src/features/inbox/components/utils/PgAnalyzeIcon.tsx diff --git a/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx b/packages/ui/src/features/inbox/components/utils/ReportCardContent.tsx similarity index 82% rename from apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx rename to packages/ui/src/features/inbox/components/utils/ReportCardContent.tsx index a6547cfbed..de5148d83f 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx +++ b/packages/ui/src/features/inbox/components/utils/ReportCardContent.tsx @@ -1,12 +1,12 @@ -import { Badge } from "@components/ui/Badge"; -import { ReportImplementationPrLink } from "@features/inbox/components/utils/ReportImplementationPrLink"; -import { SignalReportActionabilityBadge } from "@features/inbox/components/utils/SignalReportActionabilityBadge"; -import { SignalReportPriorityBadge } from "@features/inbox/components/utils/SignalReportPriorityBadge"; -import { SignalReportStatusBadge } from "@features/inbox/components/utils/SignalReportStatusBadge"; -import { SignalReportSummaryMarkdown } from "@features/inbox/components/utils/SignalReportSummaryMarkdown"; import { EyeIcon, LightningIcon } from "@phosphor-icons/react"; +import type { SignalReport } from "@posthog/shared/domain-types"; +import { ReportImplementationPrLink } from "@posthog/ui/features/inbox/components/utils/ReportImplementationPrLink"; +import { SignalReportActionabilityBadge } from "@posthog/ui/features/inbox/components/utils/SignalReportActionabilityBadge"; +import { SignalReportPriorityBadge } from "@posthog/ui/features/inbox/components/utils/SignalReportPriorityBadge"; +import { SignalReportStatusBadge } from "@posthog/ui/features/inbox/components/utils/SignalReportStatusBadge"; +import { SignalReportSummaryMarkdown } from "@posthog/ui/features/inbox/components/utils/SignalReportSummaryMarkdown"; +import { Badge } from "@posthog/ui/primitives/Badge"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; -import type { SignalReport } from "@shared/types"; import type { ReactNode } from "react"; interface ReportCardContentProps { diff --git a/apps/code/src/renderer/features/inbox/components/utils/ReportImplementationPrLink.tsx b/packages/ui/src/features/inbox/components/utils/ReportImplementationPrLink.tsx similarity index 96% rename from apps/code/src/renderer/features/inbox/components/utils/ReportImplementationPrLink.tsx rename to packages/ui/src/features/inbox/components/utils/ReportImplementationPrLink.tsx index f7ebb89c76..1a052defb8 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/ReportImplementationPrLink.tsx +++ b/packages/ui/src/features/inbox/components/utils/ReportImplementationPrLink.tsx @@ -1,6 +1,6 @@ -import { usePrDetails } from "@features/git-interaction/hooks/usePrDetails"; import { GitMerge, GitPullRequestIcon } from "@phosphor-icons/react"; import { cn } from "@posthog/quill"; +import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; import { Tooltip } from "@radix-ui/themes"; export type ImplementationPrLinkSize = "sm" | "md"; diff --git a/apps/code/src/renderer/features/inbox/components/utils/SignalReportActionabilityBadge.tsx b/packages/ui/src/features/inbox/components/utils/SignalReportActionabilityBadge.tsx similarity index 85% rename from apps/code/src/renderer/features/inbox/components/utils/SignalReportActionabilityBadge.tsx rename to packages/ui/src/features/inbox/components/utils/SignalReportActionabilityBadge.tsx index ace539a8fd..d60c6310d0 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/SignalReportActionabilityBadge.tsx +++ b/packages/ui/src/features/inbox/components/utils/SignalReportActionabilityBadge.tsx @@ -1,5 +1,5 @@ -import { Badge } from "@components/ui/Badge"; -import type { SignalReportActionability } from "@shared/types"; +import type { SignalReportActionability } from "@posthog/shared/domain-types"; +import { Badge } from "@posthog/ui/primitives/Badge"; import type { ReactNode } from "react"; const ACTIONABILITY_STYLE: Record< diff --git a/apps/code/src/renderer/features/inbox/components/utils/SignalReportPriorityBadge.tsx b/packages/ui/src/features/inbox/components/utils/SignalReportPriorityBadge.tsx similarity index 81% rename from apps/code/src/renderer/features/inbox/components/utils/SignalReportPriorityBadge.tsx rename to packages/ui/src/features/inbox/components/utils/SignalReportPriorityBadge.tsx index b5ca2b046b..cbab1e8b40 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/SignalReportPriorityBadge.tsx +++ b/packages/ui/src/features/inbox/components/utils/SignalReportPriorityBadge.tsx @@ -1,5 +1,5 @@ -import { Badge } from "@components/ui/Badge"; -import type { SignalReportPriority } from "@shared/types"; +import type { SignalReportPriority } from "@posthog/shared/domain-types"; +import { Badge } from "@posthog/ui/primitives/Badge"; import type { ReactNode } from "react"; type BadgeColor = "red" | "orange" | "amber" | "gray"; diff --git a/apps/code/src/renderer/features/inbox/components/utils/SignalReportStatusBadge.tsx b/packages/ui/src/features/inbox/components/utils/SignalReportStatusBadge.tsx similarity index 88% rename from apps/code/src/renderer/features/inbox/components/utils/SignalReportStatusBadge.tsx rename to packages/ui/src/features/inbox/components/utils/SignalReportStatusBadge.tsx index 01481f448e..7d89dda4ea 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/SignalReportStatusBadge.tsx +++ b/packages/ui/src/features/inbox/components/utils/SignalReportStatusBadge.tsx @@ -1,7 +1,7 @@ -import { Badge } from "@components/ui/Badge"; -import { inboxStatusLabel } from "@features/inbox/utils/inboxSort"; +import { inboxStatusLabel } from "@posthog/core/inbox/statusLabels"; +import type { SignalReportStatus } from "@posthog/shared/domain-types"; +import { Badge } from "@posthog/ui/primitives/Badge"; import { Tooltip } from "@radix-ui/themes"; -import type { SignalReportStatus } from "@shared/types"; const STATUS_TOOLTIPS: Record<string, string> = { ready: "Research is complete. You can create a task from this report.", diff --git a/apps/code/src/renderer/features/inbox/components/utils/SignalReportSummaryMarkdown.tsx b/packages/ui/src/features/inbox/components/utils/SignalReportSummaryMarkdown.tsx similarity index 94% rename from apps/code/src/renderer/features/inbox/components/utils/SignalReportSummaryMarkdown.tsx rename to packages/ui/src/features/inbox/components/utils/SignalReportSummaryMarkdown.tsx index d2c3d81d35..83e9fac03e 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/SignalReportSummaryMarkdown.tsx +++ b/packages/ui/src/features/inbox/components/utils/SignalReportSummaryMarkdown.tsx @@ -1,4 +1,4 @@ -import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { Box } from "@radix-ui/themes"; interface SignalReportSummaryMarkdownProps { diff --git a/apps/code/src/renderer/features/inbox/components/utils/source-product-icons.tsx b/packages/ui/src/features/inbox/components/utils/source-product-icons.tsx similarity index 100% rename from apps/code/src/renderer/features/inbox/components/utils/source-product-icons.tsx rename to packages/ui/src/features/inbox/components/utils/source-product-icons.tsx diff --git a/apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts b/packages/ui/src/features/inbox/devtools/inboxDemoConsole.ts similarity index 93% rename from apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts rename to packages/ui/src/features/inbox/devtools/inboxDemoConsole.ts index 2594cbeee8..eeb53cd586 100644 --- a/apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts +++ b/packages/ui/src/features/inbox/devtools/inboxDemoConsole.ts @@ -1,11 +1,19 @@ +import { resolveService } from "@posthog/di/container"; import type { SignalReport, SignalReportArtefact, SignalReportArtefactsResponse, SignalReportsResponse, -} from "@shared/types"; -import { logger } from "@utils/logger"; -import { queryClient } from "@utils/queryClient"; +} from "@posthog/shared/domain-types"; +import { logger } from "@posthog/ui/workbench/logger"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "@posthog/ui/workbench/queryClient"; + +function queryClientInstance(): ImperativeQueryClient { + return resolveService<ImperativeQueryClient>(IMPERATIVE_QUERY_CLIENT); +} type DemoSeedMode = "rich" | "empty" | "artefacts-unavailable"; type DemoAction = "help" | "seed" | "clear"; @@ -167,6 +175,7 @@ function setInboxDemoData(mode: DemoSeedMode): void { count: reports.length, }; + const queryClient = queryClientInstance(); queryClient.setQueryData(inboxReportsKey, reportsPayload); const existingArtefactQueries = queryClient.getQueriesData({ @@ -187,6 +196,7 @@ function setInboxDemoData(mode: DemoSeedMode): void { } function clearInboxDemoData(): void { + const queryClient = queryClientInstance(); queryClient.removeQueries({ queryKey: ["inbox", "signal-reports"], exact: false, diff --git a/packages/ui/src/features/inbox/hooks/useCreatePrReport.ts b/packages/ui/src/features/inbox/hooks/useCreatePrReport.ts new file mode 100644 index 0000000000..42d224406a --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useCreatePrReport.ts @@ -0,0 +1,145 @@ +import { SIGNAL_REPORT_TASK_SERVICE } from "@posthog/core/inbox/identifiers"; +import type { SignalReportTaskService } from "@posthog/core/inbox/signalReportTaskService"; +import { useService } from "@posthog/di/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; +import { toast } from "@posthog/ui/primitives/toast"; +import { track } from "@posthog/ui/workbench/analytics"; +import { logger } from "@posthog/ui/workbench/logger"; +import { useCallback, useState } from "react"; +import { toast as sonnerToast } from "sonner"; + +const log = logger.scope("create-pr-report"); + +interface UseCreatePrReportOptions { + reportId: string; + reportTitle: string | null; + cloudRepository: string | null; +} + +interface UseCreatePrReportReturn { + /** Create an auto-mode implementation task for the report and navigate to it on success. */ + createPrReport: () => Promise<void>; + /** True while the task is being created. */ + isCreatingPr: boolean; +} + +/** + * Create an implementation (PR) task directly from the inbox detail pane. + * + * Mirrors the Discuss flow: bypasses TaskInput so the user stays on the inbox + * until the task is ready, then jumps straight to the task detail page. The + * agent gets a short prompt that points it at the inbox MCP tools instead of + * inlining the entire report summary. + */ +export function useCreatePrReport({ + reportId, + reportTitle, + cloudRepository, +}: UseCreatePrReportOptions): UseCreatePrReportReturn { + const [isCreatingPr, setIsCreatingPr] = useState(false); + const { navigateToTask } = useNavigationStore(); + const { getUserIntegrationIdForRepo } = useUserRepositoryIntegration(); + const { invalidateTasks } = useCreateTask(); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const service = useService<SignalReportTaskService>( + SIGNAL_REPORT_TASK_SERVICE, + ); + + const createPrReport = useCallback(async () => { + if (isCreatingPr) return; + setIsCreatingPr(true); + const toastId = toast.loading( + "Starting PR task...", + reportTitle ?? undefined, + ); + const settings = useSettingsStore.getState(); + const adapter = settings.lastUsedAdapter ?? "claude"; + + const result = await service.createSignalReportTask( + { + kind: "create-pr", + reportId, + reportTitle, + cloudRepository, + githubUserIntegrationId: cloudRepository + ? (getUserIntegrationIdForRepo(cloudRepository) ?? null) + : null, + cloudRegion, + adapter, + modelOverride: settings.lastUsedModel, + reasoningLevel: settings.lastUsedReasoningEffort ?? undefined, + isDevBuild: import.meta.env.DEV, + }, + (output) => { + invalidateTasks(output.task); + navigateToTask(output.task); + }, + ); + + sonnerToast.dismiss(toastId); + setIsCreatingPr(false); + + switch (result.status) { + case "created": + track(ANALYTICS_EVENTS.TASK_CREATED, { + auto_run: true, + created_from: "command-menu", + repository_provider: "github", + workspace_mode: "cloud", + has_branch: false, + cloud_run_source: "signal_report", + cloud_pr_authorship_mode: "user", + adapter, + }); + return; + case "missing-repository": + toast.error("Pick a cloud repository before creating a PR"); + return; + case "missing-integration": + toast.error("Connect a GitHub integration to create a PR"); + return; + case "not-authenticated": + toast.error("Sign in to create a PR"); + return; + case "missing-model": + toast.error("Failed to start PR task", { + description: + "Couldn't resolve a default model. Open the task page once and pick a model, then try again.", + }); + return; + case "create-failed": + toast.error("Failed to start PR task", { description: result.error }); + log.error("Create PR task creation failed", { + failedStep: result.failedStep, + error: result.error, + reportId, + reportTitle, + }); + return; + case "errored": + toast.error("Failed to start PR task", { description: result.error }); + log.error("Unexpected error during Create PR task creation", { + error: result.error, + reportId, + }); + return; + } + }, [ + isCreatingPr, + cloudRepository, + cloudRegion, + reportId, + reportTitle, + getUserIntegrationIdForRepo, + invalidateTasks, + navigateToTask, + service, + ]); + + return { createPrReport, isCreatingPr }; +} diff --git a/packages/ui/src/features/inbox/hooks/useDiscussReport.test.tsx b/packages/ui/src/features/inbox/hooks/useDiscussReport.test.tsx new file mode 100644 index 0000000000..0b5f134092 --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useDiscussReport.test.tsx @@ -0,0 +1,83 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const createSignalReportTask = vi.hoisted(() => + vi.fn().mockResolvedValue({ status: "created" }), +); +const getUserIntegrationIdForRepo = vi.hoisted(() => vi.fn(() => "ghu_1")); +const navigateToTask = vi.hoisted(() => vi.fn()); + +vi.mock("@posthog/ui/features/auth/store", () => ({ + useAuthStateValue: (sel: (s: { cloudRegion: string }) => unknown) => + sel({ cloudRegion: "us" }), +})); +vi.mock("@posthog/ui/features/integrations/useIntegrations", () => ({ + useUserRepositoryIntegration: () => ({ getUserIntegrationIdForRepo }), +})); +vi.mock("@posthog/ui/features/settings/settingsStore", () => ({ + useSettingsStore: { + getState: () => ({ + lastUsedAdapter: "claude", + lastUsedModel: "claude-sonnet", + lastUsedReasoningEffort: undefined, + }), + }, +})); +vi.mock("@posthog/di/react", () => ({ + useService: () => ({ createSignalReportTask }), +})); +vi.mock("@posthog/ui/features/tasks/useTaskCrudMutations", () => ({ + useCreateTask: () => ({ invalidateTasks: vi.fn() }), +})); +vi.mock("@posthog/ui/features/navigation/store", () => ({ + useNavigationStore: () => ({ navigateToTask }), +})); +vi.mock("@posthog/ui/workbench/analytics", () => ({ track: vi.fn() })); +vi.mock("@posthog/ui/workbench/logger", () => ({ + logger: { scope: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() }) }, +})); +vi.mock("@posthog/ui/primitives/toast", () => ({ + toast: { error: vi.fn(), loading: vi.fn(() => "toast-1") }, +})); +vi.mock("sonner", () => ({ toast: { dismiss: vi.fn() } })); + +import { useDiscussReport } from "./useDiscussReport"; + +describe("useDiscussReport", () => { + beforeEach(() => { + vi.clearAllMocks(); + getUserIntegrationIdForRepo.mockReturnValue("ghu_1"); + createSignalReportTask.mockResolvedValue({ status: "created" }); + }); + + it("forwards a null repository to the service for gating", async () => { + createSignalReportTask.mockResolvedValue({ status: "missing-repository" }); + const { result } = renderHook(() => + useDiscussReport({ + reportId: "r1", + reportTitle: "T", + cloudRepository: null, + }), + ); + await result.current.discussReport("why?"); + expect(createSignalReportTask).toHaveBeenCalledTimes(1); + expect(createSignalReportTask.mock.calls[0][0].cloudRepository).toBeNull(); + }); + + it("creates a cloud signal_report task through the service when valid", async () => { + const { result } = renderHook(() => + useDiscussReport({ + reportId: "r1", + reportTitle: "T", + cloudRepository: "owner/repo", + }), + ); + await result.current.discussReport("why?"); + expect(createSignalReportTask).toHaveBeenCalledTimes(1); + const input = createSignalReportTask.mock.calls[0][0]; + expect(input.kind).toBe("discuss"); + expect(input.reportId).toBe("r1"); + expect(input.cloudRepository).toBe("owner/repo"); + expect(input.githubUserIntegrationId).toBe("ghu_1"); + }); +}); diff --git a/packages/ui/src/features/inbox/hooks/useDiscussReport.ts b/packages/ui/src/features/inbox/hooks/useDiscussReport.ts new file mode 100644 index 0000000000..761790820a --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useDiscussReport.ts @@ -0,0 +1,153 @@ +import { SIGNAL_REPORT_TASK_SERVICE } from "@posthog/core/inbox/identifiers"; +import type { SignalReportTaskService } from "@posthog/core/inbox/signalReportTaskService"; +import { useService } from "@posthog/di/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; +import { toast } from "@posthog/ui/primitives/toast"; +import { track } from "@posthog/ui/workbench/analytics"; +import { logger } from "@posthog/ui/workbench/logger"; +import { useCallback, useState } from "react"; +import { toast as sonnerToast } from "sonner"; + +const log = logger.scope("discuss-report"); + +interface UseDiscussReportOptions { + reportId: string; + reportTitle: string | null; + cloudRepository: string | null; +} + +interface UseDiscussReportReturn { + /** Create a Discuss task for the report and navigate to it on success. */ + discussReport: (question?: string) => Promise<void>; + /** True while a Discuss task is being created. */ + isDiscussing: boolean; +} + +/** + * Create a Discuss task directly from the inbox detail pane. + * + * Bypasses TaskInput entirely so the user stays on the inbox until the task is + * ready, then jumps straight to the task detail page. On failure we surface a + * toast and stay put. + */ +export function useDiscussReport({ + reportId, + reportTitle, + cloudRepository, +}: UseDiscussReportOptions): UseDiscussReportReturn { + const [isDiscussing, setIsDiscussing] = useState(false); + const { navigateToTask } = useNavigationStore(); + const { getUserIntegrationIdForRepo } = useUserRepositoryIntegration(); + const { invalidateTasks } = useCreateTask(); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const service = useService<SignalReportTaskService>( + SIGNAL_REPORT_TASK_SERVICE, + ); + + const discussReport = useCallback( + async (question?: string) => { + if (isDiscussing) return; + setIsDiscussing(true); + const toastId = toast.loading( + "Starting discussion...", + reportTitle ?? undefined, + ); + const settings = useSettingsStore.getState(); + const adapter = settings.lastUsedAdapter ?? "claude"; + + const result = await service.createSignalReportTask( + { + kind: "discuss", + reportId, + reportTitle, + cloudRepository, + githubUserIntegrationId: cloudRepository + ? (getUserIntegrationIdForRepo(cloudRepository) ?? null) + : null, + cloudRegion, + adapter, + modelOverride: settings.lastUsedModel, + reasoningLevel: settings.lastUsedReasoningEffort ?? undefined, + question, + isDevBuild: import.meta.env.DEV, + }, + (output) => { + invalidateTasks(output.task); + navigateToTask(output.task); + }, + ); + + sonnerToast.dismiss(toastId); + setIsDiscussing(false); + + switch (result.status) { + case "created": + track(ANALYTICS_EVENTS.TASK_CREATED, { + auto_run: true, + created_from: "command-menu", + repository_provider: "github", + workspace_mode: "cloud", + has_branch: false, + cloud_run_source: "signal_report", + cloud_pr_authorship_mode: "user", + signal_report_id: reportId, + adapter, + }); + return; + case "missing-repository": + toast.error("Pick a cloud repository before starting a discussion"); + return; + case "missing-integration": + toast.error("Connect a GitHub integration to start a discussion"); + return; + case "not-authenticated": + toast.error("Sign in to start a discussion"); + return; + case "missing-model": + toast.error("Failed to start discussion", { + description: + "Couldn't resolve a default model. Open the task page once and pick a model, then try again.", + }); + return; + case "create-failed": + toast.error("Failed to start discussion", { + description: result.error, + }); + log.error("Discuss task creation failed", { + failedStep: result.failedStep, + error: result.error, + reportId, + reportTitle, + }); + return; + case "errored": + toast.error("Failed to start discussion", { + description: result.error, + }); + log.error("Unexpected error during Discuss task creation", { + error: result.error, + reportId, + }); + return; + } + }, + [ + isDiscussing, + cloudRepository, + cloudRegion, + reportId, + reportTitle, + getUserIntegrationIdForRepo, + invalidateTasks, + navigateToTask, + service, + ], + ); + + return { discussReport, isDiscussing }; +} diff --git a/apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts b/packages/ui/src/features/inbox/hooks/useEvaluations.ts similarity index 58% rename from apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts rename to packages/ui/src/features/inbox/hooks/useEvaluations.ts index dcd207e935..f1c1900b44 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts +++ b/packages/ui/src/features/inbox/hooks/useEvaluations.ts @@ -1,11 +1,11 @@ -import { useAuthStore } from "@features/auth/stores/authStore"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { Evaluation } from "@renderer/api/posthogClient"; +import type { Evaluation } from "@posthog/api-client/posthog-client"; +import { useAuthenticatedQuery } from "../../../hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; const POLL_INTERVAL_MS = 5_000; export function useEvaluations() { - const projectId = useAuthStore((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.projectId); return useAuthenticatedQuery<Evaluation[]>( ["evaluations", projectId], (client) => diff --git a/apps/code/src/renderer/features/inbox/hooks/useExternalDataSources.ts b/packages/ui/src/features/inbox/hooks/useExternalDataSources.ts similarity index 64% rename from apps/code/src/renderer/features/inbox/hooks/useExternalDataSources.ts rename to packages/ui/src/features/inbox/hooks/useExternalDataSources.ts index 55fe227f2d..92da10af99 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useExternalDataSources.ts +++ b/packages/ui/src/features/inbox/hooks/useExternalDataSources.ts @@ -1,6 +1,6 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { ExternalDataSource } from "@renderer/api/posthogClient"; +import type { ExternalDataSource } from "@posthog/api-client/posthog-client"; +import { useAuthenticatedQuery } from "../../../hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; export function useExternalDataSources() { const projectId = useAuthStateValue((state) => state.projectId); diff --git a/packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts b/packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts new file mode 100644 index 0000000000..c1848bfb6b --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useInboxBulkActions.ts @@ -0,0 +1,203 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import type { InboxBulkActionService } from "@posthog/core/inbox/bulkActionService"; +import { + type BulkActionName, + type BulkActionResult, + bulkSelectionKey, + effectiveBulkIdsFromSelection, + formatBulkActionSummary, + getSelectedReportEligibility, + type InboxBulkSelection, +} from "@posthog/core/inbox/bulkActions"; +import { INBOX_BULK_ACTION_SERVICE } from "@posthog/core/inbox/identifiers"; +import { useService } from "@posthog/di/react"; +import type { SignalReport } from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import type { DismissReportDialogResult } from "@posthog/ui/features/inbox/components/DismissReportDialog"; +import { useInboxReportSelectionStore } from "@posthog/ui/features/inbox/inboxReportSelectionStore"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { toast } from "sonner"; + +export type { InboxBulkSelection } from "@posthog/core/inbox/bulkActions"; + +const inboxQueryKey = ["inbox", "signal-reports"] as const; + +/** Snooze disabled reason when `selectedIds` are treated as the bulk selection (matches toolbar logic). */ +export function inboxBulkSnoozeDisabledReason( + reports: SignalReport[], + selectedIds: string[], +): string | null { + return getSelectedReportEligibility(reports, selectedIds) + .snoozeDisabledReason; +} + +/** Suppress/dismiss disabled reason when `selectedIds` are treated as the bulk selection. */ +export function inboxBulkSuppressDisabledReason( + reports: SignalReport[], + selectedIds: string[], +): string | null { + return getSelectedReportEligibility(reports, selectedIds) + .suppressDisabledReason; +} + +export function useInboxBulkActions( + reports: SignalReport[], + selection: InboxBulkSelection, +) { + const queryClient = useQueryClient(); + const service = useService<InboxBulkActionService>(INBOX_BULK_ACTION_SERVICE); + const client = useOptionalAuthenticatedClient(); + const clearSelection = useInboxReportSelectionStore( + (state) => state.clearSelection, + ); + + const effectiveBulkIds = effectiveBulkIdsFromSelection(selection); + + // biome-ignore lint/correctness/useExhaustiveDependencies: `bulkKeys` serializes selection so callers may pass fresh array literals (or a lone id) without busting this memo. + const eligibility = useMemo( + () => getSelectedReportEligibility(reports, effectiveBulkIds), + [reports, bulkSelectionKey(selection)], + ); + + const settle = useCallback( + async (action: BulkActionName, result: BulkActionResult) => { + await queryClient.invalidateQueries({ + queryKey: inboxQueryKey, + exact: false, + }); + clearSelection(); + const message = formatBulkActionSummary(action, result); + if (result.failureCount > 0) { + toast.error(message); + return; + } + toast.success(message); + }, + [queryClient, clearSelection], + ); + + const run = useCallback( + ( + action: BulkActionName, + perform: ( + client: PostHogAPIClient, + reportIds: string[], + ) => Promise<BulkActionResult>, + ) => + async (reportIds: string[]) => { + if (!client) { + throw new Error("Not authenticated"); + } + const result = await perform(client, reportIds); + await settle(action, result); + return result; + }, + [client, settle], + ); + + const suppressMutation = useMutation({ + mutationFn: (input: { + reportIds: string[]; + dismissal?: DismissReportDialogResult; + }) => { + if (!client) { + throw new Error("Not authenticated"); + } + return service + .suppressReports(client, input.reportIds, input.dismissal) + .then(async (result) => { + await settle("suppress", result); + return result; + }); + }, + onError: (error: Error) => + toast.error(error.message || "Failed to dismiss reports"), + }); + const snoozeMutation = useMutation({ + mutationFn: run("snooze", (c, ids) => service.snoozeReports(c, ids)), + onError: (error: Error) => + toast.error(error.message || "Failed to snooze reports"), + }); + const deleteMutation = useMutation({ + mutationFn: run("delete", (c, ids) => service.deleteReports(c, ids)), + onError: (error: Error) => + toast.error(error.message || "Failed to delete reports"), + }); + const reingestMutation = useMutation({ + mutationFn: run("reingest", (c, ids) => service.reingestReports(c, ids)), + onError: (error: Error) => + toast.error(error.message || "Failed to reingest reports"), + }); + + const suppressSelected = useCallback( + async (dismissal?: DismissReportDialogResult) => { + if (eligibility.suppressDisabledReason !== null) { + return false; + } + await suppressMutation.mutateAsync({ + reportIds: eligibility.selectedIds, + ...(dismissal != null ? { dismissal } : {}), + }); + return true; + }, + [ + eligibility.suppressDisabledReason, + eligibility.selectedIds, + suppressMutation, + ], + ); + + const snoozeSelected = useCallback(async () => { + if (eligibility.snoozeDisabledReason !== null) { + return false; + } + await snoozeMutation.mutateAsync(eligibility.selectedIds); + return true; + }, [ + eligibility.snoozeDisabledReason, + eligibility.selectedIds, + snoozeMutation, + ]); + + const deleteSelected = useCallback(async () => { + if (eligibility.deleteDisabledReason !== null) { + return false; + } + await deleteMutation.mutateAsync(eligibility.selectedIds); + return true; + }, [ + eligibility.deleteDisabledReason, + eligibility.selectedIds, + deleteMutation, + ]); + + const reingestSelected = useCallback(async () => { + if (eligibility.reingestDisabledReason !== null) { + return false; + } + await reingestMutation.mutateAsync(eligibility.selectedIds); + return true; + }, [ + eligibility.reingestDisabledReason, + eligibility.selectedIds, + reingestMutation, + ]); + + return { + selectedReports: eligibility.selectedReports, + selectedCount: eligibility.selectedCount, + snoozeDisabledReason: eligibility.snoozeDisabledReason, + suppressDisabledReason: eligibility.suppressDisabledReason, + deleteDisabledReason: eligibility.deleteDisabledReason, + reingestDisabledReason: eligibility.reingestDisabledReason, + isSuppressing: suppressMutation.isPending, + isSnoozing: snoozeMutation.isPending, + isDeleting: deleteMutation.isPending, + isReingesting: reingestMutation.isPending, + suppressSelected, + snoozeSelected, + deleteSelected, + reingestSelected, + }; +} diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts b/packages/ui/src/features/inbox/hooks/useInboxDeepLink.ts similarity index 75% rename from apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts rename to packages/ui/src/features/inbox/hooks/useInboxDeepLink.ts index aa619e4373..72c98c7d07 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxDeepLink.ts @@ -1,19 +1,16 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { - AUTH_SCOPED_QUERY_META, - useAuthStateValue, -} from "@features/auth/hooks/authQueries"; -import { reportKeys } from "@features/inbox/hooks/useInboxReports"; -import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; -import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; -import { setPendingInboxOpenMethod } from "@features/inbox/utils/pendingInboxOpenMethod"; -import { trpcClient, useTRPC } from "@renderer/trpc"; -import { useNavigationStore } from "@stores/navigationStore"; +import { useHostTRPCClient } from "@posthog/host-router/react"; import { useQueryClient } from "@tanstack/react-query"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { logger } from "@utils/logger"; import { useCallback, useEffect, useRef } from "react"; import { toast } from "sonner"; +import { logger } from "../../../workbench/logger"; +import { useOptionalAuthenticatedClient } from "../../auth/authClient"; +import { useAuthStateValue } from "../../auth/store"; +import { AUTH_SCOPED_QUERY_META } from "../../auth/useCurrentUser"; +import { useNavigationStore } from "../../navigation/store"; +import { useInboxReportSelectionStore } from "../inboxReportSelectionStore"; +import { useInboxSignalsFilterStore } from "../inboxSignalsFilterStore"; +import { setPendingInboxOpenMethod } from "../utils/pendingInboxOpenMethod"; +import { reportKeys } from "./useInboxReports"; const log = logger.scope("inbox-deep-link"); @@ -32,7 +29,7 @@ const log = logger.scope("inbox-deep-link"); * navigate to the inbox view, and select the report id. */ export function useInboxDeepLink() { - const trpcReact = useTRPC(); + const hostClient = useHostTRPCClient(); const queryClient = useQueryClient(); const client = useOptionalAuthenticatedClient(); const isAuthenticated = useAuthStateValue( @@ -91,19 +88,20 @@ export function useInboxDeepLink() { pendingDrainedRef.current = true; void (async () => { try { - const pending = await trpcClient.deepLink.getPendingReportLink.query(); + const pending = await hostClient.deepLink.getPendingReportLink.query(); if (pending) await openReport(pending.reportId); } catch (error) { log.error("Failed to check for pending inbox deep link:", error); } })(); - }, [isAuthenticated, client, openReport]); + }, [isAuthenticated, client, openReport, hostClient]); - useSubscription( - trpcReact.deepLink.onOpenReport.subscriptionOptions(undefined, { + useEffect(() => { + const subscription = hostClient.deepLink.onOpenReport.subscribe(undefined, { onData: (data) => { if (data?.reportId) void openReport(data.reportId); }, - }), - ); + }); + return () => subscription.unsubscribe(); + }, [hostClient, openReport]); } diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLinkListSync.ts b/packages/ui/src/features/inbox/hooks/useInboxDeepLinkListSync.ts similarity index 90% rename from apps/code/src/renderer/features/inbox/hooks/useInboxDeepLinkListSync.ts rename to packages/ui/src/features/inbox/hooks/useInboxDeepLinkListSync.ts index f72561aed4..105db21378 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLinkListSync.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxDeepLinkListSync.ts @@ -1,8 +1,8 @@ -import { useInboxReportById } from "@features/inbox/hooks/useInboxReports"; -import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; -import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; -import type { SignalReport } from "@shared/types"; +import type { SignalReport } from "@posthog/shared/domain-types"; import { useEffect, useMemo, useRef } from "react"; +import { useInboxReportSelectionStore } from "../inboxReportSelectionStore"; +import { INBOX_REFETCH_INTERVAL_MS } from "../utils/inboxConstants"; +import { useInboxReportById } from "./useInboxReports"; /** * Keeps inbox list selection in sync when the selected report is not on the diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts b/packages/ui/src/features/inbox/hooks/useInboxEngagementTracker.ts similarity index 74% rename from apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts rename to packages/ui/src/features/inbox/hooks/useInboxEngagementTracker.ts index a4de5c8f50..ea3f79afe5 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxEngagementTracker.ts @@ -1,12 +1,16 @@ -import { consumePendingInboxOpenMethod } from "@features/inbox/utils/pendingInboxOpenMethod"; -import type { SignalReport } from "@shared/types"; +import { + reportAgeHours, + resolveActionProperties, +} from "@posthog/core/inbox/engagement"; import { ANALYTICS_EVENTS, type InboxReportActionProperties, type InboxReportCloseMethod, -} from "@shared/types/analytics"; -import { track } from "@utils/analytics"; +} from "@posthog/shared/analytics-events"; +import type { SignalReport } from "@posthog/shared/domain-types"; import { useCallback, useEffect, useRef } from "react"; +import { track } from "../../../workbench/analytics"; +import { consumePendingInboxOpenMethod } from "../utils/pendingInboxOpenMethod"; interface OpenInfo { reportId: string; @@ -20,14 +24,6 @@ interface OpenInfo { hasScrolled: boolean; } -/** Report age at fire time in hours, rounded to one decimal. Clamped at 0 to guard against clock skew. */ -function reportAgeHours(createdAt: string | null | undefined): number { - if (!createdAt) return 0; - const ageMs = Date.now() - new Date(createdAt).getTime(); - if (!Number.isFinite(ageMs)) return 0; - return Math.max(0, Math.round((ageMs / 3_600_000) * 10) / 10); -} - export interface InboxEngagementTracker { /** Fires INBOX_REPORT_SCROLLED once per open on the first scroll inside the detail pane. */ signalScroll(): void; @@ -186,7 +182,6 @@ export function useInboxEngagementTracker( }, ) => { const info = openInfoRef.current; - const visibleReports = reportsRef.current; const { rank: rankOverride, list_size: listSizeOverride, @@ -194,43 +189,25 @@ export function useInboxEngagementTracker( actionability: actionabilityOverride, ...rest } = action; - // Prefer the live open-info snapshot for the current report; otherwise - // fall back to a one-shot visible-list lookup. Callers firing after an - // async mutation should pass pre-mutation overrides — by then the visible - // list has been re-queried without the affected report. - const currentInfo = - info && info.reportId === action.report_id ? info : null; - const matchedReport = currentInfo - ? null - : (visibleReports.find((r) => r.id === action.report_id) ?? null); - const rank = - rankOverride !== undefined - ? rankOverride - : currentInfo - ? currentInfo.rank - : visibleReports.findIndex((r) => r.id === action.report_id); - const listSize = - listSizeOverride !== undefined - ? listSizeOverride - : visibleReports.length; - const priority = - priorityOverride !== undefined - ? priorityOverride - : currentInfo - ? currentInfo.reportPriority - : (matchedReport?.priority ?? null); - const actionability = - actionabilityOverride !== undefined - ? actionabilityOverride - : currentInfo - ? currentInfo.reportActionability - : (matchedReport?.actionability ?? null); + const resolved = resolveActionProperties({ + reportId: action.report_id, + rankOverride, + listSizeOverride, + priorityOverride, + actionabilityOverride, + openSnapshot: info + ? { + reportId: info.reportId, + rank: info.rank, + reportPriority: info.reportPriority, + reportActionability: info.reportActionability, + } + : null, + visibleReports: reportsRef.current, + }); track(ANALYTICS_EVENTS.INBOX_REPORT_ACTION, { ...rest, - rank, - list_size: listSize, - priority, - actionability, + ...resolved, }); }, [], diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts b/packages/ui/src/features/inbox/hooks/useInboxReports.ts similarity index 93% rename from apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts rename to packages/ui/src/features/inbox/hooks/useInboxReports.ts index 8ba010385c..5c09e96107 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxReports.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxReports.ts @@ -1,10 +1,3 @@ -import { - getAuthIdentity, - useAuthStateValue, -} from "@features/auth/hooks/authQueries"; -import { useInboxAvailableSuggestedReviewersStore } from "@features/inbox/stores/inboxAvailableSuggestedReviewersStore"; -import { useAuthenticatedInfiniteQuery } from "@hooks/useAuthenticatedInfiniteQuery"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { AvailableSuggestedReviewersResponse, SignalProcessingStateResponse, @@ -13,8 +6,12 @@ import type { SignalReportSignalsResponse, SignalReportsQueryParams, SignalReportsResponse, -} from "@shared/types"; +} from "@posthog/shared/domain-types"; import { useEffect, useMemo } from "react"; +import { useAuthenticatedInfiniteQuery } from "../../../hooks/useAuthenticatedInfiniteQuery"; +import { useAuthenticatedQuery } from "../../../hooks/useAuthenticatedQuery"; +import { getAuthIdentity, useAuthStateValue } from "../../auth/store"; +import { useInboxAvailableSuggestedReviewersStore } from "../inboxAvailableSuggestedReviewersStore"; const REPORTS_PAGE_SIZE = 100; diff --git a/packages/ui/src/features/inbox/hooks/useReportTasks.ts b/packages/ui/src/features/inbox/hooks/useReportTasks.ts new file mode 100644 index 0000000000..c2cd155ff5 --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useReportTasks.ts @@ -0,0 +1,43 @@ +import { + type ReportTaskData, + selectDisplayedReportTasks, + sortByRelationship, +} from "@posthog/core/inbox/reportTasks"; +import type { SignalReportStatus, Task } from "@posthog/shared/domain-types"; +import { useAuthenticatedQuery } from "../../../hooks/useAuthenticatedQuery"; + +export { getTaskPrUrl } from "@posthog/core/inbox/reportTasks"; + +export function useReportTasks( + reportId: string, + reportStatus: SignalReportStatus, +) { + const isActive = + reportStatus === "candidate" || + reportStatus === "in_progress" || + reportStatus === "pending_input"; + + return useAuthenticatedQuery<ReportTaskData[]>( + ["inbox", "report-tasks", reportId], + async (client) => { + const reportTasks = await client.getSignalReportTasks(reportId); + const relevant = selectDisplayedReportTasks(reportTasks); + const tasks = await Promise.all( + relevant.map(async (rt) => { + const task = (await client.getTask(rt.task_id)) as unknown as Task; + return { + task, + relationship: rt.relationship, + startedAt: rt.created_at, + }; + }), + ); + return sortByRelationship(tasks); + }, + { + enabled: !!reportId, + staleTime: isActive ? 5_000 : 10_000, + refetchInterval: isActive ? 5_000 : false, + }, + ); +} diff --git a/apps/code/src/renderer/features/inbox/hooks/useSeedSuggestedReviewerFilter.test.ts b/packages/ui/src/features/inbox/hooks/useSeedSuggestedReviewerFilter.test.ts similarity index 95% rename from apps/code/src/renderer/features/inbox/hooks/useSeedSuggestedReviewerFilter.test.ts rename to packages/ui/src/features/inbox/hooks/useSeedSuggestedReviewerFilter.test.ts index 4125a2a93c..8573fdf6f4 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSeedSuggestedReviewerFilter.test.ts +++ b/packages/ui/src/features/inbox/hooks/useSeedSuggestedReviewerFilter.test.ts @@ -1,6 +1,6 @@ -import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest"; +import { useInboxSignalsFilterStore } from "../inboxSignalsFilterStore"; import { useSeedSuggestedReviewerFilter } from "./useSeedSuggestedReviewerFilter"; describe("useSeedSuggestedReviewerFilter", () => { diff --git a/apps/code/src/renderer/features/inbox/hooks/useSeedSuggestedReviewerFilter.ts b/packages/ui/src/features/inbox/hooks/useSeedSuggestedReviewerFilter.ts similarity index 89% rename from apps/code/src/renderer/features/inbox/hooks/useSeedSuggestedReviewerFilter.ts rename to packages/ui/src/features/inbox/hooks/useSeedSuggestedReviewerFilter.ts index cdd8c9bf5d..a1f47c6469 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSeedSuggestedReviewerFilter.ts +++ b/packages/ui/src/features/inbox/hooks/useSeedSuggestedReviewerFilter.ts @@ -1,5 +1,5 @@ -import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { useEffect } from "react"; +import { useInboxSignalsFilterStore } from "../inboxSignalsFilterStore"; /** * Seeds the inbox suggested-reviewer filter with the current user on first diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceConfigs.ts b/packages/ui/src/features/inbox/hooks/useSignalSourceConfigs.ts similarity index 64% rename from apps/code/src/renderer/features/inbox/hooks/useSignalSourceConfigs.ts rename to packages/ui/src/features/inbox/hooks/useSignalSourceConfigs.ts index 6cc50445bc..b1277bb521 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceConfigs.ts +++ b/packages/ui/src/features/inbox/hooks/useSignalSourceConfigs.ts @@ -1,6 +1,6 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { SignalSourceConfig } from "@renderer/api/posthogClient"; +import type { SignalSourceConfig } from "@posthog/api-client/posthog-client"; +import { useAuthenticatedQuery } from "../../../hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; export function useSignalSourceConfigs() { const projectId = useAuthStateValue((state) => state.projectId); diff --git a/packages/ui/src/features/inbox/hooks/useSignalSourceManager.ts b/packages/ui/src/features/inbox/hooks/useSignalSourceManager.ts new file mode 100644 index 0000000000..228bc8acd1 --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useSignalSourceManager.ts @@ -0,0 +1,374 @@ +import type { + Evaluation, + SignalSourceConfig, +} from "@posthog/api-client/posthog-client"; +import { SIGNAL_SOURCE_SERVICE } from "@posthog/core/inbox/identifiers"; +import { + computeSourceValues, + deriveSourceStates, + type SignalSourceService, + type SignalSourceValues, +} from "@posthog/core/inbox/signalSourceService"; +import { useService } from "@posthog/di/react"; +import { getCloudUrlFromRegion } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import type { SignalUserAutonomyConfig } from "@posthog/shared/domain-types"; +import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { track } from "@posthog/ui/workbench/analytics"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useEvaluations } from "./useEvaluations"; +import { useExternalDataSources } from "./useExternalDataSources"; +import { useSignalSourceConfigs } from "./useSignalSourceConfigs"; +import { useSignalTeamConfig } from "./useSignalTeamConfig"; +import { useSignalUserAutonomyConfig } from "./useSignalUserAutonomyConfig"; + +type WarehouseSource = "github" | "linear" | "zendesk" | "pganalyze"; + +function isWarehouseSource( + source: keyof SignalSourceValues, +): source is WarehouseSource { + return ( + source === "github" || + source === "linear" || + source === "zendesk" || + source === "pganalyze" + ); +} + +export function useSignalSourceManager() { + const projectId = useAuthStateValue((state) => state.projectId); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const client = useAuthenticatedClient(); + const queryClient = useQueryClient(); + const service = useService<SignalSourceService>(SIGNAL_SOURCE_SERVICE); + const { data: configs, isLoading: configsLoading } = useSignalSourceConfigs(); + const { data: externalSources, isLoading: sourcesLoading } = + useExternalDataSources(); + const { data: evaluations } = useEvaluations(); + const { data: teamConfig } = useSignalTeamConfig(); + const { data: userAutonomyConfig, isLoading: userAutonomyConfigLoading } = + useSignalUserAutonomyConfig(); + + const [optimistic, setOptimistic] = useState< + Partial<Record<keyof SignalSourceValues, boolean>> + >({}); + const [setupSource, setSetupSource] = useState<WarehouseSource | null>(null); + const [loadingSources, setLoadingSources] = useState< + Partial<Record<keyof SignalSourceValues, boolean>> + >({}); + + const isLoading = configsLoading || sourcesLoading; + + const serverValues = useMemo(() => computeSourceValues(configs), [configs]); + + const displayValues = useMemo<SignalSourceValues>(() => { + if (Object.keys(optimistic).length === 0) return serverValues; + return { ...serverValues, ...optimistic }; + }, [serverValues, optimistic]); + + const sourceStates = useMemo(() => { + const derived = deriveSourceStates(configs, externalSources); + const states: Partial< + Record< + keyof SignalSourceValues, + { + requiresSetup: boolean; + loading: boolean; + syncStatus?: SignalSourceConfig["status"]; + } + > + > = {}; + for (const product of Object.keys( + derived, + ) as (keyof SignalSourceValues)[]) { + const state = derived[product]; + if (state) { + states[product] = { + requiresSetup: state.requiresSetup, + loading: !!loadingSources[product], + syncStatus: state.syncStatus, + }; + } + } + return states; + }, [configs, externalSources, loadingSources]); + + const evaluationsUrl = useMemo(() => { + if (!cloudRegion) return ""; + return `${getCloudUrlFromRegion(cloudRegion)}/llm-analytics/evaluations`; + }, [cloudRegion]); + + const [optimisticEvals, setOptimisticEvals] = useState< + Record<string, boolean> + >({}); + + const displayEvaluations = useMemo<Evaluation[]>(() => { + if (!evaluations) return []; + if (Object.keys(optimisticEvals).length === 0) return evaluations; + return evaluations.map((e) => + e.id in optimisticEvals ? { ...e, enabled: optimisticEvals[e.id] } : e, + ); + }, [evaluations, optimisticEvals]); + + const handleToggleEvaluation = useCallback( + async (evaluationId: string, enabled: boolean) => { + if (!client || !projectId) return; + setOptimisticEvals((prev) => ({ ...prev, [evaluationId]: enabled })); + try { + await service.toggleEvaluation( + client, + projectId, + evaluationId, + enabled, + ); + await queryClient.invalidateQueries({ queryKey: ["evaluations"] }); + } catch (error: unknown) { + toast.error( + error instanceof Error + ? error.message + : "Failed to toggle evaluation", + ); + } finally { + setOptimisticEvals((prev) => { + const next = { ...prev }; + delete next[evaluationId]; + return next; + }); + } + }, + [client, projectId, queryClient, service], + ); + + const invalidateAfterToggle = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["signals", "source-configs"], + }), + queryClient.invalidateQueries({ queryKey: ["inbox", "signal-reports"] }), + ]); + }, [queryClient]); + + const handleToggle = useCallback( + async (product: keyof SignalSourceValues, enabled: boolean) => { + if (!client || !projectId) return; + + const willSetup = + enabled && + isWarehouseSource(product) && + service.requiresSetup(product, externalSources); + if (willSetup) { + setSetupSource(product as WarehouseSource); + return; + } + + const isSyncing = enabled && isWarehouseSource(product); + if (isSyncing) { + setLoadingSources((prev) => ({ ...prev, [product]: true })); + } + setOptimistic((prev) => ({ ...prev, [product]: enabled })); + + try { + const result = await service.toggleSource( + client, + projectId, + product, + enabled, + configs, + externalSources, + ); + if (enabled) { + track(ANALYTICS_EVENTS.SIGNAL_SOURCE_CONNECTED, { + source_product: product, + is_first_connection: result.isFirstConnection, + via_setup_wizard: false, + }); + } + await invalidateAfterToggle(); + } catch (error: unknown) { + toast.error( + error instanceof Error + ? error.message + : `Failed to toggle ${product}`, + ); + } finally { + if (isSyncing) { + setLoadingSources((prev) => ({ ...prev, [product]: false })); + } + setOptimistic((prev) => { + const next = { ...prev }; + delete next[product]; + return next; + }); + } + }, + [ + client, + projectId, + configs, + externalSources, + invalidateAfterToggle, + service, + ], + ); + + const handleSetup = useCallback((source: keyof SignalSourceValues) => { + if (isWarehouseSource(source)) { + setSetupSource(source); + } + }, []); + + const handleSetupComplete = useCallback(async () => { + const completedSource = setupSource; + setSetupSource(null); + + if (completedSource && client && projectId) { + try { + const result = await service.completeSetup( + client, + projectId, + completedSource, + configs, + ); + track(ANALYTICS_EVENTS.SIGNAL_SOURCE_CONNECTED, { + source_product: completedSource, + is_first_connection: result.isFirstConnection, + via_setup_wizard: true, + }); + } catch { + toast.error( + "Data source connected, but failed to enable signal source. Try toggling it on.", + ); + } + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["external-data-sources"] }), + queryClient.invalidateQueries({ + queryKey: ["signals", "source-configs"], + }), + queryClient.invalidateQueries({ queryKey: ["inbox", "signal-reports"] }), + ]); + }, [queryClient, setupSource, configs, client, projectId, service]); + + const handleSetupCancel = useCallback(() => { + setSetupSource(null); + }, []); + + const handleUpdateAutostartPriority = useCallback( + async (priority: string) => { + if (!client) return; + try { + await service.updateAutostartPriority(client, priority); + await queryClient.invalidateQueries({ + queryKey: ["signals", "team-config"], + }); + } catch (error: unknown) { + toast.error( + error instanceof Error + ? error.message + : "Failed to update autostart priority", + ); + } + }, + [client, queryClient, service], + ); + + const handleUpdateUserAutonomyPriority = useCallback( + async (priority: string | null) => { + if (!client) return; + try { + await service.updateUserAutonomyPriority(client, priority); + await queryClient.invalidateQueries({ + queryKey: ["signals", "user-autonomy-config"], + }); + } catch (error: unknown) { + toast.error( + error instanceof Error + ? error.message + : "Failed to update autonomy setting", + ); + } + }, + [client, queryClient, service], + ); + + const handleUpdateSlackNotifications = useCallback( + async (updates: { + integrationId?: number | null; + channel?: string | null; + minPriority?: string | null; + }) => { + if (!client) return; + + const queryKey = ["signals", "user-autonomy-config"]; + const previous = + queryClient.getQueryData<SignalUserAutonomyConfig | null>(queryKey); + + const optimisticNext: SignalUserAutonomyConfig = { + ...(previous ?? + ({ autostart_priority: null } as SignalUserAutonomyConfig)), + ...("integrationId" in updates + ? { slack_notification_integration_id: updates.integrationId ?? null } + : {}), + ...("channel" in updates + ? { slack_notification_channel: updates.channel ?? null } + : {}), + ...("minPriority" in updates + ? { + slack_notification_min_priority: + (updates.minPriority as + | SignalUserAutonomyConfig["slack_notification_min_priority"] + | null + | undefined) ?? null, + } + : {}), + }; + queryClient.setQueryData<SignalUserAutonomyConfig | null>( + queryKey, + optimisticNext, + ); + + try { + const fresh = await service.updateSlackNotifications(client, updates); + queryClient.setQueryData<SignalUserAutonomyConfig | null>( + queryKey, + fresh, + ); + } catch (error: unknown) { + queryClient.setQueryData<SignalUserAutonomyConfig | null>( + queryKey, + previous ?? null, + ); + toast.error( + error instanceof Error + ? error.message + : "Failed to update Slack notification setting", + ); + } + }, + [client, queryClient, service], + ); + + return { + displayValues, + sourceStates, + setupSource, + isLoading, + handleToggle, + handleSetup, + handleSetupComplete, + handleSetupCancel, + evaluations: displayEvaluations, + evaluationsUrl, + handleToggleEvaluation, + teamConfig, + handleUpdateAutostartPriority, + userAutonomyConfig, + userAutonomyConfigLoading, + handleUpdateUserAutonomyPriority, + handleUpdateSlackNotifications, + }; +} diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalTeamConfig.ts b/packages/ui/src/features/inbox/hooks/useSignalTeamConfig.ts similarity index 76% rename from apps/code/src/renderer/features/inbox/hooks/useSignalTeamConfig.ts rename to packages/ui/src/features/inbox/hooks/useSignalTeamConfig.ts index 1183d82dea..f364911d80 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalTeamConfig.ts +++ b/packages/ui/src/features/inbox/hooks/useSignalTeamConfig.ts @@ -1,5 +1,5 @@ -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { SignalTeamConfig } from "@shared/types"; +import type { SignalTeamConfig } from "@posthog/shared/domain-types"; +import { useAuthenticatedQuery } from "../../../hooks/useAuthenticatedQuery"; export function useSignalTeamConfig(options?: { enabled?: boolean; diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalUserAutonomyConfig.ts b/packages/ui/src/features/inbox/hooks/useSignalUserAutonomyConfig.ts similarity index 77% rename from apps/code/src/renderer/features/inbox/hooks/useSignalUserAutonomyConfig.ts rename to packages/ui/src/features/inbox/hooks/useSignalUserAutonomyConfig.ts index 39b29fde51..be55669bdd 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalUserAutonomyConfig.ts +++ b/packages/ui/src/features/inbox/hooks/useSignalUserAutonomyConfig.ts @@ -1,5 +1,5 @@ -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { SignalUserAutonomyConfig } from "@shared/types"; +import type { SignalUserAutonomyConfig } from "@posthog/shared/domain-types"; +import { useAuthenticatedQuery } from "../../../hooks/useAuthenticatedQuery"; export function useSignalUserAutonomyConfig(options?: { enabled?: boolean; diff --git a/apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts b/packages/ui/src/features/inbox/hooks/useSlackChannels.ts similarity index 91% rename from apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts rename to packages/ui/src/features/inbox/hooks/useSlackChannels.ts index 49c6f167f7..e5fd56f079 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts +++ b/packages/ui/src/features/inbox/hooks/useSlackChannels.ts @@ -1,8 +1,8 @@ -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { SlackChannelsQueryParams, SlackChannelsResponse, -} from "@shared/types"; +} from "@posthog/shared/domain-types"; +import { useAuthenticatedQuery } from "../../../hooks/useAuthenticatedQuery"; const DEFAULT_CHANNEL_PAGE_SIZE = 50; diff --git a/apps/code/src/renderer/features/inbox/stores/inboxAvailableSuggestedReviewersStore.ts b/packages/ui/src/features/inbox/inboxAvailableSuggestedReviewersStore.ts similarity index 96% rename from apps/code/src/renderer/features/inbox/stores/inboxAvailableSuggestedReviewersStore.ts rename to packages/ui/src/features/inbox/inboxAvailableSuggestedReviewersStore.ts index 89129fc0ac..742bc2cc76 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxAvailableSuggestedReviewersStore.ts +++ b/packages/ui/src/features/inbox/inboxAvailableSuggestedReviewersStore.ts @@ -1,4 +1,4 @@ -import type { AvailableSuggestedReviewer } from "@shared/types"; +import type { AvailableSuggestedReviewer } from "@posthog/shared"; import { create } from "zustand"; import { persist } from "zustand/middleware"; diff --git a/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.test.ts b/packages/ui/src/features/inbox/inboxReportSelectionStore.test.ts similarity index 100% rename from apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.test.ts rename to packages/ui/src/features/inbox/inboxReportSelectionStore.test.ts diff --git a/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.ts b/packages/ui/src/features/inbox/inboxReportSelectionStore.ts similarity index 100% rename from apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.ts rename to packages/ui/src/features/inbox/inboxReportSelectionStore.ts diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts b/packages/ui/src/features/inbox/inboxSignalsFilterStore.test.ts similarity index 100% rename from apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts rename to packages/ui/src/features/inbox/inboxSignalsFilterStore.test.ts diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts b/packages/ui/src/features/inbox/inboxSignalsFilterStore.ts similarity index 99% rename from apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts rename to packages/ui/src/features/inbox/inboxSignalsFilterStore.ts index 51338816dd..192755cba3 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts +++ b/packages/ui/src/features/inbox/inboxSignalsFilterStore.ts @@ -1,7 +1,7 @@ import type { SignalReportOrderingField, SignalReportStatus, -} from "@shared/types"; +} from "@posthog/shared"; import { create } from "zustand"; import { persist } from "zustand/middleware"; diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSourcesDialogStore.ts b/packages/ui/src/features/inbox/inboxSourcesDialogStore.ts similarity index 100% rename from apps/code/src/renderer/features/inbox/stores/inboxSourcesDialogStore.ts rename to packages/ui/src/features/inbox/inboxSourcesDialogStore.ts diff --git a/packages/ui/src/features/inbox/stores/inboxCloudTaskStore.ts b/packages/ui/src/features/inbox/stores/inboxCloudTaskStore.ts new file mode 100644 index 0000000000..167e2c75eb --- /dev/null +++ b/packages/ui/src/features/inbox/stores/inboxCloudTaskStore.ts @@ -0,0 +1,32 @@ +import { create } from "zustand"; + +interface InboxCloudTaskStoreState { + isRunning: boolean; + showConfirm: boolean; + selectedRepo: string | null; +} + +interface InboxCloudTaskStoreActions { + openConfirm: (defaultRepo: string | null) => void; + closeConfirm: () => void; + setSelectedRepo: (repo: string | null) => void; + setIsRunning: (isRunning: boolean) => void; +} + +type InboxCloudTaskStore = InboxCloudTaskStoreState & + InboxCloudTaskStoreActions; + +export const useInboxCloudTaskStore = create<InboxCloudTaskStore>()((set) => ({ + isRunning: false, + showConfirm: false, + selectedRepo: null, + + openConfirm: (defaultRepo) => + set({ showConfirm: true, selectedRepo: defaultRepo }), + + closeConfirm: () => set({ showConfirm: false }), + + setSelectedRepo: (repo) => set({ selectedRepo: repo }), + + setIsRunning: (isRunning) => set({ isRunning }), +})); diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsSidebarStore.ts b/packages/ui/src/features/inbox/stores/inboxSignalsSidebarStore.ts similarity index 65% rename from apps/code/src/renderer/features/inbox/stores/inboxSignalsSidebarStore.ts rename to packages/ui/src/features/inbox/stores/inboxSignalsSidebarStore.ts index 1445a4ea84..07d38ee55f 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsSidebarStore.ts +++ b/packages/ui/src/features/inbox/stores/inboxSignalsSidebarStore.ts @@ -1,4 +1,4 @@ -import { createSidebarStore } from "@stores/createSidebarStore"; +import { createSidebarStore } from "@posthog/ui/workbench/createSidebarStore"; export const useInboxSignalsSidebarStore = createSidebarStore({ name: "inbox-signals-sidebar-storage", diff --git a/apps/code/src/renderer/features/inbox/utils/inboxConstants.ts b/packages/ui/src/features/inbox/utils/inboxConstants.ts similarity index 100% rename from apps/code/src/renderer/features/inbox/utils/inboxConstants.ts rename to packages/ui/src/features/inbox/utils/inboxConstants.ts diff --git a/packages/ui/src/features/inbox/utils/inboxSort.ts b/packages/ui/src/features/inbox/utils/inboxSort.ts new file mode 100644 index 0000000000..fd1ca167fb --- /dev/null +++ b/packages/ui/src/features/inbox/utils/inboxSort.ts @@ -0,0 +1,20 @@ +import type { SignalReportStatus } from "@posthog/shared/domain-types"; + +export function inboxStatusAccentCss(status: SignalReportStatus): string { + switch (status) { + case "ready": + return "var(--green-9)"; + case "pending_input": + return "var(--violet-9)"; + case "in_progress": + return "var(--amber-9)"; + case "candidate": + return "var(--cyan-9)"; + case "potential": + return "var(--gray-9)"; + case "failed": + return "var(--red-9)"; + default: + return "var(--gray-8)"; + } +} diff --git a/apps/code/src/renderer/features/inbox/utils/pendingInboxOpenMethod.ts b/packages/ui/src/features/inbox/utils/pendingInboxOpenMethod.ts similarity index 92% rename from apps/code/src/renderer/features/inbox/utils/pendingInboxOpenMethod.ts rename to packages/ui/src/features/inbox/utils/pendingInboxOpenMethod.ts index 63e38c7554..c1c0ecd6c5 100644 --- a/apps/code/src/renderer/features/inbox/utils/pendingInboxOpenMethod.ts +++ b/packages/ui/src/features/inbox/utils/pendingInboxOpenMethod.ts @@ -1,4 +1,4 @@ -import type { InboxReportOpenMethod } from "@shared/types/analytics"; +import type { InboxReportOpenMethod } from "@posthog/shared/analytics-events"; /** * Module-level register that lets click / keyboard / deep-link call sites annotate diff --git a/packages/ui/src/features/integrations/integrationsClientImpl.ts b/packages/ui/src/features/integrations/integrationsClientImpl.ts new file mode 100644 index 0000000000..0727dbcee1 --- /dev/null +++ b/packages/ui/src/features/integrations/integrationsClientImpl.ts @@ -0,0 +1,53 @@ +import type { + GithubConnectClient, + RepositoriesClient, + TeamFlowResult, +} from "@posthog/core/integrations/identifiers"; +import type { CloudRegion } from "@posthog/core/integrations/schemas"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative"; +import { openExternalUrl } from "@posthog/ui/workbench/openExternal"; + +async function authedClient() { + const client = await getAuthenticatedClient(); + if (!client) { + throw new Error("Not authenticated"); + } + return client; +} + +export class UiRepositoriesClient implements RepositoriesClient { + async refreshTeamRepository(integrationId: number): Promise<unknown> { + return (await authedClient()).refreshGithubRepositories(integrationId); + } + + async refreshUserRepository(installationId: string): Promise<unknown> { + return (await authedClient()).refreshGithubUserRepositories(installationId); + } +} + +export class UiGithubConnectClient implements GithubConnectClient { + async startUserConnect(projectId: number): Promise<{ install_url: string }> { + return (await authedClient()).startGithubUserIntegrationConnect(projectId); + } + + async launchUrl(url: string): Promise<void> { + openExternalUrl(url); + } + + async startTeamFlow(input: { + region: string; + projectId: number; + }): Promise<TeamFlowResult> { + return resolveService<HostTrpcClient>( + HOST_TRPC_CLIENT, + ).githubIntegration.startFlow.mutate({ + region: input.region as CloudRegion, + projectId: input.projectId, + }); + } +} diff --git a/packages/ui/src/features/integrations/store.ts b/packages/ui/src/features/integrations/store.ts new file mode 100644 index 0000000000..a6ed51e9bb --- /dev/null +++ b/packages/ui/src/features/integrations/store.ts @@ -0,0 +1,26 @@ +import { + classifyIntegrations, + type Integration, +} from "@posthog/core/integrations/selectors"; +import { create } from "zustand"; + +export type { + Integration, + IntegrationAccount, + IntegrationConfig, +} from "@posthog/core/integrations/selectors"; + +interface IntegrationStore { + integrations: Integration[]; + setIntegrations: (integrations: Integration[]) => void; +} + +export const useIntegrationStore = create<IntegrationStore>((set) => ({ + integrations: [], + setIntegrations: (integrations) => set({ integrations }), +})); + +export const useIntegrationSelectors = () => { + const integrations = useIntegrationStore((state) => state.integrations); + return classifyIntegrations(integrations); +}; diff --git a/apps/code/src/renderer/features/integrations/hooks/useGitHubIntegrationCallback.ts b/packages/ui/src/features/integrations/useGitHubIntegrationCallback.ts similarity index 57% rename from apps/code/src/renderer/features/integrations/hooks/useGitHubIntegrationCallback.ts rename to packages/ui/src/features/integrations/useGitHubIntegrationCallback.ts index c0cb20a935..c42d2a1ab4 100644 --- a/apps/code/src/renderer/features/integrations/hooks/useGitHubIntegrationCallback.ts +++ b/packages/ui/src/features/integrations/useGitHubIntegrationCallback.ts @@ -1,6 +1,5 @@ -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { logger } from "@utils/logger"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { logger } from "@posthog/ui/workbench/logger"; import { useEffect, useRef } from "react"; const log = logger.scope("github-integration-callback-hook"); @@ -28,36 +27,43 @@ export function useGitHubIntegrationCallback({ onError, onTimedOut, }: Options): void { - const trpcReact = useTRPC(); + const client = useHostTRPCClient(); const hasConsumedPendingRef = useRef(false); const optsRef = useRef({ onSuccess, onError, onTimedOut }); optsRef.current = { onSuccess, onError, onTimedOut }; - useSubscription( - trpcReact.githubIntegration.onCallback.subscriptionOptions(undefined, { - onData: (data) => { - log.info("Received integration deep link callback", data); - if (data.status === "error") { - optsRef.current.onError({ - message: data.errorMessage ?? DEFAULT_ERROR_MESSAGE, - code: data.errorCode, - }); - return; - } - optsRef.current.onSuccess(data.projectId); + useEffect(() => { + const callbackSubscription = client.githubIntegration.onCallback.subscribe( + undefined, + { + onData: (data) => { + log.info("Received integration deep link callback", data); + if (data.status === "error") { + optsRef.current.onError({ + message: data.errorMessage ?? DEFAULT_ERROR_MESSAGE, + code: data.errorCode, + }); + return; + } + optsRef.current.onSuccess(data.projectId); + }, }, - }), - ); + ); - useSubscription( - trpcReact.githubIntegration.onFlowTimedOut.subscriptionOptions(undefined, { - onData: (data) => { - log.info("GitHub integration flow timed out", data); - optsRef.current.onTimedOut?.(); - }, - }), - ); + const timedOutSubscription = + client.githubIntegration.onFlowTimedOut.subscribe(undefined, { + onData: (data) => { + log.info("GitHub integration flow timed out", data); + optsRef.current.onTimedOut?.(); + }, + }); + + return () => { + callbackSubscription.unsubscribe(); + timedOutSubscription.unsubscribe(); + }; + }, [client]); useEffect(() => { if (hasConsumedPendingRef.current) return; @@ -65,7 +71,7 @@ export function useGitHubIntegrationCallback({ void (async () => { try { const pending = - await trpcClient.githubIntegration.consumePendingCallback.query(); + await client.githubIntegration.consumePendingCallback.query(); if (!pending) return; log.info("Consumed pending integration callback on mount", pending); if (pending.status === "error") { @@ -80,5 +86,5 @@ export function useGitHubIntegrationCallback({ log.error("Failed to consume pending integration callback", error); } })(); - }, []); + }, [client]); } diff --git a/packages/ui/src/features/integrations/useGithubDisconnect.ts b/packages/ui/src/features/integrations/useGithubDisconnect.ts new file mode 100644 index 0000000000..3ed12d279f --- /dev/null +++ b/packages/ui/src/features/integrations/useGithubDisconnect.ts @@ -0,0 +1,57 @@ +import type { GithubConnectService } from "@posthog/core/onboarding/githubConnectService"; +import { GITHUB_CONNECT_SERVICE } from "@posthog/core/onboarding/identifiers"; +import { useService } from "@posthog/di/react"; +import { invalidateGithubQueries } from "@posthog/ui/features/integrations/useGithubUserConnect"; +import { toast } from "@posthog/ui/primitives/toast"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +interface DisconnectVariables { + installationId: string; + silent?: boolean; +} + +interface UseGithubDisconnect { + disconnect: (variables: DisconnectVariables) => void; + isDisconnecting: boolean; + reconnect: ( + installationId: string, + connect: () => Promise<void>, + ) => Promise<void>; +} + +export function useGithubDisconnect( + projectId: number | null, +): UseGithubDisconnect { + const queryClient = useQueryClient(); + const service = useService<GithubConnectService>(GITHUB_CONNECT_SERVICE); + + const mutation = useMutation({ + mutationFn: async (variables: DisconnectVariables) => { + await service.disconnectInstallation(variables.installationId); + return { silent: variables.silent ?? false }; + }, + onSuccess: ({ silent }) => { + invalidateGithubQueries(queryClient, projectId); + if (!silent) toast.success("GitHub disconnected."); + }, + onError: (e) => { + toast.error( + e instanceof Error ? e.message : "Failed to disconnect GitHub.", + ); + }, + }); + + const reconnect = async ( + installationId: string, + connect: () => Promise<void>, + ) => { + await service.reconnectStaleInstallation(installationId, connect); + invalidateGithubQueries(queryClient, projectId); + }; + + return { + disconnect: mutation.mutate, + isDisconnecting: mutation.isPending, + reconnect, + }; +} diff --git a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts b/packages/ui/src/features/integrations/useGithubUserConnect.ts similarity index 51% rename from apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts rename to packages/ui/src/features/integrations/useGithubUserConnect.ts index 12404fe288..8bc177c25c 100644 --- a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts +++ b/packages/ui/src/features/integrations/useGithubUserConnect.ts @@ -1,64 +1,30 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useIsOrgAdmin } from "@features/auth/hooks/useOrgRole"; -import { useGitHubIntegrationCallback } from "@features/integrations/hooks/useGitHubIntegrationCallback"; -import type { PostHogAPIClient } from "@renderer/api/posthogClient"; -import { trpcClient } from "@renderer/trpc/client"; -import { IS_DEV } from "@shared/constants/environment"; +import { + CONNECT_INITIAL_STATUS, + type ConnectError, + type ConnectState, + connectReducer, + deriveConnectFlags, + githubInvalidationKeys, + toConnectError, +} from "@posthog/core/integrations/connectMachine"; +import type { GithubConnectService } from "@posthog/core/integrations/githubConnectService"; +import { GITHUB_CONNECT_SERVICE } from "@posthog/core/integrations/identifiers"; +import { useService } from "@posthog/di/react"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useIsOrgAdmin } from "@posthog/ui/features/auth/useOrgRole"; import { type QueryClient, useQueryClient } from "@tanstack/react-query"; -import { openUrlInBrowser } from "@utils/browser"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import { useGitHubIntegrationCallback } from "./useGitHubIntegrationCallback"; -const POLL_INTERVAL_MS = 3_000; -const POLL_TIMEOUT_MS = 300_000; +export { describeGithubConnectError } from "@posthog/core/integrations/connectErrors"; -export type GithubUserConnectState = - | "idle" - | "connecting" - | "timed-out" - | "error"; +const IS_DEV = import.meta.env.DEV; -export interface GithubUserConnectError { - message: string; - code: string | null; -} - -const ERROR_MESSAGES: Record<string, string> = { - access_denied: - "You declined access on GitHub. Try again to grant the permissions PostHog Code needs.", - github_oauth_error: "GitHub returned an error during sign-in. Please retry.", - missing_params: "GitHub returned an incomplete response. Please retry.", - invalid_state: - "The connection link expired before you finished. Please retry.", - invalid_installation: - "This GitHub installation isn't reachable from your account. Try a different account or org.", - invalid_team: - "Your project access changed during sign-in. Please retry from the current project.", - invalid_installation_id: - "GitHub returned an invalid installation. Please retry.", - exchange_failed: - "Couldn't exchange the GitHub authorization code. Please retry.", - installation_verify_failed: - "Couldn't verify your access to this GitHub installation. Please retry.", - installation_not_authorized: - "Your GitHub account isn't authorized for this installation. Ask the org admin to grant access, or sign in with a different GitHub account.", - installation_fetch_failed: - "Couldn't fetch installation details from GitHub. Please retry.", - installation_token_failed: - "Couldn't get an access token from GitHub. Please retry.", - integration_create_failed: - "Couldn't save the GitHub connection. Please retry.", -}; +const POLL_INTERVAL_MS = 3_000; +const POLL_TIMEOUT_MS = 300_000; -export function describeGithubConnectError( - error: GithubUserConnectError | null, -): string { - if (!error) return ""; - if (error.code && ERROR_MESSAGES[error.code]) { - return ERROR_MESSAGES[error.code]; - } - return error.message; -} +export type GithubUserConnectState = ConnectState; +export type GithubUserConnectError = ConnectError; interface Options { projectId: number | null; @@ -78,18 +44,9 @@ export function invalidateGithubQueries( queryClient: QueryClient, projectId: number | null = null, ): void { - if (projectId !== null) { - void queryClient.invalidateQueries({ - queryKey: ["integrations", projectId], - }); + for (const queryKey of githubInvalidationKeys(projectId)) { + void queryClient.invalidateQueries({ queryKey: [...queryKey] }); } - void queryClient.invalidateQueries({ - queryKey: ["integrations", "list"], - }); - void queryClient.invalidateQueries({ - queryKey: ["user-github-integrations"], - }); - void queryClient.invalidateQueries({ queryKey: ["github_login"] }); } interface StateMachine { @@ -108,10 +65,9 @@ function useConnectStateMachine( onConnected?: () => void, ): StateMachine { const queryClient = useQueryClient(); - const [state, setState] = useState<GithubUserConnectState>("idle"); - const [error, setError] = useState<GithubUserConnectError | null>(null); - const stateRef = useRef(state); - stateRef.current = state; + const [status, dispatch] = useReducer(connectReducer, CONNECT_INITIAL_STATUS); + const stateRef = useRef(status.state); + stateRef.current = status.state; const onConnectedRef = useRef(onConnected); onConnectedRef.current = onConnected; const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); @@ -138,57 +94,52 @@ function useConnectStateMachine( // Window-focus fallback: deep link from PostHog Cloud may not fire reliably, // so refetch when the user returns to the app while a connect is in flight. useEffect(() => { - if (state !== "connecting") return; + if (status.state !== "connecting") return; const onFocus = () => invalidate(projectId); window.addEventListener("focus", onFocus); return () => window.removeEventListener("focus", onFocus); - }, [state, projectId, invalidate]); + }, [status.state, projectId, invalidate]); useGitHubIntegrationCallback({ onSuccess: (callbackProjectId) => { stopPolling(); - setState("idle"); - setError(null); + dispatch({ type: "succeed" }); invalidate(callbackProjectId ?? projectId); onConnectedRef.current?.(); }, onError: (cbError) => { stopPolling(); - setState("error"); - setError(cbError); + dispatch({ type: "fail", error: cbError }); }, onTimedOut: () => { stopPolling(); - setState("timed-out"); + dispatch({ type: "timeout" }); invalidate(projectId); }, }); const beginConnecting = useCallback(() => { stopPolling(); - setError(null); - setState("connecting"); + dispatch({ type: "begin" }); }, [stopPolling]); const finishWithError = useCallback( (e: GithubUserConnectError) => { stopPolling(); - setError(e); - setState("error"); + dispatch({ type: "fail", error: e }); }, [stopPolling], ); const reset = useCallback(() => { stopPolling(); - setError(null); - setState("idle"); + dispatch({ type: "reset" }); }, [stopPolling]); const scheduleUserFlowTimeout = useCallback(() => { pollTimeoutRef.current = setTimeout(() => { stopPolling(); - setState("timed-out"); + dispatch({ type: "timeout" }); }, POLL_TIMEOUT_MS); }, [stopPolling]); @@ -202,8 +153,8 @@ function useConnectStateMachine( return useMemo( () => ({ - state, - error, + state: status.state, + error: status.error, stateRef, beginConnecting, finishWithError, @@ -212,8 +163,8 @@ function useConnectStateMachine( scheduleDevPolling, }), [ - state, - error, + status.state, + status.error, beginConnecting, finishWithError, reset, @@ -230,46 +181,32 @@ function machineToResult( return { state: machine.state, error: machine.error, - isConnecting: machine.state === "connecting", - isTimedOut: machine.state === "timed-out", - hasError: machine.state === "error", + ...deriveConnectFlags(machine.state), connect, reset: machine.reset, }; } -async function runUserFlow( - client: PostHogAPIClient, - projectId: number, -): Promise<void> { - const res = await client.startGithubUserIntegrationConnect(projectId); - const installUrl = res.install_url?.trim() ?? ""; - if (!installUrl) { - throw new Error("GitHub connection did not return a URL"); - } - await openUrlInBrowser(installUrl); -} - export function useGithubUserConnect({ projectId }: Options): Result { - const client = useOptionalAuthenticatedClient(); + const connectService = useService<GithubConnectService>( + GITHUB_CONNECT_SERVICE, + ); const machine = useConnectStateMachine(projectId); const connect = useCallback(async () => { if (machine.stateRef.current === "connecting") return; - if (projectId === null || !client) return; + if (projectId === null) return; machine.beginConnecting(); try { - await runUserFlow(client, projectId); + await connectService.connectUser(projectId); machine.scheduleDevPolling(); machine.scheduleUserFlowTimeout(); } catch (e) { - machine.finishWithError({ - message: - e instanceof Error ? e.message : "Failed to start GitHub connection", - code: null, - }); + machine.finishWithError( + toConnectError(e, "Failed to start GitHub connection"), + ); } - }, [client, projectId, machine]); + }, [connectService, projectId, machine]); return machineToResult(machine, connect); } @@ -296,44 +233,41 @@ export function useGithubConnect({ projectHasTeamIntegration, onConnected, }: ConnectOptions): Result { - const client = useOptionalAuthenticatedClient(); + const connectService = useService<GithubConnectService>( + GITHUB_CONNECT_SERVICE, + ); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const { isAdmin } = useIsOrgAdmin(); const machine = useConnectStateMachine(projectId, onConnected); - const shouldUseTeamFlow = - isAdmin === true && - projectHasTeamIntegration === false && - cloudRegion != null; - const connect = useCallback(async () => { if (machine.stateRef.current === "connecting") return; - if (projectId === null || !client) return; + if (projectId === null) return; machine.beginConnecting(); try { - if (shouldUseTeamFlow && cloudRegion) { - const res = await trpcClient.githubIntegration.startFlow.mutate({ - region: cloudRegion, - projectId, - }); - if (!res.success) { - throw new Error(res.error ?? "Failed to start GitHub connection"); - } - // Team flow's URL launch + timeout live in the main process and route - // back through the shared callback subscription. - } else { - await runUserFlow(client, projectId); + const outcome = await connectService.connect({ + projectId, + isAdmin, + projectHasTeamIntegration, + cloudRegion, + }); + if (outcome.flow === "user") { machine.scheduleDevPolling(); machine.scheduleUserFlowTimeout(); } } catch (e) { - machine.finishWithError({ - message: - e instanceof Error ? e.message : "Failed to start GitHub connection", - code: null, - }); + machine.finishWithError( + toConnectError(e, "Failed to start GitHub connection"), + ); } - }, [client, projectId, shouldUseTeamFlow, cloudRegion, machine]); + }, [ + connectService, + projectId, + isAdmin, + projectHasTeamIntegration, + cloudRegion, + machine, + ]); return machineToResult(machine, connect); } diff --git a/apps/code/src/renderer/hooks/useIntegrations.ts b/packages/ui/src/features/integrations/useIntegrations.ts similarity index 56% rename from apps/code/src/renderer/hooks/useIntegrations.ts rename to packages/ui/src/features/integrations/useIntegrations.ts index 781d24cb94..6627748e9e 100644 --- a/apps/code/src/renderer/hooks/useIntegrations.ts +++ b/packages/ui/src/features/integrations/useIntegrations.ts @@ -1,13 +1,42 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { AUTH_SCOPED_QUERY_META } from "@features/auth/hooks/authQueries"; +import type { UserGitHubIntegration } from "@posthog/api-client/posthog-client"; +import { + branchPageSizeForOffset, + computeNextBranchOffset, + flattenBranchPages, + type GithubBranchesPage, +} from "@posthog/core/integrations/branches"; +import { REPOSITORIES_SERVICE } from "@posthog/core/integrations/identifiers"; +import { + combineGithubRepositories, + combineRepositoryPicker, + combineUserGithubRepositories, + getIntegrationIdForRepo, + getRepoEntry, + isRepoInIntegration, + type UserRepositoryIntegrationRef, +} from "@posthog/core/integrations/repositories"; +import type { RepositoriesService } from "@posthog/core/integrations/repositoriesService"; +import { + integrationKeys, + type RepositoryRefetchKey, + userGithubIntegrationKeys, +} from "@posthog/core/integrations/repositoryKeys"; +import { useService } from "@posthog/di/react"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; import { type Integration, useIntegrationSelectors, useIntegrationStore, -} from "@features/integrations/stores/integrationStore"; -import { useDebounce } from "@hooks/useDebounce"; -import type { UserGitHubIntegration } from "@renderer/api/posthogClient"; -import { useQueries, useQueryClient } from "@tanstack/react-query"; +} from "@posthog/ui/features/integrations/store"; +import { useAuthenticatedInfiniteQuery } from "@posthog/ui/hooks/useAuthenticatedInfiniteQuery"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useDebounce } from "@posthog/ui/primitives/hooks/useDebounce"; +import { + type QueryClient, + useQueries, + useQueryClient, +} from "@tanstack/react-query"; import { useCallback, useDeferredValue, @@ -15,8 +44,6 @@ import { useMemo, useState, } from "react"; -import { useAuthenticatedInfiniteQuery } from "./useAuthenticatedInfiniteQuery"; -import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; // Branch search hits a slow remote endpoint (GitHub via PostHog proxy). Debounce // keystrokes so we fire at most one request per typing burst. Empty searches @@ -24,53 +51,15 @@ import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; // stale results immediately. const BRANCH_SEARCH_DEBOUNCE_MS = 300; -const integrationKeys = { - all: ["integrations"] as const, - list: () => [...integrationKeys.all, "list"] as const, - repositories: (integrationId?: number) => - [...integrationKeys.all, "repositories", integrationId] as const, - repositoryPicker: (integrationId?: number, search?: string, limit?: number) => - [ - ...integrationKeys.all, - "repository-picker", - integrationId, - search, - limit, - ] as const, - branches: (integrationId?: number, repo?: string | null, search?: string) => - [...integrationKeys.all, "branches", integrationId, repo, search] as const, -}; - -const userGithubIntegrationKeys = { - all: ["user-github-integrations"] as const, - list: () => [...userGithubIntegrationKeys.all, "list"] as const, - repositories: (installationId?: string) => - [...userGithubIntegrationKeys.all, "repositories", installationId] as const, - repositoryPicker: ( - installationId?: string, - search?: string, - limit?: number, - ) => - [ - ...userGithubIntegrationKeys.all, - "repository-picker", - installationId, - search, - limit, - ] as const, - branches: (installationId?: string, repo?: string | null, search?: string) => - [ - ...userGithubIntegrationKeys.all, - "branches", - installationId, - repo, - search, - ] as const, -}; - -interface UserRepositoryIntegrationRef { - userIntegrationId: string; - installationId: string; +async function refetchRepositoryKeys( + queryClient: QueryClient, + keys: ReadonlyArray<RepositoryRefetchKey>, +): Promise<void> { + await Promise.all( + keys.map(({ queryKey, exact }) => + queryClient.refetchQueries({ queryKey: [...queryKey], exact }), + ), + ); } export function useIntegrations() { @@ -105,20 +94,7 @@ function useAllGithubRepositories(githubIntegrations: Integration[]) { staleTime: 5 * 60 * 1000, meta: AUTH_SCOPED_QUERY_META, })), - combine: (results) => { - const map: Record<string, number> = {}; - let pending = false; - for (const result of results) { - if (result.isPending) pending = true; - if (!result.data) continue; - for (const repo of result.data.repos ?? []) { - if (!(repo in map)) { - map[repo] = result.data.integrationId; - } - } - } - return { repositoryMap: map, isPending: pending }; - }, + combine: combineGithubRepositories, }); } @@ -153,43 +129,15 @@ function useAllUserGithubRepositories( staleTime: 5 * 60 * 1000, meta: AUTH_SCOPED_QUERY_META, })), - combine: (results) => { - const map: Record<string, UserRepositoryIntegrationRef> = {}; - const reposByInstallationId: Record<string, string[]> = {}; - const failedInstallationIds: string[] = []; - let pending = false; - results.forEach((result, index) => { - if (result.isPending) pending = true; - if (result.isError) { - const installationId = - githubIntegrations[index]?.installation_id ?? null; - if (installationId) failedInstallationIds.push(installationId); - } - if (!result.data) return; - const installationRepos = result.data.repos ?? []; - reposByInstallationId[result.data.installationId] = installationRepos; - for (const repo of installationRepos) { - if (!(repo in map)) { - map[repo] = { - userIntegrationId: result.data.userIntegrationId, - installationId: result.data.installationId, - }; - } - } - }); - return { - repositoryMap: map, - reposByInstallationId, - isPending: pending, - failedInstallationIds, - }; - }, + combine: (results) => + combineUserGithubRepositories( + results, + githubIntegrations.map((i) => i.installation_id), + ), }); } const REPOSITORIES_PAGE_SIZE = 50; -const BRANCHES_FIRST_PAGE_SIZE = 50; -const BRANCHES_PAGE_SIZE = 100; export function useGithubRepositories( search?: string, @@ -222,42 +170,14 @@ export function useGithubRepositories( deferredSearch, ); - return { integrationId: integration.id, ...page }; + return { ref: integration.id, ...page }; }, enabled: queryEnabled, staleTime: 5 * 60 * 1000, placeholderData: (prev: unknown) => prev, meta: AUTH_SCOPED_QUERY_META, })), - combine: (results) => { - const map: Record<string, number> = {}; - let pending = false; - let refreshing = false; - let hasMoreResults = false; - - for (const result of results) { - if (result.isPending) pending = true; - if (result.isRefetching) refreshing = true; - if (!result.data) continue; - - if (result.data.hasMore) { - hasMoreResults = true; - } - - for (const repo of result.data.repositories ?? []) { - if (!(repo in map)) { - map[repo] = result.data.integrationId; - } - } - } - - return { - repositoryMap: map, - isPending: pending, - isRefreshing: refreshing, - hasMore: hasMoreResults, - }; - }, + combine: combineRepositoryPicker<number>, }); const loadMore = useCallback(() => { @@ -305,8 +225,10 @@ export function useUserGithubRepositories( ); return { - userIntegrationId: integration.id, - installationId: integration.installation_id, + ref: { + userIntegrationId: integration.id, + installationId: integration.installation_id, + }, ...page, }; }, @@ -314,38 +236,7 @@ export function useUserGithubRepositories( staleTime: 5 * 60 * 1000, meta: AUTH_SCOPED_QUERY_META, })), - combine: (results) => { - const map: Record<string, UserRepositoryIntegrationRef> = {}; - let pending = false; - let refreshing = false; - let hasMoreResults = false; - - for (const result of results) { - if (result.isPending) pending = true; - if (result.isRefetching) refreshing = true; - if (!result.data) continue; - - if (result.data.hasMore) { - hasMoreResults = true; - } - - for (const repo of result.data.repositories ?? []) { - if (!(repo in map)) { - map[repo] = { - userIntegrationId: result.data.userIntegrationId, - installationId: result.data.installationId, - }; - } - } - } - - return { - repositoryMap: map, - isPending: pending, - isRefreshing: refreshing, - hasMore: hasMoreResults, - }; - }, + combine: combineRepositoryPicker<UserRepositoryIntegrationRef>, }); const loadMore = useCallback(() => { @@ -361,12 +252,6 @@ export function useUserGithubRepositories( }; } -interface GithubBranchesPage { - branches: string[]; - defaultBranch: string | null; - hasMore: boolean; -} - export function useGithubBranches( integrationId?: number, repo?: string | null, @@ -386,36 +271,26 @@ export function useGithubBranches( if (!integrationId || !repo) { return { branches: [], defaultBranch: null, hasMore: false }; } - const pageSize = - offset === 0 ? BRANCHES_FIRST_PAGE_SIZE : BRANCHES_PAGE_SIZE; return await client.getGithubBranchesPage( integrationId, repo, offset, - pageSize, + branchPageSizeForOffset(offset), debouncedSearch, ); }, { enabled: queryEnabled, initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (!lastPage.hasMore) return undefined; - return allPages.reduce((n, p) => n + p.branches.length, 0); - }, + getNextPageParam: computeNextBranchOffset, staleTime: 5 * 60 * 1000, }, ); - const data = useMemo(() => { - if (!query.data?.pages.length) { - return { branches: [] as string[], defaultBranch: null }; - } - return { - branches: query.data.pages.flatMap((p) => p.branches), - defaultBranch: query.data.pages[0]?.defaultBranch ?? null, - }; - }, [query.data?.pages]); + const data = useMemo( + () => flattenBranchPages(query.data?.pages), + [query.data?.pages], + ); const loadMore = useCallback(() => { if (!query.hasNextPage || query.isFetchingNextPage) { @@ -459,36 +334,26 @@ export function useUserGithubBranches( if (!installationId || !repo) { return { branches: [], defaultBranch: null, hasMore: false }; } - const pageSize = - offset === 0 ? BRANCHES_FIRST_PAGE_SIZE : BRANCHES_PAGE_SIZE; return await client.getGithubUserBranchesPage( installationId, repo, offset, - pageSize, + branchPageSizeForOffset(offset), debouncedSearch, ); }, { enabled: queryEnabled, initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (!lastPage.hasMore) return undefined; - return allPages.reduce((n, p) => n + p.branches.length, 0); - }, + getNextPageParam: computeNextBranchOffset, staleTime: 5 * 60 * 1000, }, ); - const data = useMemo(() => { - if (!query.data?.pages.length) { - return { branches: [] as string[], defaultBranch: null }; - } - return { - branches: query.data.pages.flatMap((p) => p.branches), - defaultBranch: query.data.pages[0]?.defaultBranch ?? null, - }; - }, [query.data?.pages]); + const data = useMemo( + () => flattenBranchPages(query.data?.pages), + [query.data?.pages], + ); const loadMore = useCallback(() => { if (!query.hasNextPage || query.isFetchingNextPage) { @@ -516,6 +381,8 @@ export function useUserGithubBranches( export function useUserRepositoryIntegration() { const client = useOptionalAuthenticatedClient(); const queryClient = useQueryClient(); + const repositoriesService = + useService<RepositoriesService>(REPOSITORIES_SERVICE); const { data: githubIntegrations = [], isPending: integrationsPending } = useUserGithubIntegrations(); const [isRefreshingRepos, setIsRefreshingRepos] = useState(false); @@ -534,17 +401,17 @@ export function useUserRepositoryIntegration() { const getUserIntegrationIdForRepo = useCallback( (repoKey: string) => - repositoryMap[repoKey?.toLowerCase()]?.userIntegrationId, + getRepoEntry(repositoryMap, repoKey)?.userIntegrationId, [repositoryMap], ); const getInstallationIdForRepo = useCallback( - (repoKey: string) => repositoryMap[repoKey?.toLowerCase()]?.installationId, + (repoKey: string) => getRepoEntry(repositoryMap, repoKey)?.installationId, [repositoryMap], ); - const isRepoInIntegration = useCallback( - (repoKey: string) => !repoKey || repoKey.toLowerCase() in repositoryMap, + const repoInIntegration = useCallback( + (repoKey: string) => isRepoInIntegration(repositoryMap, repoKey), [repositoryMap], ); @@ -556,36 +423,21 @@ export function useUserRepositoryIntegration() { setIsRefreshingRepos(true); try { - await Promise.all( - githubIntegrations.map((integration) => - client.refreshGithubUserRepositories(integration.installation_id), - ), - ); - - await Promise.all( - githubIntegrations.map((integration) => - queryClient.refetchQueries({ - queryKey: userGithubIntegrationKeys.repositories( - integration.installation_id, - ), - exact: true, - }), - ), - ); - - await queryClient.refetchQueries({ - queryKey: [...userGithubIntegrationKeys.all, "repository-picker"], - }); + const refetchKeys = + await repositoriesService.refreshUserRepositoriesAndKeys( + githubIntegrations.map((integration) => integration.installation_id), + ); + await refetchRepositoryKeys(queryClient, refetchKeys); } finally { setIsRefreshingRepos(false); } - }, [client, githubIntegrations, queryClient]); + }, [client, githubIntegrations, queryClient, repositoriesService]); return { repositories, getUserIntegrationIdForRepo, getInstallationIdForRepo, - isRepoInIntegration, + isRepoInIntegration: repoInIntegration, isLoadingRepos: integrationsPending || reposPending, isRefreshingRepos, refreshRepositories, @@ -598,6 +450,8 @@ export function useUserRepositoryIntegration() { export function useRepositoryIntegration() { const client = useOptionalAuthenticatedClient(); const queryClient = useQueryClient(); + const repositoriesService = + useService<RepositoriesService>(REPOSITORIES_SERVICE); const { isPending: integrationsPending } = useIntegrations(); const { githubIntegrations, hasGithubIntegration } = useIntegrationSelectors(); @@ -611,13 +465,13 @@ export function useRepositoryIntegration() { [repositoryMap], ); - const getIntegrationIdForRepo = useCallback( - (repoKey: string) => repositoryMap[repoKey?.toLowerCase()], + const getIntegrationIdForRepoFn = useCallback( + (repoKey: string) => getIntegrationIdForRepo(repositoryMap, repoKey), [repositoryMap], ); - const isRepoInIntegration = useCallback( - (repoKey: string) => !repoKey || repoKey.toLowerCase() in repositoryMap, + const repoInIntegration = useCallback( + (repoKey: string) => isRepoInIntegration(repositoryMap, repoKey), [repositoryMap], ); @@ -629,33 +483,20 @@ export function useRepositoryIntegration() { setIsRefreshingRepos(true); try { - await Promise.all( - githubIntegrations.map((integration) => - client.refreshGithubRepositories(integration.id), - ), - ); - - await Promise.all( - githubIntegrations.map((integration) => - queryClient.refetchQueries({ - queryKey: integrationKeys.repositories(integration.id), - exact: true, - }), - ), - ); - - await queryClient.refetchQueries({ - queryKey: [...integrationKeys.all, "repository-picker"], - }); + const refetchKeys = + await repositoriesService.refreshTeamRepositoriesAndKeys( + githubIntegrations.map((integration) => integration.id), + ); + await refetchRepositoryKeys(queryClient, refetchKeys); } finally { setIsRefreshingRepos(false); } - }, [client, githubIntegrations, queryClient]); + }, [client, githubIntegrations, queryClient, repositoriesService]); return { repositories, - getIntegrationIdForRepo, - isRepoInIntegration, + getIntegrationIdForRepo: getIntegrationIdForRepoFn, + isRepoInIntegration: repoInIntegration, isLoadingIntegrations: integrationsPending, isLoadingRepos: integrationsPending || reposPending, isRefreshingRepos, diff --git a/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts b/packages/ui/src/features/integrations/useSlackConnect.ts similarity index 62% rename from apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts rename to packages/ui/src/features/integrations/useSlackConnect.ts index 1f0f920d7c..9941490a49 100644 --- a/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts +++ b/packages/ui/src/features/integrations/useSlackConnect.ts @@ -1,17 +1,22 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useSlackIntegrationCallback } from "@features/integrations/hooks/useSlackIntegrationCallback"; -import { trpcClient } from "@renderer/trpc/client"; +import { + CONNECT_INITIAL_STATUS, + type ConnectError, + type ConnectState, + connectReducer, + deriveConnectFlags, + slackInvalidationKeys, + toConnectError, +} from "@posthog/core/integrations/connectMachine"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { type QueryClient, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import { useSlackIntegrationCallback } from "./useSlackIntegrationCallback"; const POLL_TIMEOUT_MS = 300_000; -export type SlackConnectState = "idle" | "connecting" | "timed-out" | "error"; - -export interface SlackConnectError { - message: string; - code: string | null; -} +export type SlackConnectState = ConnectState; +export type SlackConnectError = ConnectError; interface Result { state: SlackConnectState; @@ -24,8 +29,9 @@ interface Result { } function invalidateIntegrationQueries(queryClient: QueryClient): void { - void queryClient.invalidateQueries({ queryKey: ["integrations", "list"] }); - void queryClient.invalidateQueries({ queryKey: ["integrations"] }); + for (const queryKey of slackInvalidationKeys()) { + void queryClient.invalidateQueries({ queryKey: [...queryKey] }); + } } /** @@ -37,14 +43,14 @@ function invalidateIntegrationQueries(queryClient: QueryClient): void { * finishes the install in another browser still surfaces eventually). */ export function useSlackConnect(): Result { + const client = useHostTRPCClient(); const queryClient = useQueryClient(); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const projectId = useAuthStateValue((s) => s.projectId); - const [state, setState] = useState<SlackConnectState>("idle"); - const [error, setError] = useState<SlackConnectError | null>(null); - const stateRef = useRef(state); - stateRef.current = state; + const [status, dispatch] = useReducer(connectReducer, CONNECT_INITIAL_STATUS); + const stateRef = useRef(status.state); + stateRef.current = status.state; const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const clearLocalTimeout = useCallback(() => { @@ -60,45 +66,41 @@ export function useSlackConnect(): Result { // setting, OS prompt dismissed), so refetch when the user returns to the // app while a connect is in flight. useEffect(() => { - if (state !== "connecting") return; + if (status.state !== "connecting") return; const onFocus = () => invalidateIntegrationQueries(queryClient); window.addEventListener("focus", onFocus); return () => window.removeEventListener("focus", onFocus); - }, [state, queryClient]); + }, [status.state, queryClient]); useSlackIntegrationCallback({ onSuccess: () => { clearLocalTimeout(); - setState("idle"); - setError(null); + dispatch({ type: "succeed" }); invalidateIntegrationQueries(queryClient); }, onError: (cbError) => { clearLocalTimeout(); - setState("error"); - setError(cbError); + dispatch({ type: "fail", error: cbError }); }, onTimedOut: () => { clearLocalTimeout(); - setState("timed-out"); + dispatch({ type: "timeout" }); invalidateIntegrationQueries(queryClient); }, }); const reset = useCallback(() => { clearLocalTimeout(); - setError(null); - setState("idle"); + dispatch({ type: "reset" }); }, [clearLocalTimeout]); const connect = useCallback(async () => { if (stateRef.current === "connecting") return; if (projectId === null || cloudRegion === null) return; clearLocalTimeout(); - setError(null); - setState("connecting"); + dispatch({ type: "begin" }); try { - const res = await trpcClient.slackIntegration.startFlow.mutate({ + const res = await client.slackIntegration.startFlow.mutate({ region: cloudRegion, projectId, }); @@ -106,30 +108,26 @@ export function useSlackConnect(): Result { throw new Error(res.error ?? "Failed to start Slack connection"); } timeoutRef.current = setTimeout(() => { - setState("timed-out"); + dispatch({ type: "timeout" }); invalidateIntegrationQueries(queryClient); }, POLL_TIMEOUT_MS); } catch (e) { clearLocalTimeout(); - setError({ - message: - e instanceof Error ? e.message : "Failed to start Slack connection", - code: null, + dispatch({ + type: "fail", + error: toConnectError(e, "Failed to start Slack connection"), }); - setState("error"); } - }, [cloudRegion, projectId, clearLocalTimeout, queryClient]); + }, [client, cloudRegion, projectId, clearLocalTimeout, queryClient]); return useMemo( () => ({ - state, - error, - isConnecting: state === "connecting", - isTimedOut: state === "timed-out", - hasError: state === "error", + state: status.state, + error: status.error, + ...deriveConnectFlags(status.state), connect, reset, }), - [state, error, connect, reset], + [status.state, status.error, connect, reset], ); } diff --git a/apps/code/src/renderer/features/integrations/hooks/useSlackIntegrationCallback.ts b/packages/ui/src/features/integrations/useSlackIntegrationCallback.ts similarity index 58% rename from apps/code/src/renderer/features/integrations/hooks/useSlackIntegrationCallback.ts rename to packages/ui/src/features/integrations/useSlackIntegrationCallback.ts index 49676573b4..01ae638710 100644 --- a/apps/code/src/renderer/features/integrations/hooks/useSlackIntegrationCallback.ts +++ b/packages/ui/src/features/integrations/useSlackIntegrationCallback.ts @@ -1,6 +1,5 @@ -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { logger } from "@utils/logger"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { logger } from "@posthog/ui/workbench/logger"; import { useEffect, useRef } from "react"; const log = logger.scope("slack-integration-callback-hook"); @@ -28,36 +27,43 @@ export function useSlackIntegrationCallback({ onError, onTimedOut, }: Options): void { - const trpcReact = useTRPC(); + const client = useHostTRPCClient(); const hasConsumedPendingRef = useRef(false); const optsRef = useRef({ onSuccess, onError, onTimedOut }); optsRef.current = { onSuccess, onError, onTimedOut }; - useSubscription( - trpcReact.slackIntegration.onCallback.subscriptionOptions(undefined, { - onData: (data) => { - log.info("Received Slack integration deep link callback", data); - if (data.status === "error") { - optsRef.current.onError({ - message: data.errorMessage ?? DEFAULT_ERROR_MESSAGE, - code: data.errorCode, - }); - return; - } - optsRef.current.onSuccess(data.projectId, data.integrationId); + useEffect(() => { + const callbackSubscription = client.slackIntegration.onCallback.subscribe( + undefined, + { + onData: (data) => { + log.info("Received Slack integration deep link callback", data); + if (data.status === "error") { + optsRef.current.onError({ + message: data.errorMessage ?? DEFAULT_ERROR_MESSAGE, + code: data.errorCode, + }); + return; + } + optsRef.current.onSuccess(data.projectId, data.integrationId); + }, }, - }), - ); + ); - useSubscription( - trpcReact.slackIntegration.onFlowTimedOut.subscriptionOptions(undefined, { - onData: (data) => { - log.info("Slack integration flow timed out", data); - optsRef.current.onTimedOut?.(); - }, - }), - ); + const timedOutSubscription = + client.slackIntegration.onFlowTimedOut.subscribe(undefined, { + onData: (data) => { + log.info("Slack integration flow timed out", data); + optsRef.current.onTimedOut?.(); + }, + }); + + return () => { + callbackSubscription.unsubscribe(); + timedOutSubscription.unsubscribe(); + }; + }, [client]); useEffect(() => { if (hasConsumedPendingRef.current) return; @@ -65,7 +71,7 @@ export function useSlackIntegrationCallback({ void (async () => { try { const pending = - await trpcClient.slackIntegration.consumePendingCallback.query(); + await client.slackIntegration.consumePendingCallback.query(); if (!pending) return; log.info( "Consumed pending Slack integration callback on mount", @@ -86,5 +92,5 @@ export function useSlackIntegrationCallback({ ); } })(); - }, []); + }, [client]); } diff --git a/apps/code/src/renderer/features/mcp-apps/components/McpAppHost.tsx b/packages/ui/src/features/mcp-apps/components/McpAppHost.tsx similarity index 84% rename from apps/code/src/renderer/features/mcp-apps/components/McpAppHost.tsx rename to packages/ui/src/features/mcp-apps/components/McpAppHost.tsx index e909423cd3..92727ab929 100644 --- a/apps/code/src/renderer/features/mcp-apps/components/McpAppHost.tsx +++ b/packages/ui/src/features/mcp-apps/components/McpAppHost.tsx @@ -1,4 +1,3 @@ -import type { ToolViewProps } from "@features/sessions/components/session-update/toolCallUtils"; import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolResult, @@ -6,15 +5,21 @@ import type { Tool, } from "@modelcontextprotocol/sdk/types.js"; import { ArrowsIn, ArrowsOut, Plugs, X } from "@phosphor-icons/react"; +import { useService } from "@posthog/di/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { ToolViewProps } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; +import { logger } from "@posthog/ui/workbench/logger"; +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc/client"; -import { useThemeStore } from "@stores/themeStore"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; -import { logger } from "@utils/logger"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { type Phase, useAppBridge } from "../hooks/useAppBridge"; +import { + MCP_SANDBOX_PROXY_URL, + type McpSandboxProxyUrlProvider, +} from "../identifiers"; import { toCallToolResult } from "../utils/mcp-app-host-utils"; const log = logger.scope("mcp-app-host"); @@ -31,7 +36,14 @@ export function McpAppHost({ serverName, toolName, }: McpAppHostProps) { - const trpcReact = useTRPC(); + const trpc = useHostTRPC(); + const getSandboxProxyUrl = useService<McpSandboxProxyUrlProvider>( + MCP_SANDBOX_PROXY_URL, + ); + const sandboxProxyUrl = useMemo( + () => getSandboxProxyUrl(), + [getSandboxProxyUrl], + ); const containerRef = useRef<HTMLDivElement>(null); const [_phase, setPhase] = useState<Phase>("loading"); const [displayMode, setDisplayMode] = useState<McpUiDisplayMode>("inline"); @@ -41,16 +53,16 @@ export function McpAppHost({ const isDarkMode = useThemeStore((s) => s.isDarkMode); const { data: uiResource, isLoading: resourceLoading } = useQuery( - trpcReact.mcpApps.getUiResource.queryOptions( + trpc.mcpApps.getUiResource.queryOptions( { toolKey: mcpToolName }, - { staleTime: Infinity }, + { staleTime: Number.POSITIVE_INFINITY }, ), ); const { data: toolDefinition } = useQuery( - trpcReact.mcpApps.getToolDefinition.queryOptions( + trpc.mcpApps.getToolDefinition.queryOptions( { toolKey: mcpToolName }, - { staleTime: Infinity }, + { staleTime: Number.POSITIVE_INFINITY }, ), ); @@ -64,12 +76,12 @@ export function McpAppHost({ }, [mcpToolName, resourceLoading, uiResource, uiResource?.uri]); const proxyToolCallMut = useMutation( - trpcReact.mcpApps.proxyToolCall.mutationOptions(), + trpc.mcpApps.proxyToolCall.mutationOptions(), ); const proxyResourceReadMut = useMutation( - trpcReact.mcpApps.proxyResourceRead.mutationOptions(), + trpc.mcpApps.proxyResourceRead.mutationOptions(), ); - const openLinkMut = useMutation(trpcReact.mcpApps.openLink.mutationOptions()); + const openLinkMut = useMutation(trpc.mcpApps.openLink.mutationOptions()); const { sendWhenReady } = useAppBridge({ iframeEl, @@ -98,7 +110,7 @@ export function McpAppHost({ // Forward tool results from subscriptions useSubscription( - trpcReact.mcpApps.onToolResult.subscriptionOptions( + trpc.mcpApps.onToolResult.subscriptionOptions( { toolKey: mcpToolName }, { onData: (event) => { @@ -116,7 +128,7 @@ export function McpAppHost({ // Forward tool cancellations from subscriptions useSubscription( - trpcReact.mcpApps.onToolCancelled.subscriptionOptions( + trpc.mcpApps.onToolCancelled.subscriptionOptions( { toolKey: mcpToolName }, { onData: () => { @@ -162,7 +174,7 @@ export function McpAppHost({ const iframeElement = ( <iframe ref={setIframeEl} - src="mcp-sandbox://proxy" + src={sandboxProxyUrl} sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation" style={{ height: displayMode === "fullscreen" ? "100%" : `${iframeHeight}px`, diff --git a/apps/code/src/renderer/features/sessions/components/session-update/McpToolBlock.tsx b/packages/ui/src/features/mcp-apps/components/McpToolBlock.tsx similarity index 55% rename from apps/code/src/renderer/features/sessions/components/session-update/McpToolBlock.tsx rename to packages/ui/src/features/mcp-apps/components/McpToolBlock.tsx index 25d0e5e3d8..2777af33ea 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/McpToolBlock.tsx +++ b/packages/ui/src/features/mcp-apps/components/McpToolBlock.tsx @@ -1,11 +1,15 @@ -import { McpAppHost } from "@features/mcp-apps/components/McpAppHost"; -import { McpToolView } from "@features/mcp-apps/components/McpToolView"; -import { parseMcpToolKey } from "@features/mcp-apps/utils/mcp-app-host-utils"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useTRPC } from "@renderer/trpc/client"; +import { useServiceOptional } from "@posthog/di/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { McpToolView } from "@posthog/ui/features/mcp-apps/components/McpToolView"; +import { parseMcpToolKey } from "@posthog/ui/features/mcp-apps/utils/mcp-app-host-utils"; +import type { ToolViewProps } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; -import type { ToolViewProps } from "./toolCallUtils"; +import { + MCP_APP_HOST_COMPONENT, + type McpAppHostComponent, +} from "../identifiers"; interface McpToolBlockProps extends ToolViewProps { mcpToolName: string; @@ -18,11 +22,14 @@ export function McpToolBlock(props: McpToolBlockProps) { const mcpAppsDisabled = useSettingsStore((s) => s.mcpAppsDisabledServers); const isDisabledForServer = mcpAppsDisabled.includes(serverName); - const trpcReact = useTRPC(); + const trpc = useHostTRPC(); const queryClient = useQueryClient(); + const McpAppHost = useServiceOptional<McpAppHostComponent>( + MCP_APP_HOST_COMPONENT, + ); const { data: hasUi } = useQuery( - trpcReact.mcpApps.hasUiForTool.queryOptions( + trpc.mcpApps.hasUiForTool.queryOptions( { toolKey: mcpToolName }, { staleTime: Infinity, @@ -34,13 +41,13 @@ export function McpToolBlock(props: McpToolBlockProps) { // When MCP Apps discovery completes (possibly after this component mounted), // invalidate the hasUiForTool query so we pick up newly-discovered UIs. useSubscription( - trpcReact.mcpApps.onDiscoveryComplete.subscriptionOptions(undefined, { + trpc.mcpApps.onDiscoveryComplete.subscriptionOptions(undefined, { onData: (_event) => { void queryClient.invalidateQueries( - trpcReact.mcpApps.hasUiForTool.pathFilter(), + trpc.mcpApps.hasUiForTool.pathFilter(), ); void queryClient.invalidateQueries( - trpcReact.mcpApps.getUiResource.pathFilter(), + trpc.mcpApps.getUiResource.pathFilter(), ); }, }), @@ -49,7 +56,7 @@ export function McpToolBlock(props: McpToolBlockProps) { return ( <> <McpToolView {...props} /> - {hasUi && !isDisabledForServer && ( + {hasUi && !isDisabledForServer && McpAppHost && ( <McpAppHost {...props} serverName={serverName} toolName={toolName} /> )} </> diff --git a/apps/code/src/renderer/features/mcp-apps/components/McpToolView.tsx b/packages/ui/src/features/mcp-apps/components/McpToolView.tsx similarity index 95% rename from apps/code/src/renderer/features/mcp-apps/components/McpToolView.tsx rename to packages/ui/src/features/mcp-apps/components/McpToolView.tsx index c5e899871b..fb460f2b37 100644 --- a/apps/code/src/renderer/features/mcp-apps/components/McpToolView.tsx +++ b/packages/ui/src/features/mcp-apps/components/McpToolView.tsx @@ -1,7 +1,10 @@ +import { Plugs } from "@phosphor-icons/react"; +import { Box, Flex } from "@radix-ui/themes"; +import { useState } from "react"; import { getPostHogExecDisplay, isPostHogExecTool, -} from "@features/posthog-mcp/utils/posthog-exec-display"; +} from "../../posthog-mcp/utils/posthog-exec-display"; import { compactInput, ExpandableIcon, @@ -14,10 +17,7 @@ import { type ToolViewProps, truncateText, useToolCallStatus, -} from "@features/sessions/components/session-update/toolCallUtils"; -import { Plugs } from "@phosphor-icons/react"; -import { Box, Flex } from "@radix-ui/themes"; -import { useState } from "react"; +} from "../../sessions/components/session-update/toolCallUtils"; import { parseMcpToolKey } from "../utils/mcp-app-host-utils"; const POSTHOG_EXEC_INPUT_PREVIEW_MAX_LENGTH = 60; diff --git a/apps/code/src/renderer/features/mcp-apps/hooks/useAppBridge.ts b/packages/ui/src/features/mcp-apps/hooks/useAppBridge.ts similarity index 97% rename from apps/code/src/renderer/features/mcp-apps/hooks/useAppBridge.ts rename to packages/ui/src/features/mcp-apps/hooks/useAppBridge.ts index c70fa57e7c..fe8c8481a9 100644 --- a/apps/code/src/renderer/features/mcp-apps/hooks/useAppBridge.ts +++ b/packages/ui/src/features/mcp-apps/hooks/useAppBridge.ts @@ -1,5 +1,3 @@ -import { useDraftStore } from "@features/message-editor/stores/draftStore"; -import type { ToolCall } from "@features/sessions/types"; import { AppBridge, type McpUiDisplayMode, @@ -13,10 +11,12 @@ import type { ReadResourceResult, Tool, } from "@modelcontextprotocol/sdk/types.js"; -import type { McpUiResource } from "@shared/types/mcp-apps"; -import { useNavigationStore } from "@stores/navigationStore"; -import { logger } from "@utils/logger"; +import type { McpUiResource } from "@posthog/core/mcp-apps/schemas"; import { useCallback, useEffect, useRef } from "react"; +import { logger } from "../../../workbench/logger"; +import { useDraftStore } from "../../message-editor/draftStore"; +import { useNavigationStore } from "../../navigation/store"; +import type { ToolCall } from "../../sessions/types"; import { computeContainerDimensions, INLINE_MAX_HEIGHT, diff --git a/packages/ui/src/features/mcp-apps/identifiers.ts b/packages/ui/src/features/mcp-apps/identifiers.ts new file mode 100644 index 0000000000..4302670575 --- /dev/null +++ b/packages/ui/src/features/mcp-apps/identifiers.ts @@ -0,0 +1,23 @@ +import type { ToolViewProps } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; +import type { ComponentType } from "react"; + +export type McpAppHostComponent = ComponentType< + ToolViewProps & { + mcpToolName: string; + serverName: string; + toolName: string; + } +>; + +export const MCP_APP_HOST_COMPONENT = Symbol.for( + "posthog.ui.McpAppHostComponent", +); + +// The sandbox proxy iframe `src` — the one host-specific seam of McpAppHost. +// Electron supplies an isolated-origin custom-protocol URL ("mcp-sandbox://proxy"); +// web supplies a blob/separate-origin URL of the same proxy HTML. +export type McpSandboxProxyUrlProvider = () => string; + +export const MCP_SANDBOX_PROXY_URL = Symbol.for( + "posthog.ui.McpSandboxProxyUrl", +); diff --git a/apps/code/src/renderer/features/mcp-apps/utils/mcp-app-csp.test.ts b/packages/ui/src/features/mcp-apps/utils/mcp-app-csp.test.ts similarity index 100% rename from apps/code/src/renderer/features/mcp-apps/utils/mcp-app-csp.test.ts rename to packages/ui/src/features/mcp-apps/utils/mcp-app-csp.test.ts diff --git a/apps/code/src/renderer/features/mcp-apps/utils/mcp-app-csp.ts b/packages/ui/src/features/mcp-apps/utils/mcp-app-csp.ts similarity index 100% rename from apps/code/src/renderer/features/mcp-apps/utils/mcp-app-csp.ts rename to packages/ui/src/features/mcp-apps/utils/mcp-app-csp.ts diff --git a/apps/code/src/renderer/features/mcp-apps/utils/mcp-app-host-utils.test.ts b/packages/ui/src/features/mcp-apps/utils/mcp-app-host-utils.test.ts similarity index 100% rename from apps/code/src/renderer/features/mcp-apps/utils/mcp-app-host-utils.test.ts rename to packages/ui/src/features/mcp-apps/utils/mcp-app-host-utils.test.ts diff --git a/apps/code/src/renderer/features/mcp-apps/utils/mcp-app-host-utils.ts b/packages/ui/src/features/mcp-apps/utils/mcp-app-host-utils.ts similarity index 100% rename from apps/code/src/renderer/features/mcp-apps/utils/mcp-app-host-utils.ts rename to packages/ui/src/features/mcp-apps/utils/mcp-app-host-utils.ts diff --git a/apps/code/src/renderer/features/mcp-apps/utils/mcp-app-theme.test.ts b/packages/ui/src/features/mcp-apps/utils/mcp-app-theme.test.ts similarity index 100% rename from apps/code/src/renderer/features/mcp-apps/utils/mcp-app-theme.test.ts rename to packages/ui/src/features/mcp-apps/utils/mcp-app-theme.test.ts diff --git a/apps/code/src/renderer/features/mcp-apps/utils/mcp-app-theme.ts b/packages/ui/src/features/mcp-apps/utils/mcp-app-theme.ts similarity index 100% rename from apps/code/src/renderer/features/mcp-apps/utils/mcp-app-theme.ts rename to packages/ui/src/features/mcp-apps/utils/mcp-app-theme.ts diff --git a/apps/code/src/renderer/features/mcp-servers/components/McpServersView.tsx b/packages/ui/src/features/mcp-servers/components/McpServersView.tsx similarity index 95% rename from apps/code/src/renderer/features/mcp-servers/components/McpServersView.tsx rename to packages/ui/src/features/mcp-servers/components/McpServersView.tsx index 0c13063da5..35e4b520c8 100644 --- a/apps/code/src/renderer/features/mcp-servers/components/McpServersView.tsx +++ b/packages/ui/src/features/mcp-servers/components/McpServersView.tsx @@ -1,6 +1,13 @@ -import { useMcpServers } from "@features/mcp-servers/hooks/useMcpServers"; -import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import { Plugs } from "@phosphor-icons/react"; +import type { + McpRecommendedServer, + McpServerInstallation, +} from "@posthog/api-client/posthog-client"; +import { AddCustomServerForm } from "@posthog/ui/features/mcp-servers/components/parts/AddCustomServerForm"; +import { MarketplaceView } from "@posthog/ui/features/mcp-servers/components/parts/MarketplaceView"; +import { McpInstalledRail } from "@posthog/ui/features/mcp-servers/components/parts/McpInstalledRail"; +import { useMcpServers } from "@posthog/ui/features/mcp-servers/hooks/useMcpServers"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { AlertDialog, Box, @@ -10,15 +17,8 @@ import { Spinner, Text, } from "@radix-ui/themes"; -import type { - McpRecommendedServer, - McpServerInstallation, -} from "@renderer/api/posthogClient"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { AddCustomServerForm } from "./parts/AddCustomServerForm"; -import { MarketplaceView } from "./parts/MarketplaceView"; -import { McpInstalledRail } from "./parts/McpInstalledRail"; import { ServerDetailView } from "./parts/ServerDetailView"; type SceneView = diff --git a/apps/code/src/renderer/features/mcp-servers/components/parts/AddCustomServerForm.tsx b/packages/ui/src/features/mcp-servers/components/parts/AddCustomServerForm.tsx similarity index 91% rename from apps/code/src/renderer/features/mcp-servers/components/parts/AddCustomServerForm.tsx rename to packages/ui/src/features/mcp-servers/components/parts/AddCustomServerForm.tsx index be314e56f4..60f43f24a7 100644 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/AddCustomServerForm.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/AddCustomServerForm.tsx @@ -1,4 +1,9 @@ import { ArrowLeft, CaretDown, CaretRight, Plus } from "@phosphor-icons/react"; +import type { McpAuthType } from "@posthog/api-client/posthog-client"; +import { + buildCustomServerRequest, + canSubmitCustomServer, +} from "@posthog/core/mcp-servers/customServerForm"; import { Button, Flex, @@ -7,7 +12,6 @@ import { Text, TextField, } from "@radix-ui/themes"; -import type { McpAuthType } from "@renderer/api/posthogClient"; import { useCallback, useState } from "react"; interface AddCustomServerFormProps { @@ -38,26 +42,23 @@ export function AddCustomServerForm({ const [clientSecret, setClientSecret] = useState(""); const [showAdvanced, setShowAdvanced] = useState(false); - const urlValid = /^https?:\/\/.+/i.test(url.trim()); - const canSubmit = name.trim() !== "" && urlValid && !pending; + const canSubmit = canSubmitCustomServer({ name, url }) && !pending; const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); if (!canSubmit) return; - onSubmit({ - name: name.trim(), - url: url.trim(), - description: description.trim(), - auth_type: authType, - ...(authType === "api_key" && apiKey ? { api_key: apiKey } : {}), - ...(authType === "oauth" && clientId.trim() - ? { client_id: clientId.trim() } - : {}), - ...(authType === "oauth" && clientSecret.trim() - ? { client_secret: clientSecret.trim() } - : {}), - }); + onSubmit( + buildCustomServerRequest({ + name, + url, + description, + authType, + apiKey, + clientId, + clientSecret, + }), + ); }, [ canSubmit, diff --git a/apps/code/src/renderer/features/mcp-servers/components/parts/MarketplaceView.tsx b/packages/ui/src/features/mcp-servers/components/parts/MarketplaceView.tsx similarity index 98% rename from apps/code/src/renderer/features/mcp-servers/components/parts/MarketplaceView.tsx rename to packages/ui/src/features/mcp-servers/components/parts/MarketplaceView.tsx index 6abfbcaff5..861754946e 100644 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/MarketplaceView.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/MarketplaceView.tsx @@ -1,8 +1,14 @@ +import { MagnifyingGlass, Plus, X } from "@phosphor-icons/react"; +import { + MCP_CATEGORIES, + type McpCategory, + type McpRecommendedServer, + type McpServerInstallation, +} from "@posthog/api-client/posthog-client"; import { filterServersByCategory, filterServersByQuery, -} from "@features/mcp-servers/hooks/mcpFilters"; -import { MagnifyingGlass, Plus, X } from "@phosphor-icons/react"; +} from "@posthog/core/mcp-servers/filters"; import { Button, Flex, @@ -12,12 +18,6 @@ import { Text, TextField, } from "@radix-ui/themes"; -import { - MCP_CATEGORIES, - type McpCategory, - type McpRecommendedServer, - type McpServerInstallation, -} from "@renderer/api/posthogClient"; import { useMemo } from "react"; import { ServerCard } from "./ServerCard"; diff --git a/apps/code/src/renderer/features/mcp-servers/components/parts/McpInstalledRail.tsx b/packages/ui/src/features/mcp-servers/components/parts/McpInstalledRail.tsx similarity index 81% rename from apps/code/src/renderer/features/mcp-servers/components/parts/McpInstalledRail.tsx rename to packages/ui/src/features/mcp-servers/components/parts/McpInstalledRail.tsx index 13665b15a0..6070a189a2 100644 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/McpInstalledRail.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/McpInstalledRail.tsx @@ -1,5 +1,15 @@ -import { filterInstallationsByQuery } from "@features/mcp-servers/hooks/mcpFilters"; import { MagnifyingGlass, Plus, X } from "@phosphor-icons/react"; +import type { + McpRecommendedServer, + McpServerInstallation, +} from "@posthog/api-client/posthog-client"; +import { filterInstallationsByQuery } from "@posthog/core/mcp-servers/filters"; +import { + resolveServerName, + sortInstallationsByName, +} from "@posthog/core/mcp-servers/resolveServerName"; +import { getInstallationStatus } from "@posthog/core/mcp-servers/status"; +import { PULSE_COLOR } from "@posthog/ui/features/mcp-servers/components/parts/statusBadge"; import { Flex, IconButton, @@ -7,19 +17,8 @@ import { Text, TextField, } from "@radix-ui/themes"; -import type { - McpRecommendedServer, - McpServerInstallation, -} from "@renderer/api/posthogClient"; import { useMemo, useState } from "react"; import { ServerIcon } from "./icons"; -import { getInstallationStatus, type InstallationStatus } from "./statusBadge"; - -const PULSE_COLOR: Record<InstallationStatus, string> = { - connected: "var(--green-9)", - pending_oauth: "var(--amber-9)", - needs_reauth: "var(--red-9)", -}; interface McpInstalledRailProps { installations: McpServerInstallation[]; @@ -44,32 +43,14 @@ export function McpInstalledRail({ return map; }, [templates]); - const resolveName = (installation: McpServerInstallation) => { - const template = installation.template_id - ? templatesById.get(installation.template_id) - : null; - return ( - installation.display_name || - installation.name || - template?.name || - installation.url || - "Server" - ); - }; - const visibleInstallations = useMemo(() => { const filtered = filterInstallationsByQuery( installations, templatesById, search, ); - return [...filtered].sort((a, b) => - resolveName(a).localeCompare(resolveName(b), undefined, { - sensitivity: "base", - }), - ); - // biome-ignore lint/correctness/useExhaustiveDependencies: resolveName closes over templatesById already in deps - }, [installations, templatesById, search, resolveName]); + return sortInstallationsByName(filtered, templatesById); + }, [installations, templatesById, search]); return ( <aside className="flex h-full min-h-0 w-[256px] shrink-0 flex-col border-gray-6 border-r bg-gray-2"> @@ -155,12 +136,7 @@ export function McpInstalledRail({ const template = installation.template_id ? (templatesById.get(installation.template_id) ?? null) : null; - const name = - installation.display_name || - installation.name || - template?.name || - installation.url || - "Server"; + const name = resolveServerName(installation, template); const status = getInstallationStatus(installation); const active = selectedInstallationId === installation.id; return ( diff --git a/apps/code/src/renderer/features/mcp-servers/components/parts/ServerCard.tsx b/packages/ui/src/features/mcp-servers/components/parts/ServerCard.tsx similarity index 98% rename from apps/code/src/renderer/features/mcp-servers/components/parts/ServerCard.tsx rename to packages/ui/src/features/mcp-servers/components/parts/ServerCard.tsx index 05ed5f31c3..c37960cd6e 100644 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/ServerCard.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/ServerCard.tsx @@ -1,9 +1,9 @@ import { CaretRight, CheckCircle } from "@phosphor-icons/react"; -import { Badge, Button, Flex, Spinner, Text } from "@radix-ui/themes"; import { MCP_CATEGORIES, type McpRecommendedServer, -} from "@renderer/api/posthogClient"; +} from "@posthog/api-client/posthog-client"; +import { Badge, Button, Flex, Spinner, Text } from "@radix-ui/themes"; import { ServerIcon } from "./icons"; interface ServerCardProps { diff --git a/apps/code/src/renderer/features/mcp-servers/components/parts/ServerDetailView.tsx b/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx similarity index 88% rename from apps/code/src/renderer/features/mcp-servers/components/parts/ServerDetailView.tsx rename to packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx index 905e9da2b4..cfb0549129 100644 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/ServerDetailView.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx @@ -1,4 +1,3 @@ -import { useMcpInstallationTools } from "@features/mcp-servers/hooks/useMcpInstallationTools"; import { ArrowClockwise, ArrowLeft, @@ -11,6 +10,26 @@ import { Trash, X, } from "@phosphor-icons/react"; +import type { + McpRecommendedServer, + McpServerInstallation, +} from "@posthog/api-client/posthog-client"; +import { resolveServerDetails } from "@posthog/core/mcp-servers/resolveServerName"; +import { getInstallationStatus } from "@posthog/core/mcp-servers/status"; +import { + countActiveTools, + countRemovedTools, + countToolsByApproval, + filterToolsByName, + sortToolsForDisplay, +} from "@posthog/core/mcp-servers/toolDerivation"; +import { ServerIcon } from "@posthog/ui/features/mcp-servers/components/parts/icons"; +import { + STATUS_COLORS, + STATUS_LABELS, +} from "@posthog/ui/features/mcp-servers/components/parts/statusBadge"; +import { ToolRow } from "@posthog/ui/features/mcp-servers/components/parts/ToolRow"; +import { useMcpInstallationTools } from "@posthog/ui/features/mcp-servers/hooks/useMcpInstallationTools"; import { Badge, Button, @@ -23,19 +42,7 @@ import { TextField, Tooltip, } from "@radix-ui/themes"; -import type { - McpApprovalState, - McpRecommendedServer, - McpServerInstallation, -} from "@renderer/api/posthogClient"; import { useMemo, useState } from "react"; -import { ServerIcon } from "./icons"; -import { - getInstallationStatus, - STATUS_COLORS, - STATUS_LABELS, -} from "./statusBadge"; -import { ToolRow } from "./ToolRow"; interface ServerDetailViewProps { installation: McpServerInstallation | null; @@ -65,16 +72,8 @@ export function ServerDetailView({ const [showRemoved, setShowRemoved] = useState(false); const [toolSearch, setToolSearch] = useState(""); - const name = - installation?.display_name || - installation?.name || - template?.name || - installation?.url || - "Server"; - const description = installation?.description || template?.description || ""; - const docsUrl = template?.docs_url || null; - const iconKey = installation?.icon_key || template?.icon_key || null; - const authType = installation?.auth_type || template?.auth_type; + const { name, description, docsUrl, iconKey, authType } = + resolveServerDetails(installation, template); const { tools, @@ -93,33 +92,16 @@ export function ServerDetailView({ const statusLabel = status ? STATUS_LABELS[status] : "Not installed"; const statusColor = status ? STATUS_COLORS[status] : "gray"; - const counts = useMemo(() => { - return tools.reduce( - (acc, t) => { - if (t.removed_at || !t.approval_state) return acc; - acc[t.approval_state] = (acc[t.approval_state] ?? 0) + 1; - return acc; - }, - {} as Record<McpApprovalState, number>, - ); - }, [tools]); + const counts = useMemo(() => countToolsByApproval(tools), [tools]); - const visibleTools = useMemo(() => { - return [...tools].sort((a, b) => { - if (!!a.removed_at !== !!b.removed_at) { - return a.removed_at ? 1 : -1; - } - return a.tool_name.localeCompare(b.tool_name); - }); - }, [tools]); + const visibleTools = useMemo(() => sortToolsForDisplay(tools), [tools]); - const filteredTools = useMemo(() => { - if (!toolSearch) return visibleTools; - const term = toolSearch.toLowerCase(); - return visibleTools.filter((t) => t.tool_name.toLowerCase().includes(term)); - }, [visibleTools, toolSearch]); + const filteredTools = useMemo( + () => filterToolsByName(visibleTools, toolSearch), + [visibleTools, toolSearch], + ); - const removedCount = tools.filter((t) => !!t.removed_at).length; + const removedCount = countRemovedTools(tools); return ( <Flex direction="column" gap="4" className="min-w-0"> @@ -228,7 +210,7 @@ export function ServerDetailView({ <Flex align="center" gap="3"> <Text className="font-medium text-base">Tools</Text> <Badge color="gray" variant="soft" size="1"> - {tools.filter((t) => !t.removed_at).length} + {countActiveTools(tools)} </Badge> <Flex gap="2"> {counts.approved ? ( diff --git a/apps/code/src/renderer/features/mcp-servers/components/parts/ToolPolicyToggle.tsx b/packages/ui/src/features/mcp-servers/components/parts/ToolPolicyToggle.tsx similarity index 96% rename from apps/code/src/renderer/features/mcp-servers/components/parts/ToolPolicyToggle.tsx rename to packages/ui/src/features/mcp-servers/components/parts/ToolPolicyToggle.tsx index b86685f414..1a453c6d46 100644 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/ToolPolicyToggle.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/ToolPolicyToggle.tsx @@ -1,6 +1,6 @@ import { Check, Prohibit, Shield } from "@phosphor-icons/react"; +import type { McpApprovalState } from "@posthog/api-client/posthog-client"; import { Tooltip } from "@radix-ui/themes"; -import type { McpApprovalState } from "@renderer/api/posthogClient"; interface ToolPolicyToggleProps { value: McpApprovalState; diff --git a/apps/code/src/renderer/features/mcp-servers/components/parts/ToolRow.tsx b/packages/ui/src/features/mcp-servers/components/parts/ToolRow.tsx similarity index 98% rename from apps/code/src/renderer/features/mcp-servers/components/parts/ToolRow.tsx rename to packages/ui/src/features/mcp-servers/components/parts/ToolRow.tsx index 8f330bc4a0..13a743ac46 100644 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/ToolRow.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/ToolRow.tsx @@ -1,9 +1,9 @@ import { CaretDown, CaretRight } from "@phosphor-icons/react"; -import { Badge, Flex, Text } from "@radix-ui/themes"; import type { McpApprovalState, McpInstallationTool, -} from "@renderer/api/posthogClient"; +} from "@posthog/api-client/posthog-client"; +import { Badge, Flex, Text } from "@radix-ui/themes"; import { useState } from "react"; import { ToolPolicyToggle } from "./ToolPolicyToggle"; diff --git a/packages/ui/src/features/mcp-servers/components/parts/icons.tsx b/packages/ui/src/features/mcp-servers/components/parts/icons.tsx new file mode 100644 index 0000000000..ebf66d8611 --- /dev/null +++ b/packages/ui/src/features/mcp-servers/components/parts/icons.tsx @@ -0,0 +1,114 @@ +import { Plugs } from "@phosphor-icons/react"; +import { Flex } from "@radix-ui/themes"; +import IconAirOps from "../../../../assets/services/airops.png"; +import IconAtlassian from "../../../../assets/services/atlassian.svg"; +import IconAttio from "../../../../assets/services/attio.png"; +import IconBox from "../../../../assets/services/box.svg"; +import IconBrowserbase from "../../../../assets/services/browserbase.svg"; +import IconCanva from "../../../../assets/services/canva.svg"; +import IconCircle from "../../../../assets/services/circle.png"; +import IconCiscoThousandEyes from "../../../../assets/services/cisco_thousandeyes.png"; +import IconClerk from "../../../../assets/services/clerk.svg"; +import IconClickHouse from "../../../../assets/services/clickhouse.svg"; +import IconCloudflare from "../../../../assets/services/cloudflare.svg"; +import IconContext7 from "../../../../assets/services/context7.svg"; +import IconDatadog from "../../../../assets/services/datadog.svg"; +import IconFigma from "../../../../assets/services/figma.svg"; +import IconFiretiger from "../../../../assets/services/firetiger.svg"; +import IconGitHub from "../../../../assets/services/github.svg"; +import IconGitLab from "../../../../assets/services/gitlab.svg"; +import IconHex from "../../../../assets/services/hex.svg"; +import IconHubSpot from "../../../../assets/services/hubspot.svg"; +import IconLaunchDarkly from "../../../../assets/services/launchdarkly.png"; +import IconLinear from "../../../../assets/services/linear.svg"; +import IconMonday from "../../../../assets/services/monday.svg"; +import IconNeon from "../../../../assets/services/neon.svg"; +import IconNotion from "../../../../assets/services/notion.svg"; +import IconPagerDuty from "../../../../assets/services/pagerduty.svg"; +import IconPlanetScale from "../../../../assets/services/planetscale.svg"; +import IconPostman from "../../../../assets/services/postman.svg"; +import IconPrisma from "../../../../assets/services/prisma.svg"; +import IconRender from "../../../../assets/services/render.svg"; +import IconSanity from "../../../../assets/services/sanity.svg"; +import IconSentry from "../../../../assets/services/sentry.svg"; +import IconSlack from "../../../../assets/services/slack.png"; +import IconStripe from "../../../../assets/services/stripe.png"; +import IconSupabase from "../../../../assets/services/supabase.svg"; +import IconSvelte from "../../../../assets/services/svelte.png"; +import IconWix from "../../../../assets/services/wix.png"; + +const BRAND_ICONS: Record<string, string> = { + airops: IconAirOps, + atlassian: IconAtlassian, + attio: IconAttio, + box: IconBox, + browserbase: IconBrowserbase, + canva: IconCanva, + circle: IconCircle, + cisco_thousandeyes: IconCiscoThousandEyes, + clerk: IconClerk, + clickhouse: IconClickHouse, + cloudflare: IconCloudflare, + context7: IconContext7, + datadog: IconDatadog, + figma: IconFigma, + firetiger: IconFiretiger, + github: IconGitHub, + gitlab: IconGitLab, + hex: IconHex, + hubspot: IconHubSpot, + launchdarkly: IconLaunchDarkly, + linear: IconLinear, + monday: IconMonday, + neon: IconNeon, + notion: IconNotion, + pagerduty: IconPagerDuty, + planetscale: IconPlanetScale, + postman: IconPostman, + prisma: IconPrisma, + render: IconRender, + sanity: IconSanity, + sentry: IconSentry, + slack: IconSlack, + stripe: IconStripe, + supabase: IconSupabase, + svelte: IconSvelte, + wix: IconWix, +}; + +export function resolveServerIcon( + iconKey: string | null | undefined, +): string | undefined { + return iconKey ? BRAND_ICONS[iconKey] : undefined; +} + +interface ServerIconProps { + iconKey?: string | null; + size?: number; + className?: string; +} + +export function ServerIcon({ iconKey, size = 32, className }: ServerIconProps) { + const src = resolveServerIcon(iconKey); + const dimension = `${size}px`; + const radius = 2; + return ( + <Flex + align="center" + justify="center" + className={`shrink-0 overflow-hidden ${className ?? ""}`} + style={{ width: dimension, height: dimension, borderRadius: radius }} + > + {src ? ( + <img + src={src} + alt="" + className="size-full object-contain" + style={{ borderRadius: radius }} + /> + ) : ( + <Plugs size={Math.round(size * 0.55)} className="text-gray-11" /> + )} + </Flex> + ); +} diff --git a/packages/ui/src/features/mcp-servers/components/parts/statusBadge.ts b/packages/ui/src/features/mcp-servers/components/parts/statusBadge.ts new file mode 100644 index 0000000000..8e13c9f32c --- /dev/null +++ b/packages/ui/src/features/mcp-servers/components/parts/statusBadge.ts @@ -0,0 +1,22 @@ +import type { InstallationStatus } from "@posthog/core/mcp-servers/status"; + +export const STATUS_LABELS: Record<InstallationStatus, string> = { + connected: "Connected", + pending_oauth: "Finish connecting", + needs_reauth: "Reconnect required", +}; + +export const STATUS_COLORS: Record< + InstallationStatus, + "green" | "amber" | "red" +> = { + connected: "green", + pending_oauth: "amber", + needs_reauth: "red", +}; + +export const PULSE_COLOR: Record<InstallationStatus, string> = { + connected: "var(--green-9)", + pending_oauth: "var(--amber-9)", + needs_reauth: "var(--red-9)", +}; diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/useMcpInstallationTools.ts b/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts similarity index 74% rename from apps/code/src/renderer/features/mcp-servers/hooks/useMcpInstallationTools.ts rename to packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts index 86fe802b03..4caa73d3b7 100644 --- a/apps/code/src/renderer/features/mcp-servers/hooks/useMcpInstallationTools.ts +++ b/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts @@ -1,15 +1,16 @@ -import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { McpApprovalState, McpInstallationTool, -} from "@renderer/api/posthogClient"; -import { useTRPC } from "@renderer/trpc/client"; +} from "@posthog/api-client/posthog-client"; +import { dispatchBulkApproval } from "@posthog/core/mcp-servers/toolBulk"; +import { shouldAutoRefreshTools } from "@posthog/core/mcp-servers/toolRefresh"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import { useCallback, useEffect, useRef } from "react"; import { toast } from "sonner"; -import { dispatchBulkApproval } from "./mcpToolBulk"; import { mcpKeys } from "./useMcpServers"; interface UseMcpInstallationToolsOptions { @@ -26,7 +27,7 @@ export function useMcpInstallationTools( installationId: string | null, options: UseMcpInstallationToolsOptions = {}, ) { - const trpcReact = useTRPC(); + const trpc = useHostTRPC(); const queryClient = useQueryClient(); const queryKey = [ @@ -133,27 +134,17 @@ export function useMcpInstallationTools( const refreshIsPending = refreshMutation.isPending; const refreshMutate = refreshMutation.mutate; - // Auto-fire the same call as the manual Refresh button when the detail - // panel opens to a freshly-connected installation that hasn't synced its - // tools yet. The guards exist because each one stops a different misfire: - // - autoRefreshIfEmpty: opt-in; only the detail view passes it - // - installationId: nothing to refresh without one - // - isLoading: tools query hasn't settled — wait, we don't - // know yet whether it's empty - // - toolsLength > 0: tools already synced; no refresh needed - // - autoRefreshedInstallations.has(...): already auto-refreshed this - // installation in this session — don't re-fire - // on every revisit (covers genuinely-empty - // servers too) - // - refreshIsPending: refresh already in flight (e.g. user clicked - // the manual button in the same render cycle) useEffect(() => { - if (!options.autoRefreshIfEmpty) return; if (!installationId) return; - if (isLoading) return; - if (toolsLength > 0) return; - if (autoRefreshedInstallations.has(installationId)) return; - if (refreshIsPending) return; + const fire = shouldAutoRefreshTools({ + autoRefreshIfEmpty: !!options.autoRefreshIfEmpty, + installationId, + isLoading, + toolsLength, + alreadyRefreshed: autoRefreshedInstallations.has(installationId), + refreshPending: refreshIsPending, + }); + if (!fire) return; autoRefreshedInstallations.add(installationId); silentRefreshRef.current = true; refreshMutate(undefined); @@ -167,7 +158,7 @@ export function useMcpInstallationTools( ]); useSubscription( - trpcReact.mcpCallback.onOAuthComplete.subscriptionOptions(undefined, { + trpc.mcpCallback.onOAuthComplete.subscriptionOptions(undefined, { onData: (data) => { if (data.status === "success") { invalidate(); diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/useMcpServers.ts b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts similarity index 68% rename from apps/code/src/renderer/features/mcp-servers/hooks/useMcpServers.ts rename to packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts index 50074a794d..50ca0dac80 100644 --- a/apps/code/src/renderer/features/mcp-servers/hooks/useMcpServers.ts +++ b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts @@ -1,12 +1,17 @@ -import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { McpAuthType, McpRecommendedServer, McpServerInstallation, - PostHogAPIClient, -} from "@renderer/api/posthogClient"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; +} from "@posthog/api-client/posthog-client"; +import { + type IOAuthCallback, + installCustomWithOAuth, + installTemplateWithOAuth, + reauthorizeWithOAuth, +} from "@posthog/core/mcp-servers/installFlow"; +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import { useCallback, useMemo, useState } from "react"; @@ -19,66 +24,20 @@ export const mcpKeys = { ["mcp", "installations", installationId, "tools"] as const, }; -/** - * Run the OAuth install flow for an MCP server. - * Gets callback URL, calls the API, and (if a redirect_url comes back) opens the - * browser and waits for the callback. - */ -async function runOAuthInstall( - redirectUrl: string, -): Promise<{ success?: boolean; error?: string }> { - return trpcClient.mcpCallback.openAndWaitForCallback.mutate({ redirectUrl }); -} - -async function getCallbackUrl(): Promise<string> { - const { callbackUrl } = await trpcClient.mcpCallback.getCallbackUrl.query(); - return callbackUrl; -} +type HostTRPCClient = ReturnType<typeof useHostTRPCClient>; -async function installTemplateWithOAuth( - client: PostHogAPIClient, - vars: { template_id: string; api_key?: string }, -) { - const callbackUrl = await getCallbackUrl(); - const data = await client.installMcpTemplate({ - ...vars, - install_source: "posthog-code", - posthog_code_callback_url: callbackUrl, - }); - if ("redirect_url" in data && data.redirect_url) { - return runOAuthInstall(data.redirect_url); - } - return { success: true }; -} - -async function installCustomWithOAuth( - client: PostHogAPIClient, - vars: { - name: string; - url: string; - description: string; - auth_type: McpAuthType; - api_key?: string; - client_id?: string; - client_secret?: string; - }, -) { - const callbackUrl = await getCallbackUrl(); - const data = await client.installCustomMcpServer({ - ...vars, - install_source: "posthog-code", - posthog_code_callback_url: callbackUrl, - }); - if ("redirect_url" in data && data.redirect_url) { - return runOAuthInstall(data.redirect_url); - } - return { success: true }; +function createOAuthCallback(trpcClient: HostTRPCClient): IOAuthCallback { + return { + getCallbackUrl: () => trpcClient.mcpCallback.getCallbackUrl.query(), + openAndWaitForCallback: (args) => + trpcClient.mcpCallback.openAndWaitForCallback.mutate(args), + }; } -export { filterServersByCategory, filterServersByQuery } from "./mcpFilters"; - export function useMcpServers() { - const trpcReact = useTRPC(); + const trpc = useHostTRPC(); + const trpcClient = useHostTRPCClient(); + const oauth = useMemo(() => createOAuthCallback(trpcClient), [trpcClient]); const [installingId, setInstallingId] = useState<string | null>(null); const queryClient = useQueryClient(); @@ -152,7 +111,7 @@ export function useMcpServers() { const installTemplateMutation = useAuthenticatedMutation( (client, vars: { template_id: string; api_key?: string }) => - installTemplateWithOAuth(client, vars), + installTemplateWithOAuth(client, oauth, vars), { onSuccess: (data) => { if (data && "success" in data && data.success) { @@ -193,7 +152,7 @@ export function useMcpServers() { client_id?: string; client_secret?: string; }, - ) => installCustomWithOAuth(client, vars), + ) => installCustomWithOAuth(client, oauth, vars), { onSuccess: (data) => { if (data && "success" in data && data.success) { @@ -210,15 +169,8 @@ export function useMcpServers() { ); const reauthorizeMutation = useAuthenticatedMutation( - async (client, installationId: string) => { - const callbackUrl = await getCallbackUrl(); - const data = await client.authorizeMcpInstallation({ - installation_id: installationId, - install_source: "posthog-code", - posthog_code_callback_url: callbackUrl, - }); - return runOAuthInstall(data.redirect_url); - }, + (client, installationId: string) => + reauthorizeWithOAuth(client, oauth, installationId), { onSuccess: (data) => { if (data && "success" in data && data.success) { @@ -235,7 +187,7 @@ export function useMcpServers() { ); useSubscription( - trpcReact.mcpCallback.onOAuthComplete.subscriptionOptions(undefined, { + trpc.mcpCallback.onOAuthComplete.subscriptionOptions(undefined, { onData: (data) => { if (data.status === "success") { invalidateInstallations(); diff --git a/apps/code/src/renderer/features/message-editor/analytics.ts b/packages/ui/src/features/message-editor/analytics.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/analytics.ts rename to packages/ui/src/features/message-editor/analytics.ts diff --git a/apps/code/src/renderer/features/message-editor/commands.ts b/packages/ui/src/features/message-editor/commands.ts similarity index 71% rename from apps/code/src/renderer/features/message-editor/commands.ts rename to packages/ui/src/features/message-editor/commands.ts index 04deb7efb6..d9e30f0fb6 100644 --- a/apps/code/src/renderer/features/message-editor/commands.ts +++ b/packages/ui/src/features/message-editor/commands.ts @@ -1,10 +1,18 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk"; -import { useAddDirectoryDialogStore } from "@features/folder-picker/stores/addDirectoryDialogStore"; -import { trpcClient } from "@renderer/trpc/client"; -import { ANALYTICS_EVENTS, type FeedbackType } from "@shared/types/analytics"; +import { + basename, + buildFeedbackEventPayload, + parseCommandLine, +} from "@posthog/core/message-editor/commands"; +import { + ANALYTICS_EVENTS, + type FeedbackType, +} from "@posthog/shared/analytics-events"; +import { useAddDirectoryDialogStore } from "@posthog/ui/features/folder-picker/addDirectoryDialogStore"; +import { toast } from "@posthog/ui/primitives/toast"; import type { Editor } from "@tiptap/core"; -import { track } from "@utils/analytics"; -import { toast } from "@utils/toast"; +import { track } from "../../workbench/analytics"; +import { selectDirectory } from "./hostApi"; import type { MentionChipAttrs } from "./tiptap/MentionChipNode"; interface CommandContext { @@ -39,12 +47,6 @@ interface CodeCommand { ) => Promise<void> | void; } -function basename(path: string): string { - const trimmed = path.replace(/[\\/]+$/, ""); - const idx = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - return idx >= 0 ? trimmed.slice(idx + 1) || trimmed : trimmed; -} - function makeFeedbackCommand( name: string, feedbackType: FeedbackType, @@ -55,14 +57,17 @@ function makeFeedbackCommand( description: `Capture ${label.toLowerCase()} feedback`, input: { hint: "optional comment" }, execute(args, ctx) { - track(ANALYTICS_EVENTS.TASK_FEEDBACK, { - task_id: ctx.taskId, - task_run_id: ctx.session?.taskRunId ?? ctx.taskRun?.id, - log_url: ctx.session?.logUrl ?? ctx.taskRun?.log_url, - event_count: ctx.session?.events.length ?? 0, - feedback_type: feedbackType, - feedback_comment: args?.trim() || undefined, - }); + track( + ANALYTICS_EVENTS.TASK_FEEDBACK, + buildFeedbackEventPayload({ + taskId: ctx.taskId, + taskRunId: ctx.session?.taskRunId ?? ctx.taskRun?.id, + logUrl: ctx.session?.logUrl ?? ctx.taskRun?.log_url, + eventCount: ctx.session?.events.length ?? 0, + feedbackType, + comment: args, + }), + ); toast.success(`${label} feedback captured`); }, }; @@ -74,7 +79,7 @@ const addDirCommand: CodeCommand = { async onInsert(ctx) { const taskId = ctx.sessionId; try { - const path = await trpcClient.os.selectDirectory.query(); + const path = await selectDirectory(); if (!path) { ctx.editor.commands.removeMentionChipById(ctx.chipId); return; @@ -120,12 +125,12 @@ export async function tryExecuteCodeCommand( text: string, context: CommandContext, ): Promise<boolean> { - const match = text.match(/^\/(\S+)(?:\s+(.*))?$/); - if (!match) return false; + const parsed = parseCommandLine(text); + if (!parsed) return false; - const cmd = commandMap.get(match[1]); + const cmd = commandMap.get(parsed.name); if (!cmd?.execute) return false; - await cmd.execute(match[2], context); + await cmd.execute(parsed.args, context); return true; } diff --git a/apps/code/src/renderer/features/message-editor/components/AdapterIndicator.tsx b/packages/ui/src/features/message-editor/components/AdapterIndicator.tsx similarity index 100% rename from apps/code/src/renderer/features/message-editor/components/AdapterIndicator.tsx rename to packages/ui/src/features/message-editor/components/AdapterIndicator.tsx diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx b/packages/ui/src/features/message-editor/components/AttachmentMenu.test.tsx similarity index 89% rename from apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx rename to packages/ui/src/features/message-editor/components/AttachmentMenu.test.tsx index a4a5570590..df67676788 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.test.tsx +++ b/packages/ui/src/features/message-editor/components/AttachmentMenu.test.tsx @@ -47,34 +47,23 @@ vi.mock("@posthog/quill", () => ({ ComboboxList: () => null, })); -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - os: { - selectAttachments: { - query: mockSelectAttachments, - }, - downscaleImageFile: { - mutate: mockDownscaleImageFile, - }, - }, +vi.mock("../hostApi", () => ({ + selectAttachments: mockSelectAttachments, + getGhStatus: vi.fn(), + searchGithubRefs: vi.fn(), + filePersistHost: { + saveClipboardImage: vi.fn(), + saveClipboardText: vi.fn(), + saveClipboardFile: vi.fn(), + downscaleImageFile: mockDownscaleImageFile, }, - useTRPC: () => ({ - git: { - getGhStatus: { - queryOptions: () => ({}), - }, - searchGithubRefs: { - queryOptions: () => ({}), - }, - }, - }), })); vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: undefined }), })); -vi.mock("@renderer/utils/toast", () => ({ +vi.mock("@posthog/ui/primitives/toast", () => ({ toast: { error: vi.fn(), }, diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx b/packages/ui/src/features/message-editor/components/AttachmentMenu.tsx similarity index 93% rename from apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx rename to packages/ui/src/features/message-editor/components/AttachmentMenu.tsx index 32170ea698..abdc639524 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentMenu.tsx +++ b/packages/ui/src/features/message-editor/components/AttachmentMenu.tsx @@ -1,10 +1,14 @@ -import { useAddDirectoryDialogStore } from "@features/folder-picker/stores/addDirectoryDialogStore"; import { File, FolderSimple, GithubLogo, Paperclip, } from "@phosphor-icons/react"; +import { + deriveFileLabel, + type FileAttachment, + type MentionChip, +} from "@posthog/core/message-editor/content"; import { Button, DropdownMenu, @@ -13,15 +17,11 @@ import { DropdownMenuTrigger, } from "@posthog/quill"; import { isRasterImageFile } from "@posthog/shared"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { toast } from "@renderer/utils/toast"; +import { useAddDirectoryDialogStore } from "@posthog/ui/features/folder-picker/addDirectoryDialogStore"; +import { toast } from "@posthog/ui/primitives/toast"; import { useQuery } from "@tanstack/react-query"; import { useRef, useState } from "react"; -import { - deriveFileLabel, - type FileAttachment, - type MentionChip, -} from "../utils/content"; +import { getGhStatus, selectAttachments } from "../hostApi"; import { persistBrowserFile, persistImageFilePath, @@ -70,12 +70,11 @@ export function AttachmentMenu({ const paperclipRef = useRef<HTMLButtonElement>(null); const showAddDirectoryDialog = useAddDirectoryDialogStore((s) => s.show); - const trpc = useTRPC(); - const { data: ghStatus } = useQuery( - trpc.git.getGhStatus.queryOptions(undefined, { - staleTime: 60_000, - }), - ); + const { data: ghStatus } = useQuery({ + queryKey: ["git", "getGhStatus"], + queryFn: () => getGhStatus(), + staleTime: 60_000, + }); const issueDisabledReason = getIssueDisabledReason(ghStatus, repoPath); @@ -121,7 +120,7 @@ export function AttachmentMenu({ setMenuOpen(false); try { - const results = await trpcClient.os.selectAttachments.query({ mode }); + const results = await selectAttachments({ mode }); for (const { path: filePath, kind } of results) { if (kind === "file" && isRasterImageFile(filePath)) { try { diff --git a/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx b/packages/ui/src/features/message-editor/components/AttachmentsBar.tsx similarity index 92% rename from apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx rename to packages/ui/src/features/message-editor/components/AttachmentsBar.tsx index 5ca7265333..daed1510fe 100644 --- a/apps/code/src/renderer/features/message-editor/components/AttachmentsBar.tsx +++ b/packages/ui/src/features/message-editor/components/AttachmentsBar.tsx @@ -1,15 +1,15 @@ -import { SafeImagePreview } from "@components/ui/SafeImagePreview"; import { File, X } from "@phosphor-icons/react"; +import type { FileAttachment } from "@posthog/core/message-editor/content"; import { isGifFile, isRasterImageFile, parseImageDataUrl, } from "@posthog/shared"; +import { SafeImagePreview } from "@posthog/ui/primitives/SafeImagePreview"; import { Dialog, Flex, IconButton, Text } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; -import type { FileAttachment } from "../utils/content"; +import { readFileAsDataUrl } from "../hostApi"; function FrozenGifThumbnail({ src, alt }: { src: string; alt: string }) { const canvasRef = useRef<HTMLCanvasElement>(null); @@ -44,13 +44,11 @@ function ImageThumbnail({ attachment: FileAttachment; onRemove: () => void; }) { - const trpcReact = useTRPC(); - const { data: dataUrl } = useQuery( - trpcReact.os.readFileAsDataUrl.queryOptions( - { filePath: attachment.id }, - { staleTime: Infinity }, - ), - ); + const { data: dataUrl } = useQuery({ + queryKey: ["os", "readFileAsDataUrl", attachment.id], + queryFn: () => readFileAsDataUrl({ filePath: attachment.id }), + staleTime: Infinity, + }); const isGif = isGifFile(attachment.label); const parsedImage = dataUrl ? parseImageDataUrl(dataUrl) : null; diff --git a/apps/code/src/renderer/features/message-editor/components/IssuePicker.tsx b/packages/ui/src/features/message-editor/components/IssuePicker.tsx similarity index 80% rename from apps/code/src/renderer/features/message-editor/components/IssuePicker.tsx rename to packages/ui/src/features/message-editor/components/IssuePicker.tsx index e6cedea3fe..eebd6a13ea 100644 --- a/apps/code/src/renderer/features/message-editor/components/IssuePicker.tsx +++ b/packages/ui/src/features/message-editor/components/IssuePicker.tsx @@ -1,4 +1,8 @@ -import { useDebounce } from "@hooks/useDebounce"; +import type { MentionChip } from "@posthog/core/message-editor/content"; +import { + githubIssueToMentionChip, + githubPullRequestToMentionChip, +} from "@posthog/core/message-editor/githubIssueChip"; import { Combobox, ComboboxContent, @@ -7,17 +11,13 @@ import { ComboboxItem, ComboboxList, } from "@posthog/quill"; -import { useTRPC } from "@renderer/trpc/client"; +import { useDebounce } from "@posthog/ui/primitives/hooks/useDebounce"; import { useQuery } from "@tanstack/react-query"; import { useEffect, useState } from "react"; +import { IssueRow } from "../components/IssueRow"; +import { SuggestionStatus } from "../components/SuggestionStatus"; +import { searchGithubRefs } from "../hostApi"; import type { GithubRefKind, GithubRefState } from "../types"; -import type { MentionChip } from "../utils/content"; -import { - githubIssueToMentionChip, - githubPullRequestToMentionChip, -} from "../utils/githubIssueChip"; -import { IssueRow } from "./IssueRow"; -import { SuggestionStatus } from "./SuggestionStatus"; interface IssuePickerProps { repoPath: string; @@ -45,7 +45,6 @@ export function IssuePicker({ onSelect, anchor, }: IssuePickerProps) { - const trpc = useTRPC(); const [query, setQuery] = useState(""); const debouncedQuery = useDebounce(query, open ? 300 : 0); @@ -53,16 +52,17 @@ export function IssuePicker({ if (!open) setQuery(""); }, [open]); - const { data: refs = [], isFetching } = useQuery( - trpc.git.searchGithubRefs.queryOptions( - { + const { data: refs = [], isFetching } = useQuery({ + queryKey: ["git", "searchGithubRefs", repoPath, debouncedQuery || ""], + queryFn: () => + searchGithubRefs({ directoryPath: repoPath, query: debouncedQuery || undefined, limit: 25, - }, - { staleTime: 30_000, enabled: open && !!repoPath }, - ), - ); + }), + staleTime: 30_000, + enabled: open && !!repoPath, + }); const isLoading = isFetching || (open && query !== debouncedQuery); diff --git a/apps/code/src/renderer/features/message-editor/components/IssueRow.tsx b/packages/ui/src/features/message-editor/components/IssueRow.tsx similarity index 94% rename from apps/code/src/renderer/features/message-editor/components/IssueRow.tsx rename to packages/ui/src/features/message-editor/components/IssueRow.tsx index b22645086c..73caefbe92 100644 --- a/apps/code/src/renderer/features/message-editor/components/IssueRow.tsx +++ b/packages/ui/src/features/message-editor/components/IssueRow.tsx @@ -1,3 +1,4 @@ +import { githubIssueStateColor } from "@posthog/core/message-editor/githubIssueChip"; import { Item, ItemContent, @@ -6,7 +7,6 @@ import { ItemTitle, } from "@posthog/quill"; import type { GithubRefKind, GithubRefState } from "../types"; -import { githubIssueStateColor } from "../utils/githubIssueChip"; export interface IssueRowData { kind: GithubRefKind; diff --git a/apps/code/src/renderer/features/message-editor/components/ModeSelector.tsx b/packages/ui/src/features/message-editor/components/ModeSelector.tsx similarity index 97% rename from apps/code/src/renderer/features/message-editor/components/ModeSelector.tsx rename to packages/ui/src/features/message-editor/components/ModeSelector.tsx index 5d288fbbd2..1572391d60 100644 --- a/apps/code/src/renderer/features/message-editor/components/ModeSelector.tsx +++ b/packages/ui/src/features/message-editor/components/ModeSelector.tsx @@ -18,7 +18,7 @@ import { DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; -import { flattenSelectOptions } from "@renderer/features/sessions/stores/sessionStore"; +import { flattenSelectOptions } from "@posthog/ui/features/sessions/sessionStore"; import { useRef, useState } from "react"; interface ModeStyle { diff --git a/apps/code/src/renderer/features/message-editor/components/PromptHistoryDialog.tsx b/packages/ui/src/features/message-editor/components/PromptHistoryDialog.tsx similarity index 95% rename from apps/code/src/renderer/features/message-editor/components/PromptHistoryDialog.tsx rename to packages/ui/src/features/message-editor/components/PromptHistoryDialog.tsx index d6814e7ae3..20fd4322da 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptHistoryDialog.tsx +++ b/packages/ui/src/features/message-editor/components/PromptHistoryDialog.tsx @@ -11,13 +11,13 @@ import { TooltipProvider, TooltipTrigger, } from "@posthog/quill"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; -import { showMessageBox } from "@utils/dialog"; -import { formatRelativeTimeLong } from "@utils/time"; +import { formatRelativeTimeLong } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useTaskInputHistoryStore } from "@posthog/ui/features/message-editor/taskInputHistoryStore"; +import { showMessageBox } from "@posthog/ui/utils/dialog"; +import { track } from "@posthog/ui/workbench/analytics"; import Fuse from "fuse.js"; import { useMemo, useRef, useState } from "react"; -import { useTaskInputHistoryStore } from "../stores/taskInputHistoryStore"; const COLLAPSED_LIMIT = 180; diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx b/packages/ui/src/features/message-editor/components/PromptInput.stories.tsx similarity index 70% rename from apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx rename to packages/ui/src/features/message-editor/components/PromptInput.stories.tsx index bc67302db8..c1fe514cd9 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx +++ b/packages/ui/src/features/message-editor/components/PromptInput.stories.tsx @@ -1,13 +1,74 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; -import { Providers } from "@components/Providers"; -import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; -import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; -import type { AgentAdapter } from "@features/settings/stores/settingsStore"; +import { setWorkbenchContainer } from "@posthog/di/container"; +import { ServiceProvider } from "@posthog/di/react"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { PromptInput } from "@posthog/ui/features/message-editor/components/PromptInput"; +import type { MentionChip } from "@posthog/ui/features/message-editor/content"; +import type { EditorHandle } from "@posthog/ui/features/message-editor/types"; +import { ReasoningLevelSelector } from "@posthog/ui/features/sessions/components/ReasoningLevelSelector"; +import { UnifiedModelSelector } from "@posthog/ui/features/sessions/components/UnifiedModelSelector"; +import type { AgentAdapter } from "@posthog/ui/features/settings/settingsStore"; +import { IMPERATIVE_QUERY_CLIENT } from "@posthog/ui/workbench/queryClient"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useEffect, useRef, useState } from "react"; -import type { EditorHandle } from "../types"; -import type { MentionChip } from "../utils/content"; -import { PromptInput } from "./PromptInput"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Container } from "inversify"; +import { type ReactNode, useEffect, useRef, useState } from "react"; + +// --- Host-agnostic story providers --- + +const noopHostClient = { + git: { + getGhStatus: { + query: async () => ({ + installed: true, + version: "2.0.0", + authenticated: true, + username: "storybook", + error: null, + }), + }, + searchGithubRefs: { query: async () => [] }, + getGithubPullRequest: { query: async () => null }, + getGithubIssue: { query: async () => null }, + }, + fs: { + listRepoFiles: { query: async () => [] }, + readAbsoluteFile: { query: async () => null }, + }, + os: { + selectDirectory: { query: async () => null }, + selectAttachments: { query: async () => [] }, + readFileAsDataUrl: { query: async () => null }, + saveClipboardImage: { + mutate: async () => ({ path: "", name: "", mimeType: "" }), + }, + saveClipboardText: { mutate: async () => ({ path: "", name: "" }) }, + saveClipboardFile: { mutate: async () => ({ path: "", name: "" }) }, + downscaleImageFile: { mutate: async () => ({ path: "", name: "" }) }, + }, +} as unknown as HostTrpcClient; + +const storyQueryClient = new QueryClient(); + +const storyContainer = new Container(); +storyContainer + .bind<HostTrpcClient>(HOST_TRPC_CLIENT) + .toConstantValue(noopHostClient); +storyContainer.bind(IMPERATIVE_QUERY_CLIENT).toConstantValue(storyQueryClient); +setWorkbenchContainer(storyContainer); + +function StoryProviders({ children }: { children: ReactNode }) { + return ( + <QueryClientProvider client={storyQueryClient}> + <ServiceProvider container={storyContainer}> + <div className="max-w-[800px]">{children}</div> + </ServiceProvider> + </QueryClientProvider> + ); +} // --- Mock data matching SessionConfigOption shape --- @@ -135,11 +196,9 @@ const meta: Meta<typeof PromptInputWithSelectors> = { }, decorators: [ (Story) => ( - <Providers> - <div className="max-w-[800px]"> - <Story /> - </div> - </Providers> + <StoryProviders> + <Story /> + </StoryProviders> ), ], args: { diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/packages/ui/src/features/message-editor/components/PromptInput.tsx similarity index 97% rename from apps/code/src/renderer/features/message-editor/components/PromptInput.tsx rename to packages/ui/src/features/message-editor/components/PromptInput.tsx index 4e54828720..8a7ac00469 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/packages/ui/src/features/message-editor/components/PromptInput.tsx @@ -2,19 +2,19 @@ import "./message-editor.css"; import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { ArrowUp, Stop } from "@phosphor-icons/react"; import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill"; +import { cycleModeOption } from "@posthog/ui/features/sessions/sessionStore"; +import { hasOpenOverlay } from "@posthog/ui/utils/overlay"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; -import { cycleModeOption } from "@renderer/features/sessions/stores/sessionStore"; import { EditorContent } from "@tiptap/react"; -import { hasOpenOverlay } from "@utils/overlay"; import clsx from "clsx"; import { forwardRef, useCallback, useEffect, useImperativeHandle } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -import { useDraftStore } from "../stores/draftStore"; +import { ModeSelector } from "../components/ModeSelector"; +import { useDraftStore } from "../draftStore"; import { useTiptapEditor } from "../tiptap/useTiptapEditor"; import type { EditorHandle } from "../types"; import { AttachmentMenu } from "./AttachmentMenu"; import { AttachmentsBar } from "./AttachmentsBar"; -import { ModeSelector } from "./ModeSelector"; export type { EditorHandle }; diff --git a/apps/code/src/renderer/features/message-editor/components/SuggestionStatus.tsx b/packages/ui/src/features/message-editor/components/SuggestionStatus.tsx similarity index 100% rename from apps/code/src/renderer/features/message-editor/components/SuggestionStatus.tsx rename to packages/ui/src/features/message-editor/components/SuggestionStatus.tsx diff --git a/apps/code/src/renderer/features/message-editor/components/message-editor.css b/packages/ui/src/features/message-editor/components/message-editor.css similarity index 100% rename from apps/code/src/renderer/features/message-editor/components/message-editor.css rename to packages/ui/src/features/message-editor/components/message-editor.css diff --git a/packages/ui/src/features/message-editor/content.ts b/packages/ui/src/features/message-editor/content.ts new file mode 100644 index 0000000000..ee4260bb6b --- /dev/null +++ b/packages/ui/src/features/message-editor/content.ts @@ -0,0 +1,14 @@ +export type { + EditorContent, + FileAttachment, + MentionChip, +} from "@posthog/core/message-editor/content"; +export { + contentToPlainText, + contentToXml, + deriveFileLabel, + extractFilePaths, + isContentEmpty, + xmlToContent, + xmlToPlainText, +} from "@posthog/core/message-editor/content"; diff --git a/apps/code/src/renderer/features/message-editor/stores/draftStore.ts b/packages/ui/src/features/message-editor/draftStore.ts similarity index 96% rename from apps/code/src/renderer/features/message-editor/stores/draftStore.ts rename to packages/ui/src/features/message-editor/draftStore.ts index e57eb3c9b0..e30f43790f 100644 --- a/apps/code/src/renderer/features/message-editor/stores/draftStore.ts +++ b/packages/ui/src/features/message-editor/draftStore.ts @@ -1,9 +1,9 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk"; -import { electronStorage } from "@utils/electronStorage"; +import type { EditorContent } from "@posthog/core/message-editor/content"; +import { electronStorage } from "@posthog/ui/workbench/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; -import type { EditorContent } from "../utils/content"; type SessionId = string; diff --git a/packages/ui/src/features/message-editor/githubIssueUrl.ts b/packages/ui/src/features/message-editor/githubIssueUrl.ts new file mode 100644 index 0000000000..bbf7c92a8a --- /dev/null +++ b/packages/ui/src/features/message-editor/githubIssueUrl.ts @@ -0,0 +1,5 @@ +export type { + GithubRefKind, + ParsedGithubIssueUrl, +} from "@posthog/core/message-editor/githubIssueUrl"; +export { parseGithubIssueUrl } from "@posthog/core/message-editor/githubIssueUrl"; diff --git a/packages/ui/src/features/message-editor/hostApi.ts b/packages/ui/src/features/message-editor/hostApi.ts new file mode 100644 index 0000000000..7574f258b8 --- /dev/null +++ b/packages/ui/src/features/message-editor/hostApi.ts @@ -0,0 +1,109 @@ +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import type { HostRouter } from "@posthog/host-router/router"; +import type { GithubRef } from "@posthog/shared"; +import { + createTRPCOptionsProxy, + type TRPCOptionsProxy, +} from "@trpc/tanstack-react-query"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "../../workbench/queryClient"; +import type { GhStatus, SelectedAttachment } from "./identifiers"; + +function hostClient(): HostTrpcClient { + return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT); +} + +function imperativeQueryClient(): ImperativeQueryClient { + return resolveService<ImperativeQueryClient>(IMPERATIVE_QUERY_CLIENT); +} + +let optionsProxy: TRPCOptionsProxy<HostRouter> | null = null; + +function options(): TRPCOptionsProxy<HostRouter> { + if (!optionsProxy) { + optionsProxy = createTRPCOptionsProxy<HostRouter>({ + client: hostClient(), + queryClient: imperativeQueryClient(), + }); + } + return optionsProxy; +} + +export function searchGithubRefs(input: { + directoryPath: string; + query?: string; + limit?: number; +}): Promise<GithubRef[]> { + return imperativeQueryClient().fetchQuery({ + ...options().git.searchGithubRefs.queryOptions(input), + staleTime: 30_000, + }); +} + +export function getGithubPullRequest(input: { + owner: string; + repo: string; + number: number; +}): Promise<GithubRef | null> { + return imperativeQueryClient().fetchQuery({ + ...options().git.getGithubPullRequest.queryOptions(input), + staleTime: 60_000, + }); +} + +export function getGithubIssue(input: { + owner: string; + repo: string; + number: number; +}): Promise<GithubRef | null> { + return imperativeQueryClient().fetchQuery({ + ...options().git.getGithubIssue.queryOptions(input), + staleTime: 60_000, + }); +} + +export function getGhStatus(): Promise<GhStatus> { + return hostClient().git.getGhStatus.query(); +} + +export function readAbsoluteFile(input: { + filePath: string; +}): Promise<string | null> { + return hostClient().fs.readAbsoluteFile.query(input); +} + +export function selectDirectory(): Promise<string | null> { + return hostClient().os.selectDirectory.query(); +} + +export function selectAttachments(input: { + mode: "files" | "directories" | "both"; +}): Promise<SelectedAttachment[]> { + return hostClient().os.selectAttachments.query(input); +} + +export function readFileAsDataUrl(input: { + filePath: string; +}): Promise<string | null> { + return hostClient().os.readFileAsDataUrl.query(input); +} + +export const filePersistHost = { + saveClipboardImage: (input: { + base64Data: string; + mimeType: string; + originalName: string; + }) => hostClient().os.saveClipboardImage.mutate(input), + saveClipboardText: (input: { text: string; originalName?: string }) => + hostClient().os.saveClipboardText.mutate(input), + saveClipboardFile: (input: { base64Data: string; originalName: string }) => + hostClient().os.saveClipboardFile.mutate(input), + downscaleImageFile: (input: { filePath: string }) => + hostClient().os.downscaleImageFile.mutate(input), +}; diff --git a/packages/ui/src/features/message-editor/identifiers.ts b/packages/ui/src/features/message-editor/identifiers.ts new file mode 100644 index 0000000000..7a69a89394 --- /dev/null +++ b/packages/ui/src/features/message-editor/identifiers.ts @@ -0,0 +1,12 @@ +export interface GhStatus { + installed: boolean; + version: string | null; + authenticated: boolean; + username: string | null; + error: string | null; +} + +export interface SelectedAttachment { + path: string; + kind: "file" | "directory"; +} diff --git a/apps/code/src/renderer/features/message-editor/stores/promptHistoryStore.ts b/packages/ui/src/features/message-editor/promptHistoryStore.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/stores/promptHistoryStore.ts rename to packages/ui/src/features/message-editor/promptHistoryStore.ts diff --git a/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts b/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts new file mode 100644 index 0000000000..a64ead0ac3 --- /dev/null +++ b/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts @@ -0,0 +1,93 @@ +import { + githubIssueToMentionChip, + githubPullRequestToMentionChip, +} from "@posthog/core/message-editor/githubIssueChip"; +import { + getAbsolutePathSuggestion, + mergeCommands, + searchCommands, + shapeCommandSuggestions, + shapeFileSuggestions, +} from "@posthog/core/message-editor/suggestions"; +import { getAvailableCommandsForTask } from "@posthog/ui/features/sessions/sessionStore"; +import { fetchRepoFiles, searchFiles } from "../../repo-files/useRepoFiles"; +import { CODE_COMMANDS } from "../commands"; +import { useDraftStore } from "../draftStore"; +import { searchGithubRefs } from "../hostApi"; +import type { + CommandSuggestionItem, + FileSuggestionItem, + IssueSuggestionItem, +} from "../types"; + +export async function getFileSuggestions( + sessionId: string, + query: string, +): Promise<FileSuggestionItem[]> { + const repoPath = useDraftStore.getState().contexts[sessionId]?.repoPath; + const absoluteMatch = getAbsolutePathSuggestion(query); + + if (!repoPath) { + return absoluteMatch ? [absoluteMatch] : []; + } + + const { files, fzf } = await fetchRepoFiles(repoPath, { + includeDirectories: true, + }); + const matched = searchFiles(fzf, files, query); + + return shapeFileSuggestions(matched, repoPath, absoluteMatch); +} + +export async function getIssueSuggestions( + sessionId: string, + query: string, +): Promise<IssueSuggestionItem[]> { + const repoPath = useDraftStore.getState().contexts[sessionId]?.repoPath; + if (!repoPath) return []; + + try { + const refs = await searchGithubRefs({ + directoryPath: repoPath, + query: query || undefined, + limit: 25, + }); + + return refs.map((ref) => { + const chip = + ref.kind === "pr" + ? githubPullRequestToMentionChip(ref) + : githubIssueToMentionChip(ref); + return { + id: chip.id, + label: chip.label, + chipType: chip.type, + kind: ref.kind, + number: ref.number, + title: ref.title, + url: ref.url, + repo: ref.repo, + state: ref.state, + labels: ref.labels, + isDraft: ref.isDraft, + }; + }); + } catch { + return []; + } +} + +export function getCommandSuggestions( + sessionId: string, + query: string, +): CommandSuggestionItem[] { + const store = useDraftStore.getState(); + const taskId = store.contexts[sessionId]?.taskId; + const agentCommands = taskId + ? getAvailableCommandsForTask(taskId) + : (store.commands[sessionId] ?? []); + const commands = mergeCommands(CODE_COMMANDS, agentCommands); + const filtered = searchCommands(commands, query); + + return shapeCommandSuggestions(filtered); +} diff --git a/apps/code/src/renderer/features/message-editor/stores/taskInputHistoryStore.ts b/packages/ui/src/features/message-editor/taskInputHistoryStore.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/stores/taskInputHistoryStore.ts rename to packages/ui/src/features/message-editor/taskInputHistoryStore.ts diff --git a/apps/code/src/renderer/features/message-editor/tiptap/CommandGhostText.ts b/packages/ui/src/features/message-editor/tiptap/CommandGhostText.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/tiptap/CommandGhostText.ts rename to packages/ui/src/features/message-editor/tiptap/CommandGhostText.ts diff --git a/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts b/packages/ui/src/features/message-editor/tiptap/CommandMention.ts similarity index 92% rename from apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts rename to packages/ui/src/features/message-editor/tiptap/CommandMention.ts index 399a300e51..d966f4dde0 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/CommandMention.ts +++ b/packages/ui/src/features/message-editor/tiptap/CommandMention.ts @@ -1,4 +1,4 @@ -import { getCodeCommand } from "@features/message-editor/commands"; +import { getCodeCommand } from "../commands"; import { getCommandSuggestions } from "../suggestions/getSuggestions"; import { createSuggestionMention } from "./createSuggestionMention"; diff --git a/apps/code/src/renderer/features/message-editor/tiptap/FileMention.ts b/packages/ui/src/features/message-editor/tiptap/FileMention.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/tiptap/FileMention.ts rename to packages/ui/src/features/message-editor/tiptap/FileMention.ts diff --git a/apps/code/src/renderer/features/message-editor/tiptap/IssueMention.tsx b/packages/ui/src/features/message-editor/tiptap/IssueMention.tsx similarity index 100% rename from apps/code/src/renderer/features/message-editor/tiptap/IssueMention.tsx rename to packages/ui/src/features/message-editor/tiptap/IssueMention.tsx diff --git a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts b/packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/tiptap/MentionChipNode.ts rename to packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts diff --git a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx similarity index 94% rename from apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx rename to packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx index 3d87a65da0..24f13336a2 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/MentionChipView.tsx +++ b/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx @@ -1,5 +1,3 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore"; import { ChartLineIcon, FileTextIcon, @@ -13,10 +11,12 @@ import { XIcon, } from "@phosphor-icons/react"; import { Chip } from "@posthog/quill"; -import { trpcClient } from "@renderer/trpc/client"; +import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import type { Node as PmNode } from "@tiptap/pm/model"; import type { Editor } from "@tiptap/react"; import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { readAbsoluteFile } from "../hostApi"; import type { ChipType, MentionChipAttrs } from "./MentionChipNode"; const chipBase = "group/chip relative top-px active:translate-y-0 pl-1"; @@ -127,7 +127,7 @@ function PastedTextChip({ const handleClick = async () => { useFeatureSettingsStore.getState().markHintLearned("paste-as-file"); - const content = await trpcClient.fs.readAbsoluteFile.query({ + const content = await readAbsoluteFile({ filePath, }); if (!content) return; diff --git a/apps/code/src/renderer/features/message-editor/tiptap/SuggestionList.tsx b/packages/ui/src/features/message-editor/tiptap/SuggestionList.tsx similarity index 98% rename from apps/code/src/renderer/features/message-editor/tiptap/SuggestionList.tsx rename to packages/ui/src/features/message-editor/tiptap/SuggestionList.tsx index 19f15e55db..96cb74068e 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/SuggestionList.tsx +++ b/packages/ui/src/features/message-editor/tiptap/SuggestionList.tsx @@ -1,4 +1,3 @@ -import { FileIcon } from "@components/ui/FileIcon"; import { FolderIcon } from "@phosphor-icons/react"; import { Item, @@ -8,6 +7,7 @@ import { ItemTitle, Kbd, } from "@posthog/quill"; +import { FileIcon } from "@posthog/ui/primitives/FileIcon"; import { forwardRef, type ReactNode, diff --git a/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts b/packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts similarity index 97% rename from apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts rename to packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts index 0568e04e52..88eb8bba99 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/createSuggestionMention.ts +++ b/packages/ui/src/features/message-editor/tiptap/createSuggestionMention.ts @@ -1,14 +1,14 @@ -import { getPortalContainer } from "@components/ThemeWrapper"; +import { createSuggestionLoader } from "@posthog/core/message-editor/suggestionLoader"; import type { Editor } from "@tiptap/core"; import Mention, { type MentionOptions } from "@tiptap/extension-mention"; import { ReactRenderer } from "@tiptap/react"; import type { SuggestionOptions } from "@tiptap/suggestion"; import type { ReactNode } from "react"; import tippy, { type Instance as TippyInstance } from "tippy.js"; +import { getPortalContainer } from "../../../primitives/ThemeWrapper"; import type { SuggestionItem } from "../types"; import type { ChipType, MentionChipAttrs } from "./MentionChipNode"; import { SuggestionList, type SuggestionListRef } from "./SuggestionList"; -import { createSuggestionLoader } from "./suggestionLoader"; export interface SuggestionMentionConfig<T extends SuggestionItem> { name: string; diff --git a/apps/code/src/renderer/features/message-editor/tiptap/extensions.ts b/packages/ui/src/features/message-editor/tiptap/extensions.ts similarity index 100% rename from apps/code/src/renderer/features/message-editor/tiptap/extensions.ts rename to packages/ui/src/features/message-editor/tiptap/extensions.ts diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.test.tsx b/packages/ui/src/features/message-editor/tiptap/useDraftSync.test.tsx similarity index 92% rename from apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.test.tsx rename to packages/ui/src/features/message-editor/tiptap/useDraftSync.test.tsx index 133365e525..f33658a61e 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.test.tsx +++ b/packages/ui/src/features/message-editor/tiptap/useDraftSync.test.tsx @@ -1,7 +1,7 @@ import { act, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("@utils/electronStorage", () => ({ +vi.mock("@posthog/ui/workbench/rendererStorage", () => ({ electronStorage: { getItem: () => null, setItem: () => {}, @@ -9,7 +9,7 @@ vi.mock("@utils/electronStorage", () => ({ }, })); -import { useDraftStore } from "../stores/draftStore"; +import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; import { useDraftSync } from "./useDraftSync"; function DraftAttachmentsProbe({ sessionId }: { sessionId: string }) { diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts b/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts similarity index 98% rename from apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts rename to packages/ui/src/features/message-editor/tiptap/useDraftSync.ts index c9bc8a2ad4..2aeea959ea 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useDraftSync.ts +++ b/packages/ui/src/features/message-editor/tiptap/useDraftSync.ts @@ -1,11 +1,11 @@ -import type { Editor, JSONContent } from "@tiptap/core"; -import { useCallback, useLayoutEffect, useRef, useState } from "react"; -import { useDraftStore } from "../stores/draftStore"; import { type EditorContent, type FileAttachment, isContentEmpty, -} from "../utils/content"; +} from "@posthog/core/message-editor/content"; +import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; +import type { Editor, JSONContent } from "@tiptap/core"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; function tiptapJsonToEditorContent(json: JSONContent): EditorContent { const segments: EditorContent["segments"] = []; diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts similarity index 90% rename from apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts rename to packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 7df2b75f70..87285800e7 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -1,31 +1,39 @@ -import { sessionStoreSetters } from "@features/sessions/stores/sessionStore"; -import { useSettingsStore as useFeatureSettingsStore } from "@features/settings/stores/settingsStore"; -import { trpc } from "@renderer/trpc/client"; -import { toast } from "@renderer/utils/toast"; -import type { EditorView } from "@tiptap/pm/view"; -import { useEditor } from "@tiptap/react"; -import { queryClient } from "@utils/queryClient"; -import { isSendMessageSubmitKey } from "@utils/sendMessageKey"; -import type React from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { usePromptHistoryStore } from "../stores/promptHistoryStore"; -import type { FileAttachment, MentionChip } from "../utils/content"; -import { contentToXml, isContentEmpty } from "../utils/content"; import { - githubIssueToMentionChip, - githubPullRequestToMentionChip, -} from "../utils/githubIssueChip"; + contentToXml, + type FileAttachment, + isContentEmpty, + type MentionChip, +} from "@posthog/core/message-editor/content"; +import { buildGithubRefPlaceholderChip } from "@posthog/core/message-editor/githubIssueChip"; import { type ParsedGithubIssueUrl, parseGithubIssueUrl, -} from "../utils/githubIssueUrl"; +} from "@posthog/core/message-editor/githubIssueUrl"; +import { + buildMarkdownLink, + buildPastedTextLabel, + extractBashCommand, + isBashModeText, + isUrlOnly, + shouldAutoConvertLongText, +} from "@posthog/core/message-editor/paste"; +import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; +import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { toast } from "@posthog/ui/primitives/toast"; +import { isSendMessageSubmitKey } from "@posthog/ui/utils/sendMessageKey"; +import type { EditorView } from "@tiptap/pm/view"; +import { useEditor } from "@tiptap/react"; +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getGithubIssue, getGithubPullRequest } from "../hostApi"; +import { usePromptHistoryStore } from "../promptHistoryStore"; +import { getEditorExtensions } from "../tiptap/extensions"; +import { type DraftContext, useDraftSync } from "../tiptap/useDraftSync"; import { persistImageFile, persistTextContent, resolveAndAttachDroppedFiles, } from "../utils/persistFile"; -import { getEditorExtensions } from "./extensions"; -import { type DraftContext, useDraftSync } from "./useDraftSync"; export interface UseTiptapEditorOptions { sessionId: string; @@ -85,25 +93,12 @@ async function pasteTextAsFile( insertChipWithTrailingSpace(view, { type: "file", id: result.path, - label: `Pasted text #${pasteCountRef.current} (${lineCount} lines)`, + label: buildPastedTextLabel(pasteCountRef.current, lineCount), pastedText: true, }); view.focus(); } -function buildGithubRefPlaceholderChip( - parsed: ParsedGithubIssueUrl, -): MentionChip { - const source = { - number: parsed.number, - title: "Loading...", - url: parsed.normalizedUrl, - }; - return parsed.kind === "pr" - ? githubPullRequestToMentionChip(source) - : githubIssueToMentionChip(source); -} - function insertGithubRefPlaceholder( view: EditorView, parsed: ParsedGithubIssueUrl, @@ -121,16 +116,10 @@ async function fetchGithubRefTitle( }; try { if (parsed.kind === "pr") { - const pr = await queryClient.fetchQuery({ - ...trpc.git.getGithubPullRequest.queryOptions(input), - staleTime: 60_000, - }); + const pr = await getGithubPullRequest(input); return pr?.title ?? null; } - const issue = await queryClient.fetchQuery({ - ...trpc.git.getGithubIssue.queryOptions(input), - staleTime: 60_000, - }); + const issue = await getGithubIssue(input); return issue?.title ?? null; } catch { return null; @@ -390,11 +379,14 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { if ( from !== to && trimmedClipboardText && - /^https?:\/\/\S+$/.test(trimmedClipboardText) + isUrlOnly(trimmedClipboardText) ) { event.preventDefault(); const selectedText = view.state.doc.textBetween(from, to); - const linkMarkdown = `[${selectedText}](${trimmedClipboardText})`; + const linkMarkdown = buildMarkdownLink( + selectedText, + trimmedClipboardText, + ); view.dispatch( view.state.tr.replaceWith( from, @@ -456,8 +448,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { useFeatureSettingsStore.getState().autoConvertLongText; if ( clipboardText && - autoConvertThreshold !== "off" && - clipboardText.length > Number(autoConvertThreshold) + shouldAutoConvertLongText(clipboardText, autoConvertThreshold) ) { event.preventDefault(); @@ -496,7 +487,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { }, onUpdate: ({ editor: e }) => { const text = e.getText(); - const newBashMode = enableBashMode && text.trimStart().startsWith("!"); + const newBashMode = enableBashMode && isBashModeText(text); if (newBashMode !== prevBashModeRef.current) { prevBashModeRef.current = newBashMode; @@ -577,7 +568,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { draft.clearDraft(); }; - if (enableBashMode && text.startsWith("!")) { + if (enableBashMode && isBashModeText(text)) { // Bash mode requires immediate execution, can't be queued. // Intentionally bypasses onBeforeSubmit — bash commands run inline and // cannot be deferred the way normal prompts can. @@ -585,7 +576,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { toast.error("Cannot run shell commands while agent is generating"); return; } - const command = text.slice(1).trim(); + const command = extractBashCommand(text); if (command) callbackRefs.current.onBashCommand?.(command); } else { const serialized = contentToXml(content); @@ -689,7 +680,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { const isEmpty = !editor || (isEmptyState && attachments.length === 0); const isBashMode = - enableBashMode && (editor?.getText().trimStart().startsWith("!") ?? false); + enableBashMode && (editor ? isBashModeText(editor.getText()) : false); return { editor, diff --git a/apps/code/src/renderer/features/message-editor/types.ts b/packages/ui/src/features/message-editor/types.ts similarity index 92% rename from apps/code/src/renderer/features/message-editor/types.ts rename to packages/ui/src/features/message-editor/types.ts index 22624fc5d3..8420137e3d 100644 --- a/apps/code/src/renderer/features/message-editor/types.ts +++ b/packages/ui/src/features/message-editor/types.ts @@ -1,10 +1,10 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk"; -import type { GithubRefKind, GithubRefState } from "@main/services/git/schemas"; import type { EditorContent, FileAttachment, MentionChip, -} from "./utils/content"; +} from "@posthog/core/message-editor/content"; +import type { GithubRefKind, GithubRefState } from "@posthog/shared"; export type GithubIssueState = GithubRefState; export type { GithubRefKind, GithubRefState }; diff --git a/apps/code/src/renderer/hooks/useAutoFocusOnTyping.ts b/packages/ui/src/features/message-editor/useAutoFocusOnTyping.ts similarity index 92% rename from apps/code/src/renderer/hooks/useAutoFocusOnTyping.ts rename to packages/ui/src/features/message-editor/useAutoFocusOnTyping.ts index fb629ec1f3..2eea84df6f 100644 --- a/apps/code/src/renderer/hooks/useAutoFocusOnTyping.ts +++ b/packages/ui/src/features/message-editor/useAutoFocusOnTyping.ts @@ -1,5 +1,5 @@ -import type { EditorHandle } from "@features/message-editor/types"; import { type RefObject, useEffect } from "react"; +import type { EditorHandle } from "./types"; export function useAutoFocusOnTyping( editorRef: RefObject<EditorHandle | null>, diff --git a/packages/ui/src/features/message-editor/utils/persistFile.test.ts b/packages/ui/src/features/message-editor/utils/persistFile.test.ts new file mode 100644 index 0000000000..4f6e5d1f5d --- /dev/null +++ b/packages/ui/src/features/message-editor/utils/persistFile.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockDownscaleImageFile = vi.hoisted(() => vi.fn()); +const mockGetFilePath = vi.hoisted(() => vi.fn()); + +vi.mock("@posthog/di/container", () => ({ + resolveService: () => ({ + downscaleImageFile: mockDownscaleImageFile, + }), +})); + +vi.mock("@posthog/ui/utils/getFilePath", () => ({ + getFilePath: mockGetFilePath, +})); + +const mockToastWarning = vi.hoisted(() => vi.fn()); +vi.mock("@posthog/ui/primitives/toast", () => ({ + toast: { warning: mockToastWarning }, +})); + +import { + resolveAndAttachDroppedFiles, + resolveDroppedFile, +} from "./persistFile"; + +describe("resolveDroppedFile (UI glue)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when getFilePath returns empty string", async () => { + mockGetFilePath.mockReturnValue(""); + + const file = { name: "test.txt" } as File; + expect(await resolveDroppedFile(file)).toBeNull(); + }); + + it("returns file attachment directly for non-image files", async () => { + mockGetFilePath.mockReturnValue("/Users/me/doc.pdf"); + + const file = { name: "doc.pdf" } as File; + const result = await resolveDroppedFile(file); + + expect(result).toEqual({ id: "/Users/me/doc.pdf", label: "doc.pdf" }); + expect(mockDownscaleImageFile).not.toHaveBeenCalled(); + }); + + it("shows warning toast when image downscaling fails", async () => { + mockGetFilePath.mockReturnValue("/Users/me/corrupt.png"); + mockDownscaleImageFile.mockRejectedValue(new Error("decode failed")); + + const file = { name: "corrupt.png" } as File; + expect(await resolveDroppedFile(file)).toEqual({ + id: "/Users/me/corrupt.png", + label: "corrupt.png", + }); + expect(mockToastWarning).toHaveBeenCalledWith( + "Image could not be downscaled", + { description: "Attaching original file instead" }, + ); + }); +}); + +describe("resolveAndAttachDroppedFiles", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls addAttachment for each resolved file", async () => { + mockGetFilePath + .mockReturnValueOnce("/Users/me/a.txt") + .mockReturnValueOnce("") + .mockReturnValueOnce("/Users/me/b.txt"); + + const files = [ + { name: "a.txt" }, + { name: "skip.txt" }, + { name: "b.txt" }, + ] as unknown as FileList; + Object.defineProperty(files, "length", { value: 3 }); + + const addAttachment = vi.fn(); + await resolveAndAttachDroppedFiles(files, addAttachment); + + expect(addAttachment).toHaveBeenCalledTimes(2); + expect(addAttachment).toHaveBeenCalledWith({ + id: "/Users/me/a.txt", + label: "a.txt", + }); + expect(addAttachment).toHaveBeenCalledWith({ + id: "/Users/me/b.txt", + label: "b.txt", + }); + }); +}); diff --git a/packages/ui/src/features/message-editor/utils/persistFile.ts b/packages/ui/src/features/message-editor/utils/persistFile.ts new file mode 100644 index 0000000000..5b88a499c1 --- /dev/null +++ b/packages/ui/src/features/message-editor/utils/persistFile.ts @@ -0,0 +1,65 @@ +import type { FileAttachment } from "@posthog/core/message-editor/content"; +import { + type PersistedFile, + persistBrowserFile as persistBrowserFileCore, + persistGenericFile as persistGenericFileCore, + persistImageFile as persistImageFileCore, + persistImageFilePath as persistImageFilePathCore, + persistTextContent as persistTextContentCore, + resolveDroppedFile as resolveDroppedFileCore, +} from "@posthog/core/message-editor/persistFile"; +import { toast } from "@posthog/ui/primitives/toast"; +import { getFilePath } from "@posthog/ui/utils/getFilePath"; +import { filePersistHost } from "../hostApi"; + +export type { PersistedFile }; + +function host() { + return filePersistHost; +} + +export function persistImageFile(file: File): Promise<PersistedFile> { + return persistImageFileCore(host(), file); +} + +export function persistTextContent( + text: string, + originalName?: string, +): Promise<PersistedFile> { + return persistTextContentCore(host(), text, originalName); +} + +export function persistGenericFile(file: File): Promise<PersistedFile> { + return persistGenericFileCore(host(), file); +} + +export function persistImageFilePath( + filePath: string, +): Promise<{ id: string; label: string }> { + return persistImageFilePathCore(host(), filePath); +} + +export function resolveDroppedFile(file: File): Promise<FileAttachment | null> { + return resolveDroppedFileCore(host(), file, getFilePath(file), { + onDownscaleFailed: () => + toast.warning("Image could not be downscaled", { + description: "Attaching original file instead", + }), + }); +} + +export async function resolveAndAttachDroppedFiles( + files: FileList, + addAttachment: (attachment: FileAttachment) => void, +): Promise<void> { + for (let i = 0; i < files.length; i++) { + const attachment = await resolveDroppedFile(files[i]); + if (attachment) addAttachment(attachment); + } +} + +export function persistBrowserFile( + file: File, +): Promise<{ id: string; label: string }> { + return persistBrowserFileCore(host(), file); +} diff --git a/apps/code/src/renderer/stores/navigationStore.test.ts b/packages/ui/src/features/navigation/store.test.ts similarity index 87% rename from apps/code/src/renderer/stores/navigationStore.test.ts rename to packages/ui/src/features/navigation/store.test.ts index f1773a568b..2aaec72228 100644 --- a/apps/code/src/renderer/stores/navigationStore.test.ts +++ b/packages/ui/src/features/navigation/store.test.ts @@ -1,46 +1,23 @@ -import type { Task } from "@shared/types"; +import type { Task } from "@posthog/shared/domain-types"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { getItem, setItem } = vi.hoisted(() => ({ +const { getItem, setItem, removeItem } = vi.hoisted(() => ({ getItem: vi.fn(), setItem: vi.fn(), + removeItem: vi.fn(), })); -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - secureStore: { - getItem: { query: getItem }, - setItem: { query: setItem }, - removeItem: { query: vi.fn() }, - }, - }, +vi.mock("@posthog/di/container", () => ({ + resolveService: () => ({ getItem, setItem, removeItem }), + resolveServiceOptional: () => undefined, })); -vi.mock("@utils/analytics", () => ({ +vi.mock("@posthog/ui/workbench/analytics", () => ({ track: vi.fn(), - setActiveTaskAnalyticsContext: vi.fn(), -})); -vi.mock("@utils/logger", () => ({ - logger: { scope: () => ({ info: vi.fn(), error: vi.fn(), debug: vi.fn() }) }, -})); -vi.mock("@features/workspace/hooks/useWorkspace", () => ({ - workspaceApi: { - get: vi.fn().mockResolvedValue(null), - getAll: vi.fn().mockResolvedValue({}), - create: vi.fn().mockResolvedValue(null), - }, -})); -vi.mock("@features/folders/hooks/useFolders", () => ({ - foldersApi: { - getFolders: vi.fn().mockResolvedValue([]), - addFolder: vi.fn().mockResolvedValue(null), - }, -})); -vi.mock("@hooks/useRepositoryDirectory", () => ({ - getTaskDirectory: vi.fn().mockResolvedValue(null), + setActiveTaskContext: vi.fn(), })); -import { useNavigationStore } from "./navigationStore"; +import { useNavigationStore } from "./store"; const mockTask: Task = { id: "task-123", @@ -60,8 +37,10 @@ describe("navigationStore", () => { beforeEach(() => { getItem.mockReset(); setItem.mockReset(); + removeItem.mockReset(); getItem.mockResolvedValue(null); setItem.mockResolvedValue(undefined); + removeItem.mockResolvedValue(undefined); useNavigationStore.setState({ view: { type: "task-input" }, history: [{ type: "task-input" }], @@ -251,7 +230,7 @@ describe("navigationStore", () => { }); const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; - const persisted = JSON.parse(lastCall[0].value); + const persisted = JSON.parse(lastCall[1]); expect(persisted.state.view).toEqual({ type: "task-detail", taskId: "task-123", diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/packages/ui/src/features/navigation/store.ts similarity index 79% rename from apps/code/src/renderer/stores/navigationStore.ts rename to packages/ui/src/features/navigation/store.ts index 3bfb98fb3b..52be5102ac 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/packages/ui/src/features/navigation/store.ts @@ -1,16 +1,14 @@ -import { foldersApi } from "@features/folders/hooks/useFolders"; -import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; -import { getTaskDirectory } from "@hooks/useRepositoryDirectory"; -import type { Task } from "@shared/types"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { setActiveTaskAnalyticsContext, track } from "@utils/analytics"; -import { electronStorage } from "@utils/electronStorage"; -import { logger } from "@utils/logger"; -import { getTaskRepository } from "@utils/repository"; +import { resolveServiceOptional } from "@posthog/di/container"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import { setActiveTaskContext, track } from "@posthog/ui/workbench/analytics"; +import { electronStorage } from "@posthog/ui/workbench/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; - -const log = logger.scope("navigation-store"); +import { + NAVIGATION_TASK_BINDER, + type NavigationTaskBinder, +} from "./taskBinder"; type ViewType = | "task-detail" @@ -132,7 +130,7 @@ export const useNavigationStore = create<NavigationStore>()( history: newHistory, historyIndex: newHistory.length - 1, }); - setActiveTaskAnalyticsContext( + setActiveTaskContext( newView.type === "task-detail" ? (newView.data ?? null) : null, ); }; @@ -150,58 +148,13 @@ export const useNavigationStore = create<NavigationStore>()( task_id: task.id, }); - const repoKey = getTaskRepository(task) ?? undefined; - - const existingWorkspace = await workspaceApi.get(task.id); - if (existingWorkspace?.folderId) { - const folders = await foldersApi.getFolders(); - const folder = folders.find( - (f) => f.id === existingWorkspace.folderId, - ); - - if (folder && folder.exists === false) { - log.info("Folder path is stale, redirecting to folder settings", { - folderId: folder.id, - path: folder.path, - }); - navigate({ type: "folder-settings", folderId: folder.id }); - return; - } - - if (folder) { - return; - } - } - - const directory = await getTaskDirectory( - task.id, - repoKey ?? undefined, - ); - - if (directory) { - try { - await foldersApi.addFolder(directory); - - const workspaceMode = - task.latest_run?.environment === "cloud" ? "cloud" : "local"; - - await workspaceApi.create({ - taskId: task.id, - mainRepoPath: directory, - folderId: "", - folderPath: directory, - mode: workspaceMode, - }); - } catch (error) { - log.error("Failed to auto-register folder on task open:", error); - } - } else if (task.latest_run?.environment === "cloud") { - await workspaceApi.create({ - taskId: task.id, - mainRepoPath: "", - folderId: "", - folderPath: "", - mode: "cloud", + const result = await resolveServiceOptional<NavigationTaskBinder>( + NAVIGATION_TASK_BINDER, + )?.ensureWorkspaceForTask(task); + if (result?.staleFolderId) { + navigate({ + type: "folder-settings", + folderId: result.staleFolderId, }); } }, @@ -314,7 +267,7 @@ export const useNavigationStore = create<NavigationStore>()( view: newView, historyIndex: newIndex, }); - setActiveTaskAnalyticsContext( + setActiveTaskContext( newView.type === "task-detail" ? (newView.data ?? null) : null, ); } @@ -329,7 +282,7 @@ export const useNavigationStore = create<NavigationStore>()( view: newView, historyIndex: newIndex, }); - setActiveTaskAnalyticsContext( + setActiveTaskContext( newView.type === "task-detail" ? (newView.data ?? null) : null, ); } diff --git a/packages/ui/src/features/navigation/taskBinder.ts b/packages/ui/src/features/navigation/taskBinder.ts new file mode 100644 index 0000000000..7be04d6199 --- /dev/null +++ b/packages/ui/src/features/navigation/taskBinder.ts @@ -0,0 +1,15 @@ +import type { Task } from "@posthog/shared/domain-types"; + +export interface EnsureWorkspaceResult { + staleFolderId?: string; +} + +export interface NavigationTaskBinder { + ensureWorkspaceForTask( + task: Task, + ): Promise<EnsureWorkspaceResult | undefined>; +} + +export const NAVIGATION_TASK_BINDER = Symbol.for( + "posthog.ui.NavigationTaskBinder", +); diff --git a/packages/ui/src/features/navigation/taskBinderImpl.ts b/packages/ui/src/features/navigation/taskBinderImpl.ts new file mode 100644 index 0000000000..47ae5870a2 --- /dev/null +++ b/packages/ui/src/features/navigation/taskBinderImpl.ts @@ -0,0 +1,98 @@ +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { expandTildePath, getTaskRepository } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import type { + EnsureWorkspaceResult, + NavigationTaskBinder, +} from "@posthog/ui/features/navigation/taskBinder"; +import { logger } from "@posthog/ui/workbench/logger"; + +const log = logger.scope("navigation-store"); + +function hostClient(): HostTrpcClient { + return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT); +} + +async function getTaskDirectory( + taskId: string, + repoKey?: string, +): Promise<string | null> { + const workspaces = await hostClient().workspace.getAll.query(); + const workspace = workspaces?.[taskId] ?? null; + if (workspace?.folderPath) { + return expandTildePath(workspace.folderPath); + } + + if (repoKey) { + const repo = await hostClient().folders.getRepositoryByRemoteUrl.query({ + remoteUrl: repoKey, + }); + if (repo) { + return expandTildePath(repo.path); + } + } + + return null; +} + +export const navigationTaskBinder: NavigationTaskBinder = { + async ensureWorkspaceForTask( + task: Task, + ): Promise<EnsureWorkspaceResult | undefined> { + const repoKey = getTaskRepository(task) ?? undefined; + + const workspaces = await hostClient().workspace.getAll.query(); + const existingWorkspace = workspaces?.[task.id] ?? null; + if (existingWorkspace?.folderId) { + const folders = await hostClient().folders.getFolders.query(); + const folder = folders.find((f) => f.id === existingWorkspace.folderId); + + if (folder && folder.exists === false) { + log.info("Folder path is stale, redirecting to folder settings", { + folderId: folder.id, + path: folder.path, + }); + return { staleFolderId: folder.id }; + } + + if (folder) { + return undefined; + } + } + + const directory = await getTaskDirectory(task.id, repoKey ?? undefined); + + if (directory) { + try { + await hostClient().folders.addFolder.mutate({ folderPath: directory }); + + const workspaceMode = + task.latest_run?.environment === "cloud" ? "cloud" : "local"; + + await hostClient().workspace.create.mutate({ + taskId: task.id, + mainRepoPath: directory, + folderId: "", + folderPath: directory, + mode: workspaceMode, + }); + } catch (error) { + log.error("Failed to auto-register folder on task open:", error); + } + } else if (task.latest_run?.environment === "cloud") { + await hostClient().workspace.create.mutate({ + taskId: task.id, + mainRepoPath: "", + folderId: "", + folderPath: "", + mode: "cloud", + }); + } + + return undefined; + }, +}; diff --git a/packages/ui/src/features/notifications/identifiers.ts b/packages/ui/src/features/notifications/identifiers.ts new file mode 100644 index 0000000000..3343baeedb --- /dev/null +++ b/packages/ui/src/features/notifications/identifiers.ts @@ -0,0 +1,26 @@ +import type { CompletionSound } from "@posthog/ui/features/settings/settingsStore"; + +export interface NotificationSettings { + desktopNotifications: boolean; + dockBadgeNotifications: boolean; + dockBounceNotifications: boolean; + completionSound: CompletionSound; + completionVolume: number; +} + +export interface INotificationSettings { + get(): NotificationSettings; +} + +export const NOTIFICATION_SETTINGS_PROVIDER = Symbol.for( + "posthog.ui.notifications.settings", +); + +export interface IActiveView { + hasFocus(): boolean; + getActiveTaskId(): string | undefined; +} + +export const ACTIVE_VIEW_PROVIDER = Symbol.for( + "posthog.ui.notifications.activeView", +); diff --git a/packages/ui/src/features/notifications/notifications.module.ts b/packages/ui/src/features/notifications/notifications.module.ts new file mode 100644 index 0000000000..2866da3800 --- /dev/null +++ b/packages/ui/src/features/notifications/notifications.module.ts @@ -0,0 +1,6 @@ +import { ContainerModule } from "inversify"; +import { TaskNotificationService } from "./notifications"; + +export const notificationsUiModule = new ContainerModule(({ bind }) => { + bind(TaskNotificationService).toSelf().inSingletonScope(); +}); diff --git a/packages/ui/src/features/notifications/notifications.test.ts b/packages/ui/src/features/notifications/notifications.test.ts new file mode 100644 index 0000000000..c4906818bd --- /dev/null +++ b/packages/ui/src/features/notifications/notifications.test.ts @@ -0,0 +1,169 @@ +import "reflect-metadata"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@posthog/ui/utils/sounds", () => ({ + playCompletionSound: vi.fn(), +})); + +import { playCompletionSound } from "@posthog/ui/utils/sounds"; +import type { + IActiveView, + INotificationSettings, + NotificationSettings, +} from "./identifiers"; +import { TaskNotificationService } from "./notifications"; + +const TASK_ID = "task-123"; +const OTHER_TASK_ID = "task-999"; + +function makeService(overrides?: { + settings?: Partial<NotificationSettings>; + hasFocus?: boolean; + activeTaskId?: string; +}) { + const notify = vi.fn(); + const showUnreadIndicator = vi.fn(); + const requestAttention = vi.fn(); + const play = vi.mocked(playCompletionSound); + play.mockClear(); + + const settings: NotificationSettings = { + desktopNotifications: true, + dockBadgeNotifications: true, + dockBounceNotifications: true, + completionSound: "meep", + completionVolume: 80, + ...overrides?.settings, + }; + + const settingsPort: INotificationSettings = { get: () => settings }; + const viewPort: IActiveView = { + hasFocus: () => overrides?.hasFocus ?? false, + getActiveTaskId: () => overrides?.activeTaskId, + }; + + const service = new TaskNotificationService( + { notify, showUnreadIndicator, requestAttention }, + settingsPort, + viewPort, + ); + + return { service, notify, showUnreadIndicator, requestAttention, play }; +} + +describe("TaskNotificationService", () => { + describe("shouldNotify gating (via notifyPermissionRequest)", () => { + const cases = [ + { + name: "window unfocused → notifies", + hasFocus: false, + activeTaskId: TASK_ID, + taskId: TASK_ID, + shouldNotify: true, + }, + { + name: "focused on the same task → does not notify", + hasFocus: true, + activeTaskId: TASK_ID, + taskId: TASK_ID, + shouldNotify: false, + }, + { + name: "focused on a different task → notifies", + hasFocus: true, + activeTaskId: OTHER_TASK_ID, + taskId: TASK_ID, + shouldNotify: true, + }, + { + name: "focused, no active task → notifies", + hasFocus: true, + activeTaskId: undefined, + taskId: TASK_ID, + shouldNotify: true, + }, + { + name: "focused with no taskId supplied → does not notify", + hasFocus: true, + activeTaskId: undefined, + taskId: undefined, + shouldNotify: false, + }, + ] as const; + + it.each(cases)( + "$name", + ({ hasFocus, activeTaskId, taskId, shouldNotify }) => { + const { service, notify, play } = makeService({ + hasFocus, + activeTaskId, + }); + service.notifyPermissionRequest("My task", taskId); + expect(notify).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); + expect(play).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); + }, + ); + }); + + describe("notifyPromptComplete", () => { + it.each([ + { stopReason: "tool_use", shouldNotify: false }, + { stopReason: "max_tokens", shouldNotify: false }, + { stopReason: "end_turn", shouldNotify: true }, + ])( + "stop reason '$stopReason' → notifies=$shouldNotify", + ({ stopReason, shouldNotify }) => { + const { service, notify } = makeService({ hasFocus: false }); + service.notifyPromptComplete("My task", stopReason, TASK_ID); + expect(notify).toHaveBeenCalledTimes(shouldNotify ? 1 : 0); + }, + ); + }); + + describe("settings gating", () => { + it("skips desktop notification when desktopNotifications is off", () => { + const { service, notify, showUnreadIndicator, requestAttention } = + makeService({ + hasFocus: false, + settings: { desktopNotifications: false }, + }); + service.notifyPermissionRequest("My task", TASK_ID); + expect(notify).not.toHaveBeenCalled(); + expect(showUnreadIndicator).toHaveBeenCalledTimes(1); + expect(requestAttention).toHaveBeenCalledTimes(1); + }); + + it("marks the notification silent when a custom sound plays", () => { + const { service, notify } = makeService({ + hasFocus: false, + settings: { completionSound: "meep" }, + }); + service.notifyPermissionRequest("My task", TASK_ID); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ silent: true }), + ); + }); + + it("is not silent when completionSound is none", () => { + const { service, notify } = makeService({ + hasFocus: false, + settings: { completionSound: "none" }, + }); + service.notifyPromptComplete("My task", "end_turn", TASK_ID); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ silent: false }), + ); + }); + + it("truncates long titles", () => { + const { service, notify } = makeService({ hasFocus: false }); + const longTitle = "x".repeat(80); + service.notifyPromptComplete(longTitle, "end_turn", TASK_ID); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + body: `"${"x".repeat(50)}..." finished`, + }), + ); + }); + }); +}); diff --git a/packages/ui/src/features/notifications/notifications.ts b/packages/ui/src/features/notifications/notifications.ts new file mode 100644 index 0000000000..0c862b194b --- /dev/null +++ b/packages/ui/src/features/notifications/notifications.ts @@ -0,0 +1,76 @@ +import { + type INotifications, + NOTIFICATIONS_SERVICE, +} from "@posthog/platform/notifications"; +import { playCompletionSound } from "@posthog/ui/utils/sounds"; +import { inject, injectable } from "inversify"; +import { + ACTIVE_VIEW_PROVIDER, + type IActiveView, + type INotificationSettings, + NOTIFICATION_SETTINGS_PROVIDER, +} from "./identifiers"; + +const MAX_TITLE_LENGTH = 50; + +@injectable() +export class TaskNotificationService { + constructor( + @inject(NOTIFICATIONS_SERVICE) + private readonly notifications: INotifications, + @inject(NOTIFICATION_SETTINGS_PROVIDER) + private readonly settings: INotificationSettings, + @inject(ACTIVE_VIEW_PROVIDER) + private readonly view: IActiveView, + ) {} + + notifyPromptComplete( + taskTitle: string, + stopReason: string, + taskId?: string, + ): void { + if (stopReason !== "end_turn") return; + this.dispatch(`"${this.truncateTitle(taskTitle)}" finished`, taskId); + } + + notifyPermissionRequest(taskTitle: string, taskId?: string): void { + this.dispatch( + `"${this.truncateTitle(taskTitle)}" needs your input`, + taskId, + ); + } + + private dispatch(body: string, taskId?: string): void { + if (!this.shouldNotify(taskId)) return; + + const settings = this.settings.get(); + const willPlayCustomSound = settings.completionSound !== "none"; + playCompletionSound(settings.completionSound, settings.completionVolume); + + if (settings.desktopNotifications) { + this.notifications.notify({ + title: "PostHog Code", + body, + silent: willPlayCustomSound, + taskId, + }); + } + if (settings.dockBadgeNotifications) { + this.notifications.showUnreadIndicator(); + } + if (settings.dockBounceNotifications) { + this.notifications.requestAttention(); + } + } + + private shouldNotify(taskId?: string): boolean { + if (!this.view.hasFocus()) return true; + if (!taskId) return false; + return this.view.getActiveTaskId() !== taskId; + } + + private truncateTitle(title: string): string { + if (title.length <= MAX_TITLE_LENGTH) return title; + return `${title.slice(0, MAX_TITLE_LENGTH)}...`; + } +} diff --git a/apps/code/src/renderer/features/onboarding/components/CliCheckPanel.tsx b/packages/ui/src/features/onboarding/components/CliCheckPanel.tsx similarity index 93% rename from apps/code/src/renderer/features/onboarding/components/CliCheckPanel.tsx rename to packages/ui/src/features/onboarding/components/CliCheckPanel.tsx index 9da6107a56..1f620eecd8 100644 --- a/apps/code/src/renderer/features/onboarding/components/CliCheckPanel.tsx +++ b/packages/ui/src/features/onboarding/components/CliCheckPanel.tsx @@ -1,7 +1,7 @@ import { CheckCircle, CircleNotch } from "@phosphor-icons/react"; +import { PANEL_SHADOW } from "@posthog/ui/features/onboarding/components/onboardingStyles"; import { Box, Flex, Text } from "@radix-ui/themes"; import type { ReactNode } from "react"; -import { PANEL_SHADOW } from "./onboardingStyles"; interface CliCheckPanelProps { icon: ReactNode; diff --git a/apps/code/src/renderer/features/onboarding/components/ConnectGitHubStep.tsx b/packages/ui/src/features/onboarding/components/ConnectGitHubStep.tsx similarity index 89% rename from apps/code/src/renderer/features/onboarding/components/ConnectGitHubStep.tsx rename to packages/ui/src/features/onboarding/components/ConnectGitHubStep.tsx index 43bbee7a5a..9035b21c9d 100644 --- a/apps/code/src/renderer/features/onboarding/components/ConnectGitHubStep.tsx +++ b/packages/ui/src/features/onboarding/components/ConnectGitHubStep.tsx @@ -1,4 +1,3 @@ -import { useUserGithubIntegrations } from "@hooks/useIntegrations"; import { ArrowLeft, ArrowRight, @@ -6,14 +5,15 @@ import { Cloud, GitPullRequest, } from "@phosphor-icons/react"; +import type { OnboardingStepCompletedProperties } from "@posthog/shared/analytics-events"; +import { builderHog } from "@posthog/ui/assets/hedgehogs"; +import { useUserGithubIntegrations } from "@posthog/ui/features/integrations/useIntegrations"; +import { OptionalBadge } from "@posthog/ui/features/onboarding/components/OptionalBadge"; +import { StepActions } from "@posthog/ui/features/onboarding/components/StepActions"; +import { OnboardingHogTip } from "@posthog/ui/primitives/OnboardingHogTip"; import { Button, Flex, Text } from "@radix-ui/themes"; -import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; -import type { OnboardingStepCompletedProperties } from "@shared/types/analytics"; import { motion } from "framer-motion"; import { GitHubConnectPanel } from "./GitHubConnectPanel"; -import { OnboardingHogTip } from "./OnboardingHogTip"; -import { OptionalBadge } from "./OptionalBadge"; -import { StepActions } from "./StepActions"; type StepContext = Pick<OnboardingStepCompletedProperties, "github_connected">; diff --git a/apps/code/src/renderer/features/onboarding/components/FeatureBentoCard.css b/packages/ui/src/features/onboarding/components/FeatureBentoCard.css similarity index 100% rename from apps/code/src/renderer/features/onboarding/components/FeatureBentoCard.css rename to packages/ui/src/features/onboarding/components/FeatureBentoCard.css diff --git a/apps/code/src/renderer/features/onboarding/components/FeatureBentoCard.tsx b/packages/ui/src/features/onboarding/components/FeatureBentoCard.tsx similarity index 100% rename from apps/code/src/renderer/features/onboarding/components/FeatureBentoCard.tsx rename to packages/ui/src/features/onboarding/components/FeatureBentoCard.tsx diff --git a/apps/code/src/renderer/features/onboarding/components/GitHubConnectPanel.tsx b/packages/ui/src/features/onboarding/components/GitHubConnectPanel.tsx similarity index 75% rename from apps/code/src/renderer/features/onboarding/components/GitHubConnectPanel.tsx rename to packages/ui/src/features/onboarding/components/GitHubConnectPanel.tsx index 08119f7a62..35174b95a7 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitHubConnectPanel.tsx +++ b/packages/ui/src/features/onboarding/components/GitHubConnectPanel.tsx @@ -1,15 +1,3 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { - describeGithubConnectError, - invalidateGithubQueries, - useGithubConnect, -} from "@features/integrations/hooks/useGithubUserConnect"; -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { - useUserGithubIntegrations, - useUserRepositoryIntegration, -} from "@hooks/useIntegrations"; import { ArrowSquareOut, ArrowsClockwise, @@ -18,6 +6,37 @@ import { GithubLogo, Plus, } from "@phosphor-icons/react"; +import { + buildConnectFailedProps, + buildConnectFailureFingerprint, + buildInstallationSettingsUrl, + deriveAlternativeConnectedProjects, + deriveConnectButtonState, + getGithubPanelMessage, + isAnyIntegrationStale, + resolveSelectedProjectId, +} from "@posthog/core/onboarding/githubConnectPanel"; +import type { GithubConnectService } from "@posthog/core/onboarding/githubConnectService"; +import { GITHUB_CONNECT_SERVICE } from "@posthog/core/onboarding/identifiers"; +import { useService } from "@posthog/di/react"; +import type { OnboardingGithubConnectFlow } from "@posthog/shared/analytics-events"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useGithubDisconnect } from "@posthog/ui/features/integrations/useGithubDisconnect"; +import { + describeGithubConnectError, + useGithubConnect, +} from "@posthog/ui/features/integrations/useGithubUserConnect"; +import { + useUserGithubIntegrations, + useUserRepositoryIntegration, +} from "@posthog/ui/features/integrations/useIntegrations"; +import { OptionalBadge } from "@posthog/ui/features/onboarding/components/OptionalBadge"; +import { PANEL_SHADOW } from "@posthog/ui/features/onboarding/components/onboardingStyles"; +import { useProjectsWithIntegrations } from "@posthog/ui/features/onboarding/hooks/useProjectsWithIntegrations"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { track } from "@posthog/ui/workbench/analytics"; +import { openExternalUrl } from "@posthog/ui/workbench/openExternal"; import { AlertDialog, Box, @@ -28,33 +47,8 @@ import { Spinner, Text, } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import { - ANALYTICS_EVENTS, - type OnboardingGithubConnectFlow, -} from "@shared/types/analytics"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { track } from "@utils/analytics"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { useProjectsWithIntegrations } from "../hooks/useProjectsWithIntegrations"; -import { OptionalBadge } from "./OptionalBadge"; -import { PANEL_SHADOW } from "./onboardingStyles"; - -function getPanelMessage(opts: { - hasConnectError: boolean; - connectError: Parameters<typeof describeGithubConnectError>[0]; - timedOut: boolean; - isConnecting: boolean; -}): string { - if (opts.hasConnectError) - return describeGithubConnectError(opts.connectError); - if (opts.timedOut) { - return "We didn't hear back from GitHub. If the browser tab was closed, click Connect again."; - } - if (opts.isConnecting) return "Waiting for GitHub..."; - return "Unlocks cloud runs, branch pushes, and PR review on this account."; -} +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; export function GitHubConnectPanel() { const queryClient = useQueryClient(); @@ -67,10 +61,15 @@ export function GitHubConnectPanel() { const setSelectedProjectId = useOnboardingStore( (state) => state.selectProjectId, ); - const selectedProjectId = useMemo(() => { - if (manuallySelectedProjectId !== null) return manuallySelectedProjectId; - return currentProjectId ?? projects[0]?.id ?? null; - }, [manuallySelectedProjectId, currentProjectId, projects]); + const selectedProjectId = useMemo( + () => + resolveSelectedProjectId( + manuallySelectedProjectId, + currentProjectId, + projects, + ), + [manuallySelectedProjectId, currentProjectId, projects], + ); const selectedProject = useMemo( () => projects.find((p) => p.id === selectedProjectId), [projects, selectedProjectId], @@ -101,24 +100,26 @@ export function GitHubConnectPanel() { void handleConnectGitHub(); }; - const failureFingerprintRef = useRef<string | null>(null); + const connectService = useService<GithubConnectService>( + GITHUB_CONNECT_SERVICE, + ); useEffect(() => { - if (!hasConnectError && !timedOut) { - failureFingerprintRef.current = null; - return; - } - const fingerprint = timedOut ? "timeout" : (connectError?.code ?? "error"); - if (failureFingerprintRef.current === fingerprint) return; - failureFingerprintRef.current = fingerprint; - track(ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECT_FAILED, { - reason: timedOut ? "timeout" : "error", - error_type: connectError?.code ?? undefined, - }); - }, [hasConnectError, timedOut, connectError]); + const failureInputs = { + hasConnectError, + timedOut, + errorCode: connectError?.code, + }; + const fingerprint = buildConnectFailureFingerprint(failureInputs); + if (!connectService.shouldReportFailure(fingerprint)) return; + track( + ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECT_FAILED, + buildConnectFailedProps(failureInputs), + ); + }, [hasConnectError, timedOut, connectError, connectService]); - const defaultPanelMessage = getPanelMessage({ + const defaultPanelMessage = getGithubPanelMessage({ hasConnectError, - connectError, + connectErrorMessage: describeGithubConnectError(connectError), timedOut, isConnecting, }); @@ -130,15 +131,20 @@ export function GitHubConnectPanel() { const hasGitIntegration = githubUserIntegrations.length > 0; const { failedInstallationIds, reposByInstallationId } = useUserRepositoryIntegration(); - const anyIntegrationStale = githubUserIntegrations.some((i) => - failedInstallationIds.includes(i.installation_id), + const anyIntegrationStale = isAnyIntegrationStale( + githubUserIntegrations, + failedInstallationIds, ); - const alternativeConnectedProjects = useMemo(() => { - if (hasGitIntegration) return []; - if (!projectsWithGithub.length) return []; - return projectsWithGithub.filter((p) => p.id !== selectedProjectId); - }, [hasGitIntegration, projectsWithGithub, selectedProjectId]); + const alternativeConnectedProjects = useMemo( + () => + deriveAlternativeConnectedProjects( + hasGitIntegration, + projectsWithGithub, + selectedProjectId, + ), + [hasGitIntegration, projectsWithGithub, selectedProjectId], + ); const [selectedAlternativeId, setSelectedAlternativeId] = useState< number | null >(null); @@ -151,7 +157,6 @@ export function GitHubConnectPanel() { ); }, [alternativeConnectedProjects, selectedAlternativeId]); - const apiClient = useOptionalAuthenticatedClient(); const [disconnectTarget, setDisconnectTarget] = useState<{ installationId: string; accountName: string; @@ -159,23 +164,8 @@ export function GitHubConnectPanel() { const [reconnectingInstallationId, setReconnectingInstallationId] = useState< string | null >(null); - const disconnectMutation = useMutation({ - mutationFn: async (opts: { installationId: string; silent?: boolean }) => { - if (!apiClient) throw new Error("Not authenticated"); - await apiClient.disconnectGithubUserIntegration(opts.installationId); - return { silent: opts.silent ?? false }; - }, - onSuccess: ({ silent }) => { - setDisconnectTarget(null); - invalidateGithubQueries(queryClient, selectedProjectId); - if (!silent) toast.success("GitHub disconnected."); - }, - onError: (e) => { - toast.error( - e instanceof Error ? e.message : "Failed to disconnect GitHub.", - ); - }, - }); + const { disconnect, isDisconnecting, reconnect } = + useGithubDisconnect(selectedProjectId); return ( <> @@ -338,16 +328,10 @@ export function GitHubConnectPanel() { ); setReconnectingInstallationId(installationId); try { - await disconnectMutation.mutateAsync({ + await reconnect( installationId, - silent: true, - }); - } catch { - setReconnectingInstallationId(null); - return; - } - try { - await handleConnectGitHub(); + handleConnectGitHub, + ); } finally { setReconnectingInstallationId(null); } @@ -362,12 +346,12 @@ export function GitHubConnectPanel() { variant="soft" color="gray" onClick={() => { - const account = integration.account; - const url = - account?.type === "Organization" && account.name - ? `https://github.com/organizations/${account.name}/settings/installations/${installationId}` - : `https://github.com/settings/installations/${installationId}`; - trpcClient.os.openExternal.mutate({ url }); + openExternalUrl( + buildInstallationSettingsUrl( + integration.account, + installationId, + ), + ); }} > <GearSix size={12} /> @@ -452,17 +436,23 @@ export function GitHubConnectPanel() { size="2" variant="solid" onClick={() => { - const isRetry = hasConnectError || timedOut; - if (hasConnectError) resetConnect(); + const { isRetry, shouldReset } = deriveConnectButtonState({ + isConnecting, + hasConnectError, + timedOut, + }); + if (shouldReset) resetConnect(); initiateConnect("user_new", isRetry); }} loading={isConnecting} > - {isConnecting - ? "Retry connection" - : hasConnectError || timedOut - ? "Try again" - : "Connect GitHub"} + { + deriveConnectButtonState({ + isConnecting, + hasConnectError, + timedOut, + }).label + } <ArrowSquareOut size={12} /> </Button> {hasConnectError && ( @@ -484,7 +474,7 @@ export function GitHubConnectPanel() { <AlertDialog.Root open={disconnectTarget !== null} onOpenChange={(next) => { - if (!next && !disconnectMutation.isPending) { + if (!next && !isDisconnecting) { setDisconnectTarget(null); } }} @@ -501,11 +491,7 @@ export function GitHubConnectPanel() { </AlertDialog.Description> <Flex gap="3" mt="4" justify="end"> <AlertDialog.Cancel> - <Button - variant="soft" - color="gray" - disabled={disconnectMutation.isPending} - > + <Button variant="soft" color="gray" disabled={isDisconnecting}> Cancel </Button> </AlertDialog.Cancel> @@ -514,13 +500,12 @@ export function GitHubConnectPanel() { color="red" onClick={() => { if (!disconnectTarget) return; - disconnectMutation.mutate({ - installationId: disconnectTarget.installationId, - }); + disconnect({ installationId: disconnectTarget.installationId }); + setDisconnectTarget(null); }} - disabled={disconnectMutation.isPending} + disabled={isDisconnecting} > - {disconnectMutation.isPending ? <Spinner size="1" /> : null} + {isDisconnecting ? <Spinner size="1" /> : null} Disconnect </Button> </Flex> diff --git a/apps/code/src/renderer/features/onboarding/components/InstallCliStep.tsx b/packages/ui/src/features/onboarding/components/InstallCliStep.tsx similarity index 90% rename from apps/code/src/renderer/features/onboarding/components/InstallCliStep.tsx rename to packages/ui/src/features/onboarding/components/InstallCliStep.tsx index d988d27015..1d371723cf 100644 --- a/apps/code/src/renderer/features/onboarding/components/InstallCliStep.tsx +++ b/packages/ui/src/features/onboarding/components/InstallCliStep.tsx @@ -1,4 +1,3 @@ -import { Tooltip } from "@components/ui/Tooltip"; import { ArrowLeft, ArrowRight, @@ -10,22 +9,27 @@ import { GithubLogo, Warning, } from "@phosphor-icons/react"; -import { Button, Flex, IconButton, Text } from "@radix-ui/themes"; -import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { EXTERNAL_LINKS } from "@posthog/shared"; import { ANALYTICS_EVENTS, type OnboardingStepCompletedProperties, -} from "@shared/types/analytics"; +} from "@posthog/shared/analytics-events"; +import { builderHog } from "@posthog/ui/assets/hedgehogs"; +import { + CliCheckPanel, + InstalledBadge, +} from "@posthog/ui/features/onboarding/components/CliCheckPanel"; +import { OptionalBadge } from "@posthog/ui/features/onboarding/components/OptionalBadge"; +import { StepActions } from "@posthog/ui/features/onboarding/components/StepActions"; +import { OnboardingHogTip } from "@posthog/ui/primitives/OnboardingHogTip"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { track } from "@posthog/ui/workbench/analytics"; +import { openExternalUrl } from "@posthog/ui/workbench/openExternal"; +import { Button, Flex, IconButton, Text } from "@radix-ui/themes"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { track } from "@utils/analytics"; -import { EXTERNAL_LINKS } from "@utils/links"; import { motion } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; -import { CliCheckPanel, InstalledBadge } from "./CliCheckPanel"; -import { OnboardingHogTip } from "./OnboardingHogTip"; -import { OptionalBadge } from "./OptionalBadge"; -import { StepActions } from "./StepActions"; function CommandLine({ command }: { command: string }) { const [copied, setCopied] = useState(false); @@ -77,7 +81,7 @@ interface InstallCliStepProps { } export function InstallCliStep({ onNext, onBack }: InstallCliStepProps) { - const trpc = useTRPC(); + const trpc = useHostTRPC(); const queryClient = useQueryClient(); const [isCheckingGit, setIsCheckingGit] = useState(false); @@ -106,13 +110,13 @@ export function InstallCliStep({ onNext, onBack }: InstallCliStepProps) { const handleCheckGit = useCallback(async () => { setIsCheckingGit(true); - await queryClient.invalidateQueries(trpc.git.getGitStatus.queryFilter()); + await queryClient.invalidateQueries(trpc.git.getGitStatus.pathFilter()); setIsCheckingGit(false); }, [queryClient, trpc]); const handleCheckGh = useCallback(async () => { setIsCheckingGh(true); - await queryClient.invalidateQueries(trpc.git.getGhStatus.queryFilter()); + await queryClient.invalidateQueries(trpc.git.getGhStatus.pathFilter()); setIsCheckingGh(false); }, [queryClient, trpc]); @@ -189,9 +193,7 @@ export function InstallCliStep({ onNext, onBack }: InstallCliStepProps) { variant="ghost" color="gray" onClick={() => - trpcClient.os.openExternal.mutate({ - url: EXTERNAL_LINKS.gitInstall, - }) + openExternalUrl(EXTERNAL_LINKS.gitInstall) } > Other install methods @@ -257,9 +259,7 @@ export function InstallCliStep({ onNext, onBack }: InstallCliStepProps) { variant="ghost" color="gray" onClick={() => - trpcClient.os.openExternal.mutate({ - url: EXTERNAL_LINKS.ghInstall, - }) + openExternalUrl(EXTERNAL_LINKS.ghInstall) } > Other install methods diff --git a/apps/code/src/renderer/features/onboarding/components/InviteCodeStep.tsx b/packages/ui/src/features/onboarding/components/InviteCodeStep.tsx similarity index 92% rename from apps/code/src/renderer/features/onboarding/components/InviteCodeStep.tsx rename to packages/ui/src/features/onboarding/components/InviteCodeStep.tsx index 0e17070ff3..d5a3e69259 100644 --- a/apps/code/src/renderer/features/onboarding/components/InviteCodeStep.tsx +++ b/packages/ui/src/features/onboarding/components/InviteCodeStep.tsx @@ -1,12 +1,12 @@ -import { useRedeemInviteCodeMutation } from "@features/auth/hooks/authMutations"; -import { useAuthUiStateStore } from "@features/auth/stores/authUiStateStore"; import { ArrowLeft, ArrowRight } from "@phosphor-icons/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; -import happyHog from "@renderer/assets/images/hedgehogs/happy-hog.png"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; import { motion } from "framer-motion"; -import { OnboardingHogTip } from "./OnboardingHogTip"; +import { happyHog } from "../../../assets/hedgehogs"; +import { OnboardingHogTip } from "../../../primitives/OnboardingHogTip"; +import { track } from "../../../workbench/analytics"; +import { useAuthUiStateStore } from "../../auth/authUiStateStore"; +import { useRedeemInviteCodeMutation } from "../../auth/useAuthMutations"; import { StepActions } from "./StepActions"; interface InviteCodeStepProps { diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx b/packages/ui/src/features/onboarding/components/OnboardingFlow.tsx similarity index 75% rename from apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx rename to packages/ui/src/features/onboarding/components/OnboardingFlow.tsx index 3ee898f3a9..bc4a46dba5 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/packages/ui/src/features/onboarding/components/OnboardingFlow.tsx @@ -1,30 +1,33 @@ -import { FullScreenLayout } from "@components/FullScreenLayout"; -import { useLogoutMutation } from "@features/auth/hooks/authMutations"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { useUserGithubIntegrations } from "@hooks/useIntegrations"; import { ArrowRight, SignOut } from "@phosphor-icons/react"; -import { Button, Flex } from "@radix-ui/themes"; -import { IS_DEV } from "@shared/constants/environment"; import { - ANALYTICS_EVENTS, - type OnboardingStepCompletedProperties, -} from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; -import { track } from "@utils/analytics"; -import { shipIt } from "@utils/confetti"; + buildAbandonedProps, + buildCompletedProps, + buildStepCompletedProps, + type StepCompletedContext, +} from "@posthog/core/onboarding/analytics"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useLogoutMutation } from "@posthog/ui/features/auth/useAuthMutations"; +import { useUserGithubIntegrations } from "@posthog/ui/features/integrations/useIntegrations"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { ConnectGitHubStep } from "@posthog/ui/features/onboarding/components/ConnectGitHubStep"; +import { InstallCliStep } from "@posthog/ui/features/onboarding/components/InstallCliStep"; +import { StepIndicator } from "@posthog/ui/features/onboarding/components/StepIndicator"; +import { WelcomeScreen } from "@posthog/ui/features/onboarding/components/WelcomeScreen"; +import { useOnboardingFlow } from "@posthog/ui/features/onboarding/hooks/useOnboardingFlow"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { shipIt } from "@posthog/ui/primitives/confetti"; +import { FullScreenLayout } from "@posthog/ui/primitives/FullScreenLayout"; +import { track } from "@posthog/ui/workbench/analytics"; +import { Button, Flex } from "@radix-ui/themes"; import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; import { useEffect, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; - -import { useOnboardingFlow } from "../hooks/useOnboardingFlow"; -import { ConnectGitHubStep } from "./ConnectGitHubStep"; -import { InstallCliStep } from "./InstallCliStep"; import { InviteCodeStep } from "./InviteCodeStep"; import { ProjectSelectStep } from "./ProjectSelectStep"; import { SelectRepoStep } from "./SelectRepoStep"; -import { StepIndicator } from "./StepIndicator"; -import { WelcomeScreen } from "./WelcomeScreen"; + +const IS_DEV = import.meta.env.DEV; const stepVariants = { enter: (dir: number) => ({ opacity: 0, x: dir * 20 }), @@ -73,32 +76,31 @@ export function OnboardingFlow() { useEffect(() => { const handleBeforeUnload = () => { - track(ANALYTICS_EVENTS.ONBOARDING_ABANDONED, { - last_step_id: currentStep, - duration_seconds: Math.round( - (Date.now() - flowStartedAtRef.current) / 1000, - ), - }); + track( + ANALYTICS_EVENTS.ONBOARDING_ABANDONED, + buildAbandonedProps({ + lastStepId: currentStep, + flowStartedAtMs: flowStartedAtRef.current, + nowMs: Date.now(), + }), + ); }; window.addEventListener("beforeunload", handleBeforeUnload); return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [currentStep]); - type StepContext = Omit< - OnboardingStepCompletedProperties, - "step_id" | "step_index" | "total_steps" | "duration_seconds" - >; - - const trackStepCompleted = (context?: StepContext) => { - track(ANALYTICS_EVENTS.ONBOARDING_STEP_COMPLETED, { - step_id: currentStep, - step_index: currentIndex, - total_steps: activeSteps.length, - duration_seconds: Math.round( - (Date.now() - stepEnteredAtRef.current) / 1000, - ), - ...context, - }); + const trackStepCompleted = (context?: StepCompletedContext) => { + track( + ANALYTICS_EVENTS.ONBOARDING_STEP_COMPLETED, + buildStepCompletedProps({ + stepId: currentStep, + stepIndex: currentIndex, + totalSteps: activeSteps.length, + stepEnteredAtMs: stepEnteredAtRef.current, + nowMs: Date.now(), + context, + }), + ); }; const trackStepViewed = (stepIndex: number) => { @@ -112,7 +114,7 @@ export function OnboardingFlow() { stepEnteredAtRef.current = Date.now(); }; - const handleNext = (context?: StepContext) => { + const handleNext = (context?: StepCompletedContext) => { trackStepCompleted(context); trackStepViewed(currentIndex + 1); next(); @@ -138,13 +140,15 @@ export function OnboardingFlow() { } else { trackStepCompleted(); } - track(ANALYTICS_EVENTS.ONBOARDING_COMPLETED, { - duration_seconds: Math.round( - (Date.now() - flowStartedAtRef.current) / 1000, - ), - github_connected: githubUserIntegrations.length > 0, - repo_skipped: repoSkipped, - }); + track( + ANALYTICS_EVENTS.ONBOARDING_COMPLETED, + buildCompletedProps({ + flowStartedAtMs: flowStartedAtRef.current, + nowMs: Date.now(), + githubConnected: githubUserIntegrations.length > 0, + repoSkipped, + }), + ); shipIt(); completeOnboarding(); navigateToTaskInput(); @@ -161,12 +165,14 @@ export function OnboardingFlow() { }; const handleLogout = () => { - track(ANALYTICS_EVENTS.ONBOARDING_ABANDONED, { - last_step_id: currentStep, - duration_seconds: Math.round( - (Date.now() - flowStartedAtRef.current) / 1000, - ), - }); + track( + ANALYTICS_EVENTS.ONBOARDING_ABANDONED, + buildAbandonedProps({ + lastStepId: currentStep, + flowStartedAtMs: flowStartedAtRef.current, + nowMs: Date.now(), + }), + ); logoutMutation.mutate(); resetOnboarding(); }; diff --git a/apps/code/src/renderer/features/onboarding/components/OptionalBadge.tsx b/packages/ui/src/features/onboarding/components/OptionalBadge.tsx similarity index 100% rename from apps/code/src/renderer/features/onboarding/components/OptionalBadge.tsx rename to packages/ui/src/features/onboarding/components/OptionalBadge.tsx diff --git a/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx b/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx similarity index 93% rename from apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx rename to packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx index 64b3f8e81a..18d8f16901 100644 --- a/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx +++ b/packages/ui/src/features/onboarding/components/ProjectSelectStep.tsx @@ -1,18 +1,3 @@ -import { SignInCard } from "@features/auth/components/SignInCard"; -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useSelectProjectMutation } from "@features/auth/hooks/authMutations"; -import { - authKeys, - useAuthStateFetched, - useAuthStateValue, - useCurrentUser, -} from "@features/auth/hooks/authQueries"; -import { useSeatStore } from "@features/billing/stores/seatStore"; -import { - type ProjectInfo, - useProjects, -} from "@features/projects/hooks/useProjects"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { ArrowLeft, ArrowRight, @@ -28,21 +13,38 @@ import { ComboboxList, ComboboxTrigger, } from "@posthog/quill"; -import { Button, Flex, Spinner, Text } from "@radix-ui/themes"; -import happyHog from "@renderer/assets/images/hedgehogs/happy-hog.png"; +import { BILLING_FLAG } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { happyHog } from "@posthog/ui/assets/hedgehogs"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { SignInCard } from "@posthog/ui/features/auth/SignInCard"; +import { + useAuthStateFetched, + useAuthStateValue, +} from "@posthog/ui/features/auth/store"; +import { useSelectProjectMutation } from "@posthog/ui/features/auth/useAuthMutations"; +import { + authKeys, + useCurrentUser, +} from "@posthog/ui/features/auth/useCurrentUser"; +import { useSeatStore } from "@posthog/ui/features/billing/seatStore"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { StepActions } from "@posthog/ui/features/onboarding/components/StepActions"; +import { + type ProjectInfo, + useProjects, +} from "@posthog/ui/features/projects/useProjects"; +import { OnboardingHogTip } from "@posthog/ui/primitives/OnboardingHogTip"; import { FIELD_CONTENT_CLASS, FIELD_TRIGGER_CLASS, -} from "@renderer/styles/fieldTrigger"; -import { BILLING_FLAG } from "@shared/constants"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +} from "@posthog/ui/styles/fieldTrigger"; +import { track } from "@posthog/ui/workbench/analytics"; +import { logger } from "@posthog/ui/workbench/logger"; +import { Button, Flex, Spinner, Text } from "@radix-ui/themes"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { track } from "@utils/analytics"; -import { logger } from "@utils/logger"; import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useMemo, useRef, useState } from "react"; -import { OnboardingHogTip } from "./OnboardingHogTip"; -import { StepActions } from "./StepActions"; const log = logger.scope("project-select-step"); diff --git a/apps/code/src/renderer/features/onboarding/components/SelectRepoStep.tsx b/packages/ui/src/features/onboarding/components/SelectRepoStep.tsx similarity index 93% rename from apps/code/src/renderer/features/onboarding/components/SelectRepoStep.tsx rename to packages/ui/src/features/onboarding/components/SelectRepoStep.tsx index cd8d8c766d..1e02b832d8 100644 --- a/apps/code/src/renderer/features/onboarding/components/SelectRepoStep.tsx +++ b/packages/ui/src/features/onboarding/components/SelectRepoStep.tsx @@ -1,5 +1,3 @@ -import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; -import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; import { ArrowLeft, ArrowRight, @@ -8,13 +6,16 @@ import { FolderOpen, Lightbulb, } from "@phosphor-icons/react"; +import { repoMatchesGitHubRepos } from "@posthog/core/onboarding/repoProvider"; import { cn } from "@posthog/quill"; import { Box, Button, Flex, Text } from "@radix-ui/themes"; -import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; import { AnimatePresence, motion } from "framer-motion"; import { useMemo } from "react"; -import type { DetectedRepo } from "../hooks/useOnboardingFlow"; -import { OnboardingHogTip } from "./OnboardingHogTip"; +import { builderHog } from "../../../assets/hedgehogs"; +import { OnboardingHogTip } from "../../../primitives/OnboardingHogTip"; +import { FolderPicker } from "../../folder-picker/FolderPicker"; +import { useUserRepositoryIntegration } from "../../integrations/useIntegrations"; +import type { DetectedRepo } from "../types"; import { OptionalBadge } from "./OptionalBadge"; import { PANEL_SHADOW } from "./onboardingStyles"; import { StepActions } from "./StepActions"; @@ -38,12 +39,10 @@ export function SelectRepoStep({ }: SelectRepoStepProps) { const { repositories } = useUserRepositoryIntegration(); - const repoMatchesGitHub = useMemo(() => { - if (!detectedRepo || repositories.length === 0) return false; - return repositories.some( - (r) => r.toLowerCase() === detectedRepo.fullName.toLowerCase(), - ); - }, [detectedRepo, repositories]); + const repoMatchesGitHub = useMemo( + () => repoMatchesGitHubRepos(detectedRepo, repositories), + [detectedRepo, repositories], + ); return ( <Flex align="center" height="100%" px="8"> diff --git a/apps/code/src/renderer/features/onboarding/components/StepActions.tsx b/packages/ui/src/features/onboarding/components/StepActions.tsx similarity index 100% rename from apps/code/src/renderer/features/onboarding/components/StepActions.tsx rename to packages/ui/src/features/onboarding/components/StepActions.tsx diff --git a/apps/code/src/renderer/features/onboarding/components/StepIndicator.tsx b/packages/ui/src/features/onboarding/components/StepIndicator.tsx similarity index 100% rename from apps/code/src/renderer/features/onboarding/components/StepIndicator.tsx rename to packages/ui/src/features/onboarding/components/StepIndicator.tsx diff --git a/apps/code/src/renderer/features/onboarding/components/WelcomeScreen.tsx b/packages/ui/src/features/onboarding/components/WelcomeScreen.tsx similarity index 92% rename from apps/code/src/renderer/features/onboarding/components/WelcomeScreen.tsx rename to packages/ui/src/features/onboarding/components/WelcomeScreen.tsx index 29fe3b63de..c405bb9021 100644 --- a/apps/code/src/renderer/features/onboarding/components/WelcomeScreen.tsx +++ b/packages/ui/src/features/onboarding/components/WelcomeScreen.tsx @@ -6,13 +6,13 @@ import { Robot, Tray, } from "@phosphor-icons/react"; +import { explorerHog } from "@posthog/ui/assets/hedgehogs"; +import { FeatureBentoCard } from "@posthog/ui/features/onboarding/components/FeatureBentoCard"; +import { StepActions } from "@posthog/ui/features/onboarding/components/StepActions"; +import Logo from "@posthog/ui/primitives/Logo"; +import { OnboardingHogTip } from "@posthog/ui/primitives/OnboardingHogTip"; import { Button, Flex, Text } from "@radix-ui/themes"; -import explorerHog from "@renderer/assets/images/hedgehogs/explorer-hog.png"; -import Logo from "@renderer/assets/logo"; import { useCallback, useEffect, useRef, useState } from "react"; -import { FeatureBentoCard } from "./FeatureBentoCard"; -import { OnboardingHogTip } from "./OnboardingHogTip"; -import { StepActions } from "./StepActions"; const FEATURES = [ { diff --git a/apps/code/src/renderer/features/onboarding/components/onboardingStyles.ts b/packages/ui/src/features/onboarding/components/onboardingStyles.ts similarity index 100% rename from apps/code/src/renderer/features/onboarding/components/onboardingStyles.ts rename to packages/ui/src/features/onboarding/components/onboardingStyles.ts diff --git a/packages/ui/src/features/onboarding/githubConnectClientImpl.ts b/packages/ui/src/features/onboarding/githubConnectClientImpl.ts new file mode 100644 index 0000000000..a68919e5a1 --- /dev/null +++ b/packages/ui/src/features/onboarding/githubConnectClientImpl.ts @@ -0,0 +1,18 @@ +import type { GithubConnectClient } from "@posthog/core/onboarding/identifiers"; +import { getAuthenticatedClient } from "@posthog/ui/features/auth/authClientImperative"; + +async function authedClient() { + const client = await getAuthenticatedClient(); + if (!client) { + throw new Error("Not authenticated"); + } + return client; +} + +export class OnboardingGithubConnectClient implements GithubConnectClient { + async disconnectGithubUserIntegration(installationId: string): Promise<void> { + await (await authedClient()).disconnectGithubUserIntegration( + installationId, + ); + } +} diff --git a/packages/ui/src/features/onboarding/hooks/useOnboardingFlow.ts b/packages/ui/src/features/onboarding/hooks/useOnboardingFlow.ts new file mode 100644 index 0000000000..3816b9ebd5 --- /dev/null +++ b/packages/ui/src/features/onboarding/hooks/useOnboardingFlow.ts @@ -0,0 +1,133 @@ +import { + inferRepositoryProvider, + toDetectedRepo, +} from "@posthog/core/onboarding/repoProvider"; +import { + computeActiveSteps, + isFirstStep as computeIsFirstStep, + isLastStep as computeIsLastStep, + nextStep as computeNextStep, + previousStep as computePreviousStep, + type DetectedRepo, + type OnboardingStep, + stepDirection, +} from "@posthog/core/onboarding/steps"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { useActiveRepoStore } from "@posthog/ui/workbench/activeRepoStore"; +import { track } from "@posthog/ui/workbench/analytics"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +export type { DetectedRepo }; + +export function useOnboardingFlow() { + const hostClient = useHostTRPCClient(); + const currentStep = useOnboardingStore((state) => state.currentStep); + const setCurrentStep = useOnboardingStore((state) => state.setCurrentStep); + const selectedDirectory = useActiveRepoStore((state) => state.path); + const setSelectedDirectory = useActiveRepoStore((state) => state.setPath); + const directionRef = useRef<1 | -1>(1); + + const [detectedRepo, setDetectedRepo] = useState<DetectedRepo | null>(null); + const [isDetectingRepo, setIsDetectingRepo] = useState(false); + const hasRehydrated = useRef(false); + + useEffect(() => { + if (hasRehydrated.current || !selectedDirectory) return; + hasRehydrated.current = true; + setIsDetectingRepo(true); + hostClient.git.detectRepo + .query({ directoryPath: selectedDirectory }) + .then((result) => setDetectedRepo(toDetectedRepo(result))) + .catch(() => {}) + .finally(() => setIsDetectingRepo(false)); + }, [selectedDirectory, hostClient]); + + const handleDirectoryChange = useCallback( + async (path: string) => { + setSelectedDirectory(path); + setDetectedRepo(null); + if (!path) return; + + setIsDetectingRepo(true); + try { + const result = await hostClient.git.detectRepo.query({ + directoryPath: path, + }); + const repo = toDetectedRepo(result); + setDetectedRepo(repo); + track(ANALYTICS_EVENTS.ONBOARDING_FOLDER_SELECTED, { + has_git_remote: !!repo, + repository_provider: repo + ? inferRepositoryProvider(repo.remote) + : "local", + }); + } catch { + track(ANALYTICS_EVENTS.ONBOARDING_FOLDER_SELECTED, { + has_git_remote: false, + repository_provider: "local", + }); + } finally { + setIsDetectingRepo(false); + } + }, + [setSelectedDirectory, hostClient], + ); + + const hasCodeAccess = useAuthStateValue((state) => state.hasCodeAccess); + + const activeSteps = useMemo( + () => computeActiveSteps(hasCodeAccess), + [hasCodeAccess], + ); + + useEffect(() => { + if (!activeSteps.includes(currentStep)) { + setCurrentStep(activeSteps[0]); + } + }, [activeSteps, currentStep, setCurrentStep]); + + const currentIndex = activeSteps.indexOf(currentStep); + const isFirstStep = computeIsFirstStep(currentIndex); + const isLastStep = computeIsLastStep(activeSteps, currentIndex); + + const next = () => { + const step = computeNextStep(activeSteps, currentIndex); + if (step) { + directionRef.current = 1; + setCurrentStep(step); + } + }; + + const back = () => { + const step = computePreviousStep(activeSteps, currentIndex); + if (step) { + directionRef.current = -1; + setCurrentStep(step); + } + }; + + const goTo = (step: OnboardingStep) => { + directionRef.current = stepDirection(activeSteps, currentIndex, step); + setCurrentStep(step); + }; + + return { + currentStep, + currentIndex, + totalSteps: activeSteps.length, + activeSteps, + isFirstStep, + isLastStep, + direction: directionRef.current, + next, + back, + goTo, + selectedDirectory, + detectedRepo, + isDetectingRepo, + handleDirectoryChange, + }; +} diff --git a/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts b/packages/ui/src/features/onboarding/hooks/useProjectsWithIntegrations.ts similarity index 50% rename from apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts rename to packages/ui/src/features/onboarding/hooks/useProjectsWithIntegrations.ts index c6da7b49ec..e5fd6076a4 100644 --- a/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts +++ b/packages/ui/src/features/onboarding/hooks/useProjectsWithIntegrations.ts @@ -1,9 +1,10 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { AUTH_SCOPED_QUERY_META } from "@features/auth/hooks/authQueries"; -import type { Integration } from "@features/integrations/stores/integrationStore"; -import { useProjects } from "@features/projects/hooks/useProjects"; +import { deriveProjectsWithIntegrations } from "@posthog/core/onboarding/projectsWithIntegrations"; import { useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; +import { useOptionalAuthenticatedClient } from "../../auth/authClient"; +import { AUTH_SCOPED_QUERY_META } from "../../auth/useCurrentUser"; +import type { Integration } from "../../integrations/store"; +import { useProjects } from "../../projects/useProjects"; export interface ProjectWithIntegrations { id: number; @@ -17,7 +18,6 @@ export function useProjectsWithIntegrations() { const { projects, isLoading: projectsLoading } = useProjects(); const client = useOptionalAuthenticatedClient(); - // Fetch integrations for each project in parallel const integrationQueries = useQueries({ queries: projects.map((project) => ({ queryKey: ["integrations", project.id], @@ -26,7 +26,7 @@ export function useProjectsWithIntegrations() { return client.getIntegrationsForProject(project.id); }, enabled: !!client && projects.length > 0, - staleTime: 60 * 1000, // 1 minute + staleTime: 60 * 1000, meta: AUTH_SCOPED_QUERY_META, })), }); @@ -35,26 +35,13 @@ export function useProjectsWithIntegrations() { projectsLoading || integrationQueries.some((q) => q.isLoading); const isFetching = integrationQueries.some((q) => q.isFetching); - const projectsWithIntegrations: ProjectWithIntegrations[] = useMemo(() => { - return projects - .map((project, index) => { - const integrations = (integrationQueries[index]?.data ?? - []) as Integration[]; - const hasGithubIntegration = integrations.some( - (i) => i.kind === "github", - ); - return { - ...project, - integrations, - hasGithubIntegration, - }; - }) - .sort((a, b) => a.name.localeCompare(b.name)); - }, [projects, integrationQueries]); - - const projectsWithGithub = useMemo( - () => projectsWithIntegrations.filter((p) => p.hasGithubIntegration), - [projectsWithIntegrations], + const { projects: projectsWithIntegrations, projectsWithGithub } = useMemo( + () => + deriveProjectsWithIntegrations( + projects, + integrationQueries.map((q) => q.data as Integration[] | undefined), + ), + [projects, integrationQueries], ); return { diff --git a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts b/packages/ui/src/features/onboarding/onboardingStore.ts similarity index 92% rename from apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts rename to packages/ui/src/features/onboarding/onboardingStore.ts index 07db31dbbb..eef3611d1a 100644 --- a/apps/code/src/renderer/features/onboarding/stores/onboardingStore.ts +++ b/packages/ui/src/features/onboarding/onboardingStore.ts @@ -1,7 +1,7 @@ -import { logger } from "@utils/logger"; +import type { OnboardingStep } from "@posthog/ui/features/onboarding/types"; +import { logger } from "@posthog/ui/workbench/logger"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -import type { OnboardingStep } from "../types"; const log = logger.scope("onboarding-store"); diff --git a/packages/ui/src/features/onboarding/types.ts b/packages/ui/src/features/onboarding/types.ts new file mode 100644 index 0000000000..b3b3aad987 --- /dev/null +++ b/packages/ui/src/features/onboarding/types.ts @@ -0,0 +1,5 @@ +export type { + DetectedRepo, + OnboardingStep, +} from "@posthog/core/onboarding/steps"; +export { ONBOARDING_STEPS } from "@posthog/core/onboarding/steps"; diff --git a/apps/code/src/renderer/features/panels/components/DraggableTab.tsx b/packages/ui/src/features/panels/components/DraggableTab.tsx similarity index 76% rename from apps/code/src/renderer/features/panels/components/DraggableTab.tsx rename to packages/ui/src/features/panels/components/DraggableTab.tsx index 2e5ac29c21..81774a178b 100644 --- a/apps/code/src/renderer/features/panels/components/DraggableTab.tsx +++ b/packages/ui/src/features/panels/components/DraggableTab.tsx @@ -1,10 +1,10 @@ import { useSortable } from "@dnd-kit/react/sortable"; -import type { TabData } from "@features/panels/store/panelTypes"; -import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; +import { resolveWorkspaceForRepoPath } from "@posthog/core/panels/resolveWorkspaceForRepoPath"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { useExternalAppAction } from "@posthog/ui/features/external-apps/useExternalAppAction"; +import type { TabData } from "@posthog/ui/features/panels/panelTypes"; import { Cross2Icon } from "@radix-ui/react-icons"; import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import { handleExternalAppAction } from "@utils/handleExternalAppAction"; import type React from "react"; import { useCallback } from "react"; @@ -47,6 +47,9 @@ export const DraggableTab: React.FC<DraggableTabProps> = ({ badge, hasUnsavedChanges, }) => { + const hostClient = useHostTRPCClient(); + const openExternalApp = useExternalAppAction(); + const { ref, isDragging } = useSortable({ id: tabId, index, @@ -69,12 +72,11 @@ export const DraggableTab: React.FC<DraggableTabProps> = ({ async (e: React.MouseEvent) => { e.preventDefault(); - let filePath: string | undefined; - if (tabData.type === "file") { - filePath = tabData.absolutePath; - } + const filePath = + tabData.type === "file" ? tabData.absolutePath : undefined; + const repoPath = tabData.type === "file" ? tabData.repoPath : undefined; - const result = await trpcClient.contextMenu.showTabContextMenu.mutate({ + const result = await hostClient.contextMenu.showTabContextMenu.mutate({ canClose: closeable, filePath, }); @@ -91,33 +93,29 @@ export const DraggableTab: React.FC<DraggableTabProps> = ({ case "close-right": onCloseToRight?.(); break; - case "external-app": + case "external-app": { if (filePath) { - const repoPath = - tabData.type === "file" ? tabData.repoPath : undefined; - const workspaces = await workspaceApi.getAll(); - const workspace = repoPath - ? (Object.values(workspaces).find( - (ws) => - ws?.worktreePath === repoPath || - ws?.folderPath === repoPath, - ) ?? null) - : null; - - await handleExternalAppAction( - result.action.action, - filePath, - label, - { - workspace, - mainRepoPath: workspace?.folderPath, - }, - ); + const workspaces = await hostClient.workspace.getAll.query(); + const workspace = resolveWorkspaceForRepoPath(workspaces, repoPath); + await openExternalApp(result.action.action, filePath, label, { + workspace, + mainRepoPath: workspace?.folderPath, + }); } break; + } } }, - [closeable, onClose, onCloseOthers, onCloseToRight, tabData, label], + [ + closeable, + onClose, + onCloseOthers, + onCloseToRight, + tabData, + label, + hostClient, + openExternalApp, + ], ); return ( diff --git a/apps/code/src/renderer/features/panels/components/GroupNodeRenderer.tsx b/packages/ui/src/features/panels/components/GroupNodeRenderer.tsx similarity index 86% rename from apps/code/src/renderer/features/panels/components/GroupNodeRenderer.tsx rename to packages/ui/src/features/panels/components/GroupNodeRenderer.tsx index 6157caf04d..f8d3314fc2 100644 --- a/apps/code/src/renderer/features/panels/components/GroupNodeRenderer.tsx +++ b/packages/ui/src/features/panels/components/GroupNodeRenderer.tsx @@ -1,8 +1,8 @@ import React from "react"; import type { ImperativePanelGroupHandle } from "react-resizable-panels"; -import { PANEL_SIZES } from "../constants/panelConstants"; -import type { GroupPanel, PanelNode } from "../store/panelTypes"; -import { calculateDefaultSize } from "../utils/panelLayoutUtils"; +import { PANEL_SIZES } from "../panelConstants"; +import { calculateDefaultSize } from "../panelLayoutUtils"; +import type { GroupPanel, PanelNode } from "../panelTypes"; import { Panel } from "./Panel"; import { PanelGroup } from "./PanelGroup"; import { PanelResizeHandle } from "./PanelResizeHandle"; diff --git a/apps/code/src/renderer/features/panels/components/LeafNodeRenderer.tsx b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx similarity index 91% rename from apps/code/src/renderer/features/panels/components/LeafNodeRenderer.tsx rename to packages/ui/src/features/panels/components/LeafNodeRenderer.tsx index 2e6214dc13..0d6568a6b9 100644 --- a/apps/code/src/renderer/features/panels/components/LeafNodeRenderer.tsx +++ b/packages/ui/src/features/panels/components/LeafNodeRenderer.tsx @@ -1,12 +1,12 @@ import { Cloud as CloudIcon } from "@phosphor-icons/react"; +import type { Task } from "@posthog/shared/domain-types"; import { Flex, Text } from "@radix-ui/themes"; -import { useIsWorkspaceCloudRun } from "@renderer/features/workspace/hooks/useWorkspace"; -import type { Task } from "@shared/types"; import type React from "react"; import { useMemo } from "react"; +import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace"; import { useTabInjection } from "../hooks/usePanelLayoutHooks"; -import type { SplitDirection } from "../store/panelLayoutStore"; -import type { LeafPanel } from "../store/panelTypes"; +import type { SplitDirection } from "../panelLayoutStore"; +import type { LeafPanel } from "../panelTypes"; import { TabbedPanel } from "./TabbedPanel"; interface LeafNodeRendererProps { diff --git a/apps/code/src/renderer/features/panels/components/Panel.tsx b/packages/ui/src/features/panels/components/Panel.tsx similarity index 100% rename from apps/code/src/renderer/features/panels/components/Panel.tsx rename to packages/ui/src/features/panels/components/Panel.tsx diff --git a/apps/code/src/renderer/features/panels/components/PanelDropZones.tsx b/packages/ui/src/features/panels/components/PanelDropZones.tsx similarity index 97% rename from apps/code/src/renderer/features/panels/components/PanelDropZones.tsx rename to packages/ui/src/features/panels/components/PanelDropZones.tsx index f46047802e..bacbdeca42 100644 --- a/apps/code/src/renderer/features/panels/components/PanelDropZones.tsx +++ b/packages/ui/src/features/panels/components/PanelDropZones.tsx @@ -1,7 +1,7 @@ import { useDroppable } from "@dnd-kit/react"; import { Box } from "@radix-ui/themes"; import type React from "react"; -import type { SplitDirection } from "../store/panelStore"; +import type { SplitDirection } from "../panelTypes"; type DropZoneType = SplitDirection | "center"; diff --git a/apps/code/src/renderer/features/panels/components/PanelGroup.tsx b/packages/ui/src/features/panels/components/PanelGroup.tsx similarity index 100% rename from apps/code/src/renderer/features/panels/components/PanelGroup.tsx rename to packages/ui/src/features/panels/components/PanelGroup.tsx diff --git a/apps/code/src/renderer/features/panels/components/PanelLayout.tsx b/packages/ui/src/features/panels/components/PanelLayout.tsx similarity index 95% rename from apps/code/src/renderer/features/panels/components/PanelLayout.tsx rename to packages/ui/src/features/panels/components/PanelLayout.tsx index 09394a8b91..9141acdbc2 100644 --- a/apps/code/src/renderer/features/panels/components/PanelLayout.tsx +++ b/packages/ui/src/features/panels/components/PanelLayout.tsx @@ -1,5 +1,5 @@ import { DragDropProvider } from "@dnd-kit/react"; -import type { Task } from "@shared/types"; +import type { Task } from "@posthog/shared/domain-types"; import type React from "react"; import { useCallback, useEffect } from "react"; import { useDragDropHandlers } from "../hooks/useDragDropHandlers"; @@ -9,9 +9,9 @@ import { usePanelLayoutState, usePanelSizeSync, } from "../hooks/usePanelLayoutHooks"; -import type { SplitDirection } from "../store/panelLayoutStore"; -import { usePanelLayoutStore } from "../store/panelLayoutStore"; -import type { PanelNode } from "../store/panelTypes"; +import type { SplitDirection } from "../panelLayoutStore"; +import { usePanelLayoutStore } from "../panelLayoutStore"; +import type { PanelNode } from "../panelTypes"; import { GroupNodeRenderer } from "./GroupNodeRenderer"; import { LeafNodeRenderer } from "./LeafNodeRenderer"; diff --git a/apps/code/src/renderer/features/panels/components/PanelResizeHandle.tsx b/packages/ui/src/features/panels/components/PanelResizeHandle.tsx similarity index 100% rename from apps/code/src/renderer/features/panels/components/PanelResizeHandle.tsx rename to packages/ui/src/features/panels/components/PanelResizeHandle.tsx diff --git a/apps/code/src/renderer/features/panels/components/PanelTab.tsx b/packages/ui/src/features/panels/components/PanelTab.tsx similarity index 94% rename from apps/code/src/renderer/features/panels/components/PanelTab.tsx rename to packages/ui/src/features/panels/components/PanelTab.tsx index 0673b28537..4ccb6437eb 100644 --- a/apps/code/src/renderer/features/panels/components/PanelTab.tsx +++ b/packages/ui/src/features/panels/components/PanelTab.tsx @@ -1,4 +1,4 @@ -import type { TabData } from "@features/panels/store/panelTypes"; +import type { TabData } from "@posthog/ui/features/panels/panelTypes"; import type React from "react"; import { DraggableTab } from "./DraggableTab"; diff --git a/apps/code/src/renderer/features/panels/components/PanelTree.tsx b/packages/ui/src/features/panels/components/PanelTree.tsx similarity index 100% rename from apps/code/src/renderer/features/panels/components/PanelTree.tsx rename to packages/ui/src/features/panels/components/PanelTree.tsx diff --git a/apps/code/src/renderer/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx similarity index 93% rename from apps/code/src/renderer/features/panels/components/TabbedPanel.tsx rename to packages/ui/src/features/panels/components/TabbedPanel.tsx index ea030d38d8..f36f35085f 100644 --- a/apps/code/src/renderer/features/panels/components/TabbedPanel.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -1,13 +1,13 @@ -import { Tooltip } from "@components/ui/Tooltip"; import { useDroppable } from "@dnd-kit/react"; import { Plus, SquareSplitHorizontalIcon } from "@phosphor-icons/react"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones"; +import type { SplitDirection } from "@posthog/ui/features/panels/panelLayoutStore"; +import type { PanelContent } from "@posthog/ui/features/panels/panelTypes"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { Box, Flex } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; import type React from "react"; import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; -import type { SplitDirection } from "../store/panelLayoutStore"; -import type { PanelContent } from "../store/panelStore"; -import { PanelDropZones } from "./PanelDropZones"; import { PanelTab } from "./PanelTab"; const activeTabStyle: React.CSSProperties = { @@ -85,10 +85,13 @@ export const TabbedPanel: React.FC<TabbedPanelProps> = ({ rightContent, emptyState, }) => { + const hostClient = useHostTRPCClient(); + const handleSplitClick = async () => { - const result = await trpcClient.contextMenu.showSplitContextMenu.mutate(); - if (result.direction) { - onSplitPanel?.(result.direction as SplitDirection); + const result = await hostClient.contextMenu.showSplitContextMenu.mutate(); + const direction = (result.direction as SplitDirection | null) ?? null; + if (direction) { + onSplitPanel?.(direction); } }; diff --git a/apps/code/src/renderer/features/panels/hooks/useDragDropHandlers.ts b/packages/ui/src/features/panels/hooks/useDragDropHandlers.ts similarity index 96% rename from apps/code/src/renderer/features/panels/hooks/useDragDropHandlers.ts rename to packages/ui/src/features/panels/hooks/useDragDropHandlers.ts index ed3cd1ed26..fb31d0c371 100644 --- a/apps/code/src/renderer/features/panels/hooks/useDragDropHandlers.ts +++ b/packages/ui/src/features/panels/hooks/useDragDropHandlers.ts @@ -1,9 +1,6 @@ import type { DragDropEvents } from "@dnd-kit/react"; -import { - type SplitDirection, - usePanelLayoutStore, -} from "../store/panelLayoutStore"; -import { findPanelById } from "../store/panelStoreHelpers"; +import { type SplitDirection, usePanelLayoutStore } from "../panelLayoutStore"; +import { findPanelById } from "../panelStoreHelpers"; const isSplitDirection = (zone: string): zone is SplitDirection => { return ( diff --git a/apps/code/src/renderer/features/panels/hooks/usePanelKeyboardShortcuts.ts b/packages/ui/src/features/panels/hooks/usePanelKeyboardShortcuts.ts similarity index 91% rename from apps/code/src/renderer/features/panels/hooks/usePanelKeyboardShortcuts.ts rename to packages/ui/src/features/panels/hooks/usePanelKeyboardShortcuts.ts index d0d5084d32..2e4d79cf99 100644 --- a/apps/code/src/renderer/features/panels/hooks/usePanelKeyboardShortcuts.ts +++ b/packages/ui/src/features/panels/hooks/usePanelKeyboardShortcuts.ts @@ -1,7 +1,7 @@ -import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; import { useHotkeys } from "react-hotkeys-hook"; -import { usePanelLayoutStore } from "../store/panelLayoutStore"; -import { getLeafPanel } from "../store/panelStoreHelpers"; +import { SHORTCUTS } from "../../command/keyboard-shortcuts"; +import { usePanelLayoutStore } from "../panelLayoutStore"; +import { getLeafPanel } from "../panelStoreHelpers"; export function usePanelKeyboardShortcuts(taskId: string): void { const layout = usePanelLayoutStore((state) => state.getLayout(taskId)); diff --git a/apps/code/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx similarity index 84% rename from apps/code/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx rename to packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx index 6e311e2273..b29dcb9e58 100644 --- a/apps/code/src/renderer/features/panels/hooks/usePanelLayoutHooks.tsx +++ b/packages/ui/src/features/panels/hooks/usePanelLayoutHooks.tsx @@ -1,16 +1,16 @@ -import { FileIcon } from "@components/ui/FileIcon"; -import { ActionTabIcon } from "@features/actions/components/ActionTabIcon"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { TabContentRenderer } from "@features/task-detail/components/TabContentRenderer"; import { ChatCenteredText, Terminal } from "@phosphor-icons/react"; -import type { Task } from "@shared/types"; -import { isAbsolutePath } from "@utils/path"; +import { resolveTabAbsolutePath } from "@posthog/core/panels/resolveTabPath"; +import type { Task } from "@posthog/shared/domain-types"; import { useCallback, useEffect, useMemo, useRef } from "react"; import type { ImperativePanelGroupHandle } from "react-resizable-panels"; -import type { SplitDirection } from "../store/panelLayoutStore"; -import { usePanelLayoutStore } from "../store/panelLayoutStore"; -import type { PanelNode, Tab } from "../store/panelTypes"; -import { shouldUpdateSizes } from "../utils/panelLayoutUtils"; +import { FileIcon } from "../../../primitives/FileIcon"; +import { ActionTabIcon } from "../../actions/ActionTabIcon"; +import { useCwd } from "../../sidebar/useCwd"; +import { TabContentRenderer } from "../../task-detail/components/TabContentRenderer"; +import type { SplitDirection } from "../panelLayoutStore"; +import { usePanelLayoutStore } from "../panelLayoutStore"; +import { shouldUpdateSizes } from "../panelLayoutUtils"; +import type { PanelNode, Tab } from "../panelTypes"; export interface PanelLayoutState { updateSizes: (taskId: string, groupId: string, sizes: number[]) => void; @@ -86,15 +86,12 @@ export function useTabInjection( tabs.map((tab) => { let updatedData = tab.data; if (tab.data.type === "file") { - const rp = tab.data.relativePath; - const absolutePath = isAbsolutePath(rp) - ? rp - : repoPath - ? `${repoPath}/${rp}` - : rp; updatedData = { ...tab.data, - absolutePath, + absolutePath: resolveTabAbsolutePath( + tab.data.relativePath, + repoPath, + ), repoPath, }; } diff --git a/packages/ui/src/features/panels/panelConstants.ts b/packages/ui/src/features/panels/panelConstants.ts new file mode 100644 index 0000000000..86ea661812 --- /dev/null +++ b/packages/ui/src/features/panels/panelConstants.ts @@ -0,0 +1,11 @@ +export { + DEFAULT_PANEL_IDS, + DEFAULT_TAB_IDS, + PANEL_SIZES, +} from "@posthog/core/panels/panelConstants"; + +export const UI_SIZES = { + TAB_HEIGHT: 40, + TAB_LABEL_MAX_WIDTH: 200, + DROP_ZONE_SIZE: "20%", +} as const; diff --git a/apps/code/src/renderer/features/panels/store/panelLayoutStore.test.ts b/packages/ui/src/features/panels/panelLayoutStore.test.ts similarity index 99% rename from apps/code/src/renderer/features/panels/store/panelLayoutStore.test.ts rename to packages/ui/src/features/panels/panelLayoutStore.test.ts index 15e88bcdcf..35a479ff69 100644 --- a/apps/code/src/renderer/features/panels/store/panelLayoutStore.test.ts +++ b/packages/ui/src/features/panels/panelLayoutStore.test.ts @@ -1,3 +1,11 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@posthog/ui/workbench/analytics", () => ({ + track: vi.fn(), + setActiveTaskContext: vi.fn(), +})); + +import { usePanelLayoutStore } from "./panelLayoutStore"; import { assertActiveTab, assertPanelLayout, @@ -8,18 +16,7 @@ import { getPanelTree, openMultipleFiles, withRootGroup, -} from "@test/panelTestHelpers"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@utils/electronStorage", () => ({ - electronStorage: { - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, - }, -})); - -import { usePanelLayoutStore } from "./panelLayoutStore"; +} from "./panelTestHelpers"; describe("panelLayoutStore", () => { beforeEach(() => { diff --git a/packages/ui/src/features/panels/panelLayoutStore.ts b/packages/ui/src/features/panels/panelLayoutStore.ts new file mode 100644 index 0000000000..8d6965c39c --- /dev/null +++ b/packages/ui/src/features/panels/panelLayoutStore.ts @@ -0,0 +1,377 @@ +import { + addRecentFile, + addActionTab as coreAddActionTab, + addTerminalTab as coreAddTerminalTab, + closeOtherTabs as coreCloseOtherTabs, + closeTab as coreCloseTab, + closeTabsToRight as coreCloseTabsToRight, + keepTab as coreKeepTab, + moveTab as coreMoveTab, + openTab as coreOpenTab, + openTabInSplit as coreOpenTabInSplit, + reorderTabs as coreReorderTabs, + setActiveTab as coreSetActiveTab, + updateSizes as coreUpdateSizes, + updateTabLabel as coreUpdateTabLabel, + updateTabMetadata as coreUpdateTabMetadata, + createInitialTaskLayout, + splitPanelTree, +} from "@posthog/core/panels/panelLayoutTransforms"; +import { createFileTabId } from "@posthog/core/panels/panelStoreHelpers"; +import { findTabInTree } from "@posthog/core/panels/panelTree"; +import { ANALYTICS_EVENTS, getFileExtension } from "@posthog/shared"; +import { persist } from "zustand/middleware"; +import { createWithEqualityFn } from "zustand/traditional"; +import { track } from "../../workbench/analytics"; +import { updateTaskLayout } from "./panelStoreHelpers"; +import type { PanelNode, Tab } from "./panelTypes"; + +export interface TaskLayout { + panelTree: PanelNode; + openFiles: string[]; + recentFiles: string[]; + draggingTabId: string | null; + draggingTabPanelId: string | null; + focusedPanelId: string | null; +} + +export type SplitDirection = "left" | "right" | "top" | "bottom"; + +type TaskLayouts = Record<string, TaskLayout>; + +export interface PanelLayoutStore { + taskLayouts: TaskLayouts; + + getLayout: (taskId: string) => TaskLayout | null; + initializeTask: (taskId: string) => void; + openFile: (taskId: string, filePath: string, asPreview?: boolean) => void; + openFileInSplit: ( + taskId: string, + filePath: string, + asPreview?: boolean, + ) => void; + keepTab: (taskId: string, panelId: string, tabId: string) => void; + closeTab: (taskId: string, panelId: string, tabId: string) => void; + closeOtherTabs: (taskId: string, panelId: string, tabId: string) => void; + closeTabsToRight: (taskId: string, panelId: string, tabId: string) => void; + closeTabsForFile: (taskId: string, filePath: string) => void; + + setActiveTab: (taskId: string, panelId: string, tabId: string) => void; + setDraggingTab: ( + taskId: string, + tabId: string | null, + panelId: string | null, + ) => void; + clearDraggingTab: (taskId: string) => void; + reorderTabs: ( + taskId: string, + panelId: string, + sourceIndex: number, + targetIndex: number, + ) => void; + moveTab: ( + taskId: string, + tabId: string, + sourcePanelId: string, + targetPanelId: string, + ) => void; + splitPanel: ( + taskId: string, + tabId: string, + sourcePanelId: string, + targetPanelId: string, + direction: SplitDirection, + ) => void; + updateSizes: (taskId: string, groupId: string, sizes: number[]) => void; + updateTabMetadata: ( + taskId: string, + tabId: string, + metadata: Partial<Pick<Tab, "hasUnsavedChanges">>, + ) => void; + updateTabLabel: (taskId: string, tabId: string, label: string) => void; + setFocusedPanel: (taskId: string, panelId: string) => void; + addTerminalTab: (taskId: string, panelId: string) => void; + addActionTab: ( + taskId: string, + panelId: string, + action: { + actionId: string; + command: string; + cwd: string; + label: string; + }, + ) => void; + clearAllLayouts: () => void; +} + +export const usePanelLayoutStore = createWithEqualityFn<PanelLayoutStore>()( + persist( + (set, get) => ({ + taskLayouts: {}, + + getLayout: (taskId) => { + return get().taskLayouts[taskId] || null; + }, + + initializeTask: (taskId) => { + set((state) => ({ + taskLayouts: { + ...state.taskLayouts, + [taskId]: createInitialTaskLayout() as TaskLayout, + }, + })); + }, + + openFile: (taskId, filePath, asPreview = true) => { + const tabId = createFileTabId(filePath); + set((state) => + updateTaskLayout(state, taskId, (layout) => { + const updates = coreOpenTab(layout, tabId, asPreview); + return { + ...updates, + recentFiles: addRecentFile(layout.recentFiles, filePath), + } as Partial<TaskLayout>; + }), + ); + + track(ANALYTICS_EVENTS.FILE_OPENED, { + file_extension: getFileExtension(filePath), + source: "sidebar", + task_id: taskId, + }); + }, + + openFileInSplit: (taskId, filePath, asPreview = true) => { + const tabId = createFileTabId(filePath); + set((state) => + updateTaskLayout(state, taskId, (layout) => { + const updates = coreOpenTabInSplit(layout, tabId, asPreview); + return { + ...updates, + recentFiles: addRecentFile(layout.recentFiles, filePath), + } as Partial<TaskLayout>; + }), + ); + + track(ANALYTICS_EVENTS.FILE_OPENED, { + file_extension: getFileExtension(filePath), + source: "sidebar", + task_id: taskId, + }); + }, + + keepTab: (taskId, panelId, tabId) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreKeepTab(layout, panelId, tabId) as Partial<TaskLayout>, + ), + ); + }, + + closeTab: (taskId, panelId, tabId) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreCloseTab(layout, panelId, tabId) as Partial<TaskLayout>, + ), + ); + }, + + closeOtherTabs: (taskId, panelId, tabId) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreCloseOtherTabs(layout, panelId, tabId) as Partial<TaskLayout>, + ), + ); + }, + + closeTabsToRight: (taskId, panelId, tabId) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreCloseTabsToRight( + layout, + panelId, + tabId, + ) as Partial<TaskLayout>, + ), + ); + }, + + closeTabsForFile: (taskId, filePath) => { + const layout = get().taskLayouts[taskId]; + if (!layout) return; + + const tabId = createFileTabId(filePath); + const tabLocation = findTabInTree(layout.panelTree, tabId); + if (tabLocation) { + get().closeTab(taskId, tabLocation.panelId, tabId); + } + }, + + setActiveTab: (taskId, panelId, tabId) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreSetActiveTab(layout, panelId, tabId) as Partial<TaskLayout>, + ), + ); + }, + + setDraggingTab: (taskId, tabId, panelId) => { + set((state) => + updateTaskLayout(state, taskId, () => ({ + draggingTabId: tabId, + draggingTabPanelId: panelId, + })), + ); + }, + + clearDraggingTab: (taskId) => { + set((state) => + updateTaskLayout(state, taskId, () => ({ + draggingTabId: null, + draggingTabPanelId: null, + })), + ); + }, + + reorderTabs: (taskId, panelId, sourceIndex, targetIndex) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreReorderTabs( + layout, + panelId, + sourceIndex, + targetIndex, + ) as Partial<TaskLayout>, + ), + ); + }, + + moveTab: (taskId, tabId, sourcePanelId, targetPanelId) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreMoveTab( + layout, + tabId, + sourcePanelId, + targetPanelId, + ) as Partial<TaskLayout>, + ), + ); + }, + + splitPanel: (taskId, tabId, sourcePanelId, targetPanelId, direction) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + splitPanelTree( + layout, + tabId, + sourcePanelId, + targetPanelId, + direction, + ) as Partial<TaskLayout>, + ), + ); + }, + + updateSizes: (taskId, groupId, sizes) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreUpdateSizes(layout, groupId, sizes) as Partial<TaskLayout>, + ), + ); + }, + + updateTabMetadata: (taskId, tabId, metadata) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreUpdateTabMetadata( + layout, + tabId, + metadata, + ) as Partial<TaskLayout>, + ), + ); + }, + + updateTabLabel: (taskId, tabId, label) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreUpdateTabLabel(layout, tabId, label) as Partial<TaskLayout>, + ), + ); + }, + + setFocusedPanel: (taskId, panelId) => { + set((state) => + updateTaskLayout(state, taskId, () => ({ + focusedPanelId: panelId, + })), + ); + }, + + addTerminalTab: (taskId, panelId) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreAddTerminalTab(layout, panelId) as Partial<TaskLayout>, + ), + ); + }, + + addActionTab: (taskId, panelId, action) => { + set((state) => + updateTaskLayout( + state, + taskId, + (layout) => + coreAddActionTab(layout, panelId, action) as Partial<TaskLayout>, + ), + ); + }, + + clearAllLayouts: () => { + set({ taskLayouts: {} }); + }, + }), + { + name: "panel-layout-store", + version: 10, + migrate: () => ({ taskLayouts: {} }), + }, + ), +); diff --git a/packages/ui/src/features/panels/panelLayoutUtils.ts b/packages/ui/src/features/panels/panelLayoutUtils.ts new file mode 100644 index 0000000000..3bd9e7beac --- /dev/null +++ b/packages/ui/src/features/panels/panelLayoutUtils.ts @@ -0,0 +1,4 @@ +export { + calculateDefaultSize, + shouldUpdateSizes, +} from "@posthog/core/panels/panelSizeMath"; diff --git a/packages/ui/src/features/panels/panelStoreHelpers.ts b/packages/ui/src/features/panels/panelStoreHelpers.ts new file mode 100644 index 0000000000..d450e912d6 --- /dev/null +++ b/packages/ui/src/features/panels/panelStoreHelpers.ts @@ -0,0 +1,89 @@ +import * as core from "@posthog/core/panels/panelStoreHelpers"; +import type { TaskLayout } from "./panelLayoutStore"; +import type { GroupPanel, LeafPanel, PanelNode, Tab } from "./panelTypes"; + +export type { + ParsedTabId, + SplitConfig, + TabType, +} from "@posthog/core/panels/panelStoreHelpers"; + +export const DEFAULT_FALLBACK_TAB = core.DEFAULT_FALLBACK_TAB; + +export const createFileTabId = core.createFileTabId; +export const parseTabId = core.parseTabId; +export const createTabLabel = core.createTabLabel; +export const generatePanelId = core.generatePanelId; +export const resetPanelIdCounter = core.resetPanelIdCounter; +export const getSplitConfig = core.getSplitConfig; +export const selectNextTabAfterClose = core.selectNextTabAfterClose; + +export const findPanelById = core.findPanelById as ( + node: PanelNode, + panelId: string, +) => PanelNode | null; + +export const getLeafPanel = core.getLeafPanel as ( + tree: PanelNode, + panelId: string, +) => LeafPanel | null; + +export const getGroupPanel = core.getGroupPanel as ( + tree: PanelNode, + panelId: string, +) => GroupPanel | null; + +export const createNewTab = core.createNewTab as ( + tabId: string, + closeable?: boolean, + isPreview?: boolean, +) => Tab; + +export const addNewTabToPanel = core.addNewTabToPanel as ( + panel: PanelNode, + tabId: string, + closeable?: boolean, + isPreview?: boolean, +) => PanelNode; + +export const updateMetadataForTab = core.updateMetadataForTab as ( + layout: TaskLayout, + tabId: string, + action: "add" | "remove", +) => Pick<TaskLayout, "openFiles">; + +export const applyCleanupWithFallback = core.applyCleanupWithFallback as ( + cleanedTree: PanelNode | null, + originalTree: PanelNode, +) => PanelNode; + +export const isTabActiveInTree = core.isTabActiveInTree as ( + tree: PanelNode, + tabId: string, +) => boolean; + +export const isFileTabActiveInTree = core.isFileTabActiveInTree as ( + tree: PanelNode, + filePath: string, +) => boolean; + +export function updateTaskLayout( + state: { taskLayouts: Record<string, TaskLayout> }, + taskId: string, + updater: (layout: TaskLayout) => Partial<TaskLayout>, +): { taskLayouts: Record<string, TaskLayout> } { + const layout = state.taskLayouts[taskId]; + if (!layout) return state; + + const updates = updater(layout); + + return { + taskLayouts: { + ...state.taskLayouts, + [taskId]: { + ...layout, + ...updates, + }, + }, + }; +} diff --git a/apps/code/src/shared/test/panelTestHelpers.ts b/packages/ui/src/features/panels/panelTestHelpers.ts similarity index 95% rename from apps/code/src/shared/test/panelTestHelpers.ts rename to packages/ui/src/features/panels/panelTestHelpers.ts index 6a54049df6..07d05be9ae 100644 --- a/apps/code/src/shared/test/panelTestHelpers.ts +++ b/packages/ui/src/features/panels/panelTestHelpers.ts @@ -1,5 +1,5 @@ -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import type { PanelNode } from "@features/panels/store/panelTypes"; +import { usePanelLayoutStore } from "./panelLayoutStore"; +import type { PanelNode } from "./panelTypes"; export function findPanelById( node: PanelNode, diff --git a/packages/ui/src/features/panels/panelTree.ts b/packages/ui/src/features/panels/panelTree.ts new file mode 100644 index 0000000000..833e7271ec --- /dev/null +++ b/packages/ui/src/features/panels/panelTree.ts @@ -0,0 +1,50 @@ +import * as core from "@posthog/core/panels/panelTree"; +import type { PanelNode, Tab } from "./panelTypes"; + +export const removeTabFromPanel = core.removeTabFromPanel as ( + node: PanelNode, + tabId: string, +) => PanelNode; + +export const addTabToPanel = core.addTabToPanel as ( + node: PanelNode, + tab: Tab, +) => PanelNode; + +export const setActiveTabInPanel = core.setActiveTabInPanel as ( + node: PanelNode, + tabId: string, +) => PanelNode; + +export const findTabInPanel = core.findTabInPanel as ( + panel: Extract<PanelNode, { type: "leaf" }>, + tabId: string, +) => Tab | undefined; + +export const findTabInTree = core.findTabInTree as ( + node: PanelNode, + tabId: string, +) => { panelId: string; tab: Tab } | null; + +export const updateTreeNode = core.updateTreeNode as ( + node: PanelNode, + targetId: string, + updateFn: (node: PanelNode) => PanelNode, +) => PanelNode; + +export const cleanupNode = core.cleanupNode as ( + node: PanelNode, +) => PanelNode | null; + +export const mergeTreeContent = core.mergeTreeContent as ( + existingTree: PanelNode, + newTree: PanelNode, +) => PanelNode; + +export const isLeaf = core.isLeaf as ( + node: PanelNode | null, +) => node is Extract<PanelNode, { type: "leaf" }>; + +export const isGroup = core.isGroup as ( + node: PanelNode | null, +) => node is Extract<PanelNode, { type: "group" }>; diff --git a/packages/ui/src/features/panels/panelTypes.ts b/packages/ui/src/features/panels/panelTypes.ts new file mode 100644 index 0000000000..860248b5b7 --- /dev/null +++ b/packages/ui/src/features/panels/panelTypes.ts @@ -0,0 +1,50 @@ +import type { + TabData as CoreTabData, + GroupId, + PanelId, + SplitDirection, + TabId, +} from "@posthog/core/panels/panelTypes"; + +export type { GroupId, PanelId, SplitDirection, TabId }; +export type TabData = CoreTabData; + +export type Tab = { + id: TabId; + label: string; + data: TabData; + component?: React.ReactNode; + closeable?: boolean; + draggable?: boolean; + onClose?: () => void; + onSelect?: () => void; + icon?: React.ReactNode; + hasUnsavedChanges?: boolean; + badge?: React.ReactNode; + isPreview?: boolean; +}; + +export type PanelContent = { + id: PanelId; + tabs: Tab[]; + activeTabId: TabId; + showTabs?: boolean; + droppable?: boolean; +}; + +export type LeafPanel = { + type: "leaf"; + id: PanelId; + content: PanelContent; + size?: number; +}; + +export type GroupPanel = { + type: "group"; + id: GroupId; + direction: "horizontal" | "vertical"; + children: PanelNode[]; + sizes?: number[]; +}; + +export type PanelNode = LeafPanel | GroupPanel; diff --git a/packages/ui/src/features/panels/panelUtils.ts b/packages/ui/src/features/panels/panelUtils.ts new file mode 100644 index 0000000000..513745d407 --- /dev/null +++ b/packages/ui/src/features/panels/panelUtils.ts @@ -0,0 +1,5 @@ +export { + calculateSplitSizes, + normalizeSizes, + redistributeSizes, +} from "@posthog/core/panels/panelSizeMath"; diff --git a/apps/code/src/renderer/components/permissions/DefaultPermission.tsx b/packages/ui/src/features/permissions/DefaultPermission.tsx similarity index 85% rename from apps/code/src/renderer/components/permissions/DefaultPermission.tsx rename to packages/ui/src/features/permissions/DefaultPermission.tsx index 90763cfaaf..e9bbce3cf1 100644 --- a/apps/code/src/renderer/components/permissions/DefaultPermission.tsx +++ b/packages/ui/src/features/permissions/DefaultPermission.tsx @@ -1,4 +1,4 @@ -import { ActionSelector } from "@components/ActionSelector"; +import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { type BasePermissionProps, toSelectorOptions } from "./types"; export function DefaultPermission({ diff --git a/apps/code/src/renderer/components/permissions/DeletePermission.tsx b/packages/ui/src/features/permissions/DeletePermission.tsx similarity index 87% rename from apps/code/src/renderer/components/permissions/DeletePermission.tsx rename to packages/ui/src/features/permissions/DeletePermission.tsx index ad0fee4899..d63b9c0332 100644 --- a/apps/code/src/renderer/components/permissions/DeletePermission.tsx +++ b/packages/ui/src/features/permissions/DeletePermission.tsx @@ -1,6 +1,6 @@ -import { ActionSelector } from "@components/ActionSelector"; +import { compactHomePath } from "@posthog/shared"; +import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { Code, Text } from "@radix-ui/themes"; -import { compactHomePath } from "@utils/path"; import { type BasePermissionProps, toSelectorOptions } from "./types"; export function DeletePermission({ diff --git a/apps/code/src/renderer/components/permissions/EditPermission.tsx b/packages/ui/src/features/permissions/EditPermission.tsx similarity index 84% rename from apps/code/src/renderer/components/permissions/EditPermission.tsx rename to packages/ui/src/features/permissions/EditPermission.tsx index 0cbfef2815..791f158631 100644 --- a/apps/code/src/renderer/components/permissions/EditPermission.tsx +++ b/packages/ui/src/features/permissions/EditPermission.tsx @@ -1,5 +1,5 @@ -import { ActionSelector } from "@components/ActionSelector"; -import { getFilename } from "@features/sessions/components/session-update/toolCallUtils"; +import { getFilename } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; +import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { Code } from "@radix-ui/themes"; import { type BasePermissionProps, diff --git a/apps/code/src/renderer/components/permissions/ExecutePermission.tsx b/packages/ui/src/features/permissions/ExecutePermission.tsx similarity index 87% rename from apps/code/src/renderer/components/permissions/ExecutePermission.tsx rename to packages/ui/src/features/permissions/ExecutePermission.tsx index b7066b9fea..4d0e9af8e3 100644 --- a/apps/code/src/renderer/components/permissions/ExecutePermission.tsx +++ b/packages/ui/src/features/permissions/ExecutePermission.tsx @@ -1,6 +1,6 @@ -import { ActionSelector } from "@components/ActionSelector"; +import { compactHomePath } from "@posthog/shared"; +import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { Box, Code } from "@radix-ui/themes"; -import { compactHomePath } from "@utils/path"; import { type BasePermissionProps, findTextContent, diff --git a/apps/code/src/renderer/components/permissions/FetchPermission.tsx b/packages/ui/src/features/permissions/FetchPermission.tsx similarity index 95% rename from apps/code/src/renderer/components/permissions/FetchPermission.tsx rename to packages/ui/src/features/permissions/FetchPermission.tsx index 32213e6d95..1ca4fea18a 100644 --- a/apps/code/src/renderer/components/permissions/FetchPermission.tsx +++ b/packages/ui/src/features/permissions/FetchPermission.tsx @@ -1,4 +1,4 @@ -import { ActionSelector } from "@components/ActionSelector"; +import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { Link, Text } from "@radix-ui/themes"; import { type BasePermissionProps, diff --git a/apps/code/src/renderer/components/permissions/McpPermission.tsx b/packages/ui/src/features/permissions/McpPermission.tsx similarity index 84% rename from apps/code/src/renderer/components/permissions/McpPermission.tsx rename to packages/ui/src/features/permissions/McpPermission.tsx index 3759266659..9b89780d3d 100644 --- a/apps/code/src/renderer/components/permissions/McpPermission.tsx +++ b/packages/ui/src/features/permissions/McpPermission.tsx @@ -1,11 +1,11 @@ -import { ActionSelector } from "@components/ActionSelector"; -import { parseMcpToolKey } from "@features/mcp-apps/utils/mcp-app-host-utils"; +import { parseMcpToolKey } from "@posthog/ui/features/mcp-apps/utils/mcp-app-host-utils"; import { formatPosthogExecBody, getPostHogExecDisplay, isPostHogExecTool, -} from "@features/posthog-mcp/utils/posthog-exec-display"; -import { formatInput } from "@features/sessions/components/session-update/toolCallUtils"; +} from "@posthog/ui/features/posthog-mcp/utils/posthog-exec-display"; +import { formatInput } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; +import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { Box, Code } from "@radix-ui/themes"; import { DefaultPermission } from "./DefaultPermission"; import { type BasePermissionProps, toSelectorOptions } from "./types"; diff --git a/apps/code/src/renderer/components/permissions/MovePermission.tsx b/packages/ui/src/features/permissions/MovePermission.tsx similarity index 84% rename from apps/code/src/renderer/components/permissions/MovePermission.tsx rename to packages/ui/src/features/permissions/MovePermission.tsx index 84d89e07f6..809137d563 100644 --- a/apps/code/src/renderer/components/permissions/MovePermission.tsx +++ b/packages/ui/src/features/permissions/MovePermission.tsx @@ -1,4 +1,4 @@ -import { ActionSelector } from "@components/ActionSelector"; +import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { type BasePermissionProps, toSelectorOptions } from "./types"; export function MovePermission({ diff --git a/apps/code/src/renderer/components/permissions/PermissionSelector.stories.tsx b/packages/ui/src/features/permissions/PermissionSelector.stories.tsx similarity index 99% rename from apps/code/src/renderer/components/permissions/PermissionSelector.stories.tsx rename to packages/ui/src/features/permissions/PermissionSelector.stories.tsx index 07b703abd2..c698d35dda 100644 --- a/apps/code/src/renderer/components/permissions/PermissionSelector.stories.tsx +++ b/packages/ui/src/features/permissions/PermissionSelector.stories.tsx @@ -8,8 +8,8 @@ import { buildQuestionToolCallData, type QuestionItem, } from "@posthog/agent/adapters/claude/questions/utils"; +import { PermissionSelector } from "@posthog/ui/features/permissions/PermissionSelector"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PermissionSelector } from "./PermissionSelector"; function buildToolCallData( toolName: string, diff --git a/apps/code/src/renderer/components/permissions/PermissionSelector.tsx b/packages/ui/src/features/permissions/PermissionSelector.tsx similarity index 100% rename from apps/code/src/renderer/components/permissions/PermissionSelector.tsx rename to packages/ui/src/features/permissions/PermissionSelector.tsx diff --git a/apps/code/src/renderer/components/permissions/PlanContent.tsx b/packages/ui/src/features/permissions/PlanContent.tsx similarity index 100% rename from apps/code/src/renderer/components/permissions/PlanContent.tsx rename to packages/ui/src/features/permissions/PlanContent.tsx diff --git a/apps/code/src/renderer/components/permissions/QuestionPermission.tsx b/packages/ui/src/features/permissions/QuestionPermission.tsx similarity index 99% rename from apps/code/src/renderer/components/permissions/QuestionPermission.tsx rename to packages/ui/src/features/permissions/QuestionPermission.tsx index ce8e8e603e..e6d1f89506 100644 --- a/apps/code/src/renderer/components/permissions/QuestionPermission.tsx +++ b/packages/ui/src/features/permissions/QuestionPermission.tsx @@ -1,3 +1,8 @@ +import { + type QuestionItem, + type QuestionMeta, + QuestionMetaSchema, +} from "@posthog/agent/adapters/claude/questions/utils"; import { ActionSelector, CANCEL_OPTION_ID, @@ -7,12 +12,7 @@ import { type StepAnswer, type StepInfo, SUBMIT_OPTION_ID, -} from "@components/ActionSelector"; -import { - type QuestionItem, - type QuestionMeta, - QuestionMetaSchema, -} from "@posthog/agent/adapters/claude/questions/utils"; +} from "@posthog/ui/primitives/ActionSelector"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useCallback, useMemo, useState } from "react"; import { type BasePermissionProps, toSelectorOptions } from "./types"; diff --git a/apps/code/src/renderer/components/permissions/ReadPermission.tsx b/packages/ui/src/features/permissions/ReadPermission.tsx similarity index 85% rename from apps/code/src/renderer/components/permissions/ReadPermission.tsx rename to packages/ui/src/features/permissions/ReadPermission.tsx index 74e77572d6..367c491d5a 100644 --- a/apps/code/src/renderer/components/permissions/ReadPermission.tsx +++ b/packages/ui/src/features/permissions/ReadPermission.tsx @@ -1,4 +1,4 @@ -import { ActionSelector } from "@components/ActionSelector"; +import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { type BasePermissionProps, toSelectorOptions } from "./types"; export function ReadPermission({ diff --git a/apps/code/src/renderer/components/permissions/SearchPermission.tsx b/packages/ui/src/features/permissions/SearchPermission.tsx similarity index 84% rename from apps/code/src/renderer/components/permissions/SearchPermission.tsx rename to packages/ui/src/features/permissions/SearchPermission.tsx index c092bcce1d..6184247ce0 100644 --- a/apps/code/src/renderer/components/permissions/SearchPermission.tsx +++ b/packages/ui/src/features/permissions/SearchPermission.tsx @@ -1,4 +1,4 @@ -import { ActionSelector } from "@components/ActionSelector"; +import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { type BasePermissionProps, toSelectorOptions } from "./types"; export function SearchPermission({ diff --git a/apps/code/src/renderer/components/permissions/SwitchModePermission.tsx b/packages/ui/src/features/permissions/SwitchModePermission.tsx similarity index 84% rename from apps/code/src/renderer/components/permissions/SwitchModePermission.tsx rename to packages/ui/src/features/permissions/SwitchModePermission.tsx index 3aff6484ea..3e39105e92 100644 --- a/apps/code/src/renderer/components/permissions/SwitchModePermission.tsx +++ b/packages/ui/src/features/permissions/SwitchModePermission.tsx @@ -1,4 +1,4 @@ -import { ActionSelector } from "@components/ActionSelector"; +import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { type BasePermissionProps, toSelectorOptions } from "./types"; export function SwitchModePermission({ diff --git a/apps/code/src/renderer/components/permissions/ThinkPermission.tsx b/packages/ui/src/features/permissions/ThinkPermission.tsx similarity index 84% rename from apps/code/src/renderer/components/permissions/ThinkPermission.tsx rename to packages/ui/src/features/permissions/ThinkPermission.tsx index 5eb1fc0021..ba9d64564f 100644 --- a/apps/code/src/renderer/components/permissions/ThinkPermission.tsx +++ b/packages/ui/src/features/permissions/ThinkPermission.tsx @@ -1,4 +1,4 @@ -import { ActionSelector } from "@components/ActionSelector"; +import { ActionSelector } from "@posthog/ui/primitives/ActionSelector"; import { type BasePermissionProps, toSelectorOptions } from "./types"; export function ThinkPermission({ diff --git a/apps/code/src/renderer/components/permissions/types.ts b/packages/ui/src/features/permissions/types.ts similarity index 87% rename from apps/code/src/renderer/components/permissions/types.ts rename to packages/ui/src/features/permissions/types.ts index ba7cd12c84..1505da5062 100644 --- a/apps/code/src/renderer/components/permissions/types.ts +++ b/packages/ui/src/features/permissions/types.ts @@ -3,8 +3,8 @@ import type { RequestPermissionRequest, ToolCallContent, } from "@agentclientprotocol/sdk"; -import type { SelectorOption } from "@components/ActionSelector"; -import type { CodeToolKind } from "@features/sessions/types"; +import type { CodeToolKind } from "@posthog/ui/features/sessions/types"; +import type { SelectorOption } from "@posthog/ui/primitives/ActionSelector"; type AcpToolCall = RequestPermissionRequest["toolCall"]; export type PermissionToolCall = Omit<AcpToolCall, "kind"> & { @@ -41,7 +41,7 @@ export function toSelectorOptions( export { type DiffContent, findDiffContent, -} from "@features/sessions/components/session-update/toolCallUtils"; +} from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; export type TerminalContent = Extract<ToolCallContent, { type: "terminal" }>; export type StandardContent = Extract<ToolCallContent, { type: "content" }>; diff --git a/apps/code/src/renderer/features/posthog-mcp/utils/posthog-exec-display.test.ts b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts similarity index 100% rename from apps/code/src/renderer/features/posthog-mcp/utils/posthog-exec-display.test.ts rename to packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.test.ts diff --git a/apps/code/src/renderer/features/posthog-mcp/utils/posthog-exec-display.ts b/packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts similarity index 100% rename from apps/code/src/renderer/features/posthog-mcp/utils/posthog-exec-display.ts rename to packages/ui/src/features/posthog-mcp/utils/posthog-exec-display.ts diff --git a/apps/code/src/renderer/hooks/useProjectQuery.ts b/packages/ui/src/features/projects/useProjectQuery.ts similarity index 74% rename from apps/code/src/renderer/hooks/useProjectQuery.ts rename to packages/ui/src/features/projects/useProjectQuery.ts index a0137df388..da16b9240a 100644 --- a/apps/code/src/renderer/hooks/useProjectQuery.ts +++ b/packages/ui/src/features/projects/useProjectQuery.ts @@ -1,5 +1,5 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; export function useProjectQuery() { const projectId = useAuthStateValue((state) => state.projectId); diff --git a/apps/code/src/renderer/features/projects/hooks/useProjects.tsx b/packages/ui/src/features/projects/useProjects.tsx similarity index 87% rename from apps/code/src/renderer/features/projects/hooks/useProjects.tsx rename to packages/ui/src/features/projects/useProjects.tsx index 0ac73003f8..ea648f74ca 100644 --- a/apps/code/src/renderer/features/projects/hooks/useProjects.tsx +++ b/packages/ui/src/features/projects/useProjects.tsx @@ -1,14 +1,11 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useSelectProjectMutation } from "@features/auth/hooks/authMutations"; -import { - useAuthStateValue, - useCurrentUser, -} from "@features/auth/hooks/authQueries"; -import { logger } from "@utils/logger"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { useService } from "@posthog/di/react"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useSelectProjectMutation } from "@posthog/ui/features/auth/useAuthMutations"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { useEffect, useMemo } from "react"; -const log = logger.scope("useProjects"); - export interface ProjectInfo { id: number; name: string; @@ -40,6 +37,7 @@ export function groupProjectsByOrg(projects: ProjectInfo[]): GroupedProjects[] { } export function useProjects() { + const log = useService<WorkbenchLogger>(WORKBENCH_LOGGER); const availableProjectIds = useAuthStateValue( (state) => state.availableProjectIds, ); @@ -119,6 +117,7 @@ export function useProjects() { selectProject, isSelectingProject, userTeamId, + log, ]); return { diff --git a/packages/ui/src/features/provisioning/ProvisioningView.tsx b/packages/ui/src/features/provisioning/ProvisioningView.tsx new file mode 100644 index 0000000000..32b48e47a1 --- /dev/null +++ b/packages/ui/src/features/provisioning/ProvisioningView.tsx @@ -0,0 +1,42 @@ +import { Box, Flex, Spinner, Text } from "@radix-ui/themes"; +import { useEffect, useRef } from "react"; +import { useProvisioningStore } from "./store"; + +interface ProvisioningViewProps { + taskId: string; +} + +export function ProvisioningView({ taskId }: ProvisioningViewProps) { + const lines = useProvisioningStore((s) => s.output[taskId]); + const scrollRef = useRef<HTMLPreElement>(null); + + const text = (lines ?? []).join("\n"); + + useEffect(() => { + const el = scrollRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, []); + + return ( + <Box height="100%"> + <Flex direction="column" height="100%" p="3" gap="2"> + <Flex align="center" gap="2"> + <Spinner size="1" /> + <Text className="font-medium text-[13px]"> + Setting up worktree... + </Text> + </Flex> + <Box className="min-h-0 flex-1 rounded-(--radius-2) border border-(--gray-a5) bg-(--color-surface)"> + <pre + ref={scrollRef} + className="m-0 h-full overflow-auto whitespace-pre-wrap break-all p-2 font-[var(--code-font-family)] text-(--gray-12) text-[13px]" + > + {text} + </pre> + </Box> + </Flex> + </Box> + ); +} diff --git a/packages/ui/src/features/provisioning/provisioning.contribution.ts b/packages/ui/src/features/provisioning/provisioning.contribution.ts new file mode 100644 index 0000000000..c8be7b400a --- /dev/null +++ b/packages/ui/src/features/provisioning/provisioning.contribution.ts @@ -0,0 +1,23 @@ +import type { WorkbenchContribution } from "@posthog/di/contribution"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { inject, injectable } from "inversify"; +import { useProvisioningStore } from "./store"; + +@injectable() +export class ProvisioningContribution implements WorkbenchContribution { + constructor( + @inject(HOST_TRPC_CLIENT) + private readonly hostClient: HostTrpcClient, + ) {} + + start(): void { + this.hostClient.provisioning.onOutput.subscribe(undefined, { + onData: ({ taskId, data }) => { + useProvisioningStore.getState().appendChunk(taskId, data); + }, + }); + } +} diff --git a/packages/ui/src/features/provisioning/provisioning.module.ts b/packages/ui/src/features/provisioning/provisioning.module.ts new file mode 100644 index 0000000000..8e790c4b75 --- /dev/null +++ b/packages/ui/src/features/provisioning/provisioning.module.ts @@ -0,0 +1,7 @@ +import { WORKBENCH_CONTRIBUTION } from "@posthog/di/contribution"; +import { ContainerModule } from "inversify"; +import { ProvisioningContribution } from "./provisioning.contribution"; + +export const provisioningUiModule = new ContainerModule(({ bind }) => { + bind(WORKBENCH_CONTRIBUTION).to(ProvisioningContribution).inSingletonScope(); +}); diff --git a/apps/code/src/renderer/features/provisioning/stores/provisioningStore.ts b/packages/ui/src/features/provisioning/store.ts similarity index 62% rename from apps/code/src/renderer/features/provisioning/stores/provisioningStore.ts rename to packages/ui/src/features/provisioning/store.ts index 2997ca8906..bd2279bc9a 100644 --- a/apps/code/src/renderer/features/provisioning/stores/provisioningStore.ts +++ b/packages/ui/src/features/provisioning/store.ts @@ -1,19 +1,23 @@ +import { appendOutputChunk } from "@posthog/core/provisioning/output"; import { create } from "zustand"; interface ProvisioningStoreState { activeTasks: Set<string>; + output: Record<string, string[]>; } interface ProvisioningStoreActions { setActive: (taskId: string) => void; clear: (taskId: string) => void; isActive: (taskId: string) => boolean; + appendChunk: (taskId: string, chunk: string) => void; } type ProvisioningStore = ProvisioningStoreState & ProvisioningStoreActions; export const useProvisioningStore = create<ProvisioningStore>()((set, get) => ({ activeTasks: new Set(), + output: {}, setActive: (taskId) => set((state) => { @@ -26,8 +30,17 @@ export const useProvisioningStore = create<ProvisioningStore>()((set, get) => ({ set((state) => { const next = new Set(state.activeTasks); next.delete(taskId); - return { activeTasks: next }; + const { [taskId]: _removed, ...output } = state.output; + return { activeTasks: next, output }; }), isActive: (taskId) => get().activeTasks.has(taskId), + + appendChunk: (taskId, chunk) => + set((state) => ({ + output: { + ...state.output, + [taskId]: appendOutputChunk(state.output[taskId] ?? [], chunk), + }, + })), })); diff --git a/apps/code/src/renderer/hooks/useDetectedCloudRepository.ts b/packages/ui/src/features/repo-files/useDetectedCloudRepository.ts similarity index 50% rename from apps/code/src/renderer/hooks/useDetectedCloudRepository.ts rename to packages/ui/src/features/repo-files/useDetectedCloudRepository.ts index efc4491751..e2017973ba 100644 --- a/apps/code/src/renderer/hooks/useDetectedCloudRepository.ts +++ b/packages/ui/src/features/repo-files/useDetectedCloudRepository.ts @@ -1,19 +1,15 @@ -import { useTRPC } from "@renderer/trpc"; +import { useHostTRPC } from "@posthog/host-router/react"; import { useQuery } from "@tanstack/react-query"; export function useDetectedCloudRepository( folderPath: string | null | undefined, ): string | null { - const trpcReact = useTRPC(); - const { data } = useQuery( - trpcReact.git.detectRepo.queryOptions( - { directoryPath: folderPath ?? "" }, - { - enabled: !!folderPath, - staleTime: 60_000, - }, - ), - ); + const trpc = useHostTRPC(); + const { data } = useQuery({ + ...trpc.git.detectRepo.queryOptions({ directoryPath: folderPath ?? "" }), + enabled: !!folderPath, + staleTime: 60_000, + }); if (!data?.organization || !data?.repository) return null; return `${data.organization}/${data.repository}`.toLowerCase(); diff --git a/apps/code/src/renderer/hooks/useRepoFiles.ts b/packages/ui/src/features/repo-files/useRepoFiles.ts similarity index 64% rename from apps/code/src/renderer/hooks/useRepoFiles.ts rename to packages/ui/src/features/repo-files/useRepoFiles.ts index b83598fc46..be5253d5fd 100644 --- a/apps/code/src/renderer/hooks/useRepoFiles.ts +++ b/packages/ui/src/features/repo-files/useRepoFiles.ts @@ -1,9 +1,22 @@ -import { trpc, useTRPC } from "@renderer/trpc/client"; -import type { MentionItem } from "@shared/types"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { HostRouter } from "@posthog/host-router/router"; +import type { MentionItem } from "@posthog/shared/domain-types"; import { useQuery } from "@tanstack/react-query"; -import { queryClient } from "@utils/queryClient"; +import { + createTRPCOptionsProxy, + type TRPCOptionsProxy, +} from "@trpc/tanstack-react-query"; import { byLengthAsc, Fzf } from "fzf"; import { useMemo } from "react"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "../../workbench/queryClient"; export interface FileItem { path: string; @@ -28,7 +41,7 @@ function pathToFolderItem(path: string): FileItem { return { path, name, dir, kind: "directory" }; } -function transformRawFiles( +export function transformRawFiles( rawFiles: MentionItem[], includeDirectories: boolean, ): FileItem[] { @@ -42,7 +55,7 @@ function transformRawFiles( ); } -function createFzf(files: FileItem[]): Fzf<FileItem[]> { +export function createFzf(files: FileItem[]): Fzf<FileItem[]> { return new Fzf(files, { selector: (item) => item.kind === "directory" @@ -59,13 +72,11 @@ export function useRepoFiles( options: { includeDirectories?: boolean } = {}, ) { const { includeDirectories = false } = options; - const trpcReact = useTRPC(); - const { data: rawFiles, isLoading } = useQuery( - trpcReact.fs.listRepoFiles.queryOptions( - { repoPath: repoPath ?? "" }, - { enabled: enabled && !!repoPath }, - ), - ); + const trpc = useHostTRPC(); + const { data: rawFiles, isLoading } = useQuery({ + ...trpc.fs.listRepoFiles.queryOptions({ repoPath: repoPath ?? "" }), + enabled: enabled && !!repoPath, + }); const files: FileItem[] = useMemo(() => { if (!rawFiles) return []; @@ -95,19 +106,33 @@ const fzfCache = new Map< >(); function fzfCacheKey(repoPath: string, includeDirectories: boolean): string { - return `${repoPath}\u0000${includeDirectories ? "1" : "0"}`; + return `${repoPath} ${includeDirectories ? "1" : "0"}`; +} + +let optionsProxy: TRPCOptionsProxy<HostRouter> | null = null; + +function repoFilesQueryOptions(repoPath: string) { + if (!optionsProxy) { + optionsProxy = createTRPCOptionsProxy<HostRouter>({ + client: resolveService<HostTrpcClient>(HOST_TRPC_CLIENT), + queryClient: resolveService<ImperativeQueryClient>( + IMPERATIVE_QUERY_CLIENT, + ), + }); + } + return optionsProxy.fs.listRepoFiles.queryOptions({ repoPath }); } export async function fetchRepoFiles( repoPath: string, options: { includeDirectories?: boolean } = {}, -): Promise<{ - files: FileItem[]; - fzf: Fzf<FileItem[]>; -}> { +): Promise<{ files: FileItem[]; fzf: Fzf<FileItem[]> }> { const { includeDirectories = false } = options; + const queryClient = resolveService<ImperativeQueryClient>( + IMPERATIVE_QUERY_CLIENT, + ); const rawFiles = await queryClient.fetchQuery({ - ...trpc.fs.listRepoFiles.queryOptions({ repoPath }), + ...repoFilesQueryOptions(repoPath), staleTime: 1000 * 60 * 5, }); @@ -123,9 +148,6 @@ export async function fetchRepoFiles( } const fzf = createFzf(files); - fzfCache.set(cacheKey, { - fzf, - filesLength: files.length, - }); + fzfCache.set(cacheKey, { fzf, filesLength: files.length }); return { files, fzf }; } diff --git a/apps/code/src/renderer/features/right-sidebar/stores/fileTreeStore.ts b/packages/ui/src/features/right-sidebar/fileTreeStore.ts similarity index 100% rename from apps/code/src/renderer/features/right-sidebar/stores/fileTreeStore.ts rename to packages/ui/src/features/right-sidebar/fileTreeStore.ts diff --git a/packages/ui/src/features/sessions/agentPromptSender.ts b/packages/ui/src/features/sessions/agentPromptSender.ts new file mode 100644 index 0000000000..a3bdcc8d56 --- /dev/null +++ b/packages/ui/src/features/sessions/agentPromptSender.ts @@ -0,0 +1,8 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; + +export type AgentPromptSender = ( + taskId: string, + prompt: string | ContentBlock[], +) => void; + +export const AGENT_PROMPT_SENDER = Symbol.for("posthog.ui.AgentPromptSender"); diff --git a/apps/code/src/renderer/features/sessions/components/CloudInitializingView.tsx b/packages/ui/src/features/sessions/components/CloudInitializingView.tsx similarity index 94% rename from apps/code/src/renderer/features/sessions/components/CloudInitializingView.tsx rename to packages/ui/src/features/sessions/components/CloudInitializingView.tsx index 101900c6c5..b444721d55 100644 --- a/apps/code/src/renderer/features/sessions/components/CloudInitializingView.tsx +++ b/packages/ui/src/features/sessions/components/CloudInitializingView.tsx @@ -1,8 +1,8 @@ import { Spinner } from "@phosphor-icons/react"; +import type { TaskRunStatus } from "@posthog/shared/domain-types"; import { Flex, Text } from "@radix-ui/themes"; -import zenHedgehog from "@renderer/assets/images/zen.png"; -import type { TaskRunStatus } from "@shared/types"; import { useEffect, useState } from "react"; +import zenHedgehog from "../../../assets/images/zen.png"; interface CloudInitializingViewProps { cloudStatus: TaskRunStatus | null; diff --git a/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.test.tsx b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.test.tsx similarity index 95% rename from apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.test.tsx rename to packages/ui/src/features/sessions/components/ContextBreakdownPopover.test.tsx index bc6c6b085a..c64da01bb5 100644 --- a/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.test.tsx +++ b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.test.tsx @@ -1,4 +1,4 @@ -import type { ContextUsage } from "@features/sessions/hooks/useContextUsage"; +import type { ContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx similarity index 95% rename from apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx rename to packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx index 24cc8803c8..1dfa7fa36c 100644 --- a/apps/code/src/renderer/features/sessions/components/ContextBreakdownPopover.tsx +++ b/packages/ui/src/features/sessions/components/ContextBreakdownPopover.tsx @@ -1,9 +1,9 @@ -import type { ContextUsage } from "@features/sessions/hooks/useContextUsage"; import { CONTEXT_CATEGORIES, formatTokensCompact, getOverallUsageColor, -} from "@features/sessions/utils/contextColors"; +} from "@posthog/ui/features/sessions/contextColors"; +import type { ContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; import { Flex, Text } from "@radix-ui/themes"; interface ContextBreakdownPopoverProps { diff --git a/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx b/packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx similarity index 94% rename from apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx rename to packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx index f1ac3c11ba..0629a1f902 100644 --- a/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx +++ b/packages/ui/src/features/sessions/components/ContextUsageIndicator.tsx @@ -1,8 +1,8 @@ -import type { ContextUsage } from "@features/sessions/hooks/useContextUsage"; import { formatTokensCompact, getOverallUsageColor, -} from "@features/sessions/utils/contextColors"; +} from "@posthog/ui/features/sessions/contextColors"; +import type { ContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; import { Flex, Popover, Text } from "@radix-ui/themes"; import { ContextBreakdownPopover } from "./ContextBreakdownPopover"; diff --git a/apps/code/src/renderer/features/sessions/components/ConversationSearchBar.tsx b/packages/ui/src/features/sessions/components/ConversationSearchBar.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/ConversationSearchBar.tsx rename to packages/ui/src/features/sessions/components/ConversationSearchBar.tsx diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.stories.tsx b/packages/ui/src/features/sessions/components/ConversationView.stories.tsx similarity index 99% rename from apps/code/src/renderer/features/sessions/components/ConversationView.stories.tsx rename to packages/ui/src/features/sessions/components/ConversationView.stories.tsx index b3917c1ed6..11af9c9207 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.stories.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.stories.tsx @@ -3,9 +3,9 @@ import { toolInfoFromToolUse, toolUpdateFromToolResult, } from "@posthog/agent/adapters/claude/conversion/tool-use-to-acp"; -import type { AcpMessage } from "@shared/types/session-events"; +import type { AcpMessage } from "@posthog/shared/session-events"; +import { ConversationView } from "@posthog/ui/features/sessions/components/ConversationView"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ConversationView } from "./ConversationView"; let timestamp = Date.now(); let messageId = 1; diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx similarity index 82% rename from apps/code/src/renderer/features/sessions/components/ConversationView.tsx rename to packages/ui/src/features/sessions/components/ConversationView.tsx index 4afb50fd67..e98aa72f4c 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -1,50 +1,48 @@ -import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants"; -import { useContextUsage } from "@features/sessions/hooks/useContextUsage"; -import { useConversationSearch } from "@features/sessions/hooks/useConversationSearch"; -import { SessionTaskIdProvider } from "@features/sessions/hooks/useSessionTaskId"; -import { - sessionStoreSetters, - useOptimisticItemsForTask, - usePendingPermissionsForTask, - useQueuedMessagesForTask, - useSessionForTask, -} from "@features/sessions/stores/sessionStore"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { SkillButtonActionMessage } from "@features/skill-buttons/components/SkillButtonActionMessage"; import { ArrowDown, XCircle } from "@phosphor-icons/react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; -import WorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"; -import { Box, Button, Flex, Text } from "@radix-ui/themes"; -import type { Task } from "@shared/types"; -import type { AcpMessage } from "@shared/types/session-events"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useService } from "@posthog/di/react"; +import type { AcpMessage } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; import { buildConversationItems, type ConversationItem, type TurnContext, -} from "./buildConversationItems"; -import { ConversationSearchBar } from "./ConversationSearchBar"; -import { GitActionMessage } from "./GitActionMessage"; -import { GitActionResult } from "./GitActionResult"; -import { mergeConversationItems } from "./mergeConversationItems"; -import { SessionFooter } from "./SessionFooter"; -import { QueuedMessageView } from "./session-update/QueuedMessageView"; +} from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; +import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage"; +import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult"; +import { mergeConversationItems } from "@posthog/ui/features/sessions/components/mergeConversationItems"; +import { SessionFooter } from "@posthog/ui/features/sessions/components/SessionFooter"; +import { QueuedMessageView } from "@posthog/ui/features/sessions/components/session-update/QueuedMessageView"; import { type RenderItem, SessionUpdateView, -} from "./session-update/SessionUpdateView"; -import { UserMessage } from "./session-update/UserMessage"; -import { UserShellExecuteView } from "./session-update/UserShellExecuteView"; -import { VirtualizedList, type VirtualizedListHandle } from "./VirtualizedList"; - -function diffsWorkerFactory(): Worker { - return new Worker(WorkerUrl, { type: "module" }); -} - -const DIFFS_POOL_OPTIONS = { - workerFactory: diffsWorkerFactory, - totalASTLRUCacheSize: 200, -}; +} from "@posthog/ui/features/sessions/components/session-update/SessionUpdateView"; +import { UserMessage } from "@posthog/ui/features/sessions/components/session-update/UserMessage"; +import { UserShellExecuteView } from "@posthog/ui/features/sessions/components/session-update/UserShellExecuteView"; +import { + VirtualizedList, + type VirtualizedListHandle, +} from "@posthog/ui/features/sessions/components/VirtualizedList"; +import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; +import { useContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; +import { useConversationSearch } from "@posthog/ui/features/sessions/hooks/useConversationSearch"; +import { + sessionStoreSetters, + useOptimisticItemsForTask, + usePendingPermissionsForTask, + useQueuedMessagesForTask, + useSessionForTask, +} from "@posthog/ui/features/sessions/sessionStore"; +import { SessionTaskIdProvider } from "@posthog/ui/features/sessions/useSessionTaskId"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { SkillButtonActionMessage } from "@posthog/ui/features/skill-buttons/components/SkillButtonActionMessage"; +import { + DIFF_WORKER_FACTORY, + type DiffWorkerFactory, +} from "@posthog/ui/workbench/diffWorkerHost"; +import { Box, Button, Flex, Text } from "@radix-ui/themes"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; const DIFFS_HIGHLIGHTER_OPTIONS = { theme: { dark: "github-dark" as const, light: "github-light" as const }, @@ -71,6 +69,15 @@ export function ConversationView({ slackThreadUrl, compact = false, }: ConversationViewProps) { + const diffWorkerFactory = useService<DiffWorkerFactory>(DIFF_WORKER_FACTORY); + const diffsPoolOptions = useMemo( + () => ({ + workerFactory: () => diffWorkerFactory(), + totalASTLRUCacheSize: 200, + }), + [diffWorkerFactory], + ); + const listRef = useRef<VirtualizedListHandle>(null); const isAtBottomRef = useRef(true); const [showScrollButton, setShowScrollButton] = useState(false); @@ -247,7 +254,7 @@ export function ConversationView({ return ( <WorkerPoolContextProvider - poolOptions={DIFFS_POOL_OPTIONS} + poolOptions={diffsPoolOptions} highlighterOptions={DIFFS_HIGHLIGHTER_OPTIONS} > <div ref={containerRef} className="relative flex-1"> diff --git a/apps/code/src/renderer/features/sessions/components/DiffStatsChip.tsx b/packages/ui/src/features/sessions/components/DiffStatsChip.tsx similarity index 81% rename from apps/code/src/renderer/features/sessions/components/DiffStatsChip.tsx rename to packages/ui/src/features/sessions/components/DiffStatsChip.tsx index 2a412c60fb..5f0200f261 100644 --- a/apps/code/src/renderer/features/sessions/components/DiffStatsChip.tsx +++ b/packages/ui/src/features/sessions/components/DiffStatsChip.tsx @@ -1,12 +1,12 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import { useDiffStatsToggle } from "@features/code-review/hooks/useDiffStatsToggle"; import { GitDiff } from "@phosphor-icons/react"; -import { Flex, Text } from "@radix-ui/themes"; +import type { Task } from "@posthog/shared/domain-types"; +import { useDiffStatsToggle } from "@posthog/ui/features/code-review/hooks/useDiffStatsToggle"; import { formatHotkey, SHORTCUTS, -} from "@renderer/constants/keyboard-shortcuts"; -import type { Task } from "@shared/types"; +} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { Flex, Text } from "@radix-ui/themes"; interface DiffStatsChipProps { task: Task; diff --git a/apps/code/src/renderer/features/sessions/components/DirtyTreeDialog.tsx b/packages/ui/src/features/sessions/components/DirtyTreeDialog.tsx similarity index 89% rename from apps/code/src/renderer/features/sessions/components/DirtyTreeDialog.tsx rename to packages/ui/src/features/sessions/components/DirtyTreeDialog.tsx index 84e06a0806..c4a51dfcfb 100644 --- a/apps/code/src/renderer/features/sessions/components/DirtyTreeDialog.tsx +++ b/packages/ui/src/features/sessions/components/DirtyTreeDialog.tsx @@ -1,12 +1,12 @@ -import { FileIcon } from "@components/ui/FileIcon"; -import { GitDialog } from "@features/git-interaction/components/GitInteractionDialogs"; +import { Warning } from "@phosphor-icons/react"; +import { GitDialog } from "@posthog/ui/features/git-interaction/components/GitInteractionDialogs"; import { getStatusIndicator, type StatusIndicator, -} from "@features/git-interaction/utils/gitStatusUtils"; -import { Warning } from "@phosphor-icons/react"; +} from "@posthog/ui/features/git-interaction/utils/gitStatusUtils"; +import type { HandoffChangedFile } from "@posthog/ui/features/sessions/handoffDialogStore"; +import { FileIcon } from "@posthog/ui/primitives/FileIcon"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; -import type { HandoffChangedFile } from "../stores/handoffDialogStore"; interface DirtyTreeDialogProps { open: boolean; diff --git a/apps/code/src/renderer/features/sessions/components/DropZoneOverlay.tsx b/packages/ui/src/features/sessions/components/DropZoneOverlay.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/DropZoneOverlay.tsx rename to packages/ui/src/features/sessions/components/DropZoneOverlay.tsx diff --git a/apps/code/src/renderer/features/sessions/components/GeneratingIndicator.test.ts b/packages/ui/src/features/sessions/components/GeneratingIndicator.test.ts similarity index 81% rename from apps/code/src/renderer/features/sessions/components/GeneratingIndicator.test.ts rename to packages/ui/src/features/sessions/components/GeneratingIndicator.test.ts index 8e76be1431..82602871be 100644 --- a/apps/code/src/renderer/features/sessions/components/GeneratingIndicator.test.ts +++ b/packages/ui/src/features/sessions/components/GeneratingIndicator.test.ts @@ -1,7 +1,6 @@ +import { formatDuration } from "@posthog/ui/features/sessions/components/GeneratingIndicator"; import { describe, expect, it } from "vitest"; -import { formatDuration } from "./GeneratingIndicator"; - describe("formatDuration", () => { it("formats sub-minute durations with configurable precision", () => { expect(formatDuration(12_340)).toBe("12.34s"); diff --git a/apps/code/src/renderer/features/sessions/components/GeneratingIndicator.tsx b/packages/ui/src/features/sessions/components/GeneratingIndicator.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/GeneratingIndicator.tsx rename to packages/ui/src/features/sessions/components/GeneratingIndicator.tsx diff --git a/apps/code/src/renderer/features/sessions/components/GitActionMessage.tsx b/packages/ui/src/features/sessions/components/GitActionMessage.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/GitActionMessage.tsx rename to packages/ui/src/features/sessions/components/GitActionMessage.tsx diff --git a/apps/code/src/renderer/features/sessions/components/GitActionResult.tsx b/packages/ui/src/features/sessions/components/GitActionResult.tsx similarity index 87% rename from apps/code/src/renderer/features/sessions/components/GitActionResult.tsx rename to packages/ui/src/features/sessions/components/GitActionResult.tsx index 44b699d0bc..a29a976117 100644 --- a/apps/code/src/renderer/features/sessions/components/GitActionResult.tsx +++ b/packages/ui/src/features/sessions/components/GitActionResult.tsx @@ -4,10 +4,11 @@ import { GitCommit, GitPullRequest, } from "@phosphor-icons/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { GitActionType } from "@posthog/ui/features/sessions/components/GitActionMessage"; +import { openExternalUrl } from "@posthog/ui/workbench/openExternal"; import { Badge, Box, Button, Flex, Text } from "@radix-ui/themes"; -import { trpcClient, useTRPC } from "@renderer/trpc"; import { useQuery } from "@tanstack/react-query"; -import type { GitActionType } from "./GitActionMessage"; interface GitActionResultProps { actionType: GitActionType; @@ -20,24 +21,30 @@ export function GitActionResult({ repoPath, turnId: _turnId, }: GitActionResultProps) { - const trpc = useTRPC(); + const trpc = useHostTRPC(); const { data: commitInfo } = useQuery( trpc.git.getLatestCommit.queryOptions( { directoryPath: repoPath }, - { enabled: !!repoPath, staleTime: 0 }, + { + enabled: !!repoPath, + staleTime: 0, + }, ), ); const { data: repoInfo } = useQuery( trpc.git.getGitRepoInfo.queryOptions( { directoryPath: repoPath }, - { enabled: !!repoPath, staleTime: 30000 }, + { + enabled: !!repoPath, + staleTime: 30000, + }, ), ); const handleOpenUrl = (url: string) => { - trpcClient.os.openExternal.mutate({ url }); + openExternalUrl(url); }; const showCommit = commitInfo != null; diff --git a/apps/code/src/renderer/features/sessions/components/HandoffConfirmDialog.tsx b/packages/ui/src/features/sessions/components/HandoffConfirmDialog.tsx similarity index 93% rename from apps/code/src/renderer/features/sessions/components/HandoffConfirmDialog.tsx rename to packages/ui/src/features/sessions/components/HandoffConfirmDialog.tsx index 495277c166..89cfa5661d 100644 --- a/apps/code/src/renderer/features/sessions/components/HandoffConfirmDialog.tsx +++ b/packages/ui/src/features/sessions/components/HandoffConfirmDialog.tsx @@ -1,5 +1,5 @@ -import { GitDialog } from "@features/git-interaction/components/GitInteractionDialogs"; import { ArrowLineDown, Cloud } from "@phosphor-icons/react"; +import { GitDialog } from "@posthog/ui/features/git-interaction/components/GitInteractionDialogs"; import { Code, Text } from "@radix-ui/themes"; interface HandoffConfirmDialogProps { diff --git a/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx b/packages/ui/src/features/sessions/components/ModelSelector.tsx similarity index 89% rename from apps/code/src/renderer/features/sessions/components/ModelSelector.tsx rename to packages/ui/src/features/sessions/components/ModelSelector.tsx index 9ac21ffec1..dbfe97ce14 100644 --- a/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/ModelSelector.tsx @@ -1,5 +1,8 @@ import type { SessionConfigSelectGroup } from "@agentclientprotocol/sdk"; import { CaretDown } from "@phosphor-icons/react"; +import type { SessionService } from "@posthog/core/sessions/sessionService"; +import { SESSION_SERVICE } from "@posthog/core/sessions/sessionService"; +import { useService } from "@posthog/di/react"; import { Button, DropdownMenu, @@ -10,13 +13,12 @@ import { DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; -import { Fragment, useMemo } from "react"; -import { getSessionService } from "../service/service"; import { flattenSelectOptions, useModelConfigOptionForTask, useSessionForTask, -} from "../stores/sessionStore"; +} from "@posthog/ui/features/sessions/sessionStore"; +import { Fragment, useMemo } from "react"; interface ModelSelectorProps { taskId?: string; @@ -30,6 +32,7 @@ export function ModelSelector({ disabled, onModelChange, }: ModelSelectorProps) { + const sessionService = useService<SessionService>(SESSION_SERVICE); const session = useSessionForTask(taskId); const modelOption = useModelConfigOptionForTask(taskId); @@ -52,7 +55,7 @@ export function ModelSelector({ if (!taskId || !session) return; if (session.status !== "connected" && !session.isCloud) return; - getSessionService().setSessionConfigOption(taskId, selectOption.id, value); + sessionService.setSessionConfigOption(taskId, selectOption.id, value); }; const currentValue = selectOption.currentValue; diff --git a/apps/code/src/renderer/features/sessions/components/PendingChatView.tsx b/packages/ui/src/features/sessions/components/PendingChatView.tsx similarity index 74% rename from apps/code/src/renderer/features/sessions/components/PendingChatView.tsx rename to packages/ui/src/features/sessions/components/PendingChatView.tsx index e657e41f3a..01409dabe0 100644 --- a/apps/code/src/renderer/features/sessions/components/PendingChatView.tsx +++ b/packages/ui/src/features/sessions/components/PendingChatView.tsx @@ -1,9 +1,9 @@ -import type { UserMessageAttachment } from "@features/sessions/components/session-update/UserMessage"; -import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants"; import { Brain } from "@phosphor-icons/react"; +import { PendingInputPlaceholder } from "@posthog/ui/features/sessions/components/PendingInputPlaceholder"; +import { UserMessage } from "@posthog/ui/features/sessions/components/session-update/UserMessage"; +import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; +import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes"; import { Box, Flex, Text } from "@radix-ui/themes"; -import { PendingInputPlaceholder } from "./PendingInputPlaceholder"; -import { UserMessage } from "./session-update/UserMessage"; interface PendingChatViewProps { promptText: string; diff --git a/apps/code/src/renderer/features/sessions/components/PendingInputPlaceholder.tsx b/packages/ui/src/features/sessions/components/PendingInputPlaceholder.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/PendingInputPlaceholder.tsx rename to packages/ui/src/features/sessions/components/PendingInputPlaceholder.tsx diff --git a/apps/code/src/renderer/features/sessions/components/PlanStatusBar.stories.tsx b/packages/ui/src/features/sessions/components/PlanStatusBar.stories.tsx similarity index 95% rename from apps/code/src/renderer/features/sessions/components/PlanStatusBar.stories.tsx rename to packages/ui/src/features/sessions/components/PlanStatusBar.stories.tsx index 64ea47c600..a1530a2087 100644 --- a/apps/code/src/renderer/features/sessions/components/PlanStatusBar.stories.tsx +++ b/packages/ui/src/features/sessions/components/PlanStatusBar.stories.tsx @@ -1,6 +1,6 @@ -import type { Plan } from "@features/sessions/types"; +import { PlanStatusBar } from "@posthog/ui/features/sessions/components/PlanStatusBar"; +import type { Plan } from "@posthog/ui/features/sessions/types"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PlanStatusBar } from "./PlanStatusBar"; const meta: Meta<typeof PlanStatusBar> = { title: "Sessions/PlanStatusBar", diff --git a/apps/code/src/renderer/features/sessions/components/PlanStatusBar.tsx b/packages/ui/src/features/sessions/components/PlanStatusBar.tsx similarity index 90% rename from apps/code/src/renderer/features/sessions/components/PlanStatusBar.tsx rename to packages/ui/src/features/sessions/components/PlanStatusBar.tsx index 855953d281..e53c2bb65c 100644 --- a/apps/code/src/renderer/features/sessions/components/PlanStatusBar.tsx +++ b/packages/ui/src/features/sessions/components/PlanStatusBar.tsx @@ -1,7 +1,11 @@ -import { StepIcon, StepList, type StepStatus } from "@components/ui/StepList"; -import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants"; -import type { Plan } from "@features/sessions/types"; import { CaretDown, CaretRight } from "@phosphor-icons/react"; +import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; +import type { Plan } from "@posthog/ui/features/sessions/types"; +import { + StepIcon, + StepList, + type StepStatus, +} from "@posthog/ui/primitives/StepList"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useMemo, useState } from "react"; diff --git a/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx b/packages/ui/src/features/sessions/components/ReasoningLevelSelector.tsx similarity index 97% rename from apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx rename to packages/ui/src/features/sessions/components/ReasoningLevelSelector.tsx index c60408d8ee..a3250af886 100644 --- a/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx +++ b/packages/ui/src/features/sessions/components/ReasoningLevelSelector.tsx @@ -10,7 +10,7 @@ import { MenuLabel, } from "@posthog/quill"; import { useRef, useState } from "react"; -import { flattenSelectOptions } from "../stores/sessionStore"; +import { flattenSelectOptions } from "../sessionStore"; interface ReasoningLevelSelectorProps { thoughtOption?: SessionConfigOption; diff --git a/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx b/packages/ui/src/features/sessions/components/SessionFooter.tsx similarity index 88% rename from apps/code/src/renderer/features/sessions/components/SessionFooter.tsx rename to packages/ui/src/features/sessions/components/SessionFooter.tsx index 6b988222b4..f5c0247d72 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx +++ b/packages/ui/src/features/sessions/components/SessionFooter.tsx @@ -1,11 +1,13 @@ -import type { ContextUsage } from "@features/sessions/hooks/useContextUsage"; import { Brain, Pause } from "@phosphor-icons/react"; +import type { Task } from "@posthog/shared/domain-types"; +import { ContextUsageIndicator } from "@posthog/ui/features/sessions/components/ContextUsageIndicator"; +import { + formatDuration, + GeneratingIndicator, +} from "@posthog/ui/features/sessions/components/GeneratingIndicator"; +import type { ContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; import { Box, Flex, Text } from "@radix-ui/themes"; -import type { Task } from "@shared/types"; - -import { ContextUsageIndicator } from "./ContextUsageIndicator"; import { DiffStatsChip } from "./DiffStatsChip"; -import { formatDuration, GeneratingIndicator } from "./GeneratingIndicator"; interface SessionFooterProps { task?: Task; diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx similarity index 73% rename from apps/code/src/renderer/features/sessions/components/SessionView.tsx rename to packages/ui/src/features/sessions/components/SessionView.tsx index 49b9cdfa95..57eba2f25b 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -1,53 +1,49 @@ -import { isOtherOption } from "@components/action-selector/constants"; -import { PermissionSelector } from "@components/permissions/PermissionSelector"; -import { showOfflineToast } from "@features/connectivity/connectivityToast"; +import { Pause, Spinner, Warning } from "@phosphor-icons/react"; +import type { SessionService } from "@posthog/core/sessions/sessionService"; +import { SESSION_SERVICE } from "@posthog/core/sessions/sessionService"; +import { useService } from "@posthog/di/react"; +import type { AcpMessage } from "@posthog/shared"; +import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; +import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast"; import { PromptInput, type EditorHandle as PromptInputHandle, -} from "@features/message-editor/components/PromptInput"; -import { useDraftStore } from "@features/message-editor/stores/draftStore"; -import { resolveAndAttachDroppedFiles } from "@features/message-editor/utils/persistFile"; -import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants"; -import { useSessionForTask } from "@features/sessions/hooks/useSession"; +} from "@posthog/ui/features/message-editor/components/PromptInput"; +import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; +import { useAutoFocusOnTyping } from "@posthog/ui/features/message-editor/useAutoFocusOnTyping"; +import { resolveAndAttachDroppedFiles } from "@posthog/ui/features/message-editor/utils/persistFile"; +import { PermissionSelector } from "@posthog/ui/features/permissions/PermissionSelector"; +import { CloudInitializingView } from "@posthog/ui/features/sessions/components/CloudInitializingView"; +import { ConversationView } from "@posthog/ui/features/sessions/components/ConversationView"; +import { DropZoneOverlay } from "@posthog/ui/features/sessions/components/DropZoneOverlay"; +import { ModelSelector } from "@posthog/ui/features/sessions/components/ModelSelector"; +import { PendingChatView } from "@posthog/ui/features/sessions/components/PendingChatView"; +import { PlanStatusBar } from "@posthog/ui/features/sessions/components/PlanStatusBar"; +import { ReasoningLevelSelector } from "@posthog/ui/features/sessions/components/ReasoningLevelSelector"; +import { RawLogsView } from "@posthog/ui/features/sessions/components/raw-logs/RawLogsView"; +import { CHAT_CONTENT_MAX_WIDTH } from "@posthog/ui/features/sessions/constants"; import { useAdapterForTask, useModeConfigOptionForTask, usePendingPermissionsForTask, useThoughtLevelConfigOptionForTask, -} from "@features/sessions/stores/sessionStore"; -import type { Plan } from "@features/sessions/types"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; -import { useAutoFocusOnTyping } from "@hooks/useAutoFocusOnTyping"; -import { useConnectivity } from "@hooks/useConnectivity"; -import { Pause, Spinner, Warning } from "@phosphor-icons/react"; -import { Box, Button, ContextMenu, Flex, Text } from "@radix-ui/themes"; -import { toast } from "@renderer/utils/toast"; -import type { Task, TaskRunStatus } from "@shared/types"; +} from "@posthog/ui/features/sessions/sessionStore"; import { - type AcpMessage, - isJsonRpcNotification, - isJsonRpcResponse, -} from "@shared/types/session-events"; + useSessionViewActions, + useShowRawLogs, +} from "@posthog/ui/features/sessions/sessionViewStore"; +import type { Plan } from "@posthog/ui/features/sessions/types"; +import { useSessionForTask } from "@posthog/ui/features/sessions/useSession"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { useIsWorkspaceCloudRun } from "@posthog/ui/features/workspace/useWorkspace"; +import { useConnectivity } from "@posthog/ui/hooks/useConnectivity"; +import { toast } from "@posthog/ui/primitives/toast"; import { pendingTaskPromptStoreApi, usePendingTaskPrompt, -} from "@stores/pendingTaskPromptStore"; +} from "@posthog/ui/workbench/pendingTaskPromptStore"; +import { Box, Button, ContextMenu, Flex, Text } from "@radix-ui/themes"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { getSessionService } from "../service/service"; -import { flattenSelectOptions } from "../stores/sessionStore"; -import { - useSessionViewActions, - useShowRawLogs, -} from "../stores/sessionViewStore"; -import { CloudInitializingView } from "./CloudInitializingView"; -import { ConversationView } from "./ConversationView"; -import { DropZoneOverlay } from "./DropZoneOverlay"; -import { ModelSelector } from "./ModelSelector"; -import { PendingChatView } from "./PendingChatView"; -import { PlanStatusBar } from "./PlanStatusBar"; -import { ReasoningLevelSelector } from "./ReasoningLevelSelector"; -import { RawLogsView } from "./raw-logs/RawLogsView"; interface SessionViewProps { events: AcpMessage[]; @@ -83,25 +79,6 @@ interface SessionViewProps { const DEFAULT_ERROR_MESSAGE = "Failed to resume this session. The working directory may have been deleted. Please start a new session."; -/** - * When an allow_always permission is granted outside a mode-switch prompt, - * ratchet the session to the closest "auto-accept edits" preset offered by - * this adapter's mode catalog. Claude exposes `acceptEdits`; Codex has no - * exact equivalent, so fall back to `auto`. Returns undefined if neither is - * available (in which case leave the current mode untouched). - */ -function resolveAllowAlwaysUpgradeMode( - modeOption: ReturnType<typeof useModeConfigOptionForTask>, -): string | undefined { - if (modeOption?.type !== "select") return undefined; - const availableIds = new Set( - flattenSelectOptions(modeOption.options).map((opt) => opt.value), - ); - if (availableIds.has("acceptEdits")) return "acceptEdits"; - if (availableIds.has("auto")) return "auto"; - return undefined; -} - export function SessionView({ events, taskId, @@ -131,6 +108,7 @@ export function SessionView({ isActiveSession = true, hideInput = false, }: SessionViewProps) { + const sessionService = useService<SessionService>(SESSION_SERVICE); const showRawLogs = useShowRawLogs(); const { setShowRawLogs } = useSessionViewActions(); const pendingTaskPrompt = usePendingTaskPrompt(taskId); @@ -151,45 +129,27 @@ export function SessionView({ }, [taskId, isInitializing]); useEffect(() => { - if (allowBypassPermissions) return; - // Cloud runs execute in an isolated sandbox where bypass is safe, and the - // agent's own gate (ALLOW_BYPASS = !IS_ROOT || IS_SANDBOX) already permits - // it regardless of this local preference. Auto-reverting here would clobber - // the user's explicit plan-approval choice and strand them in Plan Mode. - if (isCloud) return; - const isBypass = - currentModeId === "bypassPermissions" || currentModeId === "full-access"; - if (isBypass && taskId) { - getSessionService().setSessionConfigOptionByCategory( - taskId, - "mode", - "default", - ); - } - }, [allowBypassPermissions, currentModeId, taskId, isCloud]); + sessionService.maybeRevertBypassMode(taskId, { + isCloud, + allowBypassPermissions, + currentModeId, + }); + }, [allowBypassPermissions, currentModeId, taskId, isCloud, sessionService]); const handleModeChange = useCallback( (nextMode: string) => { if (!taskId) return; - getSessionService().setSessionConfigOptionByCategory( - taskId, - "mode", - nextMode, - ); + sessionService.setSessionConfigOptionByCategory(taskId, "mode", nextMode); }, - [taskId], + [taskId, sessionService], ); const handleThoughtChange = useCallback( (value: string) => { if (!taskId || !thoughtOption) return; - getSessionService().setSessionConfigOption( - taskId, - thoughtOption.id, - value, - ); + sessionService.setSessionConfigOption(taskId, thoughtOption.id, value); }, - [taskId, thoughtOption], + [taskId, thoughtOption, sessionService], ); const sessionId = taskId ?? "default"; @@ -216,42 +176,10 @@ export function SessionView({ const isCloudRun = useIsWorkspaceCloudRun(taskId); - const latestPlan = useMemo((): Plan | null => { - let planIndex = -1; - let plan: Plan | null = null; - let turnEndResponseIndex = -1; - - for (let i = events.length - 1; i >= 0; i--) { - const msg = events[i].message; - - if ( - turnEndResponseIndex === -1 && - isJsonRpcResponse(msg) && - (msg.result as { stopReason?: string })?.stopReason !== undefined - ) { - turnEndResponseIndex = i; - } - - if ( - planIndex === -1 && - isJsonRpcNotification(msg) && - msg.method === "session/update" - ) { - const update = (msg.params as { update?: { sessionUpdate?: string } }) - ?.update; - if (update?.sessionUpdate === "plan") { - planIndex = i; - plan = update as Plan; - } - } - - if (planIndex !== -1 && turnEndResponseIndex !== -1) break; - } - - if (turnEndResponseIndex > planIndex) return null; - - return plan; - }, [events]); + const latestPlan = useMemo( + (): Plan | null => sessionService.selectLatestPlan(events) as Plan | null, + [events, sessionService], + ); const handleSubmit = useCallback( (text: string) => { @@ -292,56 +220,17 @@ export function SessionView({ ) => { if (!firstPendingPermission || !taskId) return; - const selectedOption = firstPendingPermission.options.find( - (o) => o.optionId === optionId, + const plan = await sessionService.resolvePermissionSelection( + taskId, + firstPendingPermission, + optionId, + modeOption, + customInput, + answers, ); - const isModeSwitch = - firstPendingPermission.toolCall?.kind === "switch_mode"; - if (selectedOption?.kind === "allow_always" && !isModeSwitch) { - // Pick the adapter-appropriate "upgrade" mode. Claude exposes - // acceptEdits; Codex does not — its closest analogue is auto. Resolve - // against the session's advertised mode catalog so the footer label - // stays coherent with the dropdown contents. - const upgradeMode = resolveAllowAlwaysUpgradeMode(modeOption); - if (upgradeMode) { - getSessionService().setSessionConfigOptionByCategory( - taskId, - "mode", - upgradeMode, - ); - } - } - if (customInput) { - if ( - isOtherOption(optionId) || - selectedOption?._meta?.customInput === true - ) { - await getSessionService().respondToPermission( - taskId, - firstPendingPermission.toolCallId, - optionId, - customInput, - answers, - ); - } else { - await getSessionService().respondToPermission( - taskId, - firstPendingPermission.toolCallId, - optionId, - undefined, - answers, - ); - onSendPrompt(customInput); - } - } else { - await getSessionService().respondToPermission( - taskId, - firstPendingPermission.toolCallId, - optionId, - undefined, - answers, - ); + if (plan.resendPromptText) { + onSendPrompt(plan.resendPromptText); } requestFocus(sessionId); @@ -353,18 +242,18 @@ export function SessionView({ requestFocus, sessionId, modeOption, + sessionService, ], ); const handlePermissionCancel = useCallback(async () => { if (!firstPendingPermission || !taskId) return; - await getSessionService().cancelPermission( + await sessionService.cancelPermissionAndPrompt( taskId, firstPendingPermission.toolCallId, ); - await getSessionService().cancelPrompt(taskId); requestFocus(sessionId); - }, [firstPendingPermission, taskId, requestFocus, sessionId]); + }, [firstPendingPermission, taskId, requestFocus, sessionId, sessionService]); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); diff --git a/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx similarity index 96% rename from apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx rename to packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx index 12ff6479f5..6c4668a89d 100644 --- a/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/UnifiedModelSelector.tsx @@ -2,7 +2,6 @@ import type { SessionConfigOption, SessionConfigSelectGroup, } from "@agentclientprotocol/sdk"; -import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { ArrowsClockwise, CaretDown, @@ -21,8 +20,9 @@ import { DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; +import { flattenSelectOptions } from "@posthog/ui/features/sessions/sessionStore"; +import type { AgentAdapter } from "@posthog/ui/features/settings/settingsStore"; import { Fragment, useMemo, useRef, useState } from "react"; -import { flattenSelectOptions } from "../stores/sessionStore"; const ADAPTER_ICONS: Record<AgentAdapter, React.ReactNode> = { claude: <Robot size={14} weight="regular" />, diff --git a/apps/code/src/renderer/features/sessions/components/VirtualizedList.tsx b/packages/ui/src/features/sessions/components/VirtualizedList.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/VirtualizedList.tsx rename to packages/ui/src/features/sessions/components/VirtualizedList.tsx diff --git a/apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts similarity index 98% rename from apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts rename to packages/ui/src/features/sessions/components/buildConversationItems.test.ts index 0bdc3d3d88..a8d12dde54 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts @@ -1,5 +1,5 @@ -import type { AcpMessage } from "@shared/types/session-events"; -import { makeAttachmentUri } from "@utils/promptContent"; +import { makeAttachmentUri } from "@posthog/core/sessions/promptContent"; +import type { AcpMessage } from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { buildConversationItems, diff --git a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts b/packages/ui/src/features/sessions/components/buildConversationItems.ts similarity index 95% rename from apps/code/src/renderer/features/sessions/components/buildConversationItems.ts rename to packages/ui/src/features/sessions/components/buildConversationItems.ts index fbd0d1ee4b..51798c6906 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -2,26 +2,35 @@ import type { ContentBlock, SessionNotification, } from "@agentclientprotocol/sdk"; -import type { Step, StepStatus } from "@components/ui/StepList"; -import type { QueuedMessage } from "@features/sessions/stores/sessionStore"; -import type { SessionUpdate, ToolCall } from "@features/sessions/types"; import { - extractSkillButtonId, - type SkillButtonId, -} from "@features/skill-buttons/prompts"; -import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent"; + isNotification, + POSTHOG_NOTIFICATIONS, +} from "@posthog/agent/acp-extensions"; +import { extractPromptDisplayContent } from "@posthog/core/sessions/promptContent"; import { type AcpMessage, isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type UserShellExecuteParams, -} from "@shared/types/session-events"; -import { extractPromptDisplayContent } from "@utils/promptContent"; -import { type GitActionType, parseGitActionMessage } from "./GitActionMessage"; +} from "@posthog/shared"; +import { + type GitActionType, + parseGitActionMessage, +} from "@posthog/ui/features/sessions/components/GitActionMessage"; +import type { UserShellExecute } from "@posthog/ui/features/sessions/components/session-update/UserShellExecuteView"; +import type { QueuedMessage } from "@posthog/ui/features/sessions/sessionStore"; +import type { + SessionUpdate, + ToolCall, +} from "@posthog/ui/features/sessions/types"; +import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes"; +import { + extractSkillButtonId, + type SkillButtonId, +} from "@posthog/ui/features/skill-buttons/prompts"; +import type { Step, StepStatus } from "@posthog/ui/primitives/StepList"; import type { RenderItem } from "./session-update/SessionUpdateView"; -import type { UserMessageAttachment } from "./session-update/UserMessage"; -import type { UserShellExecute } from "./session-update/UserShellExecuteView"; export interface TurnContext { toolCalls: Map<string, ToolCall>; diff --git a/apps/code/src/renderer/features/sessions/components/mergeConversationItems.test.ts b/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts similarity index 98% rename from apps/code/src/renderer/features/sessions/components/mergeConversationItems.test.ts rename to packages/ui/src/features/sessions/components/mergeConversationItems.test.ts index fe8f5ebf82..c3445af42e 100644 --- a/apps/code/src/renderer/features/sessions/components/mergeConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/mergeConversationItems.test.ts @@ -1,4 +1,4 @@ -import type { QueuedMessage } from "@features/sessions/stores/sessionStore"; +import type { QueuedMessage } from "@posthog/ui/features/sessions/sessionStore"; import { describe, expect, it } from "vitest"; import type { ConversationItem } from "./buildConversationItems"; import { mergeConversationItems } from "./mergeConversationItems"; diff --git a/apps/code/src/renderer/features/sessions/components/mergeConversationItems.ts b/packages/ui/src/features/sessions/components/mergeConversationItems.ts similarity index 100% rename from apps/code/src/renderer/features/sessions/components/mergeConversationItems.ts rename to packages/ui/src/features/sessions/components/mergeConversationItems.ts diff --git a/apps/code/src/renderer/features/sessions/components/raw-logs/RawLogEntry.tsx b/packages/ui/src/features/sessions/components/raw-logs/RawLogEntry.tsx similarity index 93% rename from apps/code/src/renderer/features/sessions/components/raw-logs/RawLogEntry.tsx rename to packages/ui/src/features/sessions/components/raw-logs/RawLogEntry.tsx index cc51f8ed10..5d5a2952ab 100644 --- a/apps/code/src/renderer/features/sessions/components/raw-logs/RawLogEntry.tsx +++ b/packages/ui/src/features/sessions/components/raw-logs/RawLogEntry.tsx @@ -1,8 +1,7 @@ import { Copy } from "@phosphor-icons/react"; +import type { AcpMessage } from "@posthog/shared"; import { Box, Code, Flex, IconButton, Text } from "@radix-ui/themes"; -import type { AcpMessage } from "@shared/types/session-events"; - interface RawLogEntryProps { event: AcpMessage; index: number; diff --git a/apps/code/src/renderer/features/sessions/components/raw-logs/RawLogsHeader.tsx b/packages/ui/src/features/sessions/components/raw-logs/RawLogsHeader.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/raw-logs/RawLogsHeader.tsx rename to packages/ui/src/features/sessions/components/raw-logs/RawLogsHeader.tsx diff --git a/apps/code/src/renderer/features/sessions/components/raw-logs/RawLogsView.tsx b/packages/ui/src/features/sessions/components/raw-logs/RawLogsView.tsx similarity index 84% rename from apps/code/src/renderer/features/sessions/components/raw-logs/RawLogsView.tsx rename to packages/ui/src/features/sessions/components/raw-logs/RawLogsView.tsx index 77325cee6e..03edab6075 100644 --- a/apps/code/src/renderer/features/sessions/components/raw-logs/RawLogsView.tsx +++ b/packages/ui/src/features/sessions/components/raw-logs/RawLogsView.tsx @@ -1,15 +1,15 @@ -import { Divider } from "@components/Divider"; -import { Box, Flex } from "@radix-ui/themes"; -import type { AcpMessage } from "@shared/types/session-events"; -import { useCallback, useMemo, useRef } from "react"; +import type { AcpMessage } from "@posthog/shared"; +import { RawLogEntry } from "@posthog/ui/features/sessions/components/raw-logs/RawLogEntry"; +import { RawLogsHeader } from "@posthog/ui/features/sessions/components/raw-logs/RawLogsHeader"; +import { VirtualizedList } from "@posthog/ui/features/sessions/components/VirtualizedList"; import { useSearchQuery, useSessionViewActions, useShowSearch, -} from "../../stores/sessionViewStore"; -import { VirtualizedList } from "../VirtualizedList"; -import { RawLogEntry } from "./RawLogEntry"; -import { RawLogsHeader } from "./RawLogsHeader"; +} from "@posthog/ui/features/sessions/sessionViewStore"; +import { Divider } from "@posthog/ui/primitives/Divider"; +import { Box, Flex } from "@radix-ui/themes"; +import { useCallback, useMemo, useRef } from "react"; interface RawLogsViewProps { events: AcpMessage[]; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx b/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx similarity index 89% rename from apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx rename to packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx index 0cf7d00083..bdb85763dd 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx +++ b/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx @@ -1,16 +1,16 @@ -import { HighlightedCode } from "@components/HighlightedCode"; -import { Tooltip } from "@components/ui/Tooltip"; -import { usePendingScrollStore } from "@features/code-editor/stores/pendingScrollStore"; -import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; -import { usePanelLayoutStore } from "@features/panels"; -import { useSessionTaskId } from "@features/sessions/hooks/useSessionTaskId"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import type { FileItem } from "@hooks/useRepoFiles"; -import { useRepoFiles } from "@hooks/useRepoFiles"; import { Check, Copy } from "@phosphor-icons/react"; import { Box, Code, IconButton } from "@radix-ui/themes"; import { memo, useCallback, useMemo, useState } from "react"; import type { Components } from "react-markdown"; +import { HighlightedCode } from "../../../../primitives/HighlightedCode"; +import { Tooltip } from "../../../../primitives/Tooltip"; +import { usePendingScrollStore } from "../../../code-editor/pendingScrollStore"; +import { MarkdownRenderer } from "../../../editor/components/MarkdownRenderer"; +import { usePanelLayoutStore } from "../../../panels/panelLayoutStore"; +import type { FileItem } from "../../../repo-files/useRepoFiles"; +import { useRepoFiles } from "../../../repo-files/useRepoFiles"; +import { useCwd } from "../../../sidebar/useCwd"; +import { useSessionTaskId } from "../../useSessionTaskId"; const FILE_WITH_DIR_RE = /^(?:\/|\.\.?\/|[a-zA-Z]:\\)?(?:[\w.@-]+\/)+[\w.@-]+\.\w+(?::\d+(?:-\d+)?)?$/; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx b/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx similarity index 95% rename from apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx rename to packages/ui/src/features/sessions/components/session-update/CodePreview.tsx index eebf2b948c..b6e4e6a3c8 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/CodePreview.tsx +++ b/packages/ui/src/features/sessions/components/session-update/CodePreview.tsx @@ -1,11 +1,10 @@ import { EditorView } from "@codemirror/view"; -import { SafeImagePreview } from "@components/ui/SafeImagePreview"; import { MultiFileDiff } from "@pierre/diffs/react"; -import { parseImageDataUrl } from "@posthog/shared"; +import { compactHomePath, parseImageDataUrl } from "@posthog/shared"; import { Code } from "@radix-ui/themes"; -import { useThemeStore } from "@stores/themeStore"; -import { compactHomePath } from "@utils/path"; import { useEffect, useMemo, useRef } from "react"; +import { SafeImagePreview } from "../../../../primitives/SafeImagePreview"; +import { useThemeStore } from "../../../../workbench/themeStore"; import { CODE_PREVIEW_CONTAINER_STYLE, CODE_PREVIEW_EDITOR_STYLE, diff --git a/apps/code/src/renderer/features/sessions/components/session-update/CompactBoundaryView.tsx b/packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/CompactBoundaryView.tsx rename to packages/ui/src/features/sessions/components/session-update/CompactBoundaryView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ConsoleMessage.tsx b/packages/ui/src/features/sessions/components/session-update/ConsoleMessage.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/ConsoleMessage.tsx rename to packages/ui/src/features/sessions/components/session-update/ConsoleMessage.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/DeleteToolView.tsx b/packages/ui/src/features/sessions/components/session-update/DeleteToolView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/DeleteToolView.tsx rename to packages/ui/src/features/sessions/components/session-update/DeleteToolView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/EditToolView.tsx b/packages/ui/src/features/sessions/components/session-update/EditToolView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/EditToolView.tsx rename to packages/ui/src/features/sessions/components/session-update/EditToolView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ErrorNotificationView.tsx b/packages/ui/src/features/sessions/components/session-update/ErrorNotificationView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/ErrorNotificationView.tsx rename to packages/ui/src/features/sessions/components/session-update/ErrorNotificationView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ExecuteToolView.tsx b/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx similarity index 97% rename from apps/code/src/renderer/features/sessions/components/session-update/ExecuteToolView.tsx rename to packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx index f2e6d6d0b6..93451de5ab 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/ExecuteToolView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ExecuteToolView.tsx @@ -1,6 +1,6 @@ import { Terminal } from "@phosphor-icons/react"; +import { compactHomePath } from "@posthog/shared"; import { Box, Flex } from "@radix-ui/themes"; -import { compactHomePath } from "@utils/path"; import { useState } from "react"; import { ExpandableIcon, diff --git a/apps/code/src/renderer/features/sessions/components/session-update/FetchToolView.tsx b/packages/ui/src/features/sessions/components/session-update/FetchToolView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/FetchToolView.tsx rename to packages/ui/src/features/sessions/components/session-update/FetchToolView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/FileMentionChip.tsx b/packages/ui/src/features/sessions/components/session-update/FileMentionChip.tsx similarity index 73% rename from apps/code/src/renderer/features/sessions/components/session-update/FileMentionChip.tsx rename to packages/ui/src/features/sessions/components/session-update/FileMentionChip.tsx index 8a6d02a197..98fcb927a0 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/FileMentionChip.tsx +++ b/packages/ui/src/features/sessions/components/session-update/FileMentionChip.tsx @@ -1,13 +1,12 @@ -import { FileIcon } from "@components/ui/FileIcon"; -import { usePanelLayoutStore } from "@features/panels"; -import { useSessionTaskId } from "@features/sessions/hooks/useSessionTaskId"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; +import { isAbsolutePath } from "@posthog/shared"; import { Flex, Text } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import { handleExternalAppAction } from "@utils/handleExternalAppAction"; -import { isAbsolutePath } from "@utils/path"; import { memo, useCallback } from "react"; +import { FileIcon } from "../../../../primitives/FileIcon"; +import { usePanelLayoutStore } from "../../../panels/panelLayoutStore"; +import { useCwd } from "../../../sidebar/useCwd"; +import { useWorkspace } from "../../../workspace/useWorkspace"; +import { useSessionTaskId } from "../../useSessionTaskId"; +import { useFileContextMenu } from "../useFileContextMenu"; import { getFilename } from "./toolCallUtils"; interface FileMentionChipProps { @@ -36,6 +35,7 @@ export const FileMentionChip = memo(function FileMentionChip({ const repoPath = useCwd(taskId ?? ""); const workspace = useWorkspace(taskId ?? undefined); const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit); + const { openForFile } = useFileContextMenu(); const filename = getFilename(filePath); const mainRepoPath = workspace?.folderPath; @@ -55,23 +55,14 @@ export const FileMentionChip = memo(function FileMentionChip({ ? `${repoPath}/${filePath}` : filePath; - const result = await trpcClient.contextMenu.showFileContextMenu.mutate({ - filePath: absolutePath, - showCollapseAll: false, + await openForFile({ + absolutePath, + filename, + workspace, + mainRepoPath, }); - - if (!result.action) return; - - if (result.action.type === "external-app") { - await handleExternalAppAction( - result.action.action, - absolutePath, - filename, - { workspace, mainRepoPath }, - ); - } }, - [filePath, repoPath, filename, workspace, mainRepoPath], + [filePath, repoPath, filename, workspace, mainRepoPath, openForFile], ); const isClickable = !!taskId; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/MoveToolView.tsx b/packages/ui/src/features/sessions/components/session-update/MoveToolView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/MoveToolView.tsx rename to packages/ui/src/features/sessions/components/session-update/MoveToolView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/PlanApprovalView.test.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx similarity index 97% rename from apps/code/src/renderer/features/sessions/components/session-update/PlanApprovalView.test.tsx rename to packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx index 1e96c34038..f9b86585bb 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/PlanApprovalView.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx @@ -1,4 +1,4 @@ -import type { ToolCall } from "@features/sessions/types"; +import type { ToolCall } from "@posthog/ui/features/sessions/types"; import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/PlanApprovalView.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx similarity index 97% rename from apps/code/src/renderer/features/sessions/components/session-update/PlanApprovalView.tsx rename to packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx index 3846bed640..4334ee9818 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/PlanApprovalView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx @@ -1,7 +1,7 @@ -import { PlanContent } from "@components/permissions/PlanContent"; import { CaretDown, CaretRight, CheckCircle } from "@phosphor-icons/react"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useMemo, useState } from "react"; +import { PlanContent } from "../../../permissions/PlanContent"; import { type ToolViewProps, useToolCallStatus } from "./toolCallUtils"; export function PlanApprovalView({ diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ProgressGroupView.tsx b/packages/ui/src/features/sessions/components/session-update/ProgressGroupView.tsx similarity index 97% rename from apps/code/src/renderer/features/sessions/components/session-update/ProgressGroupView.tsx rename to packages/ui/src/features/sessions/components/session-update/ProgressGroupView.tsx index a6a16f42ae..cae43b783c 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/ProgressGroupView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ProgressGroupView.tsx @@ -1,5 +1,5 @@ -import { type Step, StepList } from "@components/ui/StepList"; import { CaretDown, CaretRight } from "@phosphor-icons/react"; +import { type Step, StepList } from "@posthog/ui/primitives/StepList"; import * as Collapsible from "@radix-ui/react-collapsible"; import { Box, Text } from "@radix-ui/themes"; import { useEffect, useState } from "react"; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/QuestionToolView.tsx b/packages/ui/src/features/sessions/components/session-update/QuestionToolView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/QuestionToolView.tsx rename to packages/ui/src/features/sessions/components/session-update/QuestionToolView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/QueuedMessageView.tsx b/packages/ui/src/features/sessions/components/session-update/QueuedMessageView.tsx similarity index 89% rename from apps/code/src/renderer/features/sessions/components/session-update/QueuedMessageView.tsx rename to packages/ui/src/features/sessions/components/session-update/QueuedMessageView.tsx index c87879a05c..bb2aaad4e7 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/QueuedMessageView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/QueuedMessageView.tsx @@ -1,7 +1,7 @@ -import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; -import type { QueuedMessage } from "@features/sessions/stores/sessionStore"; import { Clock, X } from "@phosphor-icons/react"; import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; +import { MarkdownRenderer } from "../../../editor/components/MarkdownRenderer"; +import type { QueuedMessage } from "../../sessionStore"; import { hasFileMentions, parseFileMentions } from "./parseFileMentions"; interface QueuedMessageViewProps { diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ReadToolView.tsx b/packages/ui/src/features/sessions/components/session-update/ReadToolView.tsx similarity index 97% rename from apps/code/src/renderer/features/sessions/components/session-update/ReadToolView.tsx rename to packages/ui/src/features/sessions/components/session-update/ReadToolView.tsx index 779943e6b6..86a5b86190 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/ReadToolView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ReadToolView.tsx @@ -1,5 +1,5 @@ -import { SafeImagePreview } from "@components/ui/SafeImagePreview"; import { FileText } from "@phosphor-icons/react"; +import { SafeImagePreview } from "@posthog/ui/primitives/SafeImagePreview"; import { Box, Flex } from "@radix-ui/themes"; import { useState } from "react"; import { CodePreview } from "./CodePreview"; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/SearchToolView.tsx b/packages/ui/src/features/sessions/components/session-update/SearchToolView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/SearchToolView.tsx rename to packages/ui/src/features/sessions/components/session-update/SearchToolView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx similarity index 74% rename from apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx rename to packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx index 90eebd85bf..73c1256049 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx @@ -1,16 +1,18 @@ -import type { Step } from "@components/ui/StepList"; -import type { ConversationItem } from "@features/sessions/components/buildConversationItems"; -import type { SessionUpdate, ToolCall } from "@features/sessions/types"; +import { AgentMessage } from "@posthog/ui/features/sessions/components/session-update/AgentMessage"; +import { CompactBoundaryView } from "@posthog/ui/features/sessions/components/session-update/CompactBoundaryView"; +import { ConsoleMessage } from "@posthog/ui/features/sessions/components/session-update/ConsoleMessage"; +import { ErrorNotificationView } from "@posthog/ui/features/sessions/components/session-update/ErrorNotificationView"; +import { ProgressGroupView } from "@posthog/ui/features/sessions/components/session-update/ProgressGroupView"; +import { StatusNotificationView } from "@posthog/ui/features/sessions/components/session-update/StatusNotificationView"; +import { TaskNotificationView } from "@posthog/ui/features/sessions/components/session-update/TaskNotificationView"; +import { ThoughtView } from "@posthog/ui/features/sessions/components/session-update/ThoughtView"; +import type { + SessionUpdate, + ToolCall, +} from "@posthog/ui/features/sessions/types"; +import type { Step } from "@posthog/ui/primitives/StepList"; import { memo } from "react"; - -import { AgentMessage } from "./AgentMessage"; -import { CompactBoundaryView } from "./CompactBoundaryView"; -import { ConsoleMessage } from "./ConsoleMessage"; -import { ErrorNotificationView } from "./ErrorNotificationView"; -import { ProgressGroupView } from "./ProgressGroupView"; -import { StatusNotificationView } from "./StatusNotificationView"; -import { TaskNotificationView } from "./TaskNotificationView"; -import { ThoughtView } from "./ThoughtView"; +import type { ConversationItem } from "../buildConversationItems"; import { ToolCallBlock } from "./ToolCallBlock"; export type RenderItem = diff --git a/apps/code/src/renderer/features/sessions/components/session-update/StatusNotificationView.tsx b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/StatusNotificationView.tsx rename to packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/SubagentToolView.tsx b/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx similarity index 94% rename from apps/code/src/renderer/features/sessions/components/session-update/SubagentToolView.tsx rename to packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx index 3e2f190296..d854732efd 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/SubagentToolView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx @@ -1,21 +1,18 @@ -import type { - ConversationItem, - TurnContext, -} from "@features/sessions/components/buildConversationItems"; import { ArrowsInSimple as ArrowsInSimpleIcon, ArrowsOutSimple as ArrowsOutSimpleIcon, Robot, } from "@phosphor-icons/react"; -import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; -import { useState } from "react"; -import { SessionUpdateView } from "./SessionUpdateView"; import { LoadingIcon, StatusIndicators, type ToolViewProps, useToolCallStatus, -} from "./toolCallUtils"; +} from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; +import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; +import { useState } from "react"; +import type { ConversationItem, TurnContext } from "../buildConversationItems"; +import { SessionUpdateView } from "./SessionUpdateView"; interface SubagentToolViewProps extends ToolViewProps { childItems: ConversationItem[]; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/TaskNotificationView.tsx b/packages/ui/src/features/sessions/components/session-update/TaskNotificationView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/TaskNotificationView.tsx rename to packages/ui/src/features/sessions/components/session-update/TaskNotificationView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ThinkToolView.tsx b/packages/ui/src/features/sessions/components/session-update/ThinkToolView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/ThinkToolView.tsx rename to packages/ui/src/features/sessions/components/session-update/ThinkToolView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ThoughtView.tsx b/packages/ui/src/features/sessions/components/session-update/ThoughtView.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/ThoughtView.tsx rename to packages/ui/src/features/sessions/components/session-update/ThoughtView.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ToolCallBlock.stories.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.stories.tsx similarity index 98% rename from apps/code/src/renderer/features/sessions/components/session-update/ToolCallBlock.stories.tsx rename to packages/ui/src/features/sessions/components/session-update/ToolCallBlock.stories.tsx index 2e475722c2..481bb7c368 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/ToolCallBlock.stories.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.stories.tsx @@ -1,7 +1,10 @@ -import type { CodeToolKind, ToolCall } from "@features/sessions/types"; import { toolInfoFromToolUse } from "@posthog/agent/adapters/claude/conversion/tool-use-to-acp"; +import { ToolCallBlock } from "@posthog/ui/features/sessions/components/session-update/ToolCallBlock"; +import type { + CodeToolKind, + ToolCall, +} from "@posthog/ui/features/sessions/types"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ToolCallBlock } from "./ToolCallBlock"; function buildToolCallData( toolName: string, diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx similarity index 58% rename from apps/code/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx rename to packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx index 5ebe91129c..877d445f80 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx @@ -1,23 +1,24 @@ -import type { - ConversationItem, - TurnContext, -} from "@features/sessions/components/buildConversationItems"; -import type { ToolCall } from "@features/sessions/types"; +import { useServiceOptional } from "@posthog/di/react"; +import { DeleteToolView } from "@posthog/ui/features/sessions/components/session-update/DeleteToolView"; +import { EditToolView } from "@posthog/ui/features/sessions/components/session-update/EditToolView"; +import { ExecuteToolView } from "@posthog/ui/features/sessions/components/session-update/ExecuteToolView"; +import { FetchToolView } from "@posthog/ui/features/sessions/components/session-update/FetchToolView"; +import { MoveToolView } from "@posthog/ui/features/sessions/components/session-update/MoveToolView"; +import { PlanApprovalView } from "@posthog/ui/features/sessions/components/session-update/PlanApprovalView"; +import { QuestionToolView } from "@posthog/ui/features/sessions/components/session-update/QuestionToolView"; +import { ReadToolView } from "@posthog/ui/features/sessions/components/session-update/ReadToolView"; +import { SearchToolView } from "@posthog/ui/features/sessions/components/session-update/SearchToolView"; +import { ThinkToolView } from "@posthog/ui/features/sessions/components/session-update/ThinkToolView"; +import { ToolCallView } from "@posthog/ui/features/sessions/components/session-update/ToolCallView"; +import type { ToolViewProps } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; +import type { ToolCall } from "@posthog/ui/features/sessions/types"; import { Box } from "@radix-ui/themes"; -import { DeleteToolView } from "./DeleteToolView"; -import { EditToolView } from "./EditToolView"; -import { ExecuteToolView } from "./ExecuteToolView"; -import { FetchToolView } from "./FetchToolView"; -import { McpToolBlock } from "./McpToolBlock"; -import { MoveToolView } from "./MoveToolView"; -import { PlanApprovalView } from "./PlanApprovalView"; -import { QuestionToolView } from "./QuestionToolView"; -import { ReadToolView } from "./ReadToolView"; -import { SearchToolView } from "./SearchToolView"; +import type { ConversationItem, TurnContext } from "../buildConversationItems"; +import { + MCP_TOOL_BLOCK_COMPONENT, + type McpToolBlockComponent, +} from "./identifiers"; import { SubagentToolView } from "./SubagentToolView"; -import { ThinkToolView } from "./ThinkToolView"; -import { ToolCallView } from "./ToolCallView"; -import type { ToolViewProps } from "./toolCallUtils"; interface ToolCallBlockProps extends ToolViewProps { childItems?: ConversationItem[]; @@ -31,6 +32,9 @@ export function ToolCallBlock({ childItems, childItemsMap, }: ToolCallBlockProps) { + const McpToolBlock = useServiceOptional<McpToolBlockComponent>( + MCP_TOOL_BLOCK_COMPONENT, + ); const meta = toolCall._meta as | { claudeCode?: { toolName?: string } } | undefined; @@ -67,7 +71,11 @@ export function ToolCallBlock({ if (toolName?.startsWith("mcp__")) { return ( <Box className="pl-3"> - <McpToolBlock {...props} mcpToolName={toolName} /> + {McpToolBlock ? ( + <McpToolBlock {...props} mcpToolName={toolName} /> + ) : ( + <ToolCallView {...props} agentToolName={toolName} /> + )} </Box> ); } diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ToolCallView.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallView.tsx similarity index 96% rename from apps/code/src/renderer/features/sessions/components/session-update/ToolCallView.tsx rename to packages/ui/src/features/sessions/components/session-update/ToolCallView.tsx index 259aa193fd..5e7a50a3a3 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/ToolCallView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallView.tsx @@ -1,4 +1,3 @@ -import type { CodeToolKind } from "@features/sessions/types"; import { ArrowsClockwise, ArrowsLeftRight, @@ -14,8 +13,9 @@ import { Trash, Wrench, } from "@phosphor-icons/react"; +import { compactHomePath } from "@posthog/shared"; +import type { CodeToolKind } from "@posthog/ui/features/sessions/types"; import { Box, Flex } from "@radix-ui/themes"; -import { compactHomePath } from "@utils/path"; import { useState } from "react"; import { compactInput, diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ToolRow.tsx b/packages/ui/src/features/sessions/components/session-update/ToolRow.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/components/session-update/ToolRow.tsx rename to packages/ui/src/features/sessions/components/session-update/ToolRow.tsx diff --git a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx b/packages/ui/src/features/sessions/components/session-update/UserMessage.test.tsx similarity index 81% rename from apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx rename to packages/ui/src/features/sessions/components/session-update/UserMessage.test.tsx index 801c7f3728..a1ac9cd60a 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/UserMessage.test.tsx @@ -1,14 +1,8 @@ import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { UserMessage } from "./UserMessage"; -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - os: { openExternal: { mutate: vi.fn() } }, - }, -})); - describe("UserMessage", () => { it("renders attachment chips for cloud prompts", () => { render( diff --git a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx similarity index 96% rename from apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx rename to packages/ui/src/features/sessions/components/session-update/UserMessage.tsx index aeb82a09b1..fc2c58dc26 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx +++ b/packages/ui/src/features/sessions/components/session-update/UserMessage.tsx @@ -1,5 +1,3 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; import { CaretDown, CaretUp, @@ -11,6 +9,9 @@ import { import { Box, Flex, IconButton } from "@radix-ui/themes"; import { motion } from "framer-motion"; import { useCallback, useEffect, useRef, useState } from "react"; +import { Tooltip } from "../../../../primitives/Tooltip"; +import { MarkdownRenderer } from "../../../editor/components/MarkdownRenderer"; +import type { UserMessageAttachment } from "../../userMessageTypes"; import { hasFileMentions, MentionChip, @@ -19,11 +20,6 @@ import { const COLLAPSED_MAX_HEIGHT = 160; -export interface UserMessageAttachment { - id: string; - label: string; -} - interface UserMessageProps { content: string; timestamp?: number; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/UserShellExecuteView.tsx b/packages/ui/src/features/sessions/components/session-update/UserShellExecuteView.tsx similarity index 93% rename from apps/code/src/renderer/features/sessions/components/session-update/UserShellExecuteView.tsx rename to packages/ui/src/features/sessions/components/session-update/UserShellExecuteView.tsx index d2295e7cc7..0802f716f9 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/UserShellExecuteView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/UserShellExecuteView.tsx @@ -1,5 +1,5 @@ +import type { UserShellExecuteResult } from "@posthog/shared"; import { Box } from "@radix-ui/themes"; -import type { UserShellExecuteResult } from "@shared/types/session-events"; import { memo } from "react"; import { ExecuteToolView } from "./ExecuteToolView"; diff --git a/packages/ui/src/features/sessions/components/session-update/identifiers.ts b/packages/ui/src/features/sessions/components/session-update/identifiers.ts new file mode 100644 index 0000000000..0337709b85 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/identifiers.ts @@ -0,0 +1,10 @@ +import type { ToolViewProps } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; +import type { ComponentType } from "react"; + +export type McpToolBlockComponent = ComponentType< + ToolViewProps & { mcpToolName: string } +>; + +export const MCP_TOOL_BLOCK_COMPONENT = Symbol.for( + "posthog.ui.McpToolBlockComponent", +); diff --git a/apps/code/src/renderer/features/sessions/components/session-update/parseFileMentions.tsx b/packages/ui/src/features/sessions/components/session-update/parseFileMentions.tsx similarity index 96% rename from apps/code/src/renderer/features/sessions/components/session-update/parseFileMentions.tsx rename to packages/ui/src/features/sessions/components/session-update/parseFileMentions.tsx index f68488fd91..f50e9bde9d 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/parseFileMentions.tsx +++ b/packages/ui/src/features/sessions/components/session-update/parseFileMentions.tsx @@ -1,15 +1,15 @@ -import { GithubRefChip } from "@features/editor/components/GithubRefChip"; -import { - baseComponents, - defaultRemarkPlugins, -} from "@features/editor/components/MarkdownRenderer"; import { File, Folder, Warning } from "@phosphor-icons/react"; +import { unescapeXmlAttr } from "@posthog/shared"; import { Text } from "@radix-ui/themes"; -import { unescapeXmlAttr } from "@utils/xml"; import type { ReactNode } from "react"; import { memo } from "react"; import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; +import { GithubRefChip } from "../../../editor/components/GithubRefChip"; +import { + baseComponents, + defaultRemarkPlugins, +} from "../../../editor/components/MarkdownRenderer"; const MENTION_TAG_REGEX = /<file\s+path="([^"]+)"\s*\/>|<(github_issue|github_pr)\s+number="([^"]+)"(?:\s+title="([^"]*)")?(?:\s+url="([^"]*)")?\s*\/>|<error_context\s+label="([^"]*)">[\s\S]*?<\/error_context>|<folder\s+path="([^"]+)"\s*\/>/g; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/toolCallUtils.tsx b/packages/ui/src/features/sessions/components/session-update/toolCallUtils.tsx similarity index 97% rename from apps/code/src/renderer/features/sessions/components/session-update/toolCallUtils.tsx rename to packages/ui/src/features/sessions/components/session-update/toolCallUtils.tsx index eb73b3da42..4570e797e2 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/toolCallUtils.tsx +++ b/packages/ui/src/features/sessions/components/session-update/toolCallUtils.tsx @@ -1,7 +1,7 @@ -import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; -import type { ToolCall, ToolCallContent } from "@features/sessions/types"; import { type Icon, Minus, Plus } from "@phosphor-icons/react"; +import { DotsCircleSpinner } from "@posthog/ui/primitives/DotsCircleSpinner"; import { Box, Text } from "@radix-ui/themes"; +import type { ToolCall, ToolCallContent } from "../../types"; export function ToolTitle({ children, diff --git a/apps/code/src/renderer/features/sessions/components/session-update/useCodePreviewExtensions.ts b/packages/ui/src/features/sessions/components/session-update/useCodePreviewExtensions.ts similarity index 86% rename from apps/code/src/renderer/features/sessions/components/session-update/useCodePreviewExtensions.ts rename to packages/ui/src/features/sessions/components/session-update/useCodePreviewExtensions.ts index 81aa20bbaf..e8bc24e721 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/useCodePreviewExtensions.ts +++ b/packages/ui/src/features/sessions/components/session-update/useCodePreviewExtensions.ts @@ -1,9 +1,9 @@ import { EditorState } from "@codemirror/state"; import { EditorView, lineNumbers } from "@codemirror/view"; -import { oneDark, oneLight } from "@features/code-editor/theme/editorTheme"; -import { getLanguageExtension } from "@features/code-editor/utils/languages"; -import { useThemeStore } from "@stores/themeStore"; import { useMemo } from "react"; +import { useThemeStore } from "../../../../workbench/themeStore"; +import { oneDark, oneLight } from "../../../code-editor/theme/editorTheme"; +import { getLanguageExtension } from "../../../code-editor/utils/languages"; export function useCodePreviewExtensions( filePath: string | undefined, diff --git a/packages/ui/src/features/sessions/components/useFileContextMenu.ts b/packages/ui/src/features/sessions/components/useFileContextMenu.ts new file mode 100644 index 0000000000..345e41eab5 --- /dev/null +++ b/packages/ui/src/features/sessions/components/useFileContextMenu.ts @@ -0,0 +1,44 @@ +import { useHostTRPCClient } from "@posthog/host-router/react"; +import type { Workspace } from "@posthog/shared"; +import { useExternalAppAction } from "@posthog/ui/features/external-apps/useExternalAppAction"; +import { useCallback } from "react"; + +export interface OpenFileContextMenuInput { + absolutePath: string; + filename: string; + workspace: Workspace | null; + mainRepoPath?: string; + showCollapseAll?: boolean; + onCollapseAll?: () => void; +} + +export function useFileContextMenu() { + const hostClient = useHostTRPCClient(); + const openExternalApp = useExternalAppAction(); + const openForFile = useCallback( + async ({ + absolutePath, + filename, + workspace, + mainRepoPath, + showCollapseAll = false, + onCollapseAll, + }: OpenFileContextMenuInput) => { + const result = await hostClient.contextMenu.showFileContextMenu.mutate({ + filePath: absolutePath, + showCollapseAll, + }); + if (!result.action) return; + if (result.action.type === "collapse-all") { + onCollapseAll?.(); + } else if (result.action.type === "external-app") { + await openExternalApp(result.action.action, absolutePath, filename, { + workspace, + mainRepoPath, + }); + } + }, + [hostClient, openExternalApp], + ); + return { openForFile }; +} diff --git a/apps/code/src/renderer/features/sessions/constants.ts b/packages/ui/src/features/sessions/constants.ts similarity index 100% rename from apps/code/src/renderer/features/sessions/constants.ts rename to packages/ui/src/features/sessions/constants.ts diff --git a/apps/code/src/renderer/features/sessions/utils/contextColors.ts b/packages/ui/src/features/sessions/contextColors.ts similarity index 92% rename from apps/code/src/renderer/features/sessions/utils/contextColors.ts rename to packages/ui/src/features/sessions/contextColors.ts index fa8f27f5cc..38c03621f5 100644 --- a/apps/code/src/renderer/features/sessions/utils/contextColors.ts +++ b/packages/ui/src/features/sessions/contextColors.ts @@ -1,4 +1,4 @@ -import type { ContextBreakdown } from "@features/sessions/hooks/useContextUsage"; +import type { ContextBreakdown } from "@posthog/ui/features/sessions/hooks/useContextUsage"; export interface CategoryStyle { key: keyof ContextBreakdown; diff --git a/apps/code/src/renderer/features/sessions/stores/handoffDialogStore.ts b/packages/ui/src/features/sessions/handoffDialogStore.ts similarity index 97% rename from apps/code/src/renderer/features/sessions/stores/handoffDialogStore.ts rename to packages/ui/src/features/sessions/handoffDialogStore.ts index 85de785292..cfc22e3b09 100644 --- a/apps/code/src/renderer/features/sessions/stores/handoffDialogStore.ts +++ b/packages/ui/src/features/sessions/handoffDialogStore.ts @@ -1,4 +1,4 @@ -import type { GitFileStatus } from "@shared/types"; +import type { GitFileStatus } from "@posthog/shared"; import { create } from "zustand"; type HandoffDirection = "to-local" | "to-cloud"; diff --git a/apps/code/src/renderer/features/sessions/hooks/useAgentVersion.ts b/packages/ui/src/features/sessions/hooks/useAgentVersion.ts similarity index 86% rename from apps/code/src/renderer/features/sessions/hooks/useAgentVersion.ts rename to packages/ui/src/features/sessions/hooks/useAgentVersion.ts index 0a0eb259dd..4d7dd94a07 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useAgentVersion.ts +++ b/packages/ui/src/features/sessions/hooks/useAgentVersion.ts @@ -1,5 +1,5 @@ -import { isAgentVersion } from "@utils/agentVersion"; -import { useSessionStore } from "../stores/sessionStore"; +import { useSessionStore } from "@posthog/ui/features/sessions/sessionStore"; +import { isAgentVersion } from "@posthog/ui/utils/agentVersion"; /** * Returns the connected agent's version for the given task, or `undefined` diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts b/packages/ui/src/features/sessions/hooks/useChatTitleGenerator.test.ts similarity index 79% rename from apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts rename to packages/ui/src/features/sessions/hooks/useChatTitleGenerator.test.ts index 4784d1e117..71d6c745fb 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.test.ts +++ b/packages/ui/src/features/sessions/hooks/useChatTitleGenerator.test.ts @@ -1,4 +1,4 @@ -import type { Task } from "@shared/types"; +import type { Task } from "@posthog/shared/domain-types"; import { renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -6,28 +6,28 @@ const mockEnrichDescription = vi.hoisted(() => vi.fn().mockImplementation((desc: string) => Promise.resolve(desc)), ); const mockGenerateTitle = vi.hoisted(() => vi.fn()); -const mockGetAuthenticatedClient = vi.hoisted(() => vi.fn()); -const mockGetCachedTask = vi.hoisted(() => vi.fn()); +const mockGetQueriesData = vi.hoisted(() => vi.fn(() => [] as unknown[])); const mockIsAuthenticated = vi.hoisted(() => ({ value: true })); const mockUpdateTask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const mockSetQueriesData = vi.hoisted(() => vi.fn()); const mockSetQueryData = vi.hoisted(() => vi.fn()); const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn()); const mockPrompts = vi.hoisted(() => ({ value: [] as string[] })); -const mockSessionStoreSetters = vi.hoisted(() => ({ - updateSession: vi.fn(), -})); +const mockSessionStoreSetters = vi.hoisted(() => ({ updateSession: vi.fn() })); -vi.mock("@utils/generateTitle", () => ({ - enrichDescriptionWithFileContent: mockEnrichDescription, - generateTitleAndSummary: mockGenerateTitle, +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ + getQueriesData: mockGetQueriesData, + setQueriesData: mockSetQueriesData, + setQueryData: mockSetQueryData, + }), })); -vi.mock("@features/auth/hooks/authClient", () => ({ - getAuthenticatedClient: mockGetAuthenticatedClient, +vi.mock("@posthog/ui/features/auth/authClient", () => ({ + useOptionalAuthenticatedClient: () => ({ updateTask: mockUpdateTask }), })); -vi.mock("@features/auth/hooks/authQueries", () => ({ +vi.mock("@posthog/ui/features/auth/store", () => ({ useAuthStateValue: ( selector: (state: { status: string; @@ -41,25 +41,19 @@ vi.mock("@features/auth/hooks/authQueries", () => ({ ), })); -vi.mock("@utils/queryClient", () => ({ - getCachedTask: mockGetCachedTask, - queryClient: { - setQueriesData: mockSetQueriesData, - setQueryData: mockSetQueryData, - }, -})); - -vi.mock("@utils/session", () => ({ +vi.mock("@posthog/core/sessions/sessionEvents", () => ({ extractUserPromptsFromEvents: () => mockPrompts.value, })); -vi.mock("@features/sessions/service/service", () => ({ - getSessionService: () => ({ +vi.mock("@posthog/di/react", () => ({ + useService: () => ({ updateSessionTaskTitle: mockUpdateSessionTaskTitle, + enrichDescriptionWithFileContent: mockEnrichDescription, + generateTitleAndSummary: mockGenerateTitle, }), })); -vi.mock("@utils/logger", () => ({ +vi.mock("@posthog/ui/workbench/logger", () => ({ logger: { scope: () => ({ info: vi.fn(), @@ -70,7 +64,7 @@ vi.mock("@utils/logger", () => ({ }, })); -vi.mock("@features/sessions/stores/sessionStore", () => { +vi.mock("@posthog/ui/features/sessions/sessionStore", () => { const state = { taskIdIndex: { "task-1": "run-1" }, sessions: { "run-1": { events: mockPrompts.value } }, @@ -100,7 +94,13 @@ function createTask(overrides: Partial<Task> = {}): Task { updated_at: "2026-05-28T00:00:00.000Z", origin_product: "user_created", ...overrides, - }; + } as Task; +} + +// Simulate a task present in the ["tasks","list"] cache so the inlined +// getCachedTask (which reads queryClient.getQueriesData) finds it. +function cacheTask(task: Task): void { + mockGetQueriesData.mockReturnValue([[["tasks", "list"], [task]]]); } describe("useChatTitleGenerator", () => { @@ -111,19 +111,12 @@ describe("useChatTitleGenerator", () => { mockEnrichDescription.mockImplementation((desc: string) => Promise.resolve(desc), ); - mockGetCachedTask.mockReturnValue(undefined); - mockGetAuthenticatedClient.mockResolvedValue({ - updateTask: mockUpdateTask, - }); + mockGetQueriesData.mockReturnValue([]); }); it("does not generate when promptCount is 0 and the task already has a custom title", () => { renderHook(() => - useChatTitleGenerator( - createTask({ - title: "Custom task title", - }), - ), + useChatTitleGenerator(createTask({ title: "Custom task title" })), ); expect(mockGenerateTitle).not.toHaveBeenCalled(); }); @@ -152,13 +145,7 @@ describe("useChatTitleGenerator", () => { summary: "User is fixing a login issue", }); - renderHook(() => - useChatTitleGenerator( - createTask({ - title: "", - }), - ), - ); + renderHook(() => useChatTitleGenerator(createTask({ title: "" }))); await waitFor(() => { expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { @@ -172,18 +159,10 @@ describe("useChatTitleGenerator", () => { title: "Fix login bug", summary: "User is fixing a login issue", }); - mockGetCachedTask.mockReturnValue( - createTask({ - title_manually_set: true, - }), - ); + cacheTask(createTask({ title_manually_set: true })); renderHook(() => - useChatTitleGenerator( - createTask({ - title_manually_set: true, - }), - ), + useChatTitleGenerator(createTask({ title_manually_set: true })), ); await waitFor(() => { @@ -201,11 +180,7 @@ describe("useChatTitleGenerator", () => { mockPrompts.value = ["Fix the login bug"]; renderHook(() => - useChatTitleGenerator( - createTask({ - title: "Raw prompt title", - }), - ), + useChatTitleGenerator(createTask({ title: "Raw prompt title" })), ); await waitFor(() => { @@ -233,17 +208,14 @@ describe("useChatTitleGenerator", () => { ])( "skips title update when title_manually_set ($name)", async ({ summary, expectsSummaryUpdate }) => { - mockGetCachedTask.mockReturnValue( + cacheTask( createTask({ title: "Custom auth title", description: "fix auth", title_manually_set: true, }), ); - mockGenerateTitle.mockResolvedValue({ - title: "Auto title", - summary, - }); + mockGenerateTitle.mockResolvedValue({ title: "Auto title", summary }); mockPrompts.value = ["fix auth"]; renderHook(() => @@ -308,10 +280,7 @@ describe("useChatTitleGenerator", () => { renderHook(() => useChatTitleGenerator( - createTask({ - title: "Auth prompt", - description: "fix auth", - }), + createTask({ title: "Auth prompt", description: "fix auth" }), ), ); @@ -329,10 +298,7 @@ describe("useChatTitleGenerator", () => { renderHook(() => useChatTitleGenerator( - createTask({ - title: "Some prompt", - description: "some prompt", - }), + createTask({ title: "Some prompt", description: "some prompt" }), ), ); diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/packages/ui/src/features/sessions/hooks/useChatTitleGenerator.ts similarity index 57% rename from apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts rename to packages/ui/src/features/sessions/hooks/useChatTitleGenerator.ts index 9745dd8eba..724e81e05a 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ b/packages/ui/src/features/sessions/hooks/useChatTitleGenerator.ts @@ -1,53 +1,48 @@ -import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { xmlToPlainText } from "@features/message-editor/utils/content"; -import { getSessionService } from "@features/sessions/service/service"; +import type { Schemas } from "@posthog/api-client"; +import { + decideTitleGeneration, + formatPromptsForTitleInput, + isAutoTitleLocked, + selectPromptsForTitle, +} from "@posthog/core/sessions/chatTitle"; +import { extractUserPromptsFromEvents } from "@posthog/core/sessions/sessionEvents"; +import type { SessionService } from "@posthog/core/sessions/sessionService"; +import { SESSION_SERVICE } from "@posthog/core/sessions/sessionService"; +import { TITLE_GENERATOR_SERVICE } from "@posthog/core/sessions/titleGeneratorIdentifiers"; +import type { TitleGeneratorService } from "@posthog/core/sessions/titleGeneratorService"; +import { useService } from "@posthog/di/react"; +import type { Task } from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { sessionStoreSetters, useSessionStore, -} from "@features/sessions/stores/sessionStore"; -import { taskKeys } from "@features/tasks/hooks/taskKeys"; -import type { Schemas } from "@posthog/api-client"; -import type { Task } from "@shared/types"; -import { - enrichDescriptionWithFileContent, - generateTitleAndSummary, -} from "@utils/generateTitle"; -import { logger } from "@utils/logger"; -import { getCachedTask, queryClient } from "@utils/queryClient"; -import { extractUserPromptsFromEvents } from "@utils/session"; +} from "@posthog/ui/features/sessions/sessionStore"; +import { taskKeys } from "@posthog/ui/features/tasks/taskKeys"; +import { logger } from "@posthog/ui/workbench/logger"; +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; const log = logger.scope("chat-title-generator"); -const REGENERATE_INTERVAL = 7; - -function getFallbackTaskTitle(description: string): string { - const plainText = xmlToPlainText(description).trim(); - return (plainText || "Untitled").slice(0, 255); -} - -function isPlaceholderTaskTitle( - task: Pick<Task, "title" | "description">, -): boolean { - if (task.title.trim().length === 0) { - return true; - } - - const fallbackTitle = getFallbackTaskTitle(task.description); - return task.title === fallbackTitle; -} - -function isAutoTitleLocked(task: Task | undefined): boolean { - if (!task?.title_manually_set) { - return false; - } - - return !isPlaceholderTaskTitle(task); +function getCachedTask( + queryClient: QueryClient, + taskId: string, +): Task | undefined { + return queryClient + .getQueriesData<Task[]>({ queryKey: taskKeys.lists() }) + .flatMap(([, tasks]) => tasks ?? []) + .find((t) => t.id === taskId); } export function useChatTitleGenerator(task: Task): void { const taskId = task.id; + const sessionService = useService<SessionService>(SESSION_SERVICE); + const titleGenerator = useService<TitleGeneratorService>( + TITLE_GENERATOR_SERVICE, + ); + const queryClient = useQueryClient(); + const client = useOptionalAuthenticatedClient(); const lastGeneratedAtCount = useRef(0); const initialDescriptionHandled = useRef(false); const isGenerating = useRef(false); @@ -67,16 +62,13 @@ export function useChatTitleGenerator(task: Task): void { if (!isAuthenticated) return; if (isGenerating.current) return; - const shouldGenerateFromPrompts = - (promptCount === 1 && lastGeneratedAtCount.current === 0) || - (promptCount > 1 && - promptCount - lastGeneratedAtCount.current >= REGENERATE_INTERVAL); - - const shouldGenerateFromTaskDescription = - promptCount === 0 && - !initialDescriptionHandled.current && - task.description.trim().length > 0 && - isPlaceholderTaskTitle(task); + const { shouldGenerateFromPrompts, shouldGenerateFromTaskDescription } = + decideTitleGeneration({ + promptCount, + lastGeneratedAtCount: lastGeneratedAtCount.current, + initialDescriptionHandled: initialDescriptionHandled.current, + task, + }); if (!shouldGenerateFromPrompts && !shouldGenerateFromTaskDescription) { return; @@ -96,24 +88,25 @@ export function useChatTitleGenerator(task: Task): void { } const allPrompts = extractUserPromptsFromEvents(session.events); - const promptsForTitle = - promptCount === 1 ? allPrompts : allPrompts.slice(-REGENERATE_INTERVAL); + const promptsForTitle = selectPromptsForTitle(allPrompts, promptCount); - rawContent = promptsForTitle.map((p, i) => `${i + 1}. ${p}`).join("\n"); + rawContent = formatPromptsForTitleInput(promptsForTitle); } const run = async () => { try { - const content = await enrichDescriptionWithFileContent(rawContent); - const result = await generateTitleAndSummary(content); + const content = + await titleGenerator.enrichDescriptionWithFileContent(rawContent); + const result = await titleGenerator.generateTitleAndSummary(content); if (result) { const { title, summary } = result; - const titleLocked = isAutoTitleLocked(getCachedTask(taskId) ?? task); + const titleLocked = isAutoTitleLocked( + getCachedTask(queryClient, taskId) ?? task, + ); if (title && titleLocked) { log.debug("Skipping auto-title, user renamed task", { taskId }); } else if (title) { - const client = await getAuthenticatedClient(); if (client) { await client.updateTask(taskId, { title }); queryClient.setQueriesData<Task[]>( @@ -133,7 +126,7 @@ export function useChatTitleGenerator(task: Task): void { queryClient.setQueryData<Task>(taskKeys.detail(taskId), (old) => old ? { ...old, title } : old, ); - getSessionService().updateSessionTaskTitle(taskId, title); + sessionService.updateSessionTaskTitle(taskId, title); log.debug("Updated task title from conversation", { taskId, promptCount, @@ -166,5 +159,14 @@ export function useChatTitleGenerator(task: Task): void { }; run(); - }, [isAuthenticated, promptCount, taskId, task]); + }, [ + isAuthenticated, + promptCount, + taskId, + task, + client, + queryClient, + sessionService, + titleGenerator, + ]); } diff --git a/packages/ui/src/features/sessions/hooks/useContextUsage.ts b/packages/ui/src/features/sessions/hooks/useContextUsage.ts new file mode 100644 index 0000000000..b4904a50f4 --- /dev/null +++ b/packages/ui/src/features/sessions/hooks/useContextUsage.ts @@ -0,0 +1,13 @@ +import { + type ContextBreakdown, + type ContextUsage, + extractContextUsage, +} from "@posthog/core/sessions/contextUsage"; +import type { AcpMessage } from "@posthog/shared"; +import { useMemo } from "react"; + +export type { ContextBreakdown, ContextUsage }; + +export function useContextUsage(events: AcpMessage[]): ContextUsage | null { + return useMemo(() => extractContextUsage(events), [events]); +} diff --git a/apps/code/src/renderer/features/sessions/hooks/useConversationSearch.ts b/packages/ui/src/features/sessions/hooks/useConversationSearch.ts similarity index 93% rename from apps/code/src/renderer/features/sessions/hooks/useConversationSearch.ts rename to packages/ui/src/features/sessions/hooks/useConversationSearch.ts index 3552e93a90..5584aeb890 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useConversationSearch.ts +++ b/packages/ui/src/features/sessions/hooks/useConversationSearch.ts @@ -1,10 +1,9 @@ -import type { ConversationItem } from "@features/sessions/components/buildConversationItems"; -import type { VirtualizedListHandle } from "@features/sessions/components/VirtualizedList"; -import { extractSearchableText } from "@features/sessions/utils/extractSearchableText"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import type { ConversationSearchBarHandle } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; +import type { VirtualizedListHandle } from "@posthog/ui/features/sessions/components/VirtualizedList"; +import { extractSearchableText } from "@posthog/ui/features/sessions/utils/extractSearchableText"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ConversationSearchBarHandle } from "../components/ConversationSearchBar"; - const HIGHLIGHT_MATCH = "search-match"; const HIGHLIGHT_ACTIVE = "search-match-active"; diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts similarity index 68% rename from apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts rename to packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts index ae236342fb..eb127e1b20 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts +++ b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts @@ -1,19 +1,28 @@ -import { tryExecuteCodeCommand } from "@features/message-editor/commands"; -import { useDraftStore } from "@features/message-editor/stores/draftStore"; -import { useTaskViewed } from "@features/sidebar/hooks/useTaskViewed"; -import { trpcClient } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; -import { useNavigationStore } from "@stores/navigationStore"; -import { logger } from "@utils/logger"; -import { toast } from "@utils/toast"; -import { useCallback, useRef } from "react"; -import { getSessionService } from "../service/service"; -import type { AgentSession } from "../stores/sessionStore"; -import { sessionStoreSetters } from "../stores/sessionStore"; import { combineQueuedCloudPrompts, promptToQueuedEditorContent, -} from "../utils/cloudArtifacts"; +} from "@posthog/core/sessions/cloudPrompt"; +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { useService } from "@posthog/di/react"; +import type { Task } from "@posthog/shared/domain-types"; +import { tryExecuteCodeCommand } from "@posthog/ui/features/message-editor/commands"; +import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { + type AgentSession, + sessionStoreSetters, +} from "@posthog/ui/features/sessions/sessionStore"; +import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; +import { + SHELL_CLIENT, + type ShellClient, +} from "@posthog/ui/features/terminal/shellClient"; +import { toast } from "@posthog/ui/primitives/toast"; +import { logger } from "@posthog/ui/workbench/logger"; +import { useCallback, useRef } from "react"; const log = logger.scope("session-callbacks"); @@ -30,6 +39,8 @@ export function useSessionCallbacks({ session, repoPath, }: UseSessionCallbacksOptions) { + const sessionService = useService<SessionService>(SESSION_SERVICE); + const shellClient = useService<ShellClient>(SHELL_CLIENT); const { markActivity, markAsViewed } = useTaskViewed(); const { requestFocus, setPendingContent } = useDraftStore((s) => s.actions); @@ -57,7 +68,7 @@ export function useSessionCallbacks({ try { markAsViewed(taskId); markActivity(taskId); - await getSessionService().sendPrompt(taskId, text); + await sessionService.sendPrompt(taskId, text); const view = useNavigationStore.getState().view; const isViewingTask = @@ -72,12 +83,19 @@ export function useSessionCallbacks({ log.error("Failed to send prompt", error); } }, - [taskId, repoPath, markActivity, markAsViewed, task.latest_run], + [ + taskId, + repoPath, + markActivity, + markAsViewed, + task.latest_run, + sessionService, + ], ); const handleCancelPrompt = useCallback(async () => { const queuedMessages = sessionStoreSetters.dequeueMessages(taskId); - const result = await getSessionService().cancelPrompt(taskId); + const result = await sessionService.cancelPrompt(taskId); log.info("Prompt cancelled", { success: result }); const queuedPrompt = sessionRef.current?.isCloud @@ -99,39 +117,39 @@ export function useSessionCallbacks({ setPendingContent(taskId, pendingContent); } requestFocus(taskId); - }, [taskId, setPendingContent, requestFocus]); + }, [taskId, setPendingContent, requestFocus, sessionService]); const handleRetry = useCallback(async () => { try { if (sessionRef.current?.isCloud) { - await getSessionService().retryCloudTaskWatch(taskId); + await sessionService.retryCloudTaskWatch(taskId); return; } if (!repoPath) return; - await getSessionService().clearSessionError(taskId, repoPath); + await sessionService.clearSessionError(taskId, repoPath); } catch (error) { log.error("Failed to clear session error", error); toast.error("Failed to retry. Please try again."); } - }, [taskId, repoPath]); + }, [taskId, repoPath, sessionService]); const handleNewSession = useCallback(async () => { if (!repoPath) return; try { - await getSessionService().resetSession(taskId, repoPath); + await sessionService.resetSession(taskId, repoPath); } catch (error) { log.error("Failed to reset session", error); toast.error("Failed to start new session. Please try again."); } - }, [taskId, repoPath]); + }, [taskId, repoPath, sessionService]); const handleBashCommand = useCallback( async (command: string) => { if (!repoPath) return; const execId = `user-shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - await getSessionService().startUserShellExecute( + await sessionService.startUserShellExecute( taskId, execId, command, @@ -139,11 +157,11 @@ export function useSessionCallbacks({ ); try { - const result = await trpcClient.shell.execute.mutate({ + const result = await shellClient.execute({ cwd: repoPath, command, }); - await getSessionService().completeUserShellExecute( + await sessionService.completeUserShellExecute( taskId, execId, command, @@ -152,7 +170,7 @@ export function useSessionCallbacks({ ); } catch (error) { log.error("Failed to execute shell command", error); - await getSessionService().completeUserShellExecute( + await sessionService.completeUserShellExecute( taskId, execId, command, @@ -165,19 +183,19 @@ export function useSessionCallbacks({ ); } }, - [taskId, repoPath], + [taskId, repoPath, sessionService, shellClient], ); const initiateHandoffToCloud = useCallback(async () => { if (!repoPath) return; try { - await getSessionService().handoffToCloud(taskId, repoPath); + await sessionService.handoffToCloud(taskId, repoPath); } catch (error) { log.error("Failed to hand off to cloud", error); const message = error instanceof Error ? error.message : "Unknown error"; toast.error(`Failed to continue in cloud: ${message}`); } - }, [taskId, repoPath]); + }, [taskId, repoPath, sessionService]); return { handleSendPrompt, diff --git a/packages/ui/src/features/sessions/hooks/useSessionConnection.ts b/packages/ui/src/features/sessions/hooks/useSessionConnection.ts new file mode 100644 index 0000000000..ca99b019d9 --- /dev/null +++ b/packages/ui/src/features/sessions/hooks/useSessionConnection.ts @@ -0,0 +1,75 @@ +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { useService } from "@posthog/di/react"; +import type { Task } from "@posthog/shared/domain-types"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import type { AgentSession } from "@posthog/ui/features/sessions/sessionStore"; +import { useConnectivity } from "@posthog/ui/hooks/useConnectivity"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { useChatTitleGenerator } from "./useChatTitleGenerator"; + +interface UseSessionConnectionOptions { + taskId: string; + task: Task; + session: AgentSession | undefined; + repoPath: string | null; + isCloud: boolean; + isSuspended?: boolean; +} + +export function useSessionConnection({ + task, + session, + repoPath, + isCloud, + isSuspended, +}: UseSessionConnectionOptions) { + const queryClient = useQueryClient(); + const { isOnline } = useConnectivity(); + const cloudAuthState = useAuthStateValue((state) => state); + const sessionService = useService<SessionService>(SESSION_SERVICE); + + useChatTitleGenerator(task); + + const taskRunId = session?.taskRunId; + useEffect(() => { + if (!taskRunId) return; + return sessionService.startActivityHeartbeat(taskRunId); + }, [taskRunId, sessionService]); + + useEffect(() => { + return sessionService.reconcileTaskConnection({ + task, + session, + repoPath, + isCloud, + isSuspended, + isOnline, + cloudAuth: { + status: cloudAuthState.status, + bootstrapComplete: cloudAuthState.bootstrapComplete, + projectId: cloudAuthState.projectId, + cloudRegion: cloudAuthState.cloudRegion, + }, + onCloudStatusChange: () => { + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + }); + }, [ + task, + session, + repoPath, + isCloud, + isSuspended, + isOnline, + cloudAuthState.status, + cloudAuthState.bootstrapComplete, + cloudAuthState.projectId, + cloudAuthState.cloudRegion, + queryClient, + sessionService, + ]); +} diff --git a/packages/ui/src/features/sessions/hooks/useSessionViewState.ts b/packages/ui/src/features/sessions/hooks/useSessionViewState.ts new file mode 100644 index 0000000000..641ce6c60c --- /dev/null +++ b/packages/ui/src/features/sessions/hooks/useSessionViewState.ts @@ -0,0 +1,22 @@ +import { deriveSessionViewState } from "@posthog/core/sessions/sessionViewState"; +import type { Task } from "@posthog/shared/domain-types"; +import { useSessionForTask } from "@posthog/ui/features/sessions/sessionStore"; +import { useCwd } from "@posthog/ui/features/sidebar/useCwd"; +import { useIsCloudTask } from "@posthog/ui/features/workspace/useIsCloudTask"; +import { useWorkspace } from "@posthog/ui/features/workspace/useWorkspace"; + +export function useSessionViewState(taskId: string, task: Task) { + const session = useSessionForTask(taskId); + const repoPath = useCwd(taskId) ?? null; + const workspace = useWorkspace(taskId); + const isCloud = useIsCloudTask(taskId); + + const derived = deriveSessionViewState(session, task, workspace, isCloud); + + return { + session, + repoPath, + isCloud, + ...derived, + }; +} diff --git a/packages/ui/src/features/sessions/identifiers.ts b/packages/ui/src/features/sessions/identifiers.ts new file mode 100644 index 0000000000..01291b0536 --- /dev/null +++ b/packages/ui/src/features/sessions/identifiers.ts @@ -0,0 +1,2 @@ +export type { LocalHandoffHost } from "@posthog/core/sessions/localHandoffService"; +export { LOCAL_HANDOFF_HOST } from "@posthog/core/sessions/localHandoffService"; diff --git a/packages/ui/src/features/sessions/localHandoffService.ts b/packages/ui/src/features/sessions/localHandoffService.ts new file mode 100644 index 0000000000..9790677101 --- /dev/null +++ b/packages/ui/src/features/sessions/localHandoffService.ts @@ -0,0 +1,56 @@ +import type { + LocalHandoffDialog, + LocalHandoffNotifier, + LocalHandoffPending, +} from "@posthog/core/sessions/localHandoffService"; +import { useHandoffDialogStore } from "@posthog/ui/features/sessions/handoffDialogStore"; +import { toast } from "@posthog/ui/primitives/toast"; +import { logger } from "@posthog/ui/workbench/logger"; + +export type { + LocalHandoffDialog, + LocalHandoffHost, + LocalHandoffNotifier, + LocalHandoffPending, +} from "@posthog/core/sessions/localHandoffService"; +export { + LOCAL_HANDOFF_DIALOG, + LOCAL_HANDOFF_HOST, + LOCAL_HANDOFF_NOTIFIER, + LOCAL_HANDOFF_SERVICE, + LocalHandoffService, +} from "@posthog/core/sessions/localHandoffService"; + +const log = logger.scope("local-handoff-service"); + +export const localHandoffDialog: LocalHandoffDialog = { + openConfirm: (taskId, branchName) => + useHandoffDialogStore + .getState() + .openConfirm(taskId, "to-local", branchName), + closeConfirm: () => useHandoffDialogStore.getState().closeConfirm(), + cancelPendingFlow: () => + useHandoffDialogStore.getState().cancelPendingHandoff(), + hideDirtyTree: () => useHandoffDialogStore.getState().hideDirtyTree(), + getPendingAfterCommit: (): LocalHandoffPending | null => + useHandoffDialogStore.getState().pendingAfterCommit, + clearPendingAfterCommit: () => + useHandoffDialogStore.getState().clearPendingAfterCommit(), + openDirtyTreeForPendingHandoff: (changedFiles, pending) => + useHandoffDialogStore + .getState() + .openDirtyTreeForPendingHandoff( + changedFiles as Parameters< + ReturnType< + typeof useHandoffDialogStore.getState + >["openDirtyTreeForPendingHandoff"] + >[0], + pending, + ), +}; + +export const localHandoffNotifier: LocalHandoffNotifier = { + error: (message) => toast.error(message), + warn: (message, data) => log.warn(message, data), + logError: (message, data) => log.error(message, data), +}; diff --git a/apps/code/src/renderer/features/sessions/utils/sendPromptToAgent.ts b/packages/ui/src/features/sessions/sendPromptToAgent.ts similarity index 62% rename from apps/code/src/renderer/features/sessions/utils/sendPromptToAgent.ts rename to packages/ui/src/features/sessions/sendPromptToAgent.ts index a3cd2a3d59..03c8b8560e 100644 --- a/apps/code/src/renderer/features/sessions/utils/sendPromptToAgent.ts +++ b/packages/ui/src/features/sessions/sendPromptToAgent.ts @@ -1,9 +1,13 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { useReviewNavigationStore } from "@features/code-review/stores/reviewNavigationStore"; -import { DEFAULT_TAB_IDS } from "@features/panels/constants/panelConstants"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { findTabInTree } from "@features/panels/store/panelTree"; -import { getSessionService } from "@features/sessions/service/service"; +import { resolveService } from "@posthog/di/container"; +import { useReviewNavigationStore } from "../code-review/reviewNavigationStore"; +import { DEFAULT_TAB_IDS } from "../panels/panelConstants"; +import { usePanelLayoutStore } from "../panels/panelLayoutStore"; +import { findTabInTree } from "../panels/panelTree"; +import { + AGENT_PROMPT_SENDER, + type AgentPromptSender, +} from "./agentPromptSender"; /** * Sends a prompt to the agent session for a task, collapses the review @@ -13,7 +17,7 @@ export function sendPromptToAgent( taskId: string, prompt: string | ContentBlock[], ): void { - getSessionService().sendPrompt(taskId, prompt); + resolveService<AgentPromptSender>(AGENT_PROMPT_SENDER)(taskId, prompt); const { getReviewMode, setReviewMode } = useReviewNavigationStore.getState(); if (getReviewMode(taskId) === "expanded") { diff --git a/apps/code/src/renderer/features/sessions/stores/sessionAdapterStore.ts b/packages/ui/src/features/sessions/sessionAdapterStore.ts similarity index 93% rename from apps/code/src/renderer/features/sessions/stores/sessionAdapterStore.ts rename to packages/ui/src/features/sessions/sessionAdapterStore.ts index 912f7ea225..a1d39e2a8f 100644 --- a/apps/code/src/renderer/features/sessions/stores/sessionAdapterStore.ts +++ b/packages/ui/src/features/sessions/sessionAdapterStore.ts @@ -1,4 +1,4 @@ -import { electronStorage } from "@utils/electronStorage"; +import { electronStorage } from "@posthog/ui/workbench/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; diff --git a/apps/code/src/renderer/features/sessions/stores/sessionConfigStore.ts b/packages/ui/src/features/sessions/sessionConfigStore.ts similarity index 97% rename from apps/code/src/renderer/features/sessions/stores/sessionConfigStore.ts rename to packages/ui/src/features/sessions/sessionConfigStore.ts index f59ec00eeb..5651181ebf 100644 --- a/apps/code/src/renderer/features/sessions/stores/sessionConfigStore.ts +++ b/packages/ui/src/features/sessions/sessionConfigStore.ts @@ -1,5 +1,5 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; -import { electronStorage } from "@utils/electronStorage"; +import { electronStorage } from "@posthog/ui/workbench/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; diff --git a/packages/ui/src/features/sessions/sessionLogTypes.ts b/packages/ui/src/features/sessions/sessionLogTypes.ts new file mode 100644 index 0000000000..8e0ecdb9b9 --- /dev/null +++ b/packages/ui/src/features/sessions/sessionLogTypes.ts @@ -0,0 +1 @@ +export type { PermissionRequest } from "@posthog/shared"; diff --git a/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts similarity index 93% rename from apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts rename to packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts index 617ad07f7a..1926a9589c 100644 --- a/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts @@ -59,18 +59,6 @@ const mockTrpcOs = vi.hoisted(() => ({ openExternal: { mutate: vi.fn() }, })); -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - agent: mockTrpcAgent, - workspace: mockTrpcWorkspace, - logs: mockTrpcLogs, - cloudTask: mockTrpcCloudTask, - fs: mockTrpcFs, - handoff: mockTrpcHandoff, - os: mockTrpcOs, - }, -})); - const mockAuthenticatedClient = vi.hoisted(() => ({ createTaskRun: vi.fn(), appendTaskRunLog: vi.fn(), @@ -111,13 +99,13 @@ const mockAuth = vi.hoisted(() => ({ }), })); -vi.mock("@features/auth/hooks/authQueries", () => ({ +vi.mock("@posthog/ui/features/auth/authQueries", () => ({ AUTH_SCOPED_QUERY_META: { authScoped: true }, clearAuthScopedQueries: vi.fn(), getAuthIdentity: vi.fn(), fetchAuthState: mockAuth.fetchAuthState, })); -vi.mock("@features/auth/hooks/authClient", () => ({ +vi.mock("@posthog/ui/features/auth/authClientImperative", () => ({ getAuthenticatedClient: mockAuth.getAuthenticatedClient, createAuthenticatedClient: mockAuth.createAuthenticatedClient, })); @@ -138,7 +126,7 @@ const mockSessionConfigStore = vi.hoisted(() => ({ })); vi.mock( - "@features/sessions/stores/sessionConfigStore", + "@posthog/ui/features/sessions/sessionConfigStore", () => mockSessionConfigStore, ); @@ -158,38 +146,73 @@ const mockSessionAdapterStore = vi.hoisted(() => ({ })); vi.mock( - "@features/sessions/stores/sessionAdapterStore", + "@posthog/ui/features/sessions/sessionAdapterStore", () => mockSessionAdapterStore, ); const mockGetIsOnline = vi.hoisted(() => vi.fn(() => true)); -vi.mock("@renderer/stores/connectivityStore", () => ({ +vi.mock("@posthog/core/connectivity/connectivityStore", () => ({ getIsOnline: () => mockGetIsOnline(), })); +const mockNotificationService = vi.hoisted(() => ({ + notifyPermissionRequest: vi.fn(), + notifyPromptComplete: vi.fn(), +})); + +vi.mock("@posthog/di/container", () => ({ + resolveService: (token: unknown) => { + if (token === Symbol.for("posthog.host.trpcClient")) { + return { + agent: mockTrpcAgent, + workspace: mockTrpcWorkspace, + logs: mockTrpcLogs, + cloudTask: mockTrpcCloudTask, + fs: mockTrpcFs, + handoff: mockTrpcHandoff, + os: mockTrpcOs, + }; + } + if (token === Symbol.for("posthog.ui.ImperativeQueryClient")) { + return { + invalidateQueries: vi.fn(), + refetchQueries: vi.fn(), + setQueriesData: vi.fn(), + }; + } + if ( + typeof token === "function" && + token.name === "TaskNotificationService" + ) { + return mockNotificationService; + } + throw new Error(`resolveService: unmocked token ${String(token)}`); + }, +})); + const mockSettingsState = vi.hoisted(() => ({ customInstructions: "", })); -vi.mock("@features/settings/stores/settingsStore", () => ({ +vi.mock("@posthog/ui/features/settings/settingsStore", () => ({ useSettingsStore: { getState: () => mockSettingsState, }, })); -vi.mock("@features/sidebar/hooks/useTaskViewed", () => ({ +vi.mock("@posthog/ui/features/sidebar/taskMetaApi", () => ({ taskViewedApi: { markActivity: vi.fn(), markAsViewed: vi.fn(), }, })); -vi.mock("@utils/analytics", () => ({ +vi.mock("@posthog/ui/workbench/posthogAnalyticsImpl", () => ({ track: vi.fn(), buildPermissionToolMetadata: vi.fn(() => ({})), })); -vi.mock("@utils/logger", () => ({ +vi.mock("../../workbench/logger", () => ({ logger: { scope: () => ({ info: vi.fn(), @@ -199,21 +222,11 @@ vi.mock("@utils/logger", () => ({ }), }, })); -vi.mock("@utils/notifications", () => ({ - notifyPermissionRequest: vi.fn(), - notifyPromptComplete: vi.fn(), -})); -vi.mock("@renderer/utils/toast", () => ({ +vi.mock("@posthog/ui/primitives/toast", () => ({ toast: { error: vi.fn(), info: vi.fn() }, })); -vi.mock("@utils/queryClient", () => ({ - queryClient: { - invalidateQueries: vi.fn(), - refetchQueries: vi.fn(), - setQueriesData: vi.fn(), - }, -})); -vi.mock("@shared/utils/urls", () => ({ +vi.mock("@posthog/shared", async (importOriginal) => ({ + ...(await importOriginal<typeof import("@posthog/shared")>()), getCloudUrlFromRegion: () => "https://api.anthropic.com", })); @@ -221,10 +234,12 @@ const mockConvertStoredEntriesToEvents = vi.hoisted(() => vi.fn<(entries: unknown[]) => unknown[]>(() => []), ); -vi.mock("@utils/session", async () => { - const actual = - await vi.importActual<typeof import("@utils/session")>("@utils/session"); +vi.mock("@posthog/core/sessions/sessionEvents", async () => { + const actual = await vi.importActual< + typeof import("@posthog/core/sessions/sessionEvents") + >("@posthog/core/sessions/sessionEvents"); return { + ...actual, convertStoredEntriesToEvents: mockConvertStoredEntriesToEvents, createUserPromptEvent: vi.fn((prompt, ts) => ({ type: "acp_message", @@ -257,14 +272,14 @@ vi.mock("@utils/session", async () => { }; }); -// NOTE: deliberately NOT mocking "@features/sessions/stores/sessionStore" — +// NOTE: deliberately NOT mocking "@posthog/ui/features/sessions/sessionStore" — // the real Zustand store is the whole point of this test. -import type { AgentSession } from "@features/sessions/stores/sessionStore"; +import type { AgentSession } from "@posthog/ui/features/sessions/sessionStore"; import { sessionStoreSetters, useSessionStore, -} from "@features/sessions/stores/sessionStore"; -import { getSessionService, resetSessionService } from "./service"; +} from "@posthog/ui/features/sessions/sessionStore"; +import { getSessionService, resetSessionService } from "./sessionServiceHost"; const TASK_ID = "task-299bc88e"; const RUN_ID = "run-6f83616d"; diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts similarity index 98% rename from apps/code/src/renderer/features/sessions/service/service.test.ts rename to packages/ui/src/features/sessions/sessionServiceHost.test.ts index 1b7f411b4b..8b48908173 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts @@ -1,7 +1,7 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; -import type { AgentSession } from "@features/sessions/stores/sessionStore"; -import type { Task } from "@shared/types"; -import type { AcpMessage } from "@shared/types/session-events"; +import type { AcpMessage } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import type { AgentSession } from "@posthog/ui/features/sessions/sessionStore"; import { beforeEach, describe, expect, it, vi } from "vitest"; // --- Hoisted Mocks --- @@ -53,18 +53,6 @@ const mockTrpcOs = vi.hoisted(() => ({ openExternal: { mutate: vi.fn() }, })); -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - agent: mockTrpcAgent, - workspace: mockTrpcWorkspace, - logs: mockTrpcLogs, - cloudTask: mockTrpcCloudTask, - fs: mockTrpcFs, - handoff: mockTrpcHandoff, - os: mockTrpcOs, - }, -})); - const mockSessionStoreSetters = vi.hoisted(() => ({ setSession: vi.fn(), removeSession: vi.fn(), @@ -104,7 +92,7 @@ const mockGetConfigOptionByCategory = vi.hoisted(() => ), ); -vi.mock("@features/sessions/stores/sessionStore", () => ({ +vi.mock("@posthog/ui/features/sessions/sessionStore", () => ({ sessionStoreSetters: mockSessionStoreSetters, getConfigOptionByCategory: mockGetConfigOptionByCategory, mergeConfigOptions: vi.fn((live: unknown[], _persisted: unknown[]) => live), @@ -162,13 +150,13 @@ const mockAuth = vi.hoisted(() => ({ }), })); -vi.mock("@features/auth/hooks/authQueries", () => ({ +vi.mock("@posthog/ui/features/auth/authQueries", () => ({ AUTH_SCOPED_QUERY_META: { authScoped: true }, clearAuthScopedQueries: vi.fn(), getAuthIdentity: vi.fn(), fetchAuthState: mockAuth.fetchAuthState, })); -vi.mock("@features/auth/hooks/authClient", () => ({ +vi.mock("@posthog/ui/features/auth/authClientImperative", () => ({ getAuthenticatedClient: mockAuth.getAuthenticatedClient, createAuthenticatedClient: mockAuth.createAuthenticatedClient, })); @@ -189,7 +177,7 @@ const mockSessionConfigStore = vi.hoisted(() => ({ })); vi.mock( - "@features/sessions/stores/sessionConfigStore", + "@posthog/ui/features/sessions/sessionConfigStore", () => mockSessionConfigStore, ); @@ -209,38 +197,43 @@ const mockSessionAdapterStore = vi.hoisted(() => ({ })); vi.mock( - "@features/sessions/stores/sessionAdapterStore", + "@posthog/ui/features/sessions/sessionAdapterStore", () => mockSessionAdapterStore, ); const mockGetIsOnline = vi.hoisted(() => vi.fn(() => true)); -vi.mock("@renderer/stores/connectivityStore", () => ({ +vi.mock("@posthog/core/connectivity/connectivityStore", () => ({ getIsOnline: () => mockGetIsOnline(), })); +const mockNotificationService = vi.hoisted(() => ({ + notifyPermissionRequest: vi.fn(), + notifyPromptComplete: vi.fn(), +})); + const mockSettingsState = vi.hoisted(() => ({ customInstructions: "", })); -vi.mock("@features/settings/stores/settingsStore", () => ({ +vi.mock("@posthog/ui/features/settings/settingsStore", () => ({ useSettingsStore: { getState: () => mockSettingsState, }, })); -vi.mock("@features/sidebar/hooks/useTaskViewed", () => ({ +vi.mock("@posthog/ui/features/sidebar/taskMetaApi", () => ({ taskViewedApi: { markActivity: vi.fn(), markAsViewed: vi.fn(), }, })); -vi.mock("@utils/analytics", () => ({ +vi.mock("@posthog/ui/workbench/posthogAnalyticsImpl", () => ({ track: vi.fn(), buildPermissionToolMetadata: vi.fn(() => ({})), })); -vi.mock("@utils/logger", () => ({ +vi.mock("../../workbench/logger", () => ({ logger: { scope: () => ({ info: vi.fn(), @@ -250,30 +243,64 @@ vi.mock("@utils/logger", () => ({ }), }, })); -vi.mock("@utils/notifications", () => ({ - notifyPermissionRequest: vi.fn(), - notifyPromptComplete: vi.fn(), -})); -vi.mock("@renderer/utils/toast", () => ({ +vi.mock("@posthog/ui/primitives/toast", () => ({ toast: { error: vi.fn(), info: vi.fn() }, })); -vi.mock("@utils/queryClient", () => ({ - queryClient: { - invalidateQueries: vi.fn(), - refetchQueries: vi.fn(), - setQueriesData: vi.fn(), +vi.mock("@posthog/di/container", () => ({ + resolveService: (token: unknown) => { + if (token === Symbol.for("posthog.host.trpcClient")) { + return { + agent: mockTrpcAgent, + workspace: mockTrpcWorkspace, + logs: mockTrpcLogs, + cloudTask: mockTrpcCloudTask, + fs: mockTrpcFs, + handoff: mockTrpcHandoff, + os: mockTrpcOs, + }; + } + if (token === Symbol.for("posthog.ui.ImperativeQueryClient")) { + return { + invalidateQueries: vi.fn(), + refetchQueries: vi.fn(), + setQueriesData: vi.fn(), + }; + } + if ( + typeof token === "function" && + token.name === "TaskNotificationService" + ) { + return mockNotificationService; + } + throw new Error(`resolveService: unmocked token ${String(token)}`); }, })); -vi.mock("@shared/utils/urls", () => ({ +vi.mock("@posthog/shared", async (importOriginal) => ({ + ...(await importOriginal<typeof import("@posthog/shared")>()), getCloudUrlFromRegion: () => "https://api.anthropic.com", + getConfigOptionByCategory: mockGetConfigOptionByCategory, + mergeConfigOptions: vi.fn((live: unknown[], _persisted: unknown[]) => live), + flattenSelectOptions: vi.fn( + (options: Array<{ options?: unknown[] }> | undefined) => { + if (!options?.length) return []; + const first = options[0] as { options?: unknown[] }; + if (first && Array.isArray(first.options)) { + return options.flatMap( + (group) => (group as { options: unknown[] }).options, + ); + } + return options; + }, + ), })); const mockConvertStoredEntriesToEvents = vi.hoisted(() => vi.fn<(entries: unknown[]) => unknown[]>(() => []), ); -vi.mock("@utils/session", async () => { - const actual = - await vi.importActual<typeof import("@utils/session")>("@utils/session"); +vi.mock("@posthog/core/sessions/sessionEvents", async () => { + const actual = await vi.importActual< + typeof import("@posthog/core/sessions/sessionEvents") + >("@posthog/core/sessions/sessionEvents"); return { convertStoredEntriesToEvents: mockConvertStoredEntriesToEvents, createUserPromptEvent: vi.fn((prompt, ts) => ({ @@ -298,17 +325,21 @@ vi.mock("@utils/session", async () => { })), extractPromptText: vi.fn((p) => (typeof p === "string" ? p : "text")), getUserShellExecutesSinceLastPrompt: vi.fn(() => []), + hasSessionPromptEvent: actual.hasSessionPromptEvent, + isAbsoluteFolderPath: actual.isAbsoluteFolderPath, isFatalSessionError: actual.isFatalSessionError, isRateLimitError: actual.isRateLimitError, + isTurnCompleteEvent: actual.isTurnCompleteEvent, normalizePromptToBlocks: vi.fn((p) => typeof p === "string" ? [{ type: "text", text: p }] : p, ), + promptReferencesAbsoluteFolder: actual.promptReferencesAbsoluteFolder, shellExecutesToContextBlocks: vi.fn(() => []), }; }); -import { toast } from "@renderer/utils/toast"; -import { getSessionService, resetSessionService } from "./service"; +import { toast } from "@posthog/ui/primitives/toast"; +import { getSessionService, resetSessionService } from "./sessionServiceHost"; // --- Test Fixtures --- diff --git a/packages/ui/src/features/sessions/sessionServiceHost.ts b/packages/ui/src/features/sessions/sessionServiceHost.ts new file mode 100644 index 0000000000..cf6f873434 --- /dev/null +++ b/packages/ui/src/features/sessions/sessionServiceHost.ts @@ -0,0 +1,170 @@ +import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; +import { getIsOnline } from "@posthog/core/connectivity/connectivityStore"; +import { CloudArtifactService } from "@posthog/core/sessions/cloudArtifactService"; +import { + cloudPromptToBlocks, + combineQueuedCloudPrompts, + getCloudPromptTransport, +} from "@posthog/core/sessions/cloudPrompt"; +import { + SessionService, + type SessionServiceDeps, +} from "@posthog/core/sessions/sessionService"; +import { extractSkillButtonId } from "@posthog/core/skill-buttons/prompts"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { + createAuthenticatedClient, + getAuthenticatedClient, +} from "@posthog/ui/features/auth/authClientImperative"; +import { fetchAuthState } from "@posthog/ui/features/auth/authQueries"; +import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore"; +import { useAddDirectoryDialogStore } from "@posthog/ui/features/folder-picker/addDirectoryDialogStore"; +import { TaskNotificationService } from "@posthog/ui/features/notifications/notifications"; +import { useSessionAdapterStore } from "@posthog/ui/features/sessions/sessionAdapterStore"; +import { + getPersistedConfigOptions, + removePersistedConfigOptions, + setPersistedConfigOptions, + updatePersistedConfigOptionValue, +} from "@posthog/ui/features/sessions/sessionConfigStore"; +import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { taskViewedApi } from "@posthog/ui/features/sidebar/taskMetaApi"; +import { WORKSPACE_QUERY_KEY } from "@posthog/ui/features/workspace/identifiers"; +import { toast } from "@posthog/ui/primitives/toast"; +import { + buildPermissionToolMetadata, + track, +} from "@posthog/ui/workbench/posthogAnalyticsImpl"; +import { logger } from "../../workbench/logger"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "../../workbench/queryClient"; + +export { SessionService }; + +const log = logger.scope("session-service"); + +function hostClient(): HostTrpcClient { + return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT); +} + +function buildSessionServiceDeps(): SessionServiceDeps { + const trpc = hostClient(); + const queryClient = resolveService<ImperativeQueryClient>( + IMPERATIVE_QUERY_CLIENT, + ); + const cloudArtifactService = new CloudArtifactService((filePath) => + trpc.fs.readFileAsBase64.query({ filePath }), + ); + + return { + trpc, + store: sessionStoreSetters, + log, + toast: { + error: (msg, opts) => toast.error(msg, opts), + info: (msg, opts) => toast.info(msg, opts), + }, + track: (event, props) => { + (track as (event: string, props?: Record<string, unknown>) => void)( + event, + props, + ); + }, + buildPermissionToolMetadata, + notifyPermissionRequest: (taskTitle, taskId) => + resolveService(TaskNotificationService).notifyPermissionRequest( + taskTitle, + taskId, + ), + notifyPromptComplete: (taskTitle, stopReason, taskId) => + resolveService(TaskNotificationService).notifyPromptComplete( + taskTitle, + stopReason, + taskId, + ), + getIsOnline, + fetchAuthState, + getAuthenticatedClient, + createAuthenticatedClient, + getPersistedConfigOptions: (taskRunId) => + getPersistedConfigOptions(taskRunId) ?? undefined, + setPersistedConfigOptions, + removePersistedConfigOptions, + updatePersistedConfigOptionValue, + adapterStore: { + getAdapter: (taskRunId) => + useSessionAdapterStore.getState().getAdapter(taskRunId), + setAdapter: (taskRunId, adapter) => + useSessionAdapterStore.getState().setAdapter(taskRunId, adapter), + removeAdapter: (taskRunId) => + useSessionAdapterStore.getState().removeAdapter(taskRunId), + }, + get settings() { + return useSettingsStore.getState(); + }, + usageLimit: { + show: (...args) => useUsageLimitStore.getState().show(...args), + }, + get addDirectoryDialog() { + return { open: useAddDirectoryDialogStore.getState().open }; + }, + taskViewedApi: { + markActivity: (taskId) => taskViewedApi.markActivity(taskId), + }, + queryClient, + DEFAULT_GATEWAY_MODEL, + WORKSPACE_QUERY_KEY, + h: { + extractSkillButtonId, + cloudPromptToBlocks, + combineQueuedCloudPrompts, + getCloudPromptTransport, + uploadRunAttachments: (client, taskId, runId, filePaths) => + cloudArtifactService.uploadRunAttachments( + client, + taskId, + runId, + filePaths, + ), + uploadTaskStagedAttachments: (client, taskId, filePaths) => + cloudArtifactService.uploadTaskStagedAttachments( + client, + taskId, + filePaths, + ), + }, + }; +} + +// --- Singleton Service Instance --- + +let serviceInstance: SessionService | null = null; + +export function getSessionService(): SessionService { + if (!serviceInstance) { + serviceInstance = new SessionService(buildSessionServiceDeps()); + } + return serviceInstance; +} + +export function resetSessionService(): void { + if (serviceInstance) { + serviceInstance.reset(); + serviceInstance = null; + } + + sessionStoreSetters.clearAll(); + + hostClient() + .agent.resetAll.mutate() + .catch((err) => { + log.error("Failed to reset all sessions on main process", err); + }); +} diff --git a/apps/code/src/renderer/features/sessions/stores/sessionStore.test.ts b/packages/ui/src/features/sessions/sessionStore.test.ts similarity index 100% rename from apps/code/src/renderer/features/sessions/stores/sessionStore.test.ts rename to packages/ui/src/features/sessions/sessionStore.test.ts diff --git a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts b/packages/ui/src/features/sessions/sessionStore.ts similarity index 55% rename from apps/code/src/renderer/features/sessions/stores/sessionStore.ts rename to packages/ui/src/features/sessions/sessionStore.ts index 718206228b..9c6f840083 100644 --- a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts +++ b/packages/ui/src/features/sessions/sessionStore.ts @@ -1,215 +1,48 @@ import type { ContentBlock, SessionConfigOption, - SessionConfigSelectGroup, - SessionConfigSelectOption, - SessionConfigSelectOptions, } from "@agentclientprotocol/sdk"; -import type { ExecutionMode, TaskRunStatus } from "@shared/types"; -import type { SkillButtonId } from "@shared/types/analytics"; -import type { AcpMessage } from "@shared/types/session-events"; +import { + type AcpMessage, + type Adapter, + type AgentSession, + cycleModeOption, + type ExecutionMode, + flattenSelectOptions, + getConfigOptionByCategory, + getCurrentModeFromConfigOptions, + isSelectGroup, + mergeConfigOptions, + type OptimisticItem, + type PermissionRequest, + type QueuedMessage, + type SessionStatus, + type TaskRunStatus, +} from "@posthog/shared"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; -import type { PermissionRequest } from "../utils/parseSessionLogs"; // --- Types --- -/** Adapter type for different agent backends */ -export type Adapter = "claude" | "codex"; - -export interface QueuedMessage { - id: string; - content: string; - rawPrompt?: string | ContentBlock[]; - queuedAt: number; -} - -export type { TaskRunStatus }; - -export type OptimisticItem = - | { - type: "user_message"; - id: string; - content: string; - timestamp: number; - pinToTop?: boolean; - } - | { - type: "skill_button_action"; - id: string; - buttonId: SkillButtonId; - }; - -export interface AgentSession { - taskRunId: string; - taskId: string; - taskTitle: string; - channel: string; - events: AcpMessage[]; - startedAt: number; - status: "connecting" | "connected" | "disconnected" | "error"; - errorTitle?: string; - errorMessage?: string; - isPromptPending: boolean; - isCompacting: boolean; - promptStartedAt: number | null; - /** JSON-RPC id of the currently in-flight session/prompt request. Used to - * correlate late-arriving responses (e.g. from a cancelled prior turn) so - * they don't clear the pending state of a newer turn. */ - currentPromptId?: number | null; - logUrl?: string; - processedLineCount?: number; - framework?: "claude"; - /** Agent adapter type (e.g., "claude" or "codex") */ - adapter?: Adapter; - /** Session configuration options (model, mode, thought level, etc.) */ - configOptions?: SessionConfigOption[]; - pendingPermissions: Map<string, PermissionRequest>; - /** Accumulated time (ms) spent waiting for user input (permissions, questions, etc.) */ - pausedDurationMs: number; - messageQueue: QueuedMessage[]; - /** Whether this session is for a cloud run */ - isCloud?: boolean; - /** Cloud task run status (only set for cloud sessions) */ - cloudStatus?: TaskRunStatus; - /** Cloud task current stage */ - cloudStage?: string | null; - /** Cloud task output (PR URL, commit SHA, etc.) */ - cloudOutput?: Record<string, unknown> | null; - /** Cloud task error message */ - cloudErrorMessage?: string | null; - /** Initial prompt to re-send on retry if the first connection attempt failed */ - initialPrompt?: ContentBlock[]; - /** Cloud task branch */ - cloudBranch?: string | null; - /** Whether a cloud-to-local handoff is in progress */ - handoffInProgress?: boolean; - /** Number of session/prompt events to skip from polled logs (set during resume) */ - skipPolledPromptCount?: number; - optimisticItems: OptimisticItem[]; - /** Context window tokens used (from usage_update) */ - contextUsed?: number; - /** Context window total size in tokens (from usage_update) */ - contextSize?: number; - /** Pre-computed conversation summary for commit/PR generation context */ - conversationSummary?: string; - idleKilled?: boolean; - /** Semver of the connected agent process. Populated from the - * `_posthog/run_started` notification so that the UI can gate features - * against agent capabilities (especially relevant for cloud sandboxes - * where the agent version can lag behind the desktop). */ - agentVersion?: string; - /** Task run id for which the agent is idle. - * Set ONLY on `_posthog/turn_complete`, cleared when a - * `session/prompt` (or `sendCloudPrompt`) starts a turn. `run_started` - * does NOT set it: the initial/resume turn begins right after that - * handshake, so treating run_started as idle would drain a queued - * follow-up into the boot/resume turn race. Drives transport-drop queue - * recovery. Deliberately tracked independently of `isPromptPending`: - * `retryCloudTaskWatch()` forcibly clears `isPromptPending` on reconnect, - * so it cannot be trusted to mean "no remote turn in flight", using it - * for recovery would dispatch a queued follow-up mid-turn. */ - agentIdleForRunId?: string; -} - -// --- Config Option Helpers --- - -/** - * Type guard to check if options array contains groups (vs flat options). - */ -export function isSelectGroup( - options: SessionConfigSelectOptions, -): options is SessionConfigSelectGroup[] { - return ( - options.length > 0 && - typeof options[0] === "object" && - "options" in options[0] - ); -} - -/** - * Flatten grouped select options into a flat array. - */ -export function flattenSelectOptions( - options: SessionConfigSelectOptions, -): SessionConfigSelectOption[] { - if (!options.length) return []; - if (isSelectGroup(options)) { - return options.flatMap((group) => group.options); - } - return options as SessionConfigSelectOption[]; -} - -/** - * Merge live configOptions from server with persisted values. - * Persisted values take precedence for currentValue. - */ -export function mergeConfigOptions( - live: SessionConfigOption[], - persisted: SessionConfigOption[], -): SessionConfigOption[] { - const persistedMap = new Map(persisted.map((opt) => [opt.id, opt])); - - return live.map((liveOpt) => { - const persistedOpt = persistedMap.get(liveOpt.id); - if (persistedOpt) { - return { - ...liveOpt, - currentValue: persistedOpt.currentValue, - } as SessionConfigOption; - } - return liveOpt; - }); -} - -/** - * Get a config option by its category (e.g., "mode", "model", "thought_level"). - */ -export function getConfigOptionByCategory( - configOptions: SessionConfigOption[] | undefined, - category: string, -): SessionConfigOption | undefined { - return configOptions?.find((opt) => opt.category === category); -} - -/** - * Cycle to the next mode option value. - * Returns the next value, or undefined if cycling is not possible. - */ -export function cycleModeOption( - modeOption: SessionConfigOption | undefined, - options?: { allowBypassPermissions?: boolean }, -): string | undefined { - if (!modeOption || modeOption.type !== "select") return undefined; - - const allOptions = flattenSelectOptions(modeOption.options); - const filtered = options?.allowBypassPermissions - ? allOptions - : allOptions.filter( - (opt) => - opt.value !== "bypassPermissions" && opt.value !== "full-access", - ); - if (filtered.length === 0) return undefined; - - const currentIndex = filtered.findIndex( - (opt) => opt.value === modeOption.currentValue, - ); - if (currentIndex === -1) return filtered[0]?.value; - - const nextIndex = (currentIndex + 1) % filtered.length; - return filtered[nextIndex]?.value; -} - -/** - * Get the current mode from configOptions (for backwards compatibility). - * Returns the currentValue of the "mode" category config option. - */ -export function getCurrentModeFromConfigOptions( - configOptions: SessionConfigOption[] | undefined, -): ExecutionMode | undefined { - const modeOption = getConfigOptionByCategory(configOptions, "mode"); - return modeOption?.currentValue as ExecutionMode | undefined; -} +export type { + Adapter, + AgentSession, + ExecutionMode, + OptimisticItem, + PermissionRequest, + QueuedMessage, + SessionConfigOption, + SessionStatus, + TaskRunStatus, +}; +export { + cycleModeOption, + flattenSelectOptions, + getConfigOptionByCategory, + getCurrentModeFromConfigOptions, + isSelectGroup, + mergeConfigOptions, +}; export interface SessionState { /** Sessions indexed by taskRunId */ @@ -229,7 +62,6 @@ export const useSessionStore = create<SessionState>()( // --- Re-exports --- -export type { PermissionRequest, ExecutionMode, SessionConfigOption }; export { getAvailableCommandsForTask, getPendingPermissionsForTask, @@ -245,7 +77,7 @@ export { useSessionForTask, useSessions, useThoughtLevelConfigOptionForTask, -} from "../hooks/useSession"; +} from "./useSession"; // --- Setters --- diff --git a/apps/code/src/renderer/features/sessions/stores/sessionViewStore.ts b/packages/ui/src/features/sessions/sessionViewStore.ts similarity index 100% rename from apps/code/src/renderer/features/sessions/stores/sessionViewStore.ts rename to packages/ui/src/features/sessions/sessionViewStore.ts diff --git a/apps/code/src/renderer/features/sessions/types.ts b/packages/ui/src/features/sessions/types.ts similarity index 100% rename from apps/code/src/renderer/features/sessions/types.ts rename to packages/ui/src/features/sessions/types.ts diff --git a/apps/code/src/renderer/features/sessions/hooks/useSession.ts b/packages/ui/src/features/sessions/useSession.ts similarity index 96% rename from apps/code/src/renderer/features/sessions/hooks/useSession.ts rename to packages/ui/src/features/sessions/useSession.ts index 12edb747c1..e1516b2a7e 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSession.ts +++ b/packages/ui/src/features/sessions/useSession.ts @@ -5,7 +5,8 @@ import type { import { extractAvailableCommandsFromEvents, extractUserPromptsFromEvents, -} from "@utils/session"; +} from "@posthog/core/sessions/sessionEvents"; +import type { PermissionRequest } from "@posthog/ui/features/sessions/sessionLogTypes"; import { shallow } from "zustand/shallow"; import { type Adapter, @@ -14,8 +15,7 @@ import { type OptimisticItem, type QueuedMessage, useSessionStore, -} from "../stores/sessionStore"; -import type { PermissionRequest } from "../utils/parseSessionLogs"; +} from "./sessionStore"; export const useSessions = () => useSessionStore((s) => s.sessions); diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionTaskId.tsx b/packages/ui/src/features/sessions/useSessionTaskId.tsx similarity index 100% rename from apps/code/src/renderer/features/sessions/hooks/useSessionTaskId.tsx rename to packages/ui/src/features/sessions/useSessionTaskId.tsx diff --git a/packages/ui/src/features/sessions/userMessageTypes.ts b/packages/ui/src/features/sessions/userMessageTypes.ts new file mode 100644 index 0000000000..1209a353a3 --- /dev/null +++ b/packages/ui/src/features/sessions/userMessageTypes.ts @@ -0,0 +1,4 @@ +export interface UserMessageAttachment { + id: string; + label: string; +} diff --git a/apps/code/src/renderer/features/sessions/utils/extractSearchableText.test.ts b/packages/ui/src/features/sessions/utils/extractSearchableText.test.ts similarity index 96% rename from apps/code/src/renderer/features/sessions/utils/extractSearchableText.test.ts rename to packages/ui/src/features/sessions/utils/extractSearchableText.test.ts index 01f217b0e5..9235816100 100644 --- a/apps/code/src/renderer/features/sessions/utils/extractSearchableText.test.ts +++ b/packages/ui/src/features/sessions/utils/extractSearchableText.test.ts @@ -1,6 +1,6 @@ -import type { ConversationItem } from "@features/sessions/components/buildConversationItems"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { extractSearchableText } from "@posthog/ui/features/sessions/utils/extractSearchableText"; import { describe, expect, it } from "vitest"; -import { extractSearchableText } from "./extractSearchableText"; describe("extractSearchableText", () => { it("extracts user message content", () => { diff --git a/apps/code/src/renderer/features/sessions/utils/extractSearchableText.ts b/packages/ui/src/features/sessions/utils/extractSearchableText.ts similarity index 86% rename from apps/code/src/renderer/features/sessions/utils/extractSearchableText.ts rename to packages/ui/src/features/sessions/utils/extractSearchableText.ts index 501e0d90d2..5ac5e45a8c 100644 --- a/apps/code/src/renderer/features/sessions/utils/extractSearchableText.ts +++ b/packages/ui/src/features/sessions/utils/extractSearchableText.ts @@ -1,5 +1,5 @@ -import type { ConversationItem } from "@features/sessions/components/buildConversationItems"; -import type { RenderItem } from "@features/sessions/components/session-update/SessionUpdateView"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import type { RenderItem } from "@posthog/ui/features/sessions/components/session-update/SessionUpdateView"; function extractRenderItemText(update: RenderItem): string { switch (update.sessionUpdate) { diff --git a/apps/code/src/renderer/features/settings/components/FolderSettingsView.tsx b/packages/ui/src/features/settings/FolderSettingsView.tsx similarity index 96% rename from apps/code/src/renderer/features/settings/components/FolderSettingsView.tsx rename to packages/ui/src/features/settings/FolderSettingsView.tsx index 61ea0c4b75..46427bd546 100644 --- a/apps/code/src/renderer/features/settings/components/FolderSettingsView.tsx +++ b/packages/ui/src/features/settings/FolderSettingsView.tsx @@ -1,5 +1,3 @@ -import { useFolders } from "@features/folders/hooks/useFolders"; -import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import { ArrowLeft, Warning } from "@phosphor-icons/react"; import { Box, @@ -11,9 +9,11 @@ import { Heading, Text, } from "@radix-ui/themes"; -import { useNavigationStore } from "@renderer/stores/navigationStore"; -import { logger } from "@utils/logger"; import { useState } from "react"; +import { useSetHeaderContent } from "../../hooks/useSetHeaderContent"; +import { logger } from "../../workbench/logger"; +import { useFolders } from "../folders/useFolders"; +import { useNavigationStore } from "../navigation/store"; const log = logger.scope("folder-settings"); diff --git a/apps/code/src/renderer/features/settings/components/ModalInlineComboboxContent.tsx b/packages/ui/src/features/settings/ModalInlineComboboxContent.tsx similarity index 100% rename from apps/code/src/renderer/features/settings/components/ModalInlineComboboxContent.tsx rename to packages/ui/src/features/settings/ModalInlineComboboxContent.tsx diff --git a/apps/code/src/renderer/features/settings/components/SettingRow.tsx b/packages/ui/src/features/settings/SettingRow.tsx similarity index 100% rename from apps/code/src/renderer/features/settings/components/SettingRow.tsx rename to packages/ui/src/features/settings/SettingRow.tsx diff --git a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx b/packages/ui/src/features/settings/SettingsDialog.tsx similarity index 93% rename from apps/code/src/renderer/features/settings/components/SettingsDialog.tsx rename to packages/ui/src/features/settings/SettingsDialog.tsx index 606229da76..f75baa1192 100644 --- a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx +++ b/packages/ui/src/features/settings/SettingsDialog.tsx @@ -1,16 +1,3 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useLogoutMutation } from "@features/auth/hooks/authMutations"; -import { - useAuthStateValue, - useCurrentUser, -} from "@features/auth/hooks/authQueries"; -import { getUserInitials } from "@features/auth/utils/userInitials"; -import { - type SettingsCategory, - useSettingsDialogStore, -} from "@features/settings/stores/settingsDialogStore"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; -import { useSeat } from "@hooks/useSeat"; import { ArrowLeft, ArrowsClockwise, @@ -30,13 +17,24 @@ import { TreeStructure, Wrench, } from "@phosphor-icons/react"; +import { BILLING_FLAG } from "@posthog/shared"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useLogoutMutation } from "@posthog/ui/features/auth/useAuthMutations"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; +import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { useSeat } from "@posthog/ui/features/billing/useSeat"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { EnvironmentsSettings } from "@posthog/ui/features/settings/sections/environments/EnvironmentsSettings"; +import { + type SettingsCategory, + useSettingsDialogStore, +} from "@posthog/ui/features/settings/settingsDialogStore"; import { Avatar, Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; -import { BILLING_FLAG } from "@shared/constants"; import { type ReactNode, useEffect, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { AdvancedSettings } from "./sections/AdvancedSettings"; import { ClaudeCodeSettings } from "./sections/ClaudeCodeSettings"; -import { EnvironmentsSettings } from "./sections/environments/EnvironmentsSettings"; import { GeneralSettings } from "./sections/GeneralSettings"; import { GitHubSettings } from "./sections/GitHubSettings"; import { PersonalizationSettings } from "./sections/PersonalizationSettings"; diff --git a/apps/code/src/renderer/features/settings/components/SettingsOptionSelect.tsx b/packages/ui/src/features/settings/SettingsOptionSelect.tsx similarity index 100% rename from apps/code/src/renderer/features/settings/components/SettingsOptionSelect.tsx rename to packages/ui/src/features/settings/SettingsOptionSelect.tsx diff --git a/apps/code/src/renderer/features/settings/components/sections/AccountSettings.tsx b/packages/ui/src/features/settings/sections/AccountSettings.tsx similarity index 82% rename from apps/code/src/renderer/features/settings/components/sections/AccountSettings.tsx rename to packages/ui/src/features/settings/sections/AccountSettings.tsx index 0a743891b7..9d582049a9 100644 --- a/apps/code/src/renderer/features/settings/components/sections/AccountSettings.tsx +++ b/packages/ui/src/features/settings/sections/AccountSettings.tsx @@ -1,14 +1,12 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useLogoutMutation } from "@features/auth/hooks/authMutations"; -import { - useAuthStateValue, - useCurrentUser, -} from "@features/auth/hooks/authQueries"; -import { getUserInitials } from "@features/auth/utils/userInitials"; -import { useSeat } from "@hooks/useSeat"; import { SignOut } from "@phosphor-icons/react"; +import { formatRegionBadge } from "@posthog/shared"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useLogoutMutation } from "@posthog/ui/features/auth/useAuthMutations"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; +import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { useSeat } from "@posthog/ui/features/billing/useSeat"; import { Avatar, Badge, Button, Flex, Spinner, Text } from "@radix-ui/themes"; -import { formatRegionBadge } from "@shared/types/regions"; export function AccountSettings() { const isAuthenticated = useAuthStateValue( diff --git a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx similarity index 74% rename from apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx rename to packages/ui/src/features/settings/sections/AdvancedSettings.tsx index 72dd67866e..632fdd180f 100644 --- a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx +++ b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx @@ -1,12 +1,12 @@ -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { SettingRow } from "@features/settings/components/SettingRow"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useSetupStore } from "@features/setup/stores/setupStore"; -import { useTourStore } from "@features/tour/stores/tourStore"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; +import { useSettingsDialogStore } from "@posthog/ui/features/settings/settingsDialogStore"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { useSetupStore } from "@posthog/ui/features/setup/setupStore"; +import { useTourStore } from "@posthog/ui/features/tour/tourStore"; +import { clearApplicationStorage } from "@posthog/ui/utils/clearStorage"; import { Button, Flex, Switch } from "@radix-ui/themes"; -import { clearApplicationStorage } from "@utils/clearStorage"; export function AdvancedSettings() { const showDebugLogsToggle = diff --git a/apps/code/src/renderer/features/settings/components/sections/ClaudeCodeSettings.tsx b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx similarity index 94% rename from apps/code/src/renderer/features/settings/components/sections/ClaudeCodeSettings.tsx rename to packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx index e61d447b5a..06e6d18e4a 100644 --- a/apps/code/src/renderer/features/settings/components/sections/ClaudeCodeSettings.tsx +++ b/packages/ui/src/features/settings/sections/ClaudeCodeSettings.tsx @@ -1,6 +1,10 @@ -import { SettingRow } from "@features/settings/components/SettingRow"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { ArrowSquareOut, Check, Copy, Warning } from "@phosphor-icons/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; +import { PermissionsSettings } from "@posthog/ui/features/settings/sections/PermissionsSettings"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { track } from "@posthog/ui/workbench/analytics"; import { AlertDialog, Button, @@ -11,11 +15,7 @@ import { Switch, Text, } from "@radix-ui/themes"; -import { Tooltip } from "@renderer/components/ui/Tooltip"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; import { useCallback, useState } from "react"; -import { PermissionsSettings } from "./PermissionsSettings"; function CopyableCommand({ command }: { command: string }) { const [copied, setCopied] = useState(false); diff --git a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx similarity index 95% rename from apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx rename to packages/ui/src/features/settings/sections/GeneralSettings.tsx index e3d0e16b2e..e742b4134a 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -1,5 +1,9 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { SettingRow } from "@features/settings/components/SettingRow"; +import { ArrowSquareOut } from "@phosphor-icons/react"; +import { buildPostHogUrl } from "@posthog/core/settings/posthogUrl"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; import { type AutoConvertLongText, type CompletionSound, @@ -8,8 +12,11 @@ import { type DiffOpenMode, type SendMessagesWith, useSettingsStore, -} from "@features/settings/stores/settingsStore"; -import { ArrowSquareOut } from "@phosphor-icons/react"; +} from "@posthog/ui/features/settings/settingsStore"; +import { playCompletionSound } from "@posthog/ui/utils/sounds"; +import { track } from "@posthog/ui/workbench/analytics"; +import type { ThemePreference } from "@posthog/ui/workbench/themeStore"; +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; import { Button, Flex, @@ -19,19 +26,12 @@ import { Switch, Text, } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import type { ThemePreference } from "@stores/themeStore"; -import { useThemeStore } from "@stores/themeStore"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { track } from "@utils/analytics"; -import { playCompletionSound } from "@utils/sounds"; -import { getPostHogUrl } from "@utils/urls"; import { useCallback, useEffect } from "react"; import { toast } from "sonner"; export function GeneralSettings() { - const trpcReact = useTRPC(); + const hostTRPC = useHostTRPC(); const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); @@ -44,10 +44,10 @@ export function GeneralSettings() { const { preventSleepWhileRunning, setPreventSleepWhileRunning } = useSettingsStore(); const { data: serverPreventSleep } = useQuery( - trpcReact.sleep.getEnabled.queryOptions(), + hostTRPC.sleep.getEnabled.queryOptions(), ); const preventSleepMutation = useMutation( - trpcReact.sleep.setEnabled.mutationOptions(), + hostTRPC.sleep.setEnabled.mutationOptions(), ); useEffect(() => { @@ -229,7 +229,7 @@ export function GeneralSettings() { [hedgehogMode, setHedgehogMode], ); - const accountUrl = getPostHogUrl("/settings/user", cloudRegion); + const accountUrl = buildPostHogUrl("/settings/user", cloudRegion); return ( <Flex direction="column"> @@ -535,7 +535,7 @@ function HedgehogDescription() { const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const customizeUrl = projectId - ? getPostHogUrl( + ? buildPostHogUrl( `/project/${projectId}/settings/user-customization`, cloudRegion, ) diff --git a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx b/packages/ui/src/features/settings/sections/GitHubIntegrationSection.tsx similarity index 88% rename from apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx rename to packages/ui/src/features/settings/sections/GitHubIntegrationSection.tsx index 0296e674c1..8aa7fdeab8 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx +++ b/packages/ui/src/features/settings/sections/GitHubIntegrationSection.tsx @@ -1,16 +1,17 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { - describeGithubConnectError, - useGithubConnect, -} from "@features/integrations/hooks/useGithubUserConnect"; -import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { ArrowSquareOutIcon, CheckCircleIcon, GitBranchIcon, InfoIcon, } from "@phosphor-icons/react"; +import { summarizeReposByOwner } from "@posthog/core/settings/githubRepoSummary"; import { Button } from "@posthog/quill"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { + describeGithubConnectError, + useGithubConnect, +} from "@posthog/ui/features/integrations/useGithubUserConnect"; +import { useRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; import { Box, Flex, Spinner, Text, Tooltip } from "@radix-ui/themes"; import { useMemo } from "react"; @@ -20,19 +21,6 @@ import { useMemo } from "react"; */ const REPO_LIST_TOOLTIP_THRESHOLD = 10; -function summarizeReposByOwner( - repositories: readonly string[], -): { owner: string; count: number }[] { - const counts = new Map<string, number>(); - for (const repo of repositories) { - const owner = repo.includes("/") ? (repo.split("/", 1)[0] ?? repo) : repo; - counts.set(owner, (counts.get(owner) ?? 0) + 1); - } - return [...counts.entries()] - .map(([owner, count]) => ({ owner, count })) - .sort((a, b) => b.count - a.count || a.owner.localeCompare(b.owner)); -} - export function GitHubIntegrationSection({ hasGithubIntegration, isLoading = false, diff --git a/apps/code/src/renderer/features/settings/components/sections/GitHubSettings.tsx b/packages/ui/src/features/settings/sections/GitHubSettings.tsx similarity index 91% rename from apps/code/src/renderer/features/settings/components/sections/GitHubSettings.tsx rename to packages/ui/src/features/settings/sections/GitHubSettings.tsx index 0bf77e2605..cd8dd40b22 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GitHubSettings.tsx +++ b/packages/ui/src/features/settings/sections/GitHubSettings.tsx @@ -1,14 +1,3 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { - describeGithubConnectError, - invalidateGithubQueries, - useGithubUserConnect, -} from "@features/integrations/hooks/useGithubUserConnect"; -import { - useUserGithubIntegrations, - useUserRepositoryIntegration, -} from "@hooks/useIntegrations"; import { ArrowSquareOutIcon, CaretDownIcon, @@ -18,6 +7,22 @@ import { GithubLogoIcon, WarningIcon, } from "@phosphor-icons/react"; +import type { UserGitHubIntegration } from "@posthog/api-client/posthog-client"; +import { githubInstallationSettingsUrl } from "@posthog/core/settings/githubRepoSummary"; +import { formatRelativeTimeLong } from "@posthog/shared"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { + describeGithubConnectError, + invalidateGithubQueries, + useGithubUserConnect, +} from "@posthog/ui/features/integrations/useGithubUserConnect"; +import { + useUserGithubIntegrations, + useUserRepositoryIntegration, +} from "@posthog/ui/features/integrations/useIntegrations"; +import { toast } from "@posthog/ui/primitives/toast"; +import { openUrlInBrowser } from "@posthog/ui/utils/browser"; import { AlertDialog, Box, @@ -28,29 +33,11 @@ import { Text, Tooltip, } from "@radix-ui/themes"; -import type { UserGitHubIntegration } from "@renderer/api/posthogClient"; -import { formatRelativeTimeLong } from "@renderer/utils/time"; -import { toast } from "@renderer/utils/toast"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { openUrlInBrowser } from "@utils/browser"; import { useState } from "react"; const REPO_PREVIEW_COUNT = 3; -function githubInstallationSettingsUrl(integration: UserGitHubIntegration) { - const accountType = integration.account?.type; - const accountName = integration.account?.name; - if ( - typeof accountType === "string" && - accountType.toLowerCase() === "organization" && - typeof accountName === "string" && - accountName - ) { - return `https://github.com/organizations/${accountName}/settings/installations/${integration.installation_id}`; - } - return `https://github.com/settings/installations/${integration.installation_id}`; -} - export function GitHubSettings() { const projectId = useAuthStateValue((s) => s.projectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); diff --git a/apps/code/src/renderer/features/settings/components/sections/PermissionsSettings.tsx b/packages/ui/src/features/settings/sections/PermissionsSettings.tsx similarity index 92% rename from apps/code/src/renderer/features/settings/components/sections/PermissionsSettings.tsx rename to packages/ui/src/features/settings/sections/PermissionsSettings.tsx index 07d8fb732f..0772498f2c 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PermissionsSettings.tsx +++ b/packages/ui/src/features/settings/sections/PermissionsSettings.tsx @@ -1,6 +1,5 @@ +import { useHostTRPC } from "@posthog/host-router/react"; import { Box, Flex, Text } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc"; - import { useQuery } from "@tanstack/react-query"; function PermissionBadge({ @@ -56,8 +55,8 @@ function PermissionList({ } export function PermissionsSettings() { - const trpcReact = useTRPC(); - const { data } = useQuery(trpcReact.os.getClaudePermissions.queryOptions()); + const trpc = useHostTRPC(); + const { data } = useQuery(trpc.os.getClaudePermissions.queryOptions()); return ( <Flex direction="column" gap="3" mb="2"> diff --git a/apps/code/src/renderer/features/settings/components/sections/PersonalizationSettings.tsx b/packages/ui/src/features/settings/sections/PersonalizationSettings.tsx similarity index 89% rename from apps/code/src/renderer/features/settings/components/sections/PersonalizationSettings.tsx rename to packages/ui/src/features/settings/sections/PersonalizationSettings.tsx index d38bc836fc..644c599406 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PersonalizationSettings.tsx +++ b/packages/ui/src/features/settings/sections/PersonalizationSettings.tsx @@ -1,8 +1,8 @@ -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useDebounce } from "@hooks/useDebounce"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { useDebounce } from "@posthog/ui/primitives/hooks/useDebounce"; +import { track } from "@posthog/ui/workbench/analytics"; import { Flex, Text, TextArea } from "@radix-ui/themes"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; import { useCallback, useEffect, useState } from "react"; const MAX_INSTRUCTIONS_LENGTH = 2000; diff --git a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx similarity index 91% rename from apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx rename to packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index 9046e271bd..0c689e53a2 100644 --- a/apps/code/src/renderer/features/settings/components/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -1,18 +1,24 @@ -import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { TokenSpendAnalysisBanner } from "@features/billing/components/TokenSpendAnalysisBanner"; -import { useUsage } from "@features/billing/hooks/useUsage"; -import { useSeatStore } from "@features/billing/stores/seatStore"; -import { formatResetTime } from "@features/billing/utils"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; -import { useSeat } from "@hooks/useSeat"; -import type { UsageBucket } from "@main/services/llm-gateway/schemas"; import { ArrowSquareOut, CreditCard, Info, WarningCircle, } from "@phosphor-icons/react"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { formatResetTime } from "@posthog/core/billing/usageDisplay"; +import type { UsageBucket } from "@posthog/core/usage/schemas"; +import { PLAN_PRO_ALPHA } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useSeatStore } from "@posthog/ui/features/billing/seatStore"; +import { TokenSpendAnalysisBanner } from "@posthog/ui/features/billing/TokenSpendAnalysisBanner"; +import { useSeat } from "@posthog/ui/features/billing/useSeat"; +import { useUsage } from "@posthog/ui/features/billing/useUsage"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { getBillingUrl, getPostHogUrl } from "@posthog/ui/utils/urls"; +import { track } from "@posthog/ui/workbench/analytics"; +import { logger } from "@posthog/ui/workbench/logger"; import { Badge, Button, @@ -23,24 +29,19 @@ import { Spinner, Text, } from "@radix-ui/themes"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { PLAN_PRO_ALPHA } from "@shared/types/seat"; -import { track } from "@utils/analytics"; -import { logger } from "@utils/logger"; -import { getBillingUrl, getPostHogUrl } from "@utils/urls"; import { useEffect, useState } from "react"; const log = logger.scope("plan-usage"); const SPEND_ANALYSIS_FLAG = "posthog-code-spend-analysis"; -async function openBillingPage(orgId: string | null): Promise<void> { - if (orgId) { +async function openBillingPage( + orgId: string | null, + client: PostHogAPIClient | null, +): Promise<void> { + if (orgId && client) { try { - const client = await getAuthenticatedClient(); - if (client) { - await client.switchOrganization(orgId); - } + await client.switchOrganization(orgId); } catch (err) { log.warn("Failed to switch org before opening billing", err); } @@ -50,6 +51,7 @@ async function openBillingPage(orgId: string | null): Promise<void> { } export function PlanUsageSettings() { + const client = useOptionalAuthenticatedClient(); const { seat, orgSeat, @@ -128,7 +130,7 @@ export function PlanUsageSettings() { color="red" disabled={!billingUrl} onClick={() => { - void openBillingPage(billingOrgId); + void openBillingPage(billingOrgId, client); }} className="self-start" > @@ -343,7 +345,7 @@ export function PlanUsageSettings() { variant="outline" disabled={!billingUrl} onClick={() => { - void openBillingPage(billingOrgId); + void openBillingPage(billingOrgId, client); }} > Open diff --git a/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx b/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx new file mode 100644 index 0000000000..90b4751853 --- /dev/null +++ b/packages/ui/src/features/settings/sections/ShortcutsSettings.tsx @@ -0,0 +1,5 @@ +import { KeyboardShortcutsList } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; + +export function ShortcutsSettings() { + return <KeyboardShortcutsList />; +} diff --git a/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx b/packages/ui/src/features/settings/sections/SignalSlackNotificationsSettings.tsx similarity index 84% rename from apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx rename to packages/ui/src/features/settings/sections/SignalSlackNotificationsSettings.tsx index 2e82005629..116502677c 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx +++ b/packages/ui/src/features/settings/sections/SignalSlackNotificationsSettings.tsx @@ -1,11 +1,12 @@ -import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceManager"; -import { useSlackChannels } from "@features/inbox/hooks/useSlackChannels"; -import { useSlackConnect } from "@features/integrations/hooks/useSlackConnect"; -import { useIntegrationSelectors } from "@features/integrations/stores/integrationStore"; -import { ModalInlineComboboxContent } from "@features/settings/components/ModalInlineComboboxContent"; -import { SettingsOptionSelect } from "@features/settings/components/SettingsOptionSelect"; -import { useDebouncedValue } from "@hooks/useDebouncedValue"; import { CaretDown, Hash, Lock } from "@phosphor-icons/react"; +import { + buildChannelTargetValue, + deriveEffectiveIntegrationId, + getSlackIntegrationLabel, + mergeVisibleChannels, + parseChannelIdFromTargetValue, + parseChannelNameFromTargetValue, +} from "@posthog/core/settings/slackNotificationTarget"; import { Button, Combobox, @@ -16,8 +17,15 @@ import { ComboboxList, ComboboxTrigger, } from "@posthog/quill"; +import type { SignalReportPriority } from "@posthog/shared/domain-types"; +import { useSignalSourceManager } from "@posthog/ui/features/inbox/hooks/useSignalSourceManager"; +import { useSlackChannels } from "@posthog/ui/features/inbox/hooks/useSlackChannels"; +import { useIntegrationSelectors } from "@posthog/ui/features/integrations/store"; +import { useSlackConnect } from "@posthog/ui/features/integrations/useSlackConnect"; +import { ModalInlineComboboxContent } from "@posthog/ui/features/settings/ModalInlineComboboxContent"; +import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; +import { useDebouncedValue } from "@posthog/ui/primitives/hooks/useDebouncedValue"; import { Box, Callout, Flex, Text } from "@radix-ui/themes"; -import type { SignalReportPriority, SlackChannelOption } from "@shared/types"; import { useMemo, useRef, useState } from "react"; const NOTIFY_OFF_VALUE = "__off__"; @@ -38,42 +46,6 @@ const MIN_PRIORITY_OPTIONS: { const SETTINGS_CONTROL_CLASS = "min-w-[200px] max-w-[240px]"; -function buildChannelTargetValue( - channelId: string, - channelName: string, -): string { - const display = channelName.startsWith("#") ? channelName : `#${channelName}`; - return `${channelId}|${display}`; -} - -function parseChannelIdFromTargetValue( - value: string | null | undefined, -): string | null { - if (!value) return null; - return value.split("|")[0]?.trim() || null; -} - -function parseChannelNameFromTargetValue( - value: string | null | undefined, -): string | null { - if (!value) return null; - const display = value.split("|")[1]?.trim(); - if (!display) return null; - return display.startsWith("#") ? display.slice(1) : display; -} - -function getSlackIntegrationLabel(integration: { - id: number; - display_name?: string; - config?: { account?: { name?: string } }; -}): string { - return ( - integration.display_name ?? - integration.config?.account?.name ?? - `Slack workspace ${integration.id}` - ); -} - interface SignalSlackNotificationsSettingsProps { channelComboboxModal?: boolean; isLoading?: boolean; @@ -103,9 +75,10 @@ export function SignalSlackNotificationsSettings({ // Default the integration selection to the first one if there's only one // available — we still require an explicit channel pick to enable delivery. - const effectiveIntegrationId = - selectedIntegrationId ?? - (slackIntegrations.length === 1 ? slackIntegrations[0].id : null); + const effectiveIntegrationId = deriveEffectiveIntegrationId( + selectedIntegrationId, + slackIntegrations, + ); const channelAnchorRef = useRef<HTMLDivElement>(null); const [channelComboboxOpen, setChannelComboboxOpen] = useState(false); @@ -131,19 +104,15 @@ export function SignalSlackNotificationsSettings({ const notificationsEnabled = !!selectedIntegrationId && !!selectedChannelTarget; - const visibleChannels = useMemo(() => { - const channels = [...(channelsData?.channels ?? [])]; - if ( - selectedChannelId && - selectedChannelName && - !channels.some((channel) => channel.id === selectedChannelId) - ) { - channels.unshift( - configuredSlackChannelOption(selectedChannelId, selectedChannelName), - ); - } - return channels; - }, [channelsData?.channels, selectedChannelId, selectedChannelName]); + const visibleChannels = useMemo( + () => + mergeVisibleChannels( + channelsData?.channels ?? [], + selectedChannelId, + selectedChannelName, + ), + [channelsData?.channels, selectedChannelId, selectedChannelName], + ); const channelComboboxItems = useMemo( () => [NOTIFY_OFF_VALUE, ...visibleChannels.map((c) => c.id)], @@ -454,17 +423,3 @@ export function SignalSlackNotificationsSettings({ </Flex> ); } - -function configuredSlackChannelOption( - id: string, - name: string, -): SlackChannelOption { - return { - id, - name, - is_private: false, - is_member: true, - is_ext_shared: false, - is_private_without_access: false, - }; -} diff --git a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx b/packages/ui/src/features/settings/sections/SignalSourcesSettings.tsx similarity index 85% rename from apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx rename to packages/ui/src/features/settings/sections/SignalSourcesSettings.tsx index bfd4ce2e56..f13add8836 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx +++ b/packages/ui/src/features/settings/sections/SignalSourcesSettings.tsx @@ -1,15 +1,15 @@ -import { DataSourceSetup } from "@features/inbox/components/DataSourceSetup"; +import type { SignalReportPriority } from "@posthog/shared/domain-types"; +import { DataSourceSetup } from "@posthog/ui/features/inbox/components/DataSourceSetup"; import { SignalSourceToggles, SignalSourceTogglesSkeleton, -} from "@features/inbox/components/SignalSourceToggles"; -import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceManager"; -import { SettingsOptionSelect } from "@features/settings/components/SettingsOptionSelect"; -import { GitHubIntegrationSection } from "@features/settings/components/sections/GitHubIntegrationSection"; -import { SignalSlackNotificationsSettings } from "@features/settings/components/sections/SignalSlackNotificationsSettings"; -import { useRepositoryIntegration } from "@hooks/useIntegrations"; +} from "@posthog/ui/features/inbox/components/SignalSourceToggles"; +import { useSignalSourceManager } from "@posthog/ui/features/inbox/hooks/useSignalSourceManager"; +import { useRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; +import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; +import { GitHubIntegrationSection } from "@posthog/ui/features/settings/sections/GitHubIntegrationSection"; +import { SignalSlackNotificationsSettings } from "@posthog/ui/features/settings/sections/SignalSlackNotificationsSettings"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; -import type { SignalReportPriority } from "@shared/types"; const PRIORITY_OPTIONS: { value: SignalReportPriority; label: string }[] = [ { value: "P0", label: "P0 — Critical only" }, diff --git a/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx b/packages/ui/src/features/settings/sections/SlackSettings.tsx similarity index 90% rename from apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx rename to packages/ui/src/features/settings/sections/SlackSettings.tsx index bd75a0934d..a963193ce8 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx +++ b/packages/ui/src/features/settings/sections/SlackSettings.tsx @@ -1,14 +1,14 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { ArrowSquareOutIcon, SlackLogoIcon } from "@phosphor-icons/react"; +import { formatRelativeTimeLong } from "@posthog/shared"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { type Integration, useIntegrationSelectors, -} from "@features/integrations/stores/integrationStore"; -import { useIntegrations } from "@hooks/useIntegrations"; -import { ArrowSquareOutIcon, SlackLogoIcon } from "@phosphor-icons/react"; +} from "@posthog/ui/features/integrations/store"; +import { useIntegrations } from "@posthog/ui/features/integrations/useIntegrations"; +import { openUrlInBrowser } from "@posthog/ui/utils/browser"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { Box, Button, Flex, Spinner, Text, Tooltip } from "@radix-ui/themes"; -import { formatRelativeTimeLong } from "@renderer/utils/time"; -import { openUrlInBrowser } from "@utils/browser"; -import { getPostHogUrl } from "@utils/urls"; import { SignalSlackNotificationsSettings } from "./SignalSlackNotificationsSettings"; export function SlackSettings() { diff --git a/apps/code/src/renderer/features/settings/components/sections/TerminalSettings.tsx b/packages/ui/src/features/settings/sections/TerminalSettings.tsx similarity index 91% rename from apps/code/src/renderer/features/settings/components/sections/TerminalSettings.tsx rename to packages/ui/src/features/settings/sections/TerminalSettings.tsx index 0163053707..0d55d84c41 100644 --- a/apps/code/src/renderer/features/settings/components/sections/TerminalSettings.tsx +++ b/packages/ui/src/features/settings/sections/TerminalSettings.tsx @@ -1,12 +1,12 @@ -import { SettingRow } from "@features/settings/components/SettingRow"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; import { type TerminalFont, useSettingsStore, -} from "@features/settings/stores/settingsStore"; -import { useDebounce } from "@hooks/useDebounce"; +} from "@posthog/ui/features/settings/settingsStore"; +import { useDebounce } from "@posthog/ui/primitives/hooks/useDebounce"; +import { track } from "@posthog/ui/workbench/analytics"; import { Flex, Select, Text, TextField } from "@radix-ui/themes"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; import { useEffect, useState } from "react"; export function TerminalSettings() { diff --git a/apps/code/src/renderer/features/settings/components/sections/UpdatesSettings.tsx b/packages/ui/src/features/settings/sections/UpdatesSettings.tsx similarity index 77% rename from apps/code/src/renderer/features/settings/components/sections/UpdatesSettings.tsx rename to packages/ui/src/features/settings/sections/UpdatesSettings.tsx index c83f0ce192..52d26ad67f 100644 --- a/apps/code/src/renderer/features/settings/components/sections/UpdatesSettings.tsx +++ b/packages/ui/src/features/settings/sections/UpdatesSettings.tsx @@ -1,19 +1,18 @@ -import { SettingRow } from "@features/settings/components/SettingRow"; import { CheckCircle, XCircle } from "@phosphor-icons/react"; +import { deriveUpdateStatus } from "@posthog/core/settings/updateStatus"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { SettingRow } from "@posthog/ui/features/settings/SettingRow"; +import { logger } from "@posthog/ui/workbench/logger"; import { Badge, Button, Flex, Spinner, Text } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; -import { logger } from "@utils/logger"; import { useCallback, useEffect, useRef, useState } from "react"; const log = logger.scope("updates-settings"); export function UpdatesSettings() { - const trpcReact = useTRPC(); - const { data: appVersion } = useQuery( - trpcReact.os.getAppVersion.queryOptions(), - ); + const trpc = useHostTRPC(); + const { data: appVersion } = useQuery(trpc.os.getAppVersion.queryOptions()); const [checkingForUpdates, setCheckingForUpdates] = useState(false); const [updatesDisabled, setUpdatesDisabled] = useState(false); const [updateStatus, setUpdateStatus] = useState<{ @@ -23,7 +22,7 @@ export function UpdatesSettings() { const hasCheckedRef = useRef(false); const checkUpdatesMutation = useMutation( - trpcReact.updates.check.mutationOptions(), + trpc.updates.check.mutationOptions(), ); const handleCheckForUpdates = useCallback(async () => { @@ -69,25 +68,13 @@ export function UpdatesSettings() { }, [handleCheckForUpdates]); useSubscription( - trpcReact.updates.onStatus.subscriptionOptions(undefined, { + trpc.updates.onStatus.subscriptionOptions(undefined, { onData: (status) => { - if (status.checking && status.downloading) { - setUpdateStatus({ message: "Downloading update...", type: "info" }); - } else if (status.checking === false && status.upToDate) { - setUpdateStatus({ - message: "You're on the latest version", - type: "success", - }); - setCheckingForUpdates(false); - } else if (status.checking === false && status.updateReady) { - setUpdateStatus({ - message: status.version - ? `Update ${status.version} ready to install` - : "Update ready to install", - type: "success", - }); - setCheckingForUpdates(false); - } else if (status.checking === false) { + const derived = deriveUpdateStatus(status); + if (derived.message) { + setUpdateStatus({ message: derived.message, type: derived.type }); + } + if (derived.checking === false) { setCheckingForUpdates(false); } }, diff --git a/apps/code/src/renderer/features/settings/components/sections/WorkspacesSettings.tsx b/packages/ui/src/features/settings/sections/WorkspacesSettings.tsx similarity index 67% rename from apps/code/src/renderer/features/settings/components/sections/WorkspacesSettings.tsx rename to packages/ui/src/features/settings/sections/WorkspacesSettings.tsx index 924a13782a..bb52bdf303 100644 --- a/apps/code/src/renderer/features/settings/components/sections/WorkspacesSettings.tsx +++ b/packages/ui/src/features/settings/sections/WorkspacesSettings.tsx @@ -1,27 +1,34 @@ -import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; -import { SettingRow } from "@features/settings/components/SettingRow"; import { Folder, X } from "@phosphor-icons/react"; +import { useHostTRPCClient } from "@posthog/host-router/react"; import { Button } from "@posthog/quill"; -import { trpcClient, useTRPC } from "@renderer/trpc"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; -import { toast } from "@utils/toast"; import { useEffect, useState } from "react"; +import { toast } from "../../../primitives/toast"; +import { logger } from "../../../workbench/logger"; +import { FolderPicker } from "../../folder-picker/FolderPicker"; +import { SettingRow } from "../SettingRow"; const log = logger.scope("workspaces-settings"); +const DEFAULT_DIRECTORIES_QUERY_KEY = [ + "settings", + "additionalDirectories", + "defaults", +] as const; + export function WorkspacesSettings() { - const trpc = useTRPC(); + const hostClient = useHostTRPCClient(); const queryClient = useQueryClient(); const [localWorktreeLocation, setLocalWorktreeLocation] = useState<string>(""); - const { data: worktreeLocation } = useQuery( - trpc.secureStore.getItem.queryOptions( - { key: "worktreeLocation" }, - { select: (result) => result ?? null }, - ), - ); + const { data: worktreeLocation } = useQuery({ + queryKey: ["settings", "worktreeLocation"], + queryFn: async () => + (await hostClient.secureStore.getItem.query({ + key: "worktreeLocation", + })) ?? null, + }); useEffect(() => { if (worktreeLocation) { @@ -32,7 +39,7 @@ export function WorkspacesSettings() { const handleWorktreeLocationChange = async (newLocation: string) => { setLocalWorktreeLocation(newLocation); try { - await trpcClient.secureStore.setItem.query({ + await hostClient.secureStore.setItem.query({ key: "worktreeLocation", value: newLocation, }); @@ -41,32 +48,33 @@ export function WorkspacesSettings() { } }; - const defaultsQuery = useQuery( - trpc.additionalDirectories.listDefaults.queryOptions(), - ); + const defaultsQuery = useQuery({ + queryKey: DEFAULT_DIRECTORIES_QUERY_KEY, + queryFn: () => hostClient.additionalDirectories.listDefaults.query(), + }); const defaults = defaultsQuery.data ?? []; const invalidateDefaults = () => - queryClient.invalidateQueries( - trpc.additionalDirectories.listDefaults.pathFilter(), - ); + queryClient.invalidateQueries({ + queryKey: DEFAULT_DIRECTORIES_QUERY_KEY, + }); - const addMutation = useMutation( - trpc.additionalDirectories.addDefault.mutationOptions({ - onSuccess: invalidateDefaults, - }), - ); - const removeMutation = useMutation( - trpc.additionalDirectories.removeDefault.mutationOptions({ - onSuccess: invalidateDefaults, - }), - ); + const addMutation = useMutation({ + mutationFn: (path: string) => + hostClient.additionalDirectories.addDefault.mutate({ path }), + onSuccess: invalidateDefaults, + }); + const removeMutation = useMutation({ + mutationFn: (path: string) => + hostClient.additionalDirectories.removeDefault.mutate({ path }), + onSuccess: invalidateDefaults, + }); const handleAddDefaultDirectory = async () => { try { - const path = await trpcClient.os.selectDirectory.query(); + const path = await hostClient.os.selectDirectory.query(); if (path) { - await addMutation.mutateAsync({ path }); + await addMutation.mutateAsync(path); } } catch (err) { log.error("Failed to add default directory", err); @@ -113,7 +121,7 @@ export function WorkspacesSettings() { type="button" aria-label={`Remove ${path}`} className="cursor-pointer p-0 opacity-60 hover:opacity-100" - onClick={() => removeMutation.mutate({ path })} + onClick={() => removeMutation.mutate(path)} > <X size={12} /> </button> diff --git a/apps/code/src/renderer/features/settings/components/sections/environments/CloudEnvironmentsSettings.tsx b/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx similarity index 84% rename from apps/code/src/renderer/features/settings/components/sections/environments/CloudEnvironmentsSettings.tsx rename to packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx index 399190ab24..c4b60b5465 100644 --- a/apps/code/src/renderer/features/settings/components/sections/environments/CloudEnvironmentsSettings.tsx +++ b/packages/ui/src/features/settings/sections/environments/CloudEnvironmentsSettings.tsx @@ -1,6 +1,16 @@ -import { useSandboxEnvironments } from "@features/settings/hooks/useSandboxEnvironments"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { ArrowLeft, PencilSimple, Plus, Trash } from "@phosphor-icons/react"; +import { + buildSandboxEnvironmentInput, + emptyForm, + type SandboxEnvironmentFormState as FormState, + formFromEnv, + validateDomains, + validateEnvVars, +} from "@posthog/core/settings/sandboxEnvironmentForm"; +import type { + NetworkAccessLevel, + SandboxEnvironment, +} from "@posthog/shared/domain-types"; import { ChevronDownIcon } from "@radix-ui/react-icons"; import { Badge, @@ -11,13 +21,10 @@ import { TextArea, TextField, } from "@radix-ui/themes"; -import type { - NetworkAccessLevel, - SandboxEnvironment, - SandboxEnvironmentInput, -} from "@shared/types"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; +import { toast } from "../../../../primitives/toast"; +import { useSettingsDialogStore } from "../../settingsDialogStore"; +import { useSandboxEnvironments } from "./useSandboxEnvironments"; const NETWORK_ACCESS_OPTIONS: { value: NetworkAccessLevel; @@ -41,87 +48,6 @@ const NETWORK_ACCESS_OPTIONS: { }, ]; -const DOMAIN_RE = - /^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; -const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; - -function isValidDomain(domain: string): boolean { - return DOMAIN_RE.test(domain); -} - -function validateDomains(text: string): { - domains: string[]; - errors: string[]; -} { - const domains: string[] = []; - const errors: string[] = []; - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - if (isValidDomain(trimmed)) { - domains.push(trimmed); - } else { - errors.push(`Invalid domain: ${trimmed}`); - } - } - return { domains, errors }; -} - -function validateEnvVars(text: string): { - vars: Record<string, string>; - errors: string[]; -} { - const vars: Record<string, string> = {}; - const errors: string[] = []; - for (const [i, line] of text.split("\n").entries()) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eqIdx = trimmed.indexOf("="); - if (eqIdx <= 0) { - errors.push(`Line ${i + 1}: missing '=' separator`); - continue; - } - const key = trimmed.slice(0, eqIdx).trim(); - if (!ENV_KEY_RE.test(key)) { - errors.push(`Line ${i + 1}: invalid key "${key}"`); - continue; - } - vars[key] = trimmed.slice(eqIdx + 1).trim(); - } - return { vars, errors }; -} - -interface FormState { - name: string; - network_access_level: NetworkAccessLevel; - allowed_domains_text: string; - include_default_domains: boolean; - environment_variables_text: string; - private: boolean; -} - -function emptyForm(): FormState { - return { - name: "", - network_access_level: "full", - allowed_domains_text: "", - include_default_domains: true, - environment_variables_text: "", - private: true, - }; -} - -function formFromEnv(env: SandboxEnvironment): FormState { - return { - name: env.name, - network_access_level: env.network_access_level, - allowed_domains_text: env.allowed_domains.join("\n"), - include_default_domains: env.include_default_domains, - environment_variables_text: "", - private: env.private, - }; -} - function NetworkAccessSelect({ value, onChange, @@ -253,21 +179,11 @@ export function CloudEnvironmentsSettings() { return; } - const payload: SandboxEnvironmentInput = { - name: form.name, - network_access_level: form.network_access_level, - allowed_domains: - form.network_access_level === "custom" ? domainValidation.domains : [], - include_default_domains: - form.network_access_level === "custom" - ? form.include_default_domains - : false, - private: form.private, - repositories: [], - ...(form.environment_variables_text.trim() - ? { environment_variables: envVarValidation.vars } - : {}), - }; + const payload = buildSandboxEnvironmentInput( + form, + domainValidation.domains, + envVarValidation.vars, + ); if (editingEnv) { await updateMutation.mutateAsync({ id: editingEnv.id, ...payload }); diff --git a/apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentForm.tsx b/packages/ui/src/features/settings/sections/environments/EnvironmentForm.tsx similarity index 87% rename from apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentForm.tsx rename to packages/ui/src/features/settings/sections/environments/EnvironmentForm.tsx index 7b000b3da6..22b88ce2aa 100644 --- a/apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentForm.tsx +++ b/packages/ui/src/features/settings/sections/environments/EnvironmentForm.tsx @@ -1,15 +1,14 @@ +import { ArrowLeft, Trash } from "@phosphor-icons/react"; import { type Environment, slugifyEnvironmentName, -} from "@main/services/environment/schemas"; -import type { RegisteredFolder } from "@main/services/folders/schemas"; -import { ArrowLeft, Trash } from "@phosphor-icons/react"; +} from "@posthog/workspace-client/environment"; +import { useWorkspaceTRPC } from "@posthog/workspace-client/trpc"; import { Button, Flex, Text, TextArea, TextField } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc"; -import { useTRPC } from "@renderer/trpc/client"; -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "@utils/toast"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import { toast } from "../../../../primitives/toast"; +import type { RegisteredFolder } from "../../../folders/types"; interface EnvironmentFormProps { folder: RegisteredFolder; @@ -22,8 +21,17 @@ export function EnvironmentForm({ environment, onBack, }: EnvironmentFormProps) { - const trpc = useTRPC(); + const trpc = useWorkspaceTRPC(); const queryClient = useQueryClient(); + const createEnvironment = useMutation( + trpc.environment.create.mutationOptions(), + ); + const updateEnvironment = useMutation( + trpc.environment.update.mutationOptions(), + ); + const deleteEnvironment = useMutation( + trpc.environment.delete.mutationOptions(), + ); const isNew = !environment; const [name, setName] = useState(environment?.name ?? folder.name); @@ -50,14 +58,14 @@ export function EnvironmentForm({ : undefined; if (isNew) { - await trpcClient.environment.create.mutate({ + await createEnvironment.mutateAsync({ repoPath: folder.path, name: name.trim(), setup, }); toast.success("Environment created"); } else { - await trpcClient.environment.update.mutate({ + await updateEnvironment.mutateAsync({ repoPath: folder.path, id: environment.id, name: name.trim(), @@ -83,7 +91,7 @@ export function EnvironmentForm({ if (!confirmed) return; setIsDeleting(true); try { - await trpcClient.environment.delete.mutate({ + await deleteEnvironment.mutateAsync({ repoPath: folder.path, id: environment.id, }); diff --git a/apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentRow.tsx b/packages/ui/src/features/settings/sections/environments/EnvironmentRow.tsx similarity index 95% rename from apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentRow.tsx rename to packages/ui/src/features/settings/sections/environments/EnvironmentRow.tsx index c94db0b675..adb410deeb 100644 --- a/apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentRow.tsx +++ b/packages/ui/src/features/settings/sections/environments/EnvironmentRow.tsx @@ -1,7 +1,7 @@ import { type Environment, slugifyEnvironmentName, -} from "@main/services/environment/schemas"; +} from "@posthog/workspace-client/environment"; import { Button, Flex, Text } from "@radix-ui/themes"; interface EnvironmentRowProps { diff --git a/apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentsSettings.tsx b/packages/ui/src/features/settings/sections/environments/EnvironmentsSettings.tsx similarity index 96% rename from apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentsSettings.tsx rename to packages/ui/src/features/settings/sections/environments/EnvironmentsSettings.tsx index 74d084aaeb..f031ccd5c6 100644 --- a/apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentsSettings.tsx +++ b/packages/ui/src/features/settings/sections/environments/EnvironmentsSettings.tsx @@ -1,6 +1,6 @@ -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { Cloud, HardDrives } from "@phosphor-icons/react"; import { Flex, SegmentedControl, Text } from "@radix-ui/themes"; +import { useSettingsDialogStore } from "../../settingsDialogStore"; import { CloudEnvironmentsSettings } from "./CloudEnvironmentsSettings"; import { LocalEnvironmentsSettings } from "./LocalEnvironmentsSettings"; diff --git a/apps/code/src/renderer/features/settings/components/sections/environments/LocalEnvironmentsSettings.tsx b/packages/ui/src/features/settings/sections/environments/LocalEnvironmentsSettings.tsx similarity index 90% rename from apps/code/src/renderer/features/settings/components/sections/environments/LocalEnvironmentsSettings.tsx rename to packages/ui/src/features/settings/sections/environments/LocalEnvironmentsSettings.tsx index b272dc22c7..95924caca1 100644 --- a/apps/code/src/renderer/features/settings/components/sections/environments/LocalEnvironmentsSettings.tsx +++ b/packages/ui/src/features/settings/sections/environments/LocalEnvironmentsSettings.tsx @@ -1,11 +1,11 @@ -import { useFolders } from "@features/folders/hooks/useFolders"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import type { Environment } from "@main/services/environment/schemas"; -import type { RegisteredFolder } from "@main/services/folders/schemas"; +import type { Environment } from "@posthog/workspace-client/environment"; +import { useWorkspaceTRPC } from "@posthog/workspace-client/trpc"; import { Flex, Text } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc/client"; import { useQueries } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useState } from "react"; +import type { RegisteredFolder } from "../../../folders/types"; +import { useFolders } from "../../../folders/useFolders"; +import { useSettingsDialogStore } from "../../settingsDialogStore"; import { EnvironmentForm } from "./EnvironmentForm"; import { ProjectEnvironmentCard } from "./ProjectEnvironmentCard"; @@ -21,7 +21,7 @@ interface FormTarget { } export function LocalEnvironmentsSettings() { - const trpc = useTRPC(); + const trpc = useWorkspaceTRPC(); const { folders } = useFolders(); const [formTarget, setFormTarget] = useState<FormTarget | null>(null); diff --git a/apps/code/src/renderer/features/settings/components/sections/environments/ProjectEnvironmentCard.tsx b/packages/ui/src/features/settings/sections/environments/ProjectEnvironmentCard.tsx similarity index 94% rename from apps/code/src/renderer/features/settings/components/sections/environments/ProjectEnvironmentCard.tsx rename to packages/ui/src/features/settings/sections/environments/ProjectEnvironmentCard.tsx index dad8552ccc..a8c5b65949 100644 --- a/apps/code/src/renderer/features/settings/components/sections/environments/ProjectEnvironmentCard.tsx +++ b/packages/ui/src/features/settings/sections/environments/ProjectEnvironmentCard.tsx @@ -1,7 +1,7 @@ -import type { Environment } from "@main/services/environment/schemas"; -import type { RegisteredFolder } from "@main/services/folders/schemas"; import { Folder as FolderIcon, Plus } from "@phosphor-icons/react"; +import type { Environment } from "@posthog/workspace-client/environment"; import { Flex, IconButton, Text } from "@radix-ui/themes"; +import type { RegisteredFolder } from "../../../folders/types"; import { EnvironmentRow } from "./EnvironmentRow"; import type { ProjectEnvironments } from "./LocalEnvironmentsSettings"; diff --git a/apps/code/src/renderer/features/settings/hooks/useSandboxEnvironments.ts b/packages/ui/src/features/settings/sections/environments/useSandboxEnvironments.ts similarity index 85% rename from apps/code/src/renderer/features/settings/hooks/useSandboxEnvironments.ts rename to packages/ui/src/features/settings/sections/environments/useSandboxEnvironments.ts index 85c15d2b85..e5357bca3a 100644 --- a/apps/code/src/renderer/features/settings/hooks/useSandboxEnvironments.ts +++ b/packages/ui/src/features/settings/sections/environments/useSandboxEnvironments.ts @@ -1,8 +1,8 @@ -import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { SandboxEnvironmentInput } from "@shared/types"; +import type { SandboxEnvironmentInput } from "@posthog/shared/domain-types"; import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; +import { useAuthenticatedMutation } from "../../../../hooks/useAuthenticatedMutation"; +import { useAuthenticatedQuery } from "../../../../hooks/useAuthenticatedQuery"; +import { toast } from "../../../../primitives/toast"; const sandboxEnvKeys = { list: ["sandbox-environments", "list"] as const, diff --git a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeGroupSection.tsx b/packages/ui/src/features/settings/sections/worktrees/WorktreeGroupSection.tsx similarity index 96% rename from apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeGroupSection.tsx rename to packages/ui/src/features/settings/sections/worktrees/WorktreeGroupSection.tsx index b4c757cf61..2ebd9d1045 100644 --- a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeGroupSection.tsx +++ b/packages/ui/src/features/settings/sections/worktrees/WorktreeGroupSection.tsx @@ -1,5 +1,5 @@ +import type { Task } from "@posthog/shared/domain-types"; import { Flex, Text } from "@radix-ui/themes"; -import type { Task } from "@shared/types"; import type { WorktreeEntry } from "./WorktreeRow"; import { WorktreeRow } from "./WorktreeRow"; diff --git a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeRow.tsx b/packages/ui/src/features/settings/sections/worktrees/WorktreeRow.tsx similarity index 90% rename from apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeRow.tsx rename to packages/ui/src/features/settings/sections/worktrees/WorktreeRow.tsx index d252998f4d..ec6a413d90 100644 --- a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeRow.tsx +++ b/packages/ui/src/features/settings/sections/worktrees/WorktreeRow.tsx @@ -1,9 +1,9 @@ -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { Trash } from "@phosphor-icons/react"; +import type { Task } from "@posthog/shared/domain-types"; import { Button, Flex, Text } from "@radix-ui/themes"; -import { DotsCircleSpinner } from "@renderer/components/DotsCircleSpinner"; -import { useNavigationStore } from "@renderer/stores/navigationStore"; -import type { Task } from "@shared/types"; +import { DotsCircleSpinner } from "../../../../primitives/DotsCircleSpinner"; +import { useNavigationStore } from "../../../navigation/store"; +import { useSettingsDialogStore } from "../../settingsDialogStore"; import { WorktreeSize } from "./WorktreeSize"; export interface WorktreeEntry { diff --git a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeSize.tsx b/packages/ui/src/features/settings/sections/worktrees/WorktreeSize.tsx similarity index 91% rename from apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeSize.tsx rename to packages/ui/src/features/settings/sections/worktrees/WorktreeSize.tsx index 2eac86e170..816a99d4f6 100644 --- a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeSize.tsx +++ b/packages/ui/src/features/settings/sections/worktrees/WorktreeSize.tsx @@ -1,5 +1,5 @@ +import { useHostTRPC } from "@posthog/host-router/react"; import { Skeleton } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc"; import { useQuery } from "@tanstack/react-query"; function formatSize(bytes: number): string { @@ -18,7 +18,7 @@ interface WorktreeSizeProps { } export function WorktreeSize({ worktreePath }: WorktreeSizeProps) { - const trpc = useTRPC(); + const trpc = useHostTRPC(); const { data, isLoading } = useQuery( trpc.workspace.getWorktreeSize.queryOptions( { worktreePath }, diff --git a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreesSettings.tsx b/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx similarity index 61% rename from apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreesSettings.tsx rename to packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx index 6c1052fb10..fad3dfdda7 100644 --- a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreesSettings.tsx +++ b/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx @@ -1,26 +1,30 @@ -import { useFolders } from "@features/folders/hooks/useFolders"; -import { SettingRow } from "@features/settings/components/SettingRow"; -import { useSuspensionSettings } from "@features/suspension/hooks/useSuspensionSettings"; -import { useDeleteTask, useTasks } from "@features/tasks/hooks/useTasks"; +import { + buildTaskMap, + groupWorktrees, + parseWorktreeLimit, +} from "@posthog/core/settings/worktreeGrouping"; +import { deleteWorktree as runDeleteWorktree } from "@posthog/core/settings/worktreeMaintenanceService"; +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { Flex, Switch, Text, TextField } from "@radix-ui/themes"; -import { trpcClient, useTRPC } from "@renderer/trpc"; -import type { Task } from "@shared/types"; -import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; -import { toast } from "@utils/toast"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; -import type { WorktreeGroup } from "./WorktreeGroupSection"; +import { toast } from "../../../../primitives/toast"; +import { logger } from "../../../../workbench/logger"; +import { useFolders } from "../../../folders/useFolders"; +import { useSuspensionSettings } from "../../../suspension/useSuspensionSettings"; +import { useDeleteTask } from "../../../tasks/useTaskCrudMutations"; +import { useTasks } from "../../../tasks/useTasks"; +import { WORKSPACE_QUERY_KEY } from "../../../workspace/identifiers"; +import { SettingRow } from "../../SettingRow"; import { WorktreeGroupSection } from "./WorktreeGroupSection"; const log = logger.scope("worktrees-settings"); export function WorktreesSettings() { - const trpc = useTRPC(); const queryClient = useQueryClient(); + const trpc = useHostTRPC(); + const hostClient = useHostTRPCClient(); const { settings, updateSettings } = useSuspensionSettings(); - const deleteWorkspaceMutation = useMutation( - trpc.workspace.delete.mutationOptions(), - ); const { mutateAsync: deleteTask } = useDeleteTask(); const [deletingWorktrees, setDeletingWorktrees] = useState<Set<string>>( new Set(), @@ -30,46 +34,28 @@ export function WorktreesSettings() { const { data: tasks } = useTasks(); const worktreeQueries = useQueries({ - queries: folders.map((folder) => - trpc.workspace.listGitWorktrees.queryOptions( - { mainRepoPath: folder.path }, - { staleTime: 30_000 }, - ), - ), + queries: folders.map((folder) => ({ + queryKey: trpc.workspace.listGitWorktrees.queryKey({ + mainRepoPath: folder.path, + }), + queryFn: () => + hostClient.workspace.listGitWorktrees.query({ + mainRepoPath: folder.path, + }), + staleTime: 30_000, + })), }); - const worktreeGroups = useMemo(() => { - const groups: WorktreeGroup[] = []; - - for (let i = 0; i < folders.length; i++) { - const folder = folders[i]; - const query = worktreeQueries[i]; - - if (!query?.data || query.data.length === 0) continue; - - groups.push({ - folderPath: folder.path, - worktrees: query.data.map((wt) => ({ - worktreePath: wt.worktreePath, - head: wt.head, - branch: wt.branch, - taskIds: wt.taskIds, - })), - }); - } - - return groups.sort((a, b) => a.folderPath.localeCompare(b.folderPath)); - }, [folders, worktreeQueries]); + const worktreeGroups = useMemo( + () => + groupWorktrees( + folders, + worktreeQueries.map((q) => q?.data), + ), + [folders, worktreeQueries], + ); - const taskMap = useMemo(() => { - const map = new Map<string, Task>(); - if (tasks) { - for (const task of tasks) { - map.set(task.id, task); - } - } - return map; - }, [tasks]); + const taskMap = useMemo(() => buildTaskMap(tasks), [tasks]); const handleDeleteWorktree = useCallback( async ( @@ -78,44 +64,33 @@ export function WorktreesSettings() { existingTaskIds: string[], folderPath: string, ) => { - if (existingTaskIds.length > 0) { - const result = - await trpcClient.contextMenu.confirmDeleteWorktree.mutate({ - worktreePath, - linkedTaskCount: existingTaskIds.length, - }); - if (!result.confirmed) return; - } - setDeletingWorktrees((prev) => new Set(prev).add(worktreePath)); try { - if (allTaskIds.length > 0) { - for (const taskId of allTaskIds) { - await deleteWorkspaceMutation.mutateAsync({ - taskId, - mainRepoPath: folderPath, - }); - } - } else { - await trpcClient.workspace.deleteWorktree.mutate({ - worktreePath, - mainRepoPath: folderPath, - }); - } - - for (const taskId of existingTaskIds) { - await deleteTask(taskId); - } - - await Promise.all([ - queryClient.invalidateQueries(trpc.workspace.getAll.pathFilter()), - queryClient.invalidateQueries( - trpc.workspace.listGitWorktrees.queryFilter({ - mainRepoPath: folderPath, - }), - ), - ]); + await runDeleteWorktree( + { + confirmDeleteWorktree: (params) => + hostClient.contextMenu.confirmDeleteWorktree.mutate(params), + deleteWorkspace: (params) => + hostClient.workspace.delete.mutate(params), + deleteWorktree: (params) => + hostClient.workspace.deleteWorktree.mutate(params), + deleteTask: (taskId) => deleteTask(taskId), + invalidate: async (path) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: WORKSPACE_QUERY_KEY, + }), + queryClient.invalidateQueries( + trpc.workspace.listGitWorktrees.queryFilter({ + mainRepoPath: path, + }), + ), + ]); + }, + }, + { worktreePath, allTaskIds, existingTaskIds, folderPath }, + ); } catch (error) { log.error("Failed to delete worktree:", error); } finally { @@ -126,7 +101,7 @@ export function WorktreesSettings() { }); } }, - [deleteWorkspaceMutation, deleteTask, queryClient, trpc], + [hostClient, trpc, deleteTask, queryClient], ); const commitNumericField = useCallback( @@ -138,12 +113,12 @@ export function WorktreesSettings() { fallback: number, ) => { const input = e.currentTarget; - const val = Number.parseInt(input.value, 10); + const val = parseWorktreeLimit(input.value); const labels: Record<string, string> = { maxActiveWorktrees: "Max active worktrees", autoSuspendAfterDays: "Auto-suspend days", }; - if (val >= 1) { + if (val !== null) { updateSettings({ [field]: val }); toast.success(`${labels[field]} updated to ${val}`); } else { diff --git a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.test.ts b/packages/ui/src/features/settings/settingsDialogStore.test.ts similarity index 100% rename from apps/code/src/renderer/features/settings/stores/settingsDialogStore.test.ts rename to packages/ui/src/features/settings/settingsDialogStore.test.ts diff --git a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts b/packages/ui/src/features/settings/settingsDialogStore.ts similarity index 100% rename from apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts rename to packages/ui/src/features/settings/settingsDialogStore.ts diff --git a/apps/code/src/renderer/features/settings/stores/settingsStore.test.ts b/packages/ui/src/features/settings/settingsStore.test.ts similarity index 91% rename from apps/code/src/renderer/features/settings/stores/settingsStore.test.ts rename to packages/ui/src/features/settings/settingsStore.test.ts index 3ccaa293f1..a220df98cd 100644 --- a/apps/code/src/renderer/features/settings/stores/settingsStore.test.ts +++ b/packages/ui/src/features/settings/settingsStore.test.ts @@ -6,14 +6,8 @@ const { getItem, setItem, removeItem } = vi.hoisted(() => ({ removeItem: vi.fn(), })); -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - secureStore: { - getItem: { query: getItem }, - setItem: { query: setItem }, - removeItem: { query: removeItem }, - }, - }, +vi.mock("@posthog/di/container", () => ({ + resolveService: () => ({ getItem, setItem, removeItem }), })); import { useSettingsStore } from "./settingsStore"; @@ -41,7 +35,7 @@ describe("feature settingsStore cloud selections", () => { }); const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; - const persisted = JSON.parse(lastCall[0].value); + const persisted = JSON.parse(lastCall[1]); expect(persisted.state.lastUsedCloudRepository).toBe("posthog/posthog"); }); @@ -112,7 +106,7 @@ describe("feature settingsStore terminal font", () => { }); const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; - const persisted = JSON.parse(lastCall[0].value); + const persisted = JSON.parse(lastCall[1]); expect(persisted.state.terminalFont).toBe("custom"); expect(persisted.state.terminalCustomFontFamily).toBe("Fira Code"); diff --git a/apps/code/src/renderer/features/settings/stores/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts similarity index 98% rename from apps/code/src/renderer/features/settings/stores/settingsStore.ts rename to packages/ui/src/features/settings/settingsStore.ts index 69d626fcc1..d0894b82ae 100644 --- a/apps/code/src/renderer/features/settings/stores/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -1,6 +1,5 @@ -import type { WorkspaceMode } from "@main/services/workspace/schemas"; -import type { ExecutionMode } from "@shared/types"; -import { electronStorage } from "@utils/electronStorage"; +import type { ExecutionMode, WorkspaceMode } from "@posthog/shared"; +import { electronStorage } from "@posthog/ui/workbench/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; diff --git a/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailDialog.tsx b/packages/ui/src/features/setup/DiscoveredTaskDetailDialog.tsx similarity index 86% rename from apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailDialog.tsx rename to packages/ui/src/features/setup/DiscoveredTaskDetailDialog.tsx index 75ce6731e4..07653bfdab 100644 --- a/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailDialog.tsx +++ b/packages/ui/src/features/setup/DiscoveredTaskDetailDialog.tsx @@ -1,19 +1,7 @@ -import { Badge } from "@components/ui/Badge"; -import { Button } from "@components/ui/Button"; -import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; -import { useFolders } from "@features/folders/hooks/useFolders"; -import { - isTaskForRepo, - useSetupStore, -} from "@features/setup/stores/setupStore"; -import type { DiscoveredTask } from "@features/setup/types"; -import { buildDiscoveredTaskPrompt } from "@features/setup/utils/buildDiscoveredTaskPrompt"; -import { - CATEGORY_CONFIG, - FALLBACK_CATEGORY_CONFIG, -} from "@features/setup/utils/categoryConfig"; -import { useDetectedCloudRepository } from "@hooks/useDetectedCloudRepository"; import { PlusIcon, SparkleIcon } from "@phosphor-icons/react"; +import { buildDiscoveredTaskPrompt } from "@posthog/core/setup/buildDiscoveredTaskPrompt"; +import type { DiscoveredTask } from "@posthog/core/setup/types"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { Box, Dialog, @@ -22,10 +10,16 @@ import { Text, VisuallyHidden, } from "@radix-ui/themes"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useActiveRepoStore } from "@stores/activeRepoStore"; -import { useNavigationStore } from "@stores/navigationStore"; -import { track } from "@utils/analytics"; +import { Badge } from "../../primitives/Badge"; +import { Button } from "../../primitives/Button"; +import { useActiveRepoStore } from "../../workbench/activeRepoStore"; +import { track } from "../../workbench/analytics"; +import { MarkdownRenderer } from "../editor/components/MarkdownRenderer"; +import { useFolders } from "../folders/useFolders"; +import { useNavigationStore } from "../navigation/store"; +import { useDetectedCloudRepository } from "../repo-files/useDetectedCloudRepository"; +import { CATEGORY_CONFIG, FALLBACK_CATEGORY_CONFIG } from "./categoryConfig"; +import { isTaskForRepo, useSetupStore } from "./setupStore"; interface DiscoveredTaskDetailDialogProps { task: DiscoveredTask | null; diff --git a/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx b/packages/ui/src/features/setup/SetupScanFeed.tsx similarity index 98% rename from apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx rename to packages/ui/src/features/setup/SetupScanFeed.tsx index cbaa09464f..1b84f13ab6 100644 --- a/apps/code/src/renderer/features/setup/components/SetupScanFeed.tsx +++ b/packages/ui/src/features/setup/SetupScanFeed.tsx @@ -1,5 +1,3 @@ -import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; -import type { ActivityEntry } from "@features/setup/stores/setupStore"; import type { Icon } from "@phosphor-icons/react"; import { ArrowsClockwise, @@ -16,6 +14,8 @@ import { } from "@phosphor-icons/react"; import { Flex, Text } from "@radix-ui/themes"; import { AnimatePresence, motion } from "framer-motion"; +import { DotsCircleSpinner } from "../../primitives/DotsCircleSpinner"; +import type { ActivityEntry } from "./setupStore"; interface SetupScanFeedProps { label: string; diff --git a/apps/code/src/renderer/features/setup/utils/categoryConfig.ts b/packages/ui/src/features/setup/categoryConfig.ts similarity index 96% rename from apps/code/src/renderer/features/setup/utils/categoryConfig.ts rename to packages/ui/src/features/setup/categoryConfig.ts index fe95a496c1..3cce2e5f68 100644 --- a/apps/code/src/renderer/features/setup/utils/categoryConfig.ts +++ b/packages/ui/src/features/setup/categoryConfig.ts @@ -1,4 +1,3 @@ -import type { DiscoveredTask } from "@features/setup/types"; import type { Icon } from "@phosphor-icons/react"; import { Bug, @@ -14,6 +13,7 @@ import { Warning, Wrench, } from "@phosphor-icons/react"; +import type { DiscoveredTask } from "@posthog/core/setup/types"; export interface CategoryConfig { icon: Icon; diff --git a/packages/ui/src/features/setup/setup.module.ts b/packages/ui/src/features/setup/setup.module.ts new file mode 100644 index 0000000000..3da84403b7 --- /dev/null +++ b/packages/ui/src/features/setup/setup.module.ts @@ -0,0 +1,7 @@ +import { SETUP_RUN_SERVICE } from "@posthog/core/setup/identifiers"; +import { ContainerModule } from "inversify"; +import { SetupRunServiceImpl } from "./setupRunServiceImpl"; + +export const setupUiModule = new ContainerModule(({ bind }) => { + bind(SETUP_RUN_SERVICE).to(SetupRunServiceImpl).inSingletonScope(); +}); diff --git a/packages/ui/src/features/setup/setupRunServiceImpl.ts b/packages/ui/src/features/setup/setupRunServiceImpl.ts new file mode 100644 index 0000000000..62554a6459 --- /dev/null +++ b/packages/ui/src/features/setup/setupRunServiceImpl.ts @@ -0,0 +1,206 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import type { + DiscoveryFailureReason, + DiscoverySignalSource, + ISetupRunService, +} from "@posthog/core/setup/identifiers"; +import type { StaleFlagPayload } from "@posthog/core/setup/suggestions"; +import type { DiscoveredTask } from "@posthog/core/setup/types"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { + EXPERIMENT_SUGGESTIONS_FLAG, + getCloudUrlFromRegion, +} from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { + isTerminalStatus, + type TaskRunStatus, +} from "@posthog/shared/domain-types"; +import { injectable } from "inversify"; +import { captureException, track } from "../../workbench/analytics"; +import { createAuthenticatedClient } from "../auth/authClientImperative"; +import { fetchAuthState } from "../auth/authQueries"; +import { FEATURE_FLAGS, type FeatureFlags } from "../feature-flags/identifiers"; + +/** + * Renderer adapter for the setup discovery/enrichment orchestration. Wraps the + * host tRPC client (agent/enrichment), the authenticated PostHog API client + * (task runs), analytics, and build/env flags. Holds the authenticated client + * created at getDiscoveryContext() time for the duration of the (one-at-a-time) + * run. Stays host-agnostic: no electron, no @renderer, host capabilities flow + * through resolveService. + */ +@injectable() +export class SetupRunServiceImpl implements ISetupRunService { + private client: PostHogAPIClient | null = null; + + private hostClient(): HostTrpcClient { + return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT); + } + + async getDiscoveryContext(): Promise<{ + apiHost: string | null; + projectId: number | null; + authed: boolean; + }> { + const authState = await fetchAuthState(); + const apiHost = authState.cloudRegion + ? getCloudUrlFromRegion(authState.cloudRegion) + : null; + this.client = createAuthenticatedClient(authState); + return { + apiHost, + projectId: authState.projectId, + authed: this.client !== null, + }; + } + + private requireClient(): PostHogAPIClient { + if (!this.client) { + throw new Error("Setup discovery: no authenticated client"); + } + return this.client; + } + + async createDiscoveryTask(input: { + title: string; + description: string; + jsonSchema: Record<string, unknown>; + }): Promise<{ id: string }> { + const task = await this.requireClient().createTask({ + title: input.title, + description: input.description, + json_schema: input.jsonSchema, + }); + return { id: (task as { id: string }).id }; + } + + async createTaskRun(taskId: string): Promise<{ id: string | null }> { + const run = await this.requireClient().createTaskRun(taskId); + return { id: run?.id ?? null }; + } + + async getTaskRun( + taskId: string, + taskRunId: string, + ): Promise<{ status: string; tasks: DiscoveredTask[] | null }> { + const run = await this.requireClient().getTaskRun(taskId, taskRunId); + const output = run.output as { tasks?: DiscoveredTask[] } | null; + return { status: run.status, tasks: output?.tasks ?? null }; + } + + isTerminalStatus(status: string): boolean { + return isTerminalStatus(status as TaskRunStatus); + } + + async startAgent(input: { + taskId: string; + taskRunId: string; + repoPath: string; + apiHost: string; + projectId: number; + jsonSchema: Record<string, unknown>; + }): Promise<void> { + await this.hostClient().agent.start.mutate({ + taskId: input.taskId, + taskRunId: input.taskRunId, + repoPath: input.repoPath, + apiHost: input.apiHost, + projectId: input.projectId, + permissionMode: "bypassPermissions", + jsonSchema: input.jsonSchema, + }); + } + + async sendPrompt(input: { + sessionId: string; + promptText: string; + }): Promise<void> { + await this.hostClient().agent.prompt.mutate({ + sessionId: input.sessionId, + prompt: [{ type: "text", text: input.promptText }], + }); + } + + subscribeSessionEvents( + input: { taskRunId: string }, + handlers: { + onData: (payload: unknown) => void; + onError: (err: unknown) => void; + }, + ): { unsubscribe: () => void } { + return this.hostClient().agent.onSessionEvent.subscribe( + { taskRunId: input.taskRunId }, + { onData: handlers.onData, onError: handlers.onError }, + ); + } + + async detectPosthogInstallState( + repoPath: string, + ): Promise<"initialized" | "not_installed" | "installed_no_init"> { + return this.hostClient().enrichment.detectPosthogInstallState.query({ + repoPath, + }); + } + + async findStaleFlagSuggestions( + repoPath: string, + ): Promise<StaleFlagPayload[]> { + return this.hostClient().enrichment.findStaleFlagSuggestions.query({ + repoPath, + }); + } + + includeExperiments(): boolean { + return ( + resolveService<FeatureFlags>(FEATURE_FLAGS).isEnabled( + EXPERIMENT_SUGGESTIONS_FLAG, + ) || import.meta.env.DEV + ); + } + + trackDiscoveryStarted(p: { taskId: string; taskRunId: string }): void { + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED, { + discovery_task_id: p.taskId, + discovery_task_run_id: p.taskRunId, + }); + } + + trackDiscoveryCompleted(p: { + taskId: string; + taskRunId: string; + taskCount: number; + durationSeconds: number; + signalSource: DiscoverySignalSource; + }): void { + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_COMPLETED, { + discovery_task_id: p.taskId, + discovery_task_run_id: p.taskRunId, + task_count: p.taskCount, + duration_seconds: p.durationSeconds, + signal_source: p.signalSource, + }); + } + + trackDiscoveryFailed(p: { + taskId?: string; + taskRunId?: string; + reason: DiscoveryFailureReason; + errorMessage?: string; + }): void { + track(ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED, { + discovery_task_id: p.taskId, + discovery_task_run_id: p.taskRunId, + reason: p.reason, + error_message: p.errorMessage, + }); + } + + reportError(error: Error, scope: string): void { + captureException(error, { scope }); + } +} diff --git a/packages/ui/src/features/setup/setupStore.ts b/packages/ui/src/features/setup/setupStore.ts new file mode 100644 index 0000000000..d8bf16611f --- /dev/null +++ b/packages/ui/src/features/setup/setupStore.ts @@ -0,0 +1,194 @@ +import type { + ActivityEntry, + SetupStoreState, +} from "@posthog/core/setup/setupState"; +import { + DEFAULT_DISCOVERY, + dropAgentTasksForRepo, + EMPTY_FEED, + INITIAL_SETUP_STATE, + isTaskForRepo, + migrateSetupState, + partializeSetupState, + pushEntry, + updateDiscovery, + updateEnricher, +} from "@posthog/core/setup/setupState"; +import type { DiscoveredTask } from "@posthog/core/setup/types"; +import { logger } from "@posthog/ui/workbench/logger"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type { + ActivityEntry, + AgentFeedState, + RepoDiscoveryState, + RepoEnricherState, + SetupStoreState, +} from "@posthog/core/setup/setupState"; +export { + isTaskForRepo, + selectRepoDiscovery, + selectRepoEnricher, +} from "@posthog/core/setup/setupState"; + +const log = logger.scope("setup-store"); + +interface SetupStoreActions { + startDiscovery: (repoPath: string, taskId: string, taskRunId: string) => void; + completeDiscovery: (repoPath: string, tasks: DiscoveredTask[]) => void; + failDiscovery: (repoPath: string, message?: string) => void; + resetDiscovery: (repoPath: string) => void; + startEnrichment: (repoPath: string) => void; + completeEnrichment: (repoPath: string) => void; + failEnrichment: (repoPath: string) => void; + removeDiscoveredTask: (taskId: string, repoPath: string | null) => void; + addEnricherSuggestionIfMissing: (task: DiscoveredTask) => void; + pushDiscoveryActivity: (repoPath: string, entry: ActivityEntry) => void; + resetSetup: () => void; +} + +type SetupStore = SetupStoreState & SetupStoreActions; + +export const useSetupStore = create<SetupStore>()( + persist( + (set) => ({ + ...INITIAL_SETUP_STATE, + + startDiscovery: (repoPath, taskId, taskRunId) => { + log.info("Discovery started", { repoPath, taskId, taskRunId }); + set((state) => ({ + discoveredTasks: dropAgentTasksForRepo( + state.discoveredTasks, + repoPath, + ), + discoveryByRepo: updateDiscovery(state, repoPath, { + status: "running", + taskId, + taskRunId, + feed: EMPTY_FEED, + error: null, + }), + })); + }, + + completeDiscovery: (repoPath, tasks) => { + log.info("Discovery completed", { + repoPath, + taskCount: tasks.length, + }); + set((state) => { + const cleaned = dropAgentTasksForRepo( + state.discoveredTasks, + repoPath, + ); + const agent = tasks.map((t) => ({ + ...t, + source: "agent" as const, + repoPath: t.repoPath ?? repoPath, + })); + return { + discoveredTasks: [...cleaned, ...agent], + discoveryByRepo: updateDiscovery(state, repoPath, { + status: "done", + error: null, + }), + }; + }); + }, + + failDiscovery: (repoPath, message) => { + log.warn("Discovery failed", { repoPath, message }); + set((state) => ({ + discoveryByRepo: updateDiscovery(state, repoPath, { + status: "error", + error: message ?? null, + }), + })); + }, + + resetDiscovery: (repoPath) => { + log.info("Discovery reset", { repoPath }); + set((state) => ({ + discoveredTasks: dropAgentTasksForRepo( + state.discoveredTasks, + repoPath, + ), + discoveryByRepo: updateDiscovery(state, repoPath, { + status: "idle", + taskId: null, + taskRunId: null, + feed: EMPTY_FEED, + error: null, + }), + })); + }, + + startEnrichment: (repoPath) => { + set((state) => ({ + enricherByRepo: updateEnricher(state, repoPath, { + status: "running", + }), + })); + }, + + completeEnrichment: (repoPath) => { + set((state) => ({ + enricherByRepo: updateEnricher(state, repoPath, { status: "done" }), + })); + }, + + failEnrichment: (repoPath) => { + set((state) => ({ + enricherByRepo: updateEnricher(state, repoPath, { status: "error" }), + })); + }, + + removeDiscoveredTask: (taskId, repoPath) => { + set((state) => ({ + discoveredTasks: state.discoveredTasks.filter( + (t) => !(t.id === taskId && isTaskForRepo(t, repoPath)), + ), + })); + }, + + addEnricherSuggestionIfMissing: (task) => { + set((state) => { + const repoTask = { ...task, source: "enricher" as const }; + if ( + state.discoveredTasks.some( + (t) => t.id === repoTask.id && t.repoPath === repoTask.repoPath, + ) + ) { + return state; + } + return { + discoveredTasks: [repoTask, ...state.discoveredTasks], + }; + }); + }, + + pushDiscoveryActivity: (repoPath, entry) => { + set((state) => { + const prev = state.discoveryByRepo[repoPath] ?? DEFAULT_DISCOVERY; + return { + discoveryByRepo: updateDiscovery(state, repoPath, { + feed: pushEntry(prev.feed, entry), + }), + }; + }); + }, + + resetSetup: () => { + log.info("Setup state reset"); + set({ ...INITIAL_SETUP_STATE }); + }, + }), + { + name: "setup-store", + version: 2, + migrate: migrateSetupState, + partialize: (state) => partializeSetupState(state), + }, + ), +); diff --git a/packages/ui/src/features/setup/useSetupDiscovery.ts b/packages/ui/src/features/setup/useSetupDiscovery.ts new file mode 100644 index 0000000000..9108c04245 --- /dev/null +++ b/packages/ui/src/features/setup/useSetupDiscovery.ts @@ -0,0 +1,14 @@ +import { SetupRunService } from "@posthog/core/setup/setupRunService"; +import { useService } from "@posthog/di/react"; +import { useEffect } from "react"; +import { useActiveRepoStore } from "../../workbench/activeRepoStore"; + +export function useSetupDiscovery() { + const selectedDirectory = useActiveRepoStore((s) => s.path); + const service = useService(SetupRunService); + + useEffect(() => { + if (!selectedDirectory) return; + service.maybeStart(selectedDirectory); + }, [selectedDirectory, service]); +} diff --git a/apps/code/src/renderer/features/sidebar/components/DraggableFolder.tsx b/packages/ui/src/features/sidebar/components/DraggableFolder.tsx similarity index 100% rename from apps/code/src/renderer/features/sidebar/components/DraggableFolder.tsx rename to packages/ui/src/features/sidebar/components/DraggableFolder.tsx diff --git a/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx b/packages/ui/src/features/sidebar/components/MainSidebar.tsx similarity index 74% rename from apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx rename to packages/ui/src/features/sidebar/components/MainSidebar.tsx index 280f8c03bb..08a453433e 100644 --- a/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx +++ b/packages/ui/src/features/sidebar/components/MainSidebar.tsx @@ -1,10 +1,11 @@ -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { Sidebar } from "@posthog/ui/features/sidebar/components/Sidebar"; +import { SidebarContent } from "@posthog/ui/features/sidebar/components/SidebarContent"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { useTaskSelectionStore } from "@posthog/ui/features/sidebar/taskSelectionStore"; +import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; import { Box } from "@radix-ui/themes"; import { useEffect } from "react"; -import { useSidebarStore } from "../stores/sidebarStore"; -import { useTaskSelectionStore } from "../stores/taskSelectionStore"; -import { Sidebar, SidebarContent } from "./index"; function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx similarity index 92% rename from apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx rename to packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 7aaa897d27..8f763cb82d 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -1,11 +1,3 @@ -import { - useLogoutMutation, - useSelectProjectMutation, -} from "@features/auth/hooks/authMutations"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { CommandKeyHints } from "@features/command/components/CommandKeyHints"; -import { useProjects } from "@features/projects/hooks/useProjects"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { ArrowSquareOut, Check, @@ -45,11 +37,18 @@ import { ItemTitle, Kbd, } from "@posthog/quill"; +import { EXTERNAL_LINKS, getCloudUrlFromRegion } from "@posthog/shared"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { + useLogoutMutation, + useSelectProjectMutation, +} from "@posthog/ui/features/auth/useAuthMutations"; +import { CommandKeyHints } from "@posthog/ui/features/command/CommandKeyHints"; +import { useProjects } from "@posthog/ui/features/projects/useProjects"; +import { useSettingsDialogStore } from "@posthog/ui/features/settings/settingsDialogStore"; +import { isMac } from "@posthog/ui/utils/platform"; +import { openExternalUrl } from "@posthog/ui/workbench/openExternal"; import { Box } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { EXTERNAL_LINKS } from "@utils/links"; -import { isMac } from "@utils/platform"; import { ChevronRightIcon } from "lucide-react"; import { useState } from "react"; @@ -74,12 +73,10 @@ export function ProjectSwitcher() { setDialogOpen(false); }; - const handleCreateProject = async () => { + const handleCreateProject = () => { if (cloudRegion) { const cloudUrl = getCloudUrlFromRegion(cloudRegion); - await trpcClient.os.openExternal.mutate({ - url: `${cloudUrl}/organization/create-project`, - }); + openExternalUrl(`${cloudUrl}/organization/create-project`); } setPopoverOpen(false); }; @@ -101,15 +98,13 @@ export function ProjectSwitcher() { openSettings("shortcuts"); }; - const handleOpenExternal = async (url: string) => { - await trpcClient.os.openExternal.mutate({ url }); + const handleOpenExternal = (url: string) => { + openExternalUrl(url); setPopoverOpen(false); }; - const handleDiscord = async () => { - await trpcClient.os.openExternal.mutate({ - url: EXTERNAL_LINKS.discord, - }); + const handleDiscord = () => { + openExternalUrl(EXTERNAL_LINKS.discord); setPopoverOpen(false); }; diff --git a/apps/code/src/renderer/features/sidebar/components/Sidebar.tsx b/packages/ui/src/features/sidebar/components/Sidebar.tsx similarity index 81% rename from apps/code/src/renderer/features/sidebar/components/Sidebar.tsx rename to packages/ui/src/features/sidebar/components/Sidebar.tsx index c214ec81c8..7d1f2277cd 100644 --- a/apps/code/src/renderer/features/sidebar/components/Sidebar.tsx +++ b/packages/ui/src/features/sidebar/components/Sidebar.tsx @@ -1,6 +1,6 @@ -import { ResizableSidebar } from "@components/ResizableSidebar"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import type React from "react"; -import { useSidebarStore } from "../stores/sidebarStore"; export const Sidebar: React.FC<{ children: React.ReactNode }> = ({ children, diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx b/packages/ui/src/features/sidebar/components/SidebarContent.tsx similarity index 72% rename from apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx rename to packages/ui/src/features/sidebar/components/SidebarContent.tsx index 81dc03740c..7f4d031085 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarContent.tsx @@ -1,12 +1,12 @@ -import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; -import { SidebarUsageBar } from "@features/billing/components/SidebarUsageBar"; import { ArchiveIcon } from "@phosphor-icons/react"; +import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { SidebarUsageBar } from "@posthog/ui/features/billing/SidebarUsageBar"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/ProjectSwitcher"; +import { SidebarMenu } from "@posthog/ui/features/sidebar/components/SidebarMenu"; +import { UpdateBanner } from "@posthog/ui/features/sidebar/components/UpdateBanner"; import { Box, Flex } from "@radix-ui/themes"; -import { useNavigationStore } from "@stores/navigationStore"; import type React from "react"; -import { ProjectSwitcher } from "./ProjectSwitcher"; -import { SidebarMenu } from "./SidebarMenu"; -import { UpdateBanner } from "./UpdateBanner"; export const SidebarContent: React.FC = () => { const archivedTaskIds = useArchivedTaskIds(); diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx b/packages/ui/src/features/sidebar/components/SidebarItem.tsx similarity index 97% rename from apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx rename to packages/ui/src/features/sidebar/components/SidebarItem.tsx index a9785c51d4..8b8f6f93e0 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarItem.tsx @@ -6,8 +6,8 @@ import { TooltipProvider, TooltipTrigger, } from "@posthog/quill"; +import type { SidebarItemAction } from "@posthog/ui/features/sidebar/types"; import { useCallback } from "react"; -import type { SidebarItemAction } from "../types"; const INDENT_SIZE = 8; diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/packages/ui/src/features/sidebar/components/SidebarMenu.tsx similarity index 74% rename from apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx rename to packages/ui/src/features/sidebar/components/SidebarMenu.tsx index 89ff09e1c4..d7dc501ff4 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarMenu.tsx @@ -1,41 +1,52 @@ -import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; -import { useCommandCenterStore } from "@features/command-center/stores/commandCenterStore"; -import { useInboxReports } from "@features/inbox/hooks/useInboxReports"; -import { isReportUpForReview } from "@features/inbox/utils/filterReports"; +import { isReportUpForReview } from "@posthog/core/inbox/reportFilters"; import { - INBOX_PIPELINE_STATUS_FILTER, - INBOX_REFETCH_INTERVAL_MS, -} from "@features/inbox/utils/inboxConstants"; + computeEffectiveBulkIds, + computeOrderedVisibleTaskIds, + computePriorTaskIds, + formatArchiveResult, +} from "@posthog/core/sidebar/selection"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { ScrollArea, Separator } from "@posthog/quill"; +import type { Task } from "@posthog/shared/domain-types"; import { archiveTasksImperative, + useArchiveCacheKeys, useArchiveTask, -} from "@features/tasks/hooks/useArchiveTask"; -import { useRenameTask, useTasks } from "@features/tasks/hooks/useTasks"; -import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; -import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; -import { ScrollArea, Separator } from "@posthog/quill"; +} from "@posthog/ui/features/archive/useArchiveTask"; +import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; +import { useInboxReports } from "@posthog/ui/features/inbox/hooks/useInboxReports"; +import { + INBOX_PIPELINE_STATUS_FILTER, + INBOX_REFETCH_INTERVAL_MS, +} from "@posthog/ui/features/inbox/utils/inboxConstants"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { CommandCenterItem } from "@posthog/ui/features/sidebar/components/items/CommandCenterItem"; +import { + InboxItem, + NewTaskItem, +} from "@posthog/ui/features/sidebar/components/items/HomeItem"; +import { McpServersItem } from "@posthog/ui/features/sidebar/components/items/McpServersItem"; +import { SearchItem } from "@posthog/ui/features/sidebar/components/items/SearchItem"; +import { SkillsItem } from "@posthog/ui/features/sidebar/components/items/SkillsItem"; +import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; +import { TaskListView } from "@posthog/ui/features/sidebar/components/TaskListView"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { useTaskSelectionStore } from "@posthog/ui/features/sidebar/taskSelectionStore"; +import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks"; +import { useSidebarData } from "@posthog/ui/features/sidebar/useSidebarData"; +import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; +import { useTaskContextMenu } from "@posthog/ui/features/tasks/useTaskContextMenu"; +import { useRenameTask } from "@posthog/ui/features/tasks/useTaskMutations"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; +import { DotsCircleSpinner } from "@posthog/ui/primitives/DotsCircleSpinner"; +import { toast } from "@posthog/ui/primitives/toast"; +import { useCommandMenuStore } from "@posthog/ui/workbench/commandMenuStore"; +import { logger } from "@posthog/ui/workbench/logger"; +import { useRendererWindowFocusStore } from "@posthog/ui/workbench/rendererWindowFocusStore"; import { Box, Flex } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; -import { useCommandMenuStore } from "@stores/commandMenuStore"; -import { useNavigationStore } from "@stores/navigationStore"; -import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; import { useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; -import { toast } from "@utils/toast"; import { memo, useCallback, useEffect, useMemo, useRef } from "react"; -import { usePinnedTasks } from "../hooks/usePinnedTasks"; -import { useSidebarData } from "../hooks/useSidebarData"; -import { useTaskViewed } from "../hooks/useTaskViewed"; -import { useSidebarStore } from "../stores/sidebarStore"; -import { useTaskSelectionStore } from "../stores/taskSelectionStore"; -import { CommandCenterItem } from "./items/CommandCenterItem"; -import { InboxItem, NewTaskItem } from "./items/HomeItem"; -import { McpServersItem } from "./items/McpServersItem"; -import { SearchItem } from "./items/SearchItem"; -import { SkillsItem } from "./items/SkillsItem"; -import { SidebarItem } from "./SidebarItem"; -import { TaskListView } from "./TaskListView"; const log = logger.scope("sidebar-menu"); @@ -59,9 +70,11 @@ function SidebarMenuComponent() { const { data: workspaces = {} } = useWorkspaces(); const { markAsViewed } = useTaskViewed(); + const hostClient = useHostTRPCClient(); const { showContextMenu, editingTaskId, setEditingTaskId } = useTaskContextMenu(); const { archiveTask } = useArchiveTask(); + const archiveCacheKeys = useArchiveCacheKeys(); const { renameTask } = useRenameTask(); const { togglePin } = usePinnedTasks(); @@ -163,24 +176,15 @@ function SidebarMenuComponent() { // index for shift-click range selection so it matches what the user sees — // in by-project mode the chronological flat order would span across project // groups and pull in unrelated tasks. - const orderedVisibleTaskIds = useMemo(() => { - const ids: string[] = sidebarData.pinnedTasks.map((t) => t.id); - if (organizeMode === "by-project") { - for (const group of sidebarData.groupedTasks) { - if (collapsedSections.has(group.id)) continue; - for (const t of group.tasks) ids.push(t.id); - } - } else { - for (const t of sidebarData.flatTasks) ids.push(t.id); - } - return ids; - }, [ - sidebarData.pinnedTasks, - sidebarData.flatTasks, - sidebarData.groupedTasks, - organizeMode, - collapsedSections, - ]); + const orderedVisibleTaskIds = useMemo( + () => + computeOrderedVisibleTaskIds( + sidebarData, + organizeMode, + collapsedSections, + ), + [sidebarData, organizeMode, collapsedSections], + ); useEffect(() => { pruneSelection(allSidebarTaskIds); @@ -189,12 +193,10 @@ function SidebarMenuComponent() { // The active (routed) task is implicitly part of any bulk selection — the // user expects to see and act on it together with cmd/shift-clicked tasks. const activeTaskId = sidebarData.activeTaskId; - const effectiveBulkIds = useMemo(() => { - if (selectedTaskIds.length === 0) return []; - if (!activeTaskId) return selectedTaskIds; - if (selectedTaskIds.includes(activeTaskId)) return selectedTaskIds; - return [activeTaskId, ...selectedTaskIds]; - }, [activeTaskId, selectedTaskIds]); + const effectiveBulkIds = useMemo( + () => computeEffectiveBulkIds(selectedTaskIds, activeTaskId), + [activeTaskId, selectedTaskIds], + ); const handleTaskClick = (taskId: string, e: React.MouseEvent) => { if (e.shiftKey) { @@ -221,29 +223,29 @@ function SidebarMenuComponent() { e.stopPropagation(); try { const result = - await trpcClient.contextMenu.showBulkTaskContextMenu.mutate({ + await hostClient.contextMenu.showBulkTaskContextMenu.mutate({ taskCount: taskIds.length, }); if (!result.action) return; if (result.action.type === "archive") { - const { archived, failed } = await archiveTasksImperative( + const outcome = await archiveTasksImperative( taskIds, queryClient, + archiveCacheKeys, ); clearSelection(); - if (failed === 0) { - toast.success( - `${archived} ${archived === 1 ? "task" : "tasks"} archived`, - ); + const { kind, message } = formatArchiveResult(outcome); + if (kind === "success") { + toast.success(message); } else { - toast.error(`${archived} archived, ${failed} failed`); + toast.error(message); } } } catch (error) { log.error("Failed to show bulk context menu", error); } }, - [queryClient, clearSelection], + [queryClient, clearSelection, hostClient, archiveCacheKeys], ); const handleTaskContextMenu = ( @@ -302,33 +304,32 @@ function SidebarMenuComponent() { const handleArchivePrior = useCallback( async (taskId: string) => { const allVisible = [...sidebarData.pinnedTasks, ...sidebarData.flatTasks]; - const clickedTask = allVisible.find((t) => t.id === taskId); - if (!clickedTask) return; - - const threshold = clickedTask.createdAt; - const priorTaskIds = allVisible - .filter((t) => t.id !== taskId && t.createdAt < threshold) - .map((t) => t.id); + const priorTaskIds = computePriorTaskIds(allVisible, taskId); if (priorTaskIds.length === 0) { toast.info("No older tasks to archive"); return; } - const { archived, failed } = await archiveTasksImperative( + const outcome = await archiveTasksImperative( priorTaskIds, queryClient, + archiveCacheKeys, ); - if (failed === 0) { - toast.success( - `${archived} ${archived === 1 ? "task" : "tasks"} archived`, - ); + const { kind, message } = formatArchiveResult(outcome); + if (kind === "success") { + toast.success(message); } else { - toast.error(`${archived} archived, ${failed} failed`); + toast.error(message); } }, - [sidebarData.pinnedTasks, sidebarData.flatTasks, queryClient], + [ + sidebarData.pinnedTasks, + sidebarData.flatTasks, + queryClient, + archiveCacheKeys, + ], ); const handleTaskDoubleClick = useCallback( (taskId: string) => { diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx b/packages/ui/src/features/sidebar/components/SidebarSection.tsx similarity index 98% rename from apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx rename to packages/ui/src/features/sidebar/components/SidebarSection.tsx index c0efbb7292..2c69285b71 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarSection.tsx @@ -1,6 +1,6 @@ -import { Tooltip } from "@components/ui/Tooltip"; import { CaretDownIcon, CaretRightIcon, Plus } from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import * as Collapsible from "@radix-ui/react-collapsible"; import { useState } from "react"; diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarTrigger.tsx b/packages/ui/src/features/sidebar/components/SidebarTrigger.tsx similarity index 76% rename from apps/code/src/renderer/features/sidebar/components/SidebarTrigger.tsx rename to packages/ui/src/features/sidebar/components/SidebarTrigger.tsx index 46e1062453..e628cd952a 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarTrigger.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarTrigger.tsx @@ -1,12 +1,12 @@ -import { Tooltip } from "@components/ui/Tooltip"; import { SidebarSimpleIcon } from "@phosphor-icons/react"; -import { IconButton } from "@radix-ui/themes"; import { formatHotkey, SHORTCUTS, -} from "@renderer/constants/keyboard-shortcuts"; +} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { IconButton } from "@radix-ui/themes"; import type React from "react"; -import { useSidebarStore } from "../stores/sidebarStore"; export const SidebarTrigger: React.FC = () => { const toggle = useSidebarStore((state) => state.toggle); diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/packages/ui/src/features/sidebar/components/TaskListView.tsx similarity index 92% rename from apps/code/src/renderer/features/sidebar/components/TaskListView.tsx rename to packages/ui/src/features/sidebar/components/TaskListView.tsx index 9a3b17f17a..c532986066 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/packages/ui/src/features/sidebar/components/TaskListView.tsx @@ -1,13 +1,16 @@ import { PointerSensor } from "@dnd-kit/dom"; import type { DragDropEvents } from "@dnd-kit/react"; import { DragDropProvider } from "@dnd-kit/react"; -import { useFolders } from "@features/folders/hooks/useFolders"; -import { useMeQuery } from "@hooks/useMeQuery"; import { FunnelSimple as FunnelSimpleIcon, GitBranch, MagnifyingGlass, } from "@phosphor-icons/react"; +import { groupTasksByRelativeDate } from "@posthog/core/sidebar/groupTasks"; +import type { + TaskData, + TaskGroup, +} from "@posthog/core/sidebar/sidebarData.types"; import { Button, DropdownMenu, @@ -18,21 +21,21 @@ import { DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; +import { normalizeRepoKey } from "@posthog/shared"; +import { builderHog } from "@posthog/ui/assets/hedgehogs"; +import { useMeQuery } from "@posthog/ui/features/auth/useMeQuery"; +import { useFolders } from "@posthog/ui/features/folders/useFolders"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { DraggableFolder } from "@posthog/ui/features/sidebar/components/DraggableFolder"; +import { TaskItem } from "@posthog/ui/features/sidebar/components/items/TaskItem"; +import { SidebarSection } from "@posthog/ui/features/sidebar/components/SidebarSection"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { useTaskPrStatus } from "@posthog/ui/features/sidebar/useTaskPrStatus"; +import { useWorkspace } from "@posthog/ui/features/workspace/useWorkspace"; +import { useCommandMenuStore } from "@posthog/ui/workbench/commandMenuStore"; import { Flex, Text } from "@radix-ui/themes"; -import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; -import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; -import { normalizeRepoKey } from "@shared/utils/repo"; -import { useCommandMenuStore } from "@stores/commandMenuStore"; -import { useNavigationStore } from "@stores/navigationStore"; -import { getRelativeDateGroup } from "@utils/time"; import { motion } from "framer-motion"; import { Fragment, useCallback, useEffect, useMemo } from "react"; -import type { TaskData, TaskGroup } from "../hooks/useSidebarData"; -import { useTaskPrStatus } from "../hooks/useTaskPrStatus"; -import { useSidebarStore } from "../stores/sidebarStore"; -import { DraggableFolder } from "./DraggableFolder"; -import { TaskItem } from "./items/TaskItem"; -import { SidebarSection } from "./SidebarSection"; interface TaskListViewProps { pinnedTasks: TaskData[]; @@ -316,19 +319,10 @@ export function TaskListView({ const timestampKey: "lastActivityAt" | "createdAt" = sortMode === "updated" ? "lastActivityAt" : "createdAt"; - const dateGroupedTasks = useMemo(() => { - const groups: { label: string | null; tasks: TaskData[] }[] = []; - for (const task of flatTasks) { - const label = getRelativeDateGroup(task[timestampKey]); - const last = groups[groups.length - 1]; - if (last && last.label === label) { - last.tasks.push(task); - } else { - groups.push({ label, tasks: [task] }); - } - } - return groups; - }, [flatTasks, timestampKey]); + const dateGroupedTasks = useMemo( + () => groupTasksByRelativeDate(flatTasks, timestampKey), + [flatTasks, timestampKey], + ); return ( <Flex direction="column"> diff --git a/apps/code/src/renderer/features/sidebar/components/UpdateBanner.tsx b/packages/ui/src/features/sidebar/components/UpdateBanner.tsx similarity index 95% rename from apps/code/src/renderer/features/sidebar/components/UpdateBanner.tsx rename to packages/ui/src/features/sidebar/components/UpdateBanner.tsx index ac3cd68db2..b02cbe83c6 100644 --- a/apps/code/src/renderer/features/sidebar/components/UpdateBanner.tsx +++ b/packages/ui/src/features/sidebar/components/UpdateBanner.tsx @@ -1,6 +1,9 @@ import { ArrowsClockwise, Gift, Spinner } from "@phosphor-icons/react"; +import { + useInstallUpdate, + useUpdateView, +} from "@posthog/ui/features/updates/updateStore"; import { Box } from "@radix-ui/themes"; -import { useUpdateStore } from "@stores/updateStore"; import { AnimatePresence, motion } from "framer-motion"; interface UpdateBannerProps { @@ -8,10 +11,8 @@ interface UpdateBannerProps { } export function UpdateBanner({ variant = "sidebar" }: UpdateBannerProps) { - const status = useUpdateStore((s) => s.status); - const version = useUpdateStore((s) => s.version); - const isEnabled = useUpdateStore((s) => s.isEnabled); - const installUpdate = useUpdateStore((s) => s.installUpdate); + const { status, version, isEnabled } = useUpdateView(); + const installUpdate = useInstallUpdate(); const isVisible = isEnabled && diff --git a/apps/code/src/renderer/features/sidebar/components/items/CommandCenterItem.tsx b/packages/ui/src/features/sidebar/components/items/CommandCenterItem.tsx similarity index 100% rename from apps/code/src/renderer/features/sidebar/components/items/CommandCenterItem.tsx rename to packages/ui/src/features/sidebar/components/items/CommandCenterItem.tsx diff --git a/apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx b/packages/ui/src/features/sidebar/components/items/HomeItem.tsx similarity index 89% rename from apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx rename to packages/ui/src/features/sidebar/components/items/HomeItem.tsx index 648ce78d35..11659f225b 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/HomeItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/HomeItem.tsx @@ -1,9 +1,9 @@ -import { Tooltip } from "@components/ui/Tooltip"; import { EnvelopeSimple, Plus } from "@phosphor-icons/react"; import { Badge, type ButtonProps } from "@posthog/quill"; -import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; -import { useDraftStore } from "@renderer/features/message-editor/stores/draftStore"; -import { isContentEmpty } from "@renderer/features/message-editor/utils/content"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { isContentEmpty } from "@posthog/ui/features/message-editor/content"; +import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { SidebarItem } from "../SidebarItem"; import { SidebarKbdHint } from "./SidebarKbdHint"; diff --git a/apps/code/src/renderer/features/sidebar/components/items/McpServersItem.tsx b/packages/ui/src/features/sidebar/components/items/McpServersItem.tsx similarity index 100% rename from apps/code/src/renderer/features/sidebar/components/items/McpServersItem.tsx rename to packages/ui/src/features/sidebar/components/items/McpServersItem.tsx diff --git a/apps/code/src/renderer/features/sidebar/components/items/SearchItem.tsx b/packages/ui/src/features/sidebar/components/items/SearchItem.tsx similarity index 86% rename from apps/code/src/renderer/features/sidebar/components/items/SearchItem.tsx rename to packages/ui/src/features/sidebar/components/items/SearchItem.tsx index 99d68461b2..daa4b9cda4 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/SearchItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/SearchItem.tsx @@ -1,5 +1,5 @@ import { MagnifyingGlass } from "@phosphor-icons/react"; -import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; import { SidebarItem } from "../SidebarItem"; import { SidebarKbdHint } from "./SidebarKbdHint"; diff --git a/apps/code/src/renderer/features/sidebar/components/items/SidebarKbdHint.tsx b/packages/ui/src/features/sidebar/components/items/SidebarKbdHint.tsx similarity index 88% rename from apps/code/src/renderer/features/sidebar/components/items/SidebarKbdHint.tsx rename to packages/ui/src/features/sidebar/components/items/SidebarKbdHint.tsx index 3a751d2aed..d6152752ac 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/SidebarKbdHint.tsx +++ b/packages/ui/src/features/sidebar/components/items/SidebarKbdHint.tsx @@ -1,5 +1,5 @@ import { Kbd } from "@posthog/quill"; -import { formatHotkey } from "@renderer/constants/keyboard-shortcuts"; +import { formatHotkey } from "@posthog/ui/features/command/keyboard-shortcuts"; interface SidebarKbdHintProps { /** Raw shortcut string from SHORTCUTS, e.g. "mod+k". */ diff --git a/apps/code/src/renderer/features/sidebar/components/items/SkillsItem.tsx b/packages/ui/src/features/sidebar/components/items/SkillsItem.tsx similarity index 100% rename from apps/code/src/renderer/features/sidebar/components/items/SkillsItem.tsx rename to packages/ui/src/features/sidebar/components/items/SkillsItem.tsx diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx b/packages/ui/src/features/sidebar/components/items/TaskIcon.tsx similarity index 95% rename from apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx rename to packages/ui/src/features/sidebar/components/items/TaskIcon.tsx index de44afcd4c..a25dbedc09 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx +++ b/packages/ui/src/features/sidebar/components/items/TaskIcon.tsx @@ -1,7 +1,3 @@ -import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; -import { Tooltip } from "@components/ui/Tooltip"; -import type { SidebarPrState } from "@features/sidebar/hooks/useTaskPrStatus"; -import type { WorkspaceMode } from "@main/services/workspace/schemas"; import { ChatCircle, Circle, @@ -14,8 +10,15 @@ import { PushPin, SlackLogo, } from "@phosphor-icons/react"; -import { trpcClient } from "@renderer/trpc/client"; -import { isTerminalStatus, type TaskRunStatus } from "@shared/types"; +import type { WorkspaceMode } from "@posthog/shared"; +import { + isTerminalStatus, + type TaskRunStatus, +} from "@posthog/shared/domain-types"; +import { DotsCircleSpinner } from "../../../../primitives/DotsCircleSpinner"; +import { Tooltip } from "../../../../primitives/Tooltip"; +import { openExternalUrl } from "../../../../workbench/openExternal"; +import type { SidebarPrState } from "../../useTaskPrStatus"; export const ICON_SIZE = 12; @@ -60,7 +63,7 @@ function renderIconSpan({ return <span className="flex items-center justify-center">{icon}</span>; } const open = () => { - void trpcClient.os.openExternal.mutate({ url: link }); + openExternalUrl(link); }; return ( // biome-ignore lint/a11y/useSemanticElements: nested clickable inside SidebarItem button diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx b/packages/ui/src/features/sidebar/components/items/TaskItem.tsx similarity index 95% rename from apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx rename to packages/ui/src/features/sidebar/components/items/TaskItem.tsx index a5ee2a5b49..b8010cf3cc 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/TaskItem.tsx @@ -1,10 +1,10 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import type { SidebarPrState } from "@features/sidebar/hooks/useTaskPrStatus"; -import type { WorkspaceMode } from "@main/services/workspace/schemas"; import { Archive, PushPin } from "@phosphor-icons/react"; -import type { TaskRunStatus } from "@shared/types"; -import { formatRelativeTimeShort } from "@utils/time"; +import type { WorkspaceMode } from "@posthog/shared"; +import { formatRelativeTimeShort } from "@posthog/shared"; +import type { TaskRunStatus } from "@posthog/shared/domain-types"; import { useCallback, useEffect, useRef, useState } from "react"; +import { Tooltip } from "../../../../primitives/Tooltip"; +import type { SidebarPrState } from "../../useTaskPrStatus"; import { SidebarItem } from "../SidebarItem"; import { TaskIcon } from "./TaskIcon"; diff --git a/apps/code/src/renderer/features/sidebar/constants.ts b/packages/ui/src/features/sidebar/constants.ts similarity index 100% rename from apps/code/src/renderer/features/sidebar/constants.ts rename to packages/ui/src/features/sidebar/constants.ts diff --git a/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts b/packages/ui/src/features/sidebar/sidebarStore.ts similarity index 98% rename from apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts rename to packages/ui/src/features/sidebar/sidebarStore.ts index b87d80c2f5..37f18f4bd9 100644 --- a/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts +++ b/packages/ui/src/features/sidebar/sidebarStore.ts @@ -1,6 +1,6 @@ -import { SIDEBAR_MIN_WIDTH } from "@features/sidebar/constants"; import { create } from "zustand"; import { persist } from "zustand/middleware"; +import { SIDEBAR_MIN_WIDTH } from "./constants"; interface SidebarStoreState { open: boolean; diff --git a/packages/ui/src/features/sidebar/taskMetaApi.ts b/packages/ui/src/features/sidebar/taskMetaApi.ts new file mode 100644 index 0000000000..f8c3fc7a63 --- /dev/null +++ b/packages/ui/src/features/sidebar/taskMetaApi.ts @@ -0,0 +1,53 @@ +import { + parseTimestamps, + type TaskTimestamps, +} from "@posthog/core/sidebar/taskMeta"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; + +export type { TaskTimestamps }; + +function workspace() { + return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT).workspace; +} + +export const taskViewedApi = { + async loadTimestamps(): Promise<Record<string, TaskTimestamps>> { + return parseTimestamps(await workspace().getAllTaskTimestamps.query()); + }, + + markAsViewed(taskId: string): void { + void workspace().markViewed.mutate({ taskId }); + }, + + markActivity(taskId: string): void { + void workspace().markActivity.mutate({ taskId }); + }, +}; + +export const pinnedTasksApi = { + async getPinnedTaskIds(): Promise<string[]> { + return workspace().getPinnedTaskIds.query(); + }, + + async togglePin( + taskId: string, + ): Promise<{ taskId: string; isPinned: boolean }> { + const result = await workspace().togglePin.mutate({ taskId }); + return { taskId, isPinned: result.isPinned }; + }, + + async unpin(taskId: string): Promise<void> { + const result = await workspace().togglePin.mutate({ taskId }); + if (result.isPinned) { + await workspace().togglePin.mutate({ taskId }); + } + }, + + isPinned(pinnedTaskIds: Set<string>, taskId: string): boolean { + return pinnedTaskIds.has(taskId); + }, +}; diff --git a/apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.test.ts b/packages/ui/src/features/sidebar/taskSelectionStore.test.ts similarity index 100% rename from apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.test.ts rename to packages/ui/src/features/sidebar/taskSelectionStore.test.ts diff --git a/apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts b/packages/ui/src/features/sidebar/taskSelectionStore.ts similarity index 67% rename from apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts rename to packages/ui/src/features/sidebar/taskSelectionStore.ts index a14a8bc09e..4387199a16 100644 --- a/apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts +++ b/packages/ui/src/features/sidebar/taskSelectionStore.ts @@ -1,3 +1,8 @@ +import { + computeRangeSelection, + dedupeTaskIds, + pruneToVisible, +} from "@posthog/core/sidebar/selection"; import { create } from "zustand"; interface TaskSelectionState { @@ -34,7 +39,7 @@ export const useTaskSelectionStore = create<TaskSelectionStore>()( setSelectedTaskIds: (taskIds) => set({ - selectedTaskIds: Array.from(new Set(taskIds)), + selectedTaskIds: dedupeTaskIds(taskIds), lastClickedId: taskIds.length === 1 ? taskIds[0] : get().lastClickedId, }), @@ -50,40 +55,26 @@ export const useTaskSelectionStore = create<TaskSelectionStore>()( }), selectRange: (toId, orderedIds, fallbackAnchorId) => - set((state) => { - const anchorId = state.lastClickedId ?? fallbackAnchorId ?? null; - if (!anchorId) { - return { selectedTaskIds: [toId], lastClickedId: toId }; - } - const anchorIndex = orderedIds.indexOf(anchorId); - const toIndex = orderedIds.indexOf(toId); - if (anchorIndex === -1 || toIndex === -1) { - return { selectedTaskIds: [toId], lastClickedId: toId }; - } - const start = Math.min(anchorIndex, toIndex); - const end = Math.max(anchorIndex, toIndex); - const rangeIds = orderedIds.slice(start, end + 1); - const merged = Array.from( - new Set([...state.selectedTaskIds, ...rangeIds]), - ); - return { selectedTaskIds: merged, lastClickedId: toId }; - }), + set((state) => + computeRangeSelection( + state.lastClickedId ?? fallbackAnchorId ?? null, + toId, + orderedIds, + state.selectedTaskIds, + ), + ), isTaskSelected: (taskId) => get().selectedTaskIds.includes(taskId), clearSelection: () => set({ selectedTaskIds: [], lastClickedId: null }), - pruneSelection: (visibleTaskIds) => { - const visibleIds = new Set(visibleTaskIds); + pruneSelection: (visibleTaskIds) => set((state) => { - const filtered = state.selectedTaskIds.filter((id) => - visibleIds.has(id), - ); + const filtered = pruneToVisible(state.selectedTaskIds, visibleTaskIds); if (filtered.length === state.selectedTaskIds.length) { return state; } return { selectedTaskIds: filtered }; - }); - }, + }), }), ); diff --git a/apps/code/src/renderer/features/sidebar/types.ts b/packages/ui/src/features/sidebar/types.ts similarity index 100% rename from apps/code/src/renderer/features/sidebar/types.ts rename to packages/ui/src/features/sidebar/types.ts diff --git a/apps/code/src/renderer/features/sidebar/hooks/useCwd.ts b/packages/ui/src/features/sidebar/useCwd.ts similarity index 64% rename from apps/code/src/renderer/features/sidebar/hooks/useCwd.ts rename to packages/ui/src/features/sidebar/useCwd.ts index 61446c8e66..ccc9765e0d 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useCwd.ts +++ b/packages/ui/src/features/sidebar/useCwd.ts @@ -1,5 +1,5 @@ -import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds"; -import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; +import { useSuspendedTaskIds } from "../suspension/useSuspendedTaskIds"; +import { useWorkspace } from "../workspace/useWorkspace"; export function useCwd(taskId: string): string | undefined { const workspace = useWorkspace(taskId); diff --git a/packages/ui/src/features/sidebar/usePinnedTasks.ts b/packages/ui/src/features/sidebar/usePinnedTasks.ts new file mode 100644 index 0000000000..5b14de4621 --- /dev/null +++ b/packages/ui/src/features/sidebar/usePinnedTasks.ts @@ -0,0 +1,78 @@ +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo, useRef } from "react"; + +export function usePinnedTasks() { + const trpc = useHostTRPC(); + const hostClient = useHostTRPCClient(); + const queryClient = useQueryClient(); + const pinnedQueryKey = trpc.workspace.getPinnedTaskIds.queryKey(); + + const { data: pinnedTaskIds = [], isLoading } = useQuery( + trpc.workspace.getPinnedTaskIds.queryOptions(undefined, { + staleTime: 30_000, + }), + ); + + const pinnedSet = useMemo(() => new Set(pinnedTaskIds), [pinnedTaskIds]); + + const togglePinMutation = useMutation({ + mutationFn: ({ taskId }: { taskId: string }) => + hostClient.workspace.togglePin.mutate({ taskId }), + onMutate: async ({ taskId }) => { + await queryClient.cancelQueries({ queryKey: pinnedQueryKey }); + const previous = queryClient.getQueryData<string[]>(pinnedQueryKey); + const wasPinned = previous?.includes(taskId); + queryClient.setQueryData<string[]>(pinnedQueryKey, (old) => { + if (!old) return wasPinned ? [] : [taskId]; + return wasPinned ? old.filter((id) => id !== taskId) : [...old, taskId]; + }); + return { previous, wasPinned, taskId }; + }, + onError: (_, __, context) => { + if (context?.previous) { + queryClient.setQueryData(pinnedQueryKey, context.previous); + } + }, + onSuccess: (result, _, context) => { + const taskId = context?.taskId; + if (!taskId) return; + queryClient.setQueryData<string[]>(pinnedQueryKey, (old) => { + if (!old) return result.isPinned ? [taskId] : []; + const filtered = old.filter((id) => id !== taskId); + return result.isPinned ? [...filtered, taskId] : filtered; + }); + }, + }); + + const togglePinMutationRef = useRef(togglePinMutation); + togglePinMutationRef.current = togglePinMutation; + + const pinnedSetRef = useRef(pinnedSet); + pinnedSetRef.current = pinnedSet; + + const togglePin = useCallback(async (taskId: string) => { + await togglePinMutationRef.current.mutateAsync({ taskId }); + }, []); + + const unpin = useCallback(async (taskId: string) => { + if (!pinnedSetRef.current.has(taskId)) return; + const result = await togglePinMutationRef.current.mutateAsync({ taskId }); + if (result.isPinned) { + await togglePinMutationRef.current.mutateAsync({ taskId }); + } + }, []); + + const isPinned = useCallback( + (taskId: string) => pinnedSet.has(taskId), + [pinnedSet], + ); + + return { + pinnedTaskIds: pinnedSet, + isLoading, + togglePin, + unpin, + isPinned, + }; +} diff --git a/packages/ui/src/features/sidebar/useSidebarData.ts b/packages/ui/src/features/sidebar/useSidebarData.ts new file mode 100644 index 0000000000..aafe167012 --- /dev/null +++ b/packages/ui/src/features/sidebar/useSidebarData.ts @@ -0,0 +1,243 @@ +import { + deriveTaskData, + type FullTask, + filterVisibleTasks, + narrowFullTask, + partitionAndSortTasks, + type SidebarTask, + sliceChronological, +} from "@posthog/core/sidebar/buildSidebarData"; +import { groupByRepository } from "@posthog/core/sidebar/groupTasks"; +import type { + SidebarData, + TaskData, + TaskGroup, +} from "@posthog/core/sidebar/sidebarData.types"; +import { computeSummaryIds } from "@posthog/core/sidebar/summaryIds"; +import type { Task } from "@posthog/shared/domain-types"; +import { useEffect, useMemo, useRef } from "react"; +import { useArchivedTaskIds } from "../archive/useArchivedTaskIds"; +import { useProvisioningStore } from "../provisioning/store"; +import { useSessions } from "../sessions/sessionStore"; +import { useSuspendedTaskIds } from "../suspension/useSuspendedTaskIds"; +import { useSlackTasks, useTaskSummaries, useTasks } from "../tasks/useTasks"; +import { useWorkspaces } from "../workspace/useWorkspace"; +import { useSidebarStore } from "./sidebarStore"; +import { usePinnedTasks } from "./usePinnedTasks"; +import { useTaskViewed } from "./useTaskViewed"; + +export type { SidebarData, TaskData, TaskGroup }; + +interface ViewState { + type: + | "task-detail" + | "task-pending" + | "task-input" + | "settings" + | "folder-settings" + | "inbox" + | "archived" + | "command-center" + | "skills" + | "mcp-servers" + | "setup"; + data?: Task; +} + +interface UseSidebarDataProps { + activeView: ViewState; +} + +export function useSidebarData({ + activeView, +}: UseSidebarDataProps): SidebarData { + const showAllUsers = useSidebarStore((state) => state.showAllUsers); + const showInternal = useSidebarStore((state) => state.showInternal); + const { data: workspaces, isFetched: isWorkspacesFetched } = useWorkspaces(); + const archivedTaskIds = useArchivedTaskIds(); + const suspendedTaskIds = useSuspendedTaskIds(); + const provisioningTaskIds = useProvisioningStore((s) => s.activeTasks); + const sessions = useSessions(); + const { timestamps } = useTaskViewed(); + const historyVisibleCount = useSidebarStore( + (state) => state.historyVisibleCount, + ); + const { pinnedTaskIds } = usePinnedTasks(); + const organizeMode = useSidebarStore((state) => state.organizeMode); + const sortMode = useSidebarStore((state) => state.sortMode); + const folderOrder = useSidebarStore((state) => state.folderOrder); + + const summaryIds = useMemo( + () => + showAllUsers + ? [] + : computeSummaryIds({ + workspaceIds: workspaces ? Object.keys(workspaces) : [], + pinnedTaskIds, + provisioningTaskIds, + archivedTaskIds, + }), + [ + showAllUsers, + workspaces, + pinnedTaskIds, + provisioningTaskIds, + archivedTaskIds, + ], + ); + + const { data: summaryTasks = [], isLoading: isSummariesLoading } = + useTaskSummaries(summaryIds, { enabled: !showAllUsers }); + const { data: fullTasks = [], isLoading: isTasksLoading } = useTasks( + { showAllUsers, showInternal }, + { enabled: showAllUsers }, + ); + const { data: slackTasks = [] } = useSlackTasks({ + enabled: !showAllUsers, + showInternal, + }); + const slackTaskIds = useMemo( + () => new Set(slackTasks.map((t) => t.id)), + [slackTasks], + ); + const slackThreadUrlByTaskId = useMemo(() => { + const map = new Map<string, string>(); + for (const t of slackTasks) { + const url = t.latest_run?.state?.slack_thread_url; + if (typeof url === "string") map.set(t.id, url); + } + return map; + }, [slackTasks]); + + const rawTasks = useMemo<SidebarTask[]>( + () => + showAllUsers + ? fullTasks.map((t) => narrowFullTask(t as FullTask)) + : (summaryTasks as SidebarTask[]), + [showAllUsers, summaryTasks, fullTasks], + ); + + const isPrimaryLoading = showAllUsers ? isTasksLoading : isSummariesLoading; + const isLoading = isPrimaryLoading || !isWorkspacesFetched; + + const workspaceIds = useMemo( + () => new Set(workspaces ? Object.keys(workspaces) : []), + [workspaces], + ); + + const allTasks = useMemo( + () => + filterVisibleTasks(rawTasks, { + archivedIds: archivedTaskIds, + workspaceIds, + provisioningIds: provisioningTaskIds, + showAllUsers, + showInternal, + }), + [ + rawTasks, + archivedTaskIds, + workspaceIds, + showAllUsers, + showInternal, + provisioningTaskIds, + ], + ); + + const isHomeActive = + activeView.type === "task-input" || activeView.type === "task-pending"; + const isInboxActive = activeView.type === "inbox"; + const isCommandCenterActive = activeView.type === "command-center"; + const isSkillsActive = activeView.type === "skills"; + const isMcpServersActive = activeView.type === "mcp-servers"; + + const activeTaskId = + activeView.type === "task-detail" && activeView.data + ? activeView.data.id + : null; + + const sessionByTaskId = useMemo(() => { + const map = new Map<string, (typeof sessions)[string]>(); + for (const session of Object.values(sessions)) { + if (session.taskId) { + map.set(session.taskId, session); + } + } + return map; + }, [sessions]); + + const taskData = useMemo( + () => + allTasks.map((task) => + deriveTaskData(task, { + session: sessionByTaskId.get(task.id), + workspace: workspaces?.[task.id], + timestamp: timestamps[task.id], + pinnedIds: pinnedTaskIds, + suspendedIds: suspendedTaskIds, + slackTaskIds, + slackThreadUrlByTaskId, + }), + ), + [ + allTasks, + timestamps, + pinnedTaskIds, + suspendedTaskIds, + sessionByTaskId, + workspaces, + slackTaskIds, + slackThreadUrlByTaskId, + ], + ); + + const { pinnedTasks, sortedUnpinnedTasks, totalCount } = useMemo( + () => partitionAndSortTasks(taskData, sortMode), + [taskData, sortMode], + ); + + const { flatTasks, hasMore } = useMemo( + () => + sliceChronological( + sortedUnpinnedTasks, + organizeMode, + historyVisibleCount, + ), + [sortedUnpinnedTasks, organizeMode, historyVisibleCount], + ); + + const groupedTasks = useMemo( + () => groupByRepository(sortedUnpinnedTasks, folderOrder), + [sortedUnpinnedTasks, folderOrder], + ); + + const groupIdsRef = useRef<string[]>([]); + useEffect(() => { + if (groupedTasks.length === 0) return; + const groupIds = groupedTasks.map((g) => g.id); + const prev = groupIdsRef.current; + if ( + groupIds.length === prev.length && + groupIds.every((id, i) => id === prev[i]) + ) { + return; + } + groupIdsRef.current = groupIds; + useSidebarStore.getState().syncFolderOrder(groupIds); + }, [groupedTasks]); + + return { + isHomeActive, + isInboxActive, + isCommandCenterActive, + isSkillsActive, + isMcpServersActive, + isLoading, + activeTaskId, + pinnedTasks, + flatTasks, + groupedTasks, + totalCount, + hasMore, + }; +} diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts b/packages/ui/src/features/sidebar/useTaskPrStatus.test.ts similarity index 90% rename from apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts rename to packages/ui/src/features/sidebar/useTaskPrStatus.test.ts index 14a3417bc1..0e16f9904a 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts +++ b/packages/ui/src/features/sidebar/useTaskPrStatus.test.ts @@ -6,18 +6,11 @@ import { useTaskPrStatus } from "./useTaskPrStatus"; let queryData: unknown; let lastQueryOptions: { enabled?: boolean } | undefined; -vi.mock("@renderer/trpc/client", () => ({ - useTRPC: () => ({ +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPC: () => ({ workspace: { getTaskPrStatus: { - queryOptions: ( - input: { taskId: string; cloudPrUrl: string | null }, - opts: { staleTime: number; enabled?: boolean }, - ) => ({ - queryKey: ["workspace.getTaskPrStatus", input], - queryFn: () => undefined, - ...opts, - }), + queryOptions: (_input: unknown, opts: { enabled?: boolean }) => opts, }, }, }), diff --git a/packages/ui/src/features/sidebar/useTaskPrStatus.ts b/packages/ui/src/features/sidebar/useTaskPrStatus.ts new file mode 100644 index 0000000000..15c6779b11 --- /dev/null +++ b/packages/ui/src/features/sidebar/useTaskPrStatus.ts @@ -0,0 +1,35 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; + +export type SidebarPrState = "merged" | "open" | "draft" | "closed" | null; + +export interface TaskPrStatus { + prState: SidebarPrState; + hasDiff: boolean; +} + +const SIDEBAR_STALE_TIME = 60_000; +const EMPTY: TaskPrStatus = { prState: null, hasDiff: false }; + +export function useTaskPrStatus(task: { + id: string; + cloudPrUrl?: string | null; + taskRunEnvironment?: string | null; +}): TaskPrStatus { + const trpc = useHostTRPC(); + + const skipQuery = task.taskRunEnvironment === "cloud" && !task.cloudPrUrl; + + const { data } = useQuery( + trpc.workspace.getTaskPrStatus.queryOptions( + { taskId: task.id, cloudPrUrl: task.cloudPrUrl ?? null }, + { + staleTime: SIDEBAR_STALE_TIME, + enabled: !skipQuery, + }, + ), + ); + + if (!data || (!data.prState && !data.hasDiff)) return EMPTY; + return data; +} diff --git a/packages/ui/src/features/sidebar/useTaskViewed.ts b/packages/ui/src/features/sidebar/useTaskViewed.ts new file mode 100644 index 0000000000..870885a186 --- /dev/null +++ b/packages/ui/src/features/sidebar/useTaskViewed.ts @@ -0,0 +1,136 @@ +import { + parseTimestamps, + type RawTaskTimestamp, +} from "@posthog/core/sidebar/taskMeta"; +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo, useRef } from "react"; + +export function useTaskViewed() { + const trpc = useHostTRPC(); + const hostClient = useHostTRPCClient(); + const queryClient = useQueryClient(); + const timestampsQueryKey = trpc.workspace.getAllTaskTimestamps.queryKey(); + + const { data: rawTimestamps = {}, isLoading } = useQuery( + trpc.workspace.getAllTaskTimestamps.queryOptions(undefined, { + staleTime: 30_000, + }), + ); + + const timestamps = useMemo( + () => parseTimestamps(rawTimestamps), + [rawTimestamps], + ); + + const markViewedMutation = useMutation({ + mutationFn: ({ taskId }: { taskId: string }) => + hostClient.workspace.markViewed.mutate({ taskId }), + onMutate: async ({ taskId }) => { + await queryClient.cancelQueries({ queryKey: timestampsQueryKey }); + const previous = + queryClient.getQueryData<Record<string, RawTaskTimestamp>>( + timestampsQueryKey, + ); + const now = new Date().toISOString(); + queryClient.setQueryData<Record<string, RawTaskTimestamp>>( + timestampsQueryKey, + (old) => { + if (!old) + return { + [taskId]: { + pinnedAt: null, + lastViewedAt: now, + lastActivityAt: null, + }, + }; + return { + ...old, + [taskId]: { ...old[taskId], lastViewedAt: now }, + }; + }, + ); + return { previous }; + }, + onError: (_, __, context) => { + if (context?.previous) { + queryClient.setQueryData(timestampsQueryKey, context.previous); + } + }, + }); + + const markActivityMutation = useMutation({ + mutationFn: ({ taskId }: { taskId: string }) => + hostClient.workspace.markActivity.mutate({ taskId }), + onMutate: async ({ taskId }) => { + await queryClient.cancelQueries({ queryKey: timestampsQueryKey }); + const previous = + queryClient.getQueryData<Record<string, RawTaskTimestamp>>( + timestampsQueryKey, + ); + const existing = previous?.[taskId]; + const lastViewedAt = existing?.lastViewedAt + ? new Date(existing.lastViewedAt).getTime() + : 0; + const now = Date.now(); + const activityTime = Math.max(now, lastViewedAt + 1); + const activityIso = new Date(activityTime).toISOString(); + queryClient.setQueryData<Record<string, RawTaskTimestamp>>( + timestampsQueryKey, + (old) => { + if (!old) + return { + [taskId]: { + pinnedAt: null, + lastViewedAt: null, + lastActivityAt: activityIso, + }, + }; + return { + ...old, + [taskId]: { ...old[taskId], lastActivityAt: activityIso }, + }; + }, + ); + return { previous }; + }, + onError: (_, __, context) => { + if (context?.previous) { + queryClient.setQueryData(timestampsQueryKey, context.previous); + } + }, + }); + + const markViewedMutationRef = useRef(markViewedMutation); + markViewedMutationRef.current = markViewedMutation; + + const markActivityMutationRef = useRef(markActivityMutation); + markActivityMutationRef.current = markActivityMutation; + + const markAsViewed = useCallback((taskId: string) => { + markViewedMutationRef.current.mutate({ taskId }); + }, []); + + const markActivity = useCallback((taskId: string) => { + markActivityMutationRef.current.mutate({ taskId }); + }, []); + + const getLastViewedAt = useCallback( + (taskId: string) => timestamps[taskId]?.lastViewedAt ?? undefined, + [timestamps], + ); + + const getLastActivityAt = useCallback( + (taskId: string) => timestamps[taskId]?.lastActivityAt ?? undefined, + [timestamps], + ); + + return { + timestamps, + isLoading, + markAsViewed, + markActivity, + getLastViewedAt, + getLastActivityAt, + }; +} diff --git a/apps/code/src/renderer/features/sidebar/hooks/useVisualTaskOrder.ts b/packages/ui/src/features/sidebar/useVisualTaskOrder.ts similarity index 85% rename from apps/code/src/renderer/features/sidebar/hooks/useVisualTaskOrder.ts rename to packages/ui/src/features/sidebar/useVisualTaskOrder.ts index 97786cdc8c..c87420a130 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useVisualTaskOrder.ts +++ b/packages/ui/src/features/sidebar/useVisualTaskOrder.ts @@ -1,6 +1,9 @@ +import type { + SidebarData, + TaskData, +} from "@posthog/core/sidebar/sidebarData.types"; import { useMemo } from "react"; -import { useSidebarStore } from "../stores/sidebarStore"; -import type { SidebarData, TaskData } from "./useSidebarData"; +import { useSidebarStore } from "./sidebarStore"; export function useVisualTaskOrder(sidebarData: SidebarData): TaskData[] { const organizeMode = useSidebarStore((state) => state.organizeMode); diff --git a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.stories.tsx b/packages/ui/src/features/skill-buttons/components/SkillButtonActionMessage.stories.tsx similarity index 88% rename from apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.stories.tsx rename to packages/ui/src/features/skill-buttons/components/SkillButtonActionMessage.stories.tsx index 23a23beec2..7890bb842c 100644 --- a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.stories.tsx +++ b/packages/ui/src/features/skill-buttons/components/SkillButtonActionMessage.stories.tsx @@ -1,5 +1,5 @@ +import { SkillButtonActionMessage } from "@posthog/ui/features/skill-buttons/components/SkillButtonActionMessage"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SkillButtonActionMessage } from "./SkillButtonActionMessage"; const meta: Meta<typeof SkillButtonActionMessage> = { title: "Skill Buttons/SkillButtonActionMessage", diff --git a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.tsx b/packages/ui/src/features/skill-buttons/components/SkillButtonActionMessage.tsx similarity index 88% rename from apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.tsx rename to packages/ui/src/features/skill-buttons/components/SkillButtonActionMessage.tsx index 0b99db9fe7..43adc7a605 100644 --- a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonActionMessage.tsx +++ b/packages/ui/src/features/skill-buttons/components/SkillButtonActionMessage.tsx @@ -1,7 +1,4 @@ -import { - SKILL_BUTTONS, - type SkillButtonId, -} from "@features/skill-buttons/prompts"; +import { SKILL_BUTTONS, type SkillButtonId } from "../prompts"; interface SkillButtonActionMessageProps { buttonId: SkillButtonId; diff --git a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.stories.tsx b/packages/ui/src/features/skill-buttons/components/SkillButtonsMenu.stories.tsx similarity index 79% rename from apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.stories.tsx rename to packages/ui/src/features/skill-buttons/components/SkillButtonsMenu.stories.tsx index 8541d8f48a..eb76ddd98f 100644 --- a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.stories.tsx +++ b/packages/ui/src/features/skill-buttons/components/SkillButtonsMenu.stories.tsx @@ -1,5 +1,5 @@ +import { SkillButtonsMenu } from "@posthog/ui/features/skill-buttons/components/SkillButtonsMenu"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SkillButtonsMenu } from "./SkillButtonsMenu"; const meta: Meta<typeof SkillButtonsMenu> = { title: "Skill Buttons/SkillButtonsMenu", diff --git a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.tsx b/packages/ui/src/features/skill-buttons/components/SkillButtonsMenu.tsx similarity index 90% rename from apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.tsx rename to packages/ui/src/features/skill-buttons/components/SkillButtonsMenu.tsx index f52dcabc26..b158d9a216 100644 --- a/apps/code/src/renderer/features/skill-buttons/components/SkillButtonsMenu.tsx +++ b/packages/ui/src/features/skill-buttons/components/SkillButtonsMenu.tsx @@ -1,12 +1,3 @@ -import { sendPromptToAgent } from "@features/sessions/utils/sendPromptToAgent"; -import { - buildSkillButtonPromptBlocks, - SKILL_BUTTON_ORDER, - SKILL_BUTTONS, - type SkillButton, - type SkillButtonId, -} from "@features/skill-buttons/prompts"; -import { useSkillButtonsStore } from "@features/skill-buttons/stores/skillButtonsStore"; import { CaretDown } from "@phosphor-icons/react"; import { Button, @@ -19,8 +10,17 @@ import { TooltipProvider, TooltipTrigger, } from "@posthog/quill"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { track } from "@utils/analytics"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { track } from "../../../workbench/analytics"; +import { sendPromptToAgent } from "../../sessions/sendPromptToAgent"; +import { + buildSkillButtonPromptBlocks, + SKILL_BUTTON_ORDER, + SKILL_BUTTONS, + type SkillButton, + type SkillButtonId, +} from "../prompts"; +import { useSkillButtonsStore } from "../skillButtonsStore"; interface SkillButtonsMenuProps { taskId: string; diff --git a/packages/ui/src/features/skill-buttons/prompts.ts b/packages/ui/src/features/skill-buttons/prompts.ts new file mode 100644 index 0000000000..a91439f4e7 --- /dev/null +++ b/packages/ui/src/features/skill-buttons/prompts.ts @@ -0,0 +1,47 @@ +import { + Broadcast, + ChartBar, + Flask, + type Icon, + Pulse, + ToggleRight, + Warning, +} from "@phosphor-icons/react"; +import { + SKILL_BUTTON_CATALOG, + SKILL_BUTTON_ORDER, + type SkillButtonCatalogEntry, + type SkillButtonId, +} from "@posthog/core/skill-buttons/catalog"; +import { + buildSkillButtonPromptBlocks, + extractSkillButtonId, +} from "@posthog/core/skill-buttons/prompts"; + +export { + buildSkillButtonPromptBlocks, + extractSkillButtonId, + SKILL_BUTTON_ORDER, +}; +export type { SkillButtonId }; + +export interface SkillButton extends SkillButtonCatalogEntry { + Icon: Icon; +} + +const SKILL_BUTTON_ICONS: Record<SkillButtonId, Icon> = { + "add-analytics": ChartBar, + "create-feature-flags": ToggleRight, + "run-experiment": Flask, + "add-error-tracking": Warning, + "instrument-llm-calls": Broadcast, + "add-logging": Pulse, +}; + +export const SKILL_BUTTONS: Record<SkillButtonId, SkillButton> = + Object.fromEntries( + (Object.keys(SKILL_BUTTON_CATALOG) as SkillButtonId[]).map((id) => [ + id, + { ...SKILL_BUTTON_CATALOG[id], Icon: SKILL_BUTTON_ICONS[id] }, + ]), + ) as Record<SkillButtonId, SkillButton>; diff --git a/apps/code/src/renderer/features/skill-buttons/stores/skillButtonsStore.ts b/packages/ui/src/features/skill-buttons/skillButtonsStore.ts similarity index 72% rename from apps/code/src/renderer/features/skill-buttons/stores/skillButtonsStore.ts rename to packages/ui/src/features/skill-buttons/skillButtonsStore.ts index 229854e730..932c1f8b77 100644 --- a/apps/code/src/renderer/features/skill-buttons/stores/skillButtonsStore.ts +++ b/packages/ui/src/features/skill-buttons/skillButtonsStore.ts @@ -1,8 +1,8 @@ import { + isSkillButtonId, SKILL_BUTTON_ORDER, - SKILL_BUTTONS, -} from "@features/skill-buttons/prompts"; -import type { SkillButtonId } from "@shared/types/analytics"; + type SkillButtonId, +} from "@posthog/core/skill-buttons/catalog"; import { create } from "zustand"; import { persist } from "zustand/middleware"; @@ -30,11 +30,9 @@ export const useSkillButtonsStore = create<SkillButtonsStore>()( const persistedState = persisted as { lastSelectedId?: string; }; - const restored = - persistedState.lastSelectedId && - persistedState.lastSelectedId in SKILL_BUTTONS - ? (persistedState.lastSelectedId as SkillButtonId) - : DEFAULT_PRIMARY; + const restored = isSkillButtonId(persistedState.lastSelectedId) + ? persistedState.lastSelectedId + : DEFAULT_PRIMARY; return { ...current, lastSelectedId: restored, diff --git a/apps/code/src/renderer/features/skills/components/SkillCard.tsx b/packages/ui/src/features/skills/SkillCard.tsx similarity index 97% rename from apps/code/src/renderer/features/skills/components/SkillCard.tsx rename to packages/ui/src/features/skills/SkillCard.tsx index 62aa868c88..7a2afe6f26 100644 --- a/apps/code/src/renderer/features/skills/components/SkillCard.tsx +++ b/packages/ui/src/features/skills/SkillCard.tsx @@ -1,6 +1,6 @@ import { Folder, Package, Storefront, User } from "@phosphor-icons/react"; +import type { SkillInfo, SkillSource } from "@posthog/shared"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; -import type { SkillInfo, SkillSource } from "@shared/types/skills"; export const SOURCE_CONFIG: Record< SkillSource, diff --git a/apps/code/src/renderer/features/skills/components/SkillDetailPanel.tsx b/packages/ui/src/features/skills/SkillDetailPanel.tsx similarity index 83% rename from apps/code/src/renderer/features/skills/components/SkillDetailPanel.tsx rename to packages/ui/src/features/skills/SkillDetailPanel.tsx index 7dc87d1189..e82042bea6 100644 --- a/apps/code/src/renderer/features/skills/components/SkillDetailPanel.tsx +++ b/packages/ui/src/features/skills/SkillDetailPanel.tsx @@ -1,10 +1,9 @@ -import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; -import { ExternalAppsOpener } from "@features/task-detail/components/ExternalAppsOpener"; import { Folder, X } from "@phosphor-icons/react"; +import type { SkillInfo } from "@posthog/shared"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { ExternalAppsOpener } from "@posthog/ui/features/task-detail/components/ExternalAppsOpener"; import { Badge, Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc"; -import type { SkillInfo } from "@shared/types/skills"; -import { useQuery } from "@tanstack/react-query"; +import { useAbsoluteFileContent } from "../code-editor/hooks/useFileContent"; import { SOURCE_CONFIG } from "./SkillCard"; function stripFrontmatter(content: string): string { @@ -18,15 +17,12 @@ interface SkillDetailPanelProps { } export function SkillDetailPanel({ skill, onClose }: SkillDetailPanelProps) { - const trpcReact = useTRPC(); const config = SOURCE_CONFIG[skill.source]; const skillMdPath = `${skill.path}/SKILL.md`; - const { data: fileContent, isLoading } = useQuery( - trpcReact.fs.readAbsoluteFile.queryOptions( - { filePath: skillMdPath }, - { staleTime: 30_000 }, - ), + const { data: fileContent, isLoading } = useAbsoluteFileContent( + skillMdPath, + true, ); const body = fileContent ? stripFrontmatter(fileContent) : null; diff --git a/apps/code/src/renderer/features/skills/components/SkillsView.tsx b/packages/ui/src/features/skills/SkillsView.tsx similarity index 89% rename from apps/code/src/renderer/features/skills/components/SkillsView.tsx rename to packages/ui/src/features/skills/SkillsView.tsx index c42f98d24f..04e7cce3ae 100644 --- a/apps/code/src/renderer/features/skills/components/SkillsView.tsx +++ b/packages/ui/src/features/skills/SkillsView.tsx @@ -1,22 +1,18 @@ -import { ResizableSidebar } from "@components/ResizableSidebar"; -import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import { Lightbulb, MagnifyingGlass } from "@phosphor-icons/react"; +import type { SkillInfo, SkillSource } from "@posthog/shared"; import { Box, Flex, ScrollArea, Text, TextField } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc"; -import type { SkillInfo, SkillSource } from "@shared/types/skills"; -import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; -import { useSkillsSidebarStore } from "../stores/skillsSidebarStore"; +import { useSetHeaderContent } from "../../hooks/useSetHeaderContent"; +import { ResizableSidebar } from "../../primitives/ResizableSidebar"; import { SkillSection, SOURCE_CONFIG } from "./SkillCard"; import { SkillDetailPanel } from "./SkillDetailPanel"; +import { useSkillsSidebarStore } from "./skillsSidebarStore"; +import { useSkills } from "./useSkills"; const SOURCE_ORDER: SkillSource[] = ["user", "marketplace", "repo", "bundled"]; export function SkillsView() { - const trpcReact = useTRPC(); - const { data: skills = [], isLoading } = useQuery( - trpcReact.skills.list.queryOptions(undefined, { staleTime: 30_000 }), - ); + const { data: skills = [], isLoading } = useSkills(); const [selectedPath, setSelectedPath] = useState<string | null>(null); const [searchQuery, setSearchQuery] = useState(""); diff --git a/apps/code/src/renderer/features/skills/stores/skillsSidebarStore.ts b/packages/ui/src/features/skills/skillsSidebarStore.ts similarity index 58% rename from apps/code/src/renderer/features/skills/stores/skillsSidebarStore.ts rename to packages/ui/src/features/skills/skillsSidebarStore.ts index 0a71e1c0a0..83681abada 100644 --- a/apps/code/src/renderer/features/skills/stores/skillsSidebarStore.ts +++ b/packages/ui/src/features/skills/skillsSidebarStore.ts @@ -1,4 +1,4 @@ -import { createSidebarStore } from "@stores/createSidebarStore"; +import { createSidebarStore } from "@posthog/ui/workbench/createSidebarStore"; export const useSkillsSidebarStore = createSidebarStore({ name: "skills-sidebar", diff --git a/packages/ui/src/features/skills/useSkills.test.tsx b/packages/ui/src/features/skills/useSkills.test.tsx new file mode 100644 index 0000000000..3582278241 --- /dev/null +++ b/packages/ui/src/features/skills/useSkills.test.tsx @@ -0,0 +1,50 @@ +import type { SkillInfo } from "@posthog/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const listFn = vi.hoisted(() => vi.fn()); +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPC: () => ({ + skills: { + list: { + queryOptions: (_input: unknown, options: Record<string, unknown>) => ({ + queryKey: ["skills", "list"], + queryFn: () => listFn(), + ...options, + }), + }, + }, + }), +})); + +import { useSkills } from "./useSkills"; + +const skills = [ + { name: "Commit", source: "user", path: "/skills/commit" }, + { name: "Review", source: "bundled", path: "/skills/review" }, +] as unknown as SkillInfo[]; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + ); +} + +describe("useSkills", () => { + beforeEach(() => { + vi.clearAllMocks(); + listFn.mockResolvedValue(skills); + }); + + it("returns the skills listed by the host client", async () => { + const { result } = renderHook(() => useSkills(), { wrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual(skills); + expect(listFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/features/skills/useSkills.ts b/packages/ui/src/features/skills/useSkills.ts new file mode 100644 index 0000000000..46f756ccdc --- /dev/null +++ b/packages/ui/src/features/skills/useSkills.ts @@ -0,0 +1,9 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; + +export function useSkills() { + const trpc = useHostTRPC(); + return useQuery( + trpc.skills.list.queryOptions(undefined, { staleTime: 30_000 }), + ); +} diff --git a/apps/code/src/renderer/features/suspension/hooks/useRestoreTask.ts b/packages/ui/src/features/suspension/useRestoreTask.ts similarity index 58% rename from apps/code/src/renderer/features/suspension/hooks/useRestoreTask.ts rename to packages/ui/src/features/suspension/useRestoreTask.ts index f764f92eb0..7ec62aff34 100644 --- a/apps/code/src/renderer/features/suspension/hooks/useRestoreTask.ts +++ b/packages/ui/src/features/suspension/useRestoreTask.ts @@ -1,33 +1,41 @@ +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { invalidateGitBranchQueries, invalidateGitWorkingTreeQueries, -} from "@features/git-interaction/utils/gitCacheKeys"; -import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; -import { trpc, trpcClient } from "@renderer/trpc"; -import { useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; -import { toast } from "@utils/toast"; +} from "@posthog/ui/features/git-interaction/gitCacheKeys"; +import { toast } from "@posthog/ui/primitives/toast"; +import { logger } from "@posthog/ui/workbench/logger"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import { WORKSPACE_QUERY_KEY } from "../workspace/identifiers"; const log = logger.scope("restore-task"); export function useRestoreTask() { + const trpc = useHostTRPC(); + const hostClient = useHostTRPCClient(); const queryClient = useQueryClient(); const [isRestoring, setIsRestoring] = useState(false); + const suspensionPathKey = trpc.suspension.pathFilter().queryKey; + const restoreMutation = useMutation( + trpc.suspension.restore.mutationOptions(), + ); + const restoreTask = async (taskId: string, recreateBranch?: boolean) => { setIsRestoring(true); try { - const result = await trpcClient.suspension.restore.mutate({ + const result = await restoreMutation.mutateAsync({ taskId, recreateBranch, }); - queryClient.invalidateQueries(trpc.suspension.pathFilter()); - queryClient.invalidateQueries(trpc.workspace.pathFilter()); + queryClient.invalidateQueries({ queryKey: suspensionPathKey }); + queryClient.invalidateQueries({ queryKey: WORKSPACE_QUERY_KEY }); - const workspace = await workspaceApi.get(taskId); + const workspaces = await hostClient.workspace.getAll.query(); + const workspace = workspaces[taskId] ?? null; const repoPath = workspace?.worktreePath ?? workspace?.folderPath; if (repoPath) { invalidateGitWorkingTreeQueries(repoPath); diff --git a/packages/ui/src/features/suspension/useSuspendTask.test.tsx b/packages/ui/src/features/suspension/useSuspendTask.test.tsx new file mode 100644 index 0000000000..1949d5a849 --- /dev/null +++ b/packages/ui/src/features/suspension/useSuspendTask.test.tsx @@ -0,0 +1,90 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const suspendFn = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const workspaceClient = vi.hoisted(() => ({ + getAll: vi.fn().mockResolvedValue({}), +})); + +const SUSPENDED_TASK_IDS_KEY = ["suspension", "suspendedTaskIds"]; + +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPC: () => ({ + suspension: { + suspendedTaskIds: { + queryKey: () => SUSPENDED_TASK_IDS_KEY, + }, + pathFilter: () => ({ queryKey: ["suspension"] }), + suspend: { + mutationOptions: (options: Record<string, unknown>) => ({ + mutationFn: (input: unknown) => suspendFn(input), + ...options, + }), + }, + }, + }), + useHostTRPCClient: () => ({ + workspace: { getAll: { query: () => workspaceClient.getAll() } }, + }), +})); +vi.mock("@posthog/ui/features/focus/focusStore", () => ({ + useFocusStore: { getState: () => ({ session: null, disableFocus: vi.fn() }) }, +})); +vi.mock("@posthog/ui/features/terminal/terminalStore", () => ({ + useTerminalStore: { + getState: () => ({ clearTerminalStatesForTask: vi.fn() }), + }, +})); +vi.mock("@posthog/ui/workbench/logger", () => ({ + logger: { scope: () => ({ info: vi.fn(), error: vi.fn() }) }, +})); + +import { useSuspendTask } from "./useSuspendTask"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + ); +} + +describe("useSuspendTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + suspendFn.mockResolvedValue(undefined); + workspaceClient.getAll.mockResolvedValue({}); + }); + + it("optimistically adds the task to the suspended set and calls suspend", async () => { + const { result } = renderHook(() => useSuspendTask(), { wrapper }); + await result.current.suspendTask({ taskId: "t1" }); + expect(suspendFn).toHaveBeenCalledWith({ + taskId: "t1", + reason: "manual", + }); + }); + + it("rolls back the optimistic suspended set when suspend fails", async () => { + suspendFn.mockRejectedValueOnce(new Error("boom")); + const seen: Array<string[] | undefined> = []; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const localWrapper = ({ children }: { children: ReactNode }) => ( + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + ); + const { result } = renderHook(() => useSuspendTask(), { + wrapper: localWrapper, + }); + + await expect(result.current.suspendTask({ taskId: "t1" })).rejects.toThrow( + "boom", + ); + seen.push(queryClient.getQueryData<string[]>(SUSPENDED_TASK_IDS_KEY)); + expect(seen[0] ?? []).not.toContain("t1"); + }); +}); diff --git a/packages/ui/src/features/suspension/useSuspendTask.ts b/packages/ui/src/features/suspension/useSuspendTask.ts new file mode 100644 index 0000000000..4a5aac48b7 --- /dev/null +++ b/packages/ui/src/features/suspension/useSuspendTask.ts @@ -0,0 +1,64 @@ +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { useFocusStore } from "@posthog/ui/features/focus/focusStore"; +import { useTerminalStore } from "@posthog/ui/features/terminal/terminalStore"; +import { logger } from "@posthog/ui/workbench/logger"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { WORKSPACE_QUERY_KEY } from "../workspace/identifiers"; + +const log = logger.scope("suspend-task"); + +interface SuspendTaskInput { + taskId: string; + reason?: "manual" | "max_worktrees" | "inactivity"; +} + +export function useSuspendTask() { + const trpc = useHostTRPC(); + const hostClient = useHostTRPCClient(); + const queryClient = useQueryClient(); + + const suspendedTaskIdsKey = trpc.suspension.suspendedTaskIds.queryKey(); + const suspensionPathKey = trpc.suspension.pathFilter().queryKey; + const suspendMutation = useMutation( + trpc.suspension.suspend.mutationOptions(), + ); + + const suspendTask = async (input: SuspendTaskInput) => { + const { taskId, reason = "manual" } = input; + const focusStore = useFocusStore.getState(); + const workspaces = await hostClient.workspace.getAll.query(); + const workspace = workspaces[taskId] ?? null; + + useTerminalStore.getState().clearTerminalStatesForTask(taskId); + + queryClient.setQueryData<string[]>(suspendedTaskIdsKey, (old) => + old ? [...old, taskId] : [taskId], + ); + + if ( + workspace?.worktreePath && + focusStore.session?.worktreePath === workspace.worktreePath + ) { + log.info("Unfocusing workspace before suspending"); + await focusStore.disableFocus(); + } + + try { + await suspendMutation.mutateAsync({ taskId, reason }); + + queryClient.invalidateQueries({ queryKey: suspensionPathKey }); + queryClient.invalidateQueries({ queryKey: suspendedTaskIdsKey }); + queryClient.invalidateQueries({ queryKey: WORKSPACE_QUERY_KEY }); + } catch (error) { + log.error("Failed to suspend task", error); + + queryClient.setQueryData<string[]>(suspendedTaskIdsKey, (old) => + old ? old.filter((id) => id !== taskId) : [], + ); + + throw error; + } + }; + + return { suspendTask }; +} diff --git a/apps/code/src/renderer/features/suspension/hooks/useSuspendedTaskIds.ts b/packages/ui/src/features/suspension/useSuspendedTaskIds.ts similarity index 53% rename from apps/code/src/renderer/features/suspension/hooks/useSuspendedTaskIds.ts rename to packages/ui/src/features/suspension/useSuspendedTaskIds.ts index 1a56e45d67..ddd52bb328 100644 --- a/apps/code/src/renderer/features/suspension/hooks/useSuspendedTaskIds.ts +++ b/packages/ui/src/features/suspension/useSuspendedTaskIds.ts @@ -1,11 +1,9 @@ -import { useTRPC } from "@renderer/trpc"; +import { useHostTRPC } from "@posthog/host-router/react"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; export function useSuspendedTaskIds(): Set<string> { - const trpcReact = useTRPC(); - const { data } = useQuery( - trpcReact.suspension.suspendedTaskIds.queryOptions(), - ); + const trpc = useHostTRPC(); + const { data } = useQuery(trpc.suspension.suspendedTaskIds.queryOptions()); return useMemo(() => new Set(data ?? []), [data]); } diff --git a/packages/ui/src/features/suspension/useSuspensionSettings.ts b/packages/ui/src/features/suspension/useSuspensionSettings.ts new file mode 100644 index 0000000000..e9aa5f334d --- /dev/null +++ b/packages/ui/src/features/suspension/useSuspensionSettings.ts @@ -0,0 +1,34 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +const DEFAULT_SETTINGS = { + autoSuspendEnabled: true, + maxActiveWorktrees: 5, + autoSuspendAfterDays: 7, +}; + +export function useSuspensionSettings() { + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + + const settingsQueryKey = trpc.suspension.settings.queryKey(); + + const { data: settings } = useQuery(trpc.suspension.settings.queryOptions()); + + const updateMutation = useMutation( + trpc.suspension.updateSettings.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: settingsQueryKey }); + }, + }), + ); + + const updateSettings = ( + update: Parameters<typeof updateMutation.mutateAsync>[0], + ) => updateMutation.mutateAsync(update); + + return { + settings: settings ?? DEFAULT_SETTINGS, + updateSettings, + }; +} diff --git a/apps/code/src/renderer/features/task-detail/components/BranchMismatchDialog.tsx b/packages/ui/src/features/task-detail/BranchMismatchDialog.tsx similarity index 100% rename from apps/code/src/renderer/features/task-detail/components/BranchMismatchDialog.tsx rename to packages/ui/src/features/task-detail/BranchMismatchDialog.tsx diff --git a/apps/code/src/renderer/features/task-detail/components/HeaderTitleEditor.tsx b/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx similarity index 100% rename from apps/code/src/renderer/features/task-detail/components/HeaderTitleEditor.tsx rename to packages/ui/src/features/task-detail/HeaderTitleEditor.tsx diff --git a/apps/code/src/renderer/features/task-detail/components/ActionPanel.tsx b/packages/ui/src/features/task-detail/components/ActionPanel.tsx similarity index 84% rename from apps/code/src/renderer/features/task-detail/components/ActionPanel.tsx rename to packages/ui/src/features/task-detail/components/ActionPanel.tsx index 2c7fce73b9..904ae4438a 100644 --- a/apps/code/src/renderer/features/task-detail/components/ActionPanel.tsx +++ b/packages/ui/src/features/task-detail/components/ActionPanel.tsx @@ -1,5 +1,5 @@ -import { ActionTerminal } from "@features/terminal/components/ActionTerminal"; import { Box } from "@radix-ui/themes"; +import { ActionTerminal } from "../../terminal/ActionTerminal"; interface ActionPanelProps { taskId: string; diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx b/packages/ui/src/features/task-detail/components/ChangesPanel.tsx similarity index 75% rename from apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx rename to packages/ui/src/features/task-detail/components/ChangesPanel.tsx index af6d93c7bf..b5520c4324 100644 --- a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx +++ b/packages/ui/src/features/task-detail/components/ChangesPanel.tsx @@ -1,19 +1,3 @@ -import { TreeFileRow } from "@components/TreeDirectoryRow"; -import { PanelMessage } from "@components/ui/PanelMessage"; -import { Tooltip } from "@components/ui/Tooltip"; -import { useEffectiveDiffSource } from "@features/code-review/hooks/useEffectiveDiffSource"; -import { useExternalApps } from "@features/external-apps/hooks/useExternalApps"; -import { - useGitQueries, - useLocalBranchChangedFiles, - usePrChangedFiles, -} from "@features/git-interaction/hooks/useGitQueries"; -import { makeFileKey } from "@features/git-interaction/utils/fileKey"; -import { invalidateGitWorkingTreeQueries } from "@features/git-interaction/utils/gitCacheKeys"; -import { partitionByStaged } from "@features/git-interaction/utils/partitionByStaged"; -import { updateGitCacheFromSnapshot } from "@features/git-interaction/utils/updateGitCache"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { useCloudChangedFiles } from "@features/task-detail/hooks/useCloudChangedFiles"; import { ArrowCounterClockwiseIcon, CodeIcon, @@ -22,6 +6,12 @@ import { MinusIcon, PlusIcon, } from "@phosphor-icons/react"; +import { getFileExtension } from "@posthog/shared"; +import { + ANALYTICS_EVENTS, + type FileChangeType, +} from "@posthog/shared/analytics-events"; +import type { ChangedFile, Task } from "@posthog/shared/domain-types"; import { Badge, Box, @@ -32,24 +22,32 @@ import { Spinner, Text, } from "@radix-ui/themes"; -import { useReviewNavigationStore } from "@renderer/features/code-review/stores/reviewNavigationStore"; -import { getStatusIndicator } from "@renderer/features/git-interaction/utils/gitStatusUtils"; -import { useIsCloudTask } from "@renderer/features/workspace/hooks/useIsCloudTask"; -import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; -import { trpcClient } from "@renderer/trpc/client"; -import { track } from "@renderer/utils/analytics"; -import { getFileExtension } from "@renderer/utils/path"; -import type { ChangedFile, Task } from "@shared/types"; -import { ANALYTICS_EVENTS, type FileChangeType } from "@shared/types/analytics"; -import { useQueryClient } from "@tanstack/react-query"; -import { showMessageBox } from "@utils/dialog"; -import { handleExternalAppAction } from "@utils/handleExternalAppAction"; -import { logger } from "@utils/logger"; import { Fragment, useCallback, useMemo, useState } from "react"; +import { PanelMessage } from "../../../primitives/PanelMessage"; +import { Tooltip } from "../../../primitives/Tooltip"; +import { TreeFileRow } from "../../../primitives/TreeDirectoryRow"; +import { track } from "../../../workbench/analytics"; +import { useEffectiveDiffSource } from "../../code-review/hooks/useEffectiveDiffSource"; +import { useReviewNavigationStore } from "../../code-review/reviewNavigationStore"; +import { useExternalAppAction } from "../../external-apps/useExternalAppAction"; +import { useExternalApps } from "../../external-apps/useExternalApps"; +import { + useGitQueries, + useLocalBranchChangedFiles, + usePrChangedFiles, +} from "../../git-interaction/useGitQueries"; +import { makeFileKey } from "../../git-interaction/utils/fileKey"; +import { getStatusIndicator } from "../../git-interaction/utils/gitStatusUtils"; +import { partitionByStaged } from "../../git-interaction/utils/partitionByStaged"; +import { useFileContextMenu } from "../../sessions/components/useFileContextMenu"; +import { useCwd } from "../../sidebar/useCwd"; +import { useIsCloudTask } from "../../workspace/useIsCloudTask"; +import { useWorkspace } from "../../workspace/useWorkspace"; +import { useCloudChangedFiles } from "../hooks/useCloudChangedFiles"; +import { useDiscardFile } from "../hooks/useDiscardFile"; +import { useStageToggle } from "../hooks/useStageToggle"; import { ChangesTreeView } from "./ChangesTreeView"; -const log = logger.scope("changes-panel"); - interface ChangesPanelProps { taskId: string; task: Task; @@ -62,47 +60,10 @@ interface ChangedFileItemProps { repoPath?: string; mainRepoPath?: string; onStageToggle?: (file: ChangedFile) => void; + onDiscard?: (file: ChangedFile, fileName: string) => void; depth?: number; } -function getDiscardInfo( - file: ChangedFile, - fileName: string, -): { message: string; action: string } { - switch (file.status) { - case "modified": - return { - message: `Are you sure you want to discard changes in '${fileName}'?`, - action: "Discard File", - }; - case "deleted": - return { - message: `Are you sure you want to restore '${fileName}'?`, - action: "Restore File", - }; - case "added": - return { - message: `Are you sure you want to remove '${fileName}'?`, - action: "Remove File", - }; - case "untracked": - return { - message: `Are you sure you want to delete '${fileName}'?`, - action: "Delete File", - }; - case "renamed": - return { - message: `Are you sure you want to undo the rename of '${fileName}'?`, - action: "Undo Rename File", - }; - default: - return { - message: `Are you sure you want to discard changes in '${fileName}'?`, - action: "Discard File", - }; - } -} - function CompactIconButton({ tooltip, onClick, @@ -134,14 +95,16 @@ function ChangedFileItem({ repoPath, mainRepoPath, onStageToggle, + onDiscard, depth = 0, }: ChangedFileItemProps) { const requestScrollToFile = useReviewNavigationStore( (state) => state.requestScrollToFile, ); - const queryClient = useQueryClient(); + const openExternalApp = useExternalAppAction(); const { detectedApps } = useExternalApps(); const workspace = useWorkspace(taskId); + const { openForFile } = useFileContextMenu(); const [isHovered, setIsHovered] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -172,25 +135,17 @@ function ChangedFileItem({ const handleContextMenu = repoPath ? async (e: React.MouseEvent) => { e.preventDefault(); - const result = await trpcClient.contextMenu.showFileContextMenu.mutate({ - filePath: fullPath, + await openForFile({ + absolutePath: fullPath, + filename: fileName, + workspace, + mainRepoPath, }); - - if (!result.action) return; - - if (result.action.type === "external-app") { - await handleExternalAppAction( - result.action.action, - fullPath, - fileName, - workspaceContext, - ); - } } : undefined; const handleOpenWith = async (appId: string) => { - await handleExternalAppAction( + await openExternalApp( { type: "open-in-app", appId }, fullPath, fileName, @@ -203,40 +158,14 @@ function ChangedFileItem({ }; const handleCopyPath = async () => { - await handleExternalAppAction({ type: "copy-path" }, fullPath, fileName); + await openExternalApp({ type: "copy-path" }, fullPath, fileName); }; - const handleDiscard = repoPath - ? async (e: React.MouseEvent) => { + const handleDiscard = onDiscard + ? (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - - const { message, action } = getDiscardInfo(file, fileName); - - const dialogResult = await showMessageBox({ - type: "warning", - title: "Discard changes", - message, - buttons: ["Cancel", action], - defaultId: 1, - cancelId: 0, - }); - - if (dialogResult.response !== 1) return; - - const discardResult = await trpcClient.git.discardFileChanges.mutate({ - directoryPath: repoPath, - filePath: file.originalPath ?? file.path, - fileStatus: file.status, - }); - - if (discardResult.state) { - updateGitCacheFromSnapshot( - queryClient, - repoPath, - discardResult.state, - ); - } + onDiscard(file, fileName); } : undefined; @@ -501,11 +430,12 @@ function LocalWorkingTreeChangesPanel({ }: ChangesPanelProps) { const workspace = useWorkspace(taskId); const repoPath = useCwd(taskId); - const queryClient = useQueryClient(); const activeFilePath = useReviewNavigationStore( (s) => s.activeFilePaths[taskId] ?? null, ); const { changedFiles, changesLoading: isLoading } = useGitQueries(repoPath); + const handleStageToggle = useStageToggle(repoPath); + const handleDiscard = useDiscardFile(repoPath); const { stagedFiles, unstagedFiles } = useMemo( () => partitionByStaged(changedFiles), @@ -514,27 +444,6 @@ function LocalWorkingTreeChangesPanel({ const hasStagedFiles = stagedFiles.length > 0; - const handleStageToggle = useCallback( - async (file: ChangedFile) => { - if (!repoPath) return; - const paths = [file.originalPath ?? file.path]; - const endpoint = file.staged - ? trpcClient.git.unstageFiles - : trpcClient.git.stageFiles; - try { - const result = await endpoint.mutate({ - directoryPath: repoPath, - paths, - }); - updateGitCacheFromSnapshot(queryClient, repoPath, result); - invalidateGitWorkingTreeQueries(repoPath); - } catch (error) { - log.error("Failed to toggle staging", { file: file.path, error }); - } - }, - [repoPath, queryClient], - ); - const renderLocalFile = useCallback( (file: ChangedFile, depth: number) => { const key = makeFileKey(file.staged, file.path); @@ -547,6 +456,7 @@ function LocalWorkingTreeChangesPanel({ isActive={activeFilePath === key} mainRepoPath={workspace?.folderPath} onStageToggle={handleStageToggle} + onDiscard={handleDiscard} depth={depth} /> ); @@ -557,6 +467,7 @@ function LocalWorkingTreeChangesPanel({ activeFilePath, workspace?.folderPath, handleStageToggle, + handleDiscard, ], ); diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesTreeView.tsx b/packages/ui/src/features/task-detail/components/ChangesTreeView.tsx similarity index 96% rename from apps/code/src/renderer/features/task-detail/components/ChangesTreeView.tsx rename to packages/ui/src/features/task-detail/components/ChangesTreeView.tsx index 30b5ac74e8..e21aad27cd 100644 --- a/apps/code/src/renderer/features/task-detail/components/ChangesTreeView.tsx +++ b/packages/ui/src/features/task-detail/components/ChangesTreeView.tsx @@ -1,5 +1,5 @@ -import { TreeDirectoryRow } from "@components/TreeDirectoryRow"; -import type { ChangedFile } from "@shared/types"; +import type { ChangedFile } from "@posthog/shared/domain-types"; +import { TreeDirectoryRow } from "@posthog/ui/primitives/TreeDirectoryRow"; import { useCallback, useMemo, useState } from "react"; export interface TreeNode { diff --git a/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx b/packages/ui/src/features/task-detail/components/CloudGithubMissingNotice.tsx similarity index 88% rename from apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx rename to packages/ui/src/features/task-detail/components/CloudGithubMissingNotice.tsx index 2e8b37bdad..bf29d50da8 100644 --- a/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx +++ b/packages/ui/src/features/task-detail/components/CloudGithubMissingNotice.tsx @@ -1,10 +1,10 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { ArrowSquareOutIcon, InfoIcon } from "@phosphor-icons/react"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { describeGithubConnectError, useGithubConnect, -} from "@features/integrations/hooks/useGithubUserConnect"; -import { useRepositoryIntegration } from "@hooks/useIntegrations"; -import { ArrowSquareOutIcon, InfoIcon } from "@phosphor-icons/react"; +} from "@posthog/ui/features/integrations/useGithubUserConnect"; +import { useRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; import { Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; export function CloudGithubMissingNotice() { diff --git a/apps/code/src/renderer/features/task-detail/components/ExternalAppsOpener.tsx b/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx similarity index 88% rename from apps/code/src/renderer/features/task-detail/components/ExternalAppsOpener.tsx rename to packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx index 9d71098c8e..eade45a9f9 100644 --- a/apps/code/src/renderer/features/task-detail/components/ExternalAppsOpener.tsx +++ b/packages/ui/src/features/task-detail/components/ExternalAppsOpener.tsx @@ -1,4 +1,3 @@ -import { useExternalApps } from "@features/external-apps/hooks/useExternalApps"; import { CodeIcon, CopyIcon } from "@phosphor-icons/react"; import { Button, @@ -11,11 +10,12 @@ import { DropdownMenuTrigger, Kbd, } from "@posthog/quill"; -import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; -import { handleExternalAppAction } from "@utils/handleExternalAppAction"; import { ChevronDown } from "lucide-react"; import { useCallback } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +import { SHORTCUTS } from "../../command/keyboard-shortcuts"; +import { useExternalAppAction } from "../../external-apps/useExternalAppAction"; +import { useExternalApps } from "../../external-apps/useExternalApps"; const THUMBNAIL_ICON_SIZE = 20; const DROPDOWN_ICON_SIZE = 20; @@ -25,42 +25,39 @@ interface ExternalAppsOpenerProps { } export function ExternalAppsOpener({ targetPath }: ExternalAppsOpenerProps) { + const openExternalApp = useExternalAppAction(); const { detectedApps, defaultApp, isLoading, setLastUsedApp } = useExternalApps(); const handleOpenDefault = useCallback(async () => { if (!defaultApp || !targetPath) return; const displayName = targetPath.split("/").pop() || targetPath; - await handleExternalAppAction( + await openExternalApp( { type: "open-in-app", appId: defaultApp.id }, targetPath, displayName, ); - }, [defaultApp, targetPath]); + }, [openExternalApp, defaultApp, targetPath]); const handleOpenWith = useCallback( async (appId: string) => { if (!targetPath) return; const displayName = targetPath.split("/").pop() || targetPath; - await handleExternalAppAction( + await openExternalApp( { type: "open-in-app", appId }, targetPath, displayName, ); await setLastUsedApp(appId); }, - [targetPath, setLastUsedApp], + [openExternalApp, targetPath, setLastUsedApp], ); const handleCopyPath = useCallback(async () => { if (!targetPath) return; const displayName = targetPath.split("/").pop() || targetPath; - await handleExternalAppAction( - { type: "copy-path" }, - targetPath, - displayName, - ); - }, [targetPath]); + await openExternalApp({ type: "copy-path" }, targetPath, displayName); + }, [openExternalApp, targetPath]); useHotkeys( SHORTCUTS.OPEN_IN_EDITOR, diff --git a/apps/code/src/renderer/features/task-detail/components/FileTreePanel.tsx b/packages/ui/src/features/task-detail/components/FileTreePanel.tsx similarity index 80% rename from apps/code/src/renderer/features/task-detail/components/FileTreePanel.tsx rename to packages/ui/src/features/task-detail/components/FileTreePanel.tsx index 0a6018fb4a..ab174bfc07 100644 --- a/apps/code/src/renderer/features/task-detail/components/FileTreePanel.tsx +++ b/packages/ui/src/features/task-detail/components/FileTreePanel.tsx @@ -1,24 +1,27 @@ -import { TreeDirectoryRow, TreeFileRow } from "@components/TreeDirectoryRow"; -import { PanelMessage } from "@components/ui/PanelMessage"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { isFileTabActiveInTree } from "@features/panels/store/panelStoreHelpers"; -import { - selectIsPathExpanded, - useFileTreeStore, -} from "@features/right-sidebar/stores/fileTreeStore"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { useCloudRunState } from "@features/task-detail/hooks/useCloudRunState"; import { Cloud } from "@phosphor-icons/react"; -import { useFileWatcher as useFileWatcherUI } from "@posthog/ui/features/file-watcher/useFileWatcher"; +import { toRelativePath } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; import { useWorkspaceTRPC } from "@posthog/workspace-client/trpc"; import { Box, Button, Flex, Spinner, Text } from "@radix-ui/themes"; -import { useIsCloudTask } from "@renderer/features/workspace/hooks/useIsCloudTask"; -import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; -import { trpcClient } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { handleExternalAppAction } from "@utils/handleExternalAppAction"; -import { toRelativePath } from "@utils/path"; +import { PanelMessage } from "../../../primitives/PanelMessage"; +import { + TreeDirectoryRow, + TreeFileRow, +} from "../../../primitives/TreeDirectoryRow"; +import { openExternalUrl } from "../../../workbench/openExternal"; +import { useFileWatcher as useFileWatcherUI } from "../../file-watcher/useFileWatcher"; +import { usePanelLayoutStore } from "../../panels/panelLayoutStore"; +import { isFileTabActiveInTree } from "../../panels/panelStoreHelpers"; +import { + selectIsPathExpanded, + useFileTreeStore, +} from "../../right-sidebar/fileTreeStore"; +import { useFileContextMenu } from "../../sessions/components/useFileContextMenu"; +import { useCwd } from "../../sidebar/useCwd"; +import { useIsCloudTask } from "../../workspace/useIsCloudTask"; +import { useWorkspace } from "../../workspace/useWorkspace"; +import { useCloudRunState } from "../hooks/useCloudRunState"; interface FileTreePanelProps { taskId: string; @@ -53,6 +56,7 @@ function LazyTreeItem({ const collapseAll = useFileTreeStore((state) => state.collapseAll); const openFileInSplit = usePanelLayoutStore((state) => state.openFileInSplit); const workspace = useWorkspace(taskId); + const { openForFile } = useFileContextMenu(); const wsTrpc = useWorkspaceTRPC(); const { data: children } = useQuery( @@ -84,23 +88,14 @@ function LazyTreeItem({ const handleContextMenu = async (e: React.MouseEvent) => { e.preventDefault(); - const result = await trpcClient.contextMenu.showFileContextMenu.mutate({ - filePath: entry.path, + await openForFile({ + absolutePath: entry.path, + filename: entry.name, + workspace, + mainRepoPath, showCollapseAll: true, + onCollapseAll: () => collapseAll(taskId), }); - - if (!result.action) return; - - if (result.action.type === "collapse-all") { - collapseAll(taskId); - } else if (result.action.type === "external-app") { - await handleExternalAppAction( - result.action.action, - entry.path, - entry.name, - { workspace, mainRepoPath }, - ); - } }; const isDirectory = entry.type === "directory"; @@ -183,9 +178,7 @@ function CloudFileTreePanel({ taskId, task }: FileTreePanelProps) { <Button size="1" variant="soft" - onClick={() => - trpcClient.os.openExternal.mutate({ url: githubUrl }) - } + onClick={() => openExternalUrl(githubUrl)} > View on GitHub </Button> diff --git a/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx b/packages/ui/src/features/task-detail/components/SuggestedTaskCard.tsx similarity index 96% rename from apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx rename to packages/ui/src/features/task-detail/components/SuggestedTaskCard.tsx index c3785c6e0e..22940abf30 100644 --- a/apps/code/src/renderer/features/task-detail/components/SuggestedTaskCard.tsx +++ b/packages/ui/src/features/task-detail/components/SuggestedTaskCard.tsx @@ -1,9 +1,9 @@ -import type { DiscoveredTask } from "@features/setup/types"; +import { X } from "@phosphor-icons/react"; +import type { DiscoveredTask } from "@posthog/core/setup/types"; import { CATEGORY_CONFIG, FALLBACK_CATEGORY_CONFIG, -} from "@features/setup/utils/categoryConfig"; -import { X } from "@phosphor-icons/react"; +} from "@posthog/ui/features/setup/categoryConfig"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { motion } from "framer-motion"; diff --git a/apps/code/src/renderer/features/task-detail/components/SuggestedTasksPanel.tsx b/packages/ui/src/features/task-detail/components/SuggestedTasksPanel.tsx similarity index 96% rename from apps/code/src/renderer/features/task-detail/components/SuggestedTasksPanel.tsx rename to packages/ui/src/features/task-detail/components/SuggestedTasksPanel.tsx index f664fcf38e..426db3374e 100644 --- a/apps/code/src/renderer/features/task-detail/components/SuggestedTasksPanel.tsx +++ b/packages/ui/src/features/task-detail/components/SuggestedTasksPanel.tsx @@ -1,20 +1,11 @@ -import { DiscoveredTaskDetailDialog } from "@features/setup/components/DiscoveredTaskDetailDialog"; -import { SetupScanFeed } from "@features/setup/components/SetupScanFeed"; -import { - isTaskForRepo, - selectRepoDiscovery, - selectRepoEnricher, - useSetupStore, -} from "@features/setup/stores/setupStore"; -import type { DiscoveredTask } from "@features/setup/types"; import { CaretLeft, CaretRight, Lightning, MagnifyingGlass, } from "@phosphor-icons/react"; +import type { DiscoveredTask } from "@posthog/core/setup/types"; import { Flex, Text } from "@radix-ui/themes"; -import { useActiveRepoStore } from "@stores/activeRepoStore"; import { AnimatePresence, motion } from "framer-motion"; import { useCallback, @@ -23,6 +14,15 @@ import { useRef, useState, } from "react"; +import { useActiveRepoStore } from "../../../workbench/activeRepoStore"; +import { DiscoveredTaskDetailDialog } from "../../setup/DiscoveredTaskDetailDialog"; +import { SetupScanFeed } from "../../setup/SetupScanFeed"; +import { + isTaskForRepo, + selectRepoDiscovery, + selectRepoEnricher, + useSetupStore, +} from "../../setup/setupStore"; import { SuggestedTaskCard } from "./SuggestedTaskCard"; const VISIBLE_LIMIT = 3; diff --git a/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx similarity index 61% rename from apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx rename to packages/ui/src/features/task-detail/components/TabContentRenderer.tsx index dfecb30cc6..e460bfe500 100644 --- a/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx +++ b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx @@ -1,14 +1,14 @@ -import { CodeEditorPanel } from "@features/code-editor/components/CodeEditorPanel"; -import type { Tab } from "@features/panels/store/panelTypes"; -import { ActionPanel } from "@features/task-detail/components/ActionPanel"; -import { ChangesPanel } from "@features/task-detail/components/ChangesPanel"; -import { FileTreePanel } from "@features/task-detail/components/FileTreePanel"; -import { TaskLogsPanel } from "@features/task-detail/components/TaskLogsPanel"; -import { TaskShellPanel } from "@features/task-detail/components/TaskShellPanel"; -import { useIsWorkspaceCloudRun } from "@features/workspace/hooks/useWorkspace"; -import { CloudReviewPage } from "@renderer/features/code-review/components/CloudReviewPage"; -import { ReviewPage } from "@renderer/features/code-review/components/ReviewPage"; -import type { Task } from "@shared/types"; +import type { Task } from "@posthog/shared/domain-types"; +import { CodeEditorPanel } from "../../code-editor/components/CodeEditorPanel"; +import { CloudReviewPage } from "../../code-review/components/CloudReviewPage"; +import { ReviewPage } from "../../code-review/components/ReviewPage"; +import type { Tab } from "../../panels/panelTypes"; +import { useIsWorkspaceCloudRun } from "../../workspace/useWorkspace"; +import { ActionPanel } from "./ActionPanel"; +import { ChangesPanel } from "./ChangesPanel"; +import { FileTreePanel } from "./FileTreePanel"; +import { TaskLogsPanel } from "./TaskLogsPanel"; +import { TaskShellPanel } from "./TaskShellPanel"; interface TabContentRendererProps { tab: Tab; diff --git a/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx b/packages/ui/src/features/task-detail/components/TaskDetail.tsx similarity index 82% rename from apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx rename to packages/ui/src/features/task-detail/components/TaskDetail.tsx index 9231408c40..cb2366c9c5 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskDetail.tsx +++ b/packages/ui/src/features/task-detail/components/TaskDetail.tsx @@ -1,32 +1,28 @@ -import { CloudReviewPage } from "@features/code-review/components/CloudReviewPage"; -import { ReviewPage } from "@features/code-review/components/ReviewPage"; -import { useReviewNavigationStore } from "@features/code-review/stores/reviewNavigationStore"; -import { FilePicker } from "@features/command/components/FilePicker"; -import { clearGitReviewQueries } from "@features/git-interaction/utils/gitCacheKeys"; -import { PanelLayout } from "@features/panels"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { - getLeafPanel, - parseTabId, -} from "@features/panels/store/panelStoreHelpers"; -import { MIN_CHAT_WIDTH } from "@features/sessions/constants"; -import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { useTaskData } from "@features/task-detail/hooks/useTaskData"; -import { useRenameTask } from "@features/tasks/hooks/useTasks"; -import { useWorkspaceEvents } from "@features/workspace/hooks"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; -import { useBlurOnEscape } from "@hooks/useBlurOnEscape"; -import { useFileWatcher } from "@hooks/useFileWatcher"; -import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; +import type { Task } from "@posthog/shared/domain-types"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; -import type { Task } from "@shared/types"; -import { logger } from "@utils/logger"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys, useHotkeysContext } from "react-hotkeys-hook"; +import { useBlurOnEscape } from "../../../hooks/useBlurOnEscape"; +import { useSetHeaderContent } from "../../../hooks/useSetHeaderContent"; +import { logger } from "../../../workbench/logger"; +import { CloudReviewPage } from "../../code-review/components/CloudReviewPage"; +import { ReviewPage } from "../../code-review/components/ReviewPage"; +import { useReviewNavigationStore } from "../../code-review/reviewNavigationStore"; +import { FilePicker } from "../../command/FilePicker"; +import { useRepoFileWatcher } from "../../file-watcher/useRepoFileWatcher"; +import { clearGitReviewQueries } from "../../git-interaction/gitCacheKeys"; +import { PanelLayout } from "../../panels/components/PanelLayout"; +import { usePanelLayoutStore } from "../../panels/panelLayoutStore"; +import { getLeafPanel, parseTabId } from "../../panels/panelStoreHelpers"; +import { MIN_CHAT_WIDTH } from "../../sessions/constants"; +import { useCwd } from "../../sidebar/useCwd"; +import { useRenameTask } from "../../tasks/useTaskMutations"; +import { useWorkspace } from "../../workspace/useWorkspace"; +import { useWorkspaceEvents } from "../../workspace/useWorkspaceEvents"; +import { HeaderTitleEditor } from "../HeaderTitleEditor"; +import { useTaskData } from "../hooks/useTaskData"; import { ExternalAppsOpener } from "./ExternalAppsOpener"; -import { HeaderTitleEditor } from "./HeaderTitleEditor"; - const MIN_REVIEW_WIDTH = 300; const log = logger.scope("task-detail"); @@ -80,7 +76,7 @@ export function TaskDetail({ task: initialTask }: TaskDetailProps) { preventDefault: true, }); - useFileWatcher(effectiveRepoPath ?? null, taskId); + useRepoFileWatcher(effectiveRepoPath ?? null, taskId); useBlurOnEscape(); useWorkspaceEvents(taskId); diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx similarity index 88% rename from apps/code/src/renderer/features/task-detail/components/TaskInput.tsx rename to packages/ui/src/features/task-detail/components/TaskInput.tsx index f49729abd4..081d3f33a0 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -1,61 +1,66 @@ -import { DotPatternBackground } from "@components/DotPatternBackground"; -import { EnvironmentSelector } from "@features/environments/components/EnvironmentSelector"; -import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; -import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker"; -import { useFolders } from "@features/folders/hooks/useFolders"; -import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; -import { GitBranchDialog } from "@features/git-interaction/components/GitInteractionDialogs"; -import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; -import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; +import { X } from "@phosphor-icons/react"; +import { isValidConfigValue } from "@posthog/core/task-detail/configOptions"; +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { ButtonGroup } from "@posthog/quill"; +import type { Task } from "@posthog/shared/domain-types"; +import { Flex, Text, Tooltip } from "@radix-ui/themes"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useConnectivity } from "../../../hooks/useConnectivity"; +import { DotPatternBackground } from "../../../primitives/DotPatternBackground"; +import { toast } from "../../../primitives/toast"; +import { FOCUSABLE_SELECTOR } from "../../../utils/overlay"; +import { useActiveRepoStore } from "../../../workbench/activeRepoStore"; +import { useAuthStateValue } from "../../auth/store"; +import { EnvironmentSelector } from "../../environments/EnvironmentSelector"; +import { FolderPicker } from "../../folder-picker/FolderPicker"; +import { GitHubRepoPicker } from "../../folder-picker/GitHubRepoPicker"; +import { useFolders } from "../../folders/useFolders"; +import { BranchSelector } from "../../git-interaction/components/BranchSelector"; +import { GitBranchDialog } from "../../git-interaction/components/GitInteractionDialogs"; +import { useGitInteractionStore } from "../../git-interaction/state/gitInteractionStore"; +import { useGitQueries } from "../../git-interaction/useGitQueries"; import { createBranch, getBranchNameInputState, -} from "@features/git-interaction/utils/branchCreation"; -import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; -import { PromptHistoryDialog } from "@features/message-editor/components/PromptHistoryDialog"; -import { PromptInput } from "@features/message-editor/components/PromptInput"; -import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; -import type { EditorHandle } from "@features/message-editor/types"; -import { resolveAndAttachDroppedFiles } from "@features/message-editor/utils/persistFile"; -import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; -import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; -import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; -import { getCurrentModeFromConfigOptions } from "@features/sessions/stores/sessionStore"; -import type { AgentAdapter } from "@features/settings/stores/settingsStore"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useAutoFocusOnTyping } from "@hooks/useAutoFocusOnTyping"; -import { useConnectivity } from "@hooks/useConnectivity"; +} from "../../git-interaction/utils/branchCreation"; +import { useInboxReportSelectionStore } from "../../inbox/inboxReportSelectionStore"; import { useUserGithubBranches, useUserGithubRepositories, useUserRepositoryIntegration, -} from "@hooks/useIntegrations"; -import { X } from "@phosphor-icons/react"; -import { ButtonGroup } from "@posthog/quill"; -import { Flex, Text, Tooltip } from "@radix-ui/themes"; -import { useAuthStore } from "@renderer/features/auth/stores/authStore"; -import { useDraftStore } from "@renderer/features/message-editor/stores/draftStore"; -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { toast } from "@renderer/utils/toast"; -import { useActiveRepoStore } from "@stores/activeRepoStore"; +} from "../../integrations/useIntegrations"; +import { PromptHistoryDialog } from "../../message-editor/components/PromptHistoryDialog"; +import { PromptInput } from "../../message-editor/components/PromptInput"; +import { useDraftStore } from "../../message-editor/draftStore"; +import { useTaskInputHistoryStore } from "../../message-editor/taskInputHistoryStore"; +import type { EditorHandle } from "../../message-editor/types"; +import { useAutoFocusOnTyping } from "../../message-editor/useAutoFocusOnTyping"; +import { resolveAndAttachDroppedFiles } from "../../message-editor/utils/persistFile"; import { type TaskInputReportAssociation, useNavigationStore, -} from "@stores/navigationStore"; -import { useQuery } from "@tanstack/react-query"; -import { FOCUSABLE_SELECTOR } from "@utils/overlay"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +} from "../../navigation/store"; +import { DropZoneOverlay } from "../../sessions/components/DropZoneOverlay"; +import { ReasoningLevelSelector } from "../../sessions/components/ReasoningLevelSelector"; +import { UnifiedModelSelector } from "../../sessions/components/UnifiedModelSelector"; +import { getCurrentModeFromConfigOptions } from "../../sessions/sessionStore"; +import { useSettingsDialogStore } from "../../settings/settingsDialogStore"; +import { + type AgentAdapter, + useSettingsStore, +} from "../../settings/settingsStore"; +import { useSkills } from "../../skills/useSkills"; import { useInitialDirectoryFromFolderId } from "../hooks/useInitialDirectoryFromFolderId"; import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; -import { isValidConfigValue } from "../utils/configOptions"; import { CloudGithubMissingNotice } from "./CloudGithubMissingNotice"; import { SuggestedTasksPanel } from "./SuggestedTasksPanel"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; interface TaskInputProps { sessionId?: string; - onTaskCreated?: (task: import("@shared/types").Task) => void; + onTaskCreated?: (task: Task) => void; initialPrompt?: string; initialPromptKey?: string; initialCloudRepository?: string; @@ -74,8 +79,18 @@ export function TaskInput({ initialMode, reportAssociation, }: TaskInputProps = {}) { - const { cloudRegion } = useAuthStore(); - const trpcReact = useTRPC(); + const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + const trpc = useHostTRPC(); + const hostClient = useHostTRPCClient(); + const { data: skills } = useSkills(); + const gitWriteClient = useMemo( + () => ({ + createBranch: async (directoryPath: string, branchName: string) => { + await hostClient.git.createBranch.mutate({ directoryPath, branchName }); + }, + }), + [hostClient], + ); const { view, clearTaskInputReportAssociation, navigateToInbox } = useNavigationStore(); const setSelectedReportIds = useInboxReportSelectionStore( @@ -84,7 +99,7 @@ export function TaskInput({ const selectedDirectory = useActiveRepoStore((s) => s.path); const setSelectedDirectory = useActiveRepoStore((s) => s.setPath); const { data: mostRecentRepo } = useQuery( - trpcReact.folders.getMostRecentlyAccessedRepository.queryOptions(), + trpc.folders.getMostRecentlyAccessedRepository.queryOptions(), ); const { setLastUsedLocalWorkspaceMode, @@ -265,6 +280,7 @@ export function TaskInput({ try { const result = await createBranch({ + writeClient: gitWriteClient, repoPath: selectedDirectory || undefined, rawBranchName: newBranchName, }); @@ -278,7 +294,7 @@ export function TaskInput({ } finally { setIsCreatingBranch(false); } - }, [selectedDirectory, newBranchName, gitActions]); + }, [selectedDirectory, newBranchName, gitActions, gitWriteClient]); const handleRepositorySelect = useCallback( (repo: string | null) => { @@ -523,19 +539,15 @@ export function TaskInput({ // Populate command list for @ file mentions + / skills on mount useEffect(() => { - let cancelled = false; - trpcClient.skills.list.query().then((skills) => { - if (cancelled) return; - useDraftStore.getState().actions.setCommands( - promptSessionId, - skills.map((s) => ({ name: s.name, description: s.description })), - ); - }); + if (!skills) return; + useDraftStore.getState().actions.setCommands( + promptSessionId, + skills.map((s) => ({ name: s.name, description: s.description })), + ); return () => { - cancelled = true; useDraftStore.getState().actions.clearCommands(promptSessionId); }; - }, [promptSessionId]); + }, [promptSessionId, skills]); const hasHistory = useTaskInputHistoryStore((s) => s.entries.length > 0); const getPromptHistory = useCallback( @@ -648,6 +660,11 @@ export function TaskInput({ value={selectedEnvironment} onChange={setSelectedEnvironment} disabled={isCreatingTask} + onCreateEnvironment={() => + useSettingsDialogStore.getState().open("environments", { + repoPath: effectiveRepoPath ?? undefined, + }) + } /> )} <ButtonGroup diff --git a/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx b/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx similarity index 73% rename from apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx rename to packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx index 38ec43c016..f84cca7de6 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx +++ b/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx @@ -1,26 +1,24 @@ -import { BackgroundWrapper } from "@components/BackgroundWrapper"; -import { ErrorBoundary } from "@components/ErrorBoundary"; -import { useFolders } from "@features/folders/hooks/useFolders"; -import { useDraftStore } from "@features/message-editor/stores/draftStore"; -import { ProvisioningView } from "@features/provisioning/components/ProvisioningView"; -import { useProvisioningStore } from "@features/provisioning/stores/provisioningStore"; -import { SessionView } from "@features/sessions/components/SessionView"; -import { useSessionCallbacks } from "@features/sessions/hooks/useSessionCallbacks"; -import { useSessionConnection } from "@features/sessions/hooks/useSessionConnection"; -import { useSessionViewState } from "@features/sessions/hooks/useSessionViewState"; -import { useRestoreTask } from "@features/suspension/hooks/useRestoreTask"; -import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds"; -import { BranchMismatchDialog } from "@features/task-detail/components/BranchMismatchDialog"; -import { WorkspaceSetupPrompt } from "@features/task-detail/components/WorkspaceSetupPrompt"; -import { useBranchMismatchDialog } from "@features/workspace/hooks/useBranchMismatchDialog"; -import { - useCreateWorkspace, - useWorkspaceLoaded, -} from "@features/workspace/hooks/useWorkspace"; +import { getTaskRepository } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; import { Box, Flex } from "@radix-ui/themes"; -import type { Task } from "@shared/types"; -import { getTaskRepository } from "@utils/repository"; import { useCallback, useEffect } from "react"; +import { BackgroundWrapper } from "../../../primitives/BackgroundWrapper"; +import { ErrorBoundary } from "../../../primitives/ErrorBoundary"; +import { useFolders } from "../../folders/useFolders"; +import { useDraftStore } from "../../message-editor/draftStore"; +import { ProvisioningView } from "../../provisioning/ProvisioningView"; +import { useProvisioningStore } from "../../provisioning/store"; +import { SessionView } from "../../sessions/components/SessionView"; +import { useSessionCallbacks } from "../../sessions/hooks/useSessionCallbacks"; +import { useSessionConnection } from "../../sessions/hooks/useSessionConnection"; +import { useSessionViewState } from "../../sessions/hooks/useSessionViewState"; +import { useRestoreTask } from "../../suspension/useRestoreTask"; +import { useSuspendedTaskIds } from "../../suspension/useSuspendedTaskIds"; +import { useBranchMismatchDialog } from "../../workspace/useBranchMismatchDialog"; +import { useWorkspaceLoaded } from "../../workspace/useWorkspace"; +import { useCreateWorkspace } from "../../workspace/useWorkspaceMutations"; +import { BranchMismatchDialog } from "../BranchMismatchDialog"; +import { WorkspaceSetupPrompt } from "./WorkspaceSetupPrompt"; interface TaskLogsPanelProps { taskId: string; diff --git a/apps/code/src/renderer/features/task-detail/components/TaskPendingView.tsx b/packages/ui/src/features/task-detail/components/TaskPendingView.tsx similarity index 73% rename from apps/code/src/renderer/features/task-detail/components/TaskPendingView.tsx rename to packages/ui/src/features/task-detail/components/TaskPendingView.tsx index 37563e1082..464eb656ef 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskPendingView.tsx +++ b/packages/ui/src/features/task-detail/components/TaskPendingView.tsx @@ -1,6 +1,6 @@ -import { PendingChatView } from "@features/sessions/components/PendingChatView"; import { Box } from "@radix-ui/themes"; -import { usePendingTaskPrompt } from "@stores/pendingTaskPromptStore"; +import { usePendingTaskPrompt } from "../../../workbench/pendingTaskPromptStore"; +import { PendingChatView } from "../../sessions/components/PendingChatView"; interface TaskPendingViewProps { pendingTaskKey: string; diff --git a/apps/code/src/renderer/features/task-detail/components/TaskShellPanel.tsx b/packages/ui/src/features/task-detail/components/TaskShellPanel.tsx similarity index 60% rename from apps/code/src/renderer/features/task-detail/components/TaskShellPanel.tsx rename to packages/ui/src/features/task-detail/components/TaskShellPanel.tsx index e3c0e489bf..dc9eb30c36 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskShellPanel.tsx +++ b/packages/ui/src/features/task-detail/components/TaskShellPanel.tsx @@ -1,11 +1,12 @@ -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { useSessionForTask } from "@features/sessions/stores/sessionStore"; -import { ShellTerminal } from "@features/terminal/components/ShellTerminal"; -import { useTerminalStore } from "@features/terminal/stores/terminalStore"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; +import type { Task } from "@posthog/shared/domain-types"; import { Box } from "@radix-ui/themes"; -import type { Task } from "@shared/types"; import { useEffect } from "react"; +import { usePanelLayoutStore } from "../../panels/panelLayoutStore"; +import { useSessionForTask } from "../../sessions/sessionStore"; +import { ShellTerminal } from "../../terminal/ShellTerminal"; +import { useTerminalStore } from "../../terminal/terminalStore"; +import { useShellProcessPoller } from "../../terminal/useShellProcessPoller"; +import { useWorkspace } from "../../workspace/useWorkspace"; interface TaskShellPanelProps { taskId: string; @@ -28,14 +29,9 @@ export function TaskShellPanel({ const processName = useTerminalStore( (state) => state.terminalStates[stateKey]?.processName, ); - const startPolling = useTerminalStore((state) => state.startPolling); - const stopPolling = useTerminalStore((state) => state.stopPolling); const updateTabLabel = usePanelLayoutStore((state) => state.updateTabLabel); - useEffect(() => { - startPolling(stateKey); - return () => stopPolling(stateKey); - }, [stateKey, startPolling, stopPolling]); + useShellProcessPoller(stateKey); useEffect(() => { if (processName) { diff --git a/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx b/packages/ui/src/features/task-detail/components/WorkspaceModeSelect.tsx similarity index 96% rename from apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx rename to packages/ui/src/features/task-detail/components/WorkspaceModeSelect.tsx index 21d8c9380d..6a6b7f39fa 100644 --- a/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx +++ b/packages/ui/src/features/task-detail/components/WorkspaceModeSelect.tsx @@ -1,7 +1,3 @@ -import { useSandboxEnvironments } from "@features/settings/hooks/useSandboxEnvironments"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; -import type { WorkspaceMode } from "@main/services/workspace/schemas"; import { ArrowsSplit, CaretDown, @@ -24,7 +20,11 @@ import { ItemTitle, MenuLabel, } from "@posthog/quill"; +import type { WorkspaceMode } from "@posthog/shared"; import { useCallback, useMemo, useState } from "react"; +import { useFeatureFlag } from "../../feature-flags/useFeatureFlag"; +import { useSandboxEnvironments } from "../../settings/sections/environments/useSandboxEnvironments"; +import { useSettingsDialogStore } from "../../settings/settingsDialogStore"; export type { WorkspaceMode }; diff --git a/apps/code/src/renderer/features/task-detail/components/WorkspaceSetupPrompt.tsx b/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx similarity index 65% rename from apps/code/src/renderer/features/task-detail/components/WorkspaceSetupPrompt.tsx rename to packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx index a5c6e50985..31901d482b 100644 --- a/apps/code/src/renderer/features/task-detail/components/WorkspaceSetupPrompt.tsx +++ b/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx @@ -1,16 +1,19 @@ -import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; -import { foldersApi } from "@features/folders/hooks/useFolders"; -import { useEnsureWorkspace } from "@features/workspace/hooks/useWorkspace"; import { Folder, Warning } from "@phosphor-icons/react"; +import { + WORKSPACE_SETUP_SAGA, + type WorkspaceSetupSaga, +} from "@posthog/core/task-detail/workspaceSetupSaga"; +import { WORKSPACE_SETUP_SERVICE } from "@posthog/core/workspace/identifiers"; +import type { WorkspaceSetupService } from "@posthog/core/workspace/WorkspaceSetupService"; +import { useService } from "@posthog/di/react"; +import { getTaskRepository } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; import { Box, Button, Code, Flex, Spinner, Text } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; -import { logger } from "@utils/logger"; -import { getTaskRepository } from "@utils/repository"; -import { toast } from "@utils/toast"; -import { useCallback, useState } from "react"; - -const log = logger.scope("workspace-setup-prompt"); +import { useCallback, useMemo, useState } from "react"; +import { toast } from "../../../primitives/toast"; +import { FolderPicker } from "../../folder-picker/FolderPicker"; +import { useFolders } from "../../folders/useFolders"; +import { useEnsureWorkspace } from "../../workspace/useWorkspaceMutations"; interface WorkspaceSetupPromptProps { taskId: string; @@ -27,6 +30,16 @@ export function WorkspaceSetupPrompt({ const [detectedRepo, setDetectedRepo] = useState<string | null>(null); const repository = getTaskRepository(task); const { ensureWorkspace } = useEnsureWorkspace(); + const { addFolder } = useFolders(); + const setupService = useService<WorkspaceSetupService>( + WORKSPACE_SETUP_SERVICE, + ); + const setupSaga = useService<WorkspaceSetupSaga>(WORKSPACE_SETUP_SAGA); + + const executor = useMemo( + () => ({ addFolder, ensureWorkspace }), + [addFolder, ensureWorkspace], + ); const proceedWithSetup = useCallback( async (path: string) => { @@ -35,49 +48,32 @@ export function WorkspaceSetupPrompt({ setSelectedPath(path); setIsSettingUp(true); - try { - await foldersApi.addFolder(path); - await ensureWorkspace(taskId, path, "worktree"); - log.info("Workspace setup complete", { taskId, path }); - } catch (error) { - log.error("Failed to set up workspace", { error }); + const result = await setupSaga.setupWorkspace(executor, taskId, path); + if (!result.success) { toast.error("Failed to set up workspace. Please try again."); - } finally { - setSelectedPath(""); - setIsSettingUp(false); } + + setSelectedPath(""); + setIsSettingUp(false); }, - [taskId, ensureWorkspace], + [taskId, executor, setupSaga], ); const handleFolderSelect = useCallback( async (path: string) => { - if (repository) { - let detected = null; - try { - detected = await trpcClient.git.detectRepo.query({ - directoryPath: path, - }); - } catch (error) { - log.warn("Failed to detect repo for mismatch check", { - error, - path, - }); - } - - if (detected) { - const detectedFullName = `${detected.organization}/${detected.repository}`; - if (detectedFullName.toLowerCase() !== repository.toLowerCase()) { - setPendingPath(path); - setDetectedRepo(detectedFullName); - return; - } - } + const evaluation = await setupService.evaluateFolderSelection( + repository, + path, + ); + if (evaluation.kind === "mismatch") { + setPendingPath(path); + setDetectedRepo(evaluation.detectedRepo); + return; } await proceedWithSetup(path); }, - [repository, proceedWithSetup], + [repository, proceedWithSetup, setupService], ); const handleConfirm = useCallback(async () => { diff --git a/apps/code/src/renderer/features/task-detail/hooks/useCloudChangedFiles.ts b/packages/ui/src/features/task-detail/hooks/useCloudChangedFiles.ts similarity index 86% rename from apps/code/src/renderer/features/task-detail/hooks/useCloudChangedFiles.ts rename to packages/ui/src/features/task-detail/hooks/useCloudChangedFiles.ts index df2f4c32b4..07e9960495 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useCloudChangedFiles.ts +++ b/packages/ui/src/features/task-detail/hooks/useCloudChangedFiles.ts @@ -1,10 +1,10 @@ +import type { ChangedFile, Task } from "@posthog/shared/domain-types"; +import { useMemo } from "react"; import { useBranchChangedFiles, usePrChangedFiles, -} from "@features/git-interaction/hooks/useGitQueries"; -import { useCloudRunState } from "@features/task-detail/hooks/useCloudRunState"; -import type { ChangedFile, Task } from "@shared/types"; -import { useMemo } from "react"; +} from "../../git-interaction/useGitQueries"; +import { useCloudRunState } from "./useCloudRunState"; const EMPTY_FILES: ChangedFile[] = []; diff --git a/apps/code/src/renderer/features/task-detail/hooks/useCloudEventSummary.ts b/packages/ui/src/features/task-detail/hooks/useCloudEventSummary.ts similarity index 78% rename from apps/code/src/renderer/features/task-detail/hooks/useCloudEventSummary.ts rename to packages/ui/src/features/task-detail/hooks/useCloudEventSummary.ts index 80afe4e342..4a1d1effa7 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useCloudEventSummary.ts +++ b/packages/ui/src/features/task-detail/hooks/useCloudEventSummary.ts @@ -1,9 +1,9 @@ -import { useSessionForTask } from "@features/sessions/hooks/useSession"; import { buildCloudEventSummary, type CloudEventSummary, -} from "@features/task-detail/utils/cloudToolChanges"; +} from "@posthog/core/task-detail/cloudToolChanges"; import { useMemo } from "react"; +import { useSessionForTask } from "../../sessions/useSession"; const EMPTY_SUMMARY: CloudEventSummary = { toolCalls: new Map(), diff --git a/packages/ui/src/features/task-detail/hooks/useCloudRunState.ts b/packages/ui/src/features/task-detail/hooks/useCloudRunState.ts new file mode 100644 index 0000000000..88d2d82518 --- /dev/null +++ b/packages/ui/src/features/task-detail/hooks/useCloudRunState.ts @@ -0,0 +1,40 @@ +import { deriveCloudRunState } from "@posthog/core/task-detail/cloudRunState"; +import { extractCloudToolChangedFiles } from "@posthog/core/task-detail/cloudToolChanges"; +import type { Task } from "@posthog/shared/domain-types"; +import { useMemo } from "react"; +import { resolveCloudPrUrl } from "../../git-interaction/cloudPrUrl"; +import { useSessionForTask } from "../../sessions/useSession"; +import { useTasks } from "../../tasks/useTasks"; +import { useCloudEventSummary } from "./useCloudEventSummary"; + +export function useCloudRunState(taskId: string, task: Task) { + const { data: tasks = [] } = useTasks(); + const freshTask = useMemo( + () => tasks.find((t) => t.id === taskId) ?? task, + [tasks, taskId, task], + ); + + const session = useSessionForTask(taskId); + + const prUrl = resolveCloudPrUrl(freshTask, session); + const { effectiveBranch, repo, cloudStatus, isRunActive } = + deriveCloudRunState(freshTask, session, prUrl); + + const summary = useCloudEventSummary(taskId); + const fallbackFiles = useMemo( + () => extractCloudToolChangedFiles(summary.toolCalls), + [summary], + ); + + return { + freshTask, + session, + prUrl, + effectiveBranch, + repo, + cloudStatus, + isRunActive, + fallbackFiles, + toolCalls: summary.toolCalls, + }; +} diff --git a/packages/ui/src/features/task-detail/hooks/useDiscardFile.ts b/packages/ui/src/features/task-detail/hooks/useDiscardFile.ts new file mode 100644 index 0000000000..736db26b55 --- /dev/null +++ b/packages/ui/src/features/task-detail/hooks/useDiscardFile.ts @@ -0,0 +1,44 @@ +import { getDiscardInfo } from "@posthog/core/task-detail/discardInfo"; +import type { ChangedFile } from "@posthog/shared/domain-types"; +import { useWorkspaceTRPC } from "@posthog/workspace-client/trpc"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { showMessageBox } from "../../../utils/dialog"; +import { updateGitCacheFromSnapshot } from "../../git-interaction/utils/updateGitCache"; + +export function useDiscardFile(repoPath: string | undefined) { + const queryClient = useQueryClient(); + const trpc = useWorkspaceTRPC(); + const discardFileChanges = useMutation( + trpc.git.discardFileChanges.mutationOptions(), + ); + + return useCallback( + async (file: ChangedFile, fileName: string) => { + if (!repoPath) return; + const { message, action } = getDiscardInfo(file, fileName); + + const dialogResult = await showMessageBox({ + type: "warning", + title: "Discard changes", + message, + buttons: ["Cancel", action], + defaultId: 1, + cancelId: 0, + }); + + if (dialogResult.response !== 1) return; + + const result = await discardFileChanges.mutateAsync({ + directoryPath: repoPath, + filePath: file.originalPath ?? file.path, + fileStatus: file.status, + }); + + if (result.state) { + updateGitCacheFromSnapshot(queryClient, repoPath, result.state); + } + }, + [repoPath, queryClient, discardFileChanges], + ); +} diff --git a/apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts b/packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts similarity index 97% rename from apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts rename to packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts index 37a5a62aec..3f1b781aee 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts +++ b/packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.test.ts @@ -1,6 +1,6 @@ -import type { RegisteredFolder } from "@main/services/folders/schemas"; import { renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import type { RegisteredFolder } from "../../folders/types"; import { useInitialDirectoryFromFolderId } from "./useInitialDirectoryFromFolderId"; const folder = (id: string, path: string): RegisteredFolder => ({ diff --git a/apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts b/packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts similarity index 93% rename from apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts rename to packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts index dab03d91c8..e39dd1574a 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts +++ b/packages/ui/src/features/task-detail/hooks/useInitialDirectoryFromFolderId.ts @@ -1,5 +1,5 @@ -import type { RegisteredFolder } from "@main/services/folders/schemas"; import { useEffect, useRef } from "react"; +import type { RegisteredFolder } from "../../folders/types"; /** * Syncs `selectedDirectory` to the path of `folders[view.folderId]` once per diff --git a/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts b/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts new file mode 100644 index 0000000000..233580d3e1 --- /dev/null +++ b/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts @@ -0,0 +1,137 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; +import { + applyConfigChange, + deriveInitialConfig, +} from "@posthog/core/task-detail/previewConfig"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { getCloudUrlFromRegion } from "@posthog/shared"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { logger } from "../../../workbench/logger"; +import { useAuthStateValue } from "../../auth/store"; +import { useSettingsStore } from "../../settings/settingsStore"; + +const log = logger.scope("preview-config"); + +interface PreviewConfigResult { + configOptions: SessionConfigOption[]; + modeOption: SessionConfigOption | undefined; + modelOption: SessionConfigOption | undefined; + thoughtOption: SessionConfigOption | undefined; + isLoading: boolean; + setConfigOption: (configId: string, value: string) => void; +} + +function getOptionByCategory( + options: SessionConfigOption[], + category: string, +): SessionConfigOption | undefined { + return options.find( + (opt) => opt.category === category || opt.id === category, + ); +} + +/** + * Fetches config options (models, modes, effort levels) for the task input + * page via a lightweight tRPC query. No agent session is created. + * + * Returns config options as local state with a setter for local updates. + */ +export function usePreviewConfig( + adapter: "claude" | "codex", +): PreviewConfigResult { + const hostClient = useHostTRPCClient(); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const apiHost = useMemo( + () => (cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null), + [cloudRegion], + ); + const [configOptions, setConfigOptions] = useState<SessionConfigOption[]>([]); + const [isLoading, setIsLoading] = useState(true); + const abortRef = useRef<AbortController | null>(null); + + useEffect(() => { + if (!apiHost) return; + + abortRef.current?.abort(); + const abort = new AbortController(); + abortRef.current = abort; + + setIsLoading(true); + + hostClient.agent.getPreviewConfigOptions + .query({ apiHost, adapter }, { signal: abort.signal }) + .then((options) => { + if (abort.signal.aborted) return; + + const { + defaultInitialTaskMode, + lastUsedInitialTaskMode, + defaultReasoningEffort, + lastUsedReasoningEffort, + } = useSettingsStore.getState(); + + setConfigOptions( + deriveInitialConfig( + options, + { + defaultInitialTaskMode, + lastUsedInitialTaskMode, + defaultReasoningEffort, + lastUsedReasoningEffort, + }, + adapter, + ), + ); + setIsLoading(false); + }) + .catch((error) => { + if (abort.signal.aborted) return; + log.error("Failed to fetch preview config options", { error }); + setIsLoading(false); + }); + + return () => { + abort.abort(); + }; + }, [adapter, apiHost, hostClient]); + + const setConfigOption = useCallback( + (configId: string, value: string) => { + const effortOptions = + configId === "model" + ? (getReasoningEffortOptions(adapter, value) ?? undefined) + : undefined; + const { lastUsedReasoningEffort, defaultReasoningEffort } = + useSettingsStore.getState(); + setConfigOptions((prev) => + applyConfigChange(prev, { + adapter, + configId, + value, + effortOptions, + settings: { + defaultInitialTaskMode: "", + lastUsedInitialTaskMode: undefined, + defaultReasoningEffort, + lastUsedReasoningEffort, + }, + }), + ); + }, + [adapter], + ); + + const modeOption = getOptionByCategory(configOptions, "mode"); + const modelOption = getOptionByCategory(configOptions, "model"); + const thoughtOption = getOptionByCategory(configOptions, "thought_level"); + + return { + configOptions, + modeOption, + modelOption, + thoughtOption, + isLoading, + setConfigOption, + }; +} diff --git a/packages/ui/src/features/task-detail/hooks/useStageToggle.ts b/packages/ui/src/features/task-detail/hooks/useStageToggle.ts new file mode 100644 index 0000000000..1fa1a24baa --- /dev/null +++ b/packages/ui/src/features/task-detail/hooks/useStageToggle.ts @@ -0,0 +1,34 @@ +import type { ChangedFile } from "@posthog/shared/domain-types"; +import { useWorkspaceTRPC } from "@posthog/workspace-client/trpc"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { logger } from "../../../workbench/logger"; +import { invalidateGitWorkingTreeQueries } from "../../git-interaction/gitCacheKeys"; +import { updateGitCacheFromSnapshot } from "../../git-interaction/utils/updateGitCache"; + +const log = logger.scope("use-stage-toggle"); + +export function useStageToggle(repoPath: string | undefined) { + const queryClient = useQueryClient(); + const trpc = useWorkspaceTRPC(); + const stageFiles = useMutation(trpc.git.stageFiles.mutationOptions()); + const unstageFiles = useMutation(trpc.git.unstageFiles.mutationOptions()); + + return useCallback( + async (file: ChangedFile) => { + if (!repoPath) return; + const endpoint = file.staged ? unstageFiles : stageFiles; + try { + const result = await endpoint.mutateAsync({ + directoryPath: repoPath, + paths: [file.originalPath ?? file.path], + }); + updateGitCacheFromSnapshot(queryClient, repoPath, result); + invalidateGitWorkingTreeQueries(repoPath); + } catch (error) { + log.error("Failed to toggle staging", { file: file.path, error }); + } + }, + [repoPath, queryClient, stageFiles, unstageFiles], + ); +} diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts similarity index 65% rename from apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts rename to packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index 8b553ab95c..527ce0ad80 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -1,31 +1,40 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { buildCloudTaskDescription } from "@features/editor/utils/cloud-prompt"; -import { useTaskInputHistoryStore } from "@features/message-editor/stores/taskInputHistoryStore"; -import type { EditorHandle } from "@features/message-editor/types"; +import { + getErrorTitle, + prepareTaskInput, +} from "@posthog/core/task-detail/taskInput"; +import { + TASK_SERVICE, + type TaskService, +} from "@posthog/core/task-detail/taskService"; +import { useService } from "@posthog/di/react"; +import type { HostTrpcClient } from "@posthog/host-router/client"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { + ANALYTICS_EVENTS, + type TaskCreationInput, + type WorkspaceMode, +} from "@posthog/shared"; +import type { ExecutionMode, Task } from "@posthog/shared/domain-types"; +import { useCallback, useState } from "react"; +import { useConnectivity } from "../../../hooks/useConnectivity"; +import { toast } from "../../../primitives/toast"; +import { track } from "../../../workbench/analytics"; +import { logger } from "../../../workbench/logger"; +import { pendingTaskPromptStoreApi } from "../../../workbench/pendingTaskPromptStore"; +import { useAuthStateValue } from "../../auth/store"; import { contentToPlainText, contentToXml, type EditorContent, extractFilePaths, -} from "@features/message-editor/utils/content"; -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useCreateTask } from "@features/tasks/hooks/useTasks"; -import { useTourStore } from "@features/tour/stores/tourStore"; -import { createFirstTaskTour } from "@features/tour/tours/createFirstTaskTour"; -import { useConnectivity } from "@hooks/useConnectivity"; -import type { WorkspaceMode } from "@main/services/workspace/schemas"; -import { get } from "@renderer/di/container"; -import { RENDERER_TOKENS } from "@renderer/di/tokens"; -import { trpcClient } from "@renderer/trpc/client"; -import { toast } from "@renderer/utils/toast"; -import type { ExecutionMode, Task } from "@shared/types"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; -import { pendingTaskPromptStoreApi } from "@stores/pendingTaskPromptStore"; -import { track } from "@utils/analytics"; -import { logger } from "@utils/logger"; -import { useCallback, useState } from "react"; -import type { TaskCreationInput, TaskService } from "../service/service"; +} from "../../message-editor/content"; +import { useTaskInputHistoryStore } from "../../message-editor/taskInputHistoryStore"; +import type { EditorHandle } from "../../message-editor/types"; +import { useNavigationStore } from "../../navigation/store"; +import { useSettingsStore } from "../../settings/settingsStore"; +import { useCreateTask } from "../../tasks/useTaskCrudMutations"; +import { useTourStore } from "../../tour/tourStore"; +import { createFirstTaskTour } from "../../tour/tours/createFirstTaskTour"; const log = logger.scope("task-creation"); @@ -54,65 +63,10 @@ interface UseTaskCreationReturn { handleSubmit: (contentOverride?: EditorContent) => Promise<boolean>; } -function prepareTaskInput( - content: Parameters<typeof contentToXml>[0], - options: { - selectedDirectory: string; - selectedRepository?: string | null; - githubIntegrationId?: number; - githubUserIntegrationId?: string; - workspaceMode: WorkspaceMode; - branch?: string | null; - executionMode?: ExecutionMode; - adapter?: "claude" | "codex"; - model?: string; - reasoningLevel?: string; - environmentId?: string | null; - sandboxEnvironmentId?: string; - signalReportId?: string; - }, -): TaskCreationInput { - const serializedContent = contentToXml(content).trim(); - const filePaths = extractFilePaths(content); - - return { - content: serializedContent, - taskDescription: - options.workspaceMode === "cloud" - ? buildCloudTaskDescription(serializedContent, filePaths) - : undefined, - filePaths, - repoPath: - options.workspaceMode === "cloud" ? undefined : options.selectedDirectory, - repository: - options.workspaceMode === "cloud" - ? options.selectedRepository - : undefined, - githubIntegrationId: options.githubIntegrationId, - githubUserIntegrationId: options.githubUserIntegrationId, - workspaceMode: options.workspaceMode, - branch: options.branch, - executionMode: options.executionMode, - adapter: options.adapter, - model: options.model, - reasoningLevel: options.reasoningLevel, - environmentId: options.environmentId ?? undefined, - sandboxEnvironmentId: options.sandboxEnvironmentId, - cloudPrAuthorshipMode: - options.signalReportId && options.workspaceMode === "cloud" - ? "user" - : undefined, - cloudRunSource: - options.signalReportId && options.workspaceMode === "cloud" - ? "signal_report" - : undefined, - signalReportId: options.signalReportId, - }; -} - async function trackTaskCreated( input: TaskCreationInput, selectedDirectory: string, + hostClient: HostTrpcClient, ): Promise<void> { try { const workspaceMode = input.workspaceMode ?? "local"; @@ -121,7 +75,7 @@ async function trackTaskCreated( let usesWorktreeInclude: boolean | undefined; if (workspaceMode === "worktree" && selectedDirectory) { try { - const usage = await trpcClient.workspace.getWorktreeFileUsage.query({ + const usage = await hostClient.workspace.getWorktreeFileUsage.query({ mainRepoPath: selectedDirectory, }); usesWorktreeLink = usage.usesWorktreeLink; @@ -159,18 +113,6 @@ async function trackTaskCreated( } } -function getErrorTitle(failedStep: string): string { - const titles: Record<string, string> = { - repo_detection: "Failed to detect repository", - task_creation: "Failed to create task", - workspace_creation: "Failed to create workspace", - cloud_prompt_preparation: "Failed to prepare cloud attachments", - cloud_run: "Failed to start cloud execution", - agent_session: "Failed to start agent session", - }; - return titles[failedStep] ?? "Task creation failed"; -} - export function useTaskCreation({ editorRef, selectedDirectory, @@ -190,6 +132,8 @@ export function useTaskCreation({ onTaskCreated, }: UseTaskCreationOptions): UseTaskCreationReturn { const [isCreatingTask, setIsCreatingTask] = useState(false); + const hostClient = useHostTRPCClient(); + const taskService = useService<TaskService>(TASK_SERVICE); const { clearTaskInputReportAssociation, navigateToTask, @@ -246,7 +190,9 @@ export function useTaskCreation({ } } - const input = prepareTaskInput(content, { + const serializedContent = contentToXml(content).trim(); + const filePaths = extractFilePaths(content); + const input = prepareTaskInput(serializedContent, filePaths, { selectedDirectory, selectedRepository, githubIntegrationId, @@ -266,7 +212,6 @@ export function useTaskCreation({ useSettingsStore.getState().setLastUsedInitialTaskMode(executionMode); } - const taskService = get<TaskService>(RENDERER_TOKENS.TaskService); const result = await taskService.createTask(input, (output) => { invalidateTasks(output.task); if (signalReportId) { @@ -287,7 +232,7 @@ export function useTaskCreation({ }); if (result.success) { - void trackTaskCreated(input, selectedDirectory); + void trackTaskCreated(input, selectedDirectory, hostClient); } if (!result.success) { @@ -340,6 +285,8 @@ export function useTaskCreation({ navigateToPendingTask, navigateToTaskInput, onTaskCreated, + hostClient, + taskService, ], ); diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskData.ts b/packages/ui/src/features/task-detail/hooks/useTaskData.ts similarity index 54% rename from apps/code/src/renderer/features/task-detail/hooks/useTaskData.ts rename to packages/ui/src/features/task-detail/hooks/useTaskData.ts index 2169dc389b..ae278ba306 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskData.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskData.ts @@ -1,11 +1,16 @@ -import { useTasks } from "@features/tasks/hooks/useTasks"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; -import { useTRPC } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; -import { cloneStore } from "@stores/cloneStore"; +import { parseCloneProgress } from "@posthog/core/clone/cloneProgress"; +import { + findCloneForRepo, + isRepoCloning, +} from "@posthog/core/clone/cloneSelectors"; +import { getTaskRepository } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import { useWorkspaceTRPC } from "@posthog/workspace-client/trpc"; import { useQuery } from "@tanstack/react-query"; -import { getTaskRepository } from "@utils/repository"; import { useMemo } from "react"; +import { cloneStore } from "../../clone/cloneStore"; +import { useTasks } from "../../tasks/useTasks"; +import { useWorkspace } from "../../workspace/useWorkspace"; interface UseTaskDataParams { taskId: string; @@ -13,7 +18,7 @@ interface UseTaskDataParams { } export function useTaskData({ taskId, initialTask }: UseTaskDataParams) { - const trpcReact = useTRPC(); + const trpcReact = useWorkspaceTRPC(); const { data: tasks = [] } = useTasks(); const task = useMemo( @@ -34,23 +39,14 @@ export function useTaskData({ taskId, initialTask }: UseTaskDataParams) { const repository = getTaskRepository(task); const isCloning = cloneStore((state) => - repository ? state.isCloning(repository) : false, + repository ? isRepoCloning(state.operations, repository) : false, ); const cloneProgress = cloneStore( - (state) => { - if (!repository) return null; - const cloneOp = state.getCloneForRepo(repository); - if (!cloneOp?.latestMessage) return null; - - const percentMatch = cloneOp.latestMessage.match(/(\d+)%/); - const percent = percentMatch ? Number.parseInt(percentMatch[1], 10) : 0; - - return { - message: cloneOp.latestMessage, - percent, - }; - }, + (state) => + repository + ? parseCloneProgress(findCloneForRepo(state.operations, repository)) + : null, (a, b) => a?.message === b?.message && a?.percent === b?.percent, ); diff --git a/packages/ui/src/features/task-detail/taskCreationEffectsImpl.ts b/packages/ui/src/features/task-detail/taskCreationEffectsImpl.ts new file mode 100644 index 0000000000..b698d1fe99 --- /dev/null +++ b/packages/ui/src/features/task-detail/taskCreationEffectsImpl.ts @@ -0,0 +1,54 @@ +import type { TaskCreationEffects } from "@posthog/core/task-detail/taskCreationEffects"; +import { resolveService } from "@posthog/di/container"; +import type { + TaskCreationInput, + TaskCreationOutput, + Workspace, +} from "@posthog/shared"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "../../workbench/queryClient"; +import { useDraftStore } from "../message-editor/draftStore"; +import { useSettingsStore } from "../settings/settingsStore"; +import { WORKSPACE_QUERY_KEY } from "../workspace/identifiers"; + +function queryClient(): ImperativeQueryClient { + return resolveService<ImperativeQueryClient>(IMPERATIVE_QUERY_CLIENT); +} + +export const taskCreationEffects: TaskCreationEffects = { + onWorkspaceCreated(output: TaskCreationOutput): void { + if (!output.workspace) return; + const workspace = output.workspace; + const client = queryClient(); + client.setQueriesData<Record<string, Workspace>>( + { queryKey: WORKSPACE_QUERY_KEY }, + (old) => ({ ...old, [output.task.id]: workspace }), + ); + void client.invalidateQueries({ queryKey: WORKSPACE_QUERY_KEY }); + }, + + onCreateSuccess(output: TaskCreationOutput, input?: TaskCreationInput): void { + if (!input) return; + + const settings = useSettingsStore.getState(); + const draftStore = useDraftStore.getState(); + + const workspaceMode = + input.workspaceMode ?? output.workspace?.mode ?? "local"; + + settings.setLastUsedWorkspaceMode(workspaceMode); + + if (workspaceMode === "cloud") { + settings.setLastUsedRunMode("cloud"); + } else { + settings.setLastUsedRunMode("local"); + settings.setLastUsedLocalWorkspaceMode( + workspaceMode as "worktree" | "local", + ); + } + + draftStore.actions.setDraft("task-input", null); + }, +}; diff --git a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts new file mode 100644 index 0000000000..da7b1ff60e --- /dev/null +++ b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts @@ -0,0 +1,152 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { + CLOUD_ARTIFACT_SERVICE, + type CloudArtifactClient, +} from "@posthog/core/sessions/cloudArtifactIdentifiers"; +import type { CloudArtifactService } from "@posthog/core/sessions/cloudArtifactService"; +import { getCloudPromptTransport } from "@posthog/core/sessions/cloudPrompt"; +import type { TaskCreationApiClient } from "@posthog/core/task-detail/taskCreationApiClient"; +import type { + CloudPromptTransport, + CreatedWorkspaceInfo, + CreateWorkspaceArgs, + DetectedRepo, + ITaskCreationHost, + SetupActionDispatch, + TaskEnvironment, + TaskFolderInfo, +} from "@posthog/core/task-detail/taskCreationHost"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { expandTildePath, type Workspace } from "@posthog/shared"; +import { injectable } from "inversify"; +import { getAuthenticatedClient } from "../auth/authClientImperative"; +import { DEFAULT_PANEL_IDS } from "../panels/panelConstants"; +import { usePanelLayoutStore } from "../panels/panelLayoutStore"; +import { useProvisioningStore } from "../provisioning/store"; + +interface EnvironmentHostClient { + environment: { + get: { + query(args: { + repoPath: string; + id: string; + }): Promise<TaskEnvironment | null>; + }; + }; +} + +function hostClient(): HostTrpcClient { + return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT); +} + +@injectable() +export class TrpcTaskCreationHost implements ITaskCreationHost { + getAuthenticatedClient(): Promise<TaskCreationApiClient | null> { + return getAuthenticatedClient() as Promise<TaskCreationApiClient | null>; + } + + async getTaskDirectory( + taskId: string, + repoKey?: string, + ): Promise<string | null> { + const workspace = await this.getWorkspace(taskId); + if (workspace?.folderPath) { + return expandTildePath(workspace.folderPath); + } + + if (repoKey) { + const repo = await hostClient().folders.getRepositoryByRemoteUrl.query({ + remoteUrl: repoKey, + }); + if (repo) { + return expandTildePath(repo.path); + } + } + + return null; + } + + async getWorkspace(taskId: string): Promise<Workspace | null> { + const workspaces = await hostClient().workspace.getAll.query(); + return workspaces?.[taskId] ?? null; + } + + createWorkspace(args: CreateWorkspaceArgs): Promise<CreatedWorkspaceInfo> { + return hostClient().workspace.create.mutate(args); + } + + async deleteWorkspace(args: { + taskId: string; + mainRepoPath: string; + }): Promise<void> { + await hostClient().workspace.delete.mutate(args); + } + + getFolders(): Promise<TaskFolderInfo[]> { + return hostClient().folders.getFolders.query(); + } + + addFolder(args: { folderPath: string }): Promise<TaskFolderInfo> { + return hostClient().folders.addFolder.mutate(args); + } + + getEnvironment(args: { + repoPath: string; + id: string; + }): Promise<TaskEnvironment | null> { + return ( + hostClient() as unknown as EnvironmentHostClient + ).environment.get.query(args); + } + + detectRepo(args: { directoryPath: string }): Promise<DetectedRepo | null> { + return hostClient().git.detectRepo.query(args); + } + + getCloudPromptTransport( + prompt: string | ContentBlock[], + filePaths?: string[], + ): CloudPromptTransport { + return getCloudPromptTransport(prompt, filePaths); + } + + uploadRunAttachments( + client: TaskCreationApiClient, + taskId: string, + runId: string, + filePaths: string[], + ): Promise<string[]> { + return resolveService<CloudArtifactService>( + CLOUD_ARTIFACT_SERVICE, + ).uploadRunAttachments( + client as unknown as CloudArtifactClient, + taskId, + runId, + filePaths, + ); + } + + setProvisioningActive(taskId: string): void { + useProvisioningStore.getState().setActive(taskId); + } + + clearProvisioning(taskId: string): void { + useProvisioningStore.getState().clear(taskId); + } + + dispatchSetupAction(args: SetupActionDispatch): void { + const actionId = `setup-${args.taskId}-${Date.now()}`; + usePanelLayoutStore + .getState() + .addActionTab(args.taskId, DEFAULT_PANEL_IDS.MAIN_PANEL, { + actionId, + command: args.command, + cwd: args.cwd, + label: args.label, + }); + } +} diff --git a/apps/code/src/renderer/features/tasks/hooks/taskKeys.ts b/packages/ui/src/features/tasks/taskKeys.ts similarity index 100% rename from apps/code/src/renderer/features/tasks/hooks/taskKeys.ts rename to packages/ui/src/features/tasks/taskKeys.ts diff --git a/packages/ui/src/features/tasks/taskStore.ts b/packages/ui/src/features/tasks/taskStore.ts new file mode 100644 index 0000000000..ca9dc05a67 --- /dev/null +++ b/packages/ui/src/features/tasks/taskStore.ts @@ -0,0 +1,102 @@ +import * as filters from "@posthog/core/tasks/filters"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { TaskState } from "./taskStore.types"; + +export const useTaskStore = create<TaskState>()( + persist( + (set) => ({ + selectedIndex: null, + hoveredIndex: null, + contextMenuIndex: null, + filter: "", + orderBy: "created_at", + orderDirection: "desc", + groupBy: "none", + expandedGroups: {}, + activeFilters: {}, + filterMatchMode: "all", + filterSearchQuery: "", + filterMenuSelectedIndex: -1, + isFilterDropdownOpen: false, + editingFilterBadgeKey: null, + + setSelectedIndex: (index) => set({ selectedIndex: index }), + setHoveredIndex: (index) => set({ hoveredIndex: index }), + setContextMenuIndex: (index) => set({ contextMenuIndex: index }), + + setFilter: (filter) => set({ filter }), + setOrderBy: (orderBy) => set({ orderBy }), + setOrderDirection: (orderDirection) => set({ orderDirection }), + setGroupBy: (groupBy) => set({ groupBy }), + + toggleGroupExpanded: (groupName) => + set((state) => ({ + expandedGroups: { + ...state.expandedGroups, + [groupName]: !(state.expandedGroups[groupName] ?? true), + }, + })), + + setActiveFilters: (activeFilters) => set({ activeFilters }), + clearActiveFilters: () => set({ activeFilters: {} }), + + toggleFilter: (category, value, operator) => + set((state) => ({ + activeFilters: filters.toggleFilter( + state.activeFilters, + category, + value, + operator, + ), + })), + + addFilter: (category, value, operator) => + set((state) => ({ + activeFilters: filters.addFilter( + state.activeFilters, + category, + value, + operator, + ), + })), + + updateFilter: (category, oldValue, newValue) => + set((state) => ({ + activeFilters: filters.updateFilter( + state.activeFilters, + category, + oldValue, + newValue, + ), + })), + + toggleFilterOperator: (category, value) => + set((state) => ({ + activeFilters: filters.toggleFilterOperator( + state.activeFilters, + category, + value, + ), + })), + + setFilterMatchMode: (mode) => set({ filterMatchMode: mode }), + setFilterSearchQuery: (query) => set({ filterSearchQuery: query }), + setFilterMenuSelectedIndex: (index) => + set({ filterMenuSelectedIndex: index }), + setIsFilterDropdownOpen: (open) => set({ isFilterDropdownOpen: open }), + setEditingFilterBadgeKey: (key) => set({ editingFilterBadgeKey: key }), + }), + { + name: "task-store", + partialize: (state) => ({ + orderBy: state.orderBy, + orderDirection: state.orderDirection, + groupBy: state.groupBy, + expandedGroups: state.expandedGroups, + activeFilters: state.activeFilters, + filterMatchMode: state.filterMatchMode, + }), + }, + ), +); diff --git a/apps/code/src/renderer/features/tasks/stores/taskStore.types.ts b/packages/ui/src/features/tasks/taskStore.types.ts similarity index 68% rename from apps/code/src/renderer/features/tasks/stores/taskStore.types.ts rename to packages/ui/src/features/tasks/taskStore.types.ts index d699d716bb..3038bbc056 100644 --- a/apps/code/src/renderer/features/tasks/stores/taskStore.types.ts +++ b/packages/ui/src/features/tasks/taskStore.types.ts @@ -1,45 +1,24 @@ -export type OrderByField = - | "created_at" - | "status" - | "title" - | "repository" - | "working_directory" - | "source"; +import type { + ActiveFilters, + FilterCategory, + FilterMatchMode, + FilterOperator, + GroupByField, + OrderByField, + OrderDirection, +} from "@posthog/core/tasks/filters"; -export type OrderDirection = "asc" | "desc"; - -export type GroupByField = - | "none" - | "status" - | "creator" - | "source" - | "repository"; - -export type FilterCategory = - | "status" - | "source" - | "creator" - | "repository" - | "created_at"; - -export type FilterOperator = "is" | "is_not" | "before" | "after"; - -export interface FilterValue { - value: string; - operator: FilterOperator; -} - -export type ActiveFilters = Partial<Record<FilterCategory, FilterValue[]>>; - -export type FilterMatchMode = "all" | "any"; - -export const TASK_STATUS_ORDER: string[] = [ - "failed", - "in_progress", - "queued", - "completed", - "backlog", -]; +export type { + ActiveFilters, + FilterCategory, + FilterMatchMode, + FilterOperator, + FilterValue, + GroupByField, + OrderByField, + OrderDirection, +} from "@posthog/core/tasks/filters"; +export { TASK_STATUS_ORDER } from "@posthog/core/tasks/filters"; export interface TaskState { selectedIndex: number | null; diff --git a/apps/code/src/renderer/hooks/useTaskContextMenu.ts b/packages/ui/src/features/tasks/useTaskContextMenu.ts similarity index 58% rename from apps/code/src/renderer/hooks/useTaskContextMenu.ts rename to packages/ui/src/features/tasks/useTaskContextMenu.ts index 31c48107a5..9f527ed79d 100644 --- a/apps/code/src/renderer/hooks/useTaskContextMenu.ts +++ b/packages/ui/src/features/tasks/useTaskContextMenu.ts @@ -1,18 +1,23 @@ -import { useRestoreTask } from "@features/suspension/hooks/useRestoreTask"; -import { useSuspendTask } from "@features/suspension/hooks/useSuspendTask"; -import { useArchiveTask } from "@features/tasks/hooks/useArchiveTask"; -import { useDeleteTask } from "@features/tasks/hooks/useTasks"; -import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; -import { trpcClient } from "@renderer/trpc/client"; -import type { Task } from "@shared/types"; -import { handleExternalAppAction } from "@utils/handleExternalAppAction"; -import { logger } from "@utils/logger"; +import { + resolveExternalAppPath, + resolveTaskContextMenuIntent, +} from "@posthog/core/tasks/contextMenuActions"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import type { Task } from "@posthog/shared/domain-types"; +import { useArchiveTask } from "@posthog/ui/features/archive/useArchiveTask"; +import { useExternalAppAction } from "@posthog/ui/features/external-apps/useExternalAppAction"; +import { useRestoreTask } from "@posthog/ui/features/suspension/useRestoreTask"; +import { useSuspendTask } from "@posthog/ui/features/suspension/useSuspendTask"; +import { useDeleteTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; +import { logger } from "@posthog/ui/workbench/logger"; import { useCallback, useState } from "react"; const log = logger.scope("context-menu"); export function useTaskContextMenu() { const [editingTaskId, setEditingTaskId] = useState<string | null>(null); + const hostClient = useHostTRPCClient(); + const openExternalApp = useExternalAppAction(); const { deleteWithConfirm } = useDeleteTask(); const { archiveTask } = useArchiveTask(); const { suspendTask } = useSuspendTask(); @@ -50,7 +55,7 @@ export function useTaskContextMenu() { } = options ?? {}; try { - const result = await trpcClient.contextMenu.showTaskContextMenu.mutate({ + const result = await hostClient.contextMenu.showTaskContextMenu.mutate({ taskTitle: task.title, worktreePath, folderPath, @@ -62,7 +67,11 @@ export function useTaskContextMenu() { if (!result.action) return; - switch (result.action.type) { + const intent = resolveTaskContextMenuIntent(result.action, { + isSuspended, + }); + + switch (intent.type) { case "rename": setEditingTaskId(task.id); break; @@ -70,11 +79,10 @@ export function useTaskContextMenu() { onTogglePin?.(); break; case "suspend": - if (isSuspended) { - await restoreTask(task.id); - } else { - await suspendTask({ taskId: task.id, reason: "manual" }); - } + await suspendTask({ taskId: task.id, reason: "manual" }); + break; + case "restore": + await restoreTask(task.id); break; case "archive": await archiveTask({ taskId: task.id }); @@ -93,18 +101,17 @@ export function useTaskContextMenu() { onAddToCommandCenter?.(); break; case "external-app": { - const effectivePath = worktreePath ?? folderPath; + const effectivePath = resolveExternalAppPath( + worktreePath, + folderPath, + ); if (effectivePath) { - const workspace = await workspaceApi.get(task.id); - await handleExternalAppAction( - result.action.action, - effectivePath, - task.title, - { - workspace, - mainRepoPath: workspace?.folderPath, - }, - ); + const workspaces = await hostClient.workspace.getAll.query(); + const workspace = workspaces[task.id] ?? null; + await openExternalApp(intent.action, effectivePath, task.title, { + workspace, + mainRepoPath: workspace?.folderPath, + }); } break; } @@ -113,7 +120,14 @@ export function useTaskContextMenu() { log.error("Failed to show context menu", error); } }, - [archiveTask, deleteWithConfirm, restoreTask, suspendTask], + [ + archiveTask, + deleteWithConfirm, + restoreTask, + suspendTask, + hostClient, + openExternalApp, + ], ); return { diff --git a/packages/ui/src/features/tasks/useTaskCrudMutations.test.tsx b/packages/ui/src/features/tasks/useTaskCrudMutations.test.tsx new file mode 100644 index 0000000000..0bb0397a81 --- /dev/null +++ b/packages/ui/src/features/tasks/useTaskCrudMutations.test.tsx @@ -0,0 +1,73 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mutateAsync = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const confirmAndDelete = vi.hoisted(() => + vi.fn( + async ( + _options: { taskId: string; taskTitle: string; hasWorktree: boolean }, + runDelete: (taskId: string) => Promise<unknown>, + ) => { + await runDelete(_options.taskId); + return true; + }, + ), +); +const deletionService = vi.hoisted(() => ({ + deleteTask: vi.fn().mockResolvedValue(undefined), + confirmAndDelete, +})); + +vi.mock("@posthog/ui/hooks/useAuthenticatedMutation", () => ({ + useAuthenticatedMutation: () => ({ mutateAsync, isPending: false }), +})); +vi.mock("@posthog/di/react", () => ({ + useService: () => deletionService, +})); + +import { useDeleteTask } from "./useTaskCrudMutations"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient(); + return ( + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + ); +} + +describe("useDeleteTask.deleteWithConfirm", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("delegates to the deletion service with the delete mutation", async () => { + const { result } = renderHook(() => useDeleteTask(), { wrapper }); + + const ok = await result.current.deleteWithConfirm({ + taskId: "t1", + taskTitle: "Title", + hasWorktree: true, + }); + + expect(ok).toBe(true); + expect(confirmAndDelete).toHaveBeenCalledWith( + { taskId: "t1", taskTitle: "Title", hasWorktree: true }, + mutateAsync, + ); + expect(mutateAsync).toHaveBeenCalledWith("t1"); + }); + + it("returns false when the service reports the user declined", async () => { + confirmAndDelete.mockResolvedValueOnce(false); + const { result } = renderHook(() => useDeleteTask(), { wrapper }); + + const ok = await result.current.deleteWithConfirm({ + taskId: "t1", + taskTitle: "Title", + hasWorktree: false, + }); + + expect(ok).toBe(false); + }); +}); diff --git a/packages/ui/src/features/tasks/useTaskCrudMutations.ts b/packages/ui/src/features/tasks/useTaskCrudMutations.ts new file mode 100644 index 0000000000..8e977f03f1 --- /dev/null +++ b/packages/ui/src/features/tasks/useTaskCrudMutations.ts @@ -0,0 +1,116 @@ +import { + insertTaskDedup, + removeTaskFromList, +} from "@posthog/core/tasks/taskDelete"; +import { + TASK_DELETION_SERVICE, + type TaskDeletionService, +} from "@posthog/core/tasks/taskDeletionService"; +import { useService } from "@posthog/di/react"; +import type { Task } from "@posthog/shared/domain-types"; +import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { taskKeys } from "./taskKeys"; + +export function useCreateTask() { + const queryClient = useQueryClient(); + + const invalidateTasks = (newTask?: Task) => { + if (newTask) { + queryClient.setQueriesData<Task[]>( + { queryKey: taskKeys.lists() }, + (old) => insertTaskDedup(old, newTask), + ); + } + queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); + }; + + const mutation = useAuthenticatedMutation( + ( + client, + { + description, + repository, + github_integration, + }: { + description: string; + repository?: string; + github_integration?: number; + createdFrom?: "cli" | "command-menu"; + }, + ) => + client.createTask({ + description, + repository, + github_integration, + }) as unknown as Promise<Task>, + ); + + return { ...mutation, invalidateTasks }; +} + +interface DeleteTaskOptions { + taskId: string; + taskTitle: string; + hasWorktree: boolean; +} + +export function useDeleteTask() { + const queryClient = useQueryClient(); + const deletionService = useService<TaskDeletionService>( + TASK_DELETION_SERVICE, + ); + + const mutation = useAuthenticatedMutation( + (client, taskId: string) => deletionService.deleteTask(client, taskId), + { + onMutate: async (taskId) => { + await queryClient.cancelQueries({ queryKey: taskKeys.lists() }); + + const previousQueries: Array<{ queryKey: unknown; data: Task[] }> = []; + const queries = queryClient.getQueriesData<Task[]>({ + queryKey: taskKeys.lists(), + }); + for (const [queryKey, data] of queries) { + if (data) { + previousQueries.push({ queryKey, data }); + } + } + + queryClient.setQueriesData<Task[]>( + { queryKey: taskKeys.lists() }, + (old) => removeTaskFromList(old, taskId), + ); + + return { previousQueries }; + }, + onError: (_err, _taskId, context) => { + const ctx = context as + | { + previousQueries: Array<{ + queryKey: readonly unknown[]; + data: Task[]; + }>; + } + | undefined; + if (ctx?.previousQueries) { + for (const { queryKey, data } of ctx.previousQueries) { + queryClient.setQueryData(queryKey, data); + } + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); + }, + }, + ); + + const deleteWithConfirm = useCallback( + (options: DeleteTaskOptions) => + deletionService.confirmAndDelete(options, mutation.mutateAsync), + [deletionService, mutation.mutateAsync], + ); + + return { ...mutation, deleteWithConfirm }; +} diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx b/packages/ui/src/features/tasks/useTaskMutations.test.tsx similarity index 94% rename from apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx rename to packages/ui/src/features/tasks/useTaskMutations.test.tsx index b99a971651..450447d7fe 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx +++ b/packages/ui/src/features/tasks/useTaskMutations.test.tsx @@ -1,5 +1,5 @@ import type { Schemas } from "@posthog/api-client"; -import type { Task } from "@shared/types"; +import type { Task } from "@posthog/shared/domain-types"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook } from "@testing-library/react"; import { act, type ReactNode } from "react"; @@ -9,33 +9,18 @@ const mockUpdateTask = vi.hoisted(() => vi.fn()); const mockClient = vi.hoisted(() => ({ updateTask: mockUpdateTask })); const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn()); -vi.mock("@features/auth/hooks/authClient", () => ({ +vi.mock("@posthog/ui/features/auth/authClient", () => ({ useOptionalAuthenticatedClient: () => mockClient, })); -vi.mock("@features/sessions/service/service", () => ({ - getSessionService: () => ({ +vi.mock("@posthog/di/react", () => ({ + useService: () => ({ updateSessionTaskTitle: mockUpdateSessionTaskTitle, }), })); -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: {}, -})); - -vi.mock("@utils/logger", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - }, -})); - import { taskKeys } from "./taskKeys"; -import { useRenameTask } from "./useTasks"; +import { useRenameTask } from "./useTaskMutations"; const TASK_ID = "task-1"; const OTHER_TASK_ID = "task-2"; diff --git a/packages/ui/src/features/tasks/useTaskMutations.ts b/packages/ui/src/features/tasks/useTaskMutations.ts new file mode 100644 index 0000000000..e1df7623ac --- /dev/null +++ b/packages/ui/src/features/tasks/useTaskMutations.ts @@ -0,0 +1,143 @@ +import type { Schemas } from "@posthog/api-client"; +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { + applyRenameToDetail, + applyRenameToList, + applyRenameToSummaries, + getTaskTitle, + rollbackDetailData, + rollbackListData, + rollbackSummaryData, + shouldRollbackSessionTitle, +} from "@posthog/core/tasks/taskRename"; +import { useService } from "@posthog/di/react"; +import type { Task } from "@posthog/shared/domain-types"; +import { taskKeys } from "@posthog/ui/features/tasks/taskKeys"; +import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +export function useUpdateTask() { + const queryClient = useQueryClient(); + + return useAuthenticatedMutation( + ( + client, + { + taskId, + updates, + }: { + taskId: string; + updates: Partial<Task>; + }, + ) => + client.updateTask( + taskId, + updates as Parameters<typeof client.updateTask>[1], + ), + { + onSuccess: (_, { taskId }) => { + queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); + queryClient.invalidateQueries({ queryKey: taskKeys.detail(taskId) }); + queryClient.invalidateQueries({ queryKey: taskKeys.allSummaries() }); + }, + }, + ); +} + +export function useRenameTask() { + const queryClient = useQueryClient(); + const updateTask = useUpdateTask(); + const sessionService = useService<SessionService>(SESSION_SERVICE); + + const renameTask = useCallback( + async ({ + taskId, + currentTitle, + newTitle, + }: { + taskId: string; + currentTitle: string; + newTitle: string; + }) => { + const previousListQueries = queryClient.getQueriesData<Task[]>({ + queryKey: taskKeys.lists(), + }); + const previousSummaryQueries = queryClient.getQueriesData< + Schemas.TaskSummary[] + >({ + queryKey: taskKeys.allSummaries(), + }); + const previousDetail = queryClient.getQueryData<Task>( + taskKeys.detail(taskId), + ); + + queryClient.setQueriesData<Task[]>( + { queryKey: taskKeys.lists() }, + (old) => applyRenameToList(old, taskId, newTitle), + ); + queryClient.setQueriesData<Schemas.TaskSummary[]>( + { queryKey: taskKeys.allSummaries() }, + (old) => applyRenameToSummaries(old, taskId, newTitle), + ); + + if (previousDetail) { + queryClient.setQueryData<Task>( + taskKeys.detail(taskId), + applyRenameToDetail(previousDetail, newTitle), + ); + } + + sessionService.updateSessionTaskTitle(taskId, newTitle); + + try { + await updateTask.mutateAsync({ + taskId, + updates: { title: newTitle, title_manually_set: true }, + }); + } catch (error) { + const listTitles = queryClient + .getQueriesData<Task[]>({ queryKey: taskKeys.lists() }) + .map(([, tasks]) => getTaskTitle(tasks, taskId)); + const rollbackSession = shouldRollbackSessionTitle({ + detailTitle: queryClient.getQueryData<Task>(taskKeys.detail(taskId)) + ?.title, + listTitles, + newTitle, + }); + + for (const [queryKey, data] of previousListQueries) { + queryClient.setQueryData<Task[] | undefined>(queryKey, (current) => + rollbackListData(current, data ?? [], taskId, newTitle), + ); + } + for (const [queryKey, data] of previousSummaryQueries) { + queryClient.setQueryData<Schemas.TaskSummary[] | undefined>( + queryKey, + (current) => + rollbackSummaryData(current, data ?? [], taskId, newTitle), + ); + } + if (previousDetail) { + queryClient.setQueryData<Task | undefined>( + taskKeys.detail(taskId), + (current) => rollbackDetailData(current, previousDetail, newTitle), + ); + } + if (rollbackSession) { + sessionService.updateSessionTaskTitle(taskId, currentTitle); + } + throw error; + } + }, + [queryClient, updateTask, sessionService], + ); + + return { + renameTask, + isPending: updateTask.isPending, + }; +} diff --git a/packages/ui/src/features/tasks/useTasks.ts b/packages/ui/src/features/tasks/useTasks.ts new file mode 100644 index 0000000000..86a350e30b --- /dev/null +++ b/packages/ui/src/features/tasks/useTasks.ts @@ -0,0 +1,73 @@ +import type { Schemas } from "@posthog/api-client"; +import type { Task } from "@posthog/shared/domain-types"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useAuthenticatedQuery } from "../../hooks/useAuthenticatedQuery"; +import { useMeQuery } from "../auth/useMeQuery"; +import { taskKeys } from "./taskKeys"; + +const TASK_LIST_POLL_INTERVAL_MS = 30_000; + +export function useTasks( + filters?: { + repository?: string; + showAllUsers?: boolean; + showInternal?: boolean; + }, + options?: { enabled?: boolean }, +) { + const { data: currentUser } = useMeQuery(); + const createdBy = filters?.showAllUsers ? undefined : currentUser?.id; + const internal = filters?.showInternal ? true : undefined; + + return useAuthenticatedQuery( + taskKeys.list({ repository: filters?.repository, createdBy, internal }), + (client) => + client.getTasks({ + repository: filters?.repository, + createdBy, + internal, + }) as unknown as Promise<Task[]>, + { + enabled: (options?.enabled ?? true) && !!currentUser?.id, + refetchInterval: TASK_LIST_POLL_INTERVAL_MS, + }, + ); +} + +export function useTaskSummaries( + ids: string[], + options?: { enabled?: boolean }, +) { + return useAuthenticatedQuery<Schemas.TaskSummary[]>( + taskKeys.summaries(ids), + (client) => client.getTaskSummaries(ids), + { + enabled: (options?.enabled ?? true) && ids.length > 0, + refetchInterval: TASK_LIST_POLL_INTERVAL_MS, + placeholderData: keepPreviousData, + }, + ); +} + +// The /tasks/summaries/ endpoint doesn't include origin_product, so fetch the +// slack-origin subset separately and intersect by id in the sidebar. The +// `internal` filter mirrors the sidebar's task-visibility scope so staff +// toggling the internal view still see slack icons on internal tasks. +export function useSlackTasks(options?: { + enabled?: boolean; + showInternal?: boolean; +}) { + const internal = options?.showInternal ? true : undefined; + return useAuthenticatedQuery<Task[]>( + taskKeys.list({ originProduct: "slack", internal }), + (client) => + client.getTasks({ + originProduct: "slack", + internal, + }) as unknown as Promise<Task[]>, + { + enabled: options?.enabled ?? true, + refetchInterval: TASK_LIST_POLL_INTERVAL_MS, + }, + ); +} diff --git a/apps/code/src/renderer/features/terminal/components/ActionTerminal.tsx b/packages/ui/src/features/terminal/ActionTerminal.tsx similarity index 96% rename from apps/code/src/renderer/features/terminal/components/ActionTerminal.tsx rename to packages/ui/src/features/terminal/ActionTerminal.tsx index 368d22ca02..0b146b9bf8 100644 --- a/apps/code/src/renderer/features/terminal/components/ActionTerminal.tsx +++ b/packages/ui/src/features/terminal/ActionTerminal.tsx @@ -1,7 +1,7 @@ import { getActionSessionId, useActionStore, -} from "@features/actions/stores/actionStore"; +} from "@posthog/ui/features/actions/actionStore"; import { useCallback, useEffect, useMemo } from "react"; import { Terminal } from "./Terminal"; diff --git a/apps/code/src/renderer/features/terminal/components/ShellTerminal.tsx b/packages/ui/src/features/terminal/ShellTerminal.tsx similarity index 86% rename from apps/code/src/renderer/features/terminal/components/ShellTerminal.tsx rename to packages/ui/src/features/terminal/ShellTerminal.tsx index 7fbdcceb4d..676a1e9a1e 100644 --- a/apps/code/src/renderer/features/terminal/components/ShellTerminal.tsx +++ b/packages/ui/src/features/terminal/ShellTerminal.tsx @@ -1,6 +1,6 @@ -import { secureRandomString } from "@renderer/utils/random"; +import { useTerminalStore } from "@posthog/ui/features/terminal/terminalStore"; +import { secureRandomString } from "@posthog/ui/utils/random"; import { useMemo } from "react"; -import { useTerminalStore } from "../stores/terminalStore"; import { Terminal } from "./Terminal"; interface ShellTerminalProps { diff --git a/apps/code/src/renderer/features/terminal/components/Terminal.tsx b/packages/ui/src/features/terminal/Terminal.tsx similarity index 73% rename from apps/code/src/renderer/features/terminal/components/Terminal.tsx rename to packages/ui/src/features/terminal/Terminal.tsx index 90f021ede7..ba87f7eefe 100644 --- a/apps/code/src/renderer/features/terminal/components/Terminal.tsx +++ b/packages/ui/src/features/terminal/Terminal.tsx @@ -1,13 +1,16 @@ -import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; import { Box } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc"; -import { useThemeStore } from "@stores/themeStore"; import "@xterm/xterm/css/xterm.css"; -import { useSubscription } from "@trpc/tanstack-react-query"; +import { resolveTerminalFontFamily } from "@posthog/core/terminal/resolveTerminalFontFamily"; +import { useService } from "@posthog/di/react"; +import { + SHELL_CLIENT, + type ShellClient, +} from "@posthog/ui/features/terminal/shellClient"; +import { terminalManager } from "@posthog/ui/features/terminal/TerminalManager"; import { useCallback, useEffect, useRef } from "react"; -import { terminalManager } from "../services/TerminalManager"; -import { resolveTerminalFontFamily } from "../utils/resolveTerminalFontFamily"; export interface TerminalProps { sessionId: string; @@ -30,8 +33,8 @@ export function Terminal({ onReady, onExit, }: TerminalProps) { - const trpcReact = useTRPC(); const terminalRef = useRef<HTMLDivElement>(null); + const shellClient = useService<ShellClient>(SHELL_CLIENT); const isDarkMode = useThemeStore((state) => state.isDarkMode); const terminalFont = useSettingsStore((s) => s.terminalFont); const terminalCustomFontFamily = useSettingsStore( @@ -76,31 +79,20 @@ export function Terminal({ ); }, [terminalFont, terminalCustomFontFamily]); - // Subscribe to shell data events - useSubscription( - trpcReact.shell.onData.subscriptionOptions( - { sessionId }, - { - enabled: !!sessionId, - onData: (event) => { - terminalManager.writeData(event.sessionId, event.data); - }, - }, - ), - ); - - // Subscribe to shell exit events - useSubscription( - trpcReact.shell.onExit.subscriptionOptions( - { sessionId }, - { - enabled: !!sessionId, - onData: (event) => { - terminalManager.handleExit(event.sessionId, event.exitCode); - }, - }, - ), - ); + // Subscribe to shell data + exit events via the host shell client. + useEffect(() => { + if (!sessionId) return; + const dataSub = shellClient.onData(sessionId, (event) => { + terminalManager.writeData(event.sessionId, event.data); + }); + const exitSub = shellClient.onExit(sessionId, (event) => { + terminalManager.handleExit(event.sessionId, event.exitCode ?? undefined); + }); + return () => { + dataSub.unsubscribe(); + exitSub.unsubscribe(); + }; + }, [sessionId, shellClient]); // Event callbacks useEffect(() => { diff --git a/apps/code/src/renderer/features/terminal/services/TerminalManager.ts b/packages/ui/src/features/terminal/TerminalManager.ts similarity index 92% rename from apps/code/src/renderer/features/terminal/services/TerminalManager.ts rename to packages/ui/src/features/terminal/TerminalManager.ts index 44d4d6e03a..414106ec25 100644 --- a/apps/code/src/renderer/features/terminal/services/TerminalManager.ts +++ b/packages/ui/src/features/terminal/TerminalManager.ts @@ -1,11 +1,15 @@ -import { trpcClient } from "@renderer/trpc"; -import { logger } from "@utils/logger"; -import { isMac } from "@utils/platform"; +import { DEFAULT_TERMINAL_FONT_FAMILY } from "@posthog/core/terminal/resolveTerminalFontFamily"; +import { resolveService } from "@posthog/di/container"; +import { + SHELL_CLIENT, + type ShellClient, +} from "@posthog/ui/features/terminal/shellClient"; +import { isMac } from "@posthog/ui/utils/platform"; +import { logger } from "@posthog/ui/workbench/logger"; import { FitAddon } from "@xterm/addon-fit"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { Terminal as XTerm } from "@xterm/xterm"; -import { DEFAULT_TERMINAL_FONT_FAMILY } from "../utils/resolveTerminalFontFamily"; const log = logger.scope("terminal-manager"); @@ -99,9 +103,11 @@ function loadAddons(term: XTerm) { const serialize = new SerializeAddon(); const activateLink = (_event: MouseEvent, uri: string) => { - trpcClient.os.openExternal.mutate({ url: uri }).catch((error: Error) => { - log.error("Failed to open link:", uri, error); - }); + resolveService<ShellClient>(SHELL_CLIENT) + .openExternal({ url: uri }) + .catch((error: Error) => { + log.error("Failed to open link:", uri, error); + }); }; const webLinks = new WebLinksAddon(activateLink); @@ -197,8 +203,8 @@ class TerminalManagerImpl { // Setup user input handler const disposable = term.onData((data: string) => { - trpcClient.shell.write - .mutate({ sessionId, data }) + resolveService<ShellClient>(SHELL_CLIENT) + .write({ sessionId, data }) .catch((error: Error) => { log.error("Failed to write to shell:", error); }); @@ -221,21 +227,27 @@ class TerminalManagerImpl { command?: string, ): Promise<void> { try { - const sessionExists = await trpcClient.shell.check.query({ sessionId }); + const sessionExists = await resolveService<ShellClient>( + SHELL_CLIENT, + ).check({ sessionId }); if (!sessionExists) { if (instance.attachedElement) { instance.fitAddon.fit(); } if (command && cwd) { - await trpcClient.shell.createCommand.mutate({ + await resolveService<ShellClient>(SHELL_CLIENT).createCommand({ sessionId, command, cwd, taskId, }); } else { - await trpcClient.shell.create.mutate({ sessionId, cwd, taskId }); + await resolveService<ShellClient>(SHELL_CLIENT).create({ + sessionId, + cwd, + taskId, + }); } } @@ -243,8 +255,8 @@ class TerminalManagerImpl { if (instance.attachedElement) { instance.fitAddon.fit(); - trpcClient.shell.resize - .mutate({ + resolveService<ShellClient>(SHELL_CLIENT) + .resize({ sessionId, cols: instance.term.cols, rows: instance.term.rows, @@ -341,8 +353,8 @@ class TerminalManagerImpl { instance.fitAddon.fit(); if (instance.isReady) { - trpcClient.shell.resize - .mutate({ + resolveService<ShellClient>(SHELL_CLIENT) + .resize({ sessionId, cols: instance.term.cols, rows: instance.term.rows, diff --git a/packages/ui/src/features/terminal/shellClient.ts b/packages/ui/src/features/terminal/shellClient.ts new file mode 100644 index 0000000000..6667d9b7c5 --- /dev/null +++ b/packages/ui/src/features/terminal/shellClient.ts @@ -0,0 +1,43 @@ +export interface ShellCreateInput { + sessionId: string; + cwd?: string; + taskId?: string; +} + +export interface ShellCreateCommandInput { + sessionId: string; + command: string; + cwd: string; + taskId?: string; +} + +export interface ShellResizeInput { + sessionId: string; + cols: number; + rows: number; +} + +export interface ShellClient { + write(input: { sessionId: string; data: string }): Promise<void>; + check(input: { sessionId: string }): Promise<boolean>; + destroy(input: { sessionId: string }): Promise<void>; + create(input: ShellCreateInput): Promise<void>; + createCommand(input: ShellCreateCommandInput): Promise<void>; + resize(input: ShellResizeInput): Promise<void>; + getProcess(input: { sessionId: string }): Promise<string | null>; + execute(input: { + cwd: string; + command: string; + }): Promise<{ stdout: string; stderr: string; exitCode: number }>; + openExternal(input: { url: string }): Promise<void>; + onData( + sessionId: string, + onEvent: (event: { sessionId: string; data: string }) => void, + ): { unsubscribe: () => void }; + onExit( + sessionId: string, + onEvent: (event: { sessionId: string; exitCode: number | null }) => void, + ): { unsubscribe: () => void }; +} + +export const SHELL_CLIENT = Symbol.for("posthog.ui.ShellClient"); diff --git a/apps/code/src/renderer/features/terminal/stores/terminalStore.ts b/packages/ui/src/features/terminal/terminalStore.ts similarity index 67% rename from apps/code/src/renderer/features/terminal/stores/terminalStore.ts rename to packages/ui/src/features/terminal/terminalStore.ts index 859aff6ac2..219660555e 100644 --- a/apps/code/src/renderer/features/terminal/stores/terminalStore.ts +++ b/packages/ui/src/features/terminal/terminalStore.ts @@ -1,9 +1,8 @@ -import { trpcClient } from "@renderer/trpc/client"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { terminalManager } from "../services/TerminalManager"; +import { terminalManager } from "./TerminalManager"; -interface TerminalState { +export interface TerminalState { serializedState: string | null; sessionId: string | null; processName: string | null; @@ -11,15 +10,12 @@ interface TerminalState { interface TerminalStoreState { terminalStates: Record<string, TerminalState>; - pollingIntervals: Record<string, number>; getTerminalState: (key: string) => TerminalState | undefined; setSerializedState: (key: string, state: string) => void; setSessionId: (key: string, sessionId: string) => void; setProcessName: (key: string, processName: string | null) => void; clearTerminalState: (key: string) => void; clearTerminalStatesForTask: (taskId: string) => void; - startPolling: (key: string) => void; - stopPolling: (key: string) => void; } const DEFAULT_TERMINAL_STATE: TerminalState = { @@ -32,7 +28,6 @@ export const useTerminalStore = create<TerminalStoreState>()( persist( (set, get) => ({ terminalStates: {}, - pollingIntervals: {}, getTerminalState: (key: string) => { return get().terminalStates[key] || DEFAULT_TERMINAL_STATE; @@ -93,42 +88,6 @@ export const useTerminalStore = create<TerminalStoreState>()( return { terminalStates: newStates }; }); }, - - startPolling: (key: string) => { - const { pollingIntervals } = get(); - if (pollingIntervals[key]) return; - - const poll = async () => { - const state = get().terminalStates[key]; - if (!state?.sessionId) return; - - const processName = await trpcClient.shell.getProcess.query({ - sessionId: state.sessionId, - }); - if (processName !== state.processName) { - get().setProcessName(key, processName ?? null); - } - }; - - poll(); - const interval = window.setInterval(poll, 500); - set((prev) => ({ - pollingIntervals: { ...prev.pollingIntervals, [key]: interval }, - })); - }, - - stopPolling: (key: string) => { - const { pollingIntervals } = get(); - const interval = pollingIntervals[key]; - if (interval) { - clearInterval(interval); - set((prev) => { - const newIntervals = { ...prev.pollingIntervals }; - delete newIntervals[key]; - return { pollingIntervals: newIntervals }; - }); - } - }, }), { name: "terminal-store", @@ -144,7 +103,6 @@ export const useTerminalStore = create<TerminalStoreState>()( ), ); -// Subscribe to manager events for auto-persistence terminalManager.on("stateChange", ({ persistenceKey, serializedState }) => { useTerminalStore .getState() diff --git a/packages/ui/src/features/terminal/useShellProcessPoller.ts b/packages/ui/src/features/terminal/useShellProcessPoller.ts new file mode 100644 index 0000000000..dc2689fe45 --- /dev/null +++ b/packages/ui/src/features/terminal/useShellProcessPoller.ts @@ -0,0 +1,28 @@ +import { SHELL_PROCESS_POLLER } from "@posthog/core/terminal/identifiers"; +import type { ShellProcessPoller } from "@posthog/core/terminal/shellProcessPoller"; +import { useService } from "@posthog/di/react"; +import { useTerminalStore } from "@posthog/ui/features/terminal/terminalStore"; +import { useEffect } from "react"; + +export function useShellProcessPoller(key: string): void { + const poller = useService<ShellProcessPoller>(SHELL_PROCESS_POLLER); + + useEffect(() => { + const sessionId = + useTerminalStore.getState().terminalStates[key]?.sessionId; + if (!sessionId) return; + + const setProcessName = useTerminalStore.getState().setProcessName; + const initial = + useTerminalStore.getState().terminalStates[key]?.processName ?? null; + + poller.start( + key, + sessionId, + (processName) => setProcessName(key, processName), + initial, + ); + + return () => poller.stop(key); + }, [key, poller]); +} diff --git a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx b/packages/ui/src/features/tour/components/TourOverlay.tsx similarity index 93% rename from apps/code/src/renderer/features/tour/components/TourOverlay.tsx rename to packages/ui/src/features/tour/components/TourOverlay.tsx index 3de387ed35..0caac55775 100644 --- a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx +++ b/packages/ui/src/features/tour/components/TourOverlay.tsx @@ -1,11 +1,11 @@ -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { useCommandMenuStore } from "@stores/commandMenuStore"; +import { getTour } from "@posthog/core/tour/tourRegistry"; +import { useSettingsDialogStore } from "@posthog/ui/features/settings/settingsDialogStore"; +import { useCommandMenuStore } from "@posthog/ui/workbench/commandMenuStore"; import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { useElementRect } from "../hooks/useElementRect"; -import { useTourStore } from "../stores/tourStore"; -import { TOUR_REGISTRY } from "../tours/tourRegistry"; +import { useTourStore } from "../tourStore"; import { TourTooltip } from "./TourTooltip"; const SPOTLIGHT_PADDING = 6; @@ -52,7 +52,7 @@ export function TourOverlay() { return () => document.body.classList.remove("tour-active"); }, [activeTourId]); - const tour = activeTourId ? TOUR_REGISTRY[activeTourId] : null; + const tour = activeTourId ? getTour(activeTourId) : null; const step = tour?.steps[activeStepIndex] ?? null; const selector = step ? `[data-tour="${step.target}"]` : null; diff --git a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx b/packages/ui/src/features/tour/components/TourTooltip.tsx similarity index 95% rename from apps/code/src/renderer/features/tour/components/TourTooltip.tsx rename to packages/ui/src/features/tour/components/TourTooltip.tsx index a583c7e745..f200f78953 100644 --- a/apps/code/src/renderer/features/tour/components/TourTooltip.tsx +++ b/packages/ui/src/features/tour/components/TourTooltip.tsx @@ -1,10 +1,10 @@ +import { calculateTooltipPlacement } from "@posthog/core/tour/calculateTooltipPlacement"; +import type { TooltipPlacement, TourStep } from "@posthog/core/tour/types"; +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; import { Button, Flex, Text, Theme } from "@radix-ui/themes"; -import { useThemeStore } from "@stores/themeStore"; import { AnimatePresence, motion, useAnimationControls } from "framer-motion"; import { useEffect } from "react"; import { createPortal } from "react-dom"; -import type { TooltipPlacement, TourStep } from "../types"; -import { calculateTooltipPlacement } from "../utils/calculateTooltipPlacement"; interface TourTooltipProps { step: TourStep; @@ -161,6 +161,8 @@ export function TourTooltip({ targetRect, TOOLTIP_WIDTH_ESTIMATE, TOOLTIP_HEIGHT_ESTIMATE, + window.innerWidth, + window.innerHeight, step.preferredPlacement, ); diff --git a/apps/code/src/renderer/features/tour/hooks/useElementRect.ts b/packages/ui/src/features/tour/hooks/useElementRect.ts similarity index 100% rename from apps/code/src/renderer/features/tour/hooks/useElementRect.ts rename to packages/ui/src/features/tour/hooks/useElementRect.ts diff --git a/packages/ui/src/features/tour/tourStore.ts b/packages/ui/src/features/tour/tourStore.ts new file mode 100644 index 0000000000..dad75451de --- /dev/null +++ b/packages/ui/src/features/tour/tourStore.ts @@ -0,0 +1,100 @@ +import { + advance as advanceMachine, + completeTour as completeTourMachine, + computeReturningUserMigration, + dismiss as dismissMachine, + startTour as startTourMachine, + type TourEvent, +} from "@posthog/core/tour/tourMachine"; +import { getRegisteredTours, getTour } from "@posthog/core/tour/tourRegistry"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { track } from "@posthog/ui/workbench/analytics"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface TourStoreState { + completedTourIds: string[]; + activeTourId: string | null; + activeStepIndex: number; +} + +interface TourStoreActions { + startTour: (tourId: string) => void; + advance: (tourId: string, stepId: string) => void; + completeTour: (tourId: string) => void; + dismiss: () => void; + resetTours: () => void; + applyReturningUserMigration: (hasCompletedOnboarding: boolean) => void; +} + +type TourStore = TourStoreState & TourStoreActions; + +const RETURNING_USER_MIGRATION_KEY = "tour-store-v1-migrated"; + +function emit(events: TourEvent[]): void { + for (const event of events) { + track(ANALYTICS_EVENTS.TOUR_EVENT, event); + } +} + +export const useTourStore = create<TourStore>()( + persist( + (set, get) => ({ + completedTourIds: [], + activeTourId: null, + activeStepIndex: 0, + + startTour: (tourId) => { + const { state, events } = startTourMachine(get(), tourId, getTour); + set(state); + emit(events); + }, + + advance: (tourId, stepId) => { + const { state, events } = advanceMachine( + get(), + tourId, + stepId, + getTour, + ); + set(state); + emit(events); + }, + + completeTour: (tourId) => { + const { state, events } = completeTourMachine(get(), tourId, getTour); + set(state); + emit(events); + }, + + dismiss: () => { + const { state, events } = dismissMachine(get(), getTour); + set(state); + emit(events); + }, + + resetTours: () => { + set({ completedTourIds: [], activeTourId: null, activeStepIndex: 0 }); + }, + + applyReturningUserMigration: (hasCompletedOnboarding) => { + if (localStorage.getItem(RETURNING_USER_MIGRATION_KEY)) return; + localStorage.setItem(RETURNING_USER_MIGRATION_KEY, "1"); + + const ids = computeReturningUserMigration( + getRegisteredTours(), + hasCompletedOnboarding, + ); + for (const id of ids) { + get().completeTour(id); + } + }, + }), + { + name: "tour-store", + partialize: (state) => ({ + completedTourIds: state.completedTourIds, + }), + }, + ), +); diff --git a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts b/packages/ui/src/features/tour/tours/createFirstTaskTour.ts similarity index 73% rename from apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts rename to packages/ui/src/features/tour/tours/createFirstTaskTour.ts index bfc8c9c123..c1abecbbcd 100644 --- a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts +++ b/packages/ui/src/features/tour/tours/createFirstTaskTour.ts @@ -1,10 +1,13 @@ -import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; -import explorerHog from "@renderer/assets/images/hedgehogs/explorer-hog.png"; -import happyHog from "@renderer/assets/images/hedgehogs/happy-hog.png"; -import type { TourDefinition } from "../types"; +import type { TourDefinition } from "@posthog/core/tour/types"; +import { + builderHog, + explorerHog, + happyHog, +} from "@posthog/ui/assets/hedgehogs"; export const createFirstTaskTour: TourDefinition = { id: "create-first-task", + completeForReturningUsers: true, steps: [ { id: "folder-picker", diff --git a/packages/ui/src/features/updates/updateStore.ts b/packages/ui/src/features/updates/updateStore.ts new file mode 100644 index 0000000000..c94f2ef31e --- /dev/null +++ b/packages/ui/src/features/updates/updateStore.ts @@ -0,0 +1,51 @@ +import { + getUpdateUiStatus, + type UpdateUiStatus, + updateStore, +} from "@posthog/core/updates/updateStore"; +import { useService } from "@posthog/di/react"; +import { + UPDATES_CLIENT, + type UpdatesClient, +} from "@posthog/ui/features/updates/updatesClient"; +import { logger } from "@posthog/ui/workbench/logger"; +import { useStore } from "zustand"; + +const log = logger.scope("update-store"); + +interface UpdateView { + status: UpdateUiStatus; + version: string | null; + isEnabled: boolean; +} + +export function useUpdateView(): UpdateView { + return useStore(updateStore, (state) => ({ + status: state.status, + version: state.version, + isEnabled: state.isEnabled, + })); +} + +export function useInstallUpdate(): () => Promise<void> { + const client = useService<UpdatesClient>(UPDATES_CLIENT); + + return async () => { + if (getUpdateUiStatus() === "installing") { + return; + } + + updateStore.getState().setStatus("installing"); + + try { + const result = await client.install(); + if (!result.installed) { + log.error("Update install returned not installed"); + updateStore.getState().setStatus("ready"); + } + } catch (error) { + log.error("Failed to install update", { error }); + updateStore.getState().setStatus("ready"); + } + }; +} diff --git a/packages/ui/src/features/updates/updatesAdapter.ts b/packages/ui/src/features/updates/updatesAdapter.ts new file mode 100644 index 0000000000..d1c2ee525e --- /dev/null +++ b/packages/ui/src/features/updates/updatesAdapter.ts @@ -0,0 +1,28 @@ +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import type { UpdatesClient } from "./updatesClient"; + +function host(): HostTrpcClient { + return resolveService<HostTrpcClient>(HOST_TRPC_CLIENT); +} + +export const updatesClient: UpdatesClient = { + install: () => host().updates.install.mutate(), + check: () => host().updates.check.mutate(), + isEnabled: () => host().updates.isEnabled.query(), + getStatus: () => host().updates.getStatus.query(), + onStatus: (sub) => host().updates.onStatus.subscribe(undefined, sub), + onReady: (sub) => + host().updates.onReady.subscribe(undefined, { + onData: (data) => sub.onData({ version: data.version }), + onError: sub.onError, + }), + onCheckFromMenu: (sub) => + host().updates.onCheckFromMenu.subscribe(undefined, { + onData: () => sub.onData(), + onError: sub.onError, + }), +}; diff --git a/packages/ui/src/features/updates/updatesClient.ts b/packages/ui/src/features/updates/updatesClient.ts new file mode 100644 index 0000000000..63aed8acb1 --- /dev/null +++ b/packages/ui/src/features/updates/updatesClient.ts @@ -0,0 +1,23 @@ +import type { + CheckForUpdatesOutput, + UpdatesStatusPayload, +} from "@posthog/core/updates/schemas"; + +interface Subscriber<T> { + onData: (data: T) => void; + onError?: (error: unknown) => void; +} + +export interface UpdatesClient { + install(): Promise<{ installed: boolean }>; + check(): Promise<CheckForUpdatesOutput>; + isEnabled(): Promise<{ enabled: boolean }>; + getStatus(): Promise<UpdatesStatusPayload>; + onStatus(sub: Subscriber<UpdatesStatusPayload>): { unsubscribe: () => void }; + onReady(sub: Subscriber<{ version: string | null }>): { + unsubscribe: () => void; + }; + onCheckFromMenu(sub: Subscriber<void>): { unsubscribe: () => void }; +} + +export const UPDATES_CLIENT = Symbol.for("posthog.ui.UpdatesClient"); diff --git a/packages/ui/src/features/workspace/identifiers.ts b/packages/ui/src/features/workspace/identifiers.ts new file mode 100644 index 0000000000..43d8e4d0e4 --- /dev/null +++ b/packages/ui/src/features/workspace/identifiers.ts @@ -0,0 +1,6 @@ +/** + * Shared TanStack Query key for the workspace map. The UI read hooks own this + * query; every host invalidator (create/delete/focus/etc.) must invalidate this + * exact key so the workspace UI stays in sync. + */ +export const WORKSPACE_QUERY_KEY = ["workspace", "getAll"] as const; diff --git a/apps/code/src/renderer/features/workspace/hooks/useBranchMismatch.test.ts b/packages/ui/src/features/workspace/useBranchMismatch.test.ts similarity index 100% rename from apps/code/src/renderer/features/workspace/hooks/useBranchMismatch.test.ts rename to packages/ui/src/features/workspace/useBranchMismatch.test.ts diff --git a/apps/code/src/renderer/features/workspace/hooks/useBranchMismatch.ts b/packages/ui/src/features/workspace/useBranchMismatch.ts similarity index 84% rename from apps/code/src/renderer/features/workspace/hooks/useBranchMismatch.ts rename to packages/ui/src/features/workspace/useBranchMismatch.ts index 00950414eb..bd45f20ea3 100644 --- a/apps/code/src/renderer/features/workspace/hooks/useBranchMismatch.ts +++ b/packages/ui/src/features/workspace/useBranchMismatch.ts @@ -1,3 +1,7 @@ +import { + isBranchMismatch, + shouldWarnBranchMismatch, +} from "@posthog/core/workspace/branchMismatch"; import { useCallback, useEffect, useRef } from "react"; import { create } from "zustand"; import { useWorkspace } from "./useWorkspace"; @@ -24,8 +28,7 @@ function useBranchMismatch(taskId: string) { const workspace = useWorkspace(taskId); const linkedBranch = workspace?.linkedBranch ?? null; const currentBranch = workspace?.branchName ?? null; - const isMismatch = - !!linkedBranch && !!currentBranch && linkedBranch !== currentBranch; + const isMismatch = isBranchMismatch(linkedBranch, currentBranch); const branchWarningDismissed = useBranchWarningStore( (s) => s.dismissed[taskId] ?? false, @@ -40,7 +43,11 @@ function useBranchMismatch(taskId: string) { } }, [currentBranch, taskId, reset]); - const shouldWarn = isMismatch && !branchWarningDismissed; + const shouldWarn = shouldWarnBranchMismatch( + linkedBranch, + currentBranch, + branchWarningDismissed, + ); return { linkedBranch, diff --git a/apps/code/src/renderer/features/workspace/hooks/useBranchMismatchDialog.test.ts b/packages/ui/src/features/workspace/useBranchMismatchDialog.test.ts similarity index 91% rename from apps/code/src/renderer/features/workspace/hooks/useBranchMismatchDialog.test.ts rename to packages/ui/src/features/workspace/useBranchMismatchDialog.test.ts index 621f666afc..d4d078a73c 100644 --- a/apps/code/src/renderer/features/workspace/hooks/useBranchMismatchDialog.test.ts +++ b/packages/ui/src/features/workspace/useBranchMismatchDialog.test.ts @@ -19,52 +19,49 @@ const mockGuard = vi.hoisted(() => ({ }), ), })); -vi.mock("@features/workspace/hooks/useBranchMismatch", () => mockGuard); +vi.mock("./useBranchMismatch", () => mockGuard); -vi.mock("@features/git-interaction/hooks/useGitQueries", () => ({ +vi.mock("../git-interaction/useGitQueries", () => ({ useGitQueries: () => ({ hasChanges: false }), })); -vi.mock("@features/git-interaction/utils/gitCacheKeys", () => ({ +vi.mock("../git-interaction/gitCacheKeys", () => ({ invalidateGitBranchQueries: vi.fn(), })); -let capturedMutationOptions: { - onSuccess?: () => void; - onError?: (e: Error) => void; -} = {}; -const mockMutate = vi.fn(); - -vi.mock("@renderer/trpc/client", () => ({ - useTRPC: () => ({ +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPC: () => ({ git: { checkoutBranch: { - mutationOptions: (opts: Record<string, unknown>) => { - capturedMutationOptions = opts as typeof capturedMutationOptions; - return opts; - }, + mutationOptions: (opts: Record<string, unknown>) => opts, }, }, }), })); +let capturedMutationOptions: { + onSuccess?: () => void; + onError?: (e: Error) => void; +} = {}; +const mockMutate = vi.fn(); + vi.mock("@tanstack/react-query", () => ({ - useMutation: () => ({ - mutate: mockMutate, - isPending: false, - }), + useMutation: (opts: Record<string, unknown>) => { + capturedMutationOptions = opts as typeof capturedMutationOptions; + return { mutate: mockMutate, isPending: false }; + }, })); -vi.mock("@utils/logger", () => ({ +vi.mock("../../workbench/logger", () => ({ logger: { scope: () => ({ error: vi.fn() }) }, })); const mockTrack = vi.fn(); -vi.mock("@utils/analytics", () => ({ +vi.mock("../../workbench/analytics", () => ({ track: (...args: unknown[]) => mockTrack(...args), })); -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; import { useBranchMismatchDialog } from "./useBranchMismatchDialog"; function renderDialog(overrides?: { shouldWarn?: boolean }) { diff --git a/apps/code/src/renderer/features/workspace/hooks/useBranchMismatchDialog.ts b/packages/ui/src/features/workspace/useBranchMismatchDialog.ts similarity index 59% rename from apps/code/src/renderer/features/workspace/hooks/useBranchMismatchDialog.ts rename to packages/ui/src/features/workspace/useBranchMismatchDialog.ts index 8f74fca239..1ebaf54f93 100644 --- a/apps/code/src/renderer/features/workspace/hooks/useBranchMismatchDialog.ts +++ b/packages/ui/src/features/workspace/useBranchMismatchDialog.ts @@ -1,12 +1,19 @@ -import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; -import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; -import { useBranchMismatchGuard } from "@features/workspace/hooks/useBranchMismatch"; -import { useTRPC } from "@renderer/trpc/client"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { + type BranchMismatchDialogAction, + buildBranchMismatchAnalyticsEvent, + buildCheckoutBranchRequest, + decideBeforeSubmit, + resolveSwitchErrorMessage, +} from "@posthog/core/workspace/branchMismatchDialog"; +import { useHostTRPC } from "@posthog/host-router/react"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; import { useMutation } from "@tanstack/react-query"; -import { track } from "@utils/analytics"; -import { logger } from "@utils/logger"; import { useCallback, useRef, useState } from "react"; +import { track } from "../../workbench/analytics"; +import { logger } from "../../workbench/logger"; +import { invalidateGitBranchQueries } from "../git-interaction/gitCacheKeys"; +import { useGitQueries } from "../git-interaction/useGitQueries"; +import { useBranchMismatchGuard } from "./useBranchMismatch"; const log = logger.scope("branch-mismatch"); @@ -37,7 +44,25 @@ export function useBranchMismatchDialog({ repoPath ?? undefined, ); - const trpc = useTRPC(); + const emitAction = useCallback( + (action: BranchMismatchDialogAction) => { + const analytics = buildBranchMismatchAnalyticsEvent(action, { + taskId, + linkedBranch, + currentBranch, + hasUncommittedChanges, + }); + if (!analytics) return; + if (analytics.event === ANALYTICS_EVENTS.BRANCH_MISMATCH_WARNING_SHOWN) { + track(analytics.event, analytics.properties); + } else { + track(analytics.event, analytics.properties); + } + }, + [taskId, linkedBranch, currentBranch, hasUncommittedChanges], + ); + + const trpc = useHostTRPC(); const { mutate: checkoutBranch, isPending: isSwitching } = useMutation( trpc.git.checkoutBranch.mutationOptions({ onSuccess: () => { @@ -52,60 +77,35 @@ export function useBranchMismatchDialog({ }, onError: (error) => { log.error("Failed to switch branch", error); - setSwitchError( - error instanceof Error ? error.message : "Failed to switch branch", - ); + setSwitchError(resolveSwitchErrorMessage(error)); }, }), ); const handleBeforeSubmit = useCallback( (text: string, clearEditor: () => void): boolean => { - if (shouldWarn) { + if (!decideBeforeSubmit(shouldWarn)) { setPendingMessage(text); pendingMessageRef.current = text; pendingClearRef.current = clearEditor; - if (linkedBranch && currentBranch) { - track(ANALYTICS_EVENTS.BRANCH_MISMATCH_WARNING_SHOWN, { - task_id: taskId, - linked_branch: linkedBranch, - current_branch: currentBranch, - has_uncommitted_changes: hasUncommittedChanges, - }); - } + emitAction("shown"); return false; } return true; }, - [shouldWarn, taskId, linkedBranch, currentBranch, hasUncommittedChanges], + [shouldWarn, emitAction], ); const handleSwitch = useCallback(() => { - if (!linkedBranch || !repoPath) return; + const request = buildCheckoutBranchRequest(repoPath, linkedBranch); + if (!request) return; setSwitchError(null); - if (currentBranch) { - track(ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION, { - task_id: taskId, - action: "switch", - linked_branch: linkedBranch, - current_branch: currentBranch, - }); - } - checkoutBranch({ - directoryPath: repoPath, - branchName: linkedBranch, - }); - }, [linkedBranch, currentBranch, repoPath, taskId, checkoutBranch]); + emitAction("switch"); + checkoutBranch(request); + }, [linkedBranch, repoPath, emitAction, checkoutBranch]); const handleContinue = useCallback(() => { - if (linkedBranch && currentBranch) { - track(ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION, { - task_id: taskId, - action: "continue", - linked_branch: linkedBranch, - current_branch: currentBranch, - }); - } + emitAction("continue"); dismissWarning(); pendingClearRef.current?.(); pendingClearRef.current = null; @@ -114,22 +114,15 @@ export function useBranchMismatchDialog({ setPendingMessage(null); pendingMessageRef.current = null; setSwitchError(null); - }, [dismissWarning, taskId, linkedBranch, currentBranch]); + }, [dismissWarning, emitAction]); const handleCancel = useCallback(() => { - if (linkedBranch && currentBranch) { - track(ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION, { - task_id: taskId, - action: "cancel", - linked_branch: linkedBranch, - current_branch: currentBranch, - }); - } + emitAction("cancel"); setPendingMessage(null); pendingMessageRef.current = null; pendingClearRef.current = null; setSwitchError(null); - }, [taskId, linkedBranch, currentBranch]); + }, [emitAction]); const dialogProps = linkedBranch && currentBranch diff --git a/apps/code/src/renderer/features/workspace/hooks/useFocusWorkspace.tsx b/packages/ui/src/features/workspace/useFocusWorkspace.tsx similarity index 79% rename from apps/code/src/renderer/features/workspace/hooks/useFocusWorkspace.tsx rename to packages/ui/src/features/workspace/useFocusWorkspace.tsx index f87fd41cf4..7c3cb1c037 100644 --- a/apps/code/src/renderer/features/workspace/hooks/useFocusWorkspace.tsx +++ b/packages/ui/src/features/workspace/useFocusWorkspace.tsx @@ -1,13 +1,18 @@ -import { useTerminalStore } from "@features/terminal/stores/terminalStore"; +import { + buildEnableFocusParams, + canFocusWorkspace, + focusTerminalKey, +} from "@posthog/core/workspace/focusWorkspace"; import { Text } from "@radix-ui/themes"; +import { useCallback, useMemo } from "react"; +import { toast } from "../../primitives/toast"; import { selectIsFocusedOnWorktree, selectIsLoading, useFocusStore, -} from "@stores/focusStore"; -import { showFocusSuccessToast } from "@utils/focusToast"; -import { toast } from "@utils/toast"; -import { useCallback, useMemo } from "react"; +} from "../focus/focusStore"; +import { showFocusSuccessToast } from "../focus/focusToast"; +import { useTerminalStore } from "../terminal/terminalStore"; import { useWorkspace } from "./useWorkspace"; export function useFocusWorkspace(taskId: string) { @@ -22,11 +27,11 @@ export function useFocusWorkspace(taskId: string) { ); const getFocusTerminalKey = useCallback( - (branch: string) => `focus-terminal-${taskId}-${branch}`, + (branch: string) => focusTerminalKey(taskId, branch), [taskId], ); - const focusTerminalKey = useMemo(() => { + const focusTerminalKeyValue = useMemo(() => { if (!focusSession) return null; return getFocusTerminalKey(focusSession.branch); }, [focusSession, getFocusTerminalKey]); @@ -67,25 +72,18 @@ export function useFocusWorkspace(taskId: string) { const handleFocus = useCallback(async () => { if (!workspace) return; - if ( - workspace.mode !== "worktree" || - !workspace.branchName || - !workspace.worktreePath - ) { + const params = buildEnableFocusParams(workspace); + if (!canFocusWorkspace(workspace) || !params) { toast.error("Could not edit workspace", { description: "Only worktree-mode workspaces can be edited", }); return; } - const result = await enableFocus({ - mainRepoPath: workspace.folderPath, - worktreePath: workspace.worktreePath, - branch: workspace.branchName, - }); + const result = await enableFocus(params); if (result.success) { - showFocusSuccessToast(workspace.branchName, result); + showFocusSuccessToast(params.branch, result); } else { toast.error("Could not edit workspace", { description: result.error, @@ -106,7 +104,7 @@ export function useFocusWorkspace(taskId: string) { focusSession, isFocusLoading, isFocused, - focusTerminalKey, + focusTerminalKey: focusTerminalKeyValue, handleFocus, handleUnfocus, handleToggleFocus, diff --git a/apps/code/src/renderer/features/workspace/hooks/useIsCloudTask.ts b/packages/ui/src/features/workspace/useIsCloudTask.ts similarity index 100% rename from apps/code/src/renderer/features/workspace/hooks/useIsCloudTask.ts rename to packages/ui/src/features/workspace/useIsCloudTask.ts diff --git a/packages/ui/src/features/workspace/useLocalRepoPath.ts b/packages/ui/src/features/workspace/useLocalRepoPath.ts new file mode 100644 index 0000000000..e19efac4f2 --- /dev/null +++ b/packages/ui/src/features/workspace/useLocalRepoPath.ts @@ -0,0 +1,11 @@ +import { resolveLocalRepoPath } from "@posthog/core/workspace/localRepoPath"; +import { selectIsFocusedOnWorktree, useFocusStore } from "../focus/focusStore"; +import { useWorkspace } from "./useWorkspace"; + +export function useLocalRepoPath(taskId: string): string | undefined { + const workspace = useWorkspace(taskId); + const isFocused = useFocusStore( + selectIsFocusedOnWorktree(workspace?.worktreePath ?? ""), + ); + return resolveLocalRepoPath(workspace, isFocused); +} diff --git a/packages/ui/src/features/workspace/useWorkspace.ts b/packages/ui/src/features/workspace/useWorkspace.ts new file mode 100644 index 0000000000..00483dcd41 --- /dev/null +++ b/packages/ui/src/features/workspace/useWorkspace.ts @@ -0,0 +1,39 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import type { Workspace } from "@posthog/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +function useWorkspacesQuery() { + const trpc = useHostTRPC(); + return useQuery( + trpc.workspace.getAll.queryOptions(undefined, { + staleTime: 1000 * 60, + }), + ); +} + +export function useWorkspaces(): { + data: Record<string, Workspace> | undefined; + isFetched: boolean; +} { + const query = useWorkspacesQuery(); + return { data: query.data, isFetched: query.isFetched }; +} + +export function useWorkspace(taskId: string | undefined): Workspace | null { + const { data: workspaces } = useWorkspacesQuery(); + return useMemo( + () => workspaces?.[taskId ?? ""] ?? null, + [workspaces, taskId], + ); +} + +export function useIsWorkspaceCloudRun(taskId: string | undefined): boolean { + const workspace = useWorkspace(taskId); + return workspace?.mode === "cloud"; +} + +export function useWorkspaceLoaded(): boolean { + const { isFetched } = useWorkspacesQuery(); + return isFetched; +} diff --git a/apps/code/src/renderer/features/workspace/hooks/useWorkspaceEvents.ts b/packages/ui/src/features/workspace/useWorkspaceEvents.ts similarity index 63% rename from apps/code/src/renderer/features/workspace/hooks/useWorkspaceEvents.ts rename to packages/ui/src/features/workspace/useWorkspaceEvents.ts index 264c66b774..7099376873 100644 --- a/apps/code/src/renderer/features/workspace/hooks/useWorkspaceEvents.ts +++ b/packages/ui/src/features/workspace/useWorkspaceEvents.ts @@ -1,10 +1,11 @@ -import { trpcClient } from "@renderer/trpc/client"; -import { toast } from "@utils/toast"; +import { useHostTRPCClient } from "@posthog/host-router/react"; import { useEffect } from "react"; +import { toast } from "../../primitives/toast"; export function useWorkspaceEvents(taskId: string) { + const client = useHostTRPCClient(); useEffect(() => { - const warningSubscription = trpcClient.workspace.onWarning.subscribe( + const warningSubscription = client.workspace.onWarning.subscribe( undefined, { onData: (data) => { @@ -20,5 +21,5 @@ export function useWorkspaceEvents(taskId: string) { return () => { warningSubscription.unsubscribe(); }; - }, [taskId]); + }, [taskId, client]); } diff --git a/packages/ui/src/features/workspace/useWorkspaceMutations.test.tsx b/packages/ui/src/features/workspace/useWorkspaceMutations.test.tsx new file mode 100644 index 0000000000..678385dece --- /dev/null +++ b/packages/ui/src/features/workspace/useWorkspaceMutations.test.tsx @@ -0,0 +1,91 @@ +import type { Workspace, WorkspaceInfo } from "@posthog/shared"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const WORKSPACE_QUERY_KEY = ["workspace", "getAll"]; +const WORKTREES_FILTER = { queryKey: ["worktrees", "/repo"] }; + +const createFn = vi.hoisted(() => vi.fn()); + +vi.mock("@posthog/host-router/react", () => ({ + useHostTRPC: () => ({ + workspace: { + getAll: { + queryKey: () => WORKSPACE_QUERY_KEY, + }, + listGitWorktrees: { + queryFilter: () => WORKTREES_FILTER, + }, + create: { + mutationOptions: (options: Record<string, unknown>) => ({ + mutationFn: (input: unknown) => createFn(input), + ...options, + }), + }, + delete: { + mutationOptions: (options: Record<string, unknown>) => ({ + mutationFn: vi.fn(), + ...options, + }), + }, + }, + }), +})); + +import { useEnsureWorkspace } from "./useWorkspaceMutations"; + +const created = { taskId: "t1", mode: "worktree" } as unknown as WorkspaceInfo; + +let queryClient: QueryClient; +function wrapper({ children }: { children: ReactNode }) { + return ( + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + ); +} + +describe("useWorkspaceMutations", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + createFn.mockResolvedValue(created); + }); + + it("useEnsureWorkspace returns a cached workspace without calling create", async () => { + queryClient.setQueryData(WORKSPACE_QUERY_KEY, { + t1: { taskId: "t1" } as unknown as Workspace, + }); + const { result } = renderHook(() => useEnsureWorkspace(), { wrapper }); + + let out: Workspace | null = null; + await act(async () => { + out = await result.current.ensureWorkspace("t1", "/repo"); + }); + + expect(out).toEqual({ taskId: "t1" }); + expect(createFn).not.toHaveBeenCalled(); + }); + + it("useEnsureWorkspace creates and invalidates the worktrees filter when absent", async () => { + queryClient.setQueryData(WORKSPACE_QUERY_KEY, {}); + const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries"); + const { result } = renderHook(() => useEnsureWorkspace(), { wrapper }); + + await act(async () => { + await result.current.ensureWorkspace("t1", "/repo", "worktree"); + }); + + expect(createFn).toHaveBeenCalledWith({ + taskId: "t1", + mainRepoPath: "/repo", + folderId: "", + folderPath: "/repo", + mode: "worktree", + branch: undefined, + }); + expect(invalidateSpy).toHaveBeenCalledWith(WORKTREES_FILTER); + }); +}); diff --git a/packages/ui/src/features/workspace/useWorkspaceMutations.ts b/packages/ui/src/features/workspace/useWorkspaceMutations.ts new file mode 100644 index 0000000000..d8a385bc13 --- /dev/null +++ b/packages/ui/src/features/workspace/useWorkspaceMutations.ts @@ -0,0 +1,121 @@ +import { + buildCreateWorkspaceRequest, + selectExistingWorkspace, +} from "@posthog/core/workspace/ensureWorkspace"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { Workspace, WorkspaceMode } from "@posthog/shared"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; + +function useInvalidateWorkspaceCaches() { + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + return useCallback( + async (mainRepoPath?: string) => { + const tasks: Promise<void>[] = [ + queryClient.invalidateQueries({ + queryKey: trpc.workspace.getAll.queryKey(), + }), + ]; + if (mainRepoPath) { + tasks.push( + queryClient.invalidateQueries( + trpc.workspace.listGitWorktrees.queryFilter({ mainRepoPath }), + ), + ); + } + await Promise.all(tasks); + }, + [queryClient, trpc], + ); +} + +export function useCreateWorkspace(): { isPending: boolean } { + const trpc = useHostTRPC(); + const invalidateCaches = useInvalidateWorkspaceCaches(); + + const mutation = useMutation( + trpc.workspace.create.mutationOptions({ + onSuccess: (_data, variables) => { + void invalidateCaches(variables.mainRepoPath); + }, + }), + ); + + return { isPending: mutation.isPending }; +} + +export function useDeleteWorkspace(): { isPending: boolean } { + const trpc = useHostTRPC(); + const invalidateCaches = useInvalidateWorkspaceCaches(); + + const mutation = useMutation( + trpc.workspace.delete.mutationOptions({ + onSuccess: (_data, variables) => { + void invalidateCaches(variables.mainRepoPath); + }, + }), + ); + + return { isPending: mutation.isPending }; +} + +export function useEnsureWorkspace(): { + ensureWorkspace: ( + taskId: string, + repoPath: string, + mode?: WorkspaceMode, + branch?: string | null, + ) => Promise<Workspace | null>; + isCreating: boolean; +} { + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + const invalidateCaches = useInvalidateWorkspaceCaches(); + + const createMutation = useMutation( + trpc.workspace.create.mutationOptions({ + onSuccess: (_data, variables) => { + void invalidateCaches(variables.mainRepoPath); + }, + }), + ); + + const ensureWorkspace = useCallback( + async ( + taskId: string, + repoPath: string, + mode: WorkspaceMode = "worktree", + branch?: string | null, + ): Promise<Workspace | null> => { + const workspacesKey = trpc.workspace.getAll.queryKey(); + const existing = selectExistingWorkspace( + queryClient.getQueryData<Record<string, Workspace>>(workspacesKey), + taskId, + ); + if (existing) { + return existing; + } + + const result = await createMutation.mutateAsync( + buildCreateWorkspaceRequest(taskId, repoPath, mode, branch), + ); + + if (!result) { + throw new Error("Failed to create workspace"); + } + + await invalidateCaches(repoPath); + return selectExistingWorkspace( + queryClient.getQueryData<Record<string, Workspace>>(workspacesKey), + taskId, + ); + }, + [createMutation, queryClient, invalidateCaches, trpc], + ); + + return { + ensureWorkspace, + isCreating: createMutation.isPending, + }; +} diff --git a/packages/ui/src/features/workspace/workspace-events.contribution.test.ts b/packages/ui/src/features/workspace/workspace-events.contribution.test.ts new file mode 100644 index 0000000000..e2637aabc9 --- /dev/null +++ b/packages/ui/src/features/workspace/workspace-events.contribution.test.ts @@ -0,0 +1,89 @@ +import type { HostTrpcClient } from "@posthog/host-router/client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ImperativeQueryClient } from "../../workbench/queryClient"; + +const invalidateQueries = vi.hoisted(() => vi.fn()); + +const toast = vi.hoisted(() => ({ error: vi.fn(), info: vi.fn() })); +vi.mock("../../primitives/toast", () => ({ toast })); + +import { WORKSPACE_QUERY_KEY } from "./identifiers"; +import { WorkspaceEventsContribution } from "./workspace-events.contribution"; + +function makeClient() { + const handlers: Record<string, (data: unknown) => void> = {}; + const event = (name: string) => ({ + subscribe: ( + _input: undefined, + opts: { onData: (data: unknown) => void }, + ) => { + handlers[name] = opts.onData; + return { unsubscribe: vi.fn() }; + }, + }); + return { + handlers, + workspace: { + onError: event("onError"), + onPromoted: event("onPromoted"), + onBranchChanged: event("onBranchChanged"), + onLinkedBranchChanged: event("onLinkedBranchChanged"), + }, + }; +} + +describe("WorkspaceEventsContribution", () => { + beforeEach(() => vi.clearAllMocks()); + + it("subscribes to all four workspace events on start", () => { + const client = makeClient(); + new WorkspaceEventsContribution( + client as unknown as HostTrpcClient, + { invalidateQueries } as unknown as ImperativeQueryClient, + ).start(); + expect(Object.keys(client.handlers).sort()).toEqual([ + "onBranchChanged", + "onError", + "onLinkedBranchChanged", + "onPromoted", + ]); + }); + + it("onPromoted invalidates the workspace query and toasts", () => { + const client = makeClient(); + new WorkspaceEventsContribution( + client as unknown as HostTrpcClient, + { invalidateQueries } as unknown as ImperativeQueryClient, + ).start(); + client.handlers.onPromoted({ fromBranch: "feat/x" }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: WORKSPACE_QUERY_KEY, + }); + expect(toast.info).toHaveBeenCalled(); + }); + + it("onError toasts without invalidating", () => { + const client = makeClient(); + new WorkspaceEventsContribution( + client as unknown as HostTrpcClient, + { invalidateQueries } as unknown as ImperativeQueryClient, + ).start(); + client.handlers.onError({ message: "boom" }); + expect(toast.error).toHaveBeenCalledWith("Workspace error", { + description: "boom", + }); + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + it("onBranchChanged invalidates the workspace query", () => { + const client = makeClient(); + new WorkspaceEventsContribution( + client as unknown as HostTrpcClient, + { invalidateQueries } as unknown as ImperativeQueryClient, + ).start(); + client.handlers.onBranchChanged(undefined); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: WORKSPACE_QUERY_KEY, + }); + }); +}); diff --git a/packages/ui/src/features/workspace/workspace-events.contribution.ts b/packages/ui/src/features/workspace/workspace-events.contribution.ts new file mode 100644 index 0000000000..8b2eb571a5 --- /dev/null +++ b/packages/ui/src/features/workspace/workspace-events.contribution.ts @@ -0,0 +1,60 @@ +import type { WorkbenchContribution } from "@posthog/di/contribution"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { inject, injectable } from "inversify"; +import { toast } from "../../primitives/toast"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "../../workbench/queryClient"; +import { WORKSPACE_QUERY_KEY } from "./identifiers"; + +/** + * Boots the global workspace-event listeners once at startup (formerly inline + * useEffect/useSubscription side effects in App.tsx). Workspace mutations that + * happen host-side (promote-to-worktree, branch changes, errors) invalidate the + * shared workspace query so every workspace reader stays in sync, and surface a + * toast where the user expects feedback. + */ +@injectable() +export class WorkspaceEventsContribution implements WorkbenchContribution { + constructor( + @inject(HOST_TRPC_CLIENT) + private readonly hostClient: HostTrpcClient, + @inject(IMPERATIVE_QUERY_CLIENT) + private readonly queryClient: ImperativeQueryClient, + ) {} + + start(): void { + const invalidate = () => { + void this.queryClient.invalidateQueries({ + queryKey: WORKSPACE_QUERY_KEY, + }); + }; + + this.hostClient.workspace.onError.subscribe(undefined, { + onData: (data) => { + toast.error("Workspace error", { description: data.message }); + }, + }); + + this.hostClient.workspace.onPromoted.subscribe(undefined, { + onData: (data) => { + invalidate(); + toast.info( + "Task moved to worktree", + `Task is now working in its own worktree on branch "${data.fromBranch}"`, + ); + }, + }); + + this.hostClient.workspace.onBranchChanged.subscribe(undefined, { + onData: invalidate, + }); + this.hostClient.workspace.onLinkedBranchChanged.subscribe(undefined, { + onData: invalidate, + }); + } +} diff --git a/packages/ui/src/features/workspace/workspace.module.ts b/packages/ui/src/features/workspace/workspace.module.ts new file mode 100644 index 0000000000..b610e2fd22 --- /dev/null +++ b/packages/ui/src/features/workspace/workspace.module.ts @@ -0,0 +1,9 @@ +import { WORKBENCH_CONTRIBUTION } from "@posthog/di/contribution"; +import { ContainerModule } from "inversify"; +import { WorkspaceEventsContribution } from "./workspace-events.contribution"; + +export const workspaceUiModule = new ContainerModule(({ bind }) => { + bind(WORKBENCH_CONTRIBUTION) + .to(WorkspaceEventsContribution) + .inSingletonScope(); +}); diff --git a/packages/ui/src/hooks/createSelectors.ts b/packages/ui/src/hooks/createSelectors.ts new file mode 100644 index 0000000000..f2f6424967 --- /dev/null +++ b/packages/ui/src/hooks/createSelectors.ts @@ -0,0 +1,20 @@ +import { type StoreApi, useStore } from "zustand"; + +type WithSelectors<S> = S extends { getState: () => infer T } + ? S & { use: { [K in keyof T]: () => T[K] } } + : never; + +// UI-layer helper: attaches `.use.<field>()` selector hooks to a vanilla +// `StoreApi` (e.g. a core-owned store). React lives here, never in core. +// Idempotent — safe to apply once to a singleton store at module load. +export function createSelectors<S extends StoreApi<object>>(_store: S) { + const store = _store as WithSelectors<S>; + if (!store.use) { + store.use = {} as WithSelectors<S>["use"]; + for (const k of Object.keys(store.getState())) { + (store.use as Record<string, () => unknown>)[k] = () => + useStore(_store, (s) => s[k as keyof typeof s]); + } + } + return store; +} diff --git a/packages/ui/src/hooks/useAuthenticatedClient.ts b/packages/ui/src/hooks/useAuthenticatedClient.ts new file mode 100644 index 0000000000..32bd6b1a3a --- /dev/null +++ b/packages/ui/src/hooks/useAuthenticatedClient.ts @@ -0,0 +1,5 @@ +import { useAuthenticatedClient as useClient } from "@posthog/ui/features/auth/authClient"; + +export function useAuthenticatedClient() { + return useClient(); +} diff --git a/apps/code/src/renderer/hooks/useAuthenticatedInfiniteQuery.ts b/packages/ui/src/hooks/useAuthenticatedInfiniteQuery.ts similarity index 86% rename from apps/code/src/renderer/hooks/useAuthenticatedInfiniteQuery.ts rename to packages/ui/src/hooks/useAuthenticatedInfiniteQuery.ts index 5bba77c02b..c2ed4d1876 100644 --- a/apps/code/src/renderer/hooks/useAuthenticatedInfiniteQuery.ts +++ b/packages/ui/src/hooks/useAuthenticatedInfiniteQuery.ts @@ -1,6 +1,6 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { AUTH_SCOPED_QUERY_META } from "@features/auth/hooks/authQueries"; -import type { PostHogAPIClient } from "@renderer/api/posthogClient"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; import type { QueryKey } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query"; diff --git a/apps/code/src/renderer/hooks/useAuthenticatedMutation.ts b/packages/ui/src/hooks/useAuthenticatedMutation.ts similarity index 83% rename from apps/code/src/renderer/hooks/useAuthenticatedMutation.ts rename to packages/ui/src/hooks/useAuthenticatedMutation.ts index 99d57e660f..a28b3ae5d6 100644 --- a/apps/code/src/renderer/hooks/useAuthenticatedMutation.ts +++ b/packages/ui/src/hooks/useAuthenticatedMutation.ts @@ -1,5 +1,5 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import type { PostHogAPIClient } from "@renderer/api/posthogClient"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import type { UseMutationOptions, UseMutationResult, diff --git a/apps/code/src/renderer/hooks/useAuthenticatedQuery.ts b/packages/ui/src/hooks/useAuthenticatedQuery.ts similarity index 80% rename from apps/code/src/renderer/hooks/useAuthenticatedQuery.ts rename to packages/ui/src/hooks/useAuthenticatedQuery.ts index 2bb3636d32..ad6c0c4235 100644 --- a/apps/code/src/renderer/hooks/useAuthenticatedQuery.ts +++ b/packages/ui/src/hooks/useAuthenticatedQuery.ts @@ -1,6 +1,6 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { AUTH_SCOPED_QUERY_META } from "@features/auth/hooks/authQueries"; -import type { PostHogAPIClient } from "@renderer/api/posthogClient"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; import type { QueryKey, UseQueryOptions, diff --git a/apps/code/src/renderer/hooks/useBlurOnEscape.ts b/packages/ui/src/hooks/useBlurOnEscape.ts similarity index 81% rename from apps/code/src/renderer/hooks/useBlurOnEscape.ts rename to packages/ui/src/hooks/useBlurOnEscape.ts index ebfb918edb..24aea5cf4e 100644 --- a/apps/code/src/renderer/hooks/useBlurOnEscape.ts +++ b/packages/ui/src/hooks/useBlurOnEscape.ts @@ -1,5 +1,5 @@ -import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; -import { hasOpenOverlay } from "@utils/overlay"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { hasOpenOverlay } from "@posthog/ui/utils/overlay"; import { useHotkeys } from "react-hotkeys-hook"; export function useBlurOnEscape() { diff --git a/packages/ui/src/hooks/useConnectivity.ts b/packages/ui/src/hooks/useConnectivity.ts new file mode 100644 index 0000000000..1bfc366eec --- /dev/null +++ b/packages/ui/src/hooks/useConnectivity.ts @@ -0,0 +1,8 @@ +import { connectivityStore } from "@posthog/core/connectivity/connectivityStore"; +import { createSelectors } from "./createSelectors"; + +const connectivity = createSelectors(connectivityStore); + +export function useConnectivity() { + return { isOnline: connectivity.use.isOnline() }; +} diff --git a/apps/code/src/renderer/hooks/useSetHeaderContent.ts b/packages/ui/src/hooks/useSetHeaderContent.ts similarity index 82% rename from apps/code/src/renderer/hooks/useSetHeaderContent.ts rename to packages/ui/src/hooks/useSetHeaderContent.ts index 89d74805c7..c317310e95 100644 --- a/apps/code/src/renderer/hooks/useSetHeaderContent.ts +++ b/packages/ui/src/hooks/useSetHeaderContent.ts @@ -1,4 +1,4 @@ -import { useHeaderStore } from "@stores/headerStore"; +import { useHeaderStore } from "@posthog/ui/workbench/headerStore"; import { type ReactNode, useLayoutEffect } from "react"; export function useSetHeaderContent(content: ReactNode) { diff --git a/apps/code/src/renderer/components/ActionSelector.tsx b/packages/ui/src/primitives/ActionSelector.tsx similarity index 100% rename from apps/code/src/renderer/components/ActionSelector.tsx rename to packages/ui/src/primitives/ActionSelector.tsx diff --git a/apps/code/src/renderer/components/BackgroundWrapper.tsx b/packages/ui/src/primitives/BackgroundWrapper.tsx similarity index 100% rename from apps/code/src/renderer/components/BackgroundWrapper.tsx rename to packages/ui/src/primitives/BackgroundWrapper.tsx diff --git a/apps/code/src/renderer/components/ui/Badge.tsx b/packages/ui/src/primitives/Badge.tsx similarity index 100% rename from apps/code/src/renderer/components/ui/Badge.tsx rename to packages/ui/src/primitives/Badge.tsx diff --git a/apps/code/src/renderer/components/ui/Button.tsx b/packages/ui/src/primitives/Button.tsx similarity index 97% rename from apps/code/src/renderer/components/ui/Button.tsx rename to packages/ui/src/primitives/Button.tsx index 935b5ccd45..5a0e807501 100644 --- a/apps/code/src/renderer/components/ui/Button.tsx +++ b/packages/ui/src/primitives/Button.tsx @@ -1,10 +1,10 @@ -import { Tooltip } from "@components/ui/Tooltip"; import { Flex, Button as RadixButton, Text } from "@radix-ui/themes"; import { type ComponentPropsWithoutRef, forwardRef, type ReactNode, } from "react"; +import { Tooltip } from "./Tooltip"; export type ButtonProps = ComponentPropsWithoutRef<typeof RadixButton> & { /** Primary tooltip explaining what the button does. */ diff --git a/apps/code/src/renderer/components/CodeBlock.test.tsx b/packages/ui/src/primitives/CodeBlock.test.tsx similarity index 84% rename from apps/code/src/renderer/components/CodeBlock.test.tsx rename to packages/ui/src/primitives/CodeBlock.test.tsx index efc1c12950..0575ef70f4 100644 --- a/apps/code/src/renderer/components/CodeBlock.test.tsx +++ b/packages/ui/src/primitives/CodeBlock.test.tsx @@ -1,17 +1,17 @@ +import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; +import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode"; import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactElement } from "react"; import { describe, expect, it, vi } from "vitest"; -import { CodeBlock } from "./CodeBlock"; -import { HighlightedCode } from "./HighlightedCode"; -vi.mock("@stores/themeStore", () => ({ +vi.mock("@posthog/ui/workbench/themeStore", () => ({ useThemeStore: (selector: (state: { isDarkMode: boolean }) => unknown) => selector({ isDarkMode: false }), })); -vi.mock("@utils/syntax-highlight", () => ({ +vi.mock("@posthog/ui/utils/syntax-highlight", () => ({ highlightSyntax: () => null, })); diff --git a/apps/code/src/renderer/components/CodeBlock.tsx b/packages/ui/src/primitives/CodeBlock.tsx similarity index 100% rename from apps/code/src/renderer/components/CodeBlock.tsx rename to packages/ui/src/primitives/CodeBlock.tsx diff --git a/apps/code/src/renderer/components/Divider.tsx b/packages/ui/src/primitives/Divider.tsx similarity index 100% rename from apps/code/src/renderer/components/Divider.tsx rename to packages/ui/src/primitives/Divider.tsx diff --git a/apps/code/src/renderer/components/DotPatternBackground.tsx b/packages/ui/src/primitives/DotPatternBackground.tsx similarity index 100% rename from apps/code/src/renderer/components/DotPatternBackground.tsx rename to packages/ui/src/primitives/DotPatternBackground.tsx diff --git a/apps/code/src/renderer/components/DotsCircleSpinner.tsx b/packages/ui/src/primitives/DotsCircleSpinner.tsx similarity index 100% rename from apps/code/src/renderer/components/DotsCircleSpinner.tsx rename to packages/ui/src/primitives/DotsCircleSpinner.tsx diff --git a/packages/ui/src/primitives/DraggableTitleBar.tsx b/packages/ui/src/primitives/DraggableTitleBar.tsx new file mode 100644 index 0000000000..335232d998 --- /dev/null +++ b/packages/ui/src/primitives/DraggableTitleBar.tsx @@ -0,0 +1,16 @@ +import { Box } from "@radix-ui/themes"; + +const TITLE_BAR_HEIGHT = 36; + +/** + * A draggable title bar for Electron windows: a draggable area at the top of + * the window when using hidden title bars (e.g. the login screen). + */ +export function DraggableTitleBar() { + return ( + <Box + className="drag absolute top-0 right-0 left-0 z-10 w-full" + style={{ height: TITLE_BAR_HEIGHT }} + /> + ); +} diff --git a/apps/code/src/renderer/components/ErrorBoundary.tsx b/packages/ui/src/primitives/ErrorBoundary.tsx similarity index 69% rename from apps/code/src/renderer/components/ErrorBoundary.tsx rename to packages/ui/src/primitives/ErrorBoundary.tsx index 4dabd1f96f..27e0772679 100644 --- a/apps/code/src/renderer/components/ErrorBoundary.tsx +++ b/packages/ui/src/primitives/ErrorBoundary.tsx @@ -1,12 +1,8 @@ import { Warning } from "@phosphor-icons/react"; import { Box, Button, Callout, Flex, Text } from "@radix-ui/themes"; -import { captureException } from "@utils/analytics"; -import { logger } from "@utils/logger"; import { Component, type ErrorInfo, type ReactNode } from "react"; -const log = logger.scope("error-boundary"); - -interface Props { +export interface ErrorBoundaryProps { children: ReactNode; fallback?: ReactNode; /** Optional name to identify which boundary caught the error */ @@ -15,11 +11,20 @@ interface Props { resetKey?: unknown; /** * If returns true for a caught error, the boundary renders nothing, - * skips telemetry, and waits for `resetKey` to change before recovering. - * Use to handle transient errors that the surrounding tree will resolve - * (e.g. auth state about to flip to unauthenticated). + * skips the fallback UI, and waits for `resetKey` to change before + * recovering. Use to handle transient errors that the surrounding tree + * will resolve (e.g. auth state about to flip to unauthenticated). */ shouldSuppress?: (error: Error) => boolean; + /** + * Called when an error is caught, before rendering. The host wires this to + * its telemetry/logging; the primitive itself stays host-agnostic. + * `suppressed` is true when `shouldSuppress` matched the error. + */ + onError?: ( + error: Error, + info: { componentStack?: string | null; suppressed: boolean }, + ) => void; } interface State { @@ -27,7 +32,7 @@ interface State { lastResetKey: unknown; } -export class ErrorBoundary extends Component<Props, State> { +export class ErrorBoundary extends Component<ErrorBoundaryProps, State> { state: State = { error: null, lastResetKey: this.props.resetKey }; static getDerivedStateFromError(error: Error): Partial<State> { @@ -35,7 +40,7 @@ export class ErrorBoundary extends Component<Props, State> { } static getDerivedStateFromProps( - props: Props, + props: ErrorBoundaryProps, state: State, ): Partial<State> | null { if (props.resetKey === state.lastResetKey) return null; @@ -43,25 +48,10 @@ export class ErrorBoundary extends Component<Props, State> { } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - if (this.props.shouldSuppress?.(error)) { - log.warn("Suppressed error in boundary", { - name: this.props.name, - error: error.message, - }); - return; - } - - log.error("Error caught by boundary", { - name: this.props.name, - error: error.message, - stack: error.stack, + const suppressed = this.props.shouldSuppress?.(error) ?? false; + this.props.onError?.(error, { componentStack: errorInfo.componentStack, - }); - - captureException(error, { - $exception_component_stack: errorInfo.componentStack, - boundary_name: this.props.name, - source: "error-boundary", + suppressed, }); } diff --git a/apps/code/src/renderer/components/ui/FileIcon.tsx b/packages/ui/src/primitives/FileIcon.tsx similarity index 84% rename from apps/code/src/renderer/components/ui/FileIcon.tsx rename to packages/ui/src/primitives/FileIcon.tsx index 200ee99b16..359d24f7fd 100644 --- a/apps/code/src/renderer/components/ui/FileIcon.tsx +++ b/packages/ui/src/primitives/FileIcon.tsx @@ -1,11 +1,13 @@ +/// <reference types="vite/client" /> import { File as PhosphorFileIcon } from "@phosphor-icons/react"; import { memo } from "react"; import { getIconForFile } from "vscode-icons-js"; -const iconModules = import.meta.glob<string>( - "@renderer/assets/file-icons/*.svg", - { eager: true, query: "?url", import: "default" }, -); +const iconModules = import.meta.glob<string>("../assets/file-icons/*.svg", { + eager: true, + query: "?url", + import: "default", +}); const ICON_MAP: Record<string, string> = {}; for (const [path, url] of Object.entries(iconModules)) { diff --git a/apps/code/src/renderer/components/FullScreenLayout.tsx b/packages/ui/src/primitives/FullScreenLayout.tsx similarity index 77% rename from apps/code/src/renderer/components/FullScreenLayout.tsx rename to packages/ui/src/primitives/FullScreenLayout.tsx index 6f6725d678..f210f324f8 100644 --- a/apps/code/src/renderer/components/FullScreenLayout.tsx +++ b/packages/ui/src/primitives/FullScreenLayout.tsx @@ -1,23 +1,26 @@ -import { UpdateBanner } from "@features/sidebar/components/UpdateBanner"; import { Lifebuoy } from "@phosphor-icons/react"; +import { DotPatternBackground } from "@posthog/ui/primitives/DotPatternBackground"; +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; import { Button, Flex, Theme } from "@radix-ui/themes"; -import { trpcClient } from "@renderer/trpc/client"; -import { useThemeStore } from "@stores/themeStore"; -import { EXTERNAL_LINKS } from "@utils/links"; import type { ReactNode } from "react"; -import { DotPatternBackground } from "./DotPatternBackground"; import { DraggableTitleBar } from "./DraggableTitleBar"; interface FullScreenLayoutProps { children: ReactNode; footerLeft?: ReactNode; footerRight?: ReactNode; + /** Host-provided update banner shown in the default footer. */ + banner?: ReactNode; + /** Host opens the support link. */ + onOpenSupport?: () => void; } export function FullScreenLayout({ children, footerLeft, footerRight, + banner, + onOpenSupport, }: FullScreenLayoutProps) { const isDarkMode = useThemeStore((state) => state.isDarkMode); @@ -61,17 +64,13 @@ export function FullScreenLayout({ size="1" variant="ghost" color="gray" - onClick={() => - trpcClient.os.openExternal.mutate({ - url: EXTERNAL_LINKS.discord, - }) - } + onClick={onOpenSupport} className="opacity-50" > <Lifebuoy size={14} /> Get support </Button> - <UpdateBanner variant="compact" /> + {banner} </Flex> )} {footerRight ?? <div />} diff --git a/apps/code/src/renderer/components/HighlightedCode.tsx b/packages/ui/src/primitives/HighlightedCode.tsx similarity index 86% rename from apps/code/src/renderer/components/HighlightedCode.tsx rename to packages/ui/src/primitives/HighlightedCode.tsx index 403751b9fe..2e19a18527 100644 --- a/apps/code/src/renderer/components/HighlightedCode.tsx +++ b/packages/ui/src/primitives/HighlightedCode.tsx @@ -1,6 +1,6 @@ -import { useThemeStore } from "@stores/themeStore"; -import { highlightSyntax } from "@utils/syntax-highlight"; import { useMemo } from "react"; +import { highlightSyntax } from "../utils/syntax-highlight"; +import { useThemeStore } from "../workbench/themeStore"; interface HighlightedCodeProps { code: string; diff --git a/apps/code/src/renderer/components/ui/KeyHint.tsx b/packages/ui/src/primitives/KeyHint.tsx similarity index 100% rename from apps/code/src/renderer/components/ui/KeyHint.tsx rename to packages/ui/src/primitives/KeyHint.tsx diff --git a/apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx similarity index 99% rename from apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx rename to packages/ui/src/primitives/KeyboardShortcutsSheet.tsx index c5e973bf09..3fc1521e84 100644 --- a/apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx +++ b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx @@ -1,12 +1,12 @@ import { Box, Dialog, Flex, Text } from "@radix-ui/themes"; +import { useMemo, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import { CATEGORY_LABELS, formatHotkeyParts, getShortcutsByCategory, type ShortcutCategory, -} from "@renderer/constants/keyboard-shortcuts"; -import { useMemo, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; +} from "../features/command/keyboard-shortcuts"; function Keycap({ label, size = "md" }: { label: string; size?: "sm" | "md" }) { const [pressed, setPressed] = useState(false); diff --git a/apps/code/src/renderer/components/List.tsx b/packages/ui/src/primitives/List.tsx similarity index 100% rename from apps/code/src/renderer/components/List.tsx rename to packages/ui/src/primitives/List.tsx diff --git a/apps/code/src/renderer/components/LoginTransition.tsx b/packages/ui/src/primitives/LoginTransition.tsx similarity index 100% rename from apps/code/src/renderer/components/LoginTransition.tsx rename to packages/ui/src/primitives/LoginTransition.tsx diff --git a/apps/code/src/renderer/assets/logo.tsx b/packages/ui/src/primitives/Logo.tsx similarity index 99% rename from apps/code/src/renderer/assets/logo.tsx rename to packages/ui/src/primitives/Logo.tsx index 9f5727f47a..4b8ec80313 100644 --- a/apps/code/src/renderer/assets/logo.tsx +++ b/packages/ui/src/primitives/Logo.tsx @@ -24,7 +24,7 @@ export default function LogosLandscape({ <g id="logos"> <g id="logomark - color gradient" - clip-path="url(#clip1_417_409)" + clipPath="url(#clip1_417_409)" className="dark:invisible" > <path diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingHogTip.tsx b/packages/ui/src/primitives/OnboardingHogTip.tsx similarity index 100% rename from apps/code/src/renderer/features/onboarding/components/OnboardingHogTip.tsx rename to packages/ui/src/primitives/OnboardingHogTip.tsx diff --git a/apps/code/src/renderer/components/ui/PanelMessage.tsx b/packages/ui/src/primitives/PanelMessage.tsx similarity index 100% rename from apps/code/src/renderer/components/ui/PanelMessage.tsx rename to packages/ui/src/primitives/PanelMessage.tsx diff --git a/apps/code/src/renderer/components/ui/RelativeTimestamp.tsx b/packages/ui/src/primitives/RelativeTimestamp.tsx similarity index 86% rename from apps/code/src/renderer/components/ui/RelativeTimestamp.tsx rename to packages/ui/src/primitives/RelativeTimestamp.tsx index b184b2ff6b..53bd840002 100644 --- a/apps/code/src/renderer/components/ui/RelativeTimestamp.tsx +++ b/packages/ui/src/primitives/RelativeTimestamp.tsx @@ -1,6 +1,6 @@ -import { Tooltip } from "@components/ui/Tooltip"; +import { formatRelativeTimeLong } from "@posthog/shared"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { Text } from "@radix-ui/themes"; -import { formatRelativeTimeLong } from "@utils/time"; interface RelativeTimestampProps { timestamp: string | number | Date | null | undefined; diff --git a/apps/code/src/renderer/components/ResizableSidebar.tsx b/packages/ui/src/primitives/ResizableSidebar.tsx similarity index 97% rename from apps/code/src/renderer/components/ResizableSidebar.tsx rename to packages/ui/src/primitives/ResizableSidebar.tsx index 2761240257..a054be27d4 100644 --- a/apps/code/src/renderer/components/ResizableSidebar.tsx +++ b/packages/ui/src/primitives/ResizableSidebar.tsx @@ -1,4 +1,4 @@ -import { SIDEBAR_MIN_WIDTH } from "@features/sidebar/constants"; +import { SIDEBAR_MIN_WIDTH } from "@posthog/ui/features/sidebar/constants"; import { Box, Flex } from "@radix-ui/themes"; import React from "react"; diff --git a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx b/packages/ui/src/primitives/SafeImagePreview.tsx similarity index 97% rename from apps/code/src/renderer/components/ui/SafeImagePreview.tsx rename to packages/ui/src/primitives/SafeImagePreview.tsx index 3dee082413..67a58b3f71 100644 --- a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx +++ b/packages/ui/src/primitives/SafeImagePreview.tsx @@ -1,4 +1,3 @@ -import { useImagePanAndZoom } from "@hooks/useImagePanAndZoom"; import { buildImageDataUrl, isAllowedImageMimeType, @@ -6,6 +5,7 @@ import { } from "@posthog/shared"; import { Flex, Text } from "@radix-ui/themes"; import { useState } from "react"; +import { useImagePanAndZoom } from "./hooks/useImagePanAndZoom"; interface SafeImagePreviewProps { /** Base64-encoded image data (no data URL prefix). */ diff --git a/apps/code/src/renderer/components/ui/StepList.tsx b/packages/ui/src/primitives/StepList.tsx similarity index 100% rename from apps/code/src/renderer/components/ui/StepList.tsx rename to packages/ui/src/primitives/StepList.tsx diff --git a/apps/code/src/renderer/components/ThemeWrapper.tsx b/packages/ui/src/primitives/ThemeWrapper.tsx similarity index 93% rename from apps/code/src/renderer/components/ThemeWrapper.tsx rename to packages/ui/src/primitives/ThemeWrapper.tsx index 97dd6286dc..975771f4d3 100644 --- a/apps/code/src/renderer/components/ThemeWrapper.tsx +++ b/packages/ui/src/primitives/ThemeWrapper.tsx @@ -1,5 +1,5 @@ +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; import { Theme } from "@radix-ui/themes"; -import { useThemeStore } from "@stores/themeStore"; import type React from "react"; import { useEffect, useRef } from "react"; diff --git a/apps/code/src/renderer/components/ui/Tooltip.tsx b/packages/ui/src/primitives/Tooltip.tsx similarity index 100% rename from apps/code/src/renderer/components/ui/Tooltip.tsx rename to packages/ui/src/primitives/Tooltip.tsx diff --git a/apps/code/src/renderer/components/TreeDirectoryRow.tsx b/packages/ui/src/primitives/TreeDirectoryRow.tsx similarity index 98% rename from apps/code/src/renderer/components/TreeDirectoryRow.tsx rename to packages/ui/src/primitives/TreeDirectoryRow.tsx index 8e62497bb8..a5cc5d6756 100644 --- a/apps/code/src/renderer/components/TreeDirectoryRow.tsx +++ b/packages/ui/src/primitives/TreeDirectoryRow.tsx @@ -1,5 +1,5 @@ -import { FileIcon } from "@components/ui/FileIcon"; import { CaretRight, FolderIcon, FolderOpenIcon } from "@phosphor-icons/react"; +import { FileIcon } from "@posthog/ui/primitives/FileIcon"; import { Box, Flex } from "@radix-ui/themes"; import type { ReactNode } from "react"; diff --git a/apps/code/src/renderer/components/ZenHedgehog.tsx b/packages/ui/src/primitives/ZenHedgehog.tsx similarity index 95% rename from apps/code/src/renderer/components/ZenHedgehog.tsx rename to packages/ui/src/primitives/ZenHedgehog.tsx index 28f603a6a2..fd20f45816 100644 --- a/apps/code/src/renderer/components/ZenHedgehog.tsx +++ b/packages/ui/src/primitives/ZenHedgehog.tsx @@ -1,7 +1,7 @@ -import roboZen from "@renderer/assets/images/robo-zen.png"; -import zenHedgehog from "@renderer/assets/images/zen.png"; import { motion } from "framer-motion"; import { useRef, useState } from "react"; +import roboZen from "../assets/images/robo-zen.png"; +import zenHedgehog from "../assets/images/zen.png"; const DELAY_MS = 400; // calm pause before shaking starts const GROW_MS = 3500; // time to reach full intensity diff --git a/apps/code/src/renderer/components/action-selector/ActionSelector.tsx b/packages/ui/src/primitives/action-selector/ActionSelector.tsx similarity index 99% rename from apps/code/src/renderer/components/action-selector/ActionSelector.tsx rename to packages/ui/src/primitives/action-selector/ActionSelector.tsx index 21cb94f8e0..8678ed3070 100644 --- a/apps/code/src/renderer/components/action-selector/ActionSelector.tsx +++ b/packages/ui/src/primitives/action-selector/ActionSelector.tsx @@ -1,5 +1,5 @@ +import { compactHomePath } from "@posthog/shared"; import { Box, Flex, Text } from "@radix-ui/themes"; -import { compactHomePath } from "@utils/path"; import { useCallback, useEffect, useRef } from "react"; import { isCancelOption, isSubmitOption } from "./constants"; import { OptionRow } from "./OptionRow"; diff --git a/apps/code/src/renderer/components/action-selector/InlineEditableText.tsx b/packages/ui/src/primitives/action-selector/InlineEditableText.tsx similarity index 100% rename from apps/code/src/renderer/components/action-selector/InlineEditableText.tsx rename to packages/ui/src/primitives/action-selector/InlineEditableText.tsx diff --git a/apps/code/src/renderer/components/action-selector/OptionRow.tsx b/packages/ui/src/primitives/action-selector/OptionRow.tsx similarity index 99% rename from apps/code/src/renderer/components/action-selector/OptionRow.tsx rename to packages/ui/src/primitives/action-selector/OptionRow.tsx index 2db3b7b869..17aed08cee 100644 --- a/apps/code/src/renderer/components/action-selector/OptionRow.tsx +++ b/packages/ui/src/primitives/action-selector/OptionRow.tsx @@ -1,5 +1,5 @@ +import { compactHomePath } from "@posthog/shared"; import { Box, Checkbox, Flex, Radio, Text } from "@radix-ui/themes"; -import { compactHomePath } from "@utils/path"; import { isCancelOption, isOtherOption, isSubmitOption } from "./constants"; import { InlineEditableText } from "./InlineEditableText"; import type { SelectorOption } from "./types"; diff --git a/apps/code/src/renderer/components/action-selector/StepTabs.tsx b/packages/ui/src/primitives/action-selector/StepTabs.tsx similarity index 100% rename from apps/code/src/renderer/components/action-selector/StepTabs.tsx rename to packages/ui/src/primitives/action-selector/StepTabs.tsx diff --git a/apps/code/src/renderer/components/action-selector/constants.ts b/packages/ui/src/primitives/action-selector/constants.ts similarity index 100% rename from apps/code/src/renderer/components/action-selector/constants.ts rename to packages/ui/src/primitives/action-selector/constants.ts diff --git a/apps/code/src/renderer/components/action-selector/types.ts b/packages/ui/src/primitives/action-selector/types.ts similarity index 100% rename from apps/code/src/renderer/components/action-selector/types.ts rename to packages/ui/src/primitives/action-selector/types.ts diff --git a/apps/code/src/renderer/components/action-selector/useActionSelectorState.ts b/packages/ui/src/primitives/action-selector/useActionSelectorState.ts similarity index 100% rename from apps/code/src/renderer/components/action-selector/useActionSelectorState.ts rename to packages/ui/src/primitives/action-selector/useActionSelectorState.ts diff --git a/apps/code/src/renderer/components/ui/combobox/Combobox.css b/packages/ui/src/primitives/combobox/Combobox.css similarity index 100% rename from apps/code/src/renderer/components/ui/combobox/Combobox.css rename to packages/ui/src/primitives/combobox/Combobox.css diff --git a/apps/code/src/renderer/components/ui/combobox/Combobox.stories.tsx b/packages/ui/src/primitives/combobox/Combobox.stories.tsx similarity index 99% rename from apps/code/src/renderer/components/ui/combobox/Combobox.stories.tsx rename to packages/ui/src/primitives/combobox/Combobox.stories.tsx index 11ff1e787a..bc1fd86352 100644 --- a/apps/code/src/renderer/components/ui/combobox/Combobox.stories.tsx +++ b/packages/ui/src/primitives/combobox/Combobox.stories.tsx @@ -1,8 +1,8 @@ import { Plus } from "@phosphor-icons/react"; +import { Combobox } from "@posthog/ui/primitives/combobox/Combobox"; import { Button, Text } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; -import { Combobox } from "./Combobox"; const meta: Meta = { title: "Components/UI/Combobox", diff --git a/apps/code/src/renderer/components/ui/combobox/Combobox.tsx b/packages/ui/src/primitives/combobox/Combobox.tsx similarity index 100% rename from apps/code/src/renderer/components/ui/combobox/Combobox.tsx rename to packages/ui/src/primitives/combobox/Combobox.tsx diff --git a/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.test.ts b/packages/ui/src/primitives/combobox/useComboboxFilter.test.ts similarity index 96% rename from apps/code/src/renderer/components/ui/combobox/useComboboxFilter.test.ts rename to packages/ui/src/primitives/combobox/useComboboxFilter.test.ts index 81a3670ea8..222b00c4b6 100644 --- a/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.test.ts +++ b/packages/ui/src/primitives/combobox/useComboboxFilter.test.ts @@ -1,6 +1,6 @@ +import { useComboboxFilter } from "@posthog/ui/primitives/combobox/useComboboxFilter"; import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useComboboxFilter } from "./useComboboxFilter"; describe("useComboboxFilter", () => { beforeEach(() => { diff --git a/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.ts b/packages/ui/src/primitives/combobox/useComboboxFilter.ts similarity index 98% rename from apps/code/src/renderer/components/ui/combobox/useComboboxFilter.ts rename to packages/ui/src/primitives/combobox/useComboboxFilter.ts index 08389947a7..4e813a5360 100644 --- a/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.ts +++ b/packages/ui/src/primitives/combobox/useComboboxFilter.ts @@ -1,6 +1,6 @@ -import { useDebounce } from "@hooks/useDebounce"; import { defaultFilter } from "cmdk"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useDebounce } from "../hooks/useDebounce"; const DEFAULT_LIMIT = 50; const MIN_FUZZY_SCORE = 0.1; diff --git a/apps/code/src/renderer/utils/confetti.ts b/packages/ui/src/primitives/confetti.ts similarity index 100% rename from apps/code/src/renderer/utils/confetti.ts rename to packages/ui/src/primitives/confetti.ts diff --git a/apps/code/src/renderer/hooks/useDebounce.test.ts b/packages/ui/src/primitives/hooks/useDebounce.test.ts similarity index 96% rename from apps/code/src/renderer/hooks/useDebounce.test.ts rename to packages/ui/src/primitives/hooks/useDebounce.test.ts index 7798027e0d..4fe600ee03 100644 --- a/apps/code/src/renderer/hooks/useDebounce.test.ts +++ b/packages/ui/src/primitives/hooks/useDebounce.test.ts @@ -1,6 +1,6 @@ +import { useDebounce } from "@posthog/ui/primitives/hooks/useDebounce"; import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useDebounce } from "./useDebounce"; describe("useDebounce", () => { beforeEach(() => { diff --git a/apps/code/src/renderer/hooks/useDebounce.ts b/packages/ui/src/primitives/hooks/useDebounce.ts similarity index 100% rename from apps/code/src/renderer/hooks/useDebounce.ts rename to packages/ui/src/primitives/hooks/useDebounce.ts diff --git a/apps/code/src/renderer/hooks/useDebouncedValue.ts b/packages/ui/src/primitives/hooks/useDebouncedValue.ts similarity index 100% rename from apps/code/src/renderer/hooks/useDebouncedValue.ts rename to packages/ui/src/primitives/hooks/useDebouncedValue.ts diff --git a/apps/code/src/renderer/hooks/useImagePanAndZoom.test.tsx b/packages/ui/src/primitives/hooks/useImagePanAndZoom.test.tsx similarity index 98% rename from apps/code/src/renderer/hooks/useImagePanAndZoom.test.tsx rename to packages/ui/src/primitives/hooks/useImagePanAndZoom.test.tsx index 9b74917ea1..0a50d3aff8 100644 --- a/apps/code/src/renderer/hooks/useImagePanAndZoom.test.tsx +++ b/packages/ui/src/primitives/hooks/useImagePanAndZoom.test.tsx @@ -1,6 +1,6 @@ +import { useImagePanAndZoom } from "@posthog/ui/primitives/hooks/useImagePanAndZoom"; import { act, fireEvent, render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { useImagePanAndZoom } from "./useImagePanAndZoom"; type HookResult = ReturnType<typeof useImagePanAndZoom>; diff --git a/apps/code/src/renderer/hooks/useImagePanAndZoom.ts b/packages/ui/src/primitives/hooks/useImagePanAndZoom.ts similarity index 100% rename from apps/code/src/renderer/hooks/useImagePanAndZoom.ts rename to packages/ui/src/primitives/hooks/useImagePanAndZoom.ts diff --git a/apps/code/src/renderer/hooks/useInView.ts b/packages/ui/src/primitives/hooks/useInView.ts similarity index 100% rename from apps/code/src/renderer/hooks/useInView.ts rename to packages/ui/src/primitives/hooks/useInView.ts diff --git a/apps/code/src/renderer/utils/toast.tsx b/packages/ui/src/primitives/toast.tsx similarity index 100% rename from apps/code/src/renderer/utils/toast.tsx rename to packages/ui/src/primitives/toast.tsx diff --git a/apps/code/src/renderer/styles/fieldTrigger.ts b/packages/ui/src/styles/fieldTrigger.ts similarity index 100% rename from apps/code/src/renderer/styles/fieldTrigger.ts rename to packages/ui/src/styles/fieldTrigger.ts diff --git a/apps/code/src/renderer/styles/globals.css b/packages/ui/src/styles/globals.css similarity index 92% rename from apps/code/src/renderer/styles/globals.css rename to packages/ui/src/styles/globals.css index 36ef110614..7f48db48d4 100644 --- a/apps/code/src/renderer/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -57,7 +57,7 @@ * actually runs. Pointing the scanner at quill's dist directly here * guarantees the primitive/component class strings get compiled. */ -@source "../../../../../node_modules/@posthog/quill/dist/**/*.js"; +@source "../../../../node_modules/@posthog/quill/dist/**/*.js"; /* * Quill ships its dialog backdrop at `opacity-20` (light) / `opacity-70` @@ -92,7 +92,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { pointer-events: auto; } -@config "../../../tailwind.config.js"; +@config "../../tailwind.config.js"; /* * Tighten the default line-height paired with each named font-size utility. @@ -140,7 +140,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "Berkeley Mono"; - src: url("../../../assets/fonts/BerkeleyMono/BerkeleyMono-Regular.woff2") + src: url("../assets/fonts/BerkeleyMono/BerkeleyMono-Regular.woff2") format("woff2"); font-weight: 400; font-style: normal; @@ -148,7 +148,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "Berkeley Mono"; - src: url("../../../assets/fonts/BerkeleyMono/BerkeleyMono-Oblique.woff2") + src: url("../assets/fonts/BerkeleyMono/BerkeleyMono-Oblique.woff2") format("woff2"); font-weight: 400; font-style: italic; @@ -156,7 +156,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "Berkeley Mono"; - src: url("../../../assets/fonts/BerkeleyMono/BerkeleyMono-Bold.woff2") + src: url("../assets/fonts/BerkeleyMono/BerkeleyMono-Bold.woff2") format("woff2"); font-weight: 700; font-style: normal; @@ -164,7 +164,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "Berkeley Mono"; - src: url("../../../assets/fonts/BerkeleyMono/BerkeleyMono-Bold-Oblique.woff2") + src: url("../assets/fonts/BerkeleyMono/BerkeleyMono-Bold-Oblique.woff2") format("woff2"); font-weight: 700; font-style: italic; @@ -172,7 +172,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "JetBrains Mono"; - src: url("../../../assets/fonts/JetBrainsMono/JetBrainsMono-Light.woff2") + src: url("../assets/fonts/JetBrainsMono/JetBrainsMono-Light.woff2") format("woff2"); font-weight: 300; font-style: normal; @@ -180,7 +180,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "JetBrains Mono"; - src: url("../../../assets/fonts/JetBrainsMono/JetBrainsMono-LightItalic.woff2") + src: url("../assets/fonts/JetBrainsMono/JetBrainsMono-LightItalic.woff2") format("woff2"); font-weight: 300; font-style: italic; @@ -188,7 +188,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "JetBrains Mono"; - src: url("../../../assets/fonts/JetBrainsMono/JetBrainsMono-Regular.woff2") + src: url("../assets/fonts/JetBrainsMono/JetBrainsMono-Regular.woff2") format("woff2"); font-weight: 400; font-style: normal; @@ -196,7 +196,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "JetBrains Mono"; - src: url("../../../assets/fonts/JetBrainsMono/JetBrainsMono-Italic.woff2") + src: url("../assets/fonts/JetBrainsMono/JetBrainsMono-Italic.woff2") format("woff2"); font-weight: 400; font-style: italic; @@ -204,7 +204,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "JetBrains Mono"; - src: url("../../../assets/fonts/JetBrainsMono/JetBrainsMono-Medium.woff2") + src: url("../assets/fonts/JetBrainsMono/JetBrainsMono-Medium.woff2") format("woff2"); font-weight: 500; font-style: normal; @@ -212,7 +212,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "JetBrains Mono"; - src: url("../../../assets/fonts/JetBrainsMono/JetBrainsMono-MediumItalic.woff2") + src: url("../assets/fonts/JetBrainsMono/JetBrainsMono-MediumItalic.woff2") format("woff2"); font-weight: 500; font-style: italic; @@ -220,7 +220,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "JetBrains Mono"; - src: url("../../../assets/fonts/JetBrainsMono/JetBrainsMono-SemiBold.woff2") + src: url("../assets/fonts/JetBrainsMono/JetBrainsMono-SemiBold.woff2") format("woff2"); font-weight: 600; font-style: normal; @@ -228,7 +228,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "JetBrains Mono"; - src: url("../../../assets/fonts/JetBrainsMono/JetBrainsMono-SemiBoldItalic.woff2") + src: url("../assets/fonts/JetBrainsMono/JetBrainsMono-SemiBoldItalic.woff2") format("woff2"); font-weight: 600; font-style: italic; @@ -236,7 +236,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "JetBrains Mono"; - src: url("../../../assets/fonts/JetBrainsMono/JetBrainsMono-Bold.woff2") + src: url("../assets/fonts/JetBrainsMono/JetBrainsMono-Bold.woff2") format("woff2"); font-weight: 700; font-style: normal; @@ -244,25 +244,17 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "JetBrains Mono"; - src: url("../../../assets/fonts/JetBrainsMono/JetBrainsMono-BoldItalic.woff2") + src: url("../assets/fonts/JetBrainsMono/JetBrainsMono-BoldItalic.woff2") format("woff2"); font-weight: 700; font-style: italic; } -@font-face { - font-family: "Halfre"; - src: url("../../../assets/fonts/Halfre.otf") format("opentype"); - font-weight: 400; - font-style: normal; -} - @font-face { font-family: "Open Runde"; src: - url("../../../assets/fonts/OpenRunde/OpenRunde-Regular.woff2") - format("woff2"), - url("../../../assets/fonts/OpenRunde/OpenRunde-Regular.woff") format("woff"); + url("../assets/fonts/OpenRunde/OpenRunde-Regular.woff2") format("woff2"), + url("../assets/fonts/OpenRunde/OpenRunde-Regular.woff") format("woff"); font-weight: 400; font-style: normal; font-display: swap; @@ -271,9 +263,8 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "Open Runde"; src: - url("../../../assets/fonts/OpenRunde/OpenRunde-Medium.woff2") - format("woff2"), - url("../../../assets/fonts/OpenRunde/OpenRunde-Medium.woff") format("woff"); + url("../assets/fonts/OpenRunde/OpenRunde-Medium.woff2") format("woff2"), + url("../assets/fonts/OpenRunde/OpenRunde-Medium.woff") format("woff"); font-weight: 500; font-style: normal; font-display: swap; @@ -282,10 +273,8 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "Open Runde"; src: - url("../../../assets/fonts/OpenRunde/OpenRunde-Semibold.woff2") - format("woff2"), - url("../../../assets/fonts/OpenRunde/OpenRunde-Semibold.woff") - format("woff"); + url("../assets/fonts/OpenRunde/OpenRunde-Semibold.woff2") format("woff2"), + url("../assets/fonts/OpenRunde/OpenRunde-Semibold.woff") format("woff"); font-weight: 600; font-style: normal; font-display: swap; @@ -294,8 +283,8 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { @font-face { font-family: "Open Runde"; src: - url("../../../assets/fonts/OpenRunde/OpenRunde-Bold.woff2") format("woff2"), - url("../../../assets/fonts/OpenRunde/OpenRunde-Bold.woff") format("woff"); + url("../assets/fonts/OpenRunde/OpenRunde-Bold.woff2") format("woff2"), + url("../assets/fonts/OpenRunde/OpenRunde-Bold.woff") format("woff"); font-weight: 700; font-style: normal; font-display: swap; @@ -1080,27 +1069,27 @@ button, /* Custom hover states for Select/Menu items - slightly lighter than background */ .rt-SelectItem[data-highlighted], .rt-BaseMenuItem[data-highlighted] { - background-color: var(--gray-4) !important; - color: var(--gray-12) !important; + background-color: var(--gray-4); + color: var(--gray-12); } /* Select/Menu dropdown background matches theme */ .rt-SelectContent, .rt-BaseMenuContent { - background-color: var(--gray-2) !important; + background-color: var(--gray-2); } /* Select trigger matches theme */ .rt-SelectTrigger { - background-color: var(--gray-2) !important; + background-color: var(--gray-2); } /* Radix ScrollArea wraps viewport children in a div with display:table, which expands to content width. This class overrides that so content wraps within the viewport width instead of overflowing horizontally. */ .scroll-area-constrain-width > .rt-ScrollAreaViewport > div { - display: block !important; - width: 100% !important; + display: block; + width: 100%; } /* Inbox report list: Radix ScrollArea thumb defaults to 100ms background-color transition */ diff --git a/packages/ui/src/test/setup.ts b/packages/ui/src/test/setup.ts new file mode 100644 index 0000000000..a8410d398b --- /dev/null +++ b/packages/ui/src/test/setup.ts @@ -0,0 +1,56 @@ +import "@testing-library/jest-dom"; +import { cleanup } from "@testing-library/react"; +import { afterEach, vi } from "vitest"; + +// jsdom does not implement PointerEvent; pointer-driven UI hooks (e.g. +// useImagePanAndZoom) rely on `pointerId` propagating from pointerdown through +// pointermove. Provide a MouseEvent-backed polyfill that carries it. +if (typeof globalThis.PointerEvent === "undefined") { + class JsdomPointerEvent extends MouseEvent { + pointerId: number; + pointerType: string; + width: number; + height: number; + pressure: number; + tangentialPressure: number; + tiltX: number; + tiltY: number; + twist: number; + isPrimary: boolean; + + constructor(type: string, init: PointerEventInit = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 0; + this.pointerType = init.pointerType ?? ""; + this.width = init.width ?? 1; + this.height = init.height ?? 1; + this.pressure = init.pressure ?? 0; + this.tangentialPressure = init.tangentialPressure ?? 0; + this.tiltX = init.tiltX ?? 0; + this.tiltY = init.tiltY ?? 0; + this.twist = init.twist ?? 0; + this.isPrimary = init.isPrimary ?? false; + } + } + globalThis.PointerEvent = JsdomPointerEvent as unknown as typeof PointerEvent; +} + +// jsdom does not implement matchMedia; UI stores (e.g. themeStore) read it at +// module load to resolve the system color scheme. +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +afterEach(() => { + cleanup(); +}); diff --git a/apps/code/src/renderer/utils/agentVersion.test.ts b/packages/ui/src/utils/agentVersion.test.ts similarity index 100% rename from apps/code/src/renderer/utils/agentVersion.test.ts rename to packages/ui/src/utils/agentVersion.test.ts diff --git a/apps/code/src/renderer/utils/agentVersion.ts b/packages/ui/src/utils/agentVersion.ts similarity index 100% rename from apps/code/src/renderer/utils/agentVersion.ts rename to packages/ui/src/utils/agentVersion.ts diff --git a/apps/code/src/renderer/utils/browser.ts b/packages/ui/src/utils/browser.ts similarity index 58% rename from apps/code/src/renderer/utils/browser.ts rename to packages/ui/src/utils/browser.ts index 157a84f928..26d0e8e80e 100644 --- a/apps/code/src/renderer/utils/browser.ts +++ b/packages/ui/src/utils/browser.ts @@ -1,8 +1,8 @@ -import { trpcClient } from "@renderer/trpc/client"; +import { openExternalUrl } from "@posthog/ui/workbench/openExternal"; export async function openUrlInBrowser(url: string): Promise<void> { try { - await trpcClient.os.openExternal.mutate({ url }); + openExternalUrl(url); } catch { window.open(url, "_blank", "noopener,noreferrer"); } diff --git a/packages/ui/src/utils/clearStorage.ts b/packages/ui/src/utils/clearStorage.ts new file mode 100644 index 0000000000..1d1f9f26b1 --- /dev/null +++ b/packages/ui/src/utils/clearStorage.ts @@ -0,0 +1,27 @@ +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; +import { logger } from "@posthog/ui/workbench/logger"; + +const log = logger.scope("clear-storage"); + +export function clearApplicationStorage(): void { + const confirmed = window.confirm( + "Are you sure you want to clear all application storage?\n\nThis will remove:\n• All registered folders\n• UI state (sidebar preferences, etc.)\n• Task directory mappings\n\nYour files will not be deleted from your computer.", + ); + + if (!confirmed) return; + + resolveService<HostTrpcClient>(HOST_TRPC_CLIENT) + .folders.clearAllData.mutate() + .then(() => { + localStorage.clear(); + window.location.reload(); + }) + .catch((error: unknown) => { + log.error("Failed to clear storage:", error); + alert("Failed to clear storage. Please try again."); + }); +} diff --git a/apps/code/src/renderer/utils/dialog.ts b/packages/ui/src/utils/dialog.ts similarity index 57% rename from apps/code/src/renderer/utils/dialog.ts rename to packages/ui/src/utils/dialog.ts index c2a906ddc3..f6ce62098a 100644 --- a/apps/code/src/renderer/utils/dialog.ts +++ b/packages/ui/src/utils/dialog.ts @@ -1,6 +1,10 @@ -import { trpcClient } from "@renderer/trpc"; +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; -interface MessageBoxOptions { +export interface MessageBoxOptions { type?: "none" | "info" | "error" | "question" | "warning"; title?: string; message?: string; @@ -10,16 +14,14 @@ interface MessageBoxOptions { cancelId?: number; } -/** - * Shows a message box dialog. - */ export async function showMessageBox( options: MessageBoxOptions, ): Promise<{ response: number }> { - // Blur active element to dismiss any open tooltip if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } - return trpcClient.os.showMessageBox.mutate({ options }); + return resolveService<HostTrpcClient>( + HOST_TRPC_CLIENT, + ).os.showMessageBox.mutate({ options }); } diff --git a/packages/ui/src/utils/getFilePath.ts b/packages/ui/src/utils/getFilePath.ts new file mode 100644 index 0000000000..02a60049f2 --- /dev/null +++ b/packages/ui/src/utils/getFilePath.ts @@ -0,0 +1,14 @@ +import { resolveService } from "@posthog/di/container"; + +export interface FilePathResolver { + resolve(file: File): string | undefined; +} + +export const FILE_PATH_RESOLVER = Symbol.for("posthog.ui.FilePathResolver"); + +export function getFilePath(file: File): string { + const resolved = + resolveService<FilePathResolver>(FILE_PATH_RESOLVER).resolve(file); + if (resolved) return resolved; + return (file as File & { path?: string }).path ?? ""; +} diff --git a/apps/code/src/renderer/utils/overlay.test.ts b/packages/ui/src/utils/overlay.test.ts similarity index 100% rename from apps/code/src/renderer/utils/overlay.test.ts rename to packages/ui/src/utils/overlay.test.ts diff --git a/apps/code/src/renderer/utils/overlay.ts b/packages/ui/src/utils/overlay.ts similarity index 100% rename from apps/code/src/renderer/utils/overlay.ts rename to packages/ui/src/utils/overlay.ts diff --git a/apps/code/src/renderer/utils/platform.ts b/packages/ui/src/utils/platform.ts similarity index 100% rename from apps/code/src/renderer/utils/platform.ts rename to packages/ui/src/utils/platform.ts diff --git a/apps/code/src/renderer/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts similarity index 88% rename from apps/code/src/renderer/utils/posthogLinks.ts rename to packages/ui/src/utils/posthogLinks.ts index 5512b0ea4c..db07c66f86 100644 --- a/apps/code/src/renderer/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -1,6 +1,6 @@ -import { getCachedAuthState } from "@features/auth/hooks/authQueries"; -import type { CloudRegion } from "@shared/types/regions"; -import { getPostHogUrl } from "@utils/urls"; +import type { CloudRegion } from "@posthog/shared"; +import { useAuthStore } from "@posthog/ui/features/auth/store"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; export interface LinkOverrides { projectId?: number | null; @@ -9,7 +9,7 @@ export interface LinkOverrides { function resolveProjectId(override?: number | null): number | null { if (override != null) return override; - return getCachedAuthState().projectId ?? null; + return useAuthStore.getState().authState.projectId ?? null; } function withProjectId( diff --git a/packages/ui/src/utils/promptContent.test.ts b/packages/ui/src/utils/promptContent.test.ts new file mode 100644 index 0000000000..7bd174e974 --- /dev/null +++ b/packages/ui/src/utils/promptContent.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + extractPromptDisplayContent, + makeAttachmentUri, + parseAttachmentUri, +} from "./promptContent"; + +describe("promptContent", () => { + it("builds unique attachment URIs for same-name files", () => { + const firstUri = makeAttachmentUri("/tmp/one/README.md"); + const secondUri = makeAttachmentUri("/tmp/two/README.md"); + + expect(firstUri).not.toBe(secondUri); + expect(parseAttachmentUri(firstUri)).toEqual({ + id: firstUri, + label: "README.md", + }); + expect(parseAttachmentUri(secondUri)).toEqual({ + id: secondUri, + label: "README.md", + }); + }); + + it("keeps duplicate file labels visible when attachment ids differ", () => { + const firstUri = makeAttachmentUri("/tmp/one/README.md"); + const secondUri = makeAttachmentUri("/tmp/two/README.md"); + + const result = extractPromptDisplayContent([ + { type: "text", text: "compare both" }, + { + type: "resource", + resource: { uri: firstUri, text: "first", mimeType: "text/markdown" }, + }, + { + type: "resource", + resource: { + uri: secondUri, + text: "second", + mimeType: "text/markdown", + }, + }, + ]); + + expect(result.text).toBe("compare both"); + expect(result.attachments).toEqual([ + { id: firstUri, label: "README.md" }, + { id: secondUri, label: "README.md" }, + ]); + }); + + it("extracts cloud resource_link attachments from file URIs", () => { + const fileUri = "file:///tmp/workspace/attachments/Receipt-2264-0277.pdf"; + + const result = extractPromptDisplayContent([ + { type: "text", text: "what is this about?" }, + { + type: "resource_link", + uri: fileUri, + name: "Receipt-2264-0277.pdf", + }, + ]); + + expect(result.text).toBe("what is this about?"); + expect(result.attachments).toEqual([ + { id: fileUri, label: "Receipt-2264-0277.pdf" }, + ]); + }); +}); diff --git a/packages/ui/src/utils/promptContent.ts b/packages/ui/src/utils/promptContent.ts new file mode 100644 index 0000000000..5754d7f4e1 --- /dev/null +++ b/packages/ui/src/utils/promptContent.ts @@ -0,0 +1,125 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { getFileName } from "@posthog/shared"; + +export const ATTACHMENT_URI_PREFIX = "attachment://"; + +function hashAttachmentPath(filePath: string): string { + let hash = 2166136261; + + for (let i = 0; i < filePath.length; i++) { + hash ^= filePath.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + + return (hash >>> 0).toString(36); +} + +export function makeAttachmentUri(filePath: string): string { + const label = encodeURIComponent(getFileName(filePath)); + const id = hashAttachmentPath(filePath); + return `${ATTACHMENT_URI_PREFIX}${id}?label=${label}`; +} + +export interface AttachmentRef { + id: string; + label: string; +} + +export function parseAttachmentUri(uri: string): AttachmentRef | null { + if (!uri.startsWith(ATTACHMENT_URI_PREFIX)) { + return null; + } + + const rawValue = uri.slice(ATTACHMENT_URI_PREFIX.length); + const queryStart = rawValue.indexOf("?"); + if (queryStart < 0) { + return null; + } + + const label = + decodeURIComponent( + new URLSearchParams(rawValue.slice(queryStart + 1)).get("label") ?? "", + ) || "attachment"; + + return { id: uri, label }; +} + +function parseFileUri( + uri: string, + fallbackLabel?: string, +): AttachmentRef | null { + if (!uri.startsWith("file://")) { + return null; + } + + try { + const pathname = decodeURIComponent(new URL(uri).pathname); + const label = + fallbackLabel?.trim() || getFileName(pathname) || "attachment"; + return { id: uri, label }; + } catch { + const label = fallbackLabel?.trim() || getFileName(uri) || "attachment"; + return { id: uri, label }; + } +} + +function getBlockAttachmentRef(block: ContentBlock): AttachmentRef | null { + if (block.type === "resource") { + const uri = block.resource.uri; + if (!uri) { + return null; + } + + return parseAttachmentUri(uri) ?? parseFileUri(uri); + } + + if (block.type === "image") { + const uri = block.uri; + if (!uri) { + return null; + } + + return parseAttachmentUri(uri) ?? parseFileUri(uri); + } + + if (block.type === "resource_link") { + return parseAttachmentUri(block.uri) ?? parseFileUri(block.uri, block.name); + } + + return null; +} + +export interface PromptDisplayContent { + text: string; + attachments: AttachmentRef[]; +} + +export function extractPromptDisplayContent( + blocks: ContentBlock[], + options?: { filterHidden?: boolean }, +): PromptDisplayContent { + const filterHidden = options?.filterHidden ?? false; + + const textParts: string[] = []; + for (const block of blocks) { + if (block.type !== "text") continue; + if (filterHidden) { + const meta = (block as { _meta?: { ui?: { hidden?: boolean } } })._meta; + if (meta?.ui?.hidden) continue; + } + textParts.push(block.text); + } + + const seen = new Set<string>(); + const attachments: AttachmentRef[] = []; + for (const block of blocks) { + const ref = getBlockAttachmentRef(block); + if (!ref || seen.has(ref.id)) continue; + const { id } = ref; + if (!id) continue; + seen.add(id); + attachments.push(ref); + } + + return { text: textParts.join(""), attachments }; +} diff --git a/apps/code/src/renderer/utils/random.ts b/packages/ui/src/utils/random.ts similarity index 100% rename from apps/code/src/renderer/utils/random.ts rename to packages/ui/src/utils/random.ts diff --git a/apps/code/src/renderer/utils/sendMessageKey.test.ts b/packages/ui/src/utils/sendMessageKey.test.ts similarity index 79% rename from apps/code/src/renderer/utils/sendMessageKey.test.ts rename to packages/ui/src/utils/sendMessageKey.test.ts index 1adf900927..3cd16ad62d 100644 --- a/apps/code/src/renderer/utils/sendMessageKey.test.ts +++ b/packages/ui/src/utils/sendMessageKey.test.ts @@ -1,16 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@renderer/trpc", () => ({ - trpcClient: { - secureStore: { - getItem: { query: vi.fn() }, - setItem: { query: vi.fn() }, - }, - }, -})); - -import type { SendMessagesWith } from "@stores/settingsStore"; -import { useSettingsStore } from "@stores/settingsStore"; +import type { SendMessagesWith } from "@posthog/ui/features/settings/settingsStore"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { beforeEach, describe, expect, it } from "vitest"; import { isSendMessageSubmitKey } from "./sendMessageKey"; interface SubmitCase { diff --git a/apps/code/src/renderer/utils/sendMessageKey.ts b/packages/ui/src/utils/sendMessageKey.ts similarity index 83% rename from apps/code/src/renderer/utils/sendMessageKey.ts rename to packages/ui/src/utils/sendMessageKey.ts index e4de5ead3d..c34e218c76 100644 --- a/apps/code/src/renderer/utils/sendMessageKey.ts +++ b/packages/ui/src/utils/sendMessageKey.ts @@ -1,4 +1,4 @@ -import { useSettingsStore } from "@stores/settingsStore"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; interface SubmitKeyEvent { key: string; diff --git a/apps/code/src/renderer/utils/sounds.ts b/packages/ui/src/utils/sounds.ts similarity index 53% rename from apps/code/src/renderer/utils/sounds.ts rename to packages/ui/src/utils/sounds.ts index b0abc7b0f3..ea17f2199a 100644 --- a/apps/code/src/renderer/utils/sounds.ts +++ b/packages/ui/src/utils/sounds.ts @@ -1,17 +1,17 @@ -import type { CompletionSound } from "@features/settings/stores/settingsStore"; -import bubblesUrl from "@renderer/assets/sounds/bubbles.mp3"; -import daniloUrl from "@renderer/assets/sounds/danilo.mp3"; -import dropUrl from "@renderer/assets/sounds/drop.mp3"; -import guitarUrl from "@renderer/assets/sounds/guitar.mp3"; -import knockUrl from "@renderer/assets/sounds/knock.mp3"; -import meepUrl from "@renderer/assets/sounds/meep.mp3"; -import meepSmolUrl from "@renderer/assets/sounds/meep-smol.mp3"; -import reviUrl from "@renderer/assets/sounds/revi.mp3"; -import ringUrl from "@renderer/assets/sounds/ring.mp3"; -import shootUrl from "@renderer/assets/sounds/shoot.mp3"; -import slideUrl from "@renderer/assets/sounds/slide.mp3"; -import switchUrl from "@renderer/assets/sounds/switch.mp3"; -import wilhelmUrl from "@renderer/assets/sounds/wilhelm.mp3"; +import type { CompletionSound } from "@posthog/ui/features/settings/settingsStore"; +import bubblesUrl from "../assets/sounds/bubbles.mp3"; +import daniloUrl from "../assets/sounds/danilo.mp3"; +import dropUrl from "../assets/sounds/drop.mp3"; +import guitarUrl from "../assets/sounds/guitar.mp3"; +import knockUrl from "../assets/sounds/knock.mp3"; +import meepUrl from "../assets/sounds/meep.mp3"; +import meepSmolUrl from "../assets/sounds/meep-smol.mp3"; +import reviUrl from "../assets/sounds/revi.mp3"; +import ringUrl from "../assets/sounds/ring.mp3"; +import shootUrl from "../assets/sounds/shoot.mp3"; +import slideUrl from "../assets/sounds/slide.mp3"; +import switchUrl from "../assets/sounds/switch.mp3"; +import wilhelmUrl from "../assets/sounds/wilhelm.mp3"; const SOUND_URLS: Record<Exclude<CompletionSound, "none">, string> = { guitar: guitarUrl, diff --git a/apps/code/src/renderer/utils/syntax-highlight.ts b/packages/ui/src/utils/syntax-highlight.ts similarity index 100% rename from apps/code/src/renderer/utils/syntax-highlight.ts rename to packages/ui/src/utils/syntax-highlight.ts diff --git a/apps/code/src/renderer/utils/urls.test.ts b/packages/ui/src/utils/urls.test.ts similarity index 86% rename from apps/code/src/renderer/utils/urls.test.ts rename to packages/ui/src/utils/urls.test.ts index d0d77cf8aa..a9e05a1fa6 100644 --- a/apps/code/src/renderer/utils/urls.test.ts +++ b/packages/ui/src/utils/urls.test.ts @@ -1,10 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@features/auth/hooks/authQueries", () => ({ - getCachedAuthState: () => ({ cloudRegion: null }), -})); - -import { getBillingUrl, getPostHogUrl } from "./urls"; +import { getBillingUrl, getPostHogUrl } from "@posthog/ui/utils/urls"; +import { describe, expect, it } from "vitest"; describe("getPostHogUrl", () => { it("returns null when no region is available and the input is a path", () => { diff --git a/apps/code/src/renderer/utils/urls.ts b/packages/ui/src/utils/urls.ts similarity index 66% rename from apps/code/src/renderer/utils/urls.ts rename to packages/ui/src/utils/urls.ts index 81e47d90ea..669e2940b4 100644 --- a/apps/code/src/renderer/utils/urls.ts +++ b/packages/ui/src/utils/urls.ts @@ -1,13 +1,13 @@ -import { getCachedAuthState } from "@features/auth/hooks/authQueries"; -import type { CloudRegion } from "@shared/types/regions"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; +import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared"; +import { useAuthStore } from "@posthog/ui/features/auth/store"; export function getPostHogUrl( pathOrUrl: string, regionOverride?: CloudRegion | null, ): string | null { if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl; - const region = regionOverride ?? getCachedAuthState().cloudRegion; + const region = + regionOverride ?? useAuthStore.getState().authState.cloudRegion; if (!region) return null; const base = getCloudUrlFromRegion(region); return `${base}${pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`}`; diff --git a/packages/ui/src/workbench/App.tsx b/packages/ui/src/workbench/App.tsx new file mode 100644 index 0000000000..7235eba856 --- /dev/null +++ b/packages/ui/src/workbench/App.tsx @@ -0,0 +1,206 @@ +import { EXTERNAL_LINKS, isNotAuthenticatedError } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { AiApprovalScreen } from "@posthog/ui/features/ai-approval/AiApprovalScreen"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { + useAuthStateValue, + useCurrentUser, +} from "@posthog/ui/features/auth/authQueries"; +import { AuthScreen } from "@posthog/ui/features/auth/components/AuthScreen"; +import { InviteCodeScreen } from "@posthog/ui/features/auth/components/InviteCodeScreen"; +import { ScopeReauthPrompt } from "@posthog/ui/features/auth/components/ScopeReauthPrompt"; +import { useAuthSession } from "@posthog/ui/features/auth/useAuthSession"; +import { useIsOrgAdmin } from "@posthog/ui/features/auth/useOrgRole"; +import { AddDirectoryDialog } from "@posthog/ui/features/folder-picker/AddDirectoryDialog"; +import { OnboardingFlow } from "@posthog/ui/features/onboarding/components/OnboardingFlow"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { SettingsDialog } from "@posthog/ui/features/settings/SettingsDialog"; +import { UpdateBanner } from "@posthog/ui/features/sidebar/components/UpdateBanner"; +import { LoginTransition } from "@posthog/ui/primitives/LoginTransition"; +import { track } from "@posthog/ui/workbench/analytics"; +import { useCommandMenuStore } from "@posthog/ui/workbench/commandMenuStore"; +import { ErrorBoundary } from "@posthog/ui/workbench/ErrorBoundary"; +import { GlobalEventHandlers } from "@posthog/ui/workbench/GlobalEventHandlers"; +import { MainLayout } from "@posthog/ui/workbench/MainLayout"; +import { openExternalUrl } from "@posthog/ui/workbench/openExternal"; +import { useShortcutsSheetStore } from "@posthog/ui/workbench/shortcutsSheetStore"; +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; +import { Flex, Spinner, Text } from "@radix-ui/themes"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; +import { Toaster } from "sonner"; + +function App() { + const { isBootstrapped } = useAuthSession(); + const authState = useAuthStateValue((state) => state); + const hasCompletedOnboarding = useOnboardingStore( + (state) => state.hasCompletedOnboarding, + ); + const isAuthenticated = authState.status === "authenticated"; + const hasCodeAccess = authState.hasCodeAccess; + const isDarkMode = useThemeStore((state) => state.isDarkMode); + const toggleCommandMenu = useCommandMenuStore((state) => state.toggle); + const toggleShortcutsSheet = useShortcutsSheetStore((state) => state.toggle); + const [showTransition, setShowTransition] = useState(false); + const wasInMainApp = useRef(isAuthenticated && hasCompletedOnboarding); + + // Analytics init + dev inbox console moved to host WORKBENCH_CONTRIBUTIONs + // (AnalyticsBootContribution / InboxDemoDevContribution), started by + // startWorkbench at boot. + + // Workspace, focus, and agent event listeners moved to their feature + // WORKBENCH_CONTRIBUTIONs (WorkspaceEventsContribution / FocusEventsContribution + // / AgentEventsContribution), started by startWorkbench at boot. + + const needsInviteCode = + isAuthenticated && hasCodeAccess === false && hasCompletedOnboarding; + const isCheckingAccess = + isAuthenticated && hasCodeAccess === null && hasCompletedOnboarding; + + const authenticatedClient = useOptionalAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ + client: authenticatedClient, + enabled: + isAuthenticated && hasCompletedOnboarding && hasCodeAccess === true, + refetchOnWindowFocus: "always", + }); + const currentOrg = currentUser?.organization; + const needsAiApproval = + isAuthenticated && + hasCompletedOnboarding && + hasCodeAccess === true && + currentOrg != null && + currentOrg.is_ai_data_processing_approved !== true; + const { isAdmin: isOrgAdmin } = useIsOrgAdmin(); + const isAdmin = isOrgAdmin === true; + + // Handle transition into main app — only show the dark overlay if dark mode is active + useEffect(() => { + const isInMainApp = isAuthenticated && hasCompletedOnboarding; + if (!wasInMainApp.current && isInMainApp && isDarkMode) { + setShowTransition(true); + } + if (!isAuthenticated) { + setShowTransition(false); + } + wasInMainApp.current = isInMainApp; + }, [isAuthenticated, hasCompletedOnboarding, isDarkMode]); + + const wasShowingAiGateRef = useRef(false); + useEffect(() => { + if (wasShowingAiGateRef.current && !needsAiApproval && currentOrg != null) { + track(ANALYTICS_EVENTS.AI_CONSENT_APPROVED); + } + wasShowingAiGateRef.current = needsAiApproval; + }, [needsAiApproval, currentOrg]); + + const handleTransitionComplete = () => { + setShowTransition(false); + }; + + if (!isBootstrapped) { + return ( + <Flex align="center" justify="center" minHeight="100vh"> + <Flex align="center" gap="3"> + <Spinner size="3" /> + <Text color="gray">Loading...</Text> + </Flex> + </Flex> + ); + } + + // Rendering: onboarding (includes auth + invite code gate) → main app + const renderContent = () => { + if (!hasCompletedOnboarding) { + return ( + <motion.div key="onboarding" initial={{ opacity: 1 }}> + <OnboardingFlow /> + </motion.div> + ); + } + + if (!isAuthenticated) { + return ( + <motion.div key="auth" initial={{ opacity: 1 }}> + <AuthScreen /> + </motion.div> + ); + } + + if (isCheckingAccess) { + return ( + <motion.div key="access-check" initial={{ opacity: 1 }}> + <Flex align="center" justify="center" minHeight="100vh"> + <Flex align="center" gap="3"> + <Spinner size="3" /> + <Text color="gray">Checking access...</Text> + </Flex> + </Flex> + </motion.div> + ); + } + + if (needsInviteCode) { + return ( + <motion.div key="invite-code" initial={{ opacity: 1 }}> + <InviteCodeScreen /> + </motion.div> + ); + } + + if (needsAiApproval) { + return ( + <motion.div key="ai-approval" initial={{ opacity: 1 }}> + <AiApprovalScreen + orgName={currentOrg?.name ?? null} + isAdmin={isAdmin} + banner={<UpdateBanner variant="compact" />} + onOpenSupport={() => openExternalUrl(EXTERNAL_LINKS.discord)} + settingsDialog={<SettingsDialog />} + /> + </motion.div> + ); + } + + return ( + <motion.div + key="main" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.5, delay: showTransition ? 0.5 : 0 }} + > + <MainLayout /> + <GlobalEventHandlers + onToggleCommandMenu={toggleCommandMenu} + onToggleShortcutsSheet={toggleShortcutsSheet} + /> + </motion.div> + ); + }; + + const content = renderContent(); + + return ( + <ErrorBoundary + name="App" + resetKey={authState.status} + shouldSuppress={isNotAuthenticatedError} + > + {isAuthenticated ? ( + <AnimatePresence mode="wait">{content}</AnimatePresence> + ) : ( + content + )} + <LoginTransition + isAnimating={showTransition} + isDarkMode={isDarkMode} + onComplete={handleTransitionComplete} + /> + <ScopeReauthPrompt /> + <AddDirectoryDialog /> + <Toaster position="bottom-right" /> + </ErrorBoundary> + ); +} + +export default App; diff --git a/apps/code/src/renderer/components/ErrorBoundary.test.tsx b/packages/ui/src/workbench/ErrorBoundary.test.tsx similarity index 94% rename from apps/code/src/renderer/components/ErrorBoundary.test.tsx rename to packages/ui/src/workbench/ErrorBoundary.test.tsx index 9dfe6b7953..47c0b93e2c 100644 --- a/apps/code/src/renderer/components/ErrorBoundary.test.tsx +++ b/packages/ui/src/workbench/ErrorBoundary.test.tsx @@ -1,16 +1,19 @@ +import { + isNotAuthenticatedError, + NotAuthenticatedError, +} from "@posthog/shared"; import { Theme } from "@radix-ui/themes"; -import { isNotAuthenticatedError, NotAuthenticatedError } from "@shared/errors"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorBoundary } from "./ErrorBoundary"; -vi.mock("@utils/analytics", () => ({ +vi.mock("@posthog/ui/workbench/analytics", () => ({ captureException: vi.fn(), })); -vi.mock("@utils/logger", () => ({ +vi.mock("@posthog/ui/workbench/logger", () => ({ logger: { scope: () => ({ error: vi.fn(), @@ -21,7 +24,7 @@ vi.mock("@utils/logger", () => ({ }, })); -import { captureException } from "@utils/analytics"; +import { captureException } from "@posthog/ui/workbench/analytics"; function Thrower({ error }: { error: Error | null }) { if (error) throw error; diff --git a/packages/ui/src/workbench/ErrorBoundary.tsx b/packages/ui/src/workbench/ErrorBoundary.tsx new file mode 100644 index 0000000000..ef9ce0f46f --- /dev/null +++ b/packages/ui/src/workbench/ErrorBoundary.tsx @@ -0,0 +1,43 @@ +import { + type ErrorBoundaryProps, + ErrorBoundary as UiErrorBoundary, +} from "@posthog/ui/primitives/ErrorBoundary"; +import { captureException } from "@posthog/ui/workbench/analytics"; +import { logger } from "@posthog/ui/workbench/logger"; + +const log = logger.scope("error-boundary"); + +export type { ErrorBoundaryProps }; + +/** + * Desktop wrapper around the host-agnostic ErrorBoundary primitive. Supplies + * the app's telemetry/logging via onError so the primitive stays portable. + */ +export function ErrorBoundary(props: ErrorBoundaryProps) { + return ( + <UiErrorBoundary + {...props} + onError={(error, info) => { + if (info.suppressed) { + log.warn("Suppressed error in boundary", { + name: props.name, + error: error.message, + }); + } else { + log.error("Error caught by boundary", { + name: props.name, + error: error.message, + stack: error.stack, + componentStack: info.componentStack, + }); + captureException(error, { + $exception_component_stack: info.componentStack, + boundary_name: props.name, + source: "error-boundary", + }); + } + props.onError?.(error, info); + }} + /> + ); +} diff --git a/packages/ui/src/workbench/FullScreenLayout.tsx b/packages/ui/src/workbench/FullScreenLayout.tsx new file mode 100644 index 0000000000..8eeb63cb8d --- /dev/null +++ b/packages/ui/src/workbench/FullScreenLayout.tsx @@ -0,0 +1,21 @@ +import { EXTERNAL_LINKS } from "@posthog/shared"; +import { UpdateBanner } from "@posthog/ui/features/sidebar/components/UpdateBanner"; +import { FullScreenLayout as UiFullScreenLayout } from "@posthog/ui/primitives/FullScreenLayout"; +import { openExternalUrl } from "@posthog/ui/workbench/openExternal"; +import type { ReactNode } from "react"; + +interface FullScreenLayoutProps { + children: ReactNode; + footerLeft?: ReactNode; + footerRight?: ReactNode; +} + +export function FullScreenLayout(props: FullScreenLayoutProps) { + return ( + <UiFullScreenLayout + {...props} + banner={<UpdateBanner variant="compact" />} + onOpenSupport={() => openExternalUrl(EXTERNAL_LINKS.discord)} + /> + ); +} diff --git a/apps/code/src/renderer/components/GlobalEventHandlers.tsx b/packages/ui/src/workbench/GlobalEventHandlers.tsx similarity index 84% rename from apps/code/src/renderer/components/GlobalEventHandlers.tsx rename to packages/ui/src/workbench/GlobalEventHandlers.tsx index 2e7fe4c763..31e586bfdf 100644 --- a/apps/code/src/renderer/components/GlobalEventHandlers.tsx +++ b/packages/ui/src/workbench/GlobalEventHandlers.tsx @@ -1,23 +1,27 @@ -import { useReviewNavigationStore } from "@features/code-review/stores/reviewNavigationStore"; -import { useFolders } from "@features/folders/hooks/useFolders"; -import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; -import { getSessionService } from "@features/sessions/service/service"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { useSidebarData } from "@features/sidebar/hooks/useSidebarData"; -import { useVisualTaskOrder } from "@features/sidebar/hooks/useVisualTaskOrder"; -import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; -import { useTasks } from "@features/tasks/hooks/useTasks"; -import { useFocusWorkspace } from "@features/workspace/hooks/useFocusWorkspace"; -import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; -import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; -import { useTRPC } from "@renderer/trpc"; -import type { Task } from "@shared/types"; -import { useCommandMenuStore } from "@stores/commandMenuStore"; -import { useNavigationStore } from "@stores/navigationStore"; +import { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { useService } from "@posthog/di/react"; +import { useHostTRPC } from "@posthog/host-router/react"; +import type { Task } from "@posthog/shared/domain-types"; +import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useFolders } from "@posthog/ui/features/folders/useFolders"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; +import { useSettingsDialogStore } from "@posthog/ui/features/settings/settingsDialogStore"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { useSidebarData } from "@posthog/ui/features/sidebar/useSidebarData"; +import { useVisualTaskOrder } from "@posthog/ui/features/sidebar/useVisualTaskOrder"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { useFocusWorkspace } from "@posthog/ui/features/workspace/useFocusWorkspace"; +import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; +import { shipIt } from "@posthog/ui/primitives/confetti"; +import { clearApplicationStorage } from "@posthog/ui/utils/clearStorage"; +import { useCommandMenuStore } from "@posthog/ui/workbench/commandMenuStore"; +import { logger } from "@posthog/ui/workbench/logger"; import { useSubscription } from "@trpc/tanstack-react-query"; -import { clearApplicationStorage } from "@utils/clearStorage"; -import { shipIt } from "@utils/confetti"; -import { logger } from "@utils/logger"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; @@ -30,7 +34,8 @@ export function GlobalEventHandlers({ onToggleCommandMenu, onToggleShortcutsSheet, }: GlobalEventHandlersProps) { - const trpcReact = useTRPC(); + const trpcReact = useHostTRPC(); + const sessionService = useService<SessionService>(SESSION_SERVICE); const commandMenuOpen = useCommandMenuStore((s) => s.isOpen); const openSettingsDialog = useSettingsDialogStore((state) => state.open); const navigateToTaskInput = useNavigationStore( @@ -263,11 +268,11 @@ export function GlobalEventHandlers({ useEffect(() => { const handleFocus = () => { loadFolders(); - getSessionService().retryUnhealthyCloudSessions(); + sessionService.retryUnhealthyCloudSessions(); }; window.addEventListener("focus", handleFocus); return () => window.removeEventListener("focus", handleFocus); - }, [loadFolders]); + }, [loadFolders, sessionService]); // Check if current task's folder became invalid (e.g., moved while app was open) useEffect(() => { diff --git a/apps/code/src/renderer/components/HeaderRow.tsx b/packages/ui/src/workbench/HeaderRow.tsx similarity index 80% rename from apps/code/src/renderer/components/HeaderRow.tsx rename to packages/ui/src/workbench/HeaderRow.tsx index 6efdf954cc..cf27d8c543 100644 --- a/apps/code/src/renderer/components/HeaderRow.tsx +++ b/packages/ui/src/workbench/HeaderRow.tsx @@ -1,30 +1,30 @@ -import { Tooltip } from "@components/ui/Tooltip"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useDiffStatsToggle } from "@features/code-review/hooks/useDiffStatsToggle"; -import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; -import { CloudGitInteractionHeader } from "@features/git-interaction/components/CloudGitInteractionHeader"; -import { TaskActionsMenu } from "@features/git-interaction/components/TaskActionsMenu"; -import { HandoffConfirmDialog } from "@features/sessions/components/HandoffConfirmDialog"; -import { useSessionForTask } from "@features/sessions/hooks/useSession"; -import { useSessionCallbacks } from "@features/sessions/hooks/useSessionCallbacks"; -import { useHandoffDialogStore } from "@features/sessions/stores/handoffDialogStore"; -import { SidebarTrigger } from "@features/sidebar/components/SidebarTrigger"; -import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; -import { SkillButtonsMenu } from "@features/skill-buttons/components/SkillButtonsMenu"; -import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { Cloud, Spinner } from "@phosphor-icons/react"; import { Button as QuillButton } from "@posthog/quill"; -import { DiffStatsBadge } from "@posthog/ui/features/diff-stats/DiffStatsBadge"; -import { Box, Flex } from "@radix-ui/themes"; +import type { Task } from "@posthog/shared/domain-types"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useDiffStatsToggle } from "@posthog/ui/features/code-review/hooks/useDiffStatsToggle"; import { formatHotkey, SHORTCUTS, -} from "@renderer/constants/keyboard-shortcuts"; -import type { Task } from "@shared/types"; -import { useHeaderStore } from "@stores/headerStore"; -import { useNavigationStore } from "@stores/navigationStore"; -import { isWindows } from "@utils/platform"; +} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { DiffStatsBadge } from "@posthog/ui/features/diff-stats/DiffStatsBadge"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { BranchSelector } from "@posthog/ui/features/git-interaction/components/BranchSelector"; +import { CloudGitInteractionHeader } from "@posthog/ui/features/git-interaction/components/CloudGitInteractionHeader"; +import { TaskActionsMenu } from "@posthog/ui/features/git-interaction/components/TaskActionsMenu"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { HandoffConfirmDialog } from "@posthog/ui/features/sessions/components/HandoffConfirmDialog"; +import { useHandoffDialogStore } from "@posthog/ui/features/sessions/handoffDialogStore"; +import { useSessionCallbacks } from "@posthog/ui/features/sessions/hooks/useSessionCallbacks"; +import { useSessionForTask } from "@posthog/ui/features/sessions/useSession"; +import { SidebarTrigger } from "@posthog/ui/features/sidebar/components/SidebarTrigger"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { SkillButtonsMenu } from "@posthog/ui/features/skill-buttons/components/SkillButtonsMenu"; +import { useWorkspace } from "@posthog/ui/features/workspace/useWorkspace"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { isWindows } from "@posthog/ui/utils/platform"; +import { useHeaderStore } from "@posthog/ui/workbench/headerStore"; +import { Box, Flex } from "@radix-ui/themes"; import { useState } from "react"; const CLOUD_HANDOFF_FLAG = "phc-cloud-handoff"; diff --git a/packages/ui/src/workbench/HedgehogMode.tsx b/packages/ui/src/workbench/HedgehogMode.tsx new file mode 100644 index 0000000000..a76aa2712e --- /dev/null +++ b/packages/ui/src/workbench/HedgehogMode.tsx @@ -0,0 +1,75 @@ +import { useService } from "@posthog/di/react"; +import { useEffect, useRef } from "react"; +import { useMeQuery } from "../features/auth/useMeQuery"; +import { useSettingsStore } from "../features/settings/settingsStore"; +import { + HEDGEHOG_MODE_HOST, + type HedgehogModeHandle, + type HedgehogModeHost, +} from "./hedgehogModeHost"; +import { logger } from "./logger"; + +const log = logger.scope("hedgehog-mode"); + +export function HedgehogMode() { + const hedgehogMode = useSettingsStore((s) => s.hedgehogMode); + const setHedgehogMode = useSettingsStore((s) => s.setHedgehogMode); + const { data: user } = useMeQuery(); + const host = useService<HedgehogModeHost>(HEDGEHOG_MODE_HOST); + const containerRef = useRef<HTMLDivElement>(null); + const handleRef = useRef<HedgehogModeHandle | null>(null); + + useEffect(() => { + if (!hedgehogMode || !containerRef.current || handleRef.current) return; + if (!host) return; + + let cancelled = false; + const container = containerRef.current; + + const hedgehogConfig = user?.hedgehog_config as Record< + string, + unknown + > | null; + const actorOptions = hedgehogConfig?.actor_options; + + host + .mount(container, { + actorOptions, + onQuit: () => setHedgehogMode(false), + }) + .then((handle) => { + if (cancelled) { + handle.destroy(); + return; + } + handleRef.current = handle; + }) + .catch((err) => { + log.error("Failed to mount hedgehog mode", err); + }); + + return () => { + cancelled = true; + }; + }, [hedgehogMode, user?.hedgehog_config, setHedgehogMode, host]); + + useEffect(() => { + return () => { + if (handleRef.current) { + handleRef.current.destroy(); + handleRef.current = null; + } + }; + }, []); + + return ( + <div + ref={containerRef} + style={{ + zIndex: 999998, + visibility: hedgehogMode ? "visible" : "hidden", + }} + className="pointer-events-none fixed inset-0" + /> + ); +} diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/packages/ui/src/workbench/MainLayout.tsx similarity index 58% rename from apps/code/src/renderer/components/MainLayout.tsx rename to packages/ui/src/workbench/MainLayout.tsx index ff1d04eecf..d3f7baa8a6 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/packages/ui/src/workbench/MainLayout.tsx @@ -1,45 +1,40 @@ -import { HeaderRow } from "@components/HeaderRow"; -import { HedgehogMode } from "@components/HedgehogMode"; -import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet"; -import { SpaceSwitcher } from "@components/SpaceSwitcher"; - -import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksView"; -import { UsageLimitModal } from "@features/billing/components/UsageLimitModal"; -import { CommandMenu } from "@features/command/components/CommandMenu"; -import { CommandCenterView } from "@features/command-center/components/CommandCenterView"; -import { InboxView } from "@features/inbox/components/InboxView"; -import { useInboxDeepLink } from "@features/inbox/hooks/useInboxDeepLink"; -import { McpServersView } from "@features/mcp-servers/components/McpServersView"; -import { FolderSettingsView } from "@features/settings/components/FolderSettingsView"; -import { SettingsDialog } from "@features/settings/components/SettingsDialog"; -import { useSetupDiscovery } from "@features/setup/hooks/useSetupDiscovery"; -import { MainSidebar } from "@features/sidebar/components/MainSidebar"; -import { useSidebarData } from "@features/sidebar/hooks/useSidebarData"; -import { useVisualTaskOrder } from "@features/sidebar/hooks/useVisualTaskOrder"; -import { SkillsView } from "@features/skills/components/SkillsView"; -import { TaskDetail } from "@features/task-detail/components/TaskDetail"; -import { TaskInput } from "@features/task-detail/components/TaskInput"; -import { TaskPendingView } from "@features/task-detail/components/TaskPendingView"; -import { useTasks } from "@features/tasks/hooks/useTasks"; -import { TourOverlay } from "@features/tour/components/TourOverlay"; -import { - useWorkspaces, - workspaceApi, -} from "@features/workspace/hooks/useWorkspace"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; -import { useIntegrations } from "@hooks/useIntegrations"; +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { BILLING_FLAG, SYNC_CLOUD_TASKS_FLAG } from "@posthog/shared"; +import { ArchivedTasksView } from "@posthog/ui/features/archive/ArchivedTasksView"; +import { UsageLimitModal } from "@posthog/ui/features/billing/UsageLimitModal"; +import { CommandMenu } from "@posthog/ui/features/command/CommandMenu"; +import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; +import { CommandCenterView } from "@posthog/ui/features/command-center/components/CommandCenterView"; +import { useNewTaskDeepLink } from "@posthog/ui/features/deep-links/useNewTaskDeepLink"; +import { useTaskDeepLink } from "@posthog/ui/features/deep-links/useTaskDeepLink"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { InboxView } from "@posthog/ui/features/inbox/components/InboxView"; +import { useInboxDeepLink } from "@posthog/ui/features/inbox/hooks/useInboxDeepLink"; +import { useIntegrations } from "@posthog/ui/features/integrations/useIntegrations"; +import { McpServersView } from "@posthog/ui/features/mcp-servers/components/McpServersView"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { FolderSettingsView } from "@posthog/ui/features/settings/FolderSettingsView"; +import { SettingsDialog } from "@posthog/ui/features/settings/SettingsDialog"; +import { useSetupDiscovery } from "@posthog/ui/features/setup/useSetupDiscovery"; +import { MainSidebar } from "@posthog/ui/features/sidebar/components/MainSidebar"; +import { useSidebarData } from "@posthog/ui/features/sidebar/useSidebarData"; +import { useVisualTaskOrder } from "@posthog/ui/features/sidebar/useVisualTaskOrder"; +import { SkillsView } from "@posthog/ui/features/skills/SkillsView"; +import { TaskDetail } from "@posthog/ui/features/task-detail/components/TaskDetail"; +import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; +import { TaskPendingView } from "@posthog/ui/features/task-detail/components/TaskPendingView"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { TourOverlay } from "@posthog/ui/features/tour/components/TourOverlay"; +import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; +import { useCommandMenuStore } from "@posthog/ui/workbench/commandMenuStore"; +import { HeaderRow } from "@posthog/ui/workbench/HeaderRow"; +import { HedgehogMode } from "@posthog/ui/workbench/HedgehogMode"; +import { logger } from "@posthog/ui/workbench/logger"; +import { SpaceSwitcher } from "@posthog/ui/workbench/SpaceSwitcher"; +import { useShortcutsSheetStore } from "@posthog/ui/workbench/shortcutsSheetStore"; import { Box, Flex } from "@radix-ui/themes"; -import { useTRPC } from "@renderer/trpc/client"; -import { BILLING_FLAG, SYNC_CLOUD_TASKS_FLAG } from "@shared/constants"; -import { useCommandMenuStore } from "@stores/commandMenuStore"; -import { useNavigationStore } from "@stores/navigationStore"; -import { useShortcutsSheetStore } from "@stores/shortcutsSheetStore"; import { useQueryClient } from "@tanstack/react-query"; -import { logger } from "@utils/logger"; -import { useCallback, useEffect, useRef } from "react"; -import { useNewTaskDeepLink } from "../hooks/useNewTaskDeepLink"; -import { useTaskDeepLink } from "../hooks/useTaskDeepLink"; -import { GlobalEventHandlers } from "./GlobalEventHandlers"; +import { useEffect, useRef } from "react"; const log = logger.scope("main-layout"); @@ -52,19 +47,14 @@ export function MainLayout() { taskInputReportAssociation, taskInputCloudRepository, } = useNavigationStore(); - const { - isOpen: commandMenuOpen, - setOpen: setCommandMenuOpen, - toggle: toggleCommandMenu, - } = useCommandMenuStore(); - const { - isOpen: shortcutsSheetOpen, - toggle: toggleShortcutsSheet, - close: closeShortcutsSheet, - } = useShortcutsSheetStore(); + const { isOpen: commandMenuOpen, setOpen: setCommandMenuOpen } = + useCommandMenuStore(); + const { isOpen: shortcutsSheetOpen, close: closeShortcutsSheet } = + useShortcutsSheetStore(); const { data: tasks } = useTasks(); const { data: workspaces, isFetched: workspacesFetched } = useWorkspaces(); - const trpcReact = useTRPC(); + const trpc = useHostTRPC(); + const hostClient = useHostTRPCClient(); const queryClient = useQueryClient(); const reconcilingTaskIds = useRef<Set<string>>(new Set()); const billingEnabled = useFeatureFlag(BILLING_FLAG); @@ -102,14 +92,14 @@ export function MainLayout() { for (const id of missingIds) reconcilingTaskIds.current.add(id); // Single batched IPC instead of one mutation per task — with many cloud // tasks the per-task pattern saturates the main thread at boot. - workspaceApi - .reconcileCloudWorkspaces(missingIds) + hostClient.workspace.reconcileCloudWorkspaces + .mutate({ taskIds: missingIds }) .then((result) => { for (const id of missingIds) reconcilingTaskIds.current.delete(id); if (result.created.length > 0) { - void queryClient.invalidateQueries( - trpcReact.workspace.getAll.pathFilter(), - ); + void queryClient.invalidateQueries({ + queryKey: trpc.workspace.getAll.queryKey(), + }); } }) .catch((err) => { @@ -122,7 +112,8 @@ export function MainLayout() { workspaces, workspacesFetched, queryClient, - trpcReact, + hostClient, + trpc, ]); useEffect(() => { @@ -131,10 +122,6 @@ export function MainLayout() { } }, [view, navigateToTaskInput]); - const handleToggleCommandMenu = useCallback(() => { - toggleCommandMenu(); - }, [toggleCommandMenu]); - return ( <Flex direction="column" height="100vh"> <HeaderRow /> @@ -192,10 +179,6 @@ export function MainLayout() { open={shortcutsSheetOpen} onOpenChange={(open) => (open ? null : closeShortcutsSheet())} /> - <GlobalEventHandlers - onToggleCommandMenu={handleToggleCommandMenu} - onToggleShortcutsSheet={toggleShortcutsSheet} - /> <SettingsDialog /> <TourOverlay /> {billingEnabled && <UsageLimitModal />} diff --git a/apps/code/src/renderer/components/SpaceSwitcher.tsx b/packages/ui/src/workbench/SpaceSwitcher.tsx similarity index 92% rename from apps/code/src/renderer/components/SpaceSwitcher.tsx rename to packages/ui/src/workbench/SpaceSwitcher.tsx index 2513bea11b..a5823fd026 100644 --- a/apps/code/src/renderer/components/SpaceSwitcher.tsx +++ b/packages/ui/src/workbench/SpaceSwitcher.tsx @@ -1,6 +1,6 @@ -import type { TaskData } from "@features/sidebar/hooks/useSidebarData"; -import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; -import type { Task } from "@shared/types"; +import type { TaskData } from "@posthog/core/sidebar/sidebarData.types"; +import type { Task } from "@posthog/shared/domain-types"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; import { useCallback, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; diff --git a/apps/code/src/renderer/stores/activeRepoStore.ts b/packages/ui/src/workbench/activeRepoStore.ts similarity index 100% rename from apps/code/src/renderer/stores/activeRepoStore.ts rename to packages/ui/src/workbench/activeRepoStore.ts diff --git a/packages/ui/src/workbench/analytics.ts b/packages/ui/src/workbench/analytics.ts new file mode 100644 index 0000000000..84797b9930 --- /dev/null +++ b/packages/ui/src/workbench/analytics.ts @@ -0,0 +1,76 @@ +import { resolveService } from "@posthog/di/container"; +import type { + EventPropertyMap, + UserIdentifyProperties, +} from "@posthog/shared/analytics-events"; +import type { Task } from "@posthog/shared/domain-types"; + +type TrackArgs<K extends keyof EventPropertyMap> = + EventPropertyMap[K] extends never + ? [] + : EventPropertyMap[K] extends undefined + ? [properties?: EventPropertyMap[K]] + : [properties: EventPropertyMap[K]]; + +export interface AnalyticsUserGroups { + team?: { id: number; uuid: string; name: string } | null; + organization?: { id: string; name: string; slug: string } | null; +} + +export interface AnalyticsTracker { + track<K extends keyof EventPropertyMap>( + eventName: K, + ...args: TrackArgs<K> + ): void; + setActiveTaskContext(task: Task | null): void; + captureException( + error: Error, + additionalProperties?: Record<string, unknown>, + ): void; + identifyUser(userId: string, properties?: UserIdentifyProperties): void; + setUserGroups(user: AnalyticsUserGroups): void; + resetUser(): void; +} + +export const ANALYTICS_TRACKER = Symbol.for("posthog.ui.AnalyticsTracker"); + +export function track<K extends keyof EventPropertyMap>( + eventName: K, + ...args: TrackArgs<K> +): void { + resolveService<AnalyticsTracker>(ANALYTICS_TRACKER).track(eventName, ...args); +} + +export function setActiveTaskContext(task: Task | null): void { + resolveService<AnalyticsTracker>(ANALYTICS_TRACKER).setActiveTaskContext( + task, + ); +} + +export function captureException( + error: Error, + additionalProperties?: Record<string, unknown>, +): void { + resolveService<AnalyticsTracker>(ANALYTICS_TRACKER).captureException( + error, + additionalProperties, + ); +} + +export function identifyUser( + userId: string, + properties?: UserIdentifyProperties, +): void { + resolveService<AnalyticsTracker>(ANALYTICS_TRACKER).identifyUser( + userId, + properties, + ); +} + +export function setUserGroups(user: AnalyticsUserGroups): void { + resolveService<AnalyticsTracker>(ANALYTICS_TRACKER).setUserGroups(user); +} + +export function resetUser(): void { + resolveService<AnalyticsTracker>(ANALYTICS_TRACKER).resetUser(); +} diff --git a/apps/code/src/renderer/stores/commandMenuStore.ts b/packages/ui/src/workbench/commandMenuStore.ts similarity index 100% rename from apps/code/src/renderer/stores/commandMenuStore.ts rename to packages/ui/src/workbench/commandMenuStore.ts diff --git a/apps/code/src/renderer/stores/createSidebarStore.ts b/packages/ui/src/workbench/createSidebarStore.ts similarity index 100% rename from apps/code/src/renderer/stores/createSidebarStore.ts rename to packages/ui/src/workbench/createSidebarStore.ts diff --git a/packages/ui/src/workbench/diffWorkerHost.ts b/packages/ui/src/workbench/diffWorkerHost.ts new file mode 100644 index 0000000000..773f14c038 --- /dev/null +++ b/packages/ui/src/workbench/diffWorkerHost.ts @@ -0,0 +1,3 @@ +export type DiffWorkerFactory = () => Worker; + +export const DIFF_WORKER_FACTORY = Symbol.for("posthog.ui.DiffWorkerFactory"); diff --git a/apps/code/src/renderer/stores/headerStore.ts b/packages/ui/src/workbench/headerStore.ts similarity index 100% rename from apps/code/src/renderer/stores/headerStore.ts rename to packages/ui/src/workbench/headerStore.ts diff --git a/packages/ui/src/workbench/hedgehogModeHost.ts b/packages/ui/src/workbench/hedgehogModeHost.ts new file mode 100644 index 0000000000..cb52862309 --- /dev/null +++ b/packages/ui/src/workbench/hedgehogModeHost.ts @@ -0,0 +1,25 @@ +export interface HedgehogModeHandle { + destroy(): void; +} + +export interface HedgehogModeMountOptions { + /** Raw `hedgehog_config.actor_options` from the user profile; the host casts it. */ + actorOptions?: unknown; + /** Called when the user quits hedgehog mode from within the game. */ + onQuit: () => void; +} + +/** + * Host capability for the optional hedgehog-mode overlay. The desktop adapter + * owns the `@posthog/hedgehog-mode` (DOM/canvas) library; the ui component only + * mounts/destroys through this port so packages/ui stays environment-agnostic. + * A host that does not support hedgehogs simply leaves it unset (no-op). + */ +export interface HedgehogModeHost { + mount( + container: HTMLDivElement, + options: HedgehogModeMountOptions, + ): Promise<HedgehogModeHandle>; +} + +export const HEDGEHOG_MODE_HOST = Symbol.for("posthog.ui.HedgehogModeHost"); diff --git a/packages/ui/src/workbench/logger.ts b/packages/ui/src/workbench/logger.ts new file mode 100644 index 0000000000..faed6c6410 --- /dev/null +++ b/packages/ui/src/workbench/logger.ts @@ -0,0 +1,51 @@ +import { resolveService } from "@posthog/di/container"; + +export interface ScopedLogger { + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; + debug(...args: unknown[]): void; +} + +export interface HostLogger extends ScopedLogger { + scope(name: string): ScopedLogger; +} + +export const HOST_LOGGER = Symbol.for("posthog.ui.HostLogger"); + +function impl(): HostLogger | null { + try { + return resolveService<HostLogger>(HOST_LOGGER); + } catch { + return null; + } +} + +function deferredScope(name: string): ScopedLogger { + return { + info: (...args) => + impl() + ?.scope(name) + .info(...args), + warn: (...args) => + impl() + ?.scope(name) + .warn(...args), + error: (...args) => + impl() + ?.scope(name) + .error(...args), + debug: (...args) => + impl() + ?.scope(name) + .debug(...args), + }; +} + +export const logger: HostLogger = { + scope: (name) => deferredScope(name), + info: (...args) => impl()?.info(...args), + warn: (...args) => impl()?.warn(...args), + error: (...args) => impl()?.error(...args), + debug: (...args) => impl()?.debug(...args), +}; diff --git a/packages/ui/src/workbench/openExternal.ts b/packages/ui/src/workbench/openExternal.ts new file mode 100644 index 0000000000..c566ed861b --- /dev/null +++ b/packages/ui/src/workbench/openExternal.ts @@ -0,0 +1,11 @@ +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; + +export function openExternalUrl(url: string): void { + void resolveService<HostTrpcClient>(HOST_TRPC_CLIENT).os.openExternal.mutate({ + url, + }); +} diff --git a/apps/code/src/renderer/stores/pendingTaskPromptStore.ts b/packages/ui/src/workbench/pendingTaskPromptStore.ts similarity index 94% rename from apps/code/src/renderer/stores/pendingTaskPromptStore.ts rename to packages/ui/src/workbench/pendingTaskPromptStore.ts index 2412bd9543..ccbcec08e9 100644 --- a/apps/code/src/renderer/stores/pendingTaskPromptStore.ts +++ b/packages/ui/src/workbench/pendingTaskPromptStore.ts @@ -1,4 +1,4 @@ -import type { UserMessageAttachment } from "@features/sessions/components/session-update/UserMessage"; +import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes"; import { create } from "zustand"; export interface PendingTaskPrompt { diff --git a/apps/code/src/renderer/utils/analytics.test.ts b/packages/ui/src/workbench/posthogAnalyticsImpl.test.ts similarity index 98% rename from apps/code/src/renderer/utils/analytics.test.ts rename to packages/ui/src/workbench/posthogAnalyticsImpl.test.ts index c0a329f038..41429827f0 100644 --- a/apps/code/src/renderer/utils/analytics.test.ts +++ b/packages/ui/src/workbench/posthogAnalyticsImpl.test.ts @@ -23,7 +23,7 @@ vi.mock("posthog-js/dist/posthog-recorder", () => ({})); async function loadAnalytics() { vi.resetModules(); - return await import("./analytics"); + return await import("./posthogAnalyticsImpl"); } beforeEach(() => { diff --git a/apps/code/src/renderer/utils/analytics.ts b/packages/ui/src/workbench/posthogAnalyticsImpl.ts similarity index 88% rename from apps/code/src/renderer/utils/analytics.ts rename to packages/ui/src/workbench/posthogAnalyticsImpl.ts index d17665203f..98584938ae 100644 --- a/apps/code/src/renderer/utils/analytics.ts +++ b/packages/ui/src/workbench/posthogAnalyticsImpl.ts @@ -3,13 +3,18 @@ import posthog from "posthog-js/dist/module.full.no-external"; // The module.full.no-external bundle includes rrweb but not the initSessionRecording function // posthog-recorder (vs lazy-recorder) ensures recording is ready immediately import "posthog-js/dist/posthog-recorder"; -import type { PermissionRequest } from "@renderer/features/sessions/utils/parseSessionLogs"; -import type { Task } from "@shared/types"; import type { EventPropertyMap, UserIdentifyProperties, -} from "@shared/types/analytics"; -import { logger } from "./logger"; +} from "@posthog/shared/analytics-events"; +import type { Task } from "@posthog/shared/domain-types"; +import type { FeatureFlags } from "@posthog/ui/features/feature-flags/identifiers"; +import type { PermissionRequest } from "@posthog/ui/features/sessions/sessionLogTypes"; +import type { + AnalyticsTracker, + AnalyticsUserGroups, +} from "@posthog/ui/workbench/analytics"; +import { logger } from "@posthog/ui/workbench/logger"; const log = logger.scope("analytics"); @@ -141,12 +146,7 @@ export function identifyUser( posthog.identify(userId, properties); } -type UserWithGroups = { - team?: { id: number; uuid: string; name: string } | null; - organization?: { id: string; name: string; slug: string } | null; -}; - -export function setUserGroups(user: UserWithGroups) { +export function setUserGroups(user: AnalyticsUserGroups) { if (!isInitialized) { return; } @@ -302,3 +302,24 @@ export function reloadFeatureFlags(): void { posthog.reloadFeatureFlags(); } + +/** + * posthog-js implementation of the ANALYTICS_TRACKER port. Bound by the host + * composition root so packages/ui stays free of any direct posthog access. + */ +export const posthogAnalyticsTracker: AnalyticsTracker = { + track, + setActiveTaskContext: setActiveTaskAnalyticsContext, + captureException, + identifyUser, + setUserGroups, + resetUser, +}; + +/** + * posthog-js implementation of the FEATURE_FLAGS port. + */ +export const posthogFeatureFlags: FeatureFlags = { + isEnabled: isFeatureFlagEnabled, + onFlagsLoaded: onFeatureFlagsLoaded, +}; diff --git a/packages/ui/src/workbench/queryClient.ts b/packages/ui/src/workbench/queryClient.ts new file mode 100644 index 0000000000..e8505b4e92 --- /dev/null +++ b/packages/ui/src/workbench/queryClient.ts @@ -0,0 +1,7 @@ +import type { QueryClient } from "@tanstack/react-query"; + +export type ImperativeQueryClient = QueryClient; + +export const IMPERATIVE_QUERY_CLIENT = Symbol.for( + "posthog.ui.ImperativeQueryClient", +); diff --git a/packages/ui/src/workbench/rendererStorage.ts b/packages/ui/src/workbench/rendererStorage.ts new file mode 100644 index 0000000000..7ebd4c2c95 --- /dev/null +++ b/packages/ui/src/workbench/rendererStorage.ts @@ -0,0 +1,35 @@ +import { resolveService } from "@posthog/di/container"; +import { createJSONStorage, type StateStorage } from "zustand/middleware"; + +export interface RendererStateStorage extends StateStorage {} + +export const RENDERER_STATE_STORAGE = Symbol.for( + "posthog.ui.RendererStateStorage", +); + +function rawStorage(): StateStorage | null { + try { + return resolveService<RendererStateStorage>(RENDERER_STATE_STORAGE); + } catch { + return null; + } +} + +const lazyStorage: StateStorage = { + getItem: (key) => { + const storage = rawStorage(); + return storage ? storage.getItem(key) : null; + }, + setItem: (key, value) => { + const storage = rawStorage(); + return storage ? storage.setItem(key, value) : undefined; + }, + removeItem: (key) => { + const storage = rawStorage(); + return storage ? storage.removeItem(key) : undefined; + }, +}; + +export const rendererSecureStore: StateStorage = lazyStorage; + +export const electronStorage = createJSONStorage(() => lazyStorage); diff --git a/apps/code/src/renderer/stores/rendererWindowFocusStore.ts b/packages/ui/src/workbench/rendererWindowFocusStore.ts similarity index 100% rename from apps/code/src/renderer/stores/rendererWindowFocusStore.ts rename to packages/ui/src/workbench/rendererWindowFocusStore.ts diff --git a/apps/code/src/renderer/stores/shortcutsSheetStore.ts b/packages/ui/src/workbench/shortcutsSheetStore.ts similarity index 100% rename from apps/code/src/renderer/stores/shortcutsSheetStore.ts rename to packages/ui/src/workbench/shortcutsSheetStore.ts diff --git a/apps/code/src/renderer/stores/themeStore.ts b/packages/ui/src/workbench/themeStore.ts similarity index 100% rename from apps/code/src/renderer/stores/themeStore.ts rename to packages/ui/src/workbench/themeStore.ts diff --git a/apps/code/tailwind.config.js b/packages/ui/tailwind.config.js similarity index 88% rename from apps/code/tailwind.config.js rename to packages/ui/tailwind.config.js index 4778ccd2b0..6e88fa839b 100644 --- a/apps/code/tailwind.config.js +++ b/packages/ui/tailwind.config.js @@ -3,7 +3,13 @@ import { radixThemePreset } from "radix-themes-tw"; /** @type {import('tailwindcss').Config} */ module.exports = { presets: [radixThemePreset], - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + content: [ + "./src/**/*.{js,ts,jsx,tsx}", + "../../apps/code/src/**/*.{ts,tsx}", + "../../apps/code/index.html", + "../../apps/web/src/**/*.{ts,tsx}", + "../../apps/web/index.html", + ], theme: { extend: { animation: { diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index d9b10e2eee..0644fa12b1 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,4 +1,9 @@ { "extends": "@posthog/tsconfig/react-package.json", - "include": ["src/**/*"] + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"], + "exclude": ["src/**/*.stories.tsx"] } diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts new file mode 100644 index 0000000000..ec577392ce --- /dev/null +++ b/packages/ui/vitest.config.ts @@ -0,0 +1,28 @@ +import { fileURLToPath } from "node:url"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + // Resolve self-package imports (`@posthog/ui/*`) to source so tests that + // transitively load self-importing UI modules work under vitest. + "@posthog/ui": fileURLToPath(new URL("./src", import.meta.url)), + // `@posthog/di` exposes subpaths (`/react`, `/logger`) via a renderer + // Vite alias, not its package `exports`; mirror that for vitest so tests + // of `useService`-based hooks resolve. + "@posthog/di": fileURLToPath(new URL("../di/src", import.meta.url)), + "@posthog/host-router": fileURLToPath( + new URL("../host-router/src", import.meta.url), + ), + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/packages/workspace-client/src/environment.ts b/packages/workspace-client/src/environment.ts new file mode 100644 index 0000000000..5e870ce2e7 --- /dev/null +++ b/packages/workspace-client/src/environment.ts @@ -0,0 +1,7 @@ +export type { + CreateEnvironmentInput, + Environment, + EnvironmentAction, + UpdateEnvironmentInput, +} from "@posthog/workspace-server/services/environment/schemas"; +export { slugifyEnvironmentName } from "@posthog/workspace-server/services/environment/schemas"; diff --git a/apps/code/drizzle.config.ts b/packages/workspace-server/drizzle.config.ts similarity index 86% rename from apps/code/drizzle.config.ts rename to packages/workspace-server/drizzle.config.ts index a6b40eaa61..88817de68b 100644 --- a/apps/code/drizzle.config.ts +++ b/packages/workspace-server/drizzle.config.ts @@ -14,8 +14,8 @@ const userDataPath = path.join( export default defineConfig({ dialect: "sqlite", - schema: "./src/main/db/schema.ts", - out: "./src/main/db/migrations", + schema: "./src/db/schema.ts", + out: "./src/db/migrations", casing: "snake_case", dbCredentials: { url: path.join(userDataPath, "posthog-code.db"), diff --git a/packages/workspace-server/package.json b/packages/workspace-server/package.json index 6363e7e9d7..9d4ec6a98a 100644 --- a/packages/workspace-server/package.json +++ b/packages/workspace-server/package.json @@ -12,24 +12,43 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "clean": "node ../../scripts/rimraf.mjs .turbo" + "test": "vitest run", + "clean": "node ../../scripts/rimraf.mjs .turbo", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:view": "drizzle-kit studio" }, "dependencies": { + "@agentclientprotocol/sdk": "0.22.1", + "@anthropic-ai/claude-agent-sdk": "0.3.154", "@hono/node-server": "catalog:", "@hono/trpc-server": "catalog:", "@parcel/watcher": "catalog:", + "@posthog/agent": "workspace:*", + "@posthog/di": "workspace:*", + "@posthog/enricher": "workspace:*", "@posthog/git": "workspace:*", + "@posthog/platform": "workspace:*", + "@posthog/shared": "workspace:*", "@trpc/server": "catalog:", + "better-sqlite3": "^12.8.0", + "drizzle-orm": "^0.45.1", + "fflate": "^0.8.2", "hono": "catalog:", "ignore": "^7.0.5", "inversify": "catalog:", + "node-pty": "1.1.0", "reflect-metadata": "catalog:", + "smol-toml": "^1.6.0", "superjson": "catalog:", - "zod": "catalog:" + "zod": "^4.1.12" }, "devDependencies": { "@posthog/tsconfig": "workspace:*", + "@types/better-sqlite3": "^7.6.13", "@types/node": "catalog:", - "typescript": "catalog:" + "drizzle-kit": "^0.31.9", + "typescript": "catalog:", + "vitest": "^4.0.10" } } diff --git a/packages/workspace-server/src/db/db.module.ts b/packages/workspace-server/src/db/db.module.ts new file mode 100644 index 0000000000..7fbba29e65 --- /dev/null +++ b/packages/workspace-server/src/db/db.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { DATABASE_SERVICE } from "./identifiers"; +import { DatabaseService } from "./service"; + +export const databaseModule = new ContainerModule(({ bind }) => { + bind(DATABASE_SERVICE).to(DatabaseService).inSingletonScope(); +}); diff --git a/packages/workspace-server/src/db/identifiers.ts b/packages/workspace-server/src/db/identifiers.ts new file mode 100644 index 0000000000..d683146cb1 --- /dev/null +++ b/packages/workspace-server/src/db/identifiers.ts @@ -0,0 +1,26 @@ +export const DATABASE_SERVICE = Symbol.for("posthog.workspace.databaseService"); + +export const REPOSITORY_REPOSITORY = Symbol.for( + "posthog.workspace.repositoryRepository", +); +export const WORKSPACE_REPOSITORY = Symbol.for( + "posthog.workspace.workspaceRepository", +); +export const WORKTREE_REPOSITORY = Symbol.for( + "posthog.workspace.worktreeRepository", +); +export const ARCHIVE_REPOSITORY = Symbol.for( + "posthog.workspace.archiveRepository", +); +export const SUSPENSION_REPOSITORY = Symbol.for( + "posthog.workspace.suspensionRepository", +); +export const AUTH_SESSION_REPOSITORY = Symbol.for( + "posthog.workspace.authSessionRepository", +); +export const AUTH_PREFERENCE_REPOSITORY = Symbol.for( + "posthog.workspace.authPreferenceRepository", +); +export const DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY = Symbol.for( + "posthog.workspace.defaultAdditionalDirectoryRepository", +); diff --git a/apps/code/src/main/db/migrations/0000_red_jigsaw.sql b/packages/workspace-server/src/db/migrations/0000_red_jigsaw.sql similarity index 100% rename from apps/code/src/main/db/migrations/0000_red_jigsaw.sql rename to packages/workspace-server/src/db/migrations/0000_red_jigsaw.sql diff --git a/apps/code/src/main/db/migrations/0001_tan_lifeguard.sql b/packages/workspace-server/src/db/migrations/0001_tan_lifeguard.sql similarity index 100% rename from apps/code/src/main/db/migrations/0001_tan_lifeguard.sql rename to packages/workspace-server/src/db/migrations/0001_tan_lifeguard.sql diff --git a/apps/code/src/main/db/migrations/0002_massive_bishop.sql b/packages/workspace-server/src/db/migrations/0002_massive_bishop.sql similarity index 100% rename from apps/code/src/main/db/migrations/0002_massive_bishop.sql rename to packages/workspace-server/src/db/migrations/0002_massive_bishop.sql diff --git a/apps/code/src/main/db/migrations/0003_fair_whiplash.sql b/packages/workspace-server/src/db/migrations/0003_fair_whiplash.sql similarity index 100% rename from apps/code/src/main/db/migrations/0003_fair_whiplash.sql rename to packages/workspace-server/src/db/migrations/0003_fair_whiplash.sql diff --git a/apps/code/src/main/db/migrations/0004_auth_preferences.sql b/packages/workspace-server/src/db/migrations/0004_auth_preferences.sql similarity index 100% rename from apps/code/src/main/db/migrations/0004_auth_preferences.sql rename to packages/workspace-server/src/db/migrations/0004_auth_preferences.sql diff --git a/apps/code/src/main/db/migrations/0005_youthful_scarlet_spider.sql b/packages/workspace-server/src/db/migrations/0005_youthful_scarlet_spider.sql similarity index 100% rename from apps/code/src/main/db/migrations/0005_youthful_scarlet_spider.sql rename to packages/workspace-server/src/db/migrations/0005_youthful_scarlet_spider.sql diff --git a/apps/code/src/main/db/migrations/0006_youthful_warstar.sql b/packages/workspace-server/src/db/migrations/0006_youthful_warstar.sql similarity index 100% rename from apps/code/src/main/db/migrations/0006_youthful_warstar.sql rename to packages/workspace-server/src/db/migrations/0006_youthful_warstar.sql diff --git a/apps/code/src/main/db/migrations/meta/0000_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0000_snapshot.json similarity index 100% rename from apps/code/src/main/db/migrations/meta/0000_snapshot.json rename to packages/workspace-server/src/db/migrations/meta/0000_snapshot.json diff --git a/apps/code/src/main/db/migrations/meta/0001_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0001_snapshot.json similarity index 100% rename from apps/code/src/main/db/migrations/meta/0001_snapshot.json rename to packages/workspace-server/src/db/migrations/meta/0001_snapshot.json diff --git a/apps/code/src/main/db/migrations/meta/0002_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0002_snapshot.json similarity index 100% rename from apps/code/src/main/db/migrations/meta/0002_snapshot.json rename to packages/workspace-server/src/db/migrations/meta/0002_snapshot.json diff --git a/apps/code/src/main/db/migrations/meta/0003_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0003_snapshot.json similarity index 100% rename from apps/code/src/main/db/migrations/meta/0003_snapshot.json rename to packages/workspace-server/src/db/migrations/meta/0003_snapshot.json diff --git a/apps/code/src/main/db/migrations/meta/0004_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0004_snapshot.json similarity index 100% rename from apps/code/src/main/db/migrations/meta/0004_snapshot.json rename to packages/workspace-server/src/db/migrations/meta/0004_snapshot.json diff --git a/apps/code/src/main/db/migrations/meta/0005_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0005_snapshot.json similarity index 100% rename from apps/code/src/main/db/migrations/meta/0005_snapshot.json rename to packages/workspace-server/src/db/migrations/meta/0005_snapshot.json diff --git a/apps/code/src/main/db/migrations/meta/0006_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0006_snapshot.json similarity index 100% rename from apps/code/src/main/db/migrations/meta/0006_snapshot.json rename to packages/workspace-server/src/db/migrations/meta/0006_snapshot.json diff --git a/apps/code/src/main/db/migrations/meta/_journal.json b/packages/workspace-server/src/db/migrations/meta/_journal.json similarity index 100% rename from apps/code/src/main/db/migrations/meta/_journal.json rename to packages/workspace-server/src/db/migrations/meta/_journal.json diff --git a/apps/code/src/main/utils/normalize-path.ts b/packages/workspace-server/src/db/normalize-path.ts similarity index 100% rename from apps/code/src/main/utils/normalize-path.ts rename to packages/workspace-server/src/db/normalize-path.ts diff --git a/packages/workspace-server/src/db/repositories.module.ts b/packages/workspace-server/src/db/repositories.module.ts new file mode 100644 index 0000000000..e1e44629f8 --- /dev/null +++ b/packages/workspace-server/src/db/repositories.module.ts @@ -0,0 +1,34 @@ +import { ContainerModule } from "inversify"; +import { + ARCHIVE_REPOSITORY, + AUTH_PREFERENCE_REPOSITORY, + AUTH_SESSION_REPOSITORY, + DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, + REPOSITORY_REPOSITORY, + SUSPENSION_REPOSITORY, + WORKSPACE_REPOSITORY, + WORKTREE_REPOSITORY, +} from "./identifiers"; +import { ArchiveRepository } from "./repositories/archive-repository"; +import { AuthPreferenceRepository } from "./repositories/auth-preference-repository"; +import { AuthSessionRepository } from "./repositories/auth-session-repository"; +import { DefaultAdditionalDirectoryRepository } from "./repositories/default-additional-directory-repository"; +import { RepositoryRepository } from "./repositories/repository-repository"; +import { SuspensionRepositoryImpl } from "./repositories/suspension-repository"; +import { WorkspaceRepository } from "./repositories/workspace-repository"; +import { WorktreeRepository } from "./repositories/worktree-repository"; + +export const repositoriesModule = new ContainerModule(({ bind }) => { + bind(REPOSITORY_REPOSITORY).to(RepositoryRepository).inSingletonScope(); + bind(WORKSPACE_REPOSITORY).to(WorkspaceRepository).inSingletonScope(); + bind(WORKTREE_REPOSITORY).to(WorktreeRepository).inSingletonScope(); + bind(ARCHIVE_REPOSITORY).to(ArchiveRepository).inSingletonScope(); + bind(SUSPENSION_REPOSITORY).to(SuspensionRepositoryImpl).inSingletonScope(); + bind(AUTH_SESSION_REPOSITORY).to(AuthSessionRepository).inSingletonScope(); + bind(AUTH_PREFERENCE_REPOSITORY) + .to(AuthPreferenceRepository) + .inSingletonScope(); + bind(DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY) + .to(DefaultAdditionalDirectoryRepository) + .inSingletonScope(); +}); diff --git a/apps/code/src/main/db/repositories/archive-repository.mock.ts b/packages/workspace-server/src/db/repositories/archive-repository.mock.ts similarity index 100% rename from apps/code/src/main/db/repositories/archive-repository.mock.ts rename to packages/workspace-server/src/db/repositories/archive-repository.mock.ts diff --git a/apps/code/src/main/db/repositories/archive-repository.ts b/packages/workspace-server/src/db/repositories/archive-repository.ts similarity index 96% rename from apps/code/src/main/db/repositories/archive-repository.ts rename to packages/workspace-server/src/db/repositories/archive-repository.ts index c15137c01a..0307afdaaa 100644 --- a/apps/code/src/main/db/repositories/archive-repository.ts +++ b/packages/workspace-server/src/db/repositories/archive-repository.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; +import { DATABASE_SERVICE } from "../identifiers"; import { archives } from "../schema"; import type { DatabaseService } from "../service"; @@ -29,7 +29,7 @@ const now = () => new Date().toISOString(); @injectable() export class ArchiveRepository implements IArchiveRepository { constructor( - @inject(MAIN_TOKENS.DatabaseService) + @inject(DATABASE_SERVICE) private readonly databaseService: DatabaseService, ) {} diff --git a/apps/code/src/main/db/repositories/auth-preference-repository.mock.ts b/packages/workspace-server/src/db/repositories/auth-preference-repository.mock.ts similarity index 100% rename from apps/code/src/main/db/repositories/auth-preference-repository.mock.ts rename to packages/workspace-server/src/db/repositories/auth-preference-repository.mock.ts diff --git a/apps/code/src/main/db/repositories/auth-preference-repository.ts b/packages/workspace-server/src/db/repositories/auth-preference-repository.ts similarity index 96% rename from apps/code/src/main/db/repositories/auth-preference-repository.ts rename to packages/workspace-server/src/db/repositories/auth-preference-repository.ts index 6962e03e91..37490aedac 100644 --- a/apps/code/src/main/db/repositories/auth-preference-repository.ts +++ b/packages/workspace-server/src/db/repositories/auth-preference-repository.ts @@ -1,6 +1,6 @@ import { and, eq } from "drizzle-orm"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; +import { DATABASE_SERVICE } from "../identifiers"; import { authPreferences } from "../schema"; import type { DatabaseService } from "../service"; @@ -26,7 +26,7 @@ const now = () => new Date().toISOString(); @injectable() export class AuthPreferenceRepository implements IAuthPreferenceRepository { constructor( - @inject(MAIN_TOKENS.DatabaseService) + @inject(DATABASE_SERVICE) private readonly databaseService: DatabaseService, ) {} diff --git a/apps/code/src/main/db/repositories/auth-session-repository.mock.ts b/packages/workspace-server/src/db/repositories/auth-session-repository.mock.ts similarity index 100% rename from apps/code/src/main/db/repositories/auth-session-repository.mock.ts rename to packages/workspace-server/src/db/repositories/auth-session-repository.mock.ts diff --git a/apps/code/src/main/db/repositories/auth-session-repository.ts b/packages/workspace-server/src/db/repositories/auth-session-repository.ts similarity index 93% rename from apps/code/src/main/db/repositories/auth-session-repository.ts rename to packages/workspace-server/src/db/repositories/auth-session-repository.ts index 2aa760039b..179ff1175b 100644 --- a/apps/code/src/main/db/repositories/auth-session-repository.ts +++ b/packages/workspace-server/src/db/repositories/auth-session-repository.ts @@ -1,7 +1,8 @@ -import type { CloudRegion } from "@shared/types/regions"; +type CloudRegion = "us" | "eu" | "dev"; + import { eq } from "drizzle-orm"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; +import { DATABASE_SERVICE } from "../identifiers"; import { authSessions } from "../schema"; import type { DatabaseService } from "../service"; @@ -28,7 +29,7 @@ const now = () => new Date().toISOString(); @injectable() export class AuthSessionRepository implements IAuthSessionRepository { constructor( - @inject(MAIN_TOKENS.DatabaseService) + @inject(DATABASE_SERVICE) private readonly databaseService: DatabaseService, ) {} diff --git a/apps/code/src/main/db/repositories/default-additional-directory-repository.mock.ts b/packages/workspace-server/src/db/repositories/default-additional-directory-repository.mock.ts similarity index 100% rename from apps/code/src/main/db/repositories/default-additional-directory-repository.mock.ts rename to packages/workspace-server/src/db/repositories/default-additional-directory-repository.mock.ts diff --git a/apps/code/src/main/db/repositories/default-additional-directory-repository.ts b/packages/workspace-server/src/db/repositories/default-additional-directory-repository.ts similarity index 88% rename from apps/code/src/main/db/repositories/default-additional-directory-repository.ts rename to packages/workspace-server/src/db/repositories/default-additional-directory-repository.ts index a4fda1bcf3..e0cc271538 100644 --- a/apps/code/src/main/db/repositories/default-additional-directory-repository.ts +++ b/packages/workspace-server/src/db/repositories/default-additional-directory-repository.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { normalizeDirectoryPath } from "../../utils/normalize-path"; +import { DATABASE_SERVICE } from "../identifiers"; +import { normalizeDirectoryPath } from "../normalize-path"; import { defaultAdditionalDirectories } from "../schema"; import type { DatabaseService } from "../service"; @@ -19,7 +19,7 @@ export class DefaultAdditionalDirectoryRepository implements IDefaultAdditionalDirectoryRepository { constructor( - @inject(MAIN_TOKENS.DatabaseService) + @inject(DATABASE_SERVICE) private readonly databaseService: DatabaseService, ) {} diff --git a/packages/workspace-server/src/db/repositories/repositories.test.ts b/packages/workspace-server/src/db/repositories/repositories.test.ts new file mode 100644 index 0000000000..4f667aea67 --- /dev/null +++ b/packages/workspace-server/src/db/repositories/repositories.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { DatabaseService } from "../service"; +import { createTestDb, type TestDatabase } from "../test-helpers"; +import { RepositoryRepository } from "./repository-repository"; +import { WorkspaceRepository } from "./workspace-repository"; +import { WorktreeRepository } from "./worktree-repository"; + +let testDb: TestDatabase; +let repositories: RepositoryRepository; +let workspaces: WorkspaceRepository; +let worktrees: WorktreeRepository; + +beforeEach(() => { + testDb = createTestDb(); + const databaseService = { db: testDb.db } as unknown as DatabaseService; + repositories = new RepositoryRepository(databaseService); + workspaces = new WorkspaceRepository(databaseService); + worktrees = new WorktreeRepository(databaseService); +}); + +afterEach(() => { + testDb.close(); +}); + +describe("RepositoryRepository round-trip", () => { + it("persists a created repository and reads it back by id", () => { + const created = repositories.create({ + path: "/repos/twig", + remoteUrl: "posthog/twig", + }); + + const found = repositories.findById(created.id); + + expect(found).not.toBeNull(); + expect(found?.path).toBe("/repos/twig"); + expect(found?.remoteUrl).toBe("posthog/twig"); + }); + + it("finds a repository by path", () => { + const created = repositories.create({ path: "/repos/twig" }); + + expect(repositories.findByPath("/repos/twig")?.id).toBe(created.id); + }); + + it("updates the remote url in place", () => { + const created = repositories.create({ path: "/repos/twig" }); + + repositories.updateRemoteUrl(created.id, "posthog/twig"); + + expect(repositories.findById(created.id)?.remoteUrl).toBe("posthog/twig"); + }); + + it("removes a deleted repository from reads", () => { + const created = repositories.create({ path: "/repos/twig" }); + + repositories.delete(created.id); + + expect(repositories.findById(created.id)).toBeNull(); + }); +}); + +describe("repository → workspace → worktree round-trip", () => { + it("persists the full ownership chain across repositories", () => { + const repository = repositories.create({ path: "/repos/twig" }); + + const workspace = workspaces.create({ + taskId: "task-1", + repositoryId: repository.id, + mode: "worktree", + }); + + const worktree = worktrees.create({ + workspaceId: workspace.id, + name: "feature-branch", + path: "/worktrees/twig/feature-branch", + }); + + expect(workspaces.findByTaskId("task-1")?.repositoryId).toBe(repository.id); + expect(worktrees.findByWorkspaceId(workspace.id)?.id).toBe(worktree.id); + expect(workspaces.findAllByRepositoryId(repository.id)).toHaveLength(1); + }); +}); diff --git a/apps/code/src/main/db/repositories/repository-repository.mock.ts b/packages/workspace-server/src/db/repositories/repository-repository.mock.ts similarity index 100% rename from apps/code/src/main/db/repositories/repository-repository.mock.ts rename to packages/workspace-server/src/db/repositories/repository-repository.mock.ts diff --git a/apps/code/src/main/db/repositories/repository-repository.ts b/packages/workspace-server/src/db/repositories/repository-repository.ts similarity index 97% rename from apps/code/src/main/db/repositories/repository-repository.ts rename to packages/workspace-server/src/db/repositories/repository-repository.ts index e29b06228c..caf195a4ef 100644 --- a/apps/code/src/main/db/repositories/repository-repository.ts +++ b/packages/workspace-server/src/db/repositories/repository-repository.ts @@ -1,6 +1,6 @@ import { desc, eq } from "drizzle-orm"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; +import { DATABASE_SERVICE } from "../identifiers"; import { repositories } from "../schema"; import type { DatabaseService } from "../service"; @@ -30,7 +30,7 @@ const now = () => new Date().toISOString(); @injectable() export class RepositoryRepository implements IRepositoryRepository { constructor( - @inject(MAIN_TOKENS.DatabaseService) + @inject(DATABASE_SERVICE) private readonly databaseService: DatabaseService, ) {} diff --git a/apps/code/src/main/db/repositories/suspension-repository.mock.ts b/packages/workspace-server/src/db/repositories/suspension-repository.mock.ts similarity index 100% rename from apps/code/src/main/db/repositories/suspension-repository.mock.ts rename to packages/workspace-server/src/db/repositories/suspension-repository.mock.ts diff --git a/apps/code/src/main/db/repositories/suspension-repository.ts b/packages/workspace-server/src/db/repositories/suspension-repository.ts similarity index 90% rename from apps/code/src/main/db/repositories/suspension-repository.ts rename to packages/workspace-server/src/db/repositories/suspension-repository.ts index f1d2c836e3..15c367f591 100644 --- a/apps/code/src/main/db/repositories/suspension-repository.ts +++ b/packages/workspace-server/src/db/repositories/suspension-repository.ts @@ -1,15 +1,15 @@ import { eq } from "drizzle-orm"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens.js"; +import { DATABASE_SERVICE } from "../identifiers"; import { suspensions } from "../schema.js"; import type { DatabaseService } from "../service.js"; export type Suspension = typeof suspensions.$inferSelect; export type NewSuspension = typeof suspensions.$inferInsert; -import type { SuspensionReason } from "../../../shared/types/suspension.js"; +type SuspensionReason = "max_worktrees" | "inactivity" | "manual"; -export type { SuspensionReason } from "../../../shared/types/suspension.js"; +export type { SuspensionReason }; export interface CreateSuspensionData { workspaceId: string; @@ -34,7 +34,7 @@ const now = () => new Date().toISOString(); @injectable() export class SuspensionRepositoryImpl implements SuspensionRepository { constructor( - @inject(MAIN_TOKENS.DatabaseService) + @inject(DATABASE_SERVICE) private readonly databaseService: DatabaseService, ) {} diff --git a/apps/code/src/main/db/repositories/workspace-repository.mock.ts b/packages/workspace-server/src/db/repositories/workspace-repository.mock.ts similarity index 100% rename from apps/code/src/main/db/repositories/workspace-repository.mock.ts rename to packages/workspace-server/src/db/repositories/workspace-repository.mock.ts diff --git a/apps/code/src/main/db/repositories/workspace-repository.ts b/packages/workspace-server/src/db/repositories/workspace-repository.ts similarity index 96% rename from apps/code/src/main/db/repositories/workspace-repository.ts rename to packages/workspace-server/src/db/repositories/workspace-repository.ts index 760ba9503a..a95efd71b6 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.ts +++ b/packages/workspace-server/src/db/repositories/workspace-repository.ts @@ -1,13 +1,14 @@ +import type { WorkspaceMode } from "@posthog/shared"; import { eq, isNotNull } from "drizzle-orm"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { normalizeDirectoryPath } from "../../utils/normalize-path"; +import { DATABASE_SERVICE } from "../identifiers"; +import { normalizeDirectoryPath } from "../normalize-path"; import { workspaces } from "../schema"; import type { DatabaseService } from "../service"; export type Workspace = typeof workspaces.$inferSelect; export type NewWorkspace = typeof workspaces.$inferInsert; -export type WorkspaceMode = "cloud" | "local" | "worktree"; +export type { WorkspaceMode } from "@posthog/shared"; export interface CreateWorkspaceData { taskId: string; @@ -62,7 +63,7 @@ const now = () => new Date().toISOString(); @injectable() export class WorkspaceRepository implements IWorkspaceRepository { constructor( - @inject(MAIN_TOKENS.DatabaseService) + @inject(DATABASE_SERVICE) private readonly databaseService: DatabaseService, ) {} diff --git a/apps/code/src/main/db/repositories/worktree-repository.mock.ts b/packages/workspace-server/src/db/repositories/worktree-repository.mock.ts similarity index 100% rename from apps/code/src/main/db/repositories/worktree-repository.mock.ts rename to packages/workspace-server/src/db/repositories/worktree-repository.mock.ts diff --git a/apps/code/src/main/db/repositories/worktree-repository.ts b/packages/workspace-server/src/db/repositories/worktree-repository.ts similarity index 96% rename from apps/code/src/main/db/repositories/worktree-repository.ts rename to packages/workspace-server/src/db/repositories/worktree-repository.ts index 57468ab380..321553a463 100644 --- a/apps/code/src/main/db/repositories/worktree-repository.ts +++ b/packages/workspace-server/src/db/repositories/worktree-repository.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; +import { DATABASE_SERVICE } from "../identifiers"; import { worktrees } from "../schema"; import type { DatabaseService } from "../service"; @@ -32,7 +32,7 @@ const now = () => new Date().toISOString(); @injectable() export class WorktreeRepository implements IWorktreeRepository { constructor( - @inject(MAIN_TOKENS.DatabaseService) + @inject(DATABASE_SERVICE) private readonly databaseService: DatabaseService, ) {} diff --git a/apps/code/src/main/db/schema.ts b/packages/workspace-server/src/db/schema.ts similarity index 100% rename from apps/code/src/main/db/schema.ts rename to packages/workspace-server/src/db/schema.ts diff --git a/apps/code/src/main/db/service.ts b/packages/workspace-server/src/db/service.ts similarity index 58% rename from apps/code/src/main/db/service.ts rename to packages/workspace-server/src/db/service.ts index 853ef2dda1..dd1a0dfe55 100644 --- a/apps/code/src/main/db/service.ts +++ b/packages/workspace-server/src/db/service.ts @@ -1,5 +1,8 @@ import path from "node:path"; -import type { IStoragePaths } from "@posthog/platform/storage-paths"; +import { + type IStoragePaths, + STORAGE_PATHS_SERVICE, +} from "@posthog/platform/storage-paths"; import Database from "better-sqlite3"; import { type BetterSQLite3Database, @@ -7,13 +10,9 @@ import { } from "drizzle-orm/better-sqlite3"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { inject, injectable, postConstruct, preDestroy } from "inversify"; -import { MAIN_TOKENS } from "../di/tokens"; -import { logger } from "../utils/logger"; import * as schema from "./schema"; -const log = logger.scope("database"); - const MIGRATIONS_FOLDER = path.join(__dirname, "db-migrations"); @injectable() @@ -22,7 +21,7 @@ export class DatabaseService { private _sqlite: InstanceType<typeof Database> | null = null; constructor( - @inject(MAIN_TOKENS.StoragePaths) + @inject(STORAGE_PATHS_SERVICE) private readonly storagePaths: IStoragePaths, ) {} @@ -36,27 +35,16 @@ export class DatabaseService { @postConstruct() initialize(): void { const dbPath = path.join(this.storagePaths.appDataPath, "posthog-code.db"); - log.info("Opening database", { - path: dbPath, - migrationsFolder: MIGRATIONS_FOLDER, - }); - - try { - this._sqlite = new Database(dbPath); - this._sqlite.pragma("journal_mode = WAL"); - this._sqlite.pragma("foreign_keys = ON"); - this._db = drizzle(this._sqlite, { schema, casing: "snake_case" }); - migrate(this._db, { migrationsFolder: MIGRATIONS_FOLDER }); - } catch (error) { - log.error("Database initialization failed", error); - throw error; - } + this._sqlite = new Database(dbPath); + this._sqlite.pragma("journal_mode = WAL"); + this._sqlite.pragma("foreign_keys = ON"); + this._db = drizzle(this._sqlite, { schema, casing: "snake_case" }); + migrate(this._db, { migrationsFolder: MIGRATIONS_FOLDER }); } @preDestroy() close(): void { if (this._sqlite) { - log.info("Closing database"); this._sqlite.close(); this._sqlite = null; this._db = null; diff --git a/apps/code/src/main/db/test-helpers.ts b/packages/workspace-server/src/db/test-helpers.ts similarity index 91% rename from apps/code/src/main/db/test-helpers.ts rename to packages/workspace-server/src/db/test-helpers.ts index 72d1a8cfa3..246c4a959e 100644 --- a/apps/code/src/main/db/test-helpers.ts +++ b/packages/workspace-server/src/db/test-helpers.ts @@ -19,7 +19,7 @@ export function createTestDb(): TestDatabase { const sqlite = new Database(":memory:"); sqlite.pragma("foreign_keys = ON"); - const db = drizzle(sqlite, { schema }); + const db = drizzle(sqlite, { schema, casing: "snake_case" }); migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); return { diff --git a/packages/workspace-server/src/di/container.ts b/packages/workspace-server/src/di/container.ts index b54ae5947e..f183fa68e0 100644 --- a/packages/workspace-server/src/di/container.ts +++ b/packages/workspace-server/src/di/container.ts @@ -1,9 +1,13 @@ import "reflect-metadata"; import { Container } from "inversify"; +import { ConnectivityService } from "../services/connectivity/service"; +import { EnvironmentService } from "../services/environment/service"; import { FocusService } from "../services/focus/service"; import { FocusSyncService } from "../services/focus/sync-service"; import { FsService } from "../services/fs/service"; import { GitService } from "../services/git/service"; +import { LOGS_SERVICE } from "../services/local-logs/identifiers"; +import { LocalLogsService } from "../services/local-logs/service"; import { WatcherService } from "../services/watcher/service"; import { TOKENS } from "./tokens"; @@ -13,3 +17,13 @@ container.bind(TOKENS.FocusSyncService).to(FocusSyncService).inSingletonScope(); container.bind(TOKENS.GitService).to(GitService).inSingletonScope(); container.bind(TOKENS.FsService).to(FsService).inSingletonScope(); container.bind(TOKENS.WatcherService).to(WatcherService).inSingletonScope(); +container.bind(TOKENS.LocalLogsService).to(LocalLogsService).inSingletonScope(); +container.bind(LOGS_SERVICE).toService(TOKENS.LocalLogsService); +container + .bind(TOKENS.ConnectivityService) + .to(ConnectivityService) + .inSingletonScope(); +container + .bind(TOKENS.EnvironmentService) + .to(EnvironmentService) + .inSingletonScope(); diff --git a/packages/workspace-server/src/di/tokens.ts b/packages/workspace-server/src/di/tokens.ts index 9c905c298b..b0dd2e2410 100644 --- a/packages/workspace-server/src/di/tokens.ts +++ b/packages/workspace-server/src/di/tokens.ts @@ -1,7 +1,10 @@ export const TOKENS = Object.freeze({ - FocusService: Symbol.for("WorkspaceServer.FocusService"), - FocusSyncService: Symbol.for("WorkspaceServer.FocusSyncService"), - GitService: Symbol.for("WorkspaceServer.GitService"), - FsService: Symbol.for("WorkspaceServer.FsService"), - WatcherService: Symbol.for("WorkspaceServer.WatcherService"), + FocusService: Symbol.for("posthog.workspace.focus-service"), + FocusSyncService: Symbol.for("posthog.workspace.focus-sync-service"), + GitService: Symbol.for("posthog.workspace.git-service"), + FsService: Symbol.for("posthog.workspace.fs-service"), + WatcherService: Symbol.for("posthog.workspace.watcher-service"), + LocalLogsService: Symbol.for("posthog.workspace.local-logs-service"), + ConnectivityService: Symbol.for("posthog.workspace.connectivity-service"), + EnvironmentService: Symbol.for("posthog.workspace.environment-service"), }); diff --git a/packages/workspace-server/src/services/additional-directories/additional-directories.module.ts b/packages/workspace-server/src/services/additional-directories/additional-directories.module.ts new file mode 100644 index 0000000000..27c40f012b --- /dev/null +++ b/packages/workspace-server/src/services/additional-directories/additional-directories.module.ts @@ -0,0 +1,9 @@ +import { ContainerModule } from "inversify"; +import { AdditionalDirectoriesService } from "./additional-directories"; +import { ADDITIONAL_DIRECTORIES_SERVICE } from "./identifiers"; + +export const additionalDirectoriesModule = new ContainerModule(({ bind }) => { + bind(ADDITIONAL_DIRECTORIES_SERVICE) + .to(AdditionalDirectoriesService) + .inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/additional-directories/additional-directories.test.ts b/packages/workspace-server/src/services/additional-directories/additional-directories.test.ts new file mode 100644 index 0000000000..588d6ac925 --- /dev/null +++ b/packages/workspace-server/src/services/additional-directories/additional-directories.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import type { IDefaultAdditionalDirectoryRepository } from "../../db/repositories/default-additional-directory-repository"; +import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; +import { AdditionalDirectoriesService } from "./additional-directories"; + +function makeDefaultsRepo(initial: string[] = []) { + let dirs = [...initial]; + const repo: Pick< + IDefaultAdditionalDirectoryRepository, + "list" | "add" | "remove" + > = { + list: () => [...dirs], + add: (p) => { + if (!dirs.includes(p)) dirs.push(p); + }, + remove: (p) => { + dirs = dirs.filter((d) => d !== p); + }, + }; + return repo as IDefaultAdditionalDirectoryRepository; +} + +function makeWorkspacesRepo() { + const byTask = new Map<string, string[]>(); + const repo: Pick< + IWorkspaceRepository, + | "getAdditionalDirectories" + | "addAdditionalDirectory" + | "removeAdditionalDirectory" + > = { + getAdditionalDirectories: (taskId) => [...(byTask.get(taskId) ?? [])], + addAdditionalDirectory: (taskId, p) => { + const list = byTask.get(taskId) ?? []; + if (!list.includes(p)) list.push(p); + byTask.set(taskId, list); + }, + removeAdditionalDirectory: (taskId, p) => { + byTask.set( + taskId, + (byTask.get(taskId) ?? []).filter((d) => d !== p), + ); + }, + }; + return repo as IWorkspaceRepository; +} + +describe("AdditionalDirectoriesService", () => { + it("lists, adds, and removes default directories", () => { + const service = new AdditionalDirectoriesService( + makeDefaultsRepo(["/a"]), + makeWorkspacesRepo(), + ); + expect(service.listDefaults()).toEqual(["/a"]); + service.addDefault("/b"); + expect(service.listDefaults()).toEqual(["/a", "/b"]); + service.removeDefault("/a"); + expect(service.listDefaults()).toEqual(["/b"]); + }); + + it("scopes per-task directories to their task", () => { + const service = new AdditionalDirectoriesService( + makeDefaultsRepo(), + makeWorkspacesRepo(), + ); + service.addForTask("task-1", "/x"); + service.addForTask("task-2", "/y"); + expect(service.listForTask("task-1")).toEqual(["/x"]); + expect(service.listForTask("task-2")).toEqual(["/y"]); + service.removeForTask("task-1", "/x"); + expect(service.listForTask("task-1")).toEqual([]); + expect(service.listForTask("task-2")).toEqual(["/y"]); + }); +}); diff --git a/packages/workspace-server/src/services/additional-directories/additional-directories.ts b/packages/workspace-server/src/services/additional-directories/additional-directories.ts new file mode 100644 index 0000000000..b0dddd21d1 --- /dev/null +++ b/packages/workspace-server/src/services/additional-directories/additional-directories.ts @@ -0,0 +1,48 @@ +import { inject, injectable } from "inversify"; +import { + DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, + WORKSPACE_REPOSITORY, +} from "../../db/identifiers"; +import type { IDefaultAdditionalDirectoryRepository } from "../../db/repositories/default-additional-directory-repository"; +import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; + +/** + * Owns the "additional directories" domain: the per-device default directories + * the agent may always access, and the per-task directories added to a single + * workspace. Backing service for the additional-directories router, which + * previously reached two repositories directly (a router-bypasses-service + * anti-pattern). + */ +@injectable() +export class AdditionalDirectoriesService { + constructor( + @inject(DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY) + private readonly defaults: IDefaultAdditionalDirectoryRepository, + @inject(WORKSPACE_REPOSITORY) + private readonly workspaces: IWorkspaceRepository, + ) {} + + listDefaults(): string[] { + return this.defaults.list(); + } + + addDefault(path: string): void { + this.defaults.add(path); + } + + removeDefault(path: string): void { + this.defaults.remove(path); + } + + listForTask(taskId: string): string[] { + return this.workspaces.getAdditionalDirectories(taskId); + } + + addForTask(taskId: string, path: string): void { + this.workspaces.addAdditionalDirectory(taskId, path); + } + + removeForTask(taskId: string, path: string): void { + this.workspaces.removeAdditionalDirectory(taskId, path); + } +} diff --git a/packages/workspace-server/src/services/additional-directories/identifiers.ts b/packages/workspace-server/src/services/additional-directories/identifiers.ts new file mode 100644 index 0000000000..9e440c8aff --- /dev/null +++ b/packages/workspace-server/src/services/additional-directories/identifiers.ts @@ -0,0 +1,3 @@ +export const ADDITIONAL_DIRECTORIES_SERVICE = Symbol.for( + "posthog.workspace.additionalDirectoriesService", +); diff --git a/packages/workspace-server/src/services/agent/agent.module.ts b/packages/workspace-server/src/services/agent/agent.module.ts new file mode 100644 index 0000000000..5082b73a7e --- /dev/null +++ b/packages/workspace-server/src/services/agent/agent.module.ts @@ -0,0 +1,9 @@ +import { ContainerModule } from "inversify"; +import { AgentService } from "./agent"; +import { AgentAuthAdapter } from "./auth-adapter"; +import { AGENT_AUTH_ADAPTER, AGENT_SERVICE } from "./identifiers"; + +export const agentModule = new ContainerModule(({ bind }) => { + bind(AGENT_SERVICE).to(AgentService).inSingletonScope(); + bind(AGENT_AUTH_ADAPTER).to(AgentAuthAdapter).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/agent/service.test.ts b/packages/workspace-server/src/services/agent/agent.test.ts similarity index 96% rename from apps/code/src/main/services/agent/service.test.ts rename to packages/workspace-server/src/services/agent/agent.test.ts index 5e277e6ad7..c1fa410fa0 100644 --- a/apps/code/src/main/services/agent/service.test.ts +++ b/packages/workspace-server/src/services/agent/agent.test.ts @@ -51,25 +51,6 @@ vi.mock("electron", () => ({ app: mockApp, })); -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -vi.mock("../../utils/typed-event-emitter.js", () => ({ - TypedEventEmitter: class { - emit = vi.fn(); - on = vi.fn(); - off = vi.fn(); - }, -})); - vi.mock("@posthog/agent/agent", () => ({ Agent: mockAgentConstructor, })); @@ -101,10 +82,6 @@ vi.mock("@posthog/agent/adapters/claude/session/jsonl-hydration", () => ({ hydrateSessionJsonl: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@shared/errors.js", () => ({ - isAuthError: vi.fn(() => false), -})); - vi.mock("node:fs", async (importOriginal) => { const original = await importOriginal<typeof import("node:fs")>(); return { @@ -122,7 +99,7 @@ vi.mock("node:fs", async (importOriginal) => { }); // --- Import after mocks --- -import { AgentService, buildAutoApproveOutcome } from "./service"; +import { AgentService, buildAutoApproveOutcome } from "./agent"; // --- Test helpers --- @@ -201,6 +178,14 @@ function createMockDependencies() { addAdditionalDirectory: vi.fn(), removeAdditionalDirectory: vi.fn(), }, + loggerFactory: { + scope: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, }; } @@ -233,7 +218,9 @@ describe("AgentService", () => { deps.storagePaths as never, deps.defaultAdditionalDirectoryRepository as never, deps.workspaceRepository as never, + deps.loggerFactory as never, ); + vi.spyOn(service, "emit"); }); afterEach(() => { diff --git a/apps/code/src/main/services/agent/service.ts b/packages/workspace-server/src/services/agent/agent.ts similarity index 90% rename from apps/code/src/main/services/agent/service.ts rename to packages/workspace-server/src/services/agent/agent.ts index d60b09b6cc..0a45e73ddd 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -38,27 +38,52 @@ import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import { extractCreatedPrUrl } from "@posthog/agent/pr-url-detector"; import type * as AgentTypes from "@posthog/agent/types"; import { getCurrentBranch } from "@posthog/git/queries"; -import type { IAppMeta } from "@posthog/platform/app-meta"; -import type { IBundledResources } from "@posthog/platform/bundled-resources"; -import type { IPowerManager } from "@posthog/platform/power-manager"; -import type { IStoragePaths } from "@posthog/platform/storage-paths"; -import { isAuthError } from "@shared/errors"; -import type { AcpMessage } from "@shared/types/session-events"; +import { APP_META_SERVICE, type IAppMeta } from "@posthog/platform/app-meta"; +import { + BUNDLED_RESOURCES_SERVICE, + type IBundledResources, +} from "@posthog/platform/bundled-resources"; +import { + type IPowerManager, + POWER_MANAGER_SERVICE, +} from "@posthog/platform/power-manager"; +import { + type IStoragePaths, + STORAGE_PATHS_SERVICE, +} from "@posthog/platform/storage-paths"; +import { + type AcpMessage, + isAuthError, + TypedEventEmitter, +} from "@posthog/shared"; import { inject, injectable, preDestroy } from "inversify"; +import { + DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, + WORKSPACE_REPOSITORY, +} from "../../db/identifiers"; import type { IDefaultAdditionalDirectoryRepository } from "../../db/repositories/default-additional-directory-repository"; import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { isDevBuild } from "../../utils/env"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import type { FsService } from "../fs/service"; -import type { McpAppsService } from "../mcp-apps/service"; -import type { PosthogPluginService } from "../posthog-plugin/service"; -import type { ProcessTrackingService } from "../process-tracking/service"; +import { POSTHOG_PLUGIN_SERVICE } from "../posthog-plugin/identifiers"; +import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin"; +import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; +import type { ProcessTrackingService } from "../process-tracking/process-tracking"; import { loadSessionEnvOverrides } from "../session-env/loader"; -import type { SleepService } from "../sleep/service"; import type { AgentAuthAdapter, McpToolInstallations } from "./auth-adapter"; import { discoverExternalPlugins } from "./discover-plugins"; +import { + AGENT_AUTH_ADAPTER, + AGENT_LOGGER, + AGENT_MCP_APPS, + AGENT_REPO_FILES, + AGENT_SLEEP_COORDINATOR, +} from "./identifiers"; +import type { + AgentLogger, + AgentMcpApps, + AgentRepoFiles, + AgentScopedLogger, + AgentSleepCoordinator, +} from "./ports"; import { AgentServiceEvent, type AgentServiceEvents, @@ -73,7 +98,9 @@ import { export type { InterruptReason }; -const log = logger.scope("agent-service"); +function isDevBuild(): boolean { + return process.env.POSTHOG_CODE_IS_DEV === "true"; +} const MOCK_NODE_DIR_PREFIX = "agent-node"; @@ -115,6 +142,7 @@ class NdJsonTap { function createTappedReadableStream( underlying: ReadableStream<Uint8Array>, onMessage: MessageCallback, + log: AgentScopedLogger, ): ReadableStream<Uint8Array> { const reader = underlying.getReader(); const tap = new NdJsonTap(onMessage); @@ -147,6 +175,7 @@ function createTappedReadableStream( function createTappedWritableStream( underlying: WritableStream<Uint8Array>, onMessage: MessageCallback, + log: AgentScopedLogger, ): WritableStream<Uint8Array> { const tap = new NdJsonTap(onMessage); @@ -185,14 +214,16 @@ function createTappedWritableStream( }); } -const onAgentLog: AgentTypes.OnLogCallback = (level, scope, message, data) => { - const scopedLog = logger.scope(scope); - if (data !== undefined) { - scopedLog[level as keyof typeof scopedLog](message, data); - } else { - scopedLog[level](message); - } -}; +function makeOnAgentLog(loggerFactory: AgentLogger): AgentTypes.OnLogCallback { + return (level, scope, message, data) => { + const scopedLog = loggerFactory.scope(scope); + if (data !== undefined) { + scopedLog[level as keyof AgentScopedLogger](message, data); + } else { + scopedLog[level as keyof AgentScopedLogger](message); + } + }; +} function buildClaudeCodeOptions(args: { additionalDirectories?: string[]; @@ -292,37 +323,41 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> { { handle: ReturnType<typeof setTimeout>; deadline: number } >(); private processTracking: ProcessTrackingService; - private sleepService: SleepService; - private fsService: FsService; + private sleepService: AgentSleepCoordinator; + private fsService: AgentRepoFiles; private posthogPluginService: PosthogPluginService; private agentAuthAdapter: AgentAuthAdapter; - private mcpAppsService: McpAppsService; + private mcpAppsService: AgentMcpApps; + private readonly log: AgentScopedLogger; + private readonly onAgentLog: AgentTypes.OnLogCallback; constructor( - @inject(MAIN_TOKENS.ProcessTrackingService) + @inject(PROCESS_TRACKING_SERVICE) processTracking: ProcessTrackingService, - @inject(MAIN_TOKENS.SleepService) - sleepService: SleepService, - @inject(MAIN_TOKENS.FsService) - fsService: FsService, - @inject(MAIN_TOKENS.PosthogPluginService) + @inject(AGENT_SLEEP_COORDINATOR) + sleepService: AgentSleepCoordinator, + @inject(AGENT_REPO_FILES) + fsService: AgentRepoFiles, + @inject(POSTHOG_PLUGIN_SERVICE) posthogPluginService: PosthogPluginService, - @inject(MAIN_TOKENS.AgentAuthAdapter) + @inject(AGENT_AUTH_ADAPTER) agentAuthAdapter: AgentAuthAdapter, - @inject(MAIN_TOKENS.McpAppsService) - mcpAppsService: McpAppsService, - @inject(MAIN_TOKENS.PowerManager) + @inject(AGENT_MCP_APPS) + mcpAppsService: AgentMcpApps, + @inject(POWER_MANAGER_SERVICE) powerManager: IPowerManager, - @inject(MAIN_TOKENS.BundledResources) + @inject(BUNDLED_RESOURCES_SERVICE) private readonly bundledResources: IBundledResources, - @inject(MAIN_TOKENS.AppMeta) + @inject(APP_META_SERVICE) private readonly appMeta: IAppMeta, - @inject(MAIN_TOKENS.StoragePaths) + @inject(STORAGE_PATHS_SERVICE) private readonly storagePaths: IStoragePaths, - @inject(MAIN_TOKENS.DefaultAdditionalDirectoryRepository) + @inject(DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY) private readonly defaultAdditionalDirectoryRepository: IDefaultAdditionalDirectoryRepository, - @inject(MAIN_TOKENS.WorkspaceRepository) + @inject(WORKSPACE_REPOSITORY) private readonly workspaceRepository: IWorkspaceRepository, + @inject(AGENT_LOGGER) + loggerFactory: AgentLogger, ) { super(); this.processTracking = processTracking; @@ -331,6 +366,8 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> { this.posthogPluginService = posthogPluginService; this.agentAuthAdapter = agentAuthAdapter; this.mcpAppsService = mcpAppsService; + this.log = loggerFactory.scope("agent-service"); + this.onAgentLog = makeOnAgentLog(loggerFactory); powerManager.onResume(() => this.checkIdleDeadlines()); } @@ -361,11 +398,11 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> { const pending = this.pendingPermissions.get(key); if (!pending) { - log.warn("No pending permission found", { taskRunId, toolCallId }); + this.log.warn("No pending permission found", { taskRunId, toolCallId }); return; } - log.info("Permission response received", { + this.log.info("Permission response received", { taskRunId, toolCallId, optionId, @@ -398,14 +435,14 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> { const pending = this.pendingPermissions.get(key); if (!pending) { - log.warn("No pending permission found to cancel", { + this.log.warn("No pending permission found to cancel", { taskRunId, toolCallId, }); return; } - log.info("Permission cancelled", { taskRunId, toolCallId }); + this.log.info("Permission cancelled", { taskRunId, toolCallId }); pending.resolve({ outcome: { @@ -450,13 +487,16 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> { this.recordActivity(taskRunId); return; } - log.info("Killing idle session", { taskRunId, taskId: session.taskId }); + this.log.info("Killing idle session", { + taskRunId, + taskId: session.taskId, + }); this.emit(AgentServiceEvent.SessionIdleKilled, { taskRunId, taskId: session.taskId, }); this.cleanupSession(taskRunId).catch((err) => { - log.error("Failed to cleanup idle session", { taskRunId, err }); + this.log.error("Failed to cleanup idle session", { taskRunId, err }); }); } @@ -555,7 +595,7 @@ When creating pull requests, add the following footer at the end of the PR descr try { this.validateSessionParams(params); } catch (err) { - log.error("Invalid reconnect params", err); + this.log.error("Invalid reconnect params", err); return null; } @@ -630,7 +670,7 @@ When creating pull requests, add the following footer at the end of the PR descr skipLogPersistence: isPreview, localCachePath: join(homedir(), ".posthog-code"), debug: isDevBuild(), - onLog: onAgentLog, + onLog: this.onAgentLog, }); try { @@ -677,7 +717,7 @@ When creating pull requests, add the following footer at the end of the PR descr }, onMcpServersReady: (serverNames) => { this.mcpAppsService.handleDiscovery(serverNames).catch((err) => { - log.warn("MCP Apps discovery failed", { + this.log.warn("MCP Apps discovery failed", { error: err instanceof Error ? err.message : String(err), }); }); @@ -722,12 +762,15 @@ When creating pull requests, add the following footer at the end of the PR descr let externalPlugins: Awaited<ReturnType<typeof discoverExternalPlugins>> = []; try { - externalPlugins = await discoverExternalPlugins({ - userDataDir: this.storagePaths.appDataPath, - repoPath, - }); + externalPlugins = await discoverExternalPlugins( + { + userDataDir: this.storagePaths.appDataPath, + repoPath, + }, + this.log, + ); } catch (err) { - log.warn("Failed to discover external plugins", { + this.log.warn("Failed to discover external plugins", { error: err instanceof Error ? err.message : String(err), }); } @@ -764,10 +807,10 @@ When creating pull requests, add the following footer at the end of the PR descr runId: taskRunId, permissionMode: config.permissionMode, posthogAPI, - log, + log: this.log, }); if (!hasSession) { - log.info( + this.log.info( "No session JSONL to resume, creating new session instead", { taskId, taskRunId }, ); @@ -808,7 +851,7 @@ When creating pull requests, add the following footer at the end of the PR descr agentSessionId = existingSessionId; } else { if (isReconnect) { - log.info("No sessionId for reconnect, creating new session", { + this.log.info("No sessionId for reconnect, creating new session", { taskId, taskRunId, }); @@ -856,24 +899,26 @@ When creating pull requests, add the following footer at the end of the PR descr this.recordActivity(taskRunId); if (isRetry) { - log.info("Session created after auth retry", { taskRunId }); + this.log.info("Session created after auth retry", { taskRunId }); } return session; } catch (err) { try { await agent.cleanup(); } catch { - log.debug("Agent cleanup failed during error handling", { taskRunId }); + this.log.debug("Agent cleanup failed during error handling", { + taskRunId, + }); } if (!isRetry && isAuthError(err)) { - log.warn( + this.log.warn( `Auth error during ${isReconnect ? "reconnect" : "create"}, retrying`, { taskRunId }, ); return this.getOrCreateSession(config, isReconnect, true); } - log.error( + this.log.error( `Failed to ${isReconnect ? "reconnect" : "create"} session${ isRetry ? " after retry" : "" }`, @@ -883,7 +928,7 @@ When creating pull requests, add the following footer at the end of the PR descr // If this was already an auth retry (isRetry=true), we've exhausted retries // and return null to avoid infinite loops. if (isReconnect && !isRetry) { - log.warn("Reconnect failed, falling back to new session", { + this.log.warn("Reconnect failed, falling back to new session", { taskRunId, }); config.sessionId = undefined; @@ -906,7 +951,7 @@ When creating pull requests, add the following footer at the end of the PR descr // Prepend pending context if present let finalPrompt = prompt; if (session.pendingContext) { - log.info("Prepending context to prompt", { sessionId }); + this.log.info("Prepending context to prompt", { sessionId }); finalPrompt = [ { type: "text", @@ -979,11 +1024,11 @@ When creating pull requests, add the following footer at the end of the PR descr }); if (reason) { session.interruptReason = reason; - log.info("Session interrupted", { sessionId, reason }); + this.log.info("Session interrupted", { sessionId, reason }); } return true; } catch (err) { - log.error("Failed to cancel prompt", { sessionId, err }); + this.log.error("Failed to cancel prompt", { sessionId, err }); return false; } } @@ -1020,7 +1065,7 @@ When creating pull requests, add the following footer at the end of the PR descr session.config.permissionMode = updatedModeOption.currentValue; } } catch (err) { - log.error("Failed to set session config option", { + this.log.error("Failed to set session config option", { sessionId, configId, value, @@ -1083,7 +1128,7 @@ When creating pull requests, add the following footer at the end of the PR descr if (!session.interruptReason) { throw new Error(`Session ${sessionId} was not interrupted`); } - log.info("Resuming interrupted session", { + this.log.info("Resuming interrupted session", { sessionId, reason: session.interruptReason, }); @@ -1098,11 +1143,11 @@ When creating pull requests, add the following footer at the end of the PR descr setPendingContext(taskRunId: string, context: string): void { const session = this.sessions.get(taskRunId); if (!session) { - log.warn("Session not found for setPendingContext", { taskRunId }); + this.log.warn("Session not found for setPendingContext", { taskRunId }); return; } session.pendingContext = context; - log.info("Set pending context on session", { + this.log.info("Set pending context on session", { taskRunId, contextLength: context.length, }); @@ -1119,7 +1164,9 @@ When creating pull requests, add the following footer at the end of the PR descr ): Promise<void> { const session = this.sessions.get(sessionId); if (!session) { - log.warn("Session not found for context notification", { sessionId }); + this.log.warn("Session not found for context notification", { + sessionId, + }); return; } @@ -1140,7 +1187,7 @@ When creating pull requests, add the following footer at the end of the PR descr session.pendingContext = contextMessage; } - log.info("Notified session of context change", { + this.log.info("Notified session of context change", { sessionId, context, wasPromptPending: session.promptPending, @@ -1166,7 +1213,7 @@ For git operations while detached: for (const { handle } of this.idleTimeouts.values()) clearTimeout(handle); this.idleTimeouts.clear(); const sessionIds = Array.from(this.sessions.keys()); - log.info("Cleaning up all agent sessions", { + this.log.info("Cleaning up all agent sessions", { sessionCount: sessionIds.length, }); @@ -1174,7 +1221,7 @@ For git operations while detached: try { await session.agent.flushAllLogs(); } catch { - log.debug("Failed to flush session logs during shutdown"); + this.log.debug("Failed to flush session logs during shutdown"); } } @@ -1182,7 +1229,7 @@ For git operations while detached: await this.cleanupSession(taskRunId); } - log.info("All agent sessions cleaned up"); + this.log.info("All agent sessions cleaned up"); } private setupMockNodeEnvironment(): string { @@ -1204,7 +1251,7 @@ For git operations while detached: } this.mockNodeReady = true; } catch (err) { - log.warn("Failed to setup mock node environment", err); + this.log.warn("Failed to setup mock node environment", err); } } return mockNodeDir; @@ -1226,7 +1273,7 @@ For git operations while detached: try { await session.agent.cleanup(); } catch { - log.debug("Agent cleanup failed", { taskRunId }); + this.log.debug("Agent cleanup failed", { taskRunId }); } this.sessions.delete(taskRunId); @@ -1240,7 +1287,7 @@ For git operations while detached: // When no sessions remain, tear down MCP Apps connections and cached resources if (this.sessions.size === 0) { this.mcpAppsService.cleanup().catch(() => { - log.debug("MCP Apps cleanup failed"); + this.log.debug("MCP Apps cleanup failed"); }); } } @@ -1277,11 +1324,13 @@ For git operations while detached: const tappedReadable = createTappedReadableStream( clientStreams.readable as ReadableStream<Uint8Array>, onAcpMessage, + service.log, ); const tappedWritable = createTappedWritableStream( clientStreams.writable as WritableStream<Uint8Array>, onAcpMessage, + service.log, ); const client: Client = { @@ -1293,7 +1342,7 @@ For git operations while detached: ?.toolName || ""; const toolCallId = params.toolCall?.toolCallId || ""; - log.info("requestPermission called", { + service.log.info("requestPermission called", { taskRunId, toolCallId, toolName, @@ -1305,7 +1354,7 @@ For git operations while detached: const session = service.sessions.get(taskRunId); const approvalState = session?.mcpToolApprovals?.[toolName]; if (approvalState === "approved") { - log.info("Auto-approving read-only MCP tool", { + service.log.info("Auto-approving read-only MCP tool", { taskRunId, toolName, }); @@ -1329,7 +1378,7 @@ For git operations while detached: toolCallId, }); - log.info("Emitting permission request to renderer", { + service.log.info("Emitting permission request to renderer", { taskRunId, toolCallId, }); @@ -1362,10 +1411,13 @@ For git operations while detached: ); session.mcpToolApprovals[toolName] = "approved"; } catch (err) { - log.warn("Failed to update tool approval on backend", { - toolName, - error: err instanceof Error ? err.message : String(err), - }); + service.log.warn( + "Failed to update tool approval on backend", + { + toolName, + error: err instanceof Error ? err.message : String(err), + }, + ); } } } @@ -1380,10 +1432,13 @@ For git operations while detached: } // Fallback: no toolCallId means we can't track the response, auto-approve - log.warn("No toolCallId in permission request, auto-approving", { - taskRunId, - toolName, - }); + service.log.warn( + "No toolCallId in permission request, auto-approving", + { + taskRunId, + toolName, + }, + ); return { outcome: buildAutoApproveOutcome(params.options) }; }, @@ -1476,7 +1531,7 @@ For git operations while detached: if (notifAdapter) { session.config.adapter = notifAdapter; } - log.info("Session ID captured", { + service.log.info("Session ID captured", { taskRunId: notifTaskRunId, sessionId, adapter: notifAdapter, @@ -1605,7 +1660,7 @@ For git operations while detached: this.trackAgentFileActivity(taskRunId, session, toolName); } catch (err) { - log.debug("Error in tool call update handling", { + this.log.debug("Error in tool call update handling", { taskRunId, error: err, }); @@ -1637,24 +1692,24 @@ For git operations while detached: }); if (!prUrl) return; - log.info("Detected PR URL from gh pr create", { taskRunId, prUrl }); + this.log.info("Detected PR URL from gh pr create", { taskRunId, prUrl }); if (!session) { - log.warn("Session not found for PR attachment", { taskRunId }); + this.log.warn("Session not found for PR attachment", { taskRunId }); return; } session.agent .attachPullRequestToTask(session.taskId, prUrl) .then(() => { - log.info("PR URL attached to task", { + this.log.info("PR URL attached to task", { taskRunId, taskId: session.taskId, prUrl, }); }) .catch((err) => { - log.error("Failed to attach PR URL to task", { + this.log.error("Failed to attach PR URL to task", { taskRunId, taskId: session.taskId, prUrl, @@ -1719,7 +1774,7 @@ For git operations while detached: }); }) .catch((err) => { - log.warn("Failed to emit agent file activity event", { + this.log.warn("Failed to emit agent file activity event", { taskRunId, taskId: session.taskId, ...context, diff --git a/apps/code/src/main/services/agent/auth-adapter.test.ts b/packages/workspace-server/src/services/agent/auth-adapter.test.ts similarity index 97% rename from apps/code/src/main/services/agent/auth-adapter.test.ts rename to packages/workspace-server/src/services/agent/auth-adapter.test.ts index 4d3aaf1ff7..1b802c51a1 100644 --- a/apps/code/src/main/services/agent/auth-adapter.test.ts +++ b/packages/workspace-server/src/services/agent/auth-adapter.test.ts @@ -2,17 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockFetch = vi.hoisted(() => vi.fn()); -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - vi.mock("@posthog/agent/posthog-api", () => ({ getLlmGatewayUrl: vi.fn(() => "https://gateway.example.com"), })); @@ -58,6 +47,14 @@ function createDependencies() { (id: string) => `http://127.0.0.1:9998/${encodeURIComponent(id)}`, ), }, + loggerFactory: { + scope: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, }; } @@ -77,6 +74,7 @@ describe("AgentAuthAdapter", () => { deps.authService as never, deps.authProxy as never, deps.mcpProxy as never, + deps.loggerFactory as never, ); }); diff --git a/apps/code/src/main/services/agent/auth-adapter.ts b/packages/workspace-server/src/services/agent/auth-adapter.ts similarity index 91% rename from apps/code/src/main/services/agent/auth-adapter.ts rename to packages/workspace-server/src/services/agent/auth-adapter.ts index 1cfa711fe0..18d5a2386d 100644 --- a/apps/code/src/main/services/agent/auth-adapter.ts +++ b/packages/workspace-server/src/services/agent/auth-adapter.ts @@ -6,15 +6,14 @@ import { } from "@posthog/agent/adapters/claude/mcp/tool-metadata"; import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import type { AuthService } from "../auth/service"; -import type { AuthProxyService } from "../auth-proxy/service"; -import type { McpProxyService } from "../mcp-proxy/service"; +import type { AuthProxyService } from "../auth-proxy/auth-proxy"; +import { AUTH_PROXY_SERVICE } from "../auth-proxy/identifiers"; +import { MCP_PROXY_SERVICE } from "../mcp-proxy/identifiers"; +import type { McpProxyService } from "../mcp-proxy/mcp-proxy"; +import { AGENT_AUTH, AGENT_LOGGER } from "./identifiers"; +import type { AgentAuth, AgentLogger, AgentScopedLogger } from "./ports"; import type { Credentials } from "./schemas"; -const log = logger.scope("agent-auth-adapter"); - const VALID_APPROVAL_STATES = new Set([ "approved", "needs_approval", @@ -56,14 +55,20 @@ interface ConfigureProcessEnvInput { @injectable() export class AgentAuthAdapter { + private readonly log: AgentScopedLogger; + constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, - @inject(MAIN_TOKENS.AuthProxyService) + @inject(AGENT_AUTH) + private readonly authService: AgentAuth, + @inject(AUTH_PROXY_SERVICE) private readonly authProxy: AuthProxyService, - @inject(MAIN_TOKENS.McpProxyService) + @inject(MCP_PROXY_SERVICE) private readonly mcpProxy: McpProxyService, - ) {} + @inject(AGENT_LOGGER) + loggerFactory: AgentLogger, + ) { + this.log = loggerFactory.scope("agent-auth-adapter"); + } createPosthogConfig(credentials: Credentials): AgentPosthogConfig { return { @@ -251,7 +256,7 @@ export class AgentAuthAdapter { for (const result of results) { if (result.status !== "fulfilled") { - log.warn("Failed to fetch tool approvals for an installation", { + this.log.warn("Failed to fetch tool approvals for an installation", { error: result.reason instanceof Error ? result.reason.message @@ -295,7 +300,7 @@ export class AgentAuthAdapter { }); if (!response.ok) { - log.warn("Failed to fetch MCP installations", { + this.log.warn("Failed to fetch MCP installations", { status: response.status, }); return []; @@ -327,7 +332,7 @@ export class AgentAuthAdapter { `${baseUrl}/api/environments/${credentials.projectId}/mcp_server_installations/${i.id}/proxy/`, })); } catch (err) { - log.warn("Error fetching MCP installations", { error: err }); + this.log.warn("Error fetching MCP installations", { error: err }); return []; } } diff --git a/apps/code/src/main/services/agent/discover-plugins.test.ts b/packages/workspace-server/src/services/agent/discover-plugins.test.ts similarity index 98% rename from apps/code/src/main/services/agent/discover-plugins.test.ts rename to packages/workspace-server/src/services/agent/discover-plugins.test.ts index 000f659839..054a376b26 100644 --- a/apps/code/src/main/services/agent/discover-plugins.test.ts +++ b/packages/workspace-server/src/services/agent/discover-plugins.test.ts @@ -17,17 +17,6 @@ vi.mock("node:os", () => ({ default: { homedir: () => "/mock/home", tmpdir: () => "/mock/tmp" }, })); -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - import { discoverExternalPlugins } from "./discover-plugins"; const USER_DATA_DIR = "/mock/userData"; diff --git a/apps/code/src/main/services/agent/discover-plugins.ts b/packages/workspace-server/src/services/agent/discover-plugins.ts similarity index 54% rename from apps/code/src/main/services/agent/discover-plugins.ts rename to packages/workspace-server/src/services/agent/discover-plugins.ts index e30aca9e10..749948b4b5 100644 --- a/apps/code/src/main/services/agent/discover-plugins.ts +++ b/packages/workspace-server/src/services/agent/discover-plugins.ts @@ -3,36 +3,33 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { SdkPluginConfig } from "@anthropic-ai/claude-agent-sdk"; -import { logger } from "../../utils/logger"; -import { parseSkillFrontmatter } from "./parse-skill-frontmatter"; -import type { SkillInfo, SkillSource } from "./skill-schemas"; - -const log = logger.scope("discover-plugins"); +import { + findSkillDirs, + getMarketplaceInstallPaths, +} from "../skills/skill-discovery"; +import type { AgentScopedLogger } from "./ports"; interface DiscoverPluginsOptions { userDataDir: string; repoPath?: string; } -interface InstalledPluginEntry { - scope: string; - installPath: string; - version: string; -} - -interface InstalledPluginsFile { - version: number; - plugins: Record<string, InstalledPluginEntry[]>; -} +const noopLogger: AgentScopedLogger = { + debug() {}, + info() {}, + warn() {}, + error() {}, +}; export async function discoverExternalPlugins( options: DiscoverPluginsOptions, + log: AgentScopedLogger = noopLogger, ): Promise<SdkPluginConfig[]> { const [globalSkills, marketplacePlugins, repoSkills] = await Promise.all([ - discoverUserSkills(options.userDataDir), + discoverUserSkills(options.userDataDir, log), discoverMarketplacePlugins(), options.repoPath - ? discoverRepoSkills(options.userDataDir, options.repoPath) + ? discoverRepoSkills(options.userDataDir, options.repoPath, log) : Promise.resolve([]), ]); @@ -41,12 +38,14 @@ export async function discoverExternalPlugins( async function discoverUserSkills( userDataDir: string, + log: AgentScopedLogger, ): Promise<SdkPluginConfig[]> { return buildSyntheticPlugin( path.join(os.homedir(), ".claude", "skills"), path.join(userDataDir, "plugins", "user-skills"), "user-skills", "User Claude skills", + log, ); } @@ -55,42 +54,10 @@ async function discoverMarketplacePlugins(): Promise<SdkPluginConfig[]> { return paths.map((p) => ({ type: "local" as const, path: p })); } -export async function getMarketplaceInstallPaths(): Promise<string[]> { - const installedPath = path.join( - os.homedir(), - ".claude", - "plugins", - "installed_plugins.json", - ); - - try { - const content = await fs.promises.readFile(installedPath, "utf-8"); - const data = JSON.parse(content) as InstalledPluginsFile; - - if (!data.plugins || typeof data.plugins !== "object") { - return []; - } - - const paths: string[] = []; - for (const [key, entries] of Object.entries(data.plugins)) { - if (!Array.isArray(entries)) continue; - // Skip the marketplace posthog plugin — the app bundles its own. - if (key.split("@")[0] === "posthog") continue; - for (const entry of entries) { - if (entry.installPath && fs.existsSync(entry.installPath)) { - paths.push(entry.installPath); - } - } - } - return paths; - } catch { - return []; - } -} - async function discoverRepoSkills( userDataDir: string, repoPath: string, + log: AgentScopedLogger, ): Promise<SdkPluginConfig[]> { const skillsDir = path.join(repoPath, ".claude", "skills"); const hash = crypto @@ -104,32 +71,16 @@ async function discoverRepoSkills( path.join(userDataDir, "plugins", `repo-skills-${hash}`), `repo-skills-${hash}`, `Repo skills for ${path.basename(repoPath)}`, + log, ); } -async function findSkillDirs(sourceSkillsDir: string): Promise<string[]> { - if (!fs.existsSync(sourceSkillsDir)) { - return []; - } - - const entries = await fs.promises.readdir(sourceSkillsDir, { - withFileTypes: true, - }); - - return entries - .filter( - (e) => - (e.isDirectory() || e.isSymbolicLink()) && - fs.existsSync(path.join(sourceSkillsDir, e.name, "SKILL.md")), - ) - .map((e) => e.name); -} - async function buildSyntheticPlugin( sourceSkillsDir: string, pluginDir: string, name: string, description: string, + log: AgentScopedLogger, ): Promise<SdkPluginConfig[]> { try { const skillDirs = await findSkillDirs(sourceSkillsDir); @@ -184,35 +135,3 @@ async function buildSyntheticPlugin( return []; } } - -export async function readSkillMetadataFromDir( - skillsDir: string, - source: SkillSource, - repoName?: string, -): Promise<SkillInfo[]> { - const skillNames = await findSkillDirs(skillsDir); - if (skillNames.length === 0) return []; - - const results = await Promise.all( - skillNames.map(async (skillName) => { - const skillPath = path.join(skillsDir, skillName); - try { - const content = await fs.promises.readFile( - path.join(skillPath, "SKILL.md"), - "utf-8", - ); - const frontmatter = parseSkillFrontmatter(content); - return { - name: frontmatter?.name ?? skillName, - description: frontmatter?.description ?? "", - source, - path: skillPath, - ...(repoName ? { repoName } : {}), - } satisfies SkillInfo; - } catch { - return null; - } - }), - ); - return results.filter((r): r is SkillInfo => r !== null); -} diff --git a/packages/workspace-server/src/services/agent/identifiers.ts b/packages/workspace-server/src/services/agent/identifiers.ts new file mode 100644 index 0000000000..db4b8805af --- /dev/null +++ b/packages/workspace-server/src/services/agent/identifiers.ts @@ -0,0 +1,11 @@ +export const AGENT_SERVICE = Symbol.for("posthog.workspace.agentService"); +export const AGENT_AUTH_ADAPTER = Symbol.for( + "posthog.workspace.agentAuthAdapter", +); +export const AGENT_LOGGER = Symbol.for("posthog.workspace.agentLogger"); +export const AGENT_SLEEP_COORDINATOR = Symbol.for( + "posthog.workspace.agentSleepCoordinator", +); +export const AGENT_MCP_APPS = Symbol.for("posthog.workspace.agentMcpApps"); +export const AGENT_REPO_FILES = Symbol.for("posthog.workspace.agentRepoFiles"); +export const AGENT_AUTH = Symbol.for("posthog.workspace.agentAuth"); diff --git a/packages/workspace-server/src/services/agent/ports.ts b/packages/workspace-server/src/services/agent/ports.ts new file mode 100644 index 0000000000..15d2e26c2a --- /dev/null +++ b/packages/workspace-server/src/services/agent/ports.ts @@ -0,0 +1,64 @@ +// Narrow ports inverting AgentService's dependencies on core/host services so it +// can live in workspace-server without importing @posthog/core or apps/code. +// The host (apps/code) binds these to the concrete SleepService, McpAppsService, +// FsService bridge, AuthService, and scoped logger. + +export interface AgentScopedLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; +} + +export interface AgentLogger { + scope(scope: string): AgentScopedLogger; +} + +export interface AgentSleepCoordinator { + acquire(activityId: string): void; + release(activityId: string): void; +} + +export interface AgentMcpServerConnectionConfig { + name: string; + url: string; + headers: Record<string, string>; +} + +export interface AgentMcpApps { + handleDiscovery(serverNames: string[]): Promise<void>; + setServerConfigs(configs: AgentMcpServerConnectionConfig[]): void; + notifyToolCancelled(toolKey: string, toolCallId: string): void; + notifyToolInput(toolKey: string, toolCallId: string, args: unknown): void; + notifyToolResult( + toolKey: string, + toolCallId: string, + result: unknown, + isError?: boolean, + ): void; + cleanup(): Promise<void>; +} + +export interface AgentRepoFiles { + readRepoFile(repoPath: string, filePath: string): Promise<string | null>; + writeRepoFile( + repoPath: string, + filePath: string, + content: string, + ): Promise<void>; +} + +type AgentFetchLike = ( + input: string | Request, + init?: RequestInit, +) => Promise<Response>; + +export interface AgentAuth { + getValidAccessToken(): Promise<{ accessToken: string; apiHost: string }>; + refreshAccessToken(): Promise<{ accessToken: string; apiHost: string }>; + authenticatedFetch( + fetchImpl: AgentFetchLike, + input: string | Request, + init?: RequestInit, + ): Promise<Response>; +} diff --git a/apps/code/src/main/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts similarity index 98% rename from apps/code/src/main/services/agent/schemas.ts rename to packages/workspace-server/src/services/agent/schemas.ts index 410d77ea59..d070f167f4 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -2,11 +2,11 @@ import type { RequestPermissionRequest, PermissionOption as SdkPermissionOption, } from "@agentclientprotocol/sdk"; -import { effortLevelSchema } from "@shared/types"; +import { effortLevelSchema } from "@posthog/shared/domain-types"; import { z } from "zod"; export { effortLevelSchema }; -export type { EffortLevel } from "@shared/types"; +export type { EffortLevel } from "@posthog/shared/domain-types"; // Session credentials schema export const credentialsSchema = z.object({ diff --git a/apps/code/src/main/services/archive/service.integration.test.ts b/packages/workspace-server/src/services/archive/archive.integration.test.ts similarity index 94% rename from apps/code/src/main/services/archive/service.integration.test.ts rename to packages/workspace-server/src/services/archive/archive.integration.test.ts index 50f7e05b7d..78cb79d647 100644 --- a/apps/code/src/main/services/archive/service.integration.test.ts +++ b/packages/workspace-server/src/services/archive/archive.integration.test.ts @@ -17,26 +17,23 @@ vi.mock("electron", () => ({ })); let testWorktreeBasePath = ""; -vi.mock("../settingsStore.js", () => ({ - getWorktreeLocation: () => testWorktreeBasePath, -})); import { createMockArchiveRepository, type MockArchiveRepository, -} from "../../db/repositories/archive-repository.mock"; -import type { IRepositoryRepository } from "../../db/repositories/repository-repository"; -import { createMockRepositoryRepository } from "../../db/repositories/repository-repository.mock"; -import { createMockSuspensionRepository } from "../../db/repositories/suspension-repository.mock"; +} from "@posthog/workspace-server/db/repositories/archive-repository.mock"; +import type { IRepositoryRepository } from "@posthog/workspace-server/db/repositories/repository-repository"; +import { createMockRepositoryRepository } from "@posthog/workspace-server/db/repositories/repository-repository.mock"; +import { createMockSuspensionRepository } from "@posthog/workspace-server/db/repositories/suspension-repository.mock"; import { createMockWorkspaceRepository, type MockWorkspaceRepository, -} from "../../db/repositories/workspace-repository.mock"; +} from "@posthog/workspace-server/db/repositories/workspace-repository.mock"; import { createMockWorktreeRepository, type MockWorktreeRepository, -} from "../../db/repositories/worktree-repository.mock"; -import { ArchiveService } from "./service"; +} from "@posthog/workspace-server/db/repositories/worktree-repository.mock"; +import { ArchiveService } from "./archive"; async function createTempGitRepo(): Promise<string> { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "archive-test-")); @@ -119,15 +116,28 @@ async function withTestContext( const repoId = repo.id; const mocks = { - agentService: { cancelSessionsByTaskId: vi.fn() }, + sessionCanceller: { cancelSessionsByTaskId: vi.fn() }, processTracking: { killByTaskId: vi.fn() }, fileWatcher: { stopWatching: vi.fn() }, }; + const workspaceSettings = { + getWorktreeLocation: () => testWorktreeBasePath, + }; + const scopedLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const archiveLogger = { + ...scopedLogger, + scope: () => scopedLogger, + }; const suspensionRepo = createMockSuspensionRepository(); const service = new ArchiveService( - mocks.agentService as never, + mocks.sessionCanceller as never, mocks.processTracking as never, mocks.fileWatcher as never, repositoryRepo as never, @@ -135,6 +145,8 @@ async function withTestContext( worktreeRepo as never, archiveRepo as never, suspensionRepo as never, + workspaceSettings as never, + archiveLogger as never, ); const git = (cmd: string) => diff --git a/packages/workspace-server/src/services/archive/archive.module.ts b/packages/workspace-server/src/services/archive/archive.module.ts new file mode 100644 index 0000000000..70db69a881 --- /dev/null +++ b/packages/workspace-server/src/services/archive/archive.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { ArchiveService } from "./archive"; +import { ARCHIVE_SERVICE } from "./identifiers"; + +export const archiveModule = new ContainerModule(({ bind }) => { + bind(ARCHIVE_SERVICE).to(ArchiveService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/archive/service.ts b/packages/workspace-server/src/services/archive/archive.ts similarity index 77% rename from apps/code/src/main/services/archive/service.ts rename to packages/workspace-server/src/services/archive/archive.ts index 98830cfa3c..ca95098d8f 100644 --- a/apps/code/src/main/services/archive/service.ts +++ b/packages/workspace-server/src/services/archive/archive.ts @@ -1,15 +1,26 @@ -import fs from "node:fs/promises"; import path from "node:path"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; import { createGitClient } from "@posthog/git/client"; import { isGitRepository } from "@posthog/git/queries"; -import { - CaptureCheckpointSaga, - deleteCheckpoint, - RevertCheckpointSaga, -} from "@posthog/git/sagas/checkpoint"; +import { deleteCheckpoint } from "@posthog/git/sagas/checkpoint"; import { forceRemove } from "@posthog/git/utils"; -import { type WorktreeInfo, WorktreeManager } from "@posthog/git/worktree"; +import { WorktreeManager } from "@posthog/git/worktree"; +import { + type IWorkspaceSettings, + WORKSPACE_SETTINGS_SERVICE, +} from "@posthog/platform/workspace-settings"; import { inject, injectable } from "inversify"; +import { + ARCHIVE_REPOSITORY, + REPOSITORY_REPOSITORY, + SUSPENSION_REPOSITORY, + WORKSPACE_REPOSITORY, + WORKTREE_REPOSITORY, +} from "../../db/identifiers"; import type { Archive, ArchiveRepository, @@ -18,47 +29,57 @@ import type { RepositoryRepository } from "../../db/repositories/repository-repo import type { SuspensionReason, SuspensionRepository, -} from "../../db/repositories/suspension-repository.js"; +} from "../../db/repositories/suspension-repository"; import type { Workspace, WorkspaceRepository, } from "../../db/repositories/workspace-repository"; import type { WorktreeRepository } from "../../db/repositories/worktree-repository"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import type { AgentService } from "../agent/service"; -import type { FileWatcherBridge } from "../file-watcher/bridge"; -import type { ProcessTrackingService } from "../process-tracking/service"; -import { getWorktreeLocation } from "../settingsStore"; +import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; +import type { ProcessTrackingService } from "../process-tracking/process-tracking"; +import { + captureWorktreeCheckpoint, + restoreWorktreeFromCheckpoint, +} from "../worktree-checkpoint/worktree-checkpoint"; +import { resolveWorktreePathByProbe } from "../worktree-path/worktree-path"; +import { getCurrentBranchName } from "../worktree-query/worktree-query"; +import { ARCHIVE_FILE_WATCHER, ARCHIVE_SESSION_CANCELLER } from "./identifiers"; +import type { ArchiveFileWatcher, SessionCanceller } from "./ports"; import type { ArchivedTask, ArchiveTaskInput } from "./schemas"; -const log = logger.scope("archive"); - type RollbackFn = () => Promise<void>; @injectable() export class ArchiveService { constructor( - @inject(MAIN_TOKENS.AgentService) - private readonly agentService: AgentService, - @inject(MAIN_TOKENS.ProcessTrackingService) + @inject(ARCHIVE_SESSION_CANCELLER) + private readonly sessionCanceller: SessionCanceller, + @inject(PROCESS_TRACKING_SERVICE) private readonly processTracking: ProcessTrackingService, - @inject(MAIN_TOKENS.FileWatcherService) - private readonly fileWatcher: FileWatcherBridge, - @inject(MAIN_TOKENS.RepositoryRepository) + @inject(ARCHIVE_FILE_WATCHER) + private readonly fileWatcher: ArchiveFileWatcher, + @inject(REPOSITORY_REPOSITORY) private readonly repositoryRepo: RepositoryRepository, - @inject(MAIN_TOKENS.WorkspaceRepository) + @inject(WORKSPACE_REPOSITORY) private readonly workspaceRepo: WorkspaceRepository, - @inject(MAIN_TOKENS.WorktreeRepository) + @inject(WORKTREE_REPOSITORY) private readonly worktreeRepo: WorktreeRepository, - @inject(MAIN_TOKENS.ArchiveRepository) + @inject(ARCHIVE_REPOSITORY) private readonly archiveRepo: ArchiveRepository, - @inject(MAIN_TOKENS.SuspensionRepository) + @inject(SUSPENSION_REPOSITORY) private readonly suspensionRepo: SuspensionRepository, - ) {} + @inject(WORKSPACE_SETTINGS_SERVICE) + private readonly workspaceSettings: IWorkspaceSettings, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, + ) { + this.log = workbenchLogger.scope("archive"); + } + + private readonly log: ScopedLogger; async archiveTask(input: ArchiveTaskInput): Promise<ArchivedTask> { - log.info(`Archiving task ${input.taskId}`); + this.log.info(`Archiving task ${input.taskId}`); const rollbacks: RollbackFn[] = []; const runWithRollback = async ( @@ -71,14 +92,14 @@ export class ArchiveService { try { const result = await this.executeArchive(input, runWithRollback); - log.info(`Task ${input.taskId} archived successfully`); + this.log.info(`Task ${input.taskId} archived successfully`); return result; } catch (error) { for (const rollback of rollbacks.reverse()) { try { await rollback(); } catch (rollbackError) { - log.error("Rollback failed:", rollbackError); + this.log.error("Rollback failed:", rollbackError); } } throw error; @@ -177,7 +198,7 @@ export class ArchiveService { const worktreePath = worktree.path; const worktreeIsValid = await isGitRepository(worktreePath).catch( (error) => { - log.warn( + this.log.warn( `Failed to check worktree at ${worktreePath}; treating as invalid`, { error }, ); @@ -186,7 +207,7 @@ export class ArchiveService { ); if (!worktreeIsValid) { - log.warn( + this.log.warn( `Worktree at ${worktreePath} is missing or not a git repository; skipping checkpoint capture`, ); archivedTask.checkpointId = null; @@ -218,7 +239,7 @@ export class ArchiveService { await step( async () => { - await this.agentService.cancelSessionsByTaskId(taskId); + await this.sessionCanceller.cancelSessionsByTaskId(taskId); this.processTracking.killByTaskId(taskId); await this.fileWatcher.stopWatching(worktreePath); }, @@ -229,7 +250,7 @@ export class ArchiveService { async () => { const manager = new WorktreeManager({ mainRepoPath: folderPath, - worktreeBasePath: getWorktreeLocation(), + worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), }); await manager.deleteWorktree(worktreePath); const parentDir = path.dirname(worktreePath); @@ -243,7 +264,7 @@ export class ArchiveService { if (workspace.mode !== "worktree") { await step( async () => { - await this.agentService.cancelSessionsByTaskId(taskId); + await this.sessionCanceller.cancelSessionsByTaskId(taskId); this.processTracking.killByTaskId(taskId); }, async () => {}, @@ -270,7 +291,7 @@ export class ArchiveService { taskId: string, recreateBranch?: boolean, ): Promise<{ taskId: string; worktreeName: string | null }> { - log.info( + this.log.info( `Unarchiving task ${taskId}${recreateBranch ? " (recreate branch)" : ""}`, ); @@ -289,14 +310,14 @@ export class ArchiveService { recreateBranch, runWithRollback, ); - log.info(`Task ${taskId} unarchived successfully`); + this.log.info(`Task ${taskId} unarchived successfully`); return result; } catch (error) { for (const rollback of rollbacks.reverse()) { try { await rollback(); } catch (rollbackError) { - log.error("Rollback failed:", rollbackError); + this.log.error("Rollback failed:", rollbackError); } } throw error; @@ -345,7 +366,7 @@ export class ArchiveService { if (restoredWorktreeName) { const manager = new WorktreeManager({ mainRepoPath: folderPath, - worktreeBasePath: getWorktreeLocation(), + worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), }); const worktreePath = await this.deriveWorktreePath( folderPath, @@ -424,7 +445,7 @@ export class ArchiveService { } async deleteArchivedTask(taskId: string): Promise<void> { - log.info(`Deleting archived task ${taskId}`); + this.log.info(`Deleting archived task ${taskId}`); const workspace = this.workspaceRepo.findByTaskId(taskId); if (!workspace) { @@ -443,7 +464,7 @@ export class ArchiveService { const git = createGitClient(repo.path); await deleteCheckpoint(git, archive.checkpointId); } catch (error) { - log.warn(`Failed to delete checkpoint ${archive.checkpointId}`, { + this.log.warn(`Failed to delete checkpoint ${archive.checkpointId}`, { error, }); } @@ -452,7 +473,7 @@ export class ArchiveService { this.archiveRepo.deleteByWorkspaceId(workspace.id); this.workspaceRepo.deleteByTaskId(taskId); - log.info(`Deleted archived task ${taskId}`); + this.log.info(`Deleted archived task ${taskId}`); } private toArchivedTask( @@ -471,58 +492,27 @@ export class ArchiveService { }; } - private async deriveWorktreePath( + private deriveWorktreePath( folderPath: string, worktreeName: string, ): Promise<string> { - const worktreeBasePath = getWorktreeLocation(); - const repoName = path.basename(folderPath); - - const newFormatPath = path.join(worktreeBasePath, worktreeName, repoName); - const legacyFormatPath = path.join( - worktreeBasePath, - repoName, + return resolveWorktreePathByProbe( + this.workspaceSettings.getWorktreeLocation(), + folderPath, worktreeName, ); - - try { - await fs.access(newFormatPath); - return newFormatPath; - } catch {} - - try { - await fs.access(legacyFormatPath); - return legacyFormatPath; - } catch {} - - return newFormatPath; } - private async getCurrentBranchName(worktreePath: string): Promise<string> { - const git = createGitClient(worktreePath); - try { - const branch = await git.revparse(["--abbrev-ref", "HEAD"]); - return branch.trim(); - } catch { - return ""; - } + private getCurrentBranchName(worktreePath: string): Promise<string> { + return getCurrentBranchName(worktreePath); } - private async captureWorktreeCheckpoint( + private captureWorktreeCheckpoint( folderPath: string, worktreePath: string, checkpointId: string, ): Promise<void> { - const git = createGitClient(folderPath); - try { - await deleteCheckpoint(git, checkpointId); - } catch {} - - const saga = new CaptureCheckpointSaga(); - const result = await saga.run({ baseDir: worktreePath, checkpointId }); - if (!result.success) { - throw new Error(`Failed to capture checkpoint: ${result.error}`); - } + return captureWorktreeCheckpoint(folderPath, worktreePath, checkpointId); } private async restoreWorktreeFromCheckpoint( @@ -531,47 +521,20 @@ export class ArchiveService { archive: Archive, recreateBranch?: boolean, ): Promise<string> { - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - const manager = new WorktreeManager({ - mainRepoPath: folderPath, - worktreeBasePath: getWorktreeLocation(), - }); - const preferredName = worktree?.name ?? undefined; - - let newWorktree: WorktreeInfo; - if (archive.branchName && !recreateBranch) { - newWorktree = await manager.createWorktreeForExistingBranch( - archive.branchName, - preferredName, - ); - } else { - newWorktree = await manager.createDetachedWorktreeAtCommit( - "HEAD", - preferredName, - ); - } - if (!archive.checkpointId) { throw new Error("checkpointId is required for restoring worktree"); } + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - const revertSaga = new RevertCheckpointSaga(); - const result = await revertSaga.run({ - baseDir: newWorktree.worktreePath, + const newWorktree = await restoreWorktreeFromCheckpoint({ + mainRepoPath: folderPath, + worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), + preferredName: worktree?.name ?? undefined, + branchName: archive.branchName, checkpointId: archive.checkpointId, + recreateBranch, }); - if (!result.success) { - throw new Error( - `Worktree restored but failed to apply checkpoint: ${result.error}`, - ); - } - - if (recreateBranch && archive.branchName) { - const git = createGitClient(newWorktree.worktreePath); - await git.checkoutLocalBranch(archive.branchName); - } - if (worktree) { this.worktreeRepo.deleteByWorkspaceId(workspace.id); } diff --git a/packages/workspace-server/src/services/archive/identifiers.ts b/packages/workspace-server/src/services/archive/identifiers.ts new file mode 100644 index 0000000000..e165694c1b --- /dev/null +++ b/packages/workspace-server/src/services/archive/identifiers.ts @@ -0,0 +1,7 @@ +export const ARCHIVE_SERVICE = Symbol.for("posthog.workspace.archiveService"); +export const ARCHIVE_SESSION_CANCELLER = Symbol.for( + "posthog.workspace.archiveSessionCanceller", +); +export const ARCHIVE_FILE_WATCHER = Symbol.for( + "posthog.workspace.archiveFileWatcher", +); diff --git a/packages/workspace-server/src/services/archive/ports.ts b/packages/workspace-server/src/services/archive/ports.ts new file mode 100644 index 0000000000..744c870c1d --- /dev/null +++ b/packages/workspace-server/src/services/archive/ports.ts @@ -0,0 +1,7 @@ +export interface SessionCanceller { + cancelSessionsByTaskId(taskId: string): Promise<void>; +} + +export interface ArchiveFileWatcher { + stopWatching(worktreePath: string): Promise<void>; +} diff --git a/apps/code/src/main/services/archive/schemas.ts b/packages/workspace-server/src/services/archive/schemas.ts similarity index 68% rename from apps/code/src/main/services/archive/schemas.ts rename to packages/workspace-server/src/services/archive/schemas.ts index 263129783a..b7447c9ef6 100644 --- a/apps/code/src/main/services/archive/schemas.ts +++ b/packages/workspace-server/src/services/archive/schemas.ts @@ -1,10 +1,16 @@ import { z } from "zod"; -import { - type ArchivedTask, - archivedTaskSchema, -} from "../../../shared/types/archive"; -export { archivedTaskSchema, type ArchivedTask }; +export const archivedTaskSchema = z.object({ + taskId: z.string(), + archivedAt: z.string(), + folderId: z.string(), + mode: z.enum(["worktree", "local", "cloud"]), + worktreeName: z.string().nullable(), + branchName: z.string().nullable(), + checkpointId: z.string().nullable(), +}); + +export type ArchivedTask = z.infer<typeof archivedTaskSchema>; export const archiveTaskInput = z.object({ taskId: z.string(), diff --git a/packages/workspace-server/src/services/auth-proxy/auth-proxy.module.ts b/packages/workspace-server/src/services/auth-proxy/auth-proxy.module.ts new file mode 100644 index 0000000000..36a4c034cb --- /dev/null +++ b/packages/workspace-server/src/services/auth-proxy/auth-proxy.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { AuthProxyService } from "./auth-proxy"; +import { AUTH_PROXY_SERVICE } from "./identifiers"; + +export const authProxyModule = new ContainerModule(({ bind }) => { + bind(AUTH_PROXY_SERVICE).to(AuthProxyService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/auth-proxy/service.ts b/packages/workspace-server/src/services/auth-proxy/auth-proxy.ts similarity index 86% rename from apps/code/src/main/services/auth-proxy/service.ts rename to packages/workspace-server/src/services/auth-proxy/auth-proxy.ts index 3896996cb2..045684b907 100644 --- a/apps/code/src/main/services/auth-proxy/service.ts +++ b/packages/workspace-server/src/services/auth-proxy/auth-proxy.ts @@ -1,21 +1,28 @@ import http from "node:http"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import type { AuthService } from "../auth/service"; - -const log = logger.scope("auth-proxy"); +import { AUTH_PROXY_AUTH } from "./identifiers"; +import type { AuthProxyAuth } from "./ports"; @injectable() export class AuthProxyService { private server: http.Server | null = null; private gatewayUrl: string | null = null; private port: number | null = null; + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, - ) {} + @inject(AUTH_PROXY_AUTH) + private readonly auth: AuthProxyAuth, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, + ) { + this.log = workbenchLogger.scope("auth-proxy"); + } async start(gatewayUrl: string): Promise<string> { if (this.server) { @@ -41,7 +48,7 @@ export class AuthProxyService { }); this.server?.on("error", (err) => { - log.error("Auth proxy server error", err); + this.log.error("Auth proxy server error", err); reject(err); }); }); @@ -63,7 +70,7 @@ export class AuthProxyService { return new Promise<void>((resolve) => { this.server?.close(() => { - log.info("Auth proxy stopped"); + this.log.info("Auth proxy stopped"); this.server = null; this.port = null; resolve(); @@ -107,7 +114,7 @@ export class AuthProxyService { const hasPathTraversal = targetUrl.pathname.includes(".."); if (!sameOrigin || hasPathTraversal) { - log.warn("Rejected proxy request with invalid target URL", { + this.log.warn("Rejected proxy request with invalid target URL", { method: req.method, incoming: req.url, target: targetUrl.toString(), @@ -160,11 +167,7 @@ export class AuthProxyService { res: http.ServerResponse, ): Promise<void> { try { - const response = await this.authService.authenticatedFetch( - fetch, - url, - options, - ); + const response = await this.auth.authenticatedFetch(url, options); const responseHeaders: Record<string, string> = {}; const stripHeaders = new Set([ @@ -200,7 +203,7 @@ export class AuthProxyService { await pump(); } catch (err) { - log.error("Proxy forward error", { url, err }); + this.log.error("Proxy forward error", { url, err }); if (!res.headersSent) { res.writeHead(502); } diff --git a/packages/workspace-server/src/services/auth-proxy/identifiers.ts b/packages/workspace-server/src/services/auth-proxy/identifiers.ts new file mode 100644 index 0000000000..389054c9d9 --- /dev/null +++ b/packages/workspace-server/src/services/auth-proxy/identifiers.ts @@ -0,0 +1,4 @@ +export const AUTH_PROXY_SERVICE = Symbol.for( + "posthog.workspace.authProxyService", +); +export const AUTH_PROXY_AUTH = Symbol.for("posthog.workspace.authProxyAuth"); diff --git a/packages/workspace-server/src/services/auth-proxy/ports.ts b/packages/workspace-server/src/services/auth-proxy/ports.ts new file mode 100644 index 0000000000..1897069f6c --- /dev/null +++ b/packages/workspace-server/src/services/auth-proxy/ports.ts @@ -0,0 +1,3 @@ +export interface AuthProxyAuth { + authenticatedFetch(url: string, init?: RequestInit): Promise<Response>; +} diff --git a/apps/code/src/main/services/connectivity/schemas.ts b/packages/workspace-server/src/services/connectivity/schemas.ts similarity index 100% rename from apps/code/src/main/services/connectivity/schemas.ts rename to packages/workspace-server/src/services/connectivity/schemas.ts diff --git a/apps/code/src/main/services/connectivity/service.test.ts b/packages/workspace-server/src/services/connectivity/service.test.ts similarity index 84% rename from apps/code/src/main/services/connectivity/service.test.ts rename to packages/workspace-server/src/services/connectivity/service.test.ts index e80d8d36ee..fa9ce859fb 100644 --- a/apps/code/src/main/services/connectivity/service.test.ts +++ b/packages/workspace-server/src/services/connectivity/service.test.ts @@ -1,50 +1,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ConnectivityEvent } from "./schemas"; +import { ConnectivityService } from "./service"; const mockFetch = vi.hoisted(() => vi.fn()); -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -import { ConnectivityService } from "./service"; - const ok = (status = 200) => ({ ok: true, status }); const notOk = (status = 500) => ({ ok: false, status }); -const offline = () => { - throw new Error("offline"); -}; describe("ConnectivityService", () => { - let service: ConnectivityService; + let service: ConnectivityService | undefined; beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); mockFetch.mockResolvedValue(ok()); vi.stubGlobal("fetch", mockFetch); - - service = new ConnectivityService(); }); afterEach(() => { - service.stopPolling(); + service?.stop(); + service = undefined; vi.useRealTimers(); vi.unstubAllGlobals(); }); - describe("init", () => { + describe("initial check", () => { it("goes online after a successful HEAD check", async () => { mockFetch.mockResolvedValue(ok(204)); - service.init(); + service = new ConnectivityService(); await vi.advanceTimersByTimeAsync(0); expect(service.getStatus()).toEqual({ isOnline: true }); @@ -55,9 +39,11 @@ describe("ConnectivityService", () => { }); it("goes offline when the HEAD check throws", async () => { - mockFetch.mockImplementation(offline); + mockFetch.mockImplementation(() => { + throw new Error("offline"); + }); - service.init(); + service = new ConnectivityService(); await vi.advanceTimersByTimeAsync(0); expect(service.getStatus()).toEqual({ isOnline: false }); @@ -67,7 +53,7 @@ describe("ConnectivityService", () => { describe("checkNow", () => { it("returns online when HEAD succeeds", async () => { mockFetch.mockResolvedValue(ok(204)); - service.init(); + service = new ConnectivityService(); await vi.advanceTimersByTimeAsync(0); const result = await service.checkNow(); @@ -76,7 +62,7 @@ describe("ConnectivityService", () => { it("returns offline when HEAD rejects", async () => { mockFetch.mockRejectedValue(new Error("Network error")); - service.init(); + service = new ConnectivityService(); await vi.advanceTimersByTimeAsync(0); const result = await service.checkNow(); @@ -85,7 +71,7 @@ describe("ConnectivityService", () => { it("returns offline when HEAD returns a non-ok non-204 response", async () => { mockFetch.mockResolvedValue(notOk(500)); - service.init(); + service = new ConnectivityService(); await vi.advanceTimersByTimeAsync(0); const result = await service.checkNow(); @@ -96,7 +82,7 @@ describe("ConnectivityService", () => { describe("status change events", () => { it("emits when going offline", async () => { mockFetch.mockResolvedValue(ok(204)); - service.init(); + service = new ConnectivityService(); await vi.advanceTimersByTimeAsync(0); const handler = vi.fn(); @@ -110,7 +96,7 @@ describe("ConnectivityService", () => { it("emits when coming back online", async () => { mockFetch.mockRejectedValue(new Error("offline")); - service.init(); + service = new ConnectivityService(); await vi.advanceTimersByTimeAsync(0); const handler = vi.fn(); @@ -124,7 +110,7 @@ describe("ConnectivityService", () => { it("does not emit when status is unchanged", async () => { mockFetch.mockResolvedValue(ok(204)); - service.init(); + service = new ConnectivityService(); await vi.advanceTimersByTimeAsync(0); const handler = vi.fn(); @@ -139,7 +125,7 @@ describe("ConnectivityService", () => { describe("HTTP verification", () => { it("accepts 204 status as success", async () => { mockFetch.mockResolvedValue({ ok: false, status: 204 }); - service.init(); + service = new ConnectivityService(); await vi.advanceTimersByTimeAsync(0); const result = await service.checkNow(); @@ -148,7 +134,7 @@ describe("ConnectivityService", () => { it("accepts 200 status as success", async () => { mockFetch.mockResolvedValue(ok(200)); - service.init(); + service = new ConnectivityService(); await vi.advanceTimersByTimeAsync(0); const result = await service.checkNow(); @@ -157,9 +143,9 @@ describe("ConnectivityService", () => { }); describe("polling", () => { - it("polls periodically after init", async () => { + it("polls periodically after construction", async () => { mockFetch.mockResolvedValue(ok(204)); - service.init(); + service = new ConnectivityService(); await vi.advanceTimersByTimeAsync(0); const callsAfterInit = mockFetch.mock.calls.length; diff --git a/apps/code/src/main/services/connectivity/service.ts b/packages/workspace-server/src/services/connectivity/service.ts similarity index 63% rename from apps/code/src/main/services/connectivity/service.ts rename to packages/workspace-server/src/services/connectivity/service.ts index 255d26eb51..cbad79459d 100644 --- a/apps/code/src/main/services/connectivity/service.ts +++ b/packages/workspace-server/src/services/connectivity/service.ts @@ -1,34 +1,27 @@ -import { getBackoffDelay } from "@shared/utils/backoff"; -import { injectable, postConstruct, preDestroy } from "inversify"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { TypedEventEmitter } from "@posthog/shared"; +import { injectable } from "inversify"; import { ConnectivityEvent, type ConnectivityEvents, type ConnectivityStatusOutput, } from "./schemas"; -const log = logger.scope("connectivity"); - const CHECK_URL = "https://www.google.com/generate_204"; const CHECK_TIMEOUT_MS = 5_000; const MIN_POLL_INTERVAL_MS = 3_000; const MAX_POLL_INTERVAL_MS = 10_000; const ONLINE_POLL_INTERVAL_MS = 3_000; +const OFFLINE_BACKOFF_MULTIPLIER = 1.5; @injectable() export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> { - private isOnline = false; + private isOnline = true; private pollTimeoutId: ReturnType<typeof setTimeout> | null = null; private offlinePollAttempt = 0; - @postConstruct() - init(): void { - // Assume online until the first check says otherwise, so dependent services - // don't needlessly queue offline-recovery work on boot. - this.isOnline = true; - log.info("Connectivity service starting (assumed online)"); - + constructor() { + super(); + this.setMaxListeners(0); void this.checkConnectivity(); this.startPolling(); } @@ -42,19 +35,28 @@ export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> { return { isOnline: this.isOnline }; } + stop(): void { + if (this.pollTimeoutId) { + clearTimeout(this.pollTimeoutId); + this.pollTimeoutId = null; + } + } + + statusChangeEvents( + signal: AbortSignal | undefined, + ): AsyncIterable<ConnectivityStatusOutput> { + return this.toIterable(ConnectivityEvent.StatusChange, { signal }); + } + private setOnline(online: boolean): void { if (this.isOnline === online) return; - this.isOnline = online; - log.info("Connectivity status changed", { isOnline: online }); this.emit(ConnectivityEvent.StatusChange, { isOnline: online }); - this.offlinePollAttempt = 0; } private async checkConnectivity(): Promise<void> { - const verified = await this.verifyWithHttp(); - this.setOnline(verified); + this.setOnline(await this.verifyWithHttp()); } private async verifyWithHttp(): Promise<boolean> { @@ -64,49 +66,35 @@ export class ConnectivityService extends TypedEventEmitter<ConnectivityEvents> { signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), }); return response.ok || response.status === 204; - } catch (error) { - log.debug("HTTP connectivity check failed", { error }); + } catch { return false; } } private startPolling(): void { if (this.pollTimeoutId) return; - this.offlinePollAttempt = 0; this.schedulePoll(); } private schedulePoll(): void { - // when online: just poll periodically - // when offline: poll more frequently with backoff to detect recovery const interval = this.isOnline ? ONLINE_POLL_INTERVAL_MS - : getBackoffDelay(this.offlinePollAttempt, { - initialDelayMs: MIN_POLL_INTERVAL_MS, - maxDelayMs: MAX_POLL_INTERVAL_MS, - multiplier: 1.5, - }); + : Math.min( + MIN_POLL_INTERVAL_MS * + OFFLINE_BACKOFF_MULTIPLIER ** this.offlinePollAttempt, + MAX_POLL_INTERVAL_MS, + ); this.pollTimeoutId = setTimeout(async () => { this.pollTimeoutId = null; - const wasOffline = !this.isOnline; await this.checkConnectivity(); - if (!this.isOnline && wasOffline) { this.offlinePollAttempt++; } - this.schedulePoll(); }, interval); - } - - @preDestroy() - stopPolling(): void { - if (this.pollTimeoutId) { - clearTimeout(this.pollTimeoutId); - this.pollTimeoutId = null; - } + this.pollTimeoutId.unref?.(); } } diff --git a/apps/code/src/main/services/enrichment/detectPosthogInstallState.test.ts b/packages/workspace-server/src/services/enrichment/detectPosthogInstallState.test.ts similarity index 85% rename from apps/code/src/main/services/enrichment/detectPosthogInstallState.test.ts rename to packages/workspace-server/src/services/enrichment/detectPosthogInstallState.test.ts index 2dde349fcd..91c390e01c 100644 --- a/apps/code/src/main/services/enrichment/detectPosthogInstallState.test.ts +++ b/packages/workspace-server/src/services/enrichment/detectPosthogInstallState.test.ts @@ -2,18 +2,35 @@ import { execSync } from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { makeLoggerMock } from "@test/loggerMock"; +import type { WorkbenchLogger } from "@posthog/di/logger"; +import { listFilesContainingText } from "@posthog/git/queries"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("../../utils/logger.js", () => makeLoggerMock()); - -import type { AuthService } from "../auth/service"; -import { EnrichmentService } from "./service"; - -const stubAuthService = { - getState: vi.fn(), +import { EnrichmentService } from "./enrichment"; +import type { EnrichmentAuth, EnrichmentFileReader } from "./ports"; + +const stubAuthService: EnrichmentAuth = { + getState: vi.fn(() => ({ + status: "unauthenticated", + projectId: null, + cloudRegion: null, + })), getValidAccessToken: vi.fn(), -} as unknown as AuthService; +}; + +const fileReader: EnrichmentFileReader = { + stat: (p) => fs.stat(p).then((s) => ({ size: s.size })), + readFile: (p) => fs.readFile(p, "utf-8"), + listFilesContainingText: (repoPath, text) => + listFilesContainingText(repoPath, text), +}; + +const noopLogger: WorkbenchLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + scope: () => noopLogger, +}; async function writeFile(repoRoot: string, relPath: string, content: string) { const abs = path.join(repoRoot, relPath); @@ -27,10 +44,8 @@ describe("EnrichmentService.detectPosthogInstallState", () => { beforeEach(async () => { tmp = await fs.mkdtemp(path.join(os.tmpdir(), "posthog-detect-")); - // listAllFiles uses `git ls-files` + `git ls-files -o` under the hood, so - // the repo needs to be a git checkout. execSync("git init -q", { cwd: tmp, stdio: "pipe" }); - service = new EnrichmentService(stubAuthService); + service = new EnrichmentService(stubAuthService, fileReader, noopLogger); }); afterEach(async () => { @@ -139,8 +154,6 @@ describe("EnrichmentService.detectPosthogInstallState", () => { dependencies: { "posthog-js": "^1.0.0" }, }), ); - // A package vendor file containing `posthog.init` should NOT promote the - // repo to "initialized" — only user code counts. await writeFile( tmp, "node_modules/some-other-pkg/dist/index.js", @@ -151,10 +164,6 @@ describe("EnrichmentService.detectPosthogInstallState", () => { ); }); - // Documents the v1 limitation: detection answers "is PostHog *used*?" - // (any capture / flag / init-with-literal call). A file with init but - // zero usage falls through to `installed_no_init`, which surfaces the - // "Finish wiring" suggestion — appropriate guidance for that state. it("treats init-only-with-env-var (no capture) as installed_no_init", async () => { await writeFile( tmp, @@ -183,7 +192,6 @@ describe("EnrichmentService.detectPosthogInstallState", () => { "package.json", JSON.stringify({ dependencies: { "posthog-js": "^1.0.0" } }), ); - // listAllFiles throws on non-git dirs; detection bails to not_installed. expect(await service.detectPosthogInstallState(nonGitDir)).toBe( "not_installed", ); diff --git a/packages/workspace-server/src/services/enrichment/enrichment.module.ts b/packages/workspace-server/src/services/enrichment/enrichment.module.ts new file mode 100644 index 0000000000..9e302ff6fc --- /dev/null +++ b/packages/workspace-server/src/services/enrichment/enrichment.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { EnrichmentService } from "./enrichment"; +import { ENRICHMENT_SERVICE } from "./identifiers"; + +export const enrichmentModule = new ContainerModule(({ bind }) => { + bind(ENRICHMENT_SERVICE).to(EnrichmentService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/enrichment/service.ts b/packages/workspace-server/src/services/enrichment/enrichment.ts similarity index 84% rename from apps/code/src/main/services/enrichment/service.ts rename to packages/workspace-server/src/services/enrichment/enrichment.ts index e859d2ecc2..2602267b38 100644 --- a/apps/code/src/main/services/enrichment/service.ts +++ b/packages/workspace-server/src/services/enrichment/enrichment.ts @@ -1,27 +1,28 @@ import { createHash } from "node:crypto"; -import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; import { EXT_TO_LANG_ID, enrichSource, + type ParseResult, PostHogApi, PostHogEnricher, type SerializedEnrichment, setLogger as setEnricherLogger, toSerializable, } from "@posthog/enricher"; -import { listFilesContainingText } from "@posthog/git/queries"; import { inject, injectable } from "inversify"; -import type { PosthogInstallState } from "../../../shared/types/posthog"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import type { AuthService } from "../auth/service"; - -const log = logger.scope("enrichment-service"); +import { ENRICHMENT_AUTH, ENRICHMENT_FILE_READER } from "./identifiers"; +import type { EnrichmentAuth, EnrichmentFileReader } from "./ports"; -setEnricherLogger({ - warn: (message, ...args) => log.warn(message, ...args), -}); +export type PosthogInstallState = + | "not_installed" + | "installed_no_init" + | "initialized"; const MAX_CACHE_ENTRIES = 200; const CACHE_TTL_MS = 10 * 60 * 1000; @@ -84,19 +85,12 @@ const STALE_FLAG_SUGGESTION_CAP = 4; const STALE_FLAG_REFERENCES_PER_FLAG = 5; const STALE_LOOKBACK_DAYS = 30; -// Tree-sitter parse() is synchronous and runs on the main process event -// loop. To keep IPC responsive we (1) yield after every file (not every -// batch), (2) skip files past a size threshold — they're almost always -// minified bundles or generated code where parsing buys nothing, and -// (3) cap total parsed files so a monorepo (e.g. PostHog itself) doesn't -// stall boot for tens of seconds. When the cap trips we fall back to -// manifest-only install detection rather than failing outright. const MAX_FILE_BYTES = 256 * 1024; const MAX_FILES_TO_PARSE = 500; interface ParsedRepoEntry { langId: string; - result: import("@posthog/enricher").ParseResult | null; + result: ParseResult | null; } interface ParsedRepoCacheEntry { @@ -136,11 +130,22 @@ export class EnrichmentService { string, Promise<ParsedRepoCacheEntry | null> >(); + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, - ) {} + @inject(ENRICHMENT_AUTH) + private readonly authService: EnrichmentAuth, + @inject(ENRICHMENT_FILE_READER) + private readonly files: EnrichmentFileReader, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + this.log = logger.scope("enrichment-service"); + setEnricherLogger({ + warn: (message: string, ...args: unknown[]) => + this.log.warn(message, ...args), + }); + } async enrichFile( input: EnrichFileInput, @@ -180,7 +185,7 @@ export class EnrichmentService { absolutePath, content, onDebug: (message: string, data?: Record<string, unknown>) => { - log.debug(message, { filePath, ...(data ?? {}) }); + this.log.debug(message, { filePath, ...(data ?? {}) }); }, }); @@ -216,7 +221,7 @@ export class EnrichmentService { projectId: state.projectId, }; } catch (err) { - log.debug("Failed to resolve access token", { + this.log.debug("Failed to resolve access token", { message: err instanceof Error ? err.message : String(err), }); return null; @@ -277,7 +282,7 @@ export class EnrichmentService { const api = new PostHogApi(apiConfig); lastCalled = await api.getFlagLastCalled(flagKeys, STALE_LOOKBACK_DAYS); } catch (err) { - log.debug("Failed to fetch flag-call timestamps", { + this.log.debug("Failed to fetch flag-call timestamps", { error: err instanceof Error ? err.message : String(err), }); return []; @@ -322,9 +327,12 @@ export class EnrichmentService { ): Promise<ParsedRepoCacheEntry | null> { let posthogFiles: string[]; try { - posthogFiles = await listFilesContainingText(repoPath, "posthog"); + posthogFiles = await this.files.listFilesContainingText( + repoPath, + "posthog", + ); } catch (err) { - log.debug("git grep failed during repo scan", { + this.log.debug("git grep failed during repo scan", { repoPath, error: err instanceof Error ? err.message : String(err), }); @@ -344,7 +352,7 @@ export class EnrichmentService { if (!langId || !enricher.isSupported(langId)) continue; toParse.push({ relPath, langId }); if (toParse.length >= MAX_FILES_TO_PARSE) { - log.info("Capping repo parse to keep main process responsive", { + this.log.info("Capping repo parse to keep main process responsive", { repoPath, totalCandidates: posthogFiles.length, parseLimit: MAX_FILES_TO_PARSE, @@ -354,15 +362,11 @@ export class EnrichmentService { } const files = new Map<string, ParsedRepoEntry>(); - // Serial with a yield after every file. Tree-sitter parse() is sync CPU - // on the event loop; batching with Promise.all stacked all parses in one - // synchronous burst between yields, which froze IPC. Per-file yields cap - // each blocking window at one file's parse cost. for (const candidate of toParse) { const absPath = path.join(repoPath, candidate.relPath); let content: string; try { - const stat = await fs.stat(absPath); + const stat = await this.files.stat(absPath); if (stat.size > MAX_FILE_BYTES) { files.set(candidate.relPath, { langId: candidate.langId, @@ -370,7 +374,7 @@ export class EnrichmentService { }); continue; } - content = await fs.readFile(absPath, "utf-8"); + content = await this.files.readFile(absPath); } catch { continue; } @@ -378,7 +382,7 @@ export class EnrichmentService { const result = await enricher.parse(content, candidate.langId); files.set(candidate.relPath, { langId: candidate.langId, result }); } catch (err) { - log.debug("enricher.parse threw during repo scan, skipping file", { + this.log.debug("enricher.parse threw during repo scan, skipping file", { file: candidate.relPath, error: err instanceof Error ? err.message : String(err), }); diff --git a/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts b/packages/workspace-server/src/services/enrichment/findStaleFlagSuggestions.test.ts similarity index 84% rename from apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts rename to packages/workspace-server/src/services/enrichment/findStaleFlagSuggestions.test.ts index 4b394f783e..f1df1833df 100644 --- a/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts +++ b/packages/workspace-server/src/services/enrichment/findStaleFlagSuggestions.test.ts @@ -2,18 +2,31 @@ import { execSync } from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { makeLoggerMock } from "@test/loggerMock"; +import type { WorkbenchLogger } from "@posthog/di/logger"; +import { listFilesContainingText } from "@posthog/git/queries"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("../../utils/logger.js", () => makeLoggerMock()); +import { EnrichmentService } from "./enrichment"; +import type { EnrichmentAuth, EnrichmentFileReader } from "./ports"; const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); -import type { AuthService } from "../auth/service"; -import { EnrichmentService } from "./service"; - -function authedStub(): AuthService { +const fileReader: EnrichmentFileReader = { + stat: (p) => fs.stat(p).then((s) => ({ size: s.size })), + readFile: (p) => fs.readFile(p, "utf-8"), + listFilesContainingText: (repoPath, text) => + listFilesContainingText(repoPath, text), +}; + +const noopLogger: WorkbenchLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + scope: () => noopLogger, +}; + +function authedStub(): EnrichmentAuth { return { getState: vi.fn(() => ({ status: "authenticated", @@ -24,14 +37,18 @@ function authedStub(): AuthService { accessToken: "token-x", apiHost: "https://us.posthog.com", })), - } as unknown as AuthService; + }; } -function unauthedStub(): AuthService { +function unauthedStub(): EnrichmentAuth { return { - getState: vi.fn(() => ({ status: "unauthenticated" })), + getState: vi.fn(() => ({ + status: "unauthenticated", + projectId: null, + cloudRegion: null, + })), getValidAccessToken: vi.fn(), - } as unknown as AuthService; + }; } async function writeFile(repoRoot: string, relPath: string, content: string) { @@ -57,7 +74,7 @@ describe("EnrichmentService.findStaleFlagSuggestions", () => { tmp = await fs.mkdtemp(path.join(os.tmpdir(), "posthog-stale-")); execSync("git init -q", { cwd: tmp, stdio: "pipe" }); mockFetch.mockReset(); - service = new EnrichmentService(authedStub()); + service = new EnrichmentService(authedStub(), fileReader, noopLogger); }); afterEach(async () => { @@ -67,7 +84,7 @@ describe("EnrichmentService.findStaleFlagSuggestions", () => { it("returns [] when not authenticated", async () => { service.dispose(); - service = new EnrichmentService(unauthedStub()); + service = new EnrichmentService(unauthedStub(), fileReader, noopLogger); const out = await service.findStaleFlagSuggestions(tmp); expect(out).toEqual([]); expect(mockFetch).not.toHaveBeenCalled(); diff --git a/packages/workspace-server/src/services/enrichment/identifiers.ts b/packages/workspace-server/src/services/enrichment/identifiers.ts new file mode 100644 index 0000000000..53f03a0b77 --- /dev/null +++ b/packages/workspace-server/src/services/enrichment/identifiers.ts @@ -0,0 +1,5 @@ +export const ENRICHMENT_SERVICE = Symbol.for("posthog.core.enrichmentService"); +export const ENRICHMENT_AUTH = Symbol.for("posthog.core.enrichmentAuth"); +export const ENRICHMENT_FILE_READER = Symbol.for( + "posthog.core.enrichmentFileReader", +); diff --git a/packages/workspace-server/src/services/enrichment/ports.ts b/packages/workspace-server/src/services/enrichment/ports.ts new file mode 100644 index 0000000000..6165eb1e84 --- /dev/null +++ b/packages/workspace-server/src/services/enrichment/ports.ts @@ -0,0 +1,21 @@ +export interface EnrichmentAuthState { + status: string; + projectId: number | null; + cloudRegion: string | null; +} + +export interface EnrichmentAccessToken { + accessToken: string; + apiHost: string; +} + +export interface EnrichmentAuth { + getState(): EnrichmentAuthState; + getValidAccessToken(): Promise<EnrichmentAccessToken>; +} + +export interface EnrichmentFileReader { + stat(path: string): Promise<{ size: number }>; + readFile(path: string): Promise<string>; + listFilesContainingText(repoPath: string, text: string): Promise<string[]>; +} diff --git a/apps/code/src/main/services/environment/schemas.ts b/packages/workspace-server/src/services/environment/schemas.ts similarity index 100% rename from apps/code/src/main/services/environment/schemas.ts rename to packages/workspace-server/src/services/environment/schemas.ts diff --git a/apps/code/src/main/services/environment/service.test.ts b/packages/workspace-server/src/services/environment/service.test.ts similarity index 100% rename from apps/code/src/main/services/environment/service.test.ts rename to packages/workspace-server/src/services/environment/service.test.ts diff --git a/apps/code/src/main/services/environment/service.ts b/packages/workspace-server/src/services/environment/service.ts similarity index 100% rename from apps/code/src/main/services/environment/service.ts rename to packages/workspace-server/src/services/environment/service.ts diff --git a/packages/workspace-server/src/services/external-apps/external-apps.module.ts b/packages/workspace-server/src/services/external-apps/external-apps.module.ts new file mode 100644 index 0000000000..ed4ffef19c --- /dev/null +++ b/packages/workspace-server/src/services/external-apps/external-apps.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { ExternalAppsService } from "./external-apps"; +import { EXTERNAL_APPS_SERVICE } from "./identifiers"; + +export const externalAppsModule = new ContainerModule(({ bind }) => { + bind(EXTERNAL_APPS_SERVICE).to(ExternalAppsService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/external-apps/service.ts b/packages/workspace-server/src/services/external-apps/external-apps.ts similarity index 93% rename from apps/code/src/main/services/external-apps/service.ts rename to packages/workspace-server/src/services/external-apps/external-apps.ts index 5ca6d89d3a..542348b8ee 100644 --- a/apps/code/src/main/services/external-apps/service.ts +++ b/packages/workspace-server/src/services/external-apps/external-apps.ts @@ -2,14 +2,16 @@ import { exec } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import type { IClipboard } from "@posthog/platform/clipboard"; -import type { IFileIcon } from "@posthog/platform/file-icon"; -import type { IStoragePaths } from "@posthog/platform/storage-paths"; -import type { DetectedApplication } from "@shared/types"; -import Store from "electron-store"; +import { + CLIPBOARD_SERVICE, + type IClipboard, +} from "@posthog/platform/clipboard"; +import { FILE_ICON_SERVICE, type IFileIcon } from "@posthog/platform/file-icon"; import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { AppDefinition, ExternalAppsSchema } from "./types"; +import { EXTERNAL_APPS_STORE } from "./identifiers"; +import type { ExternalAppsStore } from "./ports"; +import type { DetectedApplication } from "./schemas"; +import type { AppDefinition } from "./types"; const execAsync = promisify(exec); @@ -486,24 +488,15 @@ export class ExternalAppsService { private cachedApps: DetectedApplication[] | null = null; private detectionPromise: Promise<DetectedApplication[]> | null = null; - private prefsStore: Store<ExternalAppsSchema>; constructor( - @inject(MAIN_TOKENS.StoragePaths) - private readonly storagePaths: IStoragePaths, - @inject(MAIN_TOKENS.Clipboard) + @inject(CLIPBOARD_SERVICE) private readonly clipboard: IClipboard, - @inject(MAIN_TOKENS.FileIcon) + @inject(FILE_ICON_SERVICE) private readonly fileIcon: IFileIcon, - ) { - this.prefsStore = new Store<ExternalAppsSchema>({ - name: "external-apps", - cwd: this.storagePaths.appDataPath, - defaults: { - externalAppsPrefs: {}, - }, - }); - } + @inject(EXTERNAL_APPS_STORE) + private readonly store: ExternalAppsStore, + ) {} private async extractIcon(appPath: string): Promise<string | undefined> { const dataUrl = await this.fileIcon.getAsDataUrl(appPath); @@ -653,20 +646,16 @@ export class ExternalAppsService { } async setLastUsed(appId: string): Promise<void> { - const prefs = this.prefsStore.get("externalAppsPrefs"); - this.prefsStore.set("externalAppsPrefs", { ...prefs, lastUsedApp: appId }); + const prefs = this.store.getPrefs(); + this.store.setPrefs({ ...prefs, lastUsedApp: appId }); } async getLastUsed(): Promise<{ lastUsedApp?: string }> { - const prefs = this.prefsStore.get("externalAppsPrefs"); + const prefs = this.store.getPrefs(); return { lastUsedApp: prefs.lastUsedApp }; } async copyPath(targetPath: string): Promise<void> { await this.clipboard.writeText(targetPath); } - - getPrefsStore() { - return this.prefsStore; - } } diff --git a/packages/workspace-server/src/services/external-apps/identifiers.ts b/packages/workspace-server/src/services/external-apps/identifiers.ts new file mode 100644 index 0000000000..2138c198ed --- /dev/null +++ b/packages/workspace-server/src/services/external-apps/identifiers.ts @@ -0,0 +1,6 @@ +export const EXTERNAL_APPS_SERVICE = Symbol.for( + "posthog.workspace.externalAppsService", +); +export const EXTERNAL_APPS_STORE = Symbol.for( + "posthog.workspace.externalAppsStore", +); diff --git a/packages/workspace-server/src/services/external-apps/ports.ts b/packages/workspace-server/src/services/external-apps/ports.ts new file mode 100644 index 0000000000..45f3e08398 --- /dev/null +++ b/packages/workspace-server/src/services/external-apps/ports.ts @@ -0,0 +1,6 @@ +import type { ExternalAppsPreferences } from "./types"; + +export interface ExternalAppsStore { + getPrefs(): ExternalAppsPreferences; + setPrefs(prefs: ExternalAppsPreferences): void; +} diff --git a/apps/code/src/main/services/external-apps/schemas.ts b/packages/workspace-server/src/services/external-apps/schemas.ts similarity index 91% rename from apps/code/src/main/services/external-apps/schemas.ts rename to packages/workspace-server/src/services/external-apps/schemas.ts index 0e180df886..42f56cb84d 100644 --- a/apps/code/src/main/services/external-apps/schemas.ts +++ b/packages/workspace-server/src/services/external-apps/schemas.ts @@ -13,7 +13,7 @@ export const copyPathInput = z.object({ targetPath: z.string(), }); -const externalAppType = z.enum([ +export const externalAppType = z.enum([ "editor", "terminal", "file-manager", @@ -42,5 +42,6 @@ export type OpenInAppInput = z.infer<typeof openInAppInput>; export type SetLastUsedInput = z.infer<typeof setLastUsedInput>; export type CopyPathInput = z.infer<typeof copyPathInput>; export type DetectedApplication = z.infer<typeof detectedApplication>; +export type ExternalAppType = z.infer<typeof externalAppType>; export type OpenInAppOutput = z.infer<typeof openInAppOutput>; export type GetLastUsedOutput = z.infer<typeof getLastUsedOutput>; diff --git a/apps/code/src/main/services/external-apps/types.ts b/packages/workspace-server/src/services/external-apps/types.ts similarity index 84% rename from apps/code/src/main/services/external-apps/types.ts rename to packages/workspace-server/src/services/external-apps/types.ts index 59284f8054..609e32c3b6 100644 --- a/apps/code/src/main/services/external-apps/types.ts +++ b/packages/workspace-server/src/services/external-apps/types.ts @@ -1,4 +1,4 @@ -import type { ExternalAppType } from "@shared/types"; +import type { ExternalAppType } from "./schemas"; export interface AppDefinition { type: ExternalAppType; diff --git a/packages/workspace-server/src/services/focus/service.ts b/packages/workspace-server/src/services/focus/service.ts index ddc58a3241..bc86a8c84d 100644 --- a/packages/workspace-server/src/services/focus/service.ts +++ b/packages/workspace-server/src/services/focus/service.ts @@ -1,4 +1,3 @@ -import { EventEmitter, on } from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; import * as watcher from "@parcel/watcher"; @@ -16,6 +15,7 @@ import { StashPopSaga, StashPushSaga, } from "@posthog/git/sagas/stash"; +import { TypedEventEmitter } from "@posthog/shared"; import { injectable } from "inversify"; import type { FocusBranchRenamedEvent, @@ -35,24 +35,6 @@ type FocusServiceEvents = { [FocusServiceEvent.ForeignBranchCheckout]: FocusForeignBranchCheckoutEvent; }; -class TypedEventEmitter<TEvents> extends EventEmitter { - emit<K extends keyof TEvents & string>( - event: K, - payload: TEvents[K], - ): boolean { - return super.emit(event, payload); - } - - async *toIterable<K extends keyof TEvents & string>( - event: K, - opts?: { signal?: AbortSignal }, - ): AsyncIterable<TEvents[K]> { - for await (const [payload] of on(this, event, opts)) { - yield payload as TEvents[K]; - } - } -} - @injectable() export class FocusService extends TypedEventEmitter<FocusServiceEvents> { private watchedMainRepo: string | null = null; diff --git a/packages/workspace-server/src/services/folders/folders.module.ts b/packages/workspace-server/src/services/folders/folders.module.ts new file mode 100644 index 0000000000..ada228e269 --- /dev/null +++ b/packages/workspace-server/src/services/folders/folders.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { FoldersService } from "./folders"; +import { FOLDERS_SERVICE } from "./identifiers"; + +export const foldersModule = new ContainerModule(({ bind }) => { + bind(FOLDERS_SERVICE).to(FoldersService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/folders/service.test.ts b/packages/workspace-server/src/services/folders/folders.test.ts similarity index 93% rename from apps/code/src/main/services/folders/service.test.ts rename to packages/workspace-server/src/services/folders/folders.test.ts index 83e7be4b0e..5eb7b9245a 100644 --- a/apps/code/src/main/services/folders/service.test.ts +++ b/packages/workspace-server/src/services/folders/folders.test.ts @@ -55,17 +55,6 @@ vi.mock("@posthog/git/worktree", () => ({ }, })); -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - vi.mock("@posthog/git/queries", () => ({ isGitRepository: vi.fn(() => Promise.resolve(true)), getRemoteUrl: vi.fn(() => Promise.resolve(null)), @@ -77,28 +66,39 @@ vi.mock("@posthog/git/sagas/init", () => ({ }, })); -vi.mock("../settingsStore.js", () => ({ - getWorktreeLocation: vi.fn(() => "/tmp/worktrees"), -})); - -vi.mock("../../db/repositories/repository-repository.js", () => ({ - RepositoryRepository: vi.fn(() => mockRepositoryRepo), -})); - -vi.mock("../../db/repositories/workspace-repository.js", () => ({ - WorkspaceRepository: vi.fn(() => mockWorkspaceRepo), -})); - -vi.mock("../../db/repositories/worktree-repository.js", () => ({ - WorktreeRepository: vi.fn(() => mockWorktreeRepo), -})); - +import type { WorkbenchLogger } from "@posthog/di/logger"; import { getRemoteUrl, isGitRepository } from "@posthog/git/queries"; import type { IDialog } from "@posthog/platform/dialog"; +import type { IWorkspaceSettings } from "@posthog/platform/workspace-settings"; import type { IRepositoryRepository } from "../../db/repositories/repository-repository"; import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; import type { IWorktreeRepository } from "../../db/repositories/worktree-repository"; -import { FoldersService } from "./service"; +import { FoldersService } from "./folders"; + +const mockWorkspaceSettings = { + getWorktreeLocation: () => "/tmp/worktrees", +} as unknown as IWorkspaceSettings; +const scopedLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; +const mockLogger: WorkbenchLogger = { + ...scopedLogger, + scope: () => scopedLogger, +}; + +function createService(): FoldersService { + return new FoldersService( + mockRepositoryRepo as unknown as IRepositoryRepository, + mockWorkspaceRepo as unknown as IWorkspaceRepository, + mockWorktreeRepo as unknown as IWorktreeRepository, + mockDialog as unknown as IDialog, + mockWorkspaceSettings, + mockLogger, + ); +} describe("FoldersService", () => { let service: FoldersService; @@ -111,12 +111,7 @@ describe("FoldersService", () => { mockWorkspaceRepo.findAll.mockReturnValue([]); mockWorktreeRepo.findAll.mockReturnValue([]); - service = new FoldersService( - mockRepositoryRepo as unknown as IRepositoryRepository, - mockWorkspaceRepo as unknown as IWorkspaceRepository, - mockWorktreeRepo as unknown as IWorktreeRepository, - mockDialog as unknown as IDialog, - ); + service = createService(); }); afterEach(() => { @@ -124,15 +119,6 @@ describe("FoldersService", () => { }); describe("initialize", () => { - function createService() { - return new FoldersService( - mockRepositoryRepo as unknown as IRepositoryRepository, - mockWorkspaceRepo as unknown as IWorkspaceRepository, - mockWorktreeRepo as unknown as IWorktreeRepository, - mockDialog as unknown as IDialog, - ); - } - it("removes folders that no longer exist on disk", async () => { mockRepositoryRepo.findAll.mockReturnValue([ { diff --git a/apps/code/src/main/services/folders/service.ts b/packages/workspace-server/src/services/folders/folders.ts similarity index 82% rename from apps/code/src/main/services/folders/service.ts rename to packages/workspace-server/src/services/folders/folders.ts index ccf338e982..3c17c04629 100644 --- a/apps/code/src/main/services/folders/service.ts +++ b/packages/workspace-server/src/services/folders/folders.ts @@ -1,42 +1,61 @@ import fs from "node:fs"; import path from "node:path"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; import { getRemoteUrl, isGitRepository } from "@posthog/git/queries"; import { InitRepositorySaga } from "@posthog/git/sagas/init"; import { parseGithubUrl } from "@posthog/git/utils"; import { WorktreeManager } from "@posthog/git/worktree"; -import type { IDialog } from "@posthog/platform/dialog"; -import { normalizeRepoKey } from "@shared/utils/repo"; +import { DIALOG_SERVICE, type IDialog } from "@posthog/platform/dialog"; +import { + type IWorkspaceSettings, + WORKSPACE_SETTINGS_SERVICE, +} from "@posthog/platform/workspace-settings"; import { inject, injectable } from "inversify"; +import { + REPOSITORY_REPOSITORY, + WORKSPACE_REPOSITORY, + WORKTREE_REPOSITORY, +} from "../../db/identifiers"; import type { IRepositoryRepository, Repository, } from "../../db/repositories/repository-repository"; import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; import type { IWorktreeRepository } from "../../db/repositories/worktree-repository"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { getWorktreeLocation } from "../settingsStore"; import type { RegisteredFolder } from "./schemas"; -const log = logger.scope("folders-service"); +function normalizeRepoKey(key: string): string { + return key.trim().replace(/\.git$/, ""); +} @injectable() export class FoldersService { constructor( - @inject(MAIN_TOKENS.RepositoryRepository) + @inject(REPOSITORY_REPOSITORY) private readonly repositoryRepo: IRepositoryRepository, - @inject(MAIN_TOKENS.WorkspaceRepository) + @inject(WORKSPACE_REPOSITORY) private readonly workspaceRepo: IWorkspaceRepository, - @inject(MAIN_TOKENS.WorktreeRepository) + @inject(WORKTREE_REPOSITORY) private readonly worktreeRepo: IWorktreeRepository, - @inject(MAIN_TOKENS.Dialog) + @inject(DIALOG_SERVICE) private readonly dialog: IDialog, + @inject(WORKSPACE_SETTINGS_SERVICE) + private readonly workspaceSettings: IWorkspaceSettings, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, ) { + this.log = workbenchLogger.scope("folders-service"); this.initialize().catch((err) => { - log.error("Folders initialization failed", err); + this.log.error("Folders initialization failed", err); }); } + private readonly log: ScopedLogger; + private async initialize(): Promise<void> { const folders = await this.getFolders(); @@ -48,11 +67,14 @@ export class FoldersService { await this.removeFolder(folder.id); removed++; } catch (err) { - log.error(`Failed to remove deleted folder ${folder.path}:`, err); + this.log.error( + `Failed to remove deleted folder ${folder.path}:`, + err, + ); } } if (removed > 0) { - log.info(`Removed ${removed} deleted folder(s)`); + this.log.info(`Removed ${removed} deleted folder(s)`); } } @@ -64,7 +86,7 @@ export class FoldersService { ); for (const [i, result] of results.entries()) { if (result.status === "rejected") { - log.error( + this.log.error( `Failed to cleanup orphaned worktrees for ${existingFolders[i].path}:`, result.reason, ); @@ -185,12 +207,12 @@ export class FoldersService { async removeFolder(folderId: string): Promise<void> { const repo = this.repositoryRepo.findById(folderId); if (!repo) { - log.debug(`Folder not found: ${folderId}`); + this.log.debug(`Folder not found: ${folderId}`); return; } const workspaces = this.workspaceRepo.findAllByRepositoryId(folderId); - const worktreeBasePath = getWorktreeLocation(); + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); const repoName = path.basename(repo.path); for (const workspace of workspaces) { @@ -209,14 +231,14 @@ export class FoldersService { }); await manager.deleteWorktree(worktreePath); } catch (error) { - log.error(`Failed to delete worktree ${worktreePath}:`, error); + this.log.error(`Failed to delete worktree ${worktreePath}:`, error); } } } } this.repositoryRepo.delete(folderId); - log.debug(`Removed folder with ID: ${folderId}`); + this.log.debug(`Removed folder with ID: ${folderId}`); } async updateFolderAccessed(folderId: string): Promise<void> { @@ -224,7 +246,7 @@ export class FoldersService { } async cleanupOrphanedWorktrees(mainRepoPath: string): Promise<void> { - const worktreeBasePath = getWorktreeLocation(); + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); const manager = new WorktreeManager({ mainRepoPath, worktreeBasePath }); const allWorktrees = this.worktreeRepo.findAll(); @@ -264,7 +286,7 @@ export class FoldersService { async clearAllData(): Promise<void> { const workspaces = this.workspaceRepo.findAll(); - const worktreeBasePath = getWorktreeLocation(); + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); for (const workspace of workspaces) { if (workspace.mode === "worktree" && workspace.repositoryId) { @@ -278,7 +300,10 @@ export class FoldersService { }); await manager.deleteWorktree(worktree.path); } catch (error) { - log.error(`Failed to delete worktree ${worktree.path}:`, error); + this.log.error( + `Failed to delete worktree ${worktree.path}:`, + error, + ); } } } @@ -288,6 +313,6 @@ export class FoldersService { this.workspaceRepo.deleteAll(); this.repositoryRepo.deleteAll(); - log.info("Cleared all application data"); + this.log.info("Cleared all application data"); } } diff --git a/packages/workspace-server/src/services/folders/identifiers.ts b/packages/workspace-server/src/services/folders/identifiers.ts new file mode 100644 index 0000000000..67ed405896 --- /dev/null +++ b/packages/workspace-server/src/services/folders/identifiers.ts @@ -0,0 +1 @@ +export const FOLDERS_SERVICE = Symbol.for("posthog.workspace.foldersService"); diff --git a/apps/code/src/main/services/folders/schemas.ts b/packages/workspace-server/src/services/folders/schemas.ts similarity index 100% rename from apps/code/src/main/services/folders/schemas.ts rename to packages/workspace-server/src/services/folders/schemas.ts diff --git a/packages/workspace-server/src/services/fs/identifiers.ts b/packages/workspace-server/src/services/fs/identifiers.ts new file mode 100644 index 0000000000..a5ff95b891 --- /dev/null +++ b/packages/workspace-server/src/services/fs/identifiers.ts @@ -0,0 +1,33 @@ +import type { BoundedReadResult, FileEntry } from "./schemas"; + +export const FS_SERVICE = Symbol.for("posthog.workspace.fsService"); + +export interface FsCapability { + listRepoFiles( + repoPath: string, + query?: string, + limit?: number, + ): Promise<FileEntry[]>; + readRepoFile(repoPath: string, filePath: string): Promise<string | null>; + readRepoFiles( + repoPath: string, + filePaths: string[], + ): Promise<Record<string, string | null>>; + readRepoFileBounded( + repoPath: string, + filePath: string, + maxLines: number, + ): Promise<BoundedReadResult>; + readRepoFilesBounded( + repoPath: string, + filePaths: string[], + maxLines: number, + ): Promise<Record<string, BoundedReadResult>>; + readAbsoluteFile(filePath: string): Promise<string | null>; + readFileAsBase64(filePath: string): Promise<string | null>; + writeRepoFile( + repoPath: string, + filePath: string, + content: string, + ): Promise<void>; +} diff --git a/packages/workspace-server/src/services/fs/schemas.ts b/packages/workspace-server/src/services/fs/schemas.ts index 7301e6d9f7..acbdea4197 100644 --- a/packages/workspace-server/src/services/fs/schemas.ts +++ b/packages/workspace-server/src/services/fs/schemas.ts @@ -10,3 +10,73 @@ export type DirectoryEntry = z.infer<typeof directoryEntrySchema>; export const listDirectoryInput = z.object({ dirPath: z.string().min(1) }); export const listDirectoryOutput = z.array(directoryEntrySchema); + +export const listRepoFilesInput = z.object({ + repoPath: z.string(), + query: z.string().optional(), + limit: z.number().optional(), +}); + +export const readRepoFileInput = z.object({ + repoPath: z.string(), + filePath: z.string(), +}); + +export const readRepoFilesInput = z.object({ + repoPath: z.string(), + filePaths: z.array(z.string()), +}); + +export const readRepoFileBoundedInput = z.object({ + repoPath: z.string(), + filePath: z.string(), + maxLines: z.number().int().positive(), +}); + +export const readRepoFilesBoundedInput = z.object({ + repoPath: z.string(), + filePaths: z.array(z.string()), + maxLines: z.number().int().positive(), +}); + +export const boundedReadResult = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("content"), content: z.string() }), + z.object({ kind: z.literal("missing") }), + z.object({ kind: z.literal("too-large") }), +]); + +export const readRepoFilesBoundedOutput = z.record( + z.string(), + boundedReadResult, +); + +export const readAbsoluteFileInput = z.object({ + filePath: z.string(), +}); + +export const writeRepoFileInput = z.object({ + repoPath: z.string(), + filePath: z.string(), + content: z.string(), +}); + +export const fileEntryKind = z.enum(["file", "directory"]); + +const fileEntry = z.object({ + path: z.string(), + name: z.string(), + kind: fileEntryKind.default("file"), + changed: z.boolean().optional(), +}); + +export const listRepoFilesOutput = z.array(fileEntry); +export const readRepoFileOutput = z.string().nullable(); +export const readRepoFilesOutput = z.record(z.string(), readRepoFileOutput); + +export type ListRepoFilesInput = z.infer<typeof listRepoFilesInput>; +export type ReadRepoFileInput = z.infer<typeof readRepoFileInput>; +export type ReadRepoFilesInput = z.infer<typeof readRepoFilesInput>; +export type WriteRepoFileInput = z.infer<typeof writeRepoFileInput>; +export type FileEntry = z.infer<typeof fileEntry>; +export type FileEntryKind = z.infer<typeof fileEntryKind>; +export type BoundedReadResult = z.infer<typeof boundedReadResult>; diff --git a/packages/workspace-server/src/services/fs/service.test.ts b/packages/workspace-server/src/services/fs/service.test.ts new file mode 100644 index 0000000000..ae7cd89a32 --- /dev/null +++ b/packages/workspace-server/src/services/fs/service.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@posthog/git/queries", () => ({ + getChangedFiles: vi.fn(async () => new Set<string>()), + listAllFiles: vi.fn(async () => []), +})); + +import { getChangedFiles, listAllFiles } from "@posthog/git/queries"; +import { FsService } from "./service"; + +describe("FsService.listRepoFiles", () => { + it("derives directory entries alongside files", async () => { + vi.mocked(getChangedFiles).mockResolvedValue(new Set()); + vi.mocked(listAllFiles).mockResolvedValue([ + "a.ts", + "src/b.ts", + "src/sub/c.ts", + ]); + + const service = new FsService(); + const entries = await service.listRepoFiles("/repo"); + + const dirs = entries + .filter((e) => e.kind === "directory") + .map((e) => e.path); + const files = entries.filter((e) => e.kind === "file").map((e) => e.path); + + expect(dirs).toEqual(["src", "src/sub"]); + expect(files).toEqual(["a.ts", "src/b.ts", "src/sub/c.ts"]); + }); + + it("filters directories and files by query substring", async () => { + vi.mocked(getChangedFiles).mockResolvedValue(new Set()); + vi.mocked(listAllFiles).mockResolvedValue([ + "a.ts", + "src/b.ts", + "src/sub/c.ts", + ]); + + const service = new FsService(); + const entries = await service.listRepoFiles("/repo", "sub"); + + expect(entries.map((e) => ({ path: e.path, kind: e.kind }))).toEqual([ + { path: "src/sub", kind: "directory" }, + { path: "src/sub/c.ts", kind: "file" }, + ]); + }); +}); + +describe("FsService repo file IO", () => { + let repo: string; + const service = new FsService(); + + beforeEach(async () => { + repo = await mkdtemp(path.join(tmpdir(), "fs-service-test-")); + }); + + afterEach(async () => { + await rm(repo, { recursive: true, force: true }); + }); + + it("writes a repo file and reads it back", async () => { + await service.writeRepoFile(repo, "file.txt", "hello"); + + expect(await service.readRepoFile(repo, "file.txt")).toBe("hello"); + expect(await readFile(path.join(repo, "file.txt"), "utf-8")).toBe("hello"); + }); + + it("returns null reading a missing file", async () => { + expect(await service.readRepoFile(repo, "nope.txt")).toBeNull(); + }); + + it("refuses to read outside the repository", async () => { + await expect( + service.readRepoFile(repo, "../escape.txt"), + ).resolves.toBeNull(); + await expect( + service.writeRepoFile(repo, "../escape.txt", "x"), + ).rejects.toThrow(/Access denied/); + }); + + it("bounds reads by line count", async () => { + await service.writeRepoFile(repo, "small.txt", "a\nb\nc"); + await service.writeRepoFile(repo, "big.txt", "a\nb\nc\nd\ne"); + + expect(await service.readRepoFileBounded(repo, "small.txt", 5)).toEqual({ + kind: "content", + content: "a\nb\nc", + }); + expect(await service.readRepoFileBounded(repo, "big.txt", 3)).toEqual({ + kind: "too-large", + }); + expect(await service.readRepoFileBounded(repo, "missing.txt", 3)).toEqual({ + kind: "missing", + }); + }); +}); diff --git a/packages/workspace-server/src/services/fs/service.ts b/packages/workspace-server/src/services/fs/service.ts index ef303beea1..251109bc80 100644 --- a/packages/workspace-server/src/services/fs/service.ts +++ b/packages/workspace-server/src/services/fs/service.ts @@ -1,10 +1,15 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { getChangedFiles, listAllFiles } from "@posthog/git/queries"; import { injectable } from "inversify"; -import type { DirectoryEntry } from "./schemas"; +import type { BoundedReadResult, DirectoryEntry, FileEntry } from "./schemas"; @injectable() export class FsService { + private static readonly CACHE_TTL = 30000; + private static readonly READ_REPO_FILES_CONCURRENCY = 24; + private cache = new Map<string, { files: FileEntry[]; timestamp: number }>(); + async listDirectory(dirPath: string): Promise<DirectoryEntry[]> { try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); @@ -26,4 +31,245 @@ export class FsService { return []; } } + + async listRepoFiles( + repoPath: string, + query?: string, + limit?: number, + ): Promise<FileEntry[]> { + if (!repoPath) return []; + + try { + const changedFiles = await getChangedFiles(repoPath); + + if (query?.trim()) { + const allFiles = await listAllFiles(repoPath); + const directories = this.deriveDirectories(allFiles); + const lowerQuery = query.toLowerCase(); + const matchingDirs = directories.filter((d) => + d.toLowerCase().includes(lowerQuery), + ); + const matchingFiles = allFiles.filter((f) => + f.toLowerCase().includes(lowerQuery), + ); + const entries = [ + ...this.toDirectoryEntries(matchingDirs), + ...this.toFileEntries(matchingFiles, changedFiles), + ]; + return limit ? entries.slice(0, limit) : entries; + } + + const cached = this.cache.get(repoPath); + if (cached && Date.now() - cached.timestamp < FsService.CACHE_TTL) { + return limit ? cached.files.slice(0, limit) : cached.files; + } + + const files = await listAllFiles(repoPath); + const directories = this.deriveDirectories(files); + const entries = [ + ...this.toDirectoryEntries(directories), + ...this.toFileEntries(files, changedFiles), + ]; + this.cache.set(repoPath, { files: entries, timestamp: Date.now() }); + + return limit ? entries.slice(0, limit) : entries; + } catch { + return []; + } + } + + invalidateCache(repoPath?: string): void { + if (repoPath) { + this.cache.delete(repoPath); + } else { + this.cache.clear(); + } + } + + async readRepoFile( + repoPath: string, + filePath: string, + ): Promise<string | null> { + try { + return await fs.readFile(this.resolvePath(repoPath, filePath), "utf-8"); + } catch { + return null; + } + } + + async readRepoFiles( + repoPath: string, + filePaths: string[], + ): Promise<Record<string, string | null>> { + const uniqueFilePaths = [...new Set(filePaths)]; + const entries = await this.mapWithConcurrency( + uniqueFilePaths, + FsService.READ_REPO_FILES_CONCURRENCY, + async (filePath) => + [filePath, await this.readRepoFile(repoPath, filePath)] as const, + ); + return Object.fromEntries(entries); + } + + async readRepoFileBounded( + repoPath: string, + filePath: string, + maxLines: number, + ): Promise<BoundedReadResult> { + try { + const content = await fs.readFile( + this.resolvePath(repoPath, filePath), + "utf-8", + ); + if (exceedsLineLimit(content, maxLines)) { + return { kind: "too-large" }; + } + return { kind: "content", content }; + } catch { + return { kind: "missing" }; + } + } + + async readRepoFilesBounded( + repoPath: string, + filePaths: string[], + maxLines: number, + ): Promise<Record<string, BoundedReadResult>> { + const uniqueFilePaths = [...new Set(filePaths)]; + const entries = await this.mapWithConcurrency( + uniqueFilePaths, + FsService.READ_REPO_FILES_CONCURRENCY, + async (filePath) => + [ + filePath, + await this.readRepoFileBounded(repoPath, filePath, maxLines), + ] as const, + ); + return Object.fromEntries(entries); + } + + async readAbsoluteFile(filePath: string): Promise<string | null> { + try { + return await fs.readFile(path.resolve(filePath), "utf-8"); + } catch { + return null; + } + } + + async readFileAsBase64(filePath: string): Promise<string | null> { + const resolved = path.resolve(filePath); + try { + const buffer = await fs.readFile(resolved); + return buffer.toString("base64"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + return null; + } + const dir = path.dirname(resolved); + const basename = path.basename(resolved); + try { + const files = await fs.readdir(dir); + const normalizeSpaces = (s: string) => s.replace(/[\s  ]/g, " "); + const normalizedTarget = normalizeSpaces(basename); + const match = files.find( + (f) => normalizeSpaces(f) === normalizedTarget, + ); + if (match) { + const buffer = await fs.readFile(path.join(dir, match)); + return buffer.toString("base64"); + } + } catch {} + return null; + } + } + + async writeRepoFile( + repoPath: string, + filePath: string, + content: string, + ): Promise<void> { + await fs.writeFile(this.resolvePath(repoPath, filePath), content, "utf-8"); + this.invalidateCache(repoPath); + } + + private resolvePath(repoPath: string, filePath: string): string { + const base = path.resolve(repoPath); + const resolved = path.resolve(base, filePath); + if (resolved !== base && !resolved.startsWith(base + path.sep)) { + throw new Error("Access denied: path outside repository"); + } + return resolved; + } + + private toFileEntries( + files: string[], + changedFiles: Set<string>, + ): FileEntry[] { + return files.map((p) => ({ + path: p, + name: path.basename(p), + kind: "file", + changed: changedFiles.has(p), + })); + } + + private toDirectoryEntries(directories: string[]): FileEntry[] { + return directories.map((p) => ({ + path: p, + name: path.basename(p), + kind: "directory", + })); + } + + private deriveDirectories(files: string[]): string[] { + const dirs = new Set<string>(); + for (const file of files) { + let parent = path.posix.dirname(file); + while (parent && parent !== "." && parent !== "/") { + if (dirs.has(parent)) break; + dirs.add(parent); + parent = path.posix.dirname(parent); + } + } + return Array.from(dirs).sort(); + } + + private async mapWithConcurrency<T, R>( + items: readonly T[], + concurrency: number, + mapper: (item: T) => Promise<R>, + ): Promise<R[]> { + if (items.length === 0) return []; + + const results = new Array<R>(items.length); + let index = 0; + + const worker = async () => { + while (index < items.length) { + const currentIndex = index++; + results[currentIndex] = await mapper(items[currentIndex]); + } + }; + + await Promise.all( + Array.from({ length: Math.min(concurrency, items.length) }, () => + worker(), + ), + ); + + return results; + } +} + +function exceedsLineLimit(content: string, maxLines: number): boolean { + let lineCount = 1; + for (let i = 0; i < content.length; i++) { + if (content.charCodeAt(i) === 10) { + lineCount++; + if (lineCount > maxLines) { + return true; + } + } + } + return false; } diff --git a/packages/workspace-server/src/services/git/git.integration.test.ts b/packages/workspace-server/src/services/git/git.integration.test.ts new file mode 100644 index 0000000000..b4b4d21391 --- /dev/null +++ b/packages/workspace-server/src/services/git/git.integration.test.ts @@ -0,0 +1,304 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { GitService } from "./service"; + +function run(cmd: string, cwd: string): void { + execSync(cmd, { cwd, stdio: "pipe" }); +} + +async function createTempGitRepo(remoteUrl?: string): Promise<string> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "git-it-")); + run("git init -b main", dir); + run("git config user.email 'test@test.com'", dir); + run("git config user.name 'Test'", dir); + run("git config commit.gpgsign false", dir); + if (remoteUrl) { + run(`git remote add origin ${remoteUrl}`, dir); + } + await fs.writeFile(path.join(dir, "README.md"), "# Test Repo\n"); + run("git add .", dir); + run("git commit -m 'Initial commit'", dir); + return dir; +} + +async function createBareRemote(): Promise<string> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "git-bare-")); + run("git init --bare -b main", dir); + return dir; +} + +function commitAll(repoDir: string, message: string): void { + execSync( + `git -C ${repoDir} add . && git -C ${repoDir} commit -m '${message}'`, + { stdio: "pipe" }, + ); +} + +describe("GitService integration (git-read + git-mutate)", () => { + let git: GitService; + let repo: string; + const dirs: string[] = []; + + beforeEach(async () => { + git = new GitService(); + repo = await createTempGitRepo(); + dirs.push(repo); + }); + + afterEach(async () => { + await Promise.all( + dirs.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })), + ); + }); + + describe("validateRepo", () => { + it("is true inside a git repo", async () => { + expect(await git.validateRepo(repo)).toBe(true); + }); + + it("is false for a non-repo directory", async () => { + const plain = await fs.mkdtemp(path.join(os.tmpdir(), "git-it-plain-")); + dirs.push(plain); + expect(await git.validateRepo(plain)).toBe(false); + }); + + it("is false for an empty path", async () => { + expect(await git.validateRepo("")).toBe(false); + }); + }); + + describe("read ops", () => { + it("getCurrentBranch returns the checked-out branch", async () => { + expect(await git.getCurrentBranch(repo)).toBe("main"); + }); + + it("getDefaultBranch resolves to main offline", async () => { + expect(await git.getDefaultBranch(repo)).toBe("main"); + }); + + it("getLatestCommit returns the initial commit", async () => { + const commit = await git.getLatestCommit(repo); + expect(commit?.message).toBe("Initial commit"); + }); + + it("getFileAtHead returns committed content", async () => { + expect(await git.getFileAtHead(repo, "README.md")).toBe("# Test Repo\n"); + }); + + it("getGitBusyState is not busy on a clean repo", async () => { + expect(await git.getGitBusyState(repo)).toEqual({ busy: false }); + }); + + it("getGitSyncStatus reports no remote", async () => { + const status = await git.getGitSyncStatus(repo); + expect(status.hasRemote).toBe(false); + }); + }); + + describe("detectRepo / getGitRepoInfo (github remote, offline)", () => { + it("detectRepo parses org + repo from the remote", async () => { + const remoteRepo = await createTempGitRepo( + "https://github.com/posthog/posthog.git", + ); + dirs.push(remoteRepo); + + const result = await git.detectRepo(remoteRepo); + expect(result).toMatchObject({ + organization: "posthog", + repository: "posthog", + branch: "main", + }); + }); + + it("getGitRepoInfo parses org + repo from the remote", async () => { + const remoteRepo = await createTempGitRepo( + "https://github.com/posthog/posthog.git", + ); + dirs.push(remoteRepo); + + const info = await git.getGitRepoInfo(remoteRepo); + expect(info).toMatchObject({ + organization: "posthog", + repository: "posthog", + currentBranch: "main", + defaultBranch: "main", + }); + }); + }); + + describe("branch mutation", () => { + it("createBranch creates and switches to the new branch", async () => { + await git.createBranch(repo, "feature"); + expect(await git.getAllBranches(repo)).toContain("feature"); + expect(await git.getCurrentBranch(repo)).toBe("feature"); + }); + + it("checkoutBranch switches back and reports the previous branch", async () => { + await git.createBranch(repo, "feature"); + + const result = await git.checkoutBranch(repo, "main"); + expect(result).toEqual({ + previousBranch: "feature", + currentBranch: "main", + }); + expect(await git.getCurrentBranch(repo)).toBe("main"); + }); + }); + + describe("staging mutation", () => { + it("getChangedFilesHead lists a new untracked file", async () => { + await fs.writeFile(path.join(repo, "new.txt"), "hello\n"); + const files = await git.getChangedFilesHead(repo); + expect(files.map((f) => f.path)).toContain("new.txt"); + }); + + it("stageFiles marks the file staged in the returned snapshot", async () => { + await fs.writeFile(path.join(repo, "new.txt"), "hello\n"); + const snapshot = await git.stageFiles(repo, ["new.txt"]); + const staged = snapshot.changedFiles?.find((f) => f.path === "new.txt"); + expect(staged?.staged).toBe(true); + }); + + it("unstageFiles clears the staged flag", async () => { + await fs.writeFile(path.join(repo, "new.txt"), "hello\n"); + await git.stageFiles(repo, ["new.txt"]); + const snapshot = await git.unstageFiles(repo, ["new.txt"]); + const entry = snapshot.changedFiles?.find((f) => f.path === "new.txt"); + expect(entry).toBeDefined(); + expect(entry?.staged).toBeFalsy(); + }); + }); + + describe("commit", () => { + it("commits staged changes and reports the sha and branch", async () => { + await fs.writeFile(path.join(repo, "feature.txt"), "feature\n"); + await git.stageFiles(repo, ["feature.txt"]); + + const result = await git.commit(repo, "add feature"); + + expect(result.success).toBe(true); + expect(result.commitSha).toMatch(/^[0-9a-f]{7,}$/); + expect(result.branch).toBe("main"); + // The file is committed -> no longer a working-tree change against HEAD. + const files = await git.getChangedFilesHead(repo); + expect(files.map((f) => f.path)).not.toContain("feature.txt"); + }); + + it("rejects an empty commit message", async () => { + const result = await git.commit(repo, " "); + expect(result.success).toBe(false); + expect(result.message).toMatch(/message is required/i); + expect(result.commitSha).toBeNull(); + }); + + it("threads a passed env through without breaking the commit", async () => { + await fs.writeFile(path.join(repo, "env.txt"), "env\n"); + await git.stageFiles(repo, ["env.txt"]); + + const result = await git.commit(repo, "with env", { + env: { POSTHOG_TEST_ENV: "1" }, + }); + + expect(result.success).toBe(true); + expect(result.commitSha).toBeTruthy(); + }); + }); + + describe("diff ops", () => { + it("getDiffUnstaged includes the working-tree change", async () => { + await fs.writeFile(path.join(repo, "README.md"), "# Test Repo\nmore\n"); + const diff = await git.getDiffUnstaged(repo); + expect(diff).toContain("more"); + }); + + it("getDiffCached includes staged changes", async () => { + await fs.writeFile(path.join(repo, "README.md"), "# Test Repo\nstaged\n"); + run("git add README.md", repo); + const diff = await git.getDiffCached(repo); + expect(diff).toContain("staged"); + }); + + it("getDiffStats counts changed files", async () => { + await fs.writeFile(path.join(repo, "README.md"), "# Test Repo\nchange\n"); + const stats = await git.getDiffStats(repo); + expect(stats.filesChanged).toBeGreaterThanOrEqual(1); + }); + }); + + describe("discardFileChanges", () => { + it("restores a modified tracked file", async () => { + await fs.writeFile(path.join(repo, "README.md"), "# Test Repo\ndirty\n"); + const result = await git.discardFileChanges( + repo, + "README.md", + "modified", + ); + expect(result.success).toBe(true); + expect(await git.getFileAtHead(repo, "README.md")).toBe("# Test Repo\n"); + const onDisk = await fs.readFile(path.join(repo, "README.md"), "utf-8"); + expect(onDisk).toBe("# Test Repo\n"); + }); + }); + + describe("remote mutation (local bare remote, offline)", () => { + let bare: string; + let work: string; + + beforeEach(async () => { + bare = await createBareRemote(); + work = await createTempGitRepo(bare); + dirs.push(bare, work); + run("git push -u origin main", work); + }); + + it("push uploads new commits to the remote", async () => { + await fs.writeFile(path.join(work, "a.txt"), "x\n"); + commitAll(work, "add a"); + + const result = await git.push(work, "origin"); + expect(result.success).toBe(true); + expect(result.message).toContain("Pushed"); + }); + + it("publish pushes a new branch and sets upstream", async () => { + await git.createBranch(work, "feature"); + await fs.writeFile(path.join(work, "f.txt"), "y\n"); + commitAll(work, "add f"); + + const result = await git.publish(work, "origin"); + expect(result.success).toBe(true); + expect(result.branch).toBe("feature"); + }); + + it("pull fetches commits pushed by another clone", async () => { + const clone = await fs.mkdtemp(path.join(os.tmpdir(), "git-clone-")); + dirs.push(clone); + run(`git clone ${bare} ${clone}`, os.tmpdir()); + run("git config user.email 'c@test.com'", clone); + run("git config user.name 'Clone'", clone); + + await fs.writeFile(path.join(work, "shared.txt"), "from-work\n"); + commitAll(work, "add shared"); + await git.push(work, "origin"); + + const result = await git.pull(clone, "origin"); + expect(result.success).toBe(true); + expect( + await fs + .readFile(path.join(clone, "shared.txt"), "utf-8") + .catch(() => null), + ).toBe("from-work\n"); + }); + + it("sync pulls then pushes successfully", async () => { + await fs.writeFile(path.join(work, "s.txt"), "z\n"); + commitAll(work, "add s"); + + const result = await git.sync(work, "origin"); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/workspace-server/src/services/git/schemas.ts b/packages/workspace-server/src/services/git/schemas.ts index 88e671109b..2a69ad56d4 100644 --- a/packages/workspace-server/src/services/git/schemas.ts +++ b/packages/workspace-server/src/services/git/schemas.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +export const directoryPathInput = z.object({ directoryPath: z.string() }); + export const diffStatsInput = z.object({ directoryPath: z.string().min(1) }); export const diffStatsSchema = z.object({ @@ -9,3 +11,530 @@ export const diffStatsSchema = z.object({ }); export type DiffStats = z.infer<typeof diffStatsSchema>; + +export const gitFileStatusSchema = z.enum([ + "modified", + "added", + "deleted", + "renamed", + "untracked", +]); + +export type GitFileStatus = z.infer<typeof gitFileStatusSchema>; + +export const changedFileSchema = z.object({ + path: z.string(), + status: gitFileStatusSchema, + originalPath: z.string().optional(), + linesAdded: z.number().optional(), + linesRemoved: z.number().optional(), + staged: z.boolean().optional(), + patch: z.string().optional(), +}); + +export type ChangedFile = z.infer<typeof changedFileSchema>; + +export const gitCommitInfoSchema = z.object({ + sha: z.string(), + shortSha: z.string(), + message: z.string(), + author: z.string(), + date: z.string(), +}); + +export type GitCommitInfo = z.infer<typeof gitCommitInfoSchema>; + +export const gitRepoInfoSchema = z.object({ + organization: z.string(), + repository: z.string(), + currentBranch: z.string().nullable(), + defaultBranch: z.string(), + compareUrl: z.string().nullable(), +}); + +export type GitRepoInfo = z.infer<typeof gitRepoInfoSchema>; + +export const detectRepoResultSchema = z + .object({ + organization: z.string(), + repository: z.string(), + remote: z.string().optional(), + branch: z.string().optional(), + }) + .nullable(); + +export type DetectRepoResult = z.infer<typeof detectRepoResultSchema>; + +export const filePathInput = z.object({ + directoryPath: z.string(), + filePath: z.string(), +}); + +export const diffInput = z.object({ + directoryPath: z.string(), + ignoreWhitespace: z.boolean().optional(), +}); + +export const stringNullableOutput = z.string().nullable(); +export const stringOutput = z.string(); +export const stringArrayOutput = z.array(z.string()); +export const changedFilesOutput = z.array(changedFileSchema); +export const gitCommitInfoNullableOutput = gitCommitInfoSchema.nullable(); +export const gitRepoInfoNullableOutput = gitRepoInfoSchema.nullable(); + +// --- git-mutate group --- + +export const gitSyncStatusSchema = z.object({ + aheadOfRemote: z.number(), + behind: z.number(), + aheadOfDefault: z.number(), + hasRemote: z.boolean(), + currentBranch: z.string().nullable(), + isFeatureBranch: z.boolean(), +}); + +export type GitSyncStatus = z.infer<typeof gitSyncStatusSchema>; + +export const gitBusyOperationSchema = z.enum([ + "rebase", + "merge", + "cherry-pick", + "revert", +]); + +export const gitBusyStateSchema = z.union([ + z.object({ busy: z.literal(false) }), + z.object({ + busy: z.literal(true), + operation: gitBusyOperationSchema, + }), +]); + +export const prStatusOutput = z.object({ + hasRemote: z.boolean(), + isGitHubRepo: z.boolean(), + currentBranch: z.string().nullable(), + defaultBranch: z.string().nullable(), + prExists: z.boolean(), + prUrl: z.string().nullable(), + prState: z.string().nullable(), + baseBranch: z.string().nullable(), + headBranch: z.string().nullable(), + isDraft: z.boolean().nullable(), + error: z.string().nullable(), +}); + +export const gitStateSnapshotSchema = z.object({ + changedFiles: z.array(changedFileSchema).optional(), + diffStats: diffStatsSchema.optional(), + syncStatus: gitSyncStatusSchema.optional(), + latestCommit: gitCommitInfoSchema.nullable().optional(), + prStatus: prStatusOutput.optional(), +}); + +export type GitStateSnapshot = z.infer<typeof gitStateSnapshotSchema>; + +export const stageFilesInput = z.object({ + directoryPath: z.string(), + paths: z.array(z.string()), +}); + +export const createBranchInput = z.object({ + directoryPath: z.string(), + branchName: z.string(), +}); + +export const checkoutBranchInput = z.object({ + directoryPath: z.string(), + branchName: z.string(), +}); + +export const checkoutBranchOutput = z.object({ + previousBranch: z.string(), + currentBranch: z.string(), +}); + +export const discardFileChangesInput = z.object({ + directoryPath: z.string(), + filePath: z.string(), + fileStatus: gitFileStatusSchema, +}); + +export const discardFileChangesOutput = z.object({ + success: z.boolean(), + state: gitStateSnapshotSchema.optional(), +}); + +export type DiscardFileChangesOutput = z.infer<typeof discardFileChangesOutput>; + +export const getGitSyncStatusInput = z.object({ + directoryPath: z.string(), + forceRefresh: z.boolean().optional(), +}); + +export const gitBusyStateInput = directoryPathInput; + +export const pushInput = z.object({ + directoryPath: z.string(), + remote: z.string().default("origin"), + branch: z.string().optional(), + setUpstream: z.boolean().default(false), + env: z.record(z.string(), z.string()).optional(), +}); + +export const pushOutput = z.object({ + success: z.boolean(), + message: z.string(), + state: gitStateSnapshotSchema.optional(), +}); + +export type PushOutput = z.infer<typeof pushOutput>; + +export const commitInput = z.object({ + directoryPath: z.string(), + message: z.string(), + paths: z.array(z.string()).optional(), + allowEmpty: z.boolean().optional(), + stagedOnly: z.boolean().optional(), + // Pre-resolved SessionStart-hook env (e.g. SSH_AUTH_SOCK for commit signing), + // resolved in the host process where AgentService runs and passed through. + env: z.record(z.string(), z.string()).optional(), +}); + +export type CommitInput = z.infer<typeof commitInput>; + +export const commitOutput = z.object({ + success: z.boolean(), + message: z.string(), + commitSha: z.string().nullable(), + branch: z.string().nullable(), + state: gitStateSnapshotSchema.optional(), +}); + +export type CommitOutput = z.infer<typeof commitOutput>; + +export const pullInput = z.object({ + directoryPath: z.string(), + remote: z.string().default("origin"), + branch: z.string().optional(), +}); + +export const pullOutput = z.object({ + success: z.boolean(), + message: z.string(), + updatedFiles: z.number().optional(), + state: gitStateSnapshotSchema.optional(), +}); + +export type PullOutput = z.infer<typeof pullOutput>; + +export const publishInput = z.object({ + directoryPath: z.string(), + remote: z.string().default("origin"), + env: z.record(z.string(), z.string()).optional(), +}); + +export const publishOutput = z.object({ + success: z.boolean(), + message: z.string(), + branch: z.string(), + state: gitStateSnapshotSchema.optional(), +}); + +export type PublishOutput = z.infer<typeof publishOutput>; + +export const syncInput = z.object({ + directoryPath: z.string(), + remote: z.string().default("origin"), +}); + +export const syncOutput = z.object({ + success: z.boolean(), + pullMessage: z.string(), + pushMessage: z.string(), + state: gitStateSnapshotSchema.optional(), +}); + +export type SyncOutput = z.infer<typeof syncOutput>; + +// --- git-pr group (pure gh-CLI PR/GitHub read ops) --- + +export const ghStatusOutput = z.object({ + installed: z.boolean(), + version: z.string().nullable(), + authenticated: z.boolean(), + username: z.string().nullable(), + error: z.string().nullable(), +}); + +export type GhStatusOutput = z.infer<typeof ghStatusOutput>; + +export const ghAuthTokenOutput = z.object({ + success: z.boolean(), + token: z.string().nullable(), + error: z.string().nullable(), +}); + +export type GhAuthTokenOutput = z.infer<typeof ghAuthTokenOutput>; + +export type PrStatusOutput = z.infer<typeof prStatusOutput>; + +export const getPrUrlForBranchInput = z.object({ + directoryPath: z.string(), + branchName: z.string(), +}); + +export const getPrUrlForBranchOutput = z.string().nullable(); + +export const openPrInput = directoryPathInput; + +export const openPrOutput = z.object({ + success: z.boolean(), + message: z.string(), + prUrl: z.string().nullable(), +}); + +export type OpenPrOutput = z.infer<typeof openPrOutput>; + +export const getPrDetailsByUrlInput = z.object({ prUrl: z.string() }); + +export const getPrDetailsByUrlOutput = z.object({ + state: z.string(), + merged: z.boolean(), + draft: z.boolean(), +}); + +export type PrDetailsByUrlOutput = z.infer<typeof getPrDetailsByUrlOutput>; + +export const getPrChangedFilesInput = z.object({ prUrl: z.string() }); + +export const getBranchChangedFilesInput = z.object({ + repo: z.string(), + branch: z.string(), +}); + +export const getLocalBranchChangedFilesInput = z.object({ + directoryPath: z.string(), + branch: z.string(), +}); + +export const prReviewCommentUserSchema = z.object({ + login: z.string(), + avatar_url: z.string(), +}); + +export const prReviewCommentSchema = z.object({ + id: z.number(), + body: z.string(), + path: z.string(), + line: z.number().nullable(), + original_line: z.number().nullable(), + side: z.enum(["LEFT", "RIGHT"]), + start_line: z.number().nullable(), + start_side: z.enum(["LEFT", "RIGHT"]).nullable(), + diff_hunk: z.string(), + in_reply_to_id: z.number().nullish(), + user: prReviewCommentUserSchema, + created_at: z.string(), + updated_at: z.string(), + subject_type: z.enum(["line", "file"]).nullable(), +}); + +export type PrReviewComment = z.infer<typeof prReviewCommentSchema>; + +export const prReviewThreadSchema = z.object({ + nodeId: z.string(), + isResolved: z.boolean(), + rootId: z.number(), + filePath: z.string(), + comments: z.array(prReviewCommentSchema), +}); + +export type PrReviewThread = z.infer<typeof prReviewThreadSchema>; + +export const getPrReviewCommentsInput = z.object({ prUrl: z.string() }); +export const getPrReviewCommentsOutput = z.array(prReviewThreadSchema); + +export const resolveReviewThreadInput = z.object({ + prUrl: z.string(), + threadNodeId: z.string(), + resolved: z.boolean(), +}); + +export const resolveReviewThreadOutput = z.object({ + success: z.boolean(), + isResolved: z.boolean(), +}); + +export type ResolveReviewThreadOutput = z.infer< + typeof resolveReviewThreadOutput +>; + +export const replyToPrCommentInput = z.object({ + prUrl: z.string(), + commentId: z.number(), + body: z.string(), +}); + +export const replyToPrCommentOutput = z.object({ + success: z.boolean(), + comment: prReviewCommentSchema.nullable(), +}); + +export type ReplyToPrCommentOutput = z.infer<typeof replyToPrCommentOutput>; + +export const prActionType = z.enum(["close", "reopen", "ready", "draft"]); +export type PrActionType = z.infer<typeof prActionType>; + +export const updatePrByUrlInput = z.object({ + prUrl: z.string(), + action: prActionType, +}); + +export const updatePrByUrlOutput = z.object({ + success: z.boolean(), + message: z.string(), +}); + +export type UpdatePrByUrlOutput = z.infer<typeof updatePrByUrlOutput>; + +export const getPrTemplateInput = directoryPathInput; + +export const getPrTemplateOutput = z.object({ + template: z.string().nullable(), + templatePath: z.string().nullable(), +}); + +export type GetPrTemplateOutput = z.infer<typeof getPrTemplateOutput>; + +export const getCommitConventionsInput = z.object({ + directoryPath: z.string(), + sampleSize: z.number().default(20), +}); + +export const getCommitConventionsOutput = z.object({ + conventionalCommits: z.boolean(), + commonPrefixes: z.array(z.string()), + sampleMessages: z.array(z.string()), +}); + +export type GetCommitConventionsOutput = z.infer< + typeof getCommitConventionsOutput +>; + +export const githubRefKindSchema = z.enum(["issue", "pr"]); +export type GithubRefKind = z.infer<typeof githubRefKindSchema>; + +export const githubRefStateSchema = z.enum(["OPEN", "CLOSED", "MERGED"]); + +export const githubRefSchema = z.object({ + kind: githubRefKindSchema, + number: z.number(), + title: z.string(), + state: githubRefStateSchema, + labels: z.array(z.string()), + url: z.string(), + repo: z.string(), + isDraft: z.boolean().optional(), +}); + +export type GithubRef = z.infer<typeof githubRefSchema>; + +export const searchGithubRefsInput = z.object({ + directoryPath: z.string(), + query: z.string().optional(), + limit: z.number().default(25), + kinds: z.array(githubRefKindSchema).optional(), +}); + +export const searchGithubRefsOutput = z.array(githubRefSchema); + +export const getGithubIssueInput = z.object({ + owner: z.string(), + repo: z.string(), + number: z.number().int().positive(), +}); + +export const getGithubIssueOutput = githubRefSchema.nullable(); + +export const getGithubPullRequestInput = getGithubIssueInput; +export const getGithubPullRequestOutput = getGithubIssueOutput; + +export const handoffLocalGitStateSchema = z.object({ + head: z.string().nullable(), + branch: z.string().nullable(), + upstreamHead: z.string().nullable(), + upstreamRemote: z.string().nullable(), + upstreamMergeRef: z.string().nullable(), +}); + +export type HandoffLocalGitState = z.infer<typeof handoffLocalGitStateSchema>; + +export const readHandoffLocalGitStateInput = z.object({ + directoryPath: z.string(), +}); +export const readHandoffLocalGitStateOutput = handoffLocalGitStateSchema; + +export const cleanupAfterCloudHandoffInput = z.object({ + directoryPath: z.string(), + branchName: z.string().nullable(), +}); +export const cleanupAfterCloudHandoffOutput = z.object({ + stashed: z.boolean(), + switched: z.boolean(), + defaultBranch: z.string().nullable(), +}); + +export const gitStatusOutput = z.object({ + installed: z.boolean(), + version: z.string().nullable(), +}); +export type GitStatusOutput = z.infer<typeof gitStatusOutput>; + +export const getHeadShaOutput = z.string(); + +export const resetSoftInput = z.object({ + directoryPath: z.string(), + sha: z.string(), +}); + +export const createPrViaGhInput = z.object({ + directoryPath: z.string(), + title: z.string().optional(), + body: z.string().optional(), + draft: z.boolean().optional(), + env: z.record(z.string(), z.string()).optional(), +}); +export const createPrViaGhOutput = z.object({ + success: z.boolean(), + message: z.string(), + prUrl: z.string().nullable(), +}); + +export const cloneRepositoryInput = z.object({ + repoUrl: z.string(), + targetPath: z.string(), + cloneId: z.string(), +}); +export const getDiffAgainstRemoteInput = z.object({ + directoryPath: z.string(), + baseBranch: z.string(), +}); + +export const getCommitsBetweenBranchesInput = z.object({ + directoryPath: z.string(), + baseBranch: z.string(), + head: z.string().optional(), + limit: z.number(), +}); +export const getCommitsBetweenBranchesOutput = z.array( + z.object({ sha: z.string(), message: z.string() }), +); + +export const cloneRepositoryOutput = z.object({ cloneId: z.string() }); +export const cloneProgressPayload = z.object({ + cloneId: z.string(), + status: z.enum(["cloning", "complete", "error"]), + message: z.string(), +}); +export type CloneProgressPayload = z.infer<typeof cloneProgressPayload>; diff --git a/packages/workspace-server/src/services/git/service.ts b/packages/workspace-server/src/services/git/service.ts index 03416af262..476c62fbfb 100644 --- a/packages/workspace-server/src/services/git/service.ts +++ b/packages/workspace-server/src/services/git/service.ts @@ -1,9 +1,1606 @@ -import { type DiffStats, getDiffStats } from "@posthog/git/queries"; +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; +import { execGh } from "@posthog/git/gh"; +import { readHandoffLocalGitState } from "@posthog/git/handoff"; +import { getGitOperationManager } from "@posthog/git/operation-manager"; +import { + type DiffStats, + type GitBusyState, + getAllBranches, + getBranchDiffPatchesByPath, + getChangedFilesBetweenBranches, + getChangedFilesDetailed, + getCommitConventions, + getCommitsBetweenBranches, + getCurrentBranch, + getDefaultBranch, + getDiffAgainstRemote, + getDiffHead, + getDiffStats, + getFileAtHead, + getGitBusyState, + getHeadSha, + getLatestCommit, + getRemoteUrl, + getStagedDiff, + getSyncStatus, + getUnstagedDiff, + fetch as gitFetch, + stageFiles as gitStageFiles, + unstageFiles as gitUnstageFiles, + isGitRepository, +} from "@posthog/git/queries"; +import { + CreateBranchSaga, + ResetToDefaultBranchSaga, + SwitchBranchSaga, +} from "@posthog/git/sagas/branch"; +import { CloneSaga } from "@posthog/git/sagas/clone"; +import { CommitSaga } from "@posthog/git/sagas/commit"; +import { DiscardFileChangesSaga } from "@posthog/git/sagas/discard"; +import { PullSaga } from "@posthog/git/sagas/pull"; +import { PushSaga } from "@posthog/git/sagas/push"; +import { StashPushSaga } from "@posthog/git/sagas/stash"; +import { parseGithubUrl } from "@posthog/git/utils"; +import { TypedEventEmitter } from "@posthog/shared"; import { injectable } from "inversify"; +import type { + ChangedFile, + CloneProgressPayload, + CommitOutput, + DetectRepoResult, + DiscardFileChangesOutput, + GetCommitConventionsOutput, + GetPrTemplateOutput, + GhAuthTokenOutput, + GhStatusOutput, + GitCommitInfo, + GitFileStatus, + GithubRef, + GithubRefKind, + GitRepoInfo, + GitStateSnapshot, + GitStatusOutput, + GitSyncStatus, + HandoffLocalGitState, + OpenPrOutput, + PrActionType, + PrDetailsByUrlOutput, + PrReviewComment, + PrReviewThread, + PrStatusOutput, + PublishOutput, + PullOutput, + PushOutput, + ReplyToPrCommentOutput, + ResolveReviewThreadOutput, + SyncOutput, + UpdatePrByUrlOutput, +} from "./schemas"; + +const FETCH_THROTTLE_MS = 30_000; + +/** + * GitHub's compare/files API returns a bare hunk body. Reconstruct a full + * unified-diff patch (with `diff --git` + `---`/`+++` headers) so downstream + * parsers can process it correctly. + */ +function toUnifiedDiffPatch( + rawPatch: string, + filename: string, + previousFilename: string | undefined, + status: ChangedFile["status"], +): string { + const oldPath = previousFilename ?? filename; + const fromPath = status === "added" ? "/dev/null" : `a/${oldPath}`; + const toPath = status === "deleted" ? "/dev/null" : `b/${filename}`; + return `diff --git a/${oldPath} b/${filename}\n--- ${fromPath}\n+++ ${toPath}\n${rawPatch}`; +} + +export const GitCloneEvent = { CloneProgress: "cloneProgress" } as const; +export interface GitCloneEvents { + [GitCloneEvent.CloneProgress]: CloneProgressPayload; +} + +const execFileAsync = promisify(execFile); @injectable() -export class GitService { +export class GitService extends TypedEventEmitter<GitCloneEvents> { async getDiffStats(directoryPath: string): Promise<DiffStats> { return getDiffStats(directoryPath); } + + async getHeadSha(directoryPath: string): Promise<string> { + return getHeadSha(directoryPath); + } + + async getDiffAgainstRemote( + directoryPath: string, + baseBranch: string, + ): Promise<string> { + return getDiffAgainstRemote(directoryPath, baseBranch); + } + + async getCommitsBetweenBranches( + directoryPath: string, + baseBranch: string, + head: string | undefined, + limit: number, + ): Promise<Array<{ sha: string; message: string }>> { + return getCommitsBetweenBranches(directoryPath, baseBranch, head, limit); + } + + async resetSoft(directoryPath: string, sha: string): Promise<void> { + await getGitOperationManager().executeWrite(directoryPath, (git) => + git.reset(["--soft", sha]), + ); + } + + async getGitStatus(): Promise<GitStatusOutput> { + try { + const { stdout } = await execFileAsync("git", ["--version"]); + return { installed: true, version: stdout.trim() }; + } catch { + return { installed: false, version: null }; + } + } + + async createPrViaGh( + directoryPath: string, + title?: string, + body?: string, + draft?: boolean, + env?: Record<string, string>, + ): Promise<{ success: boolean; message: string; prUrl: string | null }> { + const prFooter = + "\n\n---\n*Created with [PostHog Code](https://posthog.com/code?ref=pr)*"; + const args = ["pr", "create"]; + if (title) { + args.push("--title", title); + args.push("--body", (body || "") + prFooter); + } else { + args.push("--fill"); + } + if (draft) args.push("--draft"); + + const result = await execGh(args, { cwd: directoryPath, env }); + if (result.exitCode !== 0) { + return { + success: false, + message: result.stderr || result.error || "Failed to create PR", + prUrl: null, + }; + } + const prUrl = + result.stdout.match(/https:\/\/github\.com\/[^\s]+/)?.[0] ?? null; + return { success: true, message: "Pull request created", prUrl }; + } + + async cloneRepository( + repoUrl: string, + targetPath: string, + cloneId: string, + ): Promise<{ cloneId: string }> { + const emit = ( + status: CloneProgressPayload["status"], + message: string, + ): void => { + this.emit(GitCloneEvent.CloneProgress, { cloneId, status, message }); + }; + + emit("cloning", `Starting clone of ${repoUrl}...`); + const result = await new CloneSaga().run({ + repoUrl, + targetPath, + onProgress: (stage, progress, processed, total) => { + const pct = progress ? ` ${Math.round(progress)}%` : ""; + const count = total ? ` (${processed}/${total})` : ""; + emit("cloning", `${stage}${pct}${count}`); + }, + }); + if (!result.success) { + emit("error", result.error); + throw new Error(result.error); + } + emit("complete", "Clone completed successfully"); + return { cloneId }; + } + + async detectRepo(directoryPath: string): Promise<DetectRepoResult> { + if (!directoryPath) return null; + + const remoteUrl = await getRemoteUrl(directoryPath); + if (!remoteUrl) return null; + + const parsed = parseGithubUrl(remoteUrl); + if (!parsed) return null; + + const branch = await getCurrentBranch(directoryPath); + if (!branch) return null; + + return { + organization: parsed.owner, + repository: parsed.repo, + remote: remoteUrl, + branch, + }; + } + + async validateRepo(directoryPath: string): Promise<boolean> { + if (!directoryPath) return false; + return isGitRepository(directoryPath); + } + + async getRemoteUrl(directoryPath: string): Promise<string | null> { + return getRemoteUrl(directoryPath); + } + + async getCurrentBranch( + directoryPath: string, + signal?: AbortSignal, + ): Promise<string | null> { + return getCurrentBranch(directoryPath, { abortSignal: signal }); + } + + async getDefaultBranch(directoryPath: string): Promise<string> { + return getDefaultBranch(directoryPath); + } + + async getAllBranches( + directoryPath: string, + signal?: AbortSignal, + ): Promise<string[]> { + return getAllBranches(directoryPath, { abortSignal: signal }); + } + + async getChangedFilesHead( + directoryPath: string, + signal?: AbortSignal, + ): Promise<ChangedFile[]> { + const files = await getChangedFilesDetailed(directoryPath, { + excludePatterns: [".claude", "CLAUDE.local.md"], + abortSignal: signal, + }); + type HeadChangedFile = Omit<ChangedFile, "patch">; + const filteredFiles: Array<HeadChangedFile | null> = await Promise.all( + files.map(async (file) => { + if (file.status === "untracked") { + try { + const stats = await fs.promises.stat( + path.join(directoryPath, file.path), + ); + if (!stats.isFile()) return null; + } catch { + return null; + } + } + + return { + path: file.path, + status: file.status, + originalPath: file.originalPath, + linesAdded: file.linesAdded, + linesRemoved: file.linesRemoved, + staged: file.staged, + }; + }), + ); + + return filteredFiles.filter( + (file): file is HeadChangedFile => file !== null, + ); + } + + async getFileAtHead( + directoryPath: string, + filePath: string, + signal?: AbortSignal, + ): Promise<string | null> { + return getFileAtHead(directoryPath, filePath, { abortSignal: signal }); + } + + async getDiffHead( + directoryPath: string, + ignoreWhitespace?: boolean, + signal?: AbortSignal, + ): Promise<string> { + return getDiffHead(directoryPath, { + ignoreWhitespace, + abortSignal: signal, + }); + } + + async getDiffCached( + directoryPath: string, + ignoreWhitespace?: boolean, + signal?: AbortSignal, + ): Promise<string> { + return getStagedDiff(directoryPath, { + ignoreWhitespace, + abortSignal: signal, + }); + } + + async getDiffUnstaged( + directoryPath: string, + ignoreWhitespace?: boolean, + signal?: AbortSignal, + ): Promise<string> { + return getUnstagedDiff(directoryPath, { + ignoreWhitespace, + abortSignal: signal, + }); + } + + async getLatestCommit( + directoryPath: string, + signal?: AbortSignal, + ): Promise<GitCommitInfo | null> { + const commit = await getLatestCommit(directoryPath, { + abortSignal: signal, + }); + if (!commit) return null; + return { + sha: commit.sha, + shortSha: commit.shortSha, + message: commit.message, + author: commit.author, + date: commit.date, + }; + } + + async getGitRepoInfo(directoryPath: string): Promise<GitRepoInfo | null> { + try { + const remoteUrl = await getRemoteUrl(directoryPath); + if (!remoteUrl) return null; + + const parsed = parseGithubUrl(remoteUrl); + if (!parsed) return null; + + const currentBranch = await getCurrentBranch(directoryPath); + const defaultBranch = await getDefaultBranch(directoryPath); + + let compareUrl: string | null = null; + if (currentBranch && currentBranch !== defaultBranch) { + compareUrl = `https://github.com/${parsed.owner}/${parsed.repo}/compare/${defaultBranch}...${currentBranch}?expand=1`; + } + + return { + organization: parsed.owner, + repository: parsed.repo, + currentBranch: currentBranch ?? null, + defaultBranch, + compareUrl, + }; + } catch { + return null; + } + } + + // --- git-mutate group --- + + private readonly lastFetchTime = new Map<string, number>(); + + private async fetchIfStale(directoryPath: string): Promise<void> { + const now = Date.now(); + const lastFetch = this.lastFetchTime.get(directoryPath) ?? 0; + if (now - lastFetch > FETCH_THROTTLE_MS) { + try { + await gitFetch(directoryPath); + this.lastFetchTime.set(directoryPath, now); + } catch {} + } + } + + private async getGitSyncStatusInternal( + directoryPath: string, + forceRefresh = false, + ): Promise<GitSyncStatus> { + if (forceRefresh) { + this.lastFetchTime.delete(directoryPath); + } + await this.fetchIfStale(directoryPath); + + const status = await getSyncStatus(directoryPath); + return { + aheadOfRemote: status.aheadOfRemote, + behind: status.behind, + aheadOfDefault: status.aheadOfDefault, + hasRemote: status.hasRemote, + currentBranch: status.currentBranch, + isFeatureBranch: status.isFeatureBranch, + }; + } + + private async getStateSnapshot( + directoryPath: string, + options?: { + includeChangedFiles?: boolean; + includeDiffStats?: boolean; + includeSyncStatus?: boolean; + includeLatestCommit?: boolean; + }, + ): Promise<GitStateSnapshot> { + const { + includeChangedFiles = true, + includeDiffStats = true, + includeSyncStatus = true, + includeLatestCommit = true, + } = options ?? {}; + + const results = await Promise.allSettled([ + includeChangedFiles ? this.getChangedFilesHead(directoryPath) : null, + includeDiffStats ? this.getDiffStats(directoryPath) : null, + includeSyncStatus + ? this.getGitSyncStatusInternal(directoryPath, true) + : null, + includeLatestCommit ? this.getLatestCommit(directoryPath) : null, + ]); + + const getValue = <T>(r: PromiseSettledResult<T | null>): T | undefined => + r.status === "fulfilled" && r.value !== null ? r.value : undefined; + + return { + changedFiles: getValue(results[0]), + diffStats: getValue(results[1]), + syncStatus: getValue(results[2]), + latestCommit: getValue(results[3]), + }; + } + + async getGitBusyState( + directoryPath: string, + signal?: AbortSignal, + ): Promise<GitBusyState> { + return getGitBusyState(directoryPath, { abortSignal: signal }); + } + + async getGitSyncStatus( + directoryPath: string, + forceRefresh = false, + ): Promise<GitSyncStatus> { + return this.getGitSyncStatusInternal(directoryPath, forceRefresh); + } + + async createBranch(directoryPath: string, branchName: string): Promise<void> { + const saga = new CreateBranchSaga(); + const result = await saga.run({ baseDir: directoryPath, branchName }); + if (!result.success) throw new Error(result.error); + } + + async checkoutBranch( + directoryPath: string, + branchName: string, + ): Promise<{ previousBranch: string; currentBranch: string }> { + const saga = new SwitchBranchSaga(); + const result = await saga.run({ baseDir: directoryPath, branchName }); + if (!result.success) throw new Error(result.error); + return result.data; + } + + async stageFiles( + directoryPath: string, + paths: string[], + ): Promise<GitStateSnapshot> { + await gitStageFiles(directoryPath, paths); + return this.getStateSnapshot(directoryPath); + } + + async unstageFiles( + directoryPath: string, + paths: string[], + ): Promise<GitStateSnapshot> { + await gitUnstageFiles(directoryPath, paths); + return this.getStateSnapshot(directoryPath); + } + + async discardFileChanges( + directoryPath: string, + filePath: string, + fileStatus: GitFileStatus, + ): Promise<DiscardFileChangesOutput> { + const saga = new DiscardFileChangesSaga(); + const result = await saga.run({ + baseDir: directoryPath, + filePath, + fileStatus, + }); + if (!result.success) { + return { success: false }; + } + + const state = await this.getStateSnapshot(directoryPath, { + includeSyncStatus: false, + includeLatestCommit: false, + }); + + return { success: true, state }; + } + + async push( + directoryPath: string, + remote = "origin", + branch?: string, + setUpstream = false, + signal?: AbortSignal, + env?: Record<string, string>, + ): Promise<PushOutput> { + const saga = new PushSaga(); + const result = await saga.run({ + baseDir: directoryPath, + remote, + branch: branch || undefined, + setUpstream, + signal, + env, + }); + if (!result.success) { + return { success: false, message: result.error }; + } + + const state = await this.getStateSnapshot(directoryPath, { + includeChangedFiles: false, + includeDiffStats: false, + includeLatestCommit: false, + }); + + return { + success: true, + message: `Pushed ${result.data.branch} to ${result.data.remote}`, + state, + }; + } + + async commit( + directoryPath: string, + message: string, + options?: { + paths?: string[]; + allowEmpty?: boolean; + stagedOnly?: boolean; + env?: Record<string, string>; + }, + ): Promise<CommitOutput> { + const fail = (msg: string): CommitOutput => ({ + success: false, + message: msg, + commitSha: null, + branch: null, + }); + + if (!message.trim()) return fail("Commit message is required"); + + const saga = new CommitSaga(); + const result = await saga.run({ + baseDir: directoryPath, + message: message.trim(), + paths: options?.paths, + allowEmpty: options?.allowEmpty, + stagedOnly: options?.stagedOnly, + env: options?.env, + }); + + if (!result.success) return fail(result.error); + + const state = await this.getStateSnapshot(directoryPath); + + return { + success: true, + message: `Committed ${result.data.commitSha.slice(0, 7)}`, + commitSha: result.data.commitSha, + branch: result.data.branch, + state, + }; + } + + async pull( + directoryPath: string, + remote = "origin", + branch?: string, + signal?: AbortSignal, + ): Promise<PullOutput> { + const saga = new PullSaga(); + const result = await saga.run({ + baseDir: directoryPath, + remote, + branch: branch || undefined, + signal, + }); + if (!result.success) { + return { success: false, message: result.error }; + } + + const state = await this.getStateSnapshot(directoryPath); + + return { + success: true, + message: `${result.data.changes} files changed`, + updatedFiles: result.data.changes, + state, + }; + } + + async publish( + directoryPath: string, + remote = "origin", + signal?: AbortSignal, + env?: Record<string, string>, + ): Promise<PublishOutput> { + const currentBranch = await getCurrentBranch(directoryPath); + if (!currentBranch) { + return { success: false, message: "No branch to publish", branch: "" }; + } + + const pushResult = await this.push( + directoryPath, + remote, + currentBranch, + true, + signal, + env, + ); + return { + success: pushResult.success, + message: pushResult.message, + branch: currentBranch, + state: pushResult.state, + }; + } + + async sync( + directoryPath: string, + remote = "origin", + signal?: AbortSignal, + ): Promise<SyncOutput> { + const pullResult = await this.pull( + directoryPath, + remote, + undefined, + signal, + ); + if (!pullResult.success) { + return { + success: false, + pullMessage: pullResult.message, + pushMessage: "Skipped due to pull failure", + }; + } + + const pushResult = await this.push( + directoryPath, + remote, + undefined, + false, + signal, + ); + + const state = await this.getStateSnapshot(directoryPath); + + return { + success: pushResult.success, + pullMessage: pullResult.message, + pushMessage: pushResult.message, + state, + }; + } + + // --- git-pr group (pure gh-CLI PR/GitHub read ops) --- + + async getGhStatus(): Promise<GhStatusOutput> { + const versionResult = await execGh(["--version"]); + if (versionResult.exitCode !== 0) { + return { + installed: false, + version: null, + authenticated: false, + username: null, + error: versionResult.error ?? versionResult.stderr ?? null, + }; + } + + const version = versionResult.stdout.split("\n")[0]?.trim() ?? null; + const authResult = await execGh(["auth", "status"]); + const authenticated = authResult.exitCode === 0; + const authOutput = `${authResult.stdout}\n${authResult.stderr}`; + const usernameMatch = authOutput.match( + /Logged in to github.com (?:as |account )(\S+)/, + ); + + return { + installed: true, + version, + authenticated, + username: usernameMatch?.[1] ?? null, + error: authenticated + ? null + : authResult.stderr || authResult.error || null, + }; + } + + async getGhAuthToken(): Promise<GhAuthTokenOutput> { + const result = await execGh(["auth", "token"]); + if (result.exitCode !== 0) { + return { + success: false, + token: null, + error: + result.stderr || result.error || "Failed to read GitHub auth token", + }; + } + + const token = result.stdout.trim(); + if (!token) { + return { + success: false, + token: null, + error: "GitHub auth token is empty", + }; + } + + return { success: true, token, error: null }; + } + + async getPrStatus(directoryPath: string): Promise<PrStatusOutput> { + const base: PrStatusOutput = { + hasRemote: false, + isGitHubRepo: false, + currentBranch: null, + defaultBranch: null, + prExists: false, + prUrl: null, + prState: null, + baseBranch: null, + headBranch: null, + isDraft: null, + error: null, + }; + + try { + const remoteUrl = await getRemoteUrl(directoryPath); + const isGitHubRepo = !!(remoteUrl && parseGithubUrl(remoteUrl)); + const currentBranch = await getCurrentBranch(directoryPath); + const defaultBranch = await getDefaultBranch(directoryPath).catch( + () => null, + ); + + if (!isGitHubRepo || !currentBranch) { + return { + ...base, + hasRemote: !!remoteUrl, + isGitHubRepo, + currentBranch, + defaultBranch, + }; + } + + const prResult = await execGh( + ["pr", "view", "--json", "url,state,baseRefName,headRefName,isDraft"], + { cwd: directoryPath }, + ); + + const shared = { + hasRemote: true, + isGitHubRepo: true, + currentBranch, + defaultBranch, + }; + + if (prResult.exitCode !== 0) { + return { ...base, ...shared }; + } + + const data = JSON.parse(prResult.stdout) as { + url?: string; + state?: string; + baseRefName?: string; + headRefName?: string; + isDraft?: boolean; + }; + + return { + ...base, + ...shared, + prExists: !!data.url, + prUrl: data.url ?? null, + prState: data.state ?? null, + baseBranch: data.baseRefName ?? null, + headBranch: data.headRefName ?? null, + isDraft: data.isDraft ?? null, + }; + } catch (error) { + return { + ...base, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + async getPrUrlForBranch( + directoryPath: string, + branchName: string, + ): Promise<string | null> { + try { + const remoteUrl = await getRemoteUrl(directoryPath); + if (!remoteUrl) return null; + + const parsed = parseGithubUrl(remoteUrl); + if (!parsed) return null; + + const result = await execGh([ + "pr", + "list", + "--head", + branchName, + "--state", + "all", + "--json", + "url", + "--limit", + "1", + "--repo", + `${parsed.owner}/${parsed.repo}`, + ]); + + if (result.exitCode !== 0) { + return null; + } + + const data = JSON.parse(result.stdout) as Array<{ url?: string }>; + return data[0]?.url ?? null; + } catch { + return null; + } + } + + async openPr(directoryPath: string): Promise<OpenPrOutput> { + const result = await execGh(["pr", "view", "--json", "url"], { + cwd: directoryPath, + }); + + if (result.exitCode !== 0) { + return { + success: false, + message: result.stderr || result.error || "Failed to fetch PR", + prUrl: null, + }; + } + + const data = JSON.parse(result.stdout) as { url?: string }; + const prUrl = data.url ?? null; + return { success: !!prUrl, message: prUrl ? "OK" : "No PR found", prUrl }; + } + + async getPrDetailsByUrl(prUrl: string): Promise<PrDetailsByUrlOutput | null> { + const pr = parseGithubUrl(prUrl); + if (pr?.kind !== "pr") return null; + + try { + const result = await execGh([ + "api", + `repos/${pr.owner}/${pr.repo}/pulls/${pr.number}`, + "--jq", + "{state,merged,draft}", + ]); + + if (result.exitCode !== 0) { + return null; + } + + const data = JSON.parse(result.stdout) as { + state: string; + merged: boolean; + draft: boolean; + }; + + return data; + } catch { + return null; + } + } + + async getPrChangedFiles(prUrl: string): Promise<ChangedFile[]> { + const pr = parseGithubUrl(prUrl); + if (pr?.kind !== "pr") return []; + + const { owner, repo, number } = pr; + + const result = await execGh([ + "api", + `repos/${owner}/${repo}/pulls/${number}/files`, + "--paginate", + "--slurp", + ]); + + if (result.exitCode !== 0) { + throw new Error( + `Failed to fetch PR files: ${result.stderr || result.error || "Unknown error"}`, + ); + } + + const pages = JSON.parse(result.stdout) as Array< + Array<{ + filename: string; + status: string; + previous_filename?: string; + additions: number; + deletions: number; + patch?: string; + }> + >; + const files = pages.flat(); + + return files.map((f) => { + let status: ChangedFile["status"]; + switch (f.status) { + case "added": + status = "added"; + break; + case "removed": + status = "deleted"; + break; + case "renamed": + status = "renamed"; + break; + default: + status = "modified"; + break; + } + + return { + path: f.filename, + status, + originalPath: f.previous_filename, + linesAdded: f.additions, + linesRemoved: f.deletions, + patch: f.patch + ? toUnifiedDiffPatch(f.patch, f.filename, f.previous_filename, status) + : undefined, + }; + }); + } + + async getBranchChangedFiles( + repo: string, + branch: string, + ): Promise<ChangedFile[]> { + const parts = repo.split("/"); + if (parts.length !== 2) return []; + + const [owner, repoName] = parts; + + const repoResult = await execGh([ + "api", + `repos/${owner}/${repoName}`, + "--jq", + ".default_branch", + ]); + + if (repoResult.exitCode !== 0 || !repoResult.stdout.trim()) { + return []; + } + const defaultBranch = repoResult.stdout.trim(); + + const result = await execGh([ + "api", + `repos/${owner}/${repoName}/compare/${defaultBranch}...${branch}`, + ]); + + if (result.exitCode !== 0) { + throw new Error( + `Failed to fetch branch files: ${result.stderr || result.error || "Unknown error"}`, + ); + } + + const response = JSON.parse(result.stdout) as { + files?: Array<{ + filename: string; + status: string; + previous_filename?: string; + additions: number; + deletions: number; + patch?: string; + }>; + }; + const files = response.files; + + if (!files) return []; + + return files.map((f) => { + let status: ChangedFile["status"]; + switch (f.status) { + case "added": + status = "added"; + break; + case "removed": + status = "deleted"; + break; + case "renamed": + status = "renamed"; + break; + default: + status = "modified"; + break; + } + + return { + path: f.filename, + status, + originalPath: f.previous_filename, + linesAdded: f.additions, + linesRemoved: f.deletions, + patch: f.patch + ? toUnifiedDiffPatch(f.patch, f.filename, f.previous_filename, status) + : undefined, + }; + }); + } + + async getLocalBranchChangedFiles( + directoryPath: string, + branch: string, + ): Promise<ChangedFile[]> { + await this.fetchIfStale(directoryPath); + + const defaultBranch = await getDefaultBranch(directoryPath); + if (!defaultBranch) return []; + + const files = await getChangedFilesBetweenBranches( + directoryPath, + defaultBranch, + branch, + { excludePatterns: [".claude", "CLAUDE.local.md"] }, + ); + if (files.length === 0) return []; + + const patchByPath = await getBranchDiffPatchesByPath( + directoryPath, + defaultBranch, + branch, + ); + + return files.map((f) => ({ + path: f.path, + status: f.status, + originalPath: f.originalPath, + linesAdded: f.linesAdded, + linesRemoved: f.linesRemoved, + patch: patchByPath.get(f.path), + })); + } + + async updatePrByUrl( + prUrl: string, + action: PrActionType, + ): Promise<UpdatePrByUrlOutput> { + const pr = parseGithubUrl(prUrl); + if (pr?.kind !== "pr") { + return { success: false, message: "Invalid PR URL" }; + } + + try { + const args = + action === "draft" + ? ["pr", "ready", "--undo", String(pr.number)] + : ["pr", action, String(pr.number)]; + + const result = await execGh([ + ...args, + "--repo", + `${pr.owner}/${pr.repo}`, + ]); + + if (result.exitCode !== 0) { + return { + success: false, + message: result.stderr || result.error || "Unknown error", + }; + } + + return { success: true, message: result.stdout }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + async getPrReviewComments(prUrl: string): Promise<PrReviewThread[]> { + const pr = parseGithubUrl(prUrl); + if (pr?.kind !== "pr") return []; + + const { owner, repo, number } = pr; + + // Position fields (line, side, etc.) live on the thread, not on individual comments. + const query = ` + query($owner: String!, $repo: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + isResolved + isOutdated + path + diffSide + line + originalLine + startLine + startDiffSide + subjectType + comments(first: 100) { + nodes { + databaseId + body + path + diffHunk + replyTo { databaseId } + author { login avatarUrl } + createdAt + updatedAt + } + } + } + } + } + } + } + `; + + type ThreadNode = { + id: string; + isResolved: boolean; + isOutdated: boolean; + path: string; + diffSide: "LEFT" | "RIGHT"; + line: number | null; + originalLine: number | null; + startLine: number | null; + startDiffSide: "LEFT" | "RIGHT" | null; + subjectType: "LINE" | "FILE" | null; + comments: { + nodes: Array<{ + databaseId: number; + body: string; + path: string; + diffHunk: string; + replyTo: { databaseId: number } | null; + author: { login: string; avatarUrl: string }; + createdAt: string; + updatedAt: string; + }>; + }; + }; + + type PageResponse = { + data: { + repository: { + pullRequest: { + reviewThreads: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: ThreadNode[]; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + const MAX_THREAD_PAGES = 50; // 50 × 100 = 5 000 threads max + + const allNodes: ThreadNode[] = []; + let cursor: string | null = null; + + for (let page = 0; page < MAX_THREAD_PAGES; page++) { + const result = await execGh(["api", "graphql", "--input", "-"], { + input: JSON.stringify({ + query, + variables: { owner, repo, number, cursor }, + }), + }); + + if (result.exitCode !== 0) { + throw new Error( + `Failed to fetch PR review threads: ${result.stderr || result.error || "Unknown error"}`, + ); + } + + const data = JSON.parse(result.stdout) as PageResponse; + if (data.errors?.length) { + throw new Error( + `GraphQL error: ${data.errors.map((e) => e.message).join("; ")}`, + ); + } + const reviewThreads = data.data.repository.pullRequest.reviewThreads; + allNodes.push(...reviewThreads.nodes); + if (!reviewThreads.pageInfo.hasNextPage) { + break; + } + cursor = reviewThreads.pageInfo.endCursor; + } + + return allNodes.map((thread) => { + const comments: PrReviewComment[] = thread.comments.nodes.map((c) => ({ + id: c.databaseId, + body: c.body, + path: c.path, + diff_hunk: c.diffHunk, + line: thread.line, + original_line: thread.originalLine, + side: thread.diffSide, + start_line: thread.startLine, + start_side: thread.startDiffSide, + in_reply_to_id: c.replyTo?.databaseId ?? null, + user: { login: c.author.login, avatar_url: c.author.avatarUrl }, + created_at: c.createdAt, + updated_at: c.updatedAt, + subject_type: thread.subjectType + ? (thread.subjectType.toLowerCase() as "line" | "file") + : null, + })); + + return { + nodeId: thread.id, + isResolved: thread.isResolved, + rootId: comments[0]?.id ?? 0, + filePath: thread.path, + comments, + }; + }); + } + + async resolveReviewThread( + threadNodeId: string, + resolved: boolean, + ): Promise<ResolveReviewThreadOutput> { + const mutation = resolved + ? `mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } }` + : `mutation($threadId: ID!) { unresolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } }`; + + try { + const result = await execGh(["api", "graphql", "--input", "-"], { + input: JSON.stringify({ + query: mutation, + variables: { threadId: threadNodeId }, + }), + }); + + if (result.exitCode !== 0) { + return { success: false, isResolved: !resolved }; + } + + const data = JSON.parse(result.stdout) as { + data: { + resolveReviewThread?: { thread: { isResolved: boolean } }; + unresolveReviewThread?: { thread: { isResolved: boolean } }; + }; + errors?: Array<{ message: string }>; + }; + if (data.errors?.length) { + return { success: false, isResolved: !resolved }; + } + const thread = + data.data.resolveReviewThread?.thread ?? + data.data.unresolveReviewThread?.thread; + + return { success: true, isResolved: thread?.isResolved ?? resolved }; + } catch { + return { success: false, isResolved: !resolved }; + } + } + + async replyToPrComment( + prUrl: string, + commentId: number, + body: string, + ): Promise<ReplyToPrCommentOutput> { + const pr = parseGithubUrl(prUrl); + if (pr?.kind !== "pr") { + return { success: false, comment: null }; + } + + try { + const result = await execGh([ + "api", + `repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/comments/${commentId}/replies`, + "-X", + "POST", + "-f", + `body=${body}`, + ]); + + if (result.exitCode !== 0) { + return { success: false, comment: null }; + } + + const data = JSON.parse(result.stdout) as PrReviewComment; + return { success: true, comment: data }; + } catch { + return { success: false, comment: null }; + } + } + + async getPrTemplate(directoryPath: string): Promise<GetPrTemplateOutput> { + const templatePaths = [ + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/pull_request_template.md", + "PULL_REQUEST_TEMPLATE.md", + "pull_request_template.md", + "docs/PULL_REQUEST_TEMPLATE.md", + ]; + + for (const relativePath of templatePaths) { + const fullPath = path.join(directoryPath, relativePath); + try { + const content = await fs.promises.readFile(fullPath, "utf-8"); + return { template: content, templatePath: relativePath }; + } catch {} + } + + return { template: null, templatePath: null }; + } + + async getCommitConventions( + directoryPath: string, + sampleSize = 20, + ): Promise<GetCommitConventionsOutput> { + return getCommitConventions(directoryPath, sampleSize); + } + + private async resolveCanonicalRepo(repo: string): Promise<string> { + const result = await execGh([ + "repo", + "view", + repo, + "--json", + "name,owner", + "--jq", + '.owner.login + "/" + .name', + ]); + if (result.exitCode !== 0) return repo; + return result.stdout.trim() || repo; + } + + private normalizeRefState(raw: string): GithubRef["state"] { + const upper = raw.toUpperCase(); + if (upper === "OPEN") return "OPEN"; + if (upper === "MERGED") return "MERGED"; + return "CLOSED"; + } + + private parseGhRefs( + stdout: string, + repo: string, + kind: GithubRefKind, + ): GithubRef[] { + const raw = JSON.parse(stdout) as Array<{ + number: number; + title: string; + state: string; + labels?: Array<{ name: string }>; + url: string; + isDraft?: boolean; + }>; + const items = Array.isArray(raw) ? raw : [raw]; + return items.map((item) => { + // GitHub's issues API returns PRs too, so derive kind from the URL path. + const resolvedKind: GithubRefKind = item.url.includes("/pull/") + ? "pr" + : kind; + return { + kind: resolvedKind, + number: item.number, + title: item.title, + state: this.normalizeRefState(item.state), + labels: (item.labels ?? []).map((l) => l.name), + url: item.url, + repo, + isDraft: resolvedKind === "pr" ? Boolean(item.isDraft) : undefined, + }; + }); + } + + async searchGithubRefs( + directoryPath: string, + query?: string, + limit = 5, + kinds: GithubRefKind[] = ["issue", "pr"], + ): Promise<GithubRef[]> { + const repoInfo = await this.getGitRepoInfo(directoryPath); + if (!repoInfo) return []; + + // Full GitHub URL: look up directly. May target a different repo than the local one. + const urlRef = parseGithubUrl(query); + if (urlRef && urlRef.kind !== "repo" && kinds.includes(urlRef.kind)) { + const repoSlug = `${urlRef.owner}/${urlRef.repo}`; + return this.fetchGhRefs( + [urlRef.kind, "view", String(urlRef.number), "--repo", repoSlug], + repoSlug, + urlRef.kind, + ); + } + + const repo = await this.resolveCanonicalRepo( + `${repoInfo.organization}/${repoInfo.repository}`, + ); + + const trimmed = query?.trim().replace(/^#/, ""); + const refNumber = trimmed ? Number(trimmed) : Number.NaN; + + // Number lookup: `gh issue view` returns PRs too (shared number space). + if (!Number.isNaN(refNumber) && Number.isInteger(refNumber)) { + return this.fetchGhRefs( + ["issue", "view", String(refNumber), "--repo", repo], + repo, + "issue", + ); + } + + // Text search: one call via `gh search issues --include-prs` when both kinds are wanted. + if (trimmed) { + const includeIssues = kinds.includes("issue"); + const includePrs = kinds.includes("pr"); + const searchNoun = !includeIssues && includePrs ? "prs" : "issues"; + const args = [ + "search", + searchNoun, + trimmed, + "--repo", + repo, + "--limit", + String(limit), + "--match", + "title", + ]; + if (searchNoun === "issues" && includePrs) args.push("--include-prs"); + return this.fetchGhRefs(args, repo, "issue"); + } + + // Empty query: list defaults per-kind in parallel (`gh search` requires a query). + const tasks: Promise<GithubRef[]>[] = []; + if (kinds.includes("issue")) { + tasks.push( + this.fetchGhRefs( + [ + "issue", + "list", + "--repo", + repo, + "--limit", + String(limit), + "--state", + "all", + ], + repo, + "issue", + ), + ); + } + if (kinds.includes("pr")) { + tasks.push( + this.fetchGhRefs( + [ + "pr", + "list", + "--repo", + repo, + "--limit", + String(limit), + "--state", + "all", + ], + repo, + "pr", + ), + ); + } + const results = await Promise.all(tasks); + return this.sortRefs(this.dedupeRefsByUrl(results.flat())); + } + + private dedupeRefsByUrl(refs: GithubRef[]): GithubRef[] { + const byUrl = new Map<string, GithubRef>(); + for (const ref of refs) { + if (!byUrl.has(ref.url)) byUrl.set(ref.url, ref); + } + return [...byUrl.values()]; + } + + private sortRefs(refs: GithubRef[]): GithubRef[] { + return refs.sort((a, b) => b.number - a.number); + } + + async getGithubIssue( + owner: string, + repo: string, + number: number, + ): Promise<GithubRef | null> { + const repoSlug = `${owner}/${repo}`; + const refs = await this.fetchGhRefs( + ["issue", "view", String(number), "--repo", repoSlug], + repoSlug, + "issue", + ); + return refs[0] ?? null; + } + + async getGithubPullRequest( + owner: string, + repo: string, + number: number, + ): Promise<GithubRef | null> { + const repoSlug = `${owner}/${repo}`; + const refs = await this.fetchGhRefs( + ["pr", "view", String(number), "--repo", repoSlug], + repoSlug, + "pr", + ); + return refs[0] ?? null; + } + + private async fetchGhRefs( + args: string[], + repo: string, + kind: GithubRefKind, + ): Promise<GithubRef[]> { + const jsonFields = + kind === "pr" + ? "number,title,state,url,isDraft" + : "number,title,state,labels,url"; + const result = await execGh([...args, "--json", jsonFields]); + if (result.exitCode !== 0) return []; + + try { + return this.parseGhRefs(result.stdout, repo, kind); + } catch { + return []; + } + } + + async readHandoffLocalGitState( + directoryPath: string, + ): Promise<HandoffLocalGitState> { + return readHandoffLocalGitState(directoryPath); + } + + async cleanupAfterCloudHandoff( + directoryPath: string, + branchName: string | null, + ): Promise<{ + stashed: boolean; + switched: boolean; + defaultBranch: string | null; + }> { + let stashed = false; + const hasChanges = + (await this.getChangedFilesHead(directoryPath)).length > 0; + + if (hasChanges) { + const label = branchName ?? "unknown"; + const stashResult = await new StashPushSaga().run({ + baseDir: directoryPath, + message: `posthog-code: handoff backup (${label})`, + }); + if (!stashResult.success) { + return { stashed: false, switched: false, defaultBranch: null }; + } + stashed = true; + } + + const resetResult = await new ResetToDefaultBranchSaga().run({ + baseDir: directoryPath, + }); + if (!resetResult.success) { + return { stashed, switched: false, defaultBranch: null }; + } + + return { + stashed, + switched: resetResult.data.switched, + defaultBranch: resetResult.data.defaultBranch, + }; + } } diff --git a/packages/workspace-server/src/services/handoff/identifiers.ts b/packages/workspace-server/src/services/handoff/identifiers.ts new file mode 100644 index 0000000000..f1b1cc58a1 --- /dev/null +++ b/packages/workspace-server/src/services/handoff/identifiers.ts @@ -0,0 +1,6 @@ +export const HANDOFF_GIT_GATEWAY = Symbol.for( + "posthog.workspaceServer.handoffGitGateway", +); +export const HANDOFF_LOG_GATEWAY = Symbol.for( + "posthog.workspaceServer.handoffLogGateway", +); diff --git a/packages/workspace-server/src/services/handoff/ports.ts b/packages/workspace-server/src/services/handoff/ports.ts new file mode 100644 index 0000000000..ef6e685c22 --- /dev/null +++ b/packages/workspace-server/src/services/handoff/ports.ts @@ -0,0 +1,29 @@ +import type { HandoffChangedFile, HandoffLocalGitState } from "@posthog/shared"; + +/** + * Git operations the handoff host needs. The git CLI runs in the + * workspace-server child process, so the desktop fulfills this with a thin + * transport adapter over the workspace client. + */ +export interface HandoffGitGateway { + getChangedFiles(repoPath: string): Promise<readonly HandoffChangedFile[]>; + getLocalGitState(repoPath: string): Promise<HandoffLocalGitState>; + cleanupAfterCloudHandoff( + repoPath: string, + branchName: string | null, + ): Promise<{ + stashed: boolean; + switched: boolean; + defaultBranch: string | null; + }>; +} + +/** + * Local NDJSON log-cache operations the handoff host needs, served by the + * workspace-server local-logs capability. + */ +export interface HandoffLogGateway { + seedLocalLogs(taskRunId: string, content: string): Promise<void>; + countLocalLogEntries(taskRunId: string): Promise<number>; + deleteLocalLogCache(taskRunId: string): Promise<void>; +} diff --git a/packages/workspace-server/src/services/handoff/service.test.ts b/packages/workspace-server/src/services/handoff/service.test.ts new file mode 100644 index 0000000000..92c2123657 --- /dev/null +++ b/packages/workspace-server/src/services/handoff/service.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HandoffHostService } from "./service"; + +function createService(overrides: { + workspaceRepo?: Partial<{ + findByTaskId: ReturnType<typeof vi.fn>; + setModeAndRepository: ReturnType<typeof vi.fn>; + updateMode: ReturnType<typeof vi.fn>; + }>; + repositoryRepo?: Partial<{ findByPath: ReturnType<typeof vi.fn> }>; + git?: object; + logs?: object; +}) { + const workspaceRepo = { + findByTaskId: vi.fn(), + setModeAndRepository: vi.fn(), + updateMode: vi.fn(), + ...overrides.workspaceRepo, + }; + const repositoryRepo = { + findByPath: vi.fn(), + ...overrides.repositoryRepo, + }; + const git = { + getChangedFiles: vi.fn(), + getLocalGitState: vi.fn(), + cleanupAfterCloudHandoff: vi.fn(), + ...overrides.git, + }; + const logs = { + seedLocalLogs: vi.fn().mockResolvedValue(undefined), + countLocalLogEntries: vi.fn(), + deleteLocalLogCache: vi.fn(), + ...overrides.logs, + }; + + const service = new HandoffHostService( + {} as never, + {} as never, + workspaceRepo as never, + repositoryRepo as never, + {} as never, + {} as never, + git as never, + logs as never, + ); + return { service, workspaceRepo, repositoryRepo, git, logs }; +} + +describe("HandoffHostService.attachWorkspaceToFolder", () => { + it("throws when the folder is not registered", () => { + const { service } = createService({ + repositoryRepo: { findByPath: vi.fn().mockReturnValue(null) }, + }); + expect(() => service.attachWorkspaceToFolder("task-1", "/repo")).toThrow( + "No registered folder", + ); + }); + + it("throws when the task has no workspace", () => { + const { service } = createService({ + repositoryRepo: { findByPath: vi.fn().mockReturnValue({ id: "r1" }) }, + workspaceRepo: { findByTaskId: vi.fn().mockReturnValue(null) }, + }); + expect(() => service.attachWorkspaceToFolder("task-1", "/repo")).toThrow( + "No workspace exists", + ); + }); + + it("is a no-op revert when already local on the same repository", () => { + const { service, workspaceRepo } = createService({ + repositoryRepo: { findByPath: vi.fn().mockReturnValue({ id: "r1" }) }, + workspaceRepo: { + findByTaskId: vi + .fn() + .mockReturnValue({ mode: "local", repositoryId: "r1" }), + }, + }); + const { revert } = service.attachWorkspaceToFolder("task-1", "/repo"); + revert(); + expect(workspaceRepo.setModeAndRepository).not.toHaveBeenCalled(); + }); + + it("attaches and reverts to the previous mode/repository", () => { + const { service, workspaceRepo } = createService({ + repositoryRepo: { findByPath: vi.fn().mockReturnValue({ id: "r1" }) }, + workspaceRepo: { + findByTaskId: vi + .fn() + .mockReturnValue({ mode: "cloud", repositoryId: "old" }), + }, + }); + const { revert } = service.attachWorkspaceToFolder("task-1", "/repo"); + expect(workspaceRepo.setModeAndRepository).toHaveBeenCalledWith( + "task-1", + "local", + "r1", + ); + revert(); + expect(workspaceRepo.setModeAndRepository).toHaveBeenLastCalledWith( + "task-1", + "cloud", + "old", + ); + }); +}); + +describe("HandoffHostService.seedLocalLogs", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("seeds fetched content into the log gateway", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, text: () => "a\nb\n" }), + ); + const { service, logs } = createService({}); + await service.seedLocalLogs("run-1", "https://logs"); + expect(logs.seedLocalLogs).toHaveBeenCalledWith("run-1", "a\nb\n"); + }); + + it("skips seeding when the fetch fails", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false })); + const { service, logs } = createService({}); + await service.seedLocalLogs("run-1", "https://logs"); + expect(logs.seedLocalLogs).not.toHaveBeenCalled(); + }); + + it("skips seeding when the content is blank", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, text: () => " " }), + ); + const { service, logs } = createService({}); + await service.seedLocalLogs("run-1", "https://logs"); + expect(logs.seedLocalLogs).not.toHaveBeenCalled(); + }); +}); + +describe("HandoffHostService delegation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("delegates git + log reads to their gateways", async () => { + const { service, git, logs } = createService({ + git: { + getChangedFiles: vi.fn().mockResolvedValue([]), + getLocalGitState: vi.fn().mockResolvedValue({ branch: "main" }), + }, + logs: { countLocalLogEntries: vi.fn().mockResolvedValue(3) }, + }); + await service.getChangedFiles("/repo"); + await service.getLocalGitState("/repo"); + await service.countLocalLogEntries("run-1"); + expect(git.getChangedFiles).toHaveBeenCalledWith("/repo"); + expect(git.getLocalGitState).toHaveBeenCalledWith("/repo"); + expect(logs.countLocalLogEntries).toHaveBeenCalledWith("run-1"); + }); +}); diff --git a/packages/workspace-server/src/services/handoff/service.ts b/packages/workspace-server/src/services/handoff/service.ts new file mode 100644 index 0000000000..6ca8b0f216 --- /dev/null +++ b/packages/workspace-server/src/services/handoff/service.ts @@ -0,0 +1,278 @@ +import { POSTHOG_NOTIFICATIONS } from "@posthog/agent"; +import { HandoffCheckpointTracker } from "@posthog/agent/handoff-checkpoint"; +import { PostHogAPIClient } from "@posthog/agent/posthog-api"; +import type * as AgentResume from "@posthog/agent/resume"; +import { + formatConversationForResume, + resumeFromLog, +} from "@posthog/agent/resume"; +import type { GitHandoffBranchDivergence } from "@posthog/git/handoff"; +import { + APP_LIFECYCLE_SERVICE, + type IAppLifecycle, +} from "@posthog/platform/app-lifecycle"; +import { DIALOG_SERVICE, type IDialog } from "@posthog/platform/dialog"; +import type { + GitHandoffCheckpoint, + HandoffApiContext, + HandoffChangedFile, + HandoffHost, + HandoffLocalGitState, + HandoffReconnectParams, + HandoffResumeStateResult, + WorkspaceMode, +} from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import { + REPOSITORY_REPOSITORY, + WORKSPACE_REPOSITORY, +} from "../../db/identifiers"; +import type { IRepositoryRepository } from "../../db/repositories/repository-repository"; +import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; +import type { AgentService } from "../agent/agent"; +import type { AgentAuthAdapter } from "../agent/auth-adapter"; +import { AGENT_AUTH_ADAPTER, AGENT_SERVICE } from "../agent/identifiers"; +import { HANDOFF_GIT_GATEWAY, HANDOFF_LOG_GATEWAY } from "./identifiers"; +import type { HandoffGitGateway, HandoffLogGateway } from "./ports"; + +const CONTINUE_DIVERGENCE_BUTTON = 1; + +/** + * Host implementation of the core handoff orchestration's HANDOFF_HOST port. + * Owns the agent runtime glue (api client, checkpoint tracker, log resume), + * workspace/repository persistence, and the diverged-branch confirmation. Git + * and local-log syscalls run in the workspace-server child process, reached + * through the injected gateways. + */ +@injectable() +export class HandoffHostService implements HandoffHost { + constructor( + @inject(AGENT_SERVICE) + private readonly agentService: AgentService, + @inject(AGENT_AUTH_ADAPTER) + private readonly agentAuthAdapter: AgentAuthAdapter, + @inject(WORKSPACE_REPOSITORY) + private readonly workspaceRepo: IWorkspaceRepository, + @inject(REPOSITORY_REPOSITORY) + private readonly repositoryRepo: IRepositoryRepository, + @inject(DIALOG_SERVICE) + private readonly dialog: IDialog, + @inject(APP_LIFECYCLE_SERVICE) + private readonly appLifecycle: IAppLifecycle, + @inject(HANDOFF_GIT_GATEWAY) + private readonly git: HandoffGitGateway, + @inject(HANDOFF_LOG_GATEWAY) + private readonly logs: HandoffLogGateway, + ) {} + + getChangedFiles(repoPath: string): Promise<readonly HandoffChangedFile[]> { + return this.git.getChangedFiles(repoPath); + } + + getLocalGitState(repoPath: string): Promise<HandoffLocalGitState> { + return this.git.getLocalGitState(repoPath); + } + + async markRunEnvironmentLocal( + ctx: HandoffApiContext, + taskId: string, + runId: string, + ): Promise<void> { + const apiClient = this.createApiClient(ctx); + await apiClient.updateTaskRun(taskId, runId, { environment: "local" }); + } + + async fetchResumeState( + ctx: HandoffApiContext, + taskId: string, + runId: string, + ): Promise<HandoffResumeStateResult> { + const apiClient = this.createApiClient(ctx); + const taskRun = await apiClient.getTaskRun(taskId, runId); + const resumeState = await resumeFromLog({ taskId, runId, apiClient }); + return { + resumeState: { + conversation: resumeState.conversation, + latestGitCheckpoint: resumeState.latestGitCheckpoint, + }, + cloudLogUrl: taskRun.log_url ?? null, + }; + } + + formatConversation(conversation: unknown[]): string { + return formatConversationForResume( + conversation as AgentResume.ConversationTurn[], + ); + } + + async applyGitCheckpoint( + ctx: HandoffApiContext, + checkpoint: GitHandoffCheckpoint, + repoPath: string, + taskId: string, + runId: string, + localGitState?: HandoffLocalGitState, + ): Promise<void> { + const apiClient = this.createApiClient(ctx); + const tracker = new HandoffCheckpointTracker({ + repositoryPath: repoPath, + taskId, + runId, + apiClient, + }); + await tracker.applyFromHandoff(checkpoint, { + localGitState, + onDivergedBranch: (divergence) => + this.confirmDivergedBranchReset(divergence), + }); + } + + reconnectSession( + params: HandoffReconnectParams, + ): Promise<{ sessionId: string } | null> { + return this.agentService.reconnectSession(params); + } + + attachWorkspaceToFolder( + taskId: string, + repoPath: string, + ): { revert: () => void } { + const repository = this.repositoryRepo.findByPath(repoPath); + if (!repository) { + throw new Error( + `No registered folder for path '${repoPath}' — cannot attach workspace`, + ); + } + const previous = this.workspaceRepo.findByTaskId(taskId); + if (!previous) { + throw new Error(`No workspace exists for task ${taskId}`); + } + if (previous.mode === "local" && previous.repositoryId === repository.id) { + return { revert: () => {} }; + } + this.workspaceRepo.setModeAndRepository(taskId, "local", repository.id); + return { + revert: () => { + this.workspaceRepo.setModeAndRepository( + taskId, + previous.mode, + previous.repositoryId, + ); + }, + }; + } + + async seedLocalLogs(runId: string, logUrl: string): Promise<void> { + const response = await fetch(logUrl); + if (!response.ok) return; + const content = await response.text(); + if (!content?.trim()) return; + await this.logs.seedLocalLogs(runId, content); + } + + setPendingContext(taskRunId: string, context: string): void { + this.agentService.setPendingContext(taskRunId, context); + } + + async killSession(taskRunId: string): Promise<void> { + await this.agentService.cancelSession(taskRunId); + } + + updateWorkspaceMode(taskId: string, mode: WorkspaceMode): void { + this.workspaceRepo.updateMode(taskId, mode); + } + + async captureGitCheckpoint( + ctx: HandoffApiContext, + repoPath: string, + taskId: string, + runId: string, + localGitState?: HandoffLocalGitState, + ): Promise<GitHandoffCheckpoint | null> { + const apiClient = this.createApiClient(ctx); + const tracker = new HandoffCheckpointTracker({ + repositoryPath: repoPath, + taskId, + runId, + apiClient, + }); + const checkpoint = await tracker.captureForHandoff(localGitState); + if (!checkpoint) return null; + const localCheckpoint = { + ...checkpoint, + device: { type: "local" as const }, + }; + return localCheckpoint; + } + + async persistCheckpointToLog( + ctx: HandoffApiContext, + taskId: string, + runId: string, + checkpoint: GitHandoffCheckpoint, + ): Promise<void> { + const apiClient = this.createApiClient(ctx); + await apiClient.appendTaskRunLog(taskId, runId, [ + { + type: "notification", + timestamp: new Date().toISOString(), + notification: { + jsonrpc: "2.0", + method: POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT, + params: checkpoint as unknown as Record<string, unknown>, + }, + }, + ]); + } + + countLocalLogEntries(runId: string): Promise<number> { + return this.logs.countLocalLogEntries(runId); + } + + async resumeRunInCloud( + ctx: HandoffApiContext, + taskId: string, + runId: string, + ): Promise<void> { + const apiClient = this.createApiClient(ctx); + await apiClient.resumeRunInCloud(taskId, runId); + } + + async cleanupLocalAfterCloudHandoff( + repoPath: string, + branchName: string | null, + ): Promise<void> { + await this.git.cleanupAfterCloudHandoff(repoPath, branchName); + } + + deleteLocalLogCache(runId: string): Promise<void> { + return this.logs.deleteLocalLogCache(runId); + } + + private createApiClient(ctx: HandoffApiContext): PostHogAPIClient { + const config = this.agentAuthAdapter.createPosthogConfig({ + apiHost: ctx.apiHost, + projectId: ctx.teamId, + }); + return new PostHogAPIClient(config); + } + + private async confirmDivergedBranchReset( + divergence: GitHandoffBranchDivergence, + ): Promise<boolean> { + await this.appLifecycle.whenReady(); + + const response = await this.dialog.confirm({ + severity: "warning", + options: ["Cancel", "Continue"], + defaultIndex: 0, + cancelIndex: 0, + title: "Local branch has diverged", + message: `The local branch '${divergence.branch}' has commits that are not in the cloud handoff.`, + detail: + `Continuing will reset '${divergence.branch}' from ${divergence.localHead.slice(0, 7)} to ${divergence.cloudHead.slice(0, 7)}.\n\n` + + "Cancel if you want to keep the current local branch tip.", + }); + return response === CONTINUE_DIVERGENCE_BUTTON; + } +} diff --git a/packages/workspace-server/src/services/local-logs/identifiers.ts b/packages/workspace-server/src/services/local-logs/identifiers.ts new file mode 100644 index 0000000000..9b978898b3 --- /dev/null +++ b/packages/workspace-server/src/services/local-logs/identifiers.ts @@ -0,0 +1,7 @@ +export const LOGS_SERVICE = Symbol.for("posthog.workspace.logsService"); + +export interface ILogsService { + fetchS3Logs(logUrl: string): Promise<string | null>; + readLocalLogs(taskRunId: string): Promise<string | null>; + writeLocalLogs(taskRunId: string, content: string): Promise<void>; +} diff --git a/packages/workspace-server/src/services/local-logs/schemas.ts b/packages/workspace-server/src/services/local-logs/schemas.ts new file mode 100644 index 0000000000..15f59fc819 --- /dev/null +++ b/packages/workspace-server/src/services/local-logs/schemas.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const fetchS3LogsInput = z.object({ logUrl: z.string().min(1) }); +export const fetchS3LogsOutput = z.string().nullable(); + +export const readLocalLogsInput = z.object({ taskRunId: z.string().min(1) }); +export const readLocalLogsOutput = z.string().nullable(); + +export const writeLocalLogsInput = z.object({ + taskRunId: z.string().min(1), + content: z.string(), +}); + +export const seedLocalLogsInput = z.object({ + taskRunId: z.string().min(1), + content: z.string(), +}); + +export const countLocalLogEntriesInput = z.object({ + taskRunId: z.string().min(1), +}); +export const countLocalLogEntriesOutput = z.number(); + +export const deleteLocalLogCacheInput = z.object({ + taskRunId: z.string().min(1), +}); diff --git a/apps/code/src/main/services/local-logs/service.test.ts b/packages/workspace-server/src/services/local-logs/service.test.ts similarity index 75% rename from apps/code/src/main/services/local-logs/service.test.ts rename to packages/workspace-server/src/services/local-logs/service.test.ts index 80b735e739..c6d93d8493 100644 --- a/apps/code/src/main/services/local-logs/service.test.ts +++ b/packages/workspace-server/src/services/local-logs/service.test.ts @@ -2,10 +2,11 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { mockMkdir, mockWriteFile, mockReadFile } = vi.hoisted(() => ({ +const { mockMkdir, mockWriteFile, mockReadFile, mockRm } = vi.hoisted(() => ({ mockMkdir: vi.fn(), mockWriteFile: vi.fn(), mockReadFile: vi.fn(), + mockRm: vi.fn(), })); vi.mock("node:fs", () => ({ @@ -14,21 +15,11 @@ vi.mock("node:fs", () => ({ mkdir: mockMkdir, writeFile: mockWriteFile, readFile: mockReadFile, + rm: mockRm, }, }, })); -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - import { LocalLogsService } from "./service"; const RUN_ID = "run-abc"; @@ -63,6 +54,7 @@ describe("LocalLogsService", () => { mockMkdir.mockReset().mockResolvedValue(undefined); mockWriteFile.mockReset().mockResolvedValue(undefined); mockReadFile.mockReset(); + mockRm.mockReset().mockResolvedValue(undefined); }); describe("readLocalLogs", () => { @@ -231,4 +223,60 @@ describe("LocalLogsService", () => { expect(mockMkdir).toHaveBeenCalledTimes(1); }); }); + + describe("seedLocalLogs", () => { + it("appends a seed boundary marker and writes the NDJSON", async () => { + const service = new LocalLogsService(); + await service.seedLocalLogs(RUN_ID, "a\nb\n"); + expect(mockMkdir).toHaveBeenCalledWith(path.dirname(expectedPath), { + recursive: true, + }); + expect(mockWriteFile).toHaveBeenCalledWith( + expectedPath, + `a\nb\n${JSON.stringify({ type: "seed_boundary" })}\n`, + "utf-8", + ); + }); + + it("adds a trailing newline before the marker when missing", async () => { + const service = new LocalLogsService(); + await service.seedLocalLogs(RUN_ID, "no-newline"); + expect(mockWriteFile).toHaveBeenCalledWith( + expectedPath, + `no-newline\n${JSON.stringify({ type: "seed_boundary" })}\n`, + "utf-8", + ); + }); + + it("skips empty content", async () => { + const service = new LocalLogsService(); + await service.seedLocalLogs(RUN_ID, " "); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + }); + + describe("countLocalLogEntries", () => { + it("counts non-blank lines", async () => { + mockReadFile.mockResolvedValue("a\n\nb\n c \n\n"); + const service = new LocalLogsService(); + await expect(service.countLocalLogEntries(RUN_ID)).resolves.toBe(3); + expect(mockReadFile).toHaveBeenCalledWith(expectedPath, "utf-8"); + }); + + it("returns 0 when the log is missing", async () => { + mockReadFile.mockRejectedValue( + Object.assign(new Error("nope"), { code: "ENOENT" }), + ); + const service = new LocalLogsService(); + await expect(service.countLocalLogEntries(RUN_ID)).resolves.toBe(0); + }); + }); + + describe("deleteLocalLogCache", () => { + it("force-removes the run's NDJSON path", async () => { + const service = new LocalLogsService(); + await service.deleteLocalLogCache(RUN_ID); + expect(mockRm).toHaveBeenCalledWith(expectedPath, { force: true }); + }); + }); }); diff --git a/apps/code/src/main/services/local-logs/service.ts b/packages/workspace-server/src/services/local-logs/service.ts similarity index 63% rename from apps/code/src/main/services/local-logs/service.ts rename to packages/workspace-server/src/services/local-logs/service.ts index 4c4281bf2f..0ded299537 100644 --- a/apps/code/src/main/services/local-logs/service.ts +++ b/packages/workspace-server/src/services/local-logs/service.ts @@ -3,10 +3,10 @@ import os from "node:os"; import path from "node:path"; import { injectable } from "inversify"; -import { DATA_DIR } from "../../../shared/constants"; -import { logger } from "../../utils/logger"; -const log = logger.scope("local-logs"); +import type { ILogsService } from "./identifiers"; + +const DATA_DIR = ".posthog-code"; interface WriteState { pending: string | undefined; @@ -19,12 +19,27 @@ interface WriteState { * gap-reconcile loop from spawning parallel writeFile of the same NDJSON. */ @injectable() -export class LocalLogsService { +export class LocalLogsService implements ILogsService { private writes = new Map< string, { state: WriteState; inFlight: Promise<void> } >(); + async fetchS3Logs(logUrl: string): Promise<string | null> { + try { + const response = await fetch(logUrl); + if (response.status === 404) { + return null; + } + if (!response.ok) { + return null; + } + return await response.text(); + } catch { + return null; + } + } + async readLocalLogs(taskRunId: string): Promise<string | null> { const logPath = this.getLocalLogPath(taskRunId); try { @@ -33,7 +48,6 @@ export class LocalLogsService { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return null; } - log.warn("Failed to read local logs:", error); return null; } } @@ -55,6 +69,34 @@ export class LocalLogsService { return inFlight; } + async seedLocalLogs(taskRunId: string, content: string): Promise<void> { + if (!content?.trim()) return; + const logPath = this.getLocalLogPath(taskRunId); + const marker = JSON.stringify({ type: "seed_boundary" }); + const trailingNewline = content.endsWith("\n") ? "" : "\n"; + await fs.promises.mkdir(path.dirname(logPath), { recursive: true }); + await fs.promises.writeFile( + logPath, + `${content}${trailingNewline}${marker}\n`, + "utf-8", + ); + } + + async countLocalLogEntries(taskRunId: string): Promise<number> { + const logPath = this.getLocalLogPath(taskRunId); + try { + const content = await fs.promises.readFile(logPath, "utf-8"); + return content.split("\n").filter((line) => line.trim()).length; + } catch { + return 0; + } + } + + async deleteLocalLogCache(taskRunId: string): Promise<void> { + const logPath = this.getLocalLogPath(taskRunId); + await fs.promises.rm(logPath, { force: true }); + } + private async drain( taskRunId: string, initialContent: string, @@ -91,9 +133,7 @@ export class LocalLogsService { state.dirReady = true; } await fs.promises.writeFile(logPath, content, "utf-8"); - } catch (error) { - log.warn("Failed to write local logs:", error); - } + } catch {} } private getLocalLogPath(taskRunId: string): string { diff --git a/packages/workspace-server/src/services/mcp-callback/identifiers.ts b/packages/workspace-server/src/services/mcp-callback/identifiers.ts new file mode 100644 index 0000000000..e94b718c41 --- /dev/null +++ b/packages/workspace-server/src/services/mcp-callback/identifiers.ts @@ -0,0 +1,6 @@ +export const MCP_CALLBACK_SERVER = Symbol.for( + "posthog.workspace.mcpCallbackServer", +); +export const MCP_CALLBACK_SERVICE = Symbol.for( + "posthog.workspace.mcpCallbackService", +); diff --git a/packages/workspace-server/src/services/mcp-callback/mcp-callback-server.ts b/packages/workspace-server/src/services/mcp-callback/mcp-callback-server.ts new file mode 100644 index 0000000000..4e145efed9 --- /dev/null +++ b/packages/workspace-server/src/services/mcp-callback/mcp-callback-server.ts @@ -0,0 +1,136 @@ +import * as http from "node:http"; +import type { Socket } from "node:net"; +import { injectable } from "inversify"; + +export interface WaitForCallbackOptions { + port: number; + /** Pathname to match, e.g. "/mcp-oauth-complete". */ + path: string; + timeoutMs: number; + signal?: AbortSignal; + /** Fired once the server is listening — the caller opens the browser here. */ + onListening?: () => void; + /** Decides whether to render the success or error page from the params. */ + successWhen: (params: URLSearchParams) => boolean; +} + +/** + * Local HTTP server that receives an OAuth-style redirect in development and + * resolves with the callback query params. Owns the Node `http.Server`, + * connection tracking, timeout, and the served HTML. Rejects on timeout / + * cancellation (via `signal`) / listen error. + */ +@injectable() +export class McpCallbackServer { + waitForCallback(options: WaitForCallbackOptions): Promise<URLSearchParams> { + const { port, path, timeoutMs, signal, onListening, successWhen } = options; + + return new Promise<URLSearchParams>((resolve, reject) => { + const connections = new Set<Socket>(); + let settled = false; + + const cleanup = () => { + clearTimeout(timeoutId); + signal?.removeEventListener("abort", onAbort); + for (const conn of connections) { + conn.destroy(); + } + connections.clear(); + server.close(); + }; + + const finish = (action: () => void) => { + if (settled) return; + settled = true; + cleanup(); + action(); + }; + + const server = http.createServer((req, res) => { + if (!req.url) { + res.writeHead(400); + res.end(); + return; + } + + const url = new URL(req.url, `http://localhost:${port}`); + + if (url.pathname === path) { + const ok = successWhen(url.searchParams); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(callbackHtml(ok ? "success" : "error")); + finish(() => resolve(url.searchParams)); + } else { + res.writeHead(404); + res.end(); + } + }); + + server.on("connection", (conn) => { + connections.add(conn); + conn.on("close", () => connections.delete(conn)); + }); + + const timeoutId = setTimeout(() => { + finish(() => reject(new Error("MCP OAuth authorization timed out"))); + }, timeoutMs); + + const onAbort = () => { + finish(() => reject(new Error("MCP OAuth flow cancelled"))); + }; + + if (signal) { + if (signal.aborted) { + finish(() => reject(new Error("MCP OAuth flow cancelled"))); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + + server.on("error", (error) => { + finish(() => + reject( + new Error(`Failed to start callback server: ${error.message}`), + ), + ); + }); + + server.listen(port, () => { + onListening?.(); + }); + }); + } +} + +function callbackHtml(status: "success" | "error"): string { + const titles = { + success: "Authorization successful!", + error: "Authorization failed", + }; + const messages = { + success: "You can close this window and return to PostHog Code.", + error: "You can close this window and return to PostHog Code.", + }; + + return `<!DOCTYPE html> +<html class="radix-themes" data-is-root-theme="true" data-accent-color="orange" data-gray-color="slate" data-has-background="true" data-panel-background="translucent" data-radius="none" data-scaling="100%"> + <head> + <meta charset="utf-8"> + <title>${titles[status]} + + + + + +

${titles[status]}

+

${messages[status]}

+ + +`; +} diff --git a/packages/workspace-server/src/services/mcp-callback/mcp-callback.module.ts b/packages/workspace-server/src/services/mcp-callback/mcp-callback.module.ts new file mode 100644 index 0000000000..e0094dd581 --- /dev/null +++ b/packages/workspace-server/src/services/mcp-callback/mcp-callback.module.ts @@ -0,0 +1,9 @@ +import { ContainerModule } from "inversify"; +import { MCP_CALLBACK_SERVER, MCP_CALLBACK_SERVICE } from "./identifiers"; +import { McpCallbackService } from "./mcp-callback"; +import { McpCallbackServer } from "./mcp-callback-server"; + +export const mcpCallbackModule = new ContainerModule(({ bind }) => { + bind(MCP_CALLBACK_SERVER).to(McpCallbackServer).inSingletonScope(); + bind(MCP_CALLBACK_SERVICE).to(McpCallbackService).inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/mcp-callback/mcp-callback.ts b/packages/workspace-server/src/services/mcp-callback/mcp-callback.ts new file mode 100644 index 0000000000..31dff2f78a --- /dev/null +++ b/packages/workspace-server/src/services/mcp-callback/mcp-callback.ts @@ -0,0 +1,212 @@ +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { APP_META_SERVICE, type IAppMeta } from "@posthog/platform/app-meta"; +import { + DEEP_LINK_SERVICE, + type IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { + type IUrlLauncher, + URL_LAUNCHER_SERVICE, +} from "@posthog/platform/url-launcher"; +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import { MCP_CALLBACK_SERVER } from "./identifiers"; +import type { McpCallbackServer } from "./mcp-callback-server"; +import { + type GetCallbackUrlOutput, + McpCallbackEvent, + type McpCallbackEvents, + type McpCallbackResult, + type OpenAndWaitOutput, +} from "./schemas"; + +const MCP_CALLBACK_KEY = "mcp-oauth-complete"; +const DEV_CALLBACK_PORT = 8238; +const OAUTH_TIMEOUT_MS = 180_000; // 3 minutes + +interface PendingCallback { + resolve: (result: McpCallbackResult) => void; + reject: (error: Error) => void; + timeoutId?: NodeJS.Timeout; + abortController?: AbortController; +} + +@injectable() +export class McpCallbackService extends TypedEventEmitter { + private pendingCallback: PendingCallback | null = null; + private readonly log: ScopedLogger; + + constructor( + @inject(DEEP_LINK_SERVICE) + private readonly deepLinkService: IDeepLinkRegistry, + @inject(URL_LAUNCHER_SERVICE) + private readonly urlLauncher: IUrlLauncher, + @inject(MCP_CALLBACK_SERVER) + private readonly callbackServer: McpCallbackServer, + @inject(APP_META_SERVICE) + private readonly appMeta: IAppMeta, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + super(); + this.log = logger.scope("mcp-callback"); + // Register deep link handler for MCP OAuth callbacks (production) + this.deepLinkService.registerHandler( + MCP_CALLBACK_KEY, + (_path, searchParams) => this.handleCallback(searchParams), + ); + this.log.info("Registered MCP OAuth callback handler for deep links"); + } + + /** + * Get the callback URL based on environment (dev vs prod). + */ + public getCallbackUrl(): GetCallbackUrlOutput { + const callbackUrl = !this.appMeta.isProduction + ? `http://localhost:${DEV_CALLBACK_PORT}/${MCP_CALLBACK_KEY}` + : `${this.deepLinkService.getProtocol()}://${MCP_CALLBACK_KEY}`; + return { callbackUrl }; + } + + /** + * Open the OAuth authorization URL in the browser and wait for the callback. + * In dev mode, starts a local HTTP server. In production, uses deep links. + */ + public async openAndWaitForCallback( + redirectUrl: string, + ): Promise { + try { + // Cancel any existing pending callback + this.cancelPending(); + + const result = !this.appMeta.isProduction + ? await this.waitForHttpCallback(redirectUrl) + : await this.waitForDeepLinkCallback(redirectUrl); + + // Emit event for any subscribers + this.emit(McpCallbackEvent.OAuthComplete, result); + + return { + success: result.status === "success", + installationId: result.installationId, + error: result.error, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + return { success: false, error: errorMsg }; + } + } + + private handleCallback(searchParams: URLSearchParams): boolean { + const status = searchParams.get("status") as "success" | "error" | null; + const installationId = searchParams.get("installation_id") ?? undefined; + const error = searchParams.get("error") ?? undefined; + + if (!this.pendingCallback) { + this.log.warn("Received MCP OAuth callback but no pending flow"); + return false; + } + + const { resolve, timeoutId } = this.pendingCallback; + clearTimeout(timeoutId); + this.pendingCallback = null; + + const result: McpCallbackResult = { + status: status === "success" ? "success" : "error", + installationId, + error, + }; + resolve(result); + return true; + } + + /** + * Wait for callback via deep link (production). + */ + private async waitForDeepLinkCallback( + redirectUrl: string, + ): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pendingCallback = null; + reject(new Error("MCP OAuth authorization timed out")); + }, OAUTH_TIMEOUT_MS); + + this.pendingCallback = { + resolve, + reject, + timeoutId, + }; + + // Open the browser for authentication + this.urlLauncher.launch(redirectUrl).catch((error) => { + clearTimeout(timeoutId); + this.pendingCallback = null; + reject(new Error(`Failed to open browser: ${error.message}`)); + }); + }); + } + + /** + * Wait for callback via the workspace-server HTTP server (development). + */ + private async waitForHttpCallback( + redirectUrl: string, + ): Promise { + const abortController = new AbortController(); + this.pendingCallback = { + resolve: () => {}, + reject: () => {}, + abortController, + }; + + try { + const params = await this.callbackServer.waitForCallback({ + port: DEV_CALLBACK_PORT, + path: `/${MCP_CALLBACK_KEY}`, + timeoutMs: OAUTH_TIMEOUT_MS, + signal: abortController.signal, + onListening: () => { + this.log.info( + `Dev MCP OAuth callback server listening on port ${DEV_CALLBACK_PORT}`, + ); + this.urlLauncher.launch(redirectUrl).catch(() => { + abortController.abort(); + }); + }, + successWhen: (queryParams) => queryParams.get("status") === "success", + }); + + const status = params.get("status"); + return { + status: status === "success" ? "success" : "error", + installationId: params.get("installation_id") ?? undefined, + error: params.get("error") ?? undefined, + }; + } finally { + this.pendingCallback = null; + } + } + + /** + * Cancel any pending callback. + */ + private cancelPending(): void { + if (this.pendingCallback) { + if (this.pendingCallback.abortController) { + this.pendingCallback.abortController.abort(); + this.pendingCallback = null; + } else { + if (this.pendingCallback.timeoutId) { + clearTimeout(this.pendingCallback.timeoutId); + } + this.pendingCallback.reject(new Error("MCP OAuth flow cancelled")); + this.pendingCallback = null; + } + } + } +} diff --git a/apps/code/src/main/services/mcp-callback/schemas.ts b/packages/workspace-server/src/services/mcp-callback/schemas.ts similarity index 100% rename from apps/code/src/main/services/mcp-callback/schemas.ts rename to packages/workspace-server/src/services/mcp-callback/schemas.ts diff --git a/packages/workspace-server/src/services/mcp-proxy/identifiers.ts b/packages/workspace-server/src/services/mcp-proxy/identifiers.ts new file mode 100644 index 0000000000..467714237f --- /dev/null +++ b/packages/workspace-server/src/services/mcp-proxy/identifiers.ts @@ -0,0 +1,4 @@ +export const MCP_PROXY_SERVICE = Symbol.for( + "posthog.workspace.mcpProxyService", +); +export const MCP_PROXY_AUTH = Symbol.for("posthog.workspace.mcpProxyAuth"); diff --git a/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.module.ts b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.module.ts new file mode 100644 index 0000000000..ea5b9d950d --- /dev/null +++ b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { MCP_PROXY_SERVICE } from "./identifiers"; +import { McpProxyService } from "./mcp-proxy"; + +export const mcpProxyModule = new ContainerModule(({ bind }) => { + bind(MCP_PROXY_SERVICE).to(McpProxyService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/mcp-proxy/service.test.ts b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.test.ts similarity index 91% rename from apps/code/src/main/services/mcp-proxy/service.test.ts rename to packages/workspace-server/src/services/mcp-proxy/mcp-proxy.test.ts index 290aaf202d..0b273b7ffb 100644 --- a/apps/code/src/main/services/mcp-proxy/service.test.ts +++ b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.test.ts @@ -1,17 +1,7 @@ +import type { WorkbenchLogger } from "@posthog/di/logger"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AuthService } from "../auth/service"; -import { McpProxyService } from "./service"; - -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), - }, -})); +import { McpProxyService } from "./mcp-proxy"; +import type { McpProxyAuth } from "./ports"; type AuthServiceMock = { authenticatedFetch: ReturnType; @@ -39,7 +29,22 @@ describe("McpProxyService", () => { beforeEach(() => { authServiceMock = createAuthServiceMock(); - service = new McpProxyService(authServiceMock as unknown as AuthService); + const loggerMock: WorkbenchLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + scope: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }; + service = new McpProxyService( + authServiceMock as unknown as McpProxyAuth, + loggerMock, + ); }); afterEach(async () => { @@ -105,7 +110,7 @@ describe("McpProxyService", () => { expect(res.status).toBe(200); expect(await res.text()).toBe('{"ok":true}'); expect(authServiceMock.authenticatedFetch).toHaveBeenCalledTimes(1); - const [, url] = authServiceMock.authenticatedFetch.mock.calls[0]; + const [url] = authServiceMock.authenticatedFetch.mock.calls[0]; expect(url).toBe("https://upstream.example"); }); @@ -127,7 +132,7 @@ describe("McpProxyService", () => { }); expect(authServiceMock.authenticatedFetch).toHaveBeenCalledTimes(1); - const [, , options] = authServiceMock.authenticatedFetch.mock.calls[0]; + const [, options] = authServiceMock.authenticatedFetch.mock.calls[0]; expect(options.method).toBe("POST"); expect(Buffer.from(options.body).toString("utf8")).toBe( '{"hello":"world"}', @@ -152,7 +157,7 @@ describe("McpProxyService", () => { }, }); - const [, , options] = authServiceMock.authenticatedFetch.mock.calls[0]; + const [, options] = authServiceMock.authenticatedFetch.mock.calls[0]; const forwardedHeaderKeys = Object.keys(options.headers).map((k) => k.toLowerCase(), ); @@ -178,8 +183,7 @@ describe("McpProxyService", () => { await fetch(`http://127.0.0.1:${port}/alpha/tools/list`); - const [, url] = - authServiceMock.authenticatedFetch.mock.calls.at(-1) ?? []; + const [url] = authServiceMock.authenticatedFetch.mock.calls.at(-1) ?? []; expect(url).toBe("https://upstream.example/inst-2/tools/list"); }); @@ -196,7 +200,7 @@ describe("McpProxyService", () => { await fetch(`${proxyUrl}?token=abc&foo=bar`); - const [, url] = authServiceMock.authenticatedFetch.mock.calls[0]; + const [url] = authServiceMock.authenticatedFetch.mock.calls[0]; expect(url).toBe("https://upstream.example?token=abc&foo=bar"); }); }); diff --git a/apps/code/src/main/services/mcp-proxy/service.ts b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.ts similarity index 88% rename from apps/code/src/main/services/mcp-proxy/service.ts rename to packages/workspace-server/src/services/mcp-proxy/mcp-proxy.ts index 1cf267355e..426ca9e8ae 100644 --- a/apps/code/src/main/services/mcp-proxy/service.ts +++ b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.ts @@ -1,10 +1,12 @@ import http from "node:http"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; import { inject, injectable, preDestroy } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import type { AuthService } from "../auth/service"; - -const log = logger.scope("mcp-proxy"); +import { MCP_PROXY_AUTH } from "./identifiers"; +import type { McpProxyAuth } from "./ports"; function truncateRequestBody(body: RequestInit["body"]): string | undefined { if (body == null) return undefined; @@ -34,10 +36,16 @@ export class McpProxyService { private startPromise: Promise | null = null; private targets = new Map(); + private readonly log: ScopedLogger; + constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, - ) {} + @inject(MCP_PROXY_AUTH) + private readonly auth: McpProxyAuth, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + this.log = logger.scope("mcp-proxy"); + } async start(): Promise { if (this.server && this.port) return; @@ -60,7 +68,7 @@ export class McpProxyService { const addr = server.address(); if (typeof addr === "object" && addr) { this.port = addr.port; - log.info("MCP proxy started", { port: this.port }); + this.log.info("MCP proxy started", { port: this.port }); resolve(); } else { reject(new Error("Failed to get proxy address")); @@ -68,7 +76,7 @@ export class McpProxyService { }); server.on("error", (err) => { - log.error("MCP proxy server error", err); + this.log.error("MCP proxy server error", err); reject(err); }); }); @@ -93,7 +101,7 @@ export class McpProxyService { const server = this.server; await new Promise((resolve) => { server.close(() => { - log.info("MCP proxy stopped"); + this.log.info("MCP proxy stopped"); resolve(); }); }); @@ -114,7 +122,7 @@ export class McpProxyService { const target = this.targets.get(id); if (!target) { - log.warn("Unknown MCP proxy target", { id, url: req.url }); + this.log.warn("Unknown MCP proxy target", { id, url: req.url }); res.writeHead(404); res.end("Unknown target"); return; @@ -167,11 +175,7 @@ export class McpProxyService { res: http.ServerResponse, ): Promise { try { - let response = await this.authService.authenticatedFetch( - fetch, - url, - options, - ); + let response = await this.auth.authenticatedFetch(url, options); // MCP servers return HTTP 200 with auth failures encoded in the JSON-RPC // body, so authenticatedFetch's 401/403 retry never kicks in. Detect the @@ -184,17 +188,13 @@ export class McpProxyService { const bodyText = buf.toString("utf8"); if (this.isAuthErrorBody(bodyText, response.status)) { - log.warn("MCP auth failure — refreshing token and retrying", { + this.log.warn("MCP auth failure — refreshing token and retrying", { id, url, status: response.status, }); - await this.authService.refreshAccessToken(); - response = await this.authService.authenticatedFetch( - fetch, - url, - options, - ); + await this.auth.refreshAccessToken(); + response = await this.auth.authenticatedFetch(url, options); const retryContentType = response.headers.get("content-type") ?? ""; if (!retryContentType.includes("text/event-stream")) { const retryBuf = Buffer.from(await response.arrayBuffer()); @@ -216,9 +216,9 @@ export class McpProxyService { body: bodyText.slice(0, 2000), }; if (response.status >= 500) { - log.error("MCP proxy server error", details); + this.log.error("MCP proxy server error", details); } else { - log.warn("MCP proxy non-OK body", details); + this.log.warn("MCP proxy non-OK body", details); } } @@ -228,7 +228,7 @@ export class McpProxyService { this.writeStreamingResponse(response, res); } catch (err) { - log.error("MCP proxy forward error", { id, url, err }); + this.log.error("MCP proxy forward error", { id, url, err }); if (!res.headersSent) { res.writeHead(502); } diff --git a/packages/workspace-server/src/services/mcp-proxy/ports.ts b/packages/workspace-server/src/services/mcp-proxy/ports.ts new file mode 100644 index 0000000000..555cb6d3a6 --- /dev/null +++ b/packages/workspace-server/src/services/mcp-proxy/ports.ts @@ -0,0 +1,4 @@ +export interface McpProxyAuth { + authenticatedFetch(url: string, init?: RequestInit): Promise; + refreshAccessToken(): Promise; +} diff --git a/packages/workspace-server/src/services/oauth-callback/identifiers.ts b/packages/workspace-server/src/services/oauth-callback/identifiers.ts new file mode 100644 index 0000000000..d75553a978 --- /dev/null +++ b/packages/workspace-server/src/services/oauth-callback/identifiers.ts @@ -0,0 +1,3 @@ +export const OAUTH_CALLBACK_SERVER = Symbol.for( + "posthog.workspace.oauthCallbackServer", +); diff --git a/packages/workspace-server/src/services/oauth-callback/oauth-callback.module.ts b/packages/workspace-server/src/services/oauth-callback/oauth-callback.module.ts new file mode 100644 index 0000000000..ac707c07c2 --- /dev/null +++ b/packages/workspace-server/src/services/oauth-callback/oauth-callback.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { OAUTH_CALLBACK_SERVER } from "./identifiers"; +import { OAuthCallbackServer } from "./oauth-callback"; + +export const oauthCallbackModule = new ContainerModule(({ bind }) => { + bind(OAUTH_CALLBACK_SERVER).to(OAuthCallbackServer).inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/oauth-callback/oauth-callback.ts b/packages/workspace-server/src/services/oauth-callback/oauth-callback.ts new file mode 100644 index 0000000000..57db67a6a4 --- /dev/null +++ b/packages/workspace-server/src/services/oauth-callback/oauth-callback.ts @@ -0,0 +1,151 @@ +import * as http from "node:http"; +import type { Socket } from "node:net"; +import { injectable } from "inversify"; + +export interface WaitForCodeOptions { + port: number; + timeoutMs: number; + signal?: AbortSignal; + /** Fired once the server is listening — the caller opens the browser here. */ + onListening?: () => void; +} + +/** + * Local HTTP server that receives the OAuth redirect in development + * (`http://localhost:/callback`). Owns the Node `http.Server`, connection + * tracking, timeout, and the served callback HTML. Resolves with the auth code + * or rejects on provider error / timeout / cancellation (via `signal`). + */ +@injectable() +export class OAuthCallbackServer { + waitForCode(options: WaitForCodeOptions): Promise { + const { port, timeoutMs, signal, onListening } = options; + + return new Promise((resolve, reject) => { + const connections = new Set(); + let settled = false; + + const cleanup = () => { + clearTimeout(timeoutId); + signal?.removeEventListener("abort", onAbort); + for (const conn of connections) { + conn.destroy(); + } + connections.clear(); + server.close(); + }; + + const finish = (action: () => void) => { + if (settled) return; + settled = true; + cleanup(); + action(); + }; + + const server = http.createServer((req, res) => { + if (!req.url) { + res.writeHead(400); + res.end(); + return; + } + + const url = new URL(req.url, `http://localhost:${port}`); + + if (url.pathname === "/callback") { + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + + if (error) { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + callbackHtml(error === "access_denied" ? "cancelled" : "error"), + ); + finish(() => reject(new Error(`OAuth error: ${error}`))); + return; + } + + if (code) { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(callbackHtml("success")); + finish(() => resolve(code)); + return; + } + + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(callbackHtml("error")); + } else { + res.writeHead(404); + res.end(); + } + }); + + server.on("connection", (conn) => { + connections.add(conn); + conn.on("close", () => connections.delete(conn)); + }); + + const timeoutId = setTimeout(() => { + finish(() => reject(new Error("Authorization timed out"))); + }, timeoutMs); + + const onAbort = () => { + finish(() => reject(new Error("OAuth flow cancelled"))); + }; + + if (signal) { + if (signal.aborted) { + finish(() => reject(new Error("OAuth flow cancelled"))); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + + server.on("error", (error) => { + finish(() => + reject( + new Error(`Failed to start callback server: ${error.message}`), + ), + ); + }); + + server.listen(port, () => { + onListening?.(); + }); + }); + } +} + +function callbackHtml(status: "success" | "cancelled" | "error"): string { + const titles = { + success: "Authorization successful!", + cancelled: "Authorization cancelled", + error: "Authorization failed", + }; + const messages = { + success: "You can close this window and return to PostHog Code.", + cancelled: "You can close this window and return to PostHog Code.", + error: "You can close this window and return to PostHog Code.", + }; + + return ` + + + + ${titles[status]} + + + + + +

${titles[status]}

+

${messages[status]}

+ + +`; +} diff --git a/packages/workspace-server/src/services/os/identifiers.ts b/packages/workspace-server/src/services/os/identifiers.ts new file mode 100644 index 0000000000..bbb591df52 --- /dev/null +++ b/packages/workspace-server/src/services/os/identifiers.ts @@ -0,0 +1 @@ +export const OS_SERVICE = Symbol.for("posthog.workspace.osService"); diff --git a/packages/workspace-server/src/services/os/os.module.ts b/packages/workspace-server/src/services/os/os.module.ts new file mode 100644 index 0000000000..c7179e40fc --- /dev/null +++ b/packages/workspace-server/src/services/os/os.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { OS_SERVICE } from "./identifiers"; +import { OsService } from "./os"; + +export const osModule = new ContainerModule(({ bind }) => { + bind(OS_SERVICE).to(OsService).inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/os/os.test.ts b/packages/workspace-server/src/services/os/os.test.ts new file mode 100644 index 0000000000..88ced62bce --- /dev/null +++ b/packages/workspace-server/src/services/os/os.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockReadFile = vi.hoisted(() => vi.fn()); +const mockStat = vi.hoisted(() => vi.fn()); + +vi.mock("node:fs", () => { + const promises = { + readFile: mockReadFile, + stat: mockStat, + access: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), + readdir: vi.fn(), + mkdir: vi.fn(), + mkdtemp: vi.fn(), + }; + const constants = { W_OK: 2 }; + return { promises, constants, default: { promises, constants } }; +}); + +import { OsService } from "./os"; + +function createService() { + const dialog = { + pickFile: vi.fn(), + confirm: vi.fn(), + }; + const urlLauncher = { launch: vi.fn().mockResolvedValue(undefined) }; + const appMeta = { version: "9.9.9" }; + const imageProcessor = { downscale: vi.fn() }; + const workspaceSettings = { + getWorktreeLocation: vi.fn(() => "/tmp/worktrees"), + }; + + const service = new OsService( + dialog as never, + urlLauncher as never, + appMeta as never, + imageProcessor as never, + workspaceSettings as never, + ); + + return { service, dialog, urlLauncher, appMeta, workspaceSettings }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("OsService.showMessageBox", () => { + it("maps options onto dialog.confirm and returns the chosen response", async () => { + const { service, dialog } = createService(); + dialog.confirm.mockResolvedValue(1); + + const result = await service.showMessageBox({ + type: "warning", + title: "Heads up", + message: "Are you sure?", + buttons: ["Cancel", "Proceed"], + defaultId: 1, + cancelId: 0, + }); + + expect(result).toEqual({ response: 1 }); + expect(dialog.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + severity: "warning", + title: "Heads up", + message: "Are you sure?", + options: ["Cancel", "Proceed"], + defaultIndex: 1, + cancelIndex: 0, + }), + ); + }); + + it("treats a 'none' type as no severity", async () => { + const { service, dialog } = createService(); + dialog.confirm.mockResolvedValue(0); + + await service.showMessageBox({ type: "none", message: "hi" }); + + expect(dialog.confirm).toHaveBeenCalledWith( + expect.objectContaining({ severity: undefined }), + ); + }); + + it("falls back to a default title and an OK button", async () => { + const { service, dialog } = createService(); + dialog.confirm.mockResolvedValue(0); + + await service.showMessageBox({ message: "" }); + + expect(dialog.confirm).toHaveBeenCalledWith( + expect.objectContaining({ title: "PostHog Code", options: ["OK"] }), + ); + }); +}); + +describe("OsService directory and file pickers", () => { + it("returns the first picked path for selectDirectory", async () => { + const { service, dialog } = createService(); + dialog.pickFile.mockResolvedValue(["/repo/one", "/repo/two"]); + expect(await service.selectDirectory()).toBe("/repo/one"); + }); + + it("returns null from selectDirectory when nothing is picked", async () => { + const { service, dialog } = createService(); + dialog.pickFile.mockResolvedValue([]); + expect(await service.selectDirectory()).toBeNull(); + }); + + it("passes through the picked files for selectFiles", async () => { + const { service, dialog } = createService(); + dialog.pickFile.mockResolvedValue(["/a.txt", "/b.txt"]); + expect(await service.selectFiles()).toEqual(["/a.txt", "/b.txt"]); + }); + + it("classifies selected attachments by stat kind and drops unreadable ones", async () => { + const { service, dialog } = createService(); + dialog.pickFile.mockResolvedValue(["/dir", "/file", "/gone"]); + mockStat.mockImplementation(async (p: string) => { + if (p === "/gone") throw new Error("ENOENT"); + return { isDirectory: () => p === "/dir" }; + }); + + const result = await service.selectAttachments("both"); + + expect(result).toEqual([ + { path: "/dir", kind: "directory" }, + { path: "/file", kind: "file" }, + ]); + expect(dialog.pickFile).toHaveBeenCalledWith( + expect.objectContaining({ filesAndDirectories: true, multiple: true }), + ); + }); +}); + +describe("OsService simple delegations", () => { + it("returns the app version from app meta", () => { + const { service } = createService(); + expect(service.getAppVersion()).toBe("9.9.9"); + }); + + it("returns the worktree location from workspace settings", () => { + const { service } = createService(); + expect(service.getWorktreeLocation()).toBe("/tmp/worktrees"); + }); + + it("opens external URLs through the url launcher", async () => { + const { service, urlLauncher } = createService(); + await service.openExternal("https://posthog.com"); + expect(urlLauncher.launch).toHaveBeenCalledWith("https://posthog.com"); + }); +}); + +describe("OsService.getClaudePermissions", () => { + it("returns the allow and deny arrays from the settings file", async () => { + const { service } = createService(); + mockReadFile.mockResolvedValue( + JSON.stringify({ permissions: { allow: ["Read"], deny: ["Bash"] } }), + ); + + expect(await service.getClaudePermissions()).toEqual({ + allow: ["Read"], + deny: ["Bash"], + }); + }); + + it("returns empty arrays when the settings file is missing", async () => { + const { service } = createService(); + mockReadFile.mockRejectedValue(new Error("ENOENT")); + + expect(await service.getClaudePermissions()).toEqual({ + allow: [], + deny: [], + }); + }); + + it("returns empty arrays when permissions are malformed", async () => { + const { service } = createService(); + mockReadFile.mockResolvedValue( + JSON.stringify({ permissions: { allow: "not-an-array" } }), + ); + + expect(await service.getClaudePermissions()).toEqual({ + allow: [], + deny: [], + }); + }); +}); diff --git a/packages/workspace-server/src/services/os/os.ts b/packages/workspace-server/src/services/os/os.ts new file mode 100644 index 0000000000..73cc7cca14 --- /dev/null +++ b/packages/workspace-server/src/services/os/os.ts @@ -0,0 +1,315 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { APP_META_SERVICE, type IAppMeta } from "@posthog/platform/app-meta"; +import { + DIALOG_SERVICE, + type DialogSeverity, + type IDialog, +} from "@posthog/platform/dialog"; +import { + type IImageProcessor, + IMAGE_PROCESSOR_SERVICE, +} from "@posthog/platform/image-processor"; +import { + type IUrlLauncher, + URL_LAUNCHER_SERVICE, +} from "@posthog/platform/url-launcher"; +import { + type IWorkspaceSettings, + WORKSPACE_SETTINGS_SERVICE, +} from "@posthog/platform/workspace-settings"; +import { + ALLOWED_IMAGE_MIME_TYPES, + IMAGE_MIME_TYPES, + isRasterImageFile, +} from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import type { + ClaudePermissions, + ImageAttachment, + MessageBoxOptions, + SavedAttachment, + SelectAttachmentsMode, + SelectedAttachment, +} from "./schemas"; + +const fsPromises = fs.promises; + +const MAX_IMAGE_DIMENSION = 1568; +const JPEG_QUALITY = 85; +const MAX_FILE_SIZE = 50 * 1024 * 1024; +const CLIPBOARD_TEMP_DIR = path.join(os.tmpdir(), "posthog-code-clipboard"); +const claudeSettingsPath = path.join(os.homedir(), ".claude", "settings.json"); + +@injectable() +export class OsService { + constructor( + @inject(DIALOG_SERVICE) + private readonly dialog: IDialog, + @inject(URL_LAUNCHER_SERVICE) + private readonly urlLauncher: IUrlLauncher, + @inject(APP_META_SERVICE) + private readonly appMeta: IAppMeta, + @inject(IMAGE_PROCESSOR_SERVICE) + private readonly imageProcessor: IImageProcessor, + @inject(WORKSPACE_SETTINGS_SERVICE) + private readonly workspaceSettings: IWorkspaceSettings, + ) {} + + async getClaudePermissions(): Promise { + try { + const content = await fsPromises.readFile(claudeSettingsPath, "utf-8"); + const settings = JSON.parse(content); + return { + allow: Array.isArray(settings?.permissions?.allow) + ? settings.permissions.allow + : [], + deny: Array.isArray(settings?.permissions?.deny) + ? settings.permissions.deny + : [], + }; + } catch { + return { allow: [], deny: [] }; + } + } + + async selectDirectory(): Promise { + const paths = await this.dialog.pickFile({ + title: "Select a repository folder", + directories: true, + createDirectories: true, + }); + return paths[0] ?? null; + } + + async selectFiles(): Promise { + return this.dialog.pickFile({ + title: "Select files", + multiple: true, + }); + } + + async selectAttachments( + mode: SelectAttachmentsMode, + ): Promise { + const titleByMode = { + files: "Select files", + directories: "Select folders", + both: "Select files or folders", + } as const; + const paths = await this.dialog.pickFile({ + title: titleByMode[mode], + multiple: true, + directories: mode === "directories", + filesAndDirectories: mode === "both", + }); + const statResults = await Promise.all( + paths.map(async (p) => { + try { + const stat = await fsPromises.stat(p); + return { + path: p, + kind: stat.isDirectory() + ? ("directory" as const) + : ("file" as const), + }; + } catch { + return null; + } + }), + ); + return statResults.filter((r): r is SelectedAttachment => r !== null); + } + + async checkWriteAccess(directoryPath: string): Promise { + if (!directoryPath) return false; + try { + await fsPromises.access(directoryPath, fs.constants.W_OK); + const testFile = path.join( + directoryPath, + `.agent-write-test-${Date.now()}`, + ); + await fsPromises.writeFile(testFile, "ok"); + await fsPromises.unlink(testFile).catch(() => {}); + return true; + } catch { + return false; + } + } + + async showMessageBox( + options: MessageBoxOptions, + ): Promise<{ response: number }> { + const severity: DialogSeverity | undefined = + options?.type && options.type !== "none" ? options.type : undefined; + const response = await this.dialog.confirm({ + severity, + title: options?.title || "PostHog Code", + message: options?.message || "", + detail: options?.detail, + options: + Array.isArray(options?.buttons) && options.buttons.length > 0 + ? options.buttons + : ["OK"], + defaultIndex: options?.defaultId ?? 0, + cancelIndex: options?.cancelId ?? 1, + }); + return { response }; + } + + async openExternal(url: string): Promise { + await this.urlLauncher.launch(url); + } + + async searchDirectories(query: string): Promise { + if (!query?.trim()) return []; + + const searchPath = this.expandHomePath(query.trim()); + const lastSlashIdx = searchPath.lastIndexOf("/"); + const basePath = + lastSlashIdx === -1 ? "" : searchPath.substring(0, lastSlashIdx + 1); + const searchTerm = + lastSlashIdx === -1 ? searchPath : searchPath.substring(lastSlashIdx + 1); + const pathToRead = basePath || os.homedir(); + + try { + const entries = await fsPromises.readdir(pathToRead, { + withFileTypes: true, + }); + const directories = entries.filter((entry) => entry.isDirectory()); + + const filtered = searchTerm + ? directories.filter((dir) => + dir.name.toLowerCase().includes(searchTerm.toLowerCase()), + ) + : directories; + + return filtered + .map((dir) => path.join(pathToRead, dir.name)) + .sort((a, b) => path.basename(a).localeCompare(path.basename(b))) + .slice(0, 20); + } catch { + return []; + } + } + + getAppVersion(): string { + return this.appMeta.version; + } + + getWorktreeLocation(): string { + return this.workspaceSettings.getWorktreeLocation(); + } + + async readFileAsDataUrl( + filePath: string, + maxSizeBytes: number, + ): Promise { + try { + const stat = await fsPromises.stat(filePath); + if (stat.size > maxSizeBytes) return null; + + const ext = path.extname(filePath).toLowerCase().slice(1); + const mime = IMAGE_MIME_TYPES[ext]; + if (!mime || !ALLOWED_IMAGE_MIME_TYPES.has(mime)) return null; + + const buffer = await fsPromises.readFile(filePath); + return `data:${mime};base64,${buffer.toString("base64")}`; + } catch { + return null; + } + } + + async saveClipboardText( + text: string, + originalName?: string, + ): Promise { + const displayName = path.basename(originalName ?? "pasted-text.txt"); + const filePath = await this.createClipboardTempFilePath(displayName); + await fsPromises.writeFile(filePath, text, "utf-8"); + return { path: filePath, name: displayName }; + } + + async saveClipboardImage( + base64Data: string, + mimeType: string, + originalName?: string, + ): Promise { + const raw = new Uint8Array(Buffer.from(base64Data, "base64")); + const isGenericName = + !originalName || + originalName === "image.png" || + originalName === "image.jpeg" || + originalName === "image.jpg"; + const displayName = isGenericName + ? "clipboard.png" + : (originalName ?? "clipboard.png"); + + return this.downscaleAndPersist(raw, mimeType, displayName); + } + + async downscaleImageFile(filePath: string): Promise { + const ext = path.extname(filePath).toLowerCase().slice(1); + if (!isRasterImageFile(filePath)) { + throw new Error(`Unsupported image type: .${ext}`); + } + + const stat = await fsPromises.stat(filePath); + if (stat.size > MAX_FILE_SIZE) { + throw new Error( + `Image too large (${Math.round(stat.size / 1024 / 1024)}MB). Max is 50MB.`, + ); + } + + const raw = new Uint8Array(await fsPromises.readFile(filePath)); + const inputMime = IMAGE_MIME_TYPES[ext]; + + return this.downscaleAndPersist(raw, inputMime, path.basename(filePath)); + } + + async saveClipboardFile( + base64Data: string, + originalName?: string, + ): Promise { + const displayName = path.basename(originalName ?? "attachment"); + const filePath = await this.createClipboardTempFilePath(displayName); + await fsPromises.writeFile(filePath, Buffer.from(base64Data, "base64")); + return { path: filePath, name: displayName }; + } + + private async createClipboardTempFilePath( + displayName: string, + ): Promise { + const safeName = path.basename(displayName) || "attachment"; + await fsPromises.mkdir(CLIPBOARD_TEMP_DIR, { recursive: true }); + const tempDir = await fsPromises.mkdtemp( + path.join(CLIPBOARD_TEMP_DIR, "attachment-"), + ); + return path.join(tempDir, safeName); + } + + private async downscaleAndPersist( + raw: Uint8Array, + inputMime: string, + displayName: string, + ): Promise { + const { buffer, mimeType, extension } = this.imageProcessor.downscale( + raw, + inputMime, + { maxDimension: MAX_IMAGE_DIMENSION, jpegQuality: JPEG_QUALITY }, + ); + + const finalName = displayName.replace(/\.[^.]+$/, `.${extension}`); + const filePath = await this.createClipboardTempFilePath(finalName); + await fsPromises.writeFile(filePath, Buffer.from(buffer)); + + return { path: filePath, name: finalName, mimeType }; + } + + private expandHomePath(searchPath: string): string { + return searchPath.startsWith("~") + ? searchPath.replace(/^~/, os.homedir()) + : searchPath; + } +} diff --git a/packages/workspace-server/src/services/os/schemas.ts b/packages/workspace-server/src/services/os/schemas.ts new file mode 100644 index 0000000000..1a0ceb211f --- /dev/null +++ b/packages/workspace-server/src/services/os/schemas.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +export const claudePermissionsOutput = z.object({ + allow: z.array(z.string()), + deny: z.array(z.string()), +}); +export type ClaudePermissions = z.infer; + +export const selectAttachmentsInput = z.object({ + mode: z.enum(["files", "directories", "both"]).default("both"), +}); +export type SelectAttachmentsMode = z.infer< + typeof selectAttachmentsInput +>["mode"]; + +export const selectedAttachment = z.object({ + path: z.string(), + kind: z.enum(["file", "directory"]), +}); +export const selectAttachmentsOutput = z.array(selectedAttachment); +export type SelectedAttachment = z.infer; + +export const selectFilesOutput = z.array(z.string()); + +export const checkWriteAccessInput = z.object({ directoryPath: z.string() }); + +export const messageBoxOptionsSchema = z.object({ + type: z.enum(["none", "info", "error", "question", "warning"]).optional(), + title: z.string().optional(), + message: z.string().optional(), + detail: z.string().optional(), + buttons: z.array(z.string()).optional(), + defaultId: z.number().optional(), + cancelId: z.number().optional(), +}); +export type MessageBoxOptions = z.infer; +export const showMessageBoxInput = z.object({ + options: messageBoxOptionsSchema, +}); + +export const openExternalInput = z.object({ url: z.string() }); + +export const searchDirectoriesInput = z.object({ + query: z.string(), + searchRoot: z.string().optional(), +}); + +export const readFileAsDataUrlInput = z.object({ + filePath: z.string(), + maxSizeBytes: z + .number() + .optional() + .default(10 * 1024 * 1024), +}); + +export const saveClipboardTextInput = z.object({ + text: z.string(), + originalName: z.string().optional(), +}); + +export const saveClipboardImageInput = z.object({ + base64Data: z.string(), + mimeType: z.string(), + originalName: z.string().optional(), +}); + +export const downscaleImageFileInput = z.object({ + filePath: z.string().min(1), +}); + +export const saveClipboardFileInput = z.object({ + base64Data: z.string(), + originalName: z.string().optional(), +}); + +export interface SavedAttachment { + path: string; + name: string; +} + +export interface ImageAttachment { + path: string; + name: string; + mimeType: string; +} diff --git a/apps/code/src/main/services/posthog-plugin/README.md b/packages/workspace-server/src/services/posthog-plugin/README.md similarity index 100% rename from apps/code/src/main/services/posthog-plugin/README.md rename to packages/workspace-server/src/services/posthog-plugin/README.md diff --git a/apps/code/src/main/utils/extract-zip.ts b/packages/workspace-server/src/services/posthog-plugin/extract-zip.ts similarity index 100% rename from apps/code/src/main/utils/extract-zip.ts rename to packages/workspace-server/src/services/posthog-plugin/extract-zip.ts diff --git a/packages/workspace-server/src/services/posthog-plugin/identifiers.ts b/packages/workspace-server/src/services/posthog-plugin/identifiers.ts new file mode 100644 index 0000000000..85434bfa01 --- /dev/null +++ b/packages/workspace-server/src/services/posthog-plugin/identifiers.ts @@ -0,0 +1,3 @@ +export const POSTHOG_PLUGIN_SERVICE = Symbol.for( + "posthog.workspace.posthogPluginService", +); diff --git a/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.module.ts b/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.module.ts new file mode 100644 index 0000000000..5eb93d76f9 --- /dev/null +++ b/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { POSTHOG_PLUGIN_SERVICE } from "./identifiers"; +import { PosthogPluginService } from "./posthog-plugin"; + +export const posthogPluginModule = new ContainerModule(({ bind }) => { + bind(POSTHOG_PLUGIN_SERVICE).to(PosthogPluginService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/posthog-plugin/service.test.ts b/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.test.ts similarity index 92% rename from apps/code/src/main/services/posthog-plugin/service.test.ts rename to packages/workspace-server/src/services/posthog-plugin/posthog-plugin.test.ts index 731deade7e..86ff509f4b 100644 --- a/apps/code/src/main/services/posthog-plugin/service.test.ts +++ b/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.test.ts @@ -21,6 +21,33 @@ const mockBundledResources = vi.hoisted(() => ({ }, })); +const mockAppMeta = vi.hoisted(() => ({ + version: "1.0.0", + isProduction: false, +})); + +const mockAnalytics = vi.hoisted(() => ({ + initialize: vi.fn(), + track: vi.fn(), + identify: vi.fn(), + setCurrentUserId: vi.fn(), + getCurrentUserId: vi.fn(() => null), + resetUser: vi.fn(), + captureException: vi.fn(), + flush: vi.fn(async () => {}), + shutdown: vi.fn(async () => {}), +})); + +const mockLog = vi.hoisted(() => { + const scoped = { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }; + return { ...scoped, scope: (): typeof scoped => scoped }; +}); + const mockFetch = vi.hoisted(() => vi.fn()); const mockExtractZip = vi.hoisted(() => @@ -42,10 +69,9 @@ vi.mock("fflate", () => ({ unzip: mockFflateUnzip, })); -vi.mock("../../utils/extract-zip.js", async () => { - const actual = await vi.importActual< - typeof import("../../utils/extract-zip.js") - >("../../utils/extract-zip.js"); +vi.mock("./extract-zip", async () => { + const actual = + await vi.importActual("./extract-zip"); return { ...actual, extractZip: mockExtractZip, @@ -58,20 +84,12 @@ vi.mock("node:os", () => ({ default: { homedir: () => "/mock/home", tmpdir: () => "/mock/tmp" }, })); -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - +import type { WorkbenchLogger } from "@posthog/di/logger"; +import type { IAnalytics } from "@posthog/platform/analytics"; +import type { IAppMeta } from "@posthog/platform/app-meta"; import type { IBundledResources } from "@posthog/platform/bundled-resources"; import type { IStoragePaths } from "@posthog/platform/storage-paths"; -import { PosthogPluginService } from "./service"; +import { PosthogPluginService } from "./posthog-plugin"; import { syncCodexSkills } from "./update-skills-saga"; /** Expose private members for testing without `as any`. */ @@ -152,6 +170,7 @@ describe("PosthogPluginService", () => { vol.reset(); mockBundledResources._setPackaged(false); + mockAppMeta.isProduction = false; mockFetch.mockResolvedValue(mockFetchResponse(true)); vi.stubGlobal("fetch", mockFetch); mockExtractZip.mockResolvedValue(undefined); @@ -159,6 +178,9 @@ describe("PosthogPluginService", () => { service = new PosthogPluginService( mockStoragePaths as unknown as IStoragePaths, mockBundledResources as unknown as IBundledResources, + mockAnalytics as unknown as IAnalytics, + mockAppMeta as unknown as IAppMeta, + mockLog as unknown as WorkbenchLogger, ); }); @@ -173,13 +195,13 @@ describe("PosthogPluginService", () => { describe("getPluginPath", () => { it("returns bundled path in dev mode", () => { - process.env.POSTHOG_CODE_IS_DEV = "true"; + mockAppMeta.isProduction = false; mockBundledResources._setPackaged(false); expect(service.getPluginPath()).toBe(BUNDLED_PLUGIN_DIR); }); it("returns runtime path in prod when plugin.json exists", () => { - process.env.POSTHOG_CODE_IS_DEV = "false"; + mockAppMeta.isProduction = true; mockBundledResources._setPackaged(true); vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); @@ -188,7 +210,7 @@ describe("PosthogPluginService", () => { }); it("returns bundled path as fallback in prod", () => { - process.env.POSTHOG_CODE_IS_DEV = "false"; + mockAppMeta.isProduction = true; mockBundledResources._setPackaged(true); expect(service.getPluginPath()).toBe(BUNDLED_PLUGIN_DIR_PACKAGED); }); diff --git a/apps/code/src/main/services/posthog-plugin/service.ts b/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.ts similarity index 77% rename from apps/code/src/main/services/posthog-plugin/service.ts rename to packages/workspace-server/src/services/posthog-plugin/posthog-plugin.ts index eb5c925ef6..f69aff256e 100644 --- a/apps/code/src/main/services/posthog-plugin/service.ts +++ b/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.ts @@ -2,26 +2,33 @@ import { existsSync } from "node:fs"; import { cp, mkdir, rm, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; -import type { IBundledResources } from "@posthog/platform/bundled-resources"; -import type { IStoragePaths } from "@posthog/platform/storage-paths"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { + ANALYTICS_SERVICE, + type IAnalytics, +} from "@posthog/platform/analytics"; +import { APP_META_SERVICE, type IAppMeta } from "@posthog/platform/app-meta"; +import { + BUNDLED_RESOURCES_SERVICE, + type IBundledResources, +} from "@posthog/platform/bundled-resources"; +import { + type IStoragePaths, + STORAGE_PATHS_SERVICE, +} from "@posthog/platform/storage-paths"; +import { TypedEventEmitter } from "@posthog/shared"; import { inject, injectable, postConstruct, preDestroy } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { isDevBuild } from "../../utils/env"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import { captureException } from "../posthog-analytics"; import { overlayDownloadedSkills, syncCodexSkills, UpdateSkillsSaga, } from "./update-skills-saga"; -const log = logger.scope("posthog-plugin"); - const SKILLS_ZIP_URL = process.env.SKILLS_ZIP_URL ?? ""; -if (!SKILLS_ZIP_URL) { - log.warn("SKILLS_ZIP_URL environment variable is not set"); -} const CONTEXT_MILL_ZIP_URL = process.env.CONTEXT_MILL_ZIP_URL ?? ""; const UPDATE_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes const CODEX_SKILLS_DIR = join(homedir(), ".agents", "skills"); @@ -35,14 +42,22 @@ export class PosthogPluginService extends TypedEventEmitter private intervalId: ReturnType | null = null; private lastCheckAt = 0; private updating = false; + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.StoragePaths) + @inject(STORAGE_PATHS_SERVICE) private readonly storagePaths: IStoragePaths, - @inject(MAIN_TOKENS.BundledResources) + @inject(BUNDLED_RESOURCES_SERVICE) private readonly bundledResources: IBundledResources, + @inject(ANALYTICS_SERVICE) + private readonly analytics: IAnalytics, + @inject(APP_META_SERVICE) + private readonly appMeta: IAppMeta, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, ) { super(); + this.log = logger.scope("posthog-plugin"); } /** Runtime plugin dir under userData */ @@ -63,8 +78,8 @@ export class PosthogPluginService extends TypedEventEmitter @postConstruct() init(): void { this.initialize().catch((err) => { - log.error("Skills initialization failed", err); - captureException(err, { + this.log.error("Skills initialization failed", { error: err }); + this.analytics.captureException(err, { source: "posthog-plugin", operation: "initialize", }); @@ -86,7 +101,7 @@ export class PosthogPluginService extends TypedEventEmitter // Start periodic updates this.intervalId = setInterval(() => { this.updateSkills().catch((err) => { - log.warn("Periodic skills update failed", err); + this.log.warn("Periodic skills update failed", { error: err }); }); }, UPDATE_INTERVAL_MS); @@ -102,7 +117,7 @@ export class PosthogPluginService extends TypedEventEmitter * - Fallback: bundled plugin path. */ getPluginPath(): string { - if (isDevBuild()) { + if (!this.appMeta.isProduction) { return this.bundledPluginDir; } @@ -131,7 +146,7 @@ export class PosthogPluginService extends TypedEventEmitter try { await mkdir(tempDir, { recursive: true }); - const saga = new UpdateSkillsSaga(log); + const saga = new UpdateSkillsSaga(this.log); const result = await saga.run({ runtimeSkillsDir: this.runtimeSkillsDir, runtimePluginDir: this.runtimePluginDir, @@ -146,19 +161,21 @@ export class PosthogPluginService extends TypedEventEmitter if (result.success) { this.emit("skillsUpdated", true); } else { - log.warn("Skills update failed", { + this.log.warn("Skills update failed", { error: result.error, failedStep: result.failedStep, }); - captureException(new Error(result.error), { + this.analytics.captureException(new Error(result.error), { source: "posthog-plugin", operation: "updateSkills", failedStep: result.failedStep, }); } } catch (err) { - log.warn("Failed to update skills, will retry next interval", err); - captureException(err, { + this.log.warn("Failed to update skills, will retry next interval", { + error: err, + }); + this.analytics.captureException(err, { source: "posthog-plugin", operation: "updateSkills", }); @@ -175,7 +192,7 @@ export class PosthogPluginService extends TypedEventEmitter private async copyBundledPlugin(): Promise { try { if (!existsSync(this.bundledPluginDir)) { - log.warn("Bundled plugin dir not found", { + this.log.warn("Bundled plugin dir not found", { path: this.bundledPluginDir, }); return; @@ -185,8 +202,8 @@ export class PosthogPluginService extends TypedEventEmitter recursive: true, }); } catch (err) { - log.warn("Failed to copy bundled plugin", err); - captureException(err, { + this.log.warn("Failed to copy bundled plugin", { error: err }); + this.analytics.captureException(err, { source: "posthog-plugin", operation: "copyBundledPlugin", }); diff --git a/apps/code/src/main/services/posthog-plugin/update-skills-saga.ts b/packages/workspace-server/src/services/posthog-plugin/update-skills-saga.ts similarity index 99% rename from apps/code/src/main/services/posthog-plugin/update-skills-saga.ts rename to packages/workspace-server/src/services/posthog-plugin/update-skills-saga.ts index 5a1056a373..c96c547ed5 100644 --- a/apps/code/src/main/services/posthog-plugin/update-skills-saga.ts +++ b/packages/workspace-server/src/services/posthog-plugin/update-skills-saga.ts @@ -9,8 +9,8 @@ import { writeFile, } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; -import { extractZip, unzipAsync } from "@main/utils/extract-zip"; import { Saga } from "@posthog/shared"; +import { extractZip, unzipAsync } from "./extract-zip"; /** * Overlays previously-downloaded skills on top of the runtime plugin dir. diff --git a/packages/workspace-server/src/services/process-tracking/identifiers.ts b/packages/workspace-server/src/services/process-tracking/identifiers.ts new file mode 100644 index 0000000000..b8f88c9838 --- /dev/null +++ b/packages/workspace-server/src/services/process-tracking/identifiers.ts @@ -0,0 +1,3 @@ +export const PROCESS_TRACKING_SERVICE = Symbol.for( + "posthog.workspace.processTrackingService", +); diff --git a/packages/workspace-server/src/services/process-tracking/process-tracking.module.ts b/packages/workspace-server/src/services/process-tracking/process-tracking.module.ts new file mode 100644 index 0000000000..37b025a086 --- /dev/null +++ b/packages/workspace-server/src/services/process-tracking/process-tracking.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { PROCESS_TRACKING_SERVICE } from "./identifiers"; +import { ProcessTrackingService } from "./process-tracking"; + +export const processTrackingModule = new ContainerModule(({ bind }) => { + bind(PROCESS_TRACKING_SERVICE).to(ProcessTrackingService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/process-tracking/service.test.ts b/packages/workspace-server/src/services/process-tracking/process-tracking.test.ts similarity index 97% rename from apps/code/src/main/services/process-tracking/service.test.ts rename to packages/workspace-server/src/services/process-tracking/process-tracking.test.ts index 264679dd8f..c7b8eb8e6d 100644 --- a/apps/code/src/main/services/process-tracking/service.test.ts +++ b/packages/workspace-server/src/services/process-tracking/process-tracking.test.ts @@ -20,23 +20,12 @@ vi.mock("node:os", () => ({ default: { platform: mockPlatform }, })); -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -vi.mock("../../utils/process-utils.js", () => ({ +vi.mock("./process-utils", () => ({ isProcessAlive: mockIsProcessAlive, killProcessTree: mockKillProcessTree, })); -import { ProcessTrackingService } from "./service"; +import { ProcessTrackingService } from "./process-tracking"; function mockExecResolves(stdout: string): void { mockExecAsync.mockResolvedValueOnce({ stdout, stderr: "" }); diff --git a/apps/code/src/main/services/process-tracking/service.ts b/packages/workspace-server/src/services/process-tracking/process-tracking.ts similarity index 79% rename from apps/code/src/main/services/process-tracking/service.ts rename to packages/workspace-server/src/services/process-tracking/process-tracking.ts index 23b653e52b..809ee079aa 100644 --- a/apps/code/src/main/services/process-tracking/service.ts +++ b/packages/workspace-server/src/services/process-tracking/process-tracking.ts @@ -2,35 +2,22 @@ import { exec } from "node:child_process"; import { platform } from "node:os"; import { promisify } from "node:util"; import { injectable, preDestroy } from "inversify"; -import { logger } from "../../utils/logger"; -import { isProcessAlive, killProcessTree } from "../../utils/process-utils"; +import { isProcessAlive, killProcessTree } from "./process-utils"; +import type { + DiscoveredProcess, + ProcessCategory, + ProcessSnapshot, + TrackedProcess, +} from "./schemas"; -const log = logger.scope("process-tracking"); const execAsync = promisify(exec); -export type ProcessCategory = "shell" | "agent" | "child"; - -export interface TrackedProcess { - pid: number; - category: ProcessCategory; - label: string; - registeredAt: number; - taskId?: string; - metadata?: Record; -} - -export interface DiscoveredProcess { - pid: number; - ppid: number; - command: string; - tracked: boolean; -} - -export interface ProcessSnapshot { - tracked: Record; - discovered?: DiscoveredProcess[]; - timestamp: number; -} +export type { + DiscoveredProcess, + ProcessCategory, + ProcessSnapshot, + TrackedProcess, +}; @injectable() export class ProcessTrackingService { @@ -50,7 +37,6 @@ export class ProcessTrackingService { metadata?: Record, taskId?: string, ): void { - // Clean up previous entry if PID was already tracked under a different task this.removeFromTaskIndex(pid); this.processes.set(pid, { @@ -102,7 +88,6 @@ export class ProcessTrackingService { } async getSnapshot(includeDiscovered = false): Promise { - // Prune dead PIDs for (const [pid] of this.processes) { if (!isProcessAlive(pid)) { this.unregister(pid, "pruned-dead"); @@ -131,13 +116,8 @@ export class ProcessTrackingService { return snapshot; } - /** - * Uses `ps` to find all descendant processes of the Electron app, - * and flags which are tracked vs untracked. - */ async discoverChildren(): Promise { if (platform() === "win32") { - // Not implemented for Windows return []; } @@ -149,8 +129,7 @@ export class ProcessTrackingService { `ps -eo pid,ppid,comm --no-headers 2>/dev/null || ps -eo pid,ppid,comm`, ); stdout = result.stdout; - } catch (error) { - log.warn("Failed to discover child processes", error); + } catch { return []; } @@ -168,7 +147,6 @@ export class ProcessTrackingService { } } - // Build a set of all descendant PIDs const descendants = new Set(); const findDescendants = (parentPid: number): void => { for (const p of allProcesses) { @@ -224,9 +202,6 @@ export class ProcessTrackingService { killByTaskId(taskId: string): void { const procs = this.getByTaskId(taskId); - if (procs.length > 0) { - log.info(`Killing ${procs.length} processes for taskId=${taskId}`); - } for (const proc of procs) { this.kill(proc.pid); } @@ -236,10 +211,6 @@ export class ProcessTrackingService { killAll(): void { this._isShuttingDown = true; - const count = this.processes.size; - if (count > 0) { - log.info(`Killing all tracked processes (${count} active)`); - } for (const proc of this.processes.values()) { killProcessTree(proc.pid); } diff --git a/apps/code/src/main/utils/process-utils.ts b/packages/workspace-server/src/services/process-tracking/process-utils.ts similarity index 90% rename from apps/code/src/main/utils/process-utils.ts rename to packages/workspace-server/src/services/process-tracking/process-utils.ts index 4d1ead47eb..5cd9d4e686 100644 --- a/apps/code/src/main/utils/process-utils.ts +++ b/packages/workspace-server/src/services/process-tracking/process-utils.ts @@ -1,8 +1,5 @@ import { execSync } from "node:child_process"; import { platform } from "node:os"; -import { logger } from "./logger"; - -const log = logger.scope("process-utils"); const SIGKILL_GRACE_MS = 5_000; @@ -41,9 +38,7 @@ export function killProcessTree(pid: number): void { } }, SIGKILL_GRACE_MS).unref(); } - } catch (err) { - log.warn(`Failed to kill process tree for PID ${pid}`, err); - } + } catch {} } /** diff --git a/packages/workspace-server/src/services/process-tracking/schemas.ts b/packages/workspace-server/src/services/process-tracking/schemas.ts new file mode 100644 index 0000000000..a67f22035a --- /dev/null +++ b/packages/workspace-server/src/services/process-tracking/schemas.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +export const processCategorySchema = z.enum(["shell", "agent", "child"]); +export type ProcessCategory = z.infer; + +export const trackedProcessSchema = z.object({ + pid: z.number(), + category: processCategorySchema, + label: z.string(), + registeredAt: z.number(), + taskId: z.string().optional(), + metadata: z.record(z.string(), z.string()).optional(), +}); +export type TrackedProcess = z.infer; + +export const discoveredProcessSchema = z.object({ + pid: z.number(), + ppid: z.number(), + command: z.string(), + tracked: z.boolean(), +}); +export type DiscoveredProcess = z.infer; + +export const processSnapshotSchema = z.object({ + tracked: z.object({ + shell: z.array(trackedProcessSchema), + agent: z.array(trackedProcessSchema), + child: z.array(trackedProcessSchema), + }), + discovered: z.array(discoveredProcessSchema).optional(), + timestamp: z.number(), +}); +export type ProcessSnapshot = z.infer; + +export const getSnapshotInput = z + .object({ + includeDiscovered: z.boolean().optional(), + }) + .optional(); + +export const killByPidInput = z.object({ pid: z.number() }); +export const killByCategoryInput = z.object({ + category: processCategorySchema, +}); +export const killByTaskIdInput = z.object({ taskId: z.string() }); +export const listByTaskIdInput = z.object({ taskId: z.string() }); diff --git a/packages/workspace-server/src/services/repo-fs-query/repo-fs-query.test.ts b/packages/workspace-server/src/services/repo-fs-query/repo-fs-query.test.ts new file mode 100644 index 0000000000..612c81ae67 --- /dev/null +++ b/packages/workspace-server/src/services/repo-fs-query/repo-fs-query.test.ts @@ -0,0 +1,66 @@ +import { vol } from "memfs"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs/promises", async () => { + const { fs } = await import("memfs"); + return { ...fs.promises, default: fs.promises }; +}); + +import { getBranchFromPath, hasAnyFiles } from "./repo-fs-query"; + +afterEach(() => { + vol.reset(); +}); + +describe("hasAnyFiles", () => { + it("is true when the repo has a tracked file alongside .git", async () => { + vol.fromJSON({ "/repo/.git/HEAD": "x", "/repo/README.md": "hi" }); + + expect(await hasAnyFiles("/repo")).toBe(true); + }); + + it("is false when the repo contains only .git", async () => { + vol.fromJSON({ "/repo/.git/HEAD": "x" }); + + expect(await hasAnyFiles("/repo")).toBe(false); + }); + + it("is false when the path does not exist", async () => { + expect(await hasAnyFiles("/nope")).toBe(false); + }); +}); + +describe("getBranchFromPath", () => { + it("reads the branch from a .git directory HEAD", async () => { + vol.fromJSON({ "/repo/.git/HEAD": "ref: refs/heads/main\n" }); + + expect(await getBranchFromPath("/repo")).toBe("main"); + }); + + it("returns null for a detached HEAD (no ref line)", async () => { + vol.fromJSON({ "/repo/.git/HEAD": "9f1c2d3e4b5a6\n" }); + + expect(await getBranchFromPath("/repo")).toBeNull(); + }); + + it("follows a worktree .git file gitdir pointer to its HEAD", async () => { + vol.fromJSON({ + "/repo/.worktrees/feat/.git": "gitdir: /repo/.git/worktrees/feat\n", + "/repo/.git/worktrees/feat/HEAD": "ref: refs/heads/feat\n", + }); + + expect(await getBranchFromPath("/repo/.worktrees/feat")).toBe("feat"); + }); + + it("returns null when the .git file has no gitdir pointer", async () => { + vol.fromJSON({ "/repo/.worktrees/x/.git": "garbage\n" }); + + expect(await getBranchFromPath("/repo/.worktrees/x")).toBeNull(); + }); + + it("returns null when the path is not a git repo", async () => { + vol.fromJSON({ "/plain/file.txt": "hi" }); + + expect(await getBranchFromPath("/plain")).toBeNull(); + }); +}); diff --git a/packages/workspace-server/src/services/repo-fs-query/repo-fs-query.ts b/packages/workspace-server/src/services/repo-fs-query/repo-fs-query.ts new file mode 100644 index 0000000000..c08b97f1c2 --- /dev/null +++ b/packages/workspace-server/src/services/repo-fs-query/repo-fs-query.ts @@ -0,0 +1,41 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +/** True if the directory contains any entry other than `.git`. */ +export async function hasAnyFiles(repoPath: string): Promise { + try { + const entries = await readdir(repoPath); + return entries.some((entry) => entry !== ".git"); + } catch { + return false; + } +} + +/** + * Current branch for a repo or worktree, read directly from its Git HEAD file + * (no subprocess). Returns null for detached HEAD or if the path is not a repo. + */ +export async function getBranchFromPath( + repoPath: string, +): Promise { + try { + const gitPath = path.join(repoPath, ".git"); + const gitStat = await stat(gitPath); + + let headPath: string; + if (gitStat.isDirectory()) { + headPath = path.join(gitPath, "HEAD"); + } else { + const gitContent = await readFile(gitPath, "utf-8"); + const gitdirMatch = gitContent.match(/gitdir:\s*(.+)/); + if (!gitdirMatch) return null; + headPath = path.join(path.resolve(gitdirMatch[1].trim()), "HEAD"); + } + + const headContent = await readFile(headPath, "utf-8"); + const branchMatch = headContent.match(/ref: refs\/heads\/(.+)/); + return branchMatch ? branchMatch[1].trim() : null; + } catch { + return null; + } +} diff --git a/packages/workspace-server/src/services/secure-store/identifiers.ts b/packages/workspace-server/src/services/secure-store/identifiers.ts new file mode 100644 index 0000000000..41798437b1 --- /dev/null +++ b/packages/workspace-server/src/services/secure-store/identifiers.ts @@ -0,0 +1,10 @@ +export const SECURE_STORE_SERVICE = Symbol.for( + "posthog.workspace.secureStoreService", +); + +export interface ISecureStoreService { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; + clear(): void; +} diff --git a/packages/workspace-server/src/services/secure-store/schemas.ts b/packages/workspace-server/src/services/secure-store/schemas.ts new file mode 100644 index 0000000000..42f1811fe0 --- /dev/null +++ b/packages/workspace-server/src/services/secure-store/schemas.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const secureStoreGetInput = z.object({ key: z.string() }); +export const secureStoreSetInput = z.object({ + key: z.string(), + value: z.string(), +}); +export const secureStoreRemoveInput = z.object({ key: z.string() }); + +export type SecureStoreGetInput = z.infer; +export type SecureStoreSetInput = z.infer; +export type SecureStoreRemoveInput = z.infer; diff --git a/apps/code/src/main/services/session-env/loader.test.ts b/packages/workspace-server/src/services/session-env/loader.test.ts similarity index 100% rename from apps/code/src/main/services/session-env/loader.test.ts rename to packages/workspace-server/src/services/session-env/loader.test.ts diff --git a/apps/code/src/main/services/session-env/loader.ts b/packages/workspace-server/src/services/session-env/loader.ts similarity index 88% rename from apps/code/src/main/services/session-env/loader.ts rename to packages/workspace-server/src/services/session-env/loader.ts index 3ed49e8267..b596e9cf5c 100644 --- a/apps/code/src/main/services/session-env/loader.ts +++ b/packages/workspace-server/src/services/session-env/loader.ts @@ -1,9 +1,6 @@ import { spawn } from "node:child_process"; import { promises as fs } from "node:fs"; import path from "node:path"; -import { logger } from "../../utils/logger"; - -const log = logger.scope("session-env"); /** * Matches the file naming convention used by Claude Agent SDK to write @@ -110,10 +107,6 @@ export async function loadSessionEnvOverrides( }; const timer = setTimeout(() => { - log.warn("Timed out loading session env hooks", { - sessionId, - files: files.length, - }); try { proc.kill("SIGKILL"); } catch {} @@ -127,20 +120,10 @@ export async function loadSessionEnvOverrides( const chunks: Buffer[] = []; proc.stdout.on("data", (c) => chunks.push(c as Buffer)); - proc.on("error", (err) => { - log.warn("Failed to spawn bash for session env", { - sessionId, - err: err.message, - }); + proc.on("error", () => { finish({}); }); - proc.on("close", (code) => { - if (code !== 0) { - log.warn("bash exited non-zero loading session env", { - sessionId, - code, - }); - } + proc.on("close", () => { const out = Buffer.concat(chunks).toString("utf8"); const overrides: Record = {}; for (const entry of out.split("\0")) { diff --git a/packages/workspace-server/src/services/shell/identifiers.ts b/packages/workspace-server/src/services/shell/identifiers.ts new file mode 100644 index 0000000000..bb416a68a7 --- /dev/null +++ b/packages/workspace-server/src/services/shell/identifiers.ts @@ -0,0 +1 @@ +export const SHELL_SERVICE = Symbol.for("posthog.workspace.shellService"); diff --git a/apps/code/src/main/services/shell/schemas.ts b/packages/workspace-server/src/services/shell/schemas.ts similarity index 100% rename from apps/code/src/main/services/shell/schemas.ts rename to packages/workspace-server/src/services/shell/schemas.ts diff --git a/packages/workspace-server/src/services/shell/shell.module.ts b/packages/workspace-server/src/services/shell/shell.module.ts new file mode 100644 index 0000000000..b7e6acc272 --- /dev/null +++ b/packages/workspace-server/src/services/shell/shell.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { SHELL_SERVICE } from "./identifiers"; +import { ShellService } from "./shell"; + +export const shellModule = new ContainerModule(({ bind }) => { + bind(SHELL_SERVICE).to(ShellService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/shell/service.ts b/packages/workspace-server/src/services/shell/shell.ts similarity index 88% rename from apps/code/src/main/services/shell/service.ts rename to packages/workspace-server/src/services/shell/shell.ts index f82fec5da1..80b854ed66 100644 --- a/apps/code/src/main/services/shell/service.ts +++ b/packages/workspace-server/src/services/shell/shell.ts @@ -1,17 +1,30 @@ import { exec } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir, platform } from "node:os"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { + type IWorkspaceSettings, + WORKSPACE_SETTINGS_SERVICE, +} from "@posthog/platform/workspace-settings"; +import { TypedEventEmitter } from "@posthog/shared"; import { inject, injectable, preDestroy } from "inversify"; import * as pty from "node-pty"; +import { + REPOSITORY_REPOSITORY, + WORKSPACE_REPOSITORY, + WORKTREE_REPOSITORY, +} from "../../db/identifiers"; import type { RepositoryRepository } from "../../db/repositories/repository-repository"; import type { WorkspaceRepository } from "../../db/repositories/workspace-repository"; import type { WorktreeRepository } from "../../db/repositories/worktree-repository"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import { deriveWorktreePath } from "../../utils/worktree-helpers"; -import type { ProcessTrackingService } from "../process-tracking/service"; -import { buildWorkspaceEnv } from "../workspace/workspaceEnv"; +import { buildWorkspaceEnv } from "../../workspace-env"; +import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; +import type { ProcessTrackingService } from "../process-tracking/process-tracking"; +import { resolveWorktreePathByProbe } from "../worktree-path/worktree-path"; import { type ExecuteOutput, ShellEvent, type ShellEvents } from "./schemas"; // node-pty exposes destroy() at runtime but it's missing from type definitions @@ -21,7 +34,6 @@ declare module "node-pty" { } } -const log = logger.scope("shell"); const PTY_ENCODING = "utf8"; export interface ShellSession { @@ -91,22 +103,39 @@ export class ShellService extends TypedEventEmitter { private repositoryRepo: RepositoryRepository; private workspaceRepo: WorkspaceRepository; private worktreeRepo: WorktreeRepository; + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.ProcessTrackingService) + @inject(PROCESS_TRACKING_SERVICE) processTracking: ProcessTrackingService, - @inject(MAIN_TOKENS.RepositoryRepository) + @inject(REPOSITORY_REPOSITORY) repositoryRepo: RepositoryRepository, - @inject(MAIN_TOKENS.WorkspaceRepository) + @inject(WORKSPACE_REPOSITORY) workspaceRepo: WorkspaceRepository, - @inject(MAIN_TOKENS.WorktreeRepository) + @inject(WORKTREE_REPOSITORY) worktreeRepo: WorktreeRepository, + @inject(WORKSPACE_SETTINGS_SERVICE) + private readonly workspaceSettings: IWorkspaceSettings, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, ) { super(); this.processTracking = processTracking; this.repositoryRepo = repositoryRepo; this.workspaceRepo = workspaceRepo; this.worktreeRepo = worktreeRepo; + this.log = logger.scope("shell"); + } + + private deriveWorktreePath( + folderPath: string, + worktreeName: string, + ): Promise { + return resolveWorktreePathByProbe( + this.workspaceSettings.getWorktreeLocation(), + folderPath, + worktreeName, + ); } async create( @@ -364,7 +393,7 @@ export class ShellService extends TypedEventEmitter { const workingDir = cwd || home; if (!existsSync(workingDir)) { - log.warn( + this.log.warn( `Shell session ${sessionId}: cwd "${workingDir}" does not exist, falling back to home`, ); return home; @@ -393,7 +422,7 @@ export class ShellService extends TypedEventEmitter { const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); if (worktree) { worktreeName = worktree.name; - worktreePath = deriveWorktreePath(repo.path, worktreeName); + worktreePath = await this.deriveWorktreePath(repo.path, worktreeName); } } diff --git a/packages/workspace-server/src/services/skills/identifiers.ts b/packages/workspace-server/src/services/skills/identifiers.ts new file mode 100644 index 0000000000..036ec8c003 --- /dev/null +++ b/packages/workspace-server/src/services/skills/identifiers.ts @@ -0,0 +1 @@ +export const SKILLS_SERVICE = Symbol.for("posthog.workspace.skillsService"); diff --git a/apps/code/src/main/services/agent/parse-skill-frontmatter.ts b/packages/workspace-server/src/services/skills/parse-skill-frontmatter.ts similarity index 100% rename from apps/code/src/main/services/agent/parse-skill-frontmatter.ts rename to packages/workspace-server/src/services/skills/parse-skill-frontmatter.ts diff --git a/apps/code/src/main/services/agent/skill-schemas.ts b/packages/workspace-server/src/services/skills/schemas.ts similarity index 75% rename from apps/code/src/main/services/agent/skill-schemas.ts rename to packages/workspace-server/src/services/skills/schemas.ts index 01713be84c..76f459c7a7 100644 --- a/apps/code/src/main/services/agent/skill-schemas.ts +++ b/packages/workspace-server/src/services/skills/schemas.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -export type { SkillInfo, SkillSource } from "@shared/types/skills"; - export const skillSource = z.enum(["bundled", "user", "repo", "marketplace"]); export const skillInfo = z.object({ @@ -13,3 +11,6 @@ export const skillInfo = z.object({ }); export const listSkillsOutput = z.array(skillInfo); + +export type SkillInfo = z.infer; +export type SkillSource = z.infer; diff --git a/packages/workspace-server/src/services/skills/skill-discovery.test.ts b/packages/workspace-server/src/services/skills/skill-discovery.test.ts new file mode 100644 index 0000000000..0b52eda4d7 --- /dev/null +++ b/packages/workspace-server/src/services/skills/skill-discovery.test.ts @@ -0,0 +1,85 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { findSkillDirs, readSkillMetadataFromDir } from "./skill-discovery"; + +let root: string; + +async function createSkill( + skillsDir: string, + name: string, + frontmatter?: string, +) { + const skillPath = path.join(skillsDir, name); + await mkdir(skillPath, { recursive: true }); + await writeFile(path.join(skillPath, "SKILL.md"), frontmatter ?? `# ${name}`); +} + +beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "skills-test-")); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("findSkillDirs", () => { + it("returns empty for a missing directory", async () => { + expect(await findSkillDirs(path.join(root, "nope"))).toEqual([]); + }); + + it("lists only directories containing SKILL.md", async () => { + const skillsDir = path.join(root, "skills"); + await createSkill(skillsDir, "alpha"); + await mkdir(path.join(skillsDir, "not-a-skill"), { recursive: true }); + await writeFile(path.join(skillsDir, "not-a-skill", "README.md"), "nope"); + await writeFile(path.join(skillsDir, "loose-file.txt"), "hello"); + + expect(await findSkillDirs(skillsDir)).toEqual(["alpha"]); + }); +}); + +describe("readSkillMetadataFromDir", () => { + it("returns empty when no skills exist", async () => { + expect( + await readSkillMetadataFromDir(path.join(root, "skills"), "user"), + ).toEqual([]); + }); + + it("parses frontmatter name/description and tags the source", async () => { + const skillsDir = path.join(root, "skills"); + await createSkill( + skillsDir, + "my-skill", + "---\nname: Pretty Name\ndescription: Does a thing\n---\nbody", + ); + + const result = await readSkillMetadataFromDir(skillsDir, "repo", "my-repo"); + + expect(result).toEqual([ + { + name: "Pretty Name", + description: "Does a thing", + source: "repo", + path: path.join(skillsDir, "my-skill"), + repoName: "my-repo", + }, + ]); + }); + + it("falls back to the directory name when frontmatter is absent", async () => { + const skillsDir = path.join(root, "skills"); + await createSkill(skillsDir, "bare-skill", "no frontmatter here"); + + const result = await readSkillMetadataFromDir(skillsDir, "user"); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: "bare-skill", + description: "", + source: "user", + }); + expect(result[0]).not.toHaveProperty("repoName"); + }); +}); diff --git a/packages/workspace-server/src/services/skills/skill-discovery.ts b/packages/workspace-server/src/services/skills/skill-discovery.ts new file mode 100644 index 0000000000..fbe1b43618 --- /dev/null +++ b/packages/workspace-server/src/services/skills/skill-discovery.ts @@ -0,0 +1,101 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { parseSkillFrontmatter } from "./parse-skill-frontmatter"; +import type { SkillInfo, SkillSource } from "./schemas"; + +interface InstalledPluginEntry { + scope: string; + installPath: string; + version: string; +} + +interface InstalledPluginsFile { + version: number; + plugins: Record; +} + +export async function findSkillDirs( + sourceSkillsDir: string, +): Promise { + if (!fs.existsSync(sourceSkillsDir)) { + return []; + } + + const entries = await fs.promises.readdir(sourceSkillsDir, { + withFileTypes: true, + }); + + return entries + .filter( + (e) => + (e.isDirectory() || e.isSymbolicLink()) && + fs.existsSync(path.join(sourceSkillsDir, e.name, "SKILL.md")), + ) + .map((e) => e.name); +} + +export async function getMarketplaceInstallPaths(): Promise { + const installedPath = path.join( + os.homedir(), + ".claude", + "plugins", + "installed_plugins.json", + ); + + try { + const content = await fs.promises.readFile(installedPath, "utf-8"); + const data = JSON.parse(content) as InstalledPluginsFile; + + if (!data.plugins || typeof data.plugins !== "object") { + return []; + } + + const paths: string[] = []; + for (const [key, entries] of Object.entries(data.plugins)) { + if (!Array.isArray(entries)) continue; + // Skip the marketplace posthog plugin — the app bundles its own. + if (key.split("@")[0] === "posthog") continue; + for (const entry of entries) { + if (entry.installPath && fs.existsSync(entry.installPath)) { + paths.push(entry.installPath); + } + } + } + return paths; + } catch { + return []; + } +} + +export async function readSkillMetadataFromDir( + skillsDir: string, + source: SkillSource, + repoName?: string, +): Promise { + const skillNames = await findSkillDirs(skillsDir); + if (skillNames.length === 0) return []; + + const results = await Promise.all( + skillNames.map(async (skillName) => { + const skillPath = path.join(skillsDir, skillName); + try { + const content = await fs.promises.readFile( + path.join(skillPath, "SKILL.md"), + "utf-8", + ); + const frontmatter = parseSkillFrontmatter(content); + return { + name: frontmatter?.name ?? skillName, + description: frontmatter?.description ?? "", + source, + path: skillPath, + ...(repoName ? { repoName } : {}), + } satisfies SkillInfo; + } catch { + return null; + } + }), + ); + return results.filter((r): r is SkillInfo => r !== null); +} diff --git a/packages/workspace-server/src/services/skills/skills.module.ts b/packages/workspace-server/src/services/skills/skills.module.ts new file mode 100644 index 0000000000..726241202a --- /dev/null +++ b/packages/workspace-server/src/services/skills/skills.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { SKILLS_SERVICE } from "./identifiers"; +import { SkillsService } from "./skills"; + +export const skillsModule = new ContainerModule(({ bind }) => { + bind(SKILLS_SERVICE).to(SkillsService).inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/skills/skills.ts b/packages/workspace-server/src/services/skills/skills.ts new file mode 100644 index 0000000000..80b86c7912 --- /dev/null +++ b/packages/workspace-server/src/services/skills/skills.ts @@ -0,0 +1,48 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import { inject, injectable } from "inversify"; +import type { FoldersService } from "../folders/folders"; +import { FOLDERS_SERVICE } from "../folders/identifiers"; +import { POSTHOG_PLUGIN_SERVICE } from "../posthog-plugin/identifiers"; +import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin"; +import type { SkillInfo } from "./schemas"; +import { + getMarketplaceInstallPaths, + readSkillMetadataFromDir, +} from "./skill-discovery"; + +@injectable() +export class SkillsService { + constructor( + @inject(POSTHOG_PLUGIN_SERVICE) + private readonly plugin: PosthogPluginService, + @inject(FOLDERS_SERVICE) + private readonly folders: FoldersService, + ) {} + + async listSkills(): Promise { + const pluginPath = this.plugin.getPluginPath(); + const folders = await this.folders.getFolders(); + const marketplacePaths = await getMarketplaceInstallPaths(); + + const results = await Promise.all([ + readSkillMetadataFromDir(path.join(pluginPath, "skills"), "bundled"), + readSkillMetadataFromDir( + path.join(os.homedir(), ".claude", "skills"), + "user", + ), + ...folders.map((f) => + readSkillMetadataFromDir( + path.join(f.path, ".claude", "skills"), + "repo", + f.name, + ), + ), + ...marketplacePaths.map((p) => + readSkillMetadataFromDir(path.join(p, "skills"), "marketplace"), + ), + ]); + + return results.flat(); + } +} diff --git a/packages/workspace-server/src/services/suspension/identifiers.ts b/packages/workspace-server/src/services/suspension/identifiers.ts new file mode 100644 index 0000000000..104c606c79 --- /dev/null +++ b/packages/workspace-server/src/services/suspension/identifiers.ts @@ -0,0 +1,9 @@ +export const SUSPENSION_SERVICE = Symbol.for( + "posthog.workspace.suspensionService", +); +export const SUSPENSION_SESSION_CANCELLER = Symbol.for( + "posthog.workspace.suspensionSessionCanceller", +); +export const SUSPENSION_FILE_WATCHER = Symbol.for( + "posthog.workspace.suspensionFileWatcher", +); diff --git a/packages/workspace-server/src/services/suspension/ports.ts b/packages/workspace-server/src/services/suspension/ports.ts new file mode 100644 index 0000000000..3f397fbbac --- /dev/null +++ b/packages/workspace-server/src/services/suspension/ports.ts @@ -0,0 +1,7 @@ +export interface SessionCanceller { + cancelSessionsByTaskId(taskId: string): Promise; +} + +export interface SuspensionFileWatcher { + stopWatching(worktreePath: string): Promise; +} diff --git a/apps/code/src/main/services/suspension/schemas.ts b/packages/workspace-server/src/services/suspension/schemas.ts similarity index 51% rename from apps/code/src/main/services/suspension/schemas.ts rename to packages/workspace-server/src/services/suspension/schemas.ts index 1cc43bf534..c67e7dcfb1 100644 --- a/apps/code/src/main/services/suspension/schemas.ts +++ b/packages/workspace-server/src/services/suspension/schemas.ts @@ -1,12 +1,33 @@ import { z } from "zod"; -import { - type SuspendedTask, - suspendedTaskSchema, - suspensionReasonSchema, - suspensionSettingsSchema, -} from "../../../shared/types/suspension.js"; - -export { suspendedTaskSchema, type SuspendedTask }; + +export const suspensionReasonSchema = z.enum([ + "max_worktrees", + "inactivity", + "manual", +]); + +export type SuspensionReason = z.infer; + +export const suspendedTaskSchema = z.object({ + taskId: z.string(), + suspendedAt: z.string(), + reason: suspensionReasonSchema, + folderId: z.string(), + mode: z.enum(["worktree", "local", "cloud"]), + worktreeName: z.string().nullable(), + branchName: z.string().nullable(), + checkpointId: z.string().nullable(), +}); + +export type SuspendedTask = z.infer; + +export const suspensionSettingsSchema = z.object({ + autoSuspendEnabled: z.boolean(), + maxActiveWorktrees: z.number().min(1), + autoSuspendAfterDays: z.number().min(1), +}); + +export type SuspensionSettings = z.infer; export const suspendTaskInput = z.object({ taskId: z.string(), diff --git a/packages/workspace-server/src/services/suspension/suspension.module.ts b/packages/workspace-server/src/services/suspension/suspension.module.ts new file mode 100644 index 0000000000..de81ad5042 --- /dev/null +++ b/packages/workspace-server/src/services/suspension/suspension.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { SUSPENSION_SERVICE } from "./identifiers"; +import { SuspensionService } from "./suspension"; + +export const suspensionModule = new ContainerModule(({ bind }) => { + bind(SUSPENSION_SERVICE).to(SuspensionService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/suspension/service.test.ts b/packages/workspace-server/src/services/suspension/suspension.test.ts similarity index 82% rename from apps/code/src/main/services/suspension/service.test.ts rename to packages/workspace-server/src/services/suspension/suspension.test.ts index d3a8135eae..913b6dac63 100644 --- a/apps/code/src/main/services/suspension/service.test.ts +++ b/packages/workspace-server/src/services/suspension/suspension.test.ts @@ -14,16 +14,6 @@ const mockWorktreeManagerProto = vi.hoisted(() => ({ createDetachedWorktreeAtCommit: vi.fn(), })); -vi.mock("../settingsStore.js", () => ({ - getAutoSuspendEnabled: mockGetAutoSuspendEnabled, - getMaxActiveWorktrees: mockGetMaxActiveWorktrees, - getAutoSuspendAfterDays: mockGetAutoSuspendAfterDays, - setAutoSuspendEnabled: vi.fn(), - setMaxActiveWorktrees: vi.fn(), - setAutoSuspendAfterDays: vi.fn(), - getWorktreeLocation: vi.fn(() => "/tmp/worktrees"), -})); - vi.mock("@posthog/git/client", () => ({ createGitClient: mockCreateGitClient, })); @@ -60,56 +50,55 @@ vi.mock("node:fs/promises", () => { return { default: fns, ...fns }; }); -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -vi.mock("../../di/tokens.js", () => ({ - MAIN_TOKENS: { - AgentService: Symbol.for("Main.AgentService"), - ProcessTrackingService: Symbol.for("Main.ProcessTrackingService"), - FileWatcherService: Symbol.for("Main.FileWatcherService"), - RepositoryRepository: Symbol.for("Main.RepositoryRepository"), - WorkspaceRepository: Symbol.for("Main.WorkspaceRepository"), - WorktreeRepository: Symbol.for("Main.WorktreeRepository"), - SuspensionRepository: Symbol.for("Main.SuspensionRepository"), - ArchiveRepository: Symbol.for("Main.ArchiveRepository"), - }, -})); - -import { createMockArchiveRepository } from "../../db/repositories/archive-repository.mock.js"; -import { createMockRepositoryRepository } from "../../db/repositories/repository-repository.mock.js"; -import { createMockSuspensionRepository } from "../../db/repositories/suspension-repository.mock.js"; -import type { Workspace } from "../../db/repositories/workspace-repository.js"; -import { createMockWorkspaceRepository } from "../../db/repositories/workspace-repository.mock.js"; -import { createMockWorktreeRepository } from "../../db/repositories/worktree-repository.mock.js"; -import type { AgentService } from "../agent/service.js"; -import type { FileWatcherBridge } from "../file-watcher/bridge"; -import type { ProcessTrackingService } from "../process-tracking/service.js"; -import { SuspensionService } from "./service.js"; +import type { IWorkspaceSettings } from "@posthog/platform/workspace-settings"; +import { createMockArchiveRepository } from "@posthog/workspace-server/db/repositories/archive-repository.mock"; +import { createMockRepositoryRepository } from "@posthog/workspace-server/db/repositories/repository-repository.mock"; +import { createMockSuspensionRepository } from "@posthog/workspace-server/db/repositories/suspension-repository.mock"; +import type { Workspace } from "@posthog/workspace-server/db/repositories/workspace-repository"; +import { createMockWorkspaceRepository } from "@posthog/workspace-server/db/repositories/workspace-repository.mock"; +import { createMockWorktreeRepository } from "@posthog/workspace-server/db/repositories/worktree-repository.mock"; +import type { ProcessTrackingService } from "../process-tracking/process-tracking"; +import type { SessionCanceller, SuspensionFileWatcher } from "./ports"; +import { SuspensionService } from "./suspension"; function createMocks() { const agentService = { cancelSessionsByTaskId: vi.fn(), - } as unknown as AgentService; + } as unknown as SessionCanceller; const processTracking = { killByTaskId: vi.fn(), } as unknown as ProcessTrackingService; const fileWatcher = { stopWatching: vi.fn(), - } as unknown as FileWatcherBridge; + } as unknown as SuspensionFileWatcher; const repositoryRepo = createMockRepositoryRepository(); const workspaceRepo = createMockWorkspaceRepository(); const worktreeRepo = createMockWorktreeRepository(); const suspensionRepo = createMockSuspensionRepository(); const archiveRepo = createMockArchiveRepository(); + const workspaceSettings = { + getAutoSuspendEnabled: mockGetAutoSuspendEnabled, + getMaxActiveWorktrees: mockGetMaxActiveWorktrees, + getAutoSuspendAfterDays: mockGetAutoSuspendAfterDays, + setAutoSuspendEnabled: vi.fn(), + setMaxActiveWorktrees: vi.fn(), + setAutoSuspendAfterDays: vi.fn(), + getWorktreeLocation: () => "/tmp/worktrees", + getAllWorktreeLocations: () => ["/tmp/worktrees"], + setWorktreeLocation: vi.fn(), + } as unknown as IWorkspaceSettings; + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + scope: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }; repositoryRepo.create({ path: "/repo", id: "repo-1" }); @@ -122,6 +111,8 @@ function createMocks() { worktreeRepo, suspensionRepo, archiveRepo, + workspaceSettings, + logger, }; } @@ -135,6 +126,8 @@ function makeService(mocks: ReturnType) { mocks.worktreeRepo, mocks.suspensionRepo, mocks.archiveRepo, + mocks.workspaceSettings, + mocks.logger, ); } diff --git a/apps/code/src/main/services/suspension/service.ts b/packages/workspace-server/src/services/suspension/suspension.ts similarity index 74% rename from apps/code/src/main/services/suspension/service.ts rename to packages/workspace-server/src/services/suspension/suspension.ts index ba765fc3c3..791c1adbe2 100644 --- a/apps/code/src/main/services/suspension/service.ts +++ b/packages/workspace-server/src/services/suspension/suspension.ts @@ -1,43 +1,51 @@ -import fs from "node:fs/promises"; import path from "node:path"; -import { createGitClient } from "@posthog/git/client"; import { - CaptureCheckpointSaga, - deleteCheckpoint, - RevertCheckpointSaga, -} from "@posthog/git/sagas/checkpoint"; + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { createGitClient } from "@posthog/git/client"; +import { deleteCheckpoint } from "@posthog/git/sagas/checkpoint"; import { forceRemove } from "@posthog/git/utils"; -import { type WorktreeInfo, WorktreeManager } from "@posthog/git/worktree"; +import { WorktreeManager } from "@posthog/git/worktree"; +import { + type IWorkspaceSettings, + WORKSPACE_SETTINGS_SERVICE, +} from "@posthog/platform/workspace-settings"; +import { TypedEventEmitter } from "@posthog/shared"; import { inject, injectable } from "inversify"; -import type { IArchiveRepository } from "../../db/repositories/archive-repository.js"; -import type { IRepositoryRepository } from "../../db/repositories/repository-repository.js"; +import { + ARCHIVE_REPOSITORY, + REPOSITORY_REPOSITORY, + SUSPENSION_REPOSITORY, + WORKSPACE_REPOSITORY, + WORKTREE_REPOSITORY, +} from "../../db/identifiers"; +import type { IArchiveRepository } from "../../db/repositories/archive-repository"; +import type { IRepositoryRepository } from "../../db/repositories/repository-repository"; import type { SuspensionReason, SuspensionRepository, -} from "../../db/repositories/suspension-repository.js"; +} from "../../db/repositories/suspension-repository"; import type { IWorkspaceRepository, Workspace, -} from "../../db/repositories/workspace-repository.js"; -import type { IWorktreeRepository } from "../../db/repositories/worktree-repository.js"; -import { MAIN_TOKENS } from "../../di/tokens.js"; -import { logger } from "../../utils/logger.js"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter.js"; -import type { AgentService } from "../agent/service.js"; -import type { FileWatcherBridge } from "../file-watcher/bridge"; -import type { ProcessTrackingService } from "../process-tracking/service.js"; +} from "../../db/repositories/workspace-repository"; +import type { IWorktreeRepository } from "../../db/repositories/worktree-repository"; +import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; +import type { ProcessTrackingService } from "../process-tracking/process-tracking"; +import { + captureWorktreeCheckpoint, + restoreWorktreeFromCheckpoint, +} from "../worktree-checkpoint/worktree-checkpoint"; +import { resolveWorktreePathByProbe } from "../worktree-path/worktree-path"; +import { getCurrentBranchName } from "../worktree-query/worktree-query"; import { - getAutoSuspendAfterDays, - getAutoSuspendEnabled, - getMaxActiveWorktrees, - getWorktreeLocation, - setAutoSuspendAfterDays, - setAutoSuspendEnabled, - setMaxActiveWorktrees, -} from "../settingsStore.js"; -import type { SuspendedTask } from "./schemas.js"; - -const log = logger.scope("suspension"); + SUSPENSION_FILE_WATCHER, + SUSPENSION_SESSION_CANCELLER, +} from "./identifiers"; +import type { SessionCanceller, SuspensionFileWatcher } from "./ports"; +import type { SuspendedTask } from "./schemas"; type RollbackFn = () => Promise; type StepFn = ( @@ -58,33 +66,39 @@ export interface SuspensionServiceEvents { @injectable() export class SuspensionService extends TypedEventEmitter { private inactivityTimerId: ReturnType | null = null; + private readonly log: ScopedLogger; constructor( - @inject(MAIN_TOKENS.AgentService) - private readonly agentService: AgentService, - @inject(MAIN_TOKENS.ProcessTrackingService) + @inject(SUSPENSION_SESSION_CANCELLER) + private readonly sessionCanceller: SessionCanceller, + @inject(PROCESS_TRACKING_SERVICE) private readonly processTracking: ProcessTrackingService, - @inject(MAIN_TOKENS.FileWatcherService) - private readonly fileWatcher: FileWatcherBridge, - @inject(MAIN_TOKENS.RepositoryRepository) + @inject(SUSPENSION_FILE_WATCHER) + private readonly fileWatcher: SuspensionFileWatcher, + @inject(REPOSITORY_REPOSITORY) private readonly repositoryRepo: IRepositoryRepository, - @inject(MAIN_TOKENS.WorkspaceRepository) + @inject(WORKSPACE_REPOSITORY) private readonly workspaceRepo: IWorkspaceRepository, - @inject(MAIN_TOKENS.WorktreeRepository) + @inject(WORKTREE_REPOSITORY) private readonly worktreeRepo: IWorktreeRepository, - @inject(MAIN_TOKENS.SuspensionRepository) + @inject(SUSPENSION_REPOSITORY) private readonly suspensionRepo: SuspensionRepository, - @inject(MAIN_TOKENS.ArchiveRepository) + @inject(ARCHIVE_REPOSITORY) private readonly archiveRepo: IArchiveRepository, + @inject(WORKSPACE_SETTINGS_SERVICE) + private readonly workspaceSettings: IWorkspaceSettings, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, ) { super(); + this.log = logger.scope("suspension"); } async suspendTask( taskId: string, reason: SuspensionReason, ): Promise { - log.info(`Suspending task ${taskId} (reason: ${reason})`); + this.log.info(`Suspending task ${taskId} (reason: ${reason})`); const result = await this.withRollback((step) => this.executeSuspend(taskId, reason, step), ); @@ -96,7 +110,7 @@ export class SuspensionService extends TypedEventEmitter { - log.info( + this.log.info( `Restoring suspended task ${taskId}${recreateBranch ? " (recreate branch)" : ""}`, ); const result = await this.withRollback((step) => @@ -136,8 +150,8 @@ export class SuspensionService extends TypedEventEmitter { - if (!getAutoSuspendEnabled()) return; - const maxActive = getMaxActiveWorktrees(); + if (!this.workspaceSettings.getAutoSuspendEnabled()) return; + const maxActive = this.workspaceSettings.getMaxActiveWorktrees(); const active = this.getActiveWorktreeWorkspaces(); if (active.length < maxActive) return; @@ -148,15 +162,15 @@ export class SuspensionService extends TypedEventEmitter { - if (!getAutoSuspendEnabled()) return; - const thresholdDays = getAutoSuspendAfterDays(); + if (!this.workspaceSettings.getAutoSuspendEnabled()) return; + const thresholdDays = this.workspaceSettings.getAutoSuspendAfterDays(); const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - thresholdDays); @@ -167,7 +181,7 @@ export class SuspensionService extends TypedEventEmitter { this.suspendInactiveWorktrees().catch((error) => { - log.error("Inactivity checker failed:", error); + this.log.error("Inactivity checker failed:", error); }); }, ONE_HOUR_MS); } @@ -192,9 +206,9 @@ export class SuspensionService extends TypedEventEmitter(fn: (step: StepFn) => Promise): Promise { @@ -225,7 +241,7 @@ export class SuspensionService extends TypedEventEmitter { - await this.agentService.cancelSessionsByTaskId(taskId); + await this.sessionCanceller.cancelSessionsByTaskId(taskId); this.processTracking.killByTaskId(taskId); if (worktreePath) await this.fileWatcher.stopWatching(worktreePath); } @@ -438,29 +454,16 @@ export class SuspensionService extends TypedEventEmitter { - try { - const git = createGitClient(worktreePath); - return (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); - } catch { - return ""; - } + private getCurrentBranchName(worktreePath: string): Promise { + return getCurrentBranchName(worktreePath); } - private async captureWorktreeCheckpoint( + private captureWorktreeCheckpoint( folderPath: string, worktreePath: string, checkpointId: string, ): Promise { - const git = createGitClient(folderPath); - try { - await deleteCheckpoint(git, checkpointId); - } catch {} - - const saga = new CaptureCheckpointSaga(); - const result = await saga.run({ baseDir: worktreePath, checkpointId }); - if (!result.success) - throw new Error(`Failed to capture checkpoint: ${result.error}`); + return captureWorktreeCheckpoint(folderPath, worktreePath, checkpointId); } private async restoreWorktreeFromCheckpoint( @@ -471,63 +474,28 @@ export class SuspensionService extends TypedEventEmitter { const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - const manager = this.createWorktreeManager(folderPath); - const preferredName = worktree?.name ?? undefined; - - let newWorktree: WorktreeInfo; - if (branchName && !recreateBranch) { - newWorktree = await manager.createWorktreeForExistingBranch( - branchName, - preferredName, - ); - } else { - newWorktree = await manager.createDetachedWorktreeAtCommit( - "HEAD", - preferredName, - ); - } - const revertSaga = new RevertCheckpointSaga(); - const result = await revertSaga.run({ - baseDir: newWorktree.worktreePath, + const newWorktree = await restoreWorktreeFromCheckpoint({ + mainRepoPath: folderPath, + worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), + preferredName: worktree?.name ?? undefined, + branchName, checkpointId, + recreateBranch, }); - if (!result.success) - throw new Error( - `Worktree restored but failed to apply checkpoint: ${result.error}`, - ); - - if (recreateBranch && branchName) { - const git = createGitClient(newWorktree.worktreePath); - await git.checkoutLocalBranch(branchName); - } if (worktree) this.worktreeRepo.deleteByWorkspaceId(workspace.id); return newWorktree.worktreeName; } - private async deriveWorktreePath( + private deriveWorktreePath( folderPath: string, worktreeName: string, ): Promise { - const worktreeBasePath = getWorktreeLocation(); - const repoName = path.basename(folderPath); - - const newFormatPath = path.join(worktreeBasePath, worktreeName, repoName); - const legacyFormatPath = path.join( - worktreeBasePath, - repoName, + return resolveWorktreePathByProbe( + this.workspaceSettings.getWorktreeLocation(), + folderPath, worktreeName, ); - - try { - await fs.access(newFormatPath); - return newFormatPath; - } catch {} - try { - await fs.access(legacyFormatPath); - return legacyFormatPath; - } catch {} - return newFormatPath; } } diff --git a/packages/workspace-server/src/services/watcher-registry/identifiers.ts b/packages/workspace-server/src/services/watcher-registry/identifiers.ts new file mode 100644 index 0000000000..80ecb785b3 --- /dev/null +++ b/packages/workspace-server/src/services/watcher-registry/identifiers.ts @@ -0,0 +1,3 @@ +export const WATCHER_REGISTRY_SERVICE = Symbol.for( + "posthog.workspace.watcherRegistryService", +); diff --git a/packages/workspace-server/src/services/watcher-registry/watcher-registry.module.ts b/packages/workspace-server/src/services/watcher-registry/watcher-registry.module.ts new file mode 100644 index 0000000000..e675289bcd --- /dev/null +++ b/packages/workspace-server/src/services/watcher-registry/watcher-registry.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { WATCHER_REGISTRY_SERVICE } from "./identifiers"; +import { WatcherRegistryService } from "./watcher-registry"; + +export const watcherRegistryModule = new ContainerModule(({ bind }) => { + bind(WATCHER_REGISTRY_SERVICE).to(WatcherRegistryService).inSingletonScope(); +}); diff --git a/apps/code/src/main/services/watcher-registry/service.ts b/packages/workspace-server/src/services/watcher-registry/watcher-registry.ts similarity index 67% rename from apps/code/src/main/services/watcher-registry/service.ts rename to packages/workspace-server/src/services/watcher-registry/watcher-registry.ts index 9ac03ae930..8645ad6d0d 100644 --- a/apps/code/src/main/services/watcher-registry/service.ts +++ b/packages/workspace-server/src/services/watcher-registry/watcher-registry.ts @@ -1,8 +1,10 @@ import type * as watcher from "@parcel/watcher"; -import { injectable } from "inversify"; -import { logger } from "../../utils/logger"; - -const log = logger.scope("watcher-registry"); +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; const UNSUBSCRIBE_TIMEOUT_MS = 2000; @@ -10,6 +12,14 @@ const UNSUBSCRIBE_TIMEOUT_MS = 2000; export class WatcherRegistryService { private subscriptions = new Map(); private _isShutdown = false; + private readonly log: ScopedLogger; + + constructor( + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + this.log = logger.scope("watcher-registry"); + } get isShutdown(): boolean { return this._isShutdown; @@ -17,9 +27,11 @@ export class WatcherRegistryService { register(id: string, subscription: watcher.AsyncSubscription): void { if (this._isShutdown) { - log.warn(`Attempted to register watcher after shutdown: ${id}`); + this.log.warn(`Attempted to register watcher after shutdown: ${id}`); subscription.unsubscribe().catch((err) => { - log.warn(`Failed to unsubscribe rejected watcher ${id}:`, err); + this.log.warn(`Failed to unsubscribe rejected watcher ${id}:`, { + error: err, + }); }); return; } @@ -27,7 +39,9 @@ export class WatcherRegistryService { if (this.subscriptions.has(id)) { const existing = this.subscriptions.get(id); existing?.unsubscribe().catch((err) => { - log.warn(`Failed to unsubscribe replaced watcher ${id}:`, err); + this.log.warn(`Failed to unsubscribe replaced watcher ${id}:`, { + error: err, + }); }); } @@ -41,15 +55,15 @@ export class WatcherRegistryService { this.subscriptions.delete(id); try { await subscription.unsubscribe(); - log.debug(`Unregistered watcher: ${id}`); + this.log.debug(`Unregistered watcher: ${id}`); } catch (err) { - log.warn(`Failed to unsubscribe watcher ${id}:`, err); + this.log.warn(`Failed to unsubscribe watcher ${id}:`, { error: err }); } } async shutdownAll(): Promise { if (this._isShutdown) { - log.warn("shutdownAll called but already shutdown"); + this.log.warn("shutdownAll called but already shutdown"); return; } @@ -57,11 +71,11 @@ export class WatcherRegistryService { const count = this.subscriptions.size; if (count === 0) { - log.info("No watchers to shutdown"); + this.log.info("No watchers to shutdown"); return; } - log.info(`Shutting down ${count} watchers`); + this.log.info(`Shutting down ${count} watchers`); const entries = Array.from(this.subscriptions.entries()); this.subscriptions.clear(); @@ -76,11 +90,11 @@ export class WatcherRegistryService { ).length; if (failures > 0 || timeouts > 0) { - log.warn( + this.log.warn( `Watcher shutdown: ${count - failures - timeouts} clean, ${timeouts} timed out, ${failures} failed`, ); } else { - log.info(`All ${count} watchers shutdown successfully`); + this.log.info(`All ${count} watchers shutdown successfully`); } } @@ -96,18 +110,18 @@ export class WatcherRegistryService { .unsubscribe() .then(() => "ok" as const) .catch((err) => { - log.warn(`Failed to unsubscribe watcher ${id}:`, err); + this.log.warn(`Failed to unsubscribe watcher ${id}:`, { error: err }); return "ok" as const; }); const result = await Promise.race([unsubPromise, timeoutPromise]); if (result === "timeout") { - log.warn( + this.log.warn( `Watcher ${id} unsubscribe timed out after ${UNSUBSCRIBE_TIMEOUT_MS}ms`, ); } else { - log.debug(`Shutdown watcher: ${id}`); + this.log.debug(`Shutdown watcher: ${id}`); } return result; diff --git a/packages/workspace-server/src/services/workspace-metadata/identifiers.ts b/packages/workspace-server/src/services/workspace-metadata/identifiers.ts new file mode 100644 index 0000000000..94d2dd9490 --- /dev/null +++ b/packages/workspace-server/src/services/workspace-metadata/identifiers.ts @@ -0,0 +1,3 @@ +export const WORKSPACE_METADATA_SERVICE = Symbol.for( + "posthog.workspace.workspaceMetadataService", +); diff --git a/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.module.ts b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.module.ts new file mode 100644 index 0000000000..3e858b2602 --- /dev/null +++ b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.module.ts @@ -0,0 +1,9 @@ +import { ContainerModule } from "inversify"; +import { WORKSPACE_METADATA_SERVICE } from "./identifiers"; +import { WorkspaceMetadataService } from "./workspace-metadata"; + +export const workspaceMetadataModule = new ContainerModule(({ bind }) => { + bind(WORKSPACE_METADATA_SERVICE) + .to(WorkspaceMetadataService) + .inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts new file mode 100644 index 0000000000..71dea7bcb9 --- /dev/null +++ b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WorkspaceMetadataService } from "./workspace-metadata"; + +const NOW_ISO = "2026-01-01T00:00:00.000Z"; + +function createService() { + const repo = { + findByTaskId: vi.fn(), + findAll: vi.fn(), + findAllPinned: vi.fn(), + updatePinnedAt: vi.fn(), + updateLastViewedAt: vi.fn(), + updateLastActivityAt: vi.fn(), + }; + const service = new WorkspaceMetadataService(repo as never); + return { service, repo }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW_ISO)); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("WorkspaceMetadataService.togglePin", () => { + it("returns an unpinned result and updates nothing when the workspace is missing", () => { + const { service, repo } = createService(); + repo.findByTaskId.mockReturnValue(undefined); + + expect(service.togglePin("t1")).toEqual({ + isPinned: false, + pinnedAt: null, + }); + expect(repo.updatePinnedAt).not.toHaveBeenCalled(); + }); + + it("pins an unpinned workspace with the current timestamp", () => { + const { service, repo } = createService(); + repo.findByTaskId.mockReturnValue({ taskId: "t1", pinnedAt: null }); + + expect(service.togglePin("t1")).toEqual({ + isPinned: true, + pinnedAt: NOW_ISO, + }); + expect(repo.updatePinnedAt).toHaveBeenCalledWith("t1", NOW_ISO); + }); + + it("unpins an already-pinned workspace", () => { + const { service, repo } = createService(); + repo.findByTaskId.mockReturnValue({ + taskId: "t1", + pinnedAt: "2025-01-01T00:00:00.000Z", + }); + + expect(service.togglePin("t1")).toEqual({ + isPinned: false, + pinnedAt: null, + }); + expect(repo.updatePinnedAt).toHaveBeenCalledWith("t1", null); + }); +}); + +describe("WorkspaceMetadataService.markViewed", () => { + it("records the current time as the last viewed timestamp", () => { + const { service, repo } = createService(); + service.markViewed("t1"); + expect(repo.updateLastViewedAt).toHaveBeenCalledWith("t1", NOW_ISO); + }); +}); + +describe("WorkspaceMetadataService.markActivity", () => { + it("uses the current time when the last viewed time is in the past", () => { + const { service, repo } = createService(); + repo.findByTaskId.mockReturnValue({ + taskId: "t1", + lastViewedAt: "2020-01-01T00:00:00.000Z", + }); + + service.markActivity("t1"); + + expect(repo.updateLastActivityAt).toHaveBeenCalledWith("t1", NOW_ISO); + }); + + it("clamps activity to one ms after a future last-viewed time", () => { + const { service, repo } = createService(); + const future = "2027-01-01T00:00:00.000Z"; + repo.findByTaskId.mockReturnValue({ taskId: "t1", lastViewedAt: future }); + + service.markActivity("t1"); + + const expected = new Date(new Date(future).getTime() + 1).toISOString(); + expect(repo.updateLastActivityAt).toHaveBeenCalledWith("t1", expected); + }); + + it("falls back to the current time when there is no last viewed time", () => { + const { service, repo } = createService(); + repo.findByTaskId.mockReturnValue({ taskId: "t1", lastViewedAt: null }); + + service.markActivity("t1"); + + expect(repo.updateLastActivityAt).toHaveBeenCalledWith("t1", NOW_ISO); + }); +}); + +describe("WorkspaceMetadataService projections", () => { + it("returns the task ids of all pinned workspaces", () => { + const { service, repo } = createService(); + repo.findAllPinned.mockReturnValue([{ taskId: "a" }, { taskId: "b" }]); + + expect(service.getPinnedTaskIds()).toEqual(["a", "b"]); + }); + + it("projects the timestamps for a task, defaulting missing values to null", () => { + const { service, repo } = createService(); + repo.findByTaskId.mockReturnValue({ + taskId: "t1", + pinnedAt: "2025-01-01T00:00:00.000Z", + lastViewedAt: null, + lastActivityAt: null, + }); + + expect(service.getTaskTimestamps("t1")).toEqual({ + pinnedAt: "2025-01-01T00:00:00.000Z", + lastViewedAt: null, + lastActivityAt: null, + }); + }); + + it("returns all-null timestamps for an unknown task", () => { + const { service, repo } = createService(); + repo.findByTaskId.mockReturnValue(undefined); + + expect(service.getTaskTimestamps("missing")).toEqual({ + pinnedAt: null, + lastViewedAt: null, + lastActivityAt: null, + }); + }); + + it("builds a record of timestamps keyed by task id", () => { + const { service, repo } = createService(); + repo.findAll.mockReturnValue([ + { + taskId: "a", + pinnedAt: "p", + lastViewedAt: "v", + lastActivityAt: "x", + }, + ]); + + expect(service.getAllTaskTimestamps()).toEqual({ + a: { pinnedAt: "p", lastViewedAt: "v", lastActivityAt: "x" }, + }); + }); +}); diff --git a/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts new file mode 100644 index 0000000000..3cb8f14b9b --- /dev/null +++ b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts @@ -0,0 +1,74 @@ +import { inject, injectable } from "inversify"; +import { WORKSPACE_REPOSITORY } from "../../db/identifiers"; +import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; + +export interface TaskTimestamps { + pinnedAt: string | null; + lastViewedAt: string | null; + lastActivityAt: string | null; +} + +/** + * Pin / view / activity metadata for tasks — pure projections over the + * Workspace records. Extracted from the monolithic WorkspaceService so these + * data operations live next to the repository, with no git/fs/orchestration. + */ +@injectable() +export class WorkspaceMetadataService { + constructor( + @inject(WORKSPACE_REPOSITORY) + private readonly workspaceRepo: IWorkspaceRepository, + ) {} + + togglePin(taskId: string): { isPinned: boolean; pinnedAt: string | null } { + const workspace = this.workspaceRepo.findByTaskId(taskId); + if (!workspace) { + return { isPinned: false, pinnedAt: null }; + } + const newPinnedAt = workspace.pinnedAt ? null : new Date().toISOString(); + this.workspaceRepo.updatePinnedAt(taskId, newPinnedAt); + return { isPinned: newPinnedAt !== null, pinnedAt: newPinnedAt }; + } + + markViewed(taskId: string): void { + this.workspaceRepo.updateLastViewedAt(taskId, new Date().toISOString()); + } + + markActivity(taskId: string): void { + const workspace = this.workspaceRepo.findByTaskId(taskId); + const lastViewedAt = workspace?.lastViewedAt + ? new Date(workspace.lastViewedAt).getTime() + : 0; + const now = Date.now(); + const activityTime = Math.max(now, lastViewedAt + 1); + this.workspaceRepo.updateLastActivityAt( + taskId, + new Date(activityTime).toISOString(), + ); + } + + getPinnedTaskIds(): string[] { + return this.workspaceRepo.findAllPinned().map((w) => w.taskId); + } + + getTaskTimestamps(taskId: string): TaskTimestamps { + const workspace = this.workspaceRepo.findByTaskId(taskId); + return { + pinnedAt: workspace?.pinnedAt ?? null, + lastViewedAt: workspace?.lastViewedAt ?? null, + lastActivityAt: workspace?.lastActivityAt ?? null, + }; + } + + getAllTaskTimestamps(): Record { + const result: Record = {}; + for (const w of this.workspaceRepo.findAll()) { + result[w.taskId] = { + pinnedAt: w.pinnedAt, + lastViewedAt: w.lastViewedAt, + lastActivityAt: w.lastActivityAt, + }; + } + return result; + } +} diff --git a/packages/workspace-server/src/services/workspace/identifiers.ts b/packages/workspace-server/src/services/workspace/identifiers.ts new file mode 100644 index 0000000000..91a8e92294 --- /dev/null +++ b/packages/workspace-server/src/services/workspace/identifiers.ts @@ -0,0 +1,11 @@ +export const WORKSPACE_SERVICE = Symbol.for( + "posthog.workspace.workspaceService", +); +export const WORKSPACE_FILE_WATCHER = Symbol.for( + "posthog.workspace.workspaceFileWatcher", +); +export const WORKSPACE_FOCUS = Symbol.for("posthog.workspace.workspaceFocus"); +export const WORKSPACE_AGENT = Symbol.for("posthog.workspace.workspaceAgent"); +export const WORKSPACE_PROVISIONING = Symbol.for( + "posthog.workspace.workspaceProvisioning", +); diff --git a/packages/workspace-server/src/services/workspace/ports.ts b/packages/workspace-server/src/services/workspace/ports.ts new file mode 100644 index 0000000000..31d330a00b --- /dev/null +++ b/packages/workspace-server/src/services/workspace/ports.ts @@ -0,0 +1,33 @@ +export interface GitStateChangedEvent { + repoPath: string; +} + +export interface BranchRenamedEvent { + mainRepoPath: string; + worktreePath: string; + oldBranch: string; + newBranch: string; +} + +export interface AgentFileActivityEvent { + taskId: string; + branchName: string | null; +} + +export interface WorkspaceFileWatcher { + stopWatching(worktreePath: string): Promise; + onGitStateChanged(handler: (event: GitStateChangedEvent) => void): void; +} + +export interface WorkspaceFocus { + onBranchRenamed(handler: (event: BranchRenamedEvent) => void): void; +} + +export interface WorkspaceAgent { + cancelSessionsByTaskId(taskId: string): Promise; + onAgentFileActivity(handler: (event: AgentFileActivityEvent) => void): void; +} + +export interface WorkspaceProvisioning { + emitOutput(taskId: string, data: string): void; +} diff --git a/apps/code/src/main/services/workspace/schemas.ts b/packages/workspace-server/src/services/workspace/schemas.ts similarity index 84% rename from apps/code/src/main/services/workspace/schemas.ts rename to packages/workspace-server/src/services/workspace/schemas.ts index 2569bab385..3006f6fd91 100644 --- a/apps/code/src/main/services/workspace/schemas.ts +++ b/packages/workspace-server/src/services/workspace/schemas.ts @@ -1,39 +1,17 @@ +import { + workspaceInfoSchema, + workspaceModeSchema, + workspaceSchema, + worktreeInfoSchema, +} from "@posthog/shared"; import { z } from "zod"; -// Base schemas -// Note: "root" is deprecated, migrated to "local" on read -export const workspaceModeSchema = z - .enum(["worktree", "local", "cloud", "root"]) - .transform((val) => (val === "root" ? "local" : val)); -export const worktreeInfoSchema = z.object({ - worktreePath: z.string(), - worktreeName: z.string(), - branchName: z.string().nullable(), - baseBranch: z.string(), - createdAt: z.string(), - output: z.string().optional(), -}); - -export const workspaceInfoSchema = z.object({ - taskId: z.string(), - mode: workspaceModeSchema, - worktree: worktreeInfoSchema.nullable(), - branchName: z.string().nullable(), - linkedBranch: z.string().nullable(), -}); - -export const workspaceSchema = z.object({ - taskId: z.string(), - folderId: z.string(), - folderPath: z.string(), - mode: workspaceModeSchema, - worktreePath: z.string().nullable(), - worktreeName: z.string().nullable(), - branchName: z.string().nullable(), - baseBranch: z.string().nullable(), - linkedBranch: z.string().nullable(), - createdAt: z.string(), -}); +export { + workspaceInfoSchema, + workspaceModeSchema, + workspaceSchema, + worktreeInfoSchema, +}; // Input schemas export const createWorkspaceInput = z @@ -266,10 +244,12 @@ export type SidebarPrState = z.infer; export type TaskPrStatus = z.infer; // Type exports -export type WorkspaceMode = z.infer; -export type WorktreeInfo = z.infer; -export type WorkspaceInfo = z.infer; -export type Workspace = z.infer; +export type { + Workspace, + WorkspaceInfo, + WorkspaceMode, + WorktreeInfo, +} from "@posthog/shared"; export type CreateWorkspaceInput = z.infer; export type ReconcileCloudWorkspacesInput = z.infer< diff --git a/packages/workspace-server/src/services/workspace/workspace.module.ts b/packages/workspace-server/src/services/workspace/workspace.module.ts new file mode 100644 index 0000000000..fce1a60ce9 --- /dev/null +++ b/packages/workspace-server/src/services/workspace/workspace.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { WORKSPACE_SERVICE } from "./identifiers"; +import { WorkspaceService } from "./workspace"; + +export const workspaceModule = new ContainerModule(({ bind }) => { + bind(WORKSPACE_SERVICE).to(WorkspaceService).inSingletonScope(); +}); diff --git a/packages/workspace-server/src/services/workspace/workspace.test.ts b/packages/workspace-server/src/services/workspace/workspace.test.ts new file mode 100644 index 0000000000..82d0bd461d --- /dev/null +++ b/packages/workspace-server/src/services/workspace/workspace.test.ts @@ -0,0 +1,217 @@ +import type { WorkbenchLogger } from "@posthog/di/logger"; +import type { IAnalytics } from "@posthog/platform/analytics"; +import type { IWorkspaceSettings } from "@posthog/platform/workspace-settings"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createMockRepositoryRepository } from "../../db/repositories/repository-repository.mock"; +import { createMockWorkspaceRepository } from "../../db/repositories/workspace-repository.mock"; +import { createMockWorktreeRepository } from "../../db/repositories/worktree-repository.mock"; +import type { ProcessTrackingService } from "../process-tracking/process-tracking"; +import type { SuspensionService } from "../suspension/suspension"; +import type { + WorkspaceAgent, + WorkspaceFileWatcher, + WorkspaceFocus, + WorkspaceProvisioning, +} from "./ports"; +import { WorkspaceService, WorkspaceServiceEvent } from "./workspace"; + +function createMocks() { + const agent = { + cancelSessionsByTaskId: vi.fn(async () => {}), + onAgentFileActivity: vi.fn(), + } satisfies WorkspaceAgent; + const processTracking = { + killByTaskId: vi.fn(), + } as unknown as ProcessTrackingService; + const repositoryRepo = createMockRepositoryRepository(); + const workspaceRepo = createMockWorkspaceRepository(); + const worktreeRepo = createMockWorktreeRepository(); + const suspensionService = { + suspendLeastRecentIfOverLimit: vi.fn(async () => {}), + } as unknown as SuspensionService; + const provisioning = { + emitOutput: vi.fn(), + } satisfies WorkspaceProvisioning; + const fileWatcher = { + stopWatching: vi.fn(async () => {}), + onGitStateChanged: vi.fn(), + } satisfies WorkspaceFileWatcher; + const focus = { + onBranchRenamed: vi.fn(), + } satisfies WorkspaceFocus; + const workspaceSettings = { + getWorktreeLocation: () => "/tmp/worktrees", + } as unknown as IWorkspaceSettings; + const analytics = { + track: vi.fn(), + } as unknown as IAnalytics; + const scopedLog = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const log: WorkbenchLogger = { + ...scopedLog, + scope: vi.fn(() => scopedLog), + }; + + return { + agent, + processTracking, + repositoryRepo, + workspaceRepo, + worktreeRepo, + suspensionService, + provisioning, + fileWatcher, + focus, + workspaceSettings, + analytics, + log, + }; +} + +function makeService(mocks: ReturnType): WorkspaceService { + return new WorkspaceService( + mocks.agent, + mocks.processTracking, + mocks.repositoryRepo, + mocks.workspaceRepo, + mocks.worktreeRepo, + mocks.suspensionService, + mocks.provisioning, + mocks.fileWatcher, + mocks.focus, + mocks.workspaceSettings, + mocks.analytics, + mocks.log, + ); +} + +describe("WorkspaceService", () => { + let mocks: ReturnType; + let service: WorkspaceService; + + beforeEach(() => { + mocks = createMocks(); + service = makeService(mocks); + }); + + describe("reconcileCloudWorkspaces", () => { + it("creates only task ids that have no existing workspace, deduped", async () => { + mocks.workspaceRepo.create({ + taskId: "existing", + repositoryId: null, + mode: "cloud", + }); + const createCloudMany = vi.spyOn(mocks.workspaceRepo, "createCloudMany"); + + const result = await service.reconcileCloudWorkspaces([ + "existing", + "new-a", + "new-a", + "new-b", + ]); + + expect(result.created.sort()).toEqual(["new-a", "new-b"]); + expect(createCloudMany).toHaveBeenCalledWith(["new-a", "new-b"]); + }); + + it("returns empty and skips insert when nothing is new", async () => { + const createCloudMany = vi.spyOn(mocks.workspaceRepo, "createCloudMany"); + + const result = await service.reconcileCloudWorkspaces([]); + + expect(result.created).toEqual([]); + expect(createCloudMany).not.toHaveBeenCalled(); + }); + }); + + describe("linkBranch", () => { + it("persists the link, emits LinkedBranchChanged, and tracks analytics", () => { + const updateLinkedBranch = vi.spyOn( + mocks.workspaceRepo, + "updateLinkedBranch", + ); + const emitted = vi.fn(); + service.on(WorkspaceServiceEvent.LinkedBranchChanged, emitted); + + service.linkBranch("task-1", "feature/x", "user"); + + expect(updateLinkedBranch).toHaveBeenCalledWith("task-1", "feature/x"); + expect(emitted).toHaveBeenCalledWith({ + taskId: "task-1", + branchName: "feature/x", + }); + expect(mocks.analytics.track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.BRANCH_LINKED, + expect.objectContaining({ + task_id: "task-1", + branch_name: "feature/x", + source: "user", + }), + ); + }); + }); + + describe("unlinkBranch", () => { + it("clears the link, emits LinkedBranchChanged null, and tracks analytics", () => { + const updateLinkedBranch = vi.spyOn( + mocks.workspaceRepo, + "updateLinkedBranch", + ); + const emitted = vi.fn(); + service.on(WorkspaceServiceEvent.LinkedBranchChanged, emitted); + + service.unlinkBranch("task-1", "user"); + + expect(updateLinkedBranch).toHaveBeenCalledWith("task-1", null); + expect(emitted).toHaveBeenCalledWith({ + taskId: "task-1", + branchName: null, + }); + expect(mocks.analytics.track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.BRANCH_UNLINKED, + expect.objectContaining({ task_id: "task-1", source: "user" }), + ); + }); + }); + + describe("getWorkspace (cloud mode)", () => { + it("projects a cloud workspace without touching git or fs", async () => { + mocks.workspaceRepo.create({ + taskId: "cloud-task", + repositoryId: "remote-repo", + mode: "cloud", + }); + + const workspace = await service.getWorkspace("cloud-task"); + + expect(workspace).toMatchObject({ + taskId: "cloud-task", + folderId: "remote-repo", + mode: "cloud", + worktreePath: null, + worktreeName: null, + branchName: null, + }); + }); + + it("returns null when no workspace exists for the task", async () => { + expect(await service.getWorkspace("missing")).toBeNull(); + }); + }); + + describe("branch watcher wiring", () => { + it("subscribes to each upstream source exactly once", () => { + service.initBranchWatcher(); + service.initBranchWatcher(); + + expect(mocks.fileWatcher.onGitStateChanged).toHaveBeenCalledTimes(1); + expect(mocks.focus.onBranchRenamed).toHaveBeenCalledTimes(1); + expect(mocks.agent.onAgentFileActivity).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/code/src/main/services/workspace/service.ts b/packages/workspace-server/src/services/workspace/workspace.ts similarity index 74% rename from apps/code/src/main/services/workspace/service.ts rename to packages/workspace-server/src/services/workspace/workspace.ts index eada6cf4bf..56fbcdba8e 100644 --- a/apps/code/src/main/services/workspace/service.ts +++ b/packages/workspace-server/src/services/workspace/workspace.ts @@ -1,39 +1,60 @@ -import { execFile } from "node:child_process"; import * as fs from "node:fs"; -import * as fsPromises from "node:fs/promises"; import path from "node:path"; -import { promisify } from "node:util"; -import { trackAppEvent } from "@main/services/posthog-analytics"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; import { createGitClient } from "@posthog/git/client"; import { getCurrentBranch, getDefaultBranch, hasTrackedFiles, - listWorktrees, } from "@posthog/git/queries"; import { CreateOrSwitchBranchSaga } from "@posthog/git/sagas/branch"; import { DetachHeadSaga } from "@posthog/git/sagas/head"; import { WorktreeManager } from "@posthog/git/worktree"; -import { FileWatcherEventKind as FileWatcherEvent } from "@posthog/workspace-server/services/watcher/schemas"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { + ANALYTICS_SERVICE, + type IAnalytics, +} from "@posthog/platform/analytics"; +import { + type IWorkspaceSettings, + WORKSPACE_SETTINGS_SERVICE, +} from "@posthog/platform/workspace-settings"; +import { ANALYTICS_EVENTS, TypedEventEmitter } from "@posthog/shared"; import { inject, injectable } from "inversify"; -import type { RepositoryRepository } from "../../db/repositories/repository-repository"; -import type { WorkspaceRepository } from "../../db/repositories/workspace-repository"; -import type { WorktreeRepository } from "../../db/repositories/worktree-repository"; -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; -import { deriveWorktreePath } from "../../utils/worktree-helpers"; -import { AgentServiceEvent } from "../agent/schemas"; -import type { AgentService } from "../agent/service"; -import type { FileWatcherBridge } from "../file-watcher/bridge"; -import type { FocusService } from "../focus/service"; -import { FocusServiceEvent } from "../focus/service"; -import type { ProcessTrackingService } from "../process-tracking/service"; -import type { ProvisioningService } from "../provisioning/service"; -import { getWorktreeLocation } from "../settingsStore"; -import type { SuspensionService } from "../suspension/service.js"; +import { + REPOSITORY_REPOSITORY, + WORKSPACE_REPOSITORY, + WORKTREE_REPOSITORY, +} from "../../db/identifiers"; +import type { IRepositoryRepository } from "../../db/repositories/repository-repository"; +import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; +import type { IWorktreeRepository } from "../../db/repositories/worktree-repository"; +import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; +import type { ProcessTrackingService } from "../process-tracking/process-tracking"; +import { getBranchFromPath, hasAnyFiles } from "../repo-fs-query/repo-fs-query"; +import { SUSPENSION_SERVICE } from "../suspension/identifiers"; +import type { SuspensionService } from "../suspension/suspension"; +import { deriveWorktreePath as deriveWorktreePathFromBase } from "../worktree-path/worktree-path"; +import { + deleteWorktree as deleteGitWorktree, + listTwigWorktrees, + resolveLocalWorktreePath, +} from "../worktree-query/worktree-query"; +import { + WORKSPACE_AGENT, + WORKSPACE_FILE_WATCHER, + WORKSPACE_FOCUS, + WORKSPACE_PROVISIONING, +} from "./identifiers"; +import type { + WorkspaceAgent, + WorkspaceFileWatcher, + WorkspaceFocus, + WorkspaceProvisioning, +} from "./ports"; import type { BranchChangedPayload, CreateWorkspaceInput, @@ -47,8 +68,6 @@ import type { WorktreeInfo, } from "./schemas"; -const execFileAsync = promisify(execFile); - type TaskAssociation = | { taskId: string; folderId: string; mode: "local" } | { taskId: string; folderId: string | null; mode: "cloud" } @@ -60,68 +79,6 @@ type TaskAssociation = branchName: string | null; }; -/** - * True if a worktree exclude file (.worktreelink / .worktreeinclude) exists and has at least - * one non-empty, non-comment entry. - */ -async function hasExcludeFileEntries( - mainRepoPath: string, - fileName: string, -): Promise { - try { - const contents = await fsPromises.readFile( - path.join(mainRepoPath, fileName), - "utf8", - ); - return contents.split("\n").some((line) => { - const trimmed = line.trim(); - return trimmed.length > 0 && !trimmed.startsWith("#"); - }); - } catch { - return false; - } -} - -async function hasAnyFiles(repoPath: string): Promise { - try { - const entries = await fsPromises.readdir(repoPath); - return entries.some((entry) => entry !== ".git"); - } catch { - return false; - } -} - -/** - * Get the current branch name for a repo or worktree by reading its Git HEAD file. - * Returns null if in detached HEAD state or doesn't exist. - */ -async function getBranchFromPath(repoPath: string): Promise { - try { - const gitPath = path.join(repoPath, ".git"); - const stat = await fsPromises.stat(gitPath); - - let headPath: string; - if (stat.isDirectory()) { - // Regular repo - .git is a directory - headPath = path.join(gitPath, "HEAD"); - } else { - // Worktree - .git is a file pointing to gitdir - const gitContent = await fsPromises.readFile(gitPath, "utf-8"); - const gitdirMatch = gitContent.match(/gitdir:\s*(.+)/); - if (!gitdirMatch) return null; - headPath = path.join(path.resolve(gitdirMatch[1].trim()), "HEAD"); - } - - const headContent = await fsPromises.readFile(headPath, "utf-8"); - const branchMatch = headContent.match(/ref: refs\/heads\/(.+)/); - return branchMatch ? branchMatch[1].trim() : null; - } catch { - return null; - } -} - -const log = logger.scope("workspace"); - export const WorkspaceServiceEvent = { Error: "error", Warning: "warning", @@ -140,30 +97,49 @@ export interface WorkspaceServiceEvents { @injectable() export class WorkspaceService extends TypedEventEmitter { - @inject(MAIN_TOKENS.AgentService) - private agentService!: AgentService; - - @inject(MAIN_TOKENS.ProcessTrackingService) - private processTracking!: ProcessTrackingService; - - @inject(MAIN_TOKENS.RepositoryRepository) - private repositoryRepo!: RepositoryRepository; - - @inject(MAIN_TOKENS.WorkspaceRepository) - private workspaceRepo!: WorkspaceRepository; - - @inject(MAIN_TOKENS.WorktreeRepository) - private worktreeRepo!: WorktreeRepository; - - @inject(MAIN_TOKENS.SuspensionService) - private suspensionService!: SuspensionService; - - @inject(MAIN_TOKENS.ProvisioningService) - private provisioningService!: ProvisioningService; + private readonly log: ScopedLogger; + + constructor( + @inject(WORKSPACE_AGENT) + private readonly agent: WorkspaceAgent, + @inject(PROCESS_TRACKING_SERVICE) + private readonly processTracking: ProcessTrackingService, + @inject(REPOSITORY_REPOSITORY) + private readonly repositoryRepo: IRepositoryRepository, + @inject(WORKSPACE_REPOSITORY) + private readonly workspaceRepo: IWorkspaceRepository, + @inject(WORKTREE_REPOSITORY) + private readonly worktreeRepo: IWorktreeRepository, + @inject(SUSPENSION_SERVICE) + private readonly suspensionService: SuspensionService, + @inject(WORKSPACE_PROVISIONING) + private readonly provisioning: WorkspaceProvisioning, + @inject(WORKSPACE_FILE_WATCHER) + private readonly fileWatcher: WorkspaceFileWatcher, + @inject(WORKSPACE_FOCUS) + private readonly focus: WorkspaceFocus, + @inject(WORKSPACE_SETTINGS_SERVICE) + private readonly workspaceSettings: IWorkspaceSettings, + @inject(ANALYTICS_SERVICE) + private readonly analytics: IAnalytics, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + super(); + this.log = logger.scope("workspace"); + } private creatingWorkspaces = new Map>(); private branchWatcherInitialized = false; + private deriveWorktreePath(folderPath: string, worktreeName: string): string { + return deriveWorktreePathFromBase( + this.workspaceSettings.getWorktreeLocation(), + folderPath, + worktreeName, + ); + } + private findTaskAssociation(taskId: string): TaskAssociation | null { const workspace = this.workspaceRepo.findByTaskId(taskId); if (!workspace) return null; @@ -248,25 +224,11 @@ export class WorkspaceService extends TypedEventEmitter if (this.branchWatcherInitialized) return; this.branchWatcherInitialized = true; - const fileWatcher = container.get( - MAIN_TOKENS.FileWatcherService, - ); - const focusService = container.get(MAIN_TOKENS.FocusService); - - fileWatcher.on( - FileWatcherEvent.GitStateChanged, - this.handleGitStateChanged.bind(this), - ); + this.fileWatcher.onGitStateChanged(this.handleGitStateChanged.bind(this)); - focusService.on( - FocusServiceEvent.BranchRenamed, - this.handleFocusBranchRenamed.bind(this), - ); + this.focus.onBranchRenamed(this.handleFocusBranchRenamed.bind(this)); - this.agentService.on( - AgentServiceEvent.AgentFileActivity, - this.handleAgentFileActivity.bind(this), - ); + this.agent.onAgentFileActivity(this.handleAgentFileActivity.bind(this)); } private handleFocusBranchRenamed({ @@ -283,7 +245,7 @@ export class WorkspaceService extends TypedEventEmitter if (assoc.mode !== "worktree") continue; const folderPath = this.getFolderPath(assoc.folderId); if (!folderPath) continue; - const derivedPath = deriveWorktreePath(folderPath, assoc.worktree); + const derivedPath = this.deriveWorktreePath(folderPath, assoc.worktree); if (derivedPath === worktreePath && assoc.branchName !== newBranch) { this.updateAssociationBranchName(assoc.taskId, newBranch); this.emit(WorkspaceServiceEvent.BranchChanged, { @@ -308,7 +270,10 @@ export class WorkspaceService extends TypedEventEmitter if (!folderPath) continue; if (assoc.mode === "worktree") { - const worktreePath = deriveWorktreePath(folderPath, assoc.worktree); + const worktreePath = this.deriveWorktreePath( + folderPath, + assoc.worktree, + ); if (worktreePath !== repoPath) continue; const currentBranch = await getBranchFromPath(repoPath); @@ -359,15 +324,21 @@ export class WorkspaceService extends TypedEventEmitter const defaultBranch = await getDefaultBranch(folderPath); if (branchName === defaultBranch) return; } catch (error) { - log.warn("Failed to determine default branch, skipping branch link", { - taskId, - branchName, - error, - }); - trackAppEvent(ANALYTICS_EVENTS.BRANCH_LINK_DEFAULT_BRANCH_UNKNOWN, { - task_id: taskId, - branch_name: branchName, - }); + this.log.warn( + "Failed to determine default branch, skipping branch link", + { + taskId, + branchName, + error, + }, + ); + this.analytics.track( + ANALYTICS_EVENTS.BRANCH_LINK_DEFAULT_BRANCH_UNKNOWN, + { + task_id: taskId, + branch_name: branchName, + }, + ); return; } @@ -392,12 +363,12 @@ export class WorkspaceService extends TypedEventEmitter taskId, branchName, }); - trackAppEvent(ANALYTICS_EVENTS.BRANCH_LINKED, { + this.analytics.track(ANALYTICS_EVENTS.BRANCH_LINKED, { task_id: taskId, branch_name: branchName, source: source ?? "unknown", }); - log.info("Linked branch to task", { taskId, branchName, source }); + this.log.info("Linked branch to task", { taskId, branchName, source }); } public unlinkBranch(taskId: string, source?: "agent" | "user"): void { @@ -406,32 +377,20 @@ export class WorkspaceService extends TypedEventEmitter taskId, branchName: null, }); - trackAppEvent(ANALYTICS_EVENTS.BRANCH_UNLINKED, { + this.analytics.track(ANALYTICS_EVENTS.BRANCH_UNLINKED, { task_id: taskId, source: source ?? "unknown", }); - log.info("Unlinked branch from task", { taskId, source }); + this.log.info("Unlinked branch from task", { taskId, source }); } - private async getLocalWorktreePathIfExists( + private getLocalWorktreePathIfExists( mainRepoPath: string, ): Promise { - try { - const worktreeBasePath = getWorktreeLocation(); - const worktreeManager = new WorktreeManager({ - mainRepoPath, - worktreeBasePath, - }); - const localPath = worktreeManager.getLocalWorktreePath(); - const exists = await worktreeManager.localWorktreeExists(); - if (exists) { - return localPath; - } - return null; - } catch (error) { - log.warn(`Error checking local worktree for ${mainRepoPath}:`, error); - return null; - } + return resolveLocalWorktreePath( + mainRepoPath, + this.workspaceSettings.getWorktreeLocation(), + ); } // Batched cloud-workspace reconcile. The renderer calls this once on boot @@ -451,7 +410,7 @@ export class WorkspaceService extends TypedEventEmitter const toCreate = uniqueRequested.filter((id) => !existingTaskIds.has(id)); if (toCreate.length === 0) return { created: [] }; - log.info( + this.log.info( `Reconciling ${toCreate.length} cloud workspaces (requested ${taskIds.length})`, ); this.workspaceRepo.createCloudMany(toCreate); @@ -462,7 +421,7 @@ export class WorkspaceService extends TypedEventEmitter // Prevent concurrent workspace creation for the same task const existingPromise = this.creatingWorkspaces.get(options.taskId); if (existingPromise) { - log.warn( + this.log.warn( `Workspace creation already in progress for task ${options.taskId}, waiting for existing operation`, ); return existingPromise; @@ -492,13 +451,13 @@ export class WorkspaceService extends TypedEventEmitter const existingWorkspace = await this.getWorkspaceInfo(taskId); if (existingWorkspace) { - log.info( + this.log.info( `Workspace already exists for task ${taskId}, returning existing workspace`, ); return existingWorkspace; } - log.info( + this.log.info( `Creating workspace for task ${taskId} in ${mainRepoPath} (mode: ${mode}, useExistingBranch: ${useExistingBranch})`, ); @@ -525,9 +484,11 @@ export class WorkspaceService extends TypedEventEmitter if (branch) { const currentBranch = await getCurrentBranch(folderPath); if (currentBranch === branch) { - log.info(`Already on branch ${branch}, skipping checkout`); + this.log.info(`Already on branch ${branch}, skipping checkout`); } else { - log.info(`Creating/switching to branch ${branch} for task ${taskId}`); + this.log.info( + `Creating/switching to branch ${branch} for task ${taskId}`, + ); const saga = new CreateOrSwitchBranchSaga(); const result = await saga.run({ baseDir: folderPath, @@ -535,14 +496,14 @@ export class WorkspaceService extends TypedEventEmitter }); if (!result.success) { const message = `Could not switch to branch "${branch}". Please commit or stash your changes first.`; - log.error(message, result.error); + this.log.error(message, result.error); this.emitWorkspaceError(taskId, message); throw new Error(message); } if (result.data.created) { - log.info(`Created and switched to new branch ${branch}`); + this.log.info(`Created and switched to new branch ${branch}`); } else { - log.info(`Switched to existing branch ${branch}`); + this.log.info(`Switched to existing branch ${branch}`); } } } @@ -565,7 +526,7 @@ export class WorkspaceService extends TypedEventEmitter await this.suspensionService.suspendLeastRecentIfOverLimit(); - const worktreeBasePath = getWorktreeLocation(); + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); const worktreeManager = new WorktreeManager({ mainRepoPath, worktreeBasePath, @@ -580,11 +541,11 @@ export class WorkspaceService extends TypedEventEmitter const isTrunkSelected = selectedBranch === defaultBranch; const onOutput = (data: string) => { - this.provisioningService.emitOutput(taskId, data); + this.provisioning.emitOutput(taskId, data); }; if (isTrunkSelected) { - log.info( + this.log.info( `Trunk branch selected (${defaultBranch}), creating detached worktree`, ); worktree = await worktreeManager.createWorktree({ @@ -592,11 +553,11 @@ export class WorkspaceService extends TypedEventEmitter onOutput, fetchBeforeCreate: true, }); - log.info( + this.log.info( `Created detached worktree from trunk: ${worktree.worktreeName} at ${worktree.worktreePath}`, ); } else { - log.info( + this.log.info( `Non-trunk branch selected (${selectedBranch}), attempting checkout`, ); try { @@ -605,7 +566,7 @@ export class WorkspaceService extends TypedEventEmitter undefined, { onOutput }, ); - log.info( + this.log.info( `Created worktree with branch checkout: ${worktree.worktreeName} at ${worktree.worktreePath} (branch: ${selectedBranch})`, ); } catch (checkoutError) { @@ -614,14 +575,14 @@ export class WorkspaceService extends TypedEventEmitter ? checkoutError.message : String(checkoutError); if (errorMessage.includes("is already used by worktree")) { - log.info( + this.log.info( `Branch ${selectedBranch} is occupied, falling back to detached worktree`, ); worktree = await worktreeManager.createWorktree({ baseBranch: selectedBranch, onOutput, }); - log.info( + this.log.info( `Created detached worktree from occupied branch: ${worktree.worktreeName} at ${worktree.worktreePath}`, ); } else { @@ -635,7 +596,7 @@ export class WorkspaceService extends TypedEventEmitter if (!worktreeHasFiles) { const mainHasFiles = await hasAnyFiles(mainRepoPath); if (mainHasFiles) { - log.warn( + this.log.warn( `Worktree ${worktree.worktreeName} is empty but main repo has files`, ); this.emitWorkspaceWarning( @@ -646,7 +607,7 @@ export class WorkspaceService extends TypedEventEmitter } } } catch (error) { - log.error(`Failed to create worktree for task ${taskId}:`, error); + this.log.error(`Failed to create worktree for task ${taskId}:`, error); throw new Error(`Failed to create worktree: ${String(error)}`); } @@ -672,24 +633,26 @@ export class WorkspaceService extends TypedEventEmitter } async deleteWorkspace(taskId: string, mainRepoPath: string): Promise { - log.info(`Deleting workspace for task ${taskId}`); + this.log.info(`Deleting workspace for task ${taskId}`); const association = this.findTaskAssociation(taskId); if (!association) { - log.warn(`No workspace found for task ${taskId}`); + this.log.warn(`No workspace found for task ${taskId}`); return; } if (association.mode === "cloud") { this.removeTaskAssociation(taskId); - log.info(`Cloud workspace deleted for task ${taskId}`); + this.log.info(`Cloud workspace deleted for task ${taskId}`); return; } const folderId = association.folderId; const folderPath = this.getFolderPath(folderId); if (!folderPath) { - log.warn(`No folder found for task ${taskId}, removing association only`); + this.log.warn( + `No folder found for task ${taskId}, removing association only`, + ); this.removeTaskAssociation(taskId); return; } @@ -697,10 +660,10 @@ export class WorkspaceService extends TypedEventEmitter let worktreePath: string | null = null; if (association.mode === "worktree") { - worktreePath = deriveWorktreePath(folderPath, association.worktree); + worktreePath = this.deriveWorktreePath(folderPath, association.worktree); } - await this.agentService.cancelSessionsByTaskId(taskId); + await this.agent.cancelSessionsByTaskId(taskId); this.processTracking.killByTaskId(taskId); if (association.mode === "worktree" && worktreePath) { @@ -725,7 +688,7 @@ export class WorkspaceService extends TypedEventEmitter this.removeTaskAssociation(taskId); - log.info(`Workspace deleted for task ${taskId}`); + this.log.info(`Workspace deleted for task ${taskId}`); } private removeTaskAssociation(taskId: string): void { @@ -737,13 +700,13 @@ export class WorkspaceService extends TypedEventEmitter } private async cleanupRepoWorktreeFolder(folderPath: string): Promise { - const worktreeBasePath = getWorktreeLocation(); + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); const repoName = path.basename(folderPath); const repoWorktreeFolderPath = path.join(worktreeBasePath, repoName); // Safety check 1: Never delete the project folder itself if (path.resolve(repoWorktreeFolderPath) === path.resolve(folderPath)) { - log.warn( + this.log.warn( `Skipping cleanup of worktree folder: path matches project folder (${folderPath})`, ); return; @@ -759,7 +722,7 @@ export class WorkspaceService extends TypedEventEmitter ); if (otherFoldersWithSameName.length > 0) { - log.info( + this.log.info( `Skipping cleanup of worktree folder ${repoWorktreeFolderPath}: used by other folders: ${otherFoldersWithSameName.map((f) => f.path).join(", ")}`, ); return; @@ -771,16 +734,16 @@ export class WorkspaceService extends TypedEventEmitter const validFiles = files.filter((f) => f !== ".DS_Store"); if (validFiles.length > 0) { - log.info( + this.log.info( `Skipping cleanup of worktree folder ${repoWorktreeFolderPath}: folder not empty (contains: ${validFiles.slice(0, 3).join(", ")}${validFiles.length > 3 ? "..." : ""})`, ); return; } fs.rmSync(repoWorktreeFolderPath, { recursive: true, force: true }); - log.info(`Cleaned up worktree folder at ${repoWorktreeFolderPath}`); + this.log.info(`Cleaned up worktree folder at ${repoWorktreeFolderPath}`); } catch (error) { - log.warn( + this.log.warn( `Failed to cleanup worktree folder at ${repoWorktreeFolderPath}:`, error, ); @@ -808,7 +771,7 @@ export class WorkspaceService extends TypedEventEmitter if (association.mode === "local") { const exists = fs.existsSync(folderPath); if (!exists) { - log.info( + this.log.info( `Folder for task ${taskId} no longer exists, removing association`, ); this.removeTaskAssociation(taskId); @@ -818,10 +781,13 @@ export class WorkspaceService extends TypedEventEmitter } if (association.mode === "worktree") { - const worktreePath = deriveWorktreePath(folderPath, association.worktree); + const worktreePath = this.deriveWorktreePath( + folderPath, + association.worktree, + ); const exists = fs.existsSync(worktreePath); if (!exists) { - log.info( + this.log.info( `Worktree for task ${taskId} no longer exists, removing association`, ); this.removeTaskAssociation(taskId); @@ -864,7 +830,7 @@ export class WorkspaceService extends TypedEventEmitter if (assoc.mode === "worktree") { worktreeName = assoc.worktree; - worktreePath = deriveWorktreePath(folderPath, worktreeName); + worktreePath = this.deriveWorktreePath(folderPath, worktreeName); const gitBranch = await getBranchFromPath(worktreePath); branchName = gitBranch ?? assoc.branchName; } else if (assoc.mode === "local") { @@ -914,7 +880,7 @@ export class WorkspaceService extends TypedEventEmitter if (association.mode === "worktree") { if (folderPath) { - const worktreePath = deriveWorktreePath( + const worktreePath = this.deriveWorktreePath( folderPath, association.worktree, ); @@ -974,7 +940,7 @@ export class WorkspaceService extends TypedEventEmitter if (assoc.mode === "worktree") { worktreeName = assoc.worktree; - worktreePath = deriveWorktreePath(folderPath, worktreeName); + worktreePath = this.deriveWorktreePath(folderPath, worktreeName); } let branchName: string | null = null; @@ -1015,20 +981,22 @@ export class WorkspaceService extends TypedEventEmitter mainRepoPath: string, branch: string, ): Promise { - log.info(`Promoting task ${taskId} to worktree mode on branch ${branch}`); + this.log.info( + `Promoting task ${taskId} to worktree mode on branch ${branch}`, + ); const association = this.findTaskAssociation(taskId); if (!association) { - log.warn(`No association found for task ${taskId}`); + this.log.warn(`No association found for task ${taskId}`); return null; } if (association.mode !== "local") { - log.warn(`Task ${taskId} is not in local mode, cannot promote`); + this.log.warn(`Task ${taskId} is not in local mode, cannot promote`); return null; } - const worktreeBasePath = getWorktreeLocation(); + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); const worktreeManager = new WorktreeManager({ mainRepoPath, worktreeBasePath, @@ -1038,7 +1006,7 @@ export class WorkspaceService extends TypedEventEmitter try { const currentBranch = await getCurrentBranch(mainRepoPath); if (currentBranch === branch) { - log.info( + this.log.info( `Main repo is on target branch ${branch}, detaching before creating worktree`, ); const detachSaga = new DetachHeadSaga(); @@ -1049,11 +1017,11 @@ export class WorkspaceService extends TypedEventEmitter } worktree = await worktreeManager.createWorktreeForExistingBranch(branch); - log.info( + this.log.info( `Created worktree for promoted task: ${worktree.worktreeName} at ${worktree.worktreePath}`, ); } catch (error) { - log.error( + this.log.error( `Failed to create worktree for promoted task ${taskId}:`, error, ); @@ -1068,7 +1036,7 @@ export class WorkspaceService extends TypedEventEmitter name: worktree.worktreeName, path: worktree.worktreePath, }); - log.info(`Updated task ${taskId} association to worktree mode`); + this.log.info(`Updated task ${taskId} association to worktree mode`); } this.emit(WorkspaceServiceEvent.Promoted, { @@ -1098,7 +1066,7 @@ export class WorkspaceService extends TypedEventEmitter if (assoc.mode !== "worktree") continue; const folderPath = this.getFolderPath(assoc.folderId); if (!folderPath) continue; - const derivedPath = deriveWorktreePath(folderPath, assoc.worktree); + const derivedPath = this.deriveWorktreePath(folderPath, assoc.worktree); if (derivedPath === worktreePath) { result.push({ taskId: assoc.taskId }); } @@ -1115,48 +1083,18 @@ export class WorkspaceService extends TypedEventEmitter taskIds: string[]; }> > { - const worktreeBasePath = getWorktreeLocation(); - const rawWorktrees = await listWorktrees(mainRepoPath); - - const twigWorktrees = rawWorktrees.filter((wt) => { - const isMainRepo = path.resolve(wt.path) === path.resolve(mainRepoPath); - const isUnderTwig = path - .resolve(wt.path) - .startsWith(path.resolve(worktreeBasePath)); - return !isMainRepo && isUnderTwig; - }); - - return twigWorktrees.map((wt) => { - const taskIds = this.getWorktreeTasks(wt.path).map((t) => t.taskId); - return { - worktreePath: wt.path, - head: wt.head, - branch: wt.branch, - taskIds, - }; - }); - } - - async getWorktreeFileUsage( - mainRepoPath: string, - ): Promise<{ usesWorktreeLink: boolean; usesWorktreeInclude: boolean }> { - const [usesWorktreeLink, usesWorktreeInclude] = await Promise.all([ - hasExcludeFileEntries(mainRepoPath, ".worktreelink"), - hasExcludeFileEntries(mainRepoPath, ".worktreeinclude"), - ]); - return { usesWorktreeLink, usesWorktreeInclude }; - } + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); + const twigWorktrees = await listTwigWorktrees( + mainRepoPath, + worktreeBasePath, + ); - async getWorktreeSize(worktreePath: string): Promise<{ sizeBytes: number }> { - try { - const { stdout } = await execFileAsync("du", ["-s", worktreePath]); - const [sizeStr] = stdout.trim().split("\t"); - const sizeBytes = sizeStr ? parseInt(sizeStr, 10) * 512 : 0; - return { sizeBytes }; - } catch (error) { - log.warn(`Failed to get size for ${worktreePath}:`, error); - return { sizeBytes: 0 }; - } + return twigWorktrees.map((wt) => ({ + worktreePath: wt.worktreePath, + head: wt.head, + branch: wt.branch, + taskIds: this.getWorktreeTasks(wt.worktreePath).map((t) => t.taskId), + })); } async deleteWorktree( @@ -1172,9 +1110,8 @@ export class WorkspaceService extends TypedEventEmitter } } - const worktreeBasePath = getWorktreeLocation(); - const manager = new WorktreeManager({ mainRepoPath, worktreeBasePath }); - await manager.deleteWorktree(worktreePath); + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); + await deleteGitWorktree(mainRepoPath, worktreeBasePath, worktreePath); if (worktree) { this.worktreeRepo.deleteByWorkspaceId(worktree.workspaceId); @@ -1188,32 +1125,28 @@ export class WorkspaceService extends TypedEventEmitter branchName: string | null, ): Promise { try { - const fileWatcher = container.get( - MAIN_TOKENS.FileWatcherService, - ); - await fileWatcher.stopWatching(worktreePath); + await this.fileWatcher.stopWatching(worktreePath); } catch (error) { - log.warn( + this.log.warn( `Failed to stop file watcher for worktree ${worktreePath}:`, error, ); } try { - const worktreeBasePath = getWorktreeLocation(); - const manager = new WorktreeManager({ mainRepoPath, worktreeBasePath }); - await manager.deleteWorktree(worktreePath); + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); + await deleteGitWorktree(mainRepoPath, worktreeBasePath, worktreePath); } catch (error) { - log.error(`Failed to delete worktree for task ${taskId}:`, error); + this.log.error(`Failed to delete worktree for task ${taskId}:`, error); } if (branchName) { try { const git = createGitClient(mainRepoPath); await git.deleteLocalBranch(branchName, true); - log.info(`Deleted branch ${branchName} for task ${taskId}`); + this.log.info(`Deleted branch ${branchName} for task ${taskId}`); } catch (error) { - log.warn( + this.log.warn( `Failed to delete branch ${branchName} for task ${taskId}:`, error, ); diff --git a/packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.test.ts b/packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.test.ts new file mode 100644 index 0000000000..25a117290a --- /dev/null +++ b/packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockManager = vi.hoisted(() => ({ + createWorktreeForExistingBranch: vi.fn(), + createDetachedWorktreeAtCommit: vi.fn(), +})); +const mockRevertRun = vi.hoisted(() => vi.fn()); +const mockCaptureRun = vi.hoisted(() => vi.fn()); +const mockDeleteCheckpoint = vi.hoisted(() => vi.fn()); +const mockCheckoutLocalBranch = vi.hoisted(() => vi.fn()); + +vi.mock("@posthog/git/worktree", () => ({ + WorktreeManager: class { + createWorktreeForExistingBranch = + mockManager.createWorktreeForExistingBranch; + createDetachedWorktreeAtCommit = mockManager.createDetachedWorktreeAtCommit; + }, +})); + +vi.mock("@posthog/git/sagas/checkpoint", () => ({ + RevertCheckpointSaga: class { + run = mockRevertRun; + }, + CaptureCheckpointSaga: class { + run = mockCaptureRun; + }, + deleteCheckpoint: mockDeleteCheckpoint, +})); + +vi.mock("@posthog/git/client", () => ({ + createGitClient: vi.fn(() => ({ + checkoutLocalBranch: mockCheckoutLocalBranch, + })), +})); + +import { + captureWorktreeCheckpoint, + restoreWorktreeFromCheckpoint, +} from "./worktree-checkpoint"; + +const BRANCH_WT = { worktreePath: "/wt/branch" }; +const DETACHED_WT = { worktreePath: "/wt/detached" }; + +const baseParams = { + mainRepoPath: "/repo", + worktreeBasePath: "/repo/.worktrees", + preferredName: "feat", + branchName: "feat" as string | null, + checkpointId: "cp-1", +}; + +beforeEach(() => { + mockManager.createWorktreeForExistingBranch.mockResolvedValue(BRANCH_WT); + mockManager.createDetachedWorktreeAtCommit.mockResolvedValue(DETACHED_WT); + mockRevertRun.mockResolvedValue({ success: true }); + mockCaptureRun.mockResolvedValue({ success: true }); + mockDeleteCheckpoint.mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("restoreWorktreeFromCheckpoint", () => { + it("creates a worktree for the existing branch when not recreating it", async () => { + const result = await restoreWorktreeFromCheckpoint(baseParams); + + expect(mockManager.createWorktreeForExistingBranch).toHaveBeenCalledWith( + "feat", + "feat", + ); + expect(mockManager.createDetachedWorktreeAtCommit).not.toHaveBeenCalled(); + expect(result).toBe(BRANCH_WT); + }); + + it("creates a detached worktree at HEAD when there is no branch", async () => { + const result = await restoreWorktreeFromCheckpoint({ + ...baseParams, + branchName: null, + }); + + expect(mockManager.createDetachedWorktreeAtCommit).toHaveBeenCalledWith( + "HEAD", + "feat", + ); + expect(result).toBe(DETACHED_WT); + }); + + it("reverts the new worktree to the requested checkpoint", async () => { + await restoreWorktreeFromCheckpoint(baseParams); + + expect(mockRevertRun).toHaveBeenCalledWith({ + baseDir: "/wt/branch", + checkpointId: "cp-1", + }); + }); + + it("throws when the checkpoint revert fails", async () => { + mockRevertRun.mockResolvedValue({ success: false, error: "bad patch" }); + + await expect(restoreWorktreeFromCheckpoint(baseParams)).rejects.toThrow( + /failed to apply checkpoint: bad patch/, + ); + }); + + it("recreates the branch after revert when recreateBranch is set", async () => { + await restoreWorktreeFromCheckpoint({ + ...baseParams, + recreateBranch: true, + }); + + expect(mockManager.createDetachedWorktreeAtCommit).toHaveBeenCalled(); + expect(mockCheckoutLocalBranch).toHaveBeenCalledWith("feat"); + }); + + it("does not recreate the branch on the default path", async () => { + await restoreWorktreeFromCheckpoint(baseParams); + + expect(mockCheckoutLocalBranch).not.toHaveBeenCalled(); + }); +}); + +describe("captureWorktreeCheckpoint", () => { + it("clears any stale checkpoint before capturing", async () => { + await captureWorktreeCheckpoint("/repo", "/wt/branch", "cp-1"); + + expect(mockDeleteCheckpoint).toHaveBeenCalledWith( + expect.anything(), + "cp-1", + ); + expect(mockCaptureRun).toHaveBeenCalledWith({ + baseDir: "/wt/branch", + checkpointId: "cp-1", + }); + }); + + it("captures even when clearing the stale checkpoint throws", async () => { + mockDeleteCheckpoint.mockRejectedValue(new Error("no such checkpoint")); + + await captureWorktreeCheckpoint("/repo", "/wt/branch", "cp-1"); + + expect(mockCaptureRun).toHaveBeenCalledTimes(1); + }); + + it("throws when the capture saga fails", async () => { + mockCaptureRun.mockResolvedValue({ success: false, error: "dirty index" }); + + await expect( + captureWorktreeCheckpoint("/repo", "/wt/branch", "cp-1"), + ).rejects.toThrow(/Failed to capture checkpoint: dirty index/); + }); +}); diff --git a/packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.ts b/packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.ts new file mode 100644 index 0000000000..2e8034f5f6 --- /dev/null +++ b/packages/workspace-server/src/services/worktree-checkpoint/worktree-checkpoint.ts @@ -0,0 +1,84 @@ +import { createGitClient } from "@posthog/git/client"; +import { + CaptureCheckpointSaga, + deleteCheckpoint, + RevertCheckpointSaga, +} from "@posthog/git/sagas/checkpoint"; +import { type WorktreeInfo, WorktreeManager } from "@posthog/git/worktree"; + +export interface RestoreWorktreeFromCheckpointParams { + mainRepoPath: string; + worktreeBasePath: string; + /** Reuse this worktree name if provided. */ + preferredName: string | undefined; + branchName: string | null; + checkpointId: string; + recreateBranch?: boolean; +} + +/** + * Recreate a worktree (for an existing branch, or detached at HEAD) and revert + * it to a captured checkpoint, optionally recreating the branch. Shared by + * archive (unarchive) + suspension (restore); callers own their repo bookkeeping. + */ +export async function restoreWorktreeFromCheckpoint( + params: RestoreWorktreeFromCheckpointParams, +): Promise { + const manager = new WorktreeManager({ + mainRepoPath: params.mainRepoPath, + worktreeBasePath: params.worktreeBasePath, + }); + + let newWorktree: WorktreeInfo; + if (params.branchName && !params.recreateBranch) { + newWorktree = await manager.createWorktreeForExistingBranch( + params.branchName, + params.preferredName, + ); + } else { + newWorktree = await manager.createDetachedWorktreeAtCommit( + "HEAD", + params.preferredName, + ); + } + + const revertSaga = new RevertCheckpointSaga(); + const result = await revertSaga.run({ + baseDir: newWorktree.worktreePath, + checkpointId: params.checkpointId, + }); + if (!result.success) { + throw new Error( + `Worktree restored but failed to apply checkpoint: ${result.error}`, + ); + } + + if (params.recreateBranch && params.branchName) { + const git = createGitClient(newWorktree.worktreePath); + await git.checkoutLocalBranch(params.branchName); + } + + return newWorktree; +} + +/** + * Capture a checkpoint of a worktree's current state. Clears any stale + * checkpoint of the same id first, then runs CaptureCheckpointSaga. Shared by + * archive + suspension, which capture identically. + */ +export async function captureWorktreeCheckpoint( + folderPath: string, + worktreePath: string, + checkpointId: string, +): Promise { + const git = createGitClient(folderPath); + try { + await deleteCheckpoint(git, checkpointId); + } catch {} + + const saga = new CaptureCheckpointSaga(); + const result = await saga.run({ baseDir: worktreePath, checkpointId }); + if (!result.success) { + throw new Error(`Failed to capture checkpoint: ${result.error}`); + } +} diff --git a/packages/workspace-server/src/services/worktree-path/worktree-path.test.ts b/packages/workspace-server/src/services/worktree-path/worktree-path.test.ts new file mode 100644 index 0000000000..e1a7f910fa --- /dev/null +++ b/packages/workspace-server/src/services/worktree-path/worktree-path.test.ts @@ -0,0 +1,81 @@ +import { vol } from "memfs"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs/promises", async () => { + const { fs } = await import("memfs"); + return { ...fs.promises, default: fs.promises }; +}); + +import { + deriveWorktreePath, + resolveWorktreePathByProbe, +} from "./worktree-path"; + +const BASE = "/worktrees"; +const FOLDER = "/repos/my-repo"; + +afterEach(() => { + vol.reset(); +}); + +describe("deriveWorktreePath", () => { + it("uses the new // layout for numeric names", () => { + expect(deriveWorktreePath(BASE, FOLDER, "123")).toBe( + "/worktrees/123/my-repo", + ); + }); + + it("uses the legacy // layout for non-numeric names", () => { + expect(deriveWorktreePath(BASE, FOLDER, "feature-x")).toBe( + "/worktrees/my-repo/feature-x", + ); + }); + + it("derives the repo name from the folder path basename", () => { + expect(deriveWorktreePath(BASE, "/a/b/other-repo", "feat")).toBe( + "/worktrees/other-repo/feat", + ); + }); + + it("treats a name with non-digit characters as legacy", () => { + expect(deriveWorktreePath(BASE, FOLDER, "12a")).toBe( + "/worktrees/my-repo/12a", + ); + }); +}); + +describe("resolveWorktreePathByProbe", () => { + const NEW_PATH = "/worktrees/feat/my-repo"; + const LEGACY_PATH = "/worktrees/my-repo/feat"; + + it("prefers the new-format path when it exists on disk", async () => { + vol.mkdirSync(NEW_PATH, { recursive: true }); + + expect(await resolveWorktreePathByProbe(BASE, FOLDER, "feat")).toBe( + NEW_PATH, + ); + }); + + it("falls back to the legacy path when only it exists", async () => { + vol.mkdirSync(LEGACY_PATH, { recursive: true }); + + expect(await resolveWorktreePathByProbe(BASE, FOLDER, "feat")).toBe( + LEGACY_PATH, + ); + }); + + it("prefers the new path when both layouts exist", async () => { + vol.mkdirSync(NEW_PATH, { recursive: true }); + vol.mkdirSync(LEGACY_PATH, { recursive: true }); + + expect(await resolveWorktreePathByProbe(BASE, FOLDER, "feat")).toBe( + NEW_PATH, + ); + }); + + it("defaults to the new-format path when neither layout exists", async () => { + expect(await resolveWorktreePathByProbe(BASE, FOLDER, "feat")).toBe( + NEW_PATH, + ); + }); +}); diff --git a/packages/workspace-server/src/services/worktree-path/worktree-path.ts b/packages/workspace-server/src/services/worktree-path/worktree-path.ts new file mode 100644 index 0000000000..2930f3c7bd --- /dev/null +++ b/packages/workspace-server/src/services/worktree-path/worktree-path.ts @@ -0,0 +1,50 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; + +function newFormat(base: string, repoName: string, worktreeName: string) { + return path.join(base, worktreeName, repoName); +} +function legacyFormat(base: string, repoName: string, worktreeName: string) { + return path.join(base, repoName, worktreeName); +} + +/** + * Worktree path by name heuristic: numeric names use the new + * `//` layout, everything else the legacy `//`. + */ +export function deriveWorktreePath( + worktreeBasePath: string, + folderPath: string, + worktreeName: string, +): string { + const repoName = path.basename(folderPath); + const isLegacy = !/^\d+$/.test(worktreeName); + return isLegacy + ? legacyFormat(worktreeBasePath, repoName, worktreeName) + : newFormat(worktreeBasePath, repoName, worktreeName); +} + +/** + * Worktree path by probing disk: prefer the new-format path if it exists, else + * the legacy path if it exists, else fall back to new-format. Used when + * resolving an already-created worktree whose layout is unknown. + */ +export async function resolveWorktreePathByProbe( + worktreeBasePath: string, + folderPath: string, + worktreeName: string, +): Promise { + const repoName = path.basename(folderPath); + const newPath = newFormat(worktreeBasePath, repoName, worktreeName); + const legacyPath = legacyFormat(worktreeBasePath, repoName, worktreeName); + + try { + await access(newPath); + return newPath; + } catch {} + try { + await access(legacyPath); + return legacyPath; + } catch {} + return newPath; +} diff --git a/packages/workspace-server/src/services/worktree-query/worktree-query.test.ts b/packages/workspace-server/src/services/worktree-query/worktree-query.test.ts new file mode 100644 index 0000000000..c10371ac10 --- /dev/null +++ b/packages/workspace-server/src/services/worktree-query/worktree-query.test.ts @@ -0,0 +1,109 @@ +import { vol } from "memfs"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs/promises", async () => { + const { fs } = await import("memfs"); + return { ...fs.promises, default: fs.promises }; +}); + +const listWorktrees = vi.fn(); +vi.mock("@posthog/git/queries", () => ({ + listWorktrees: (...args: unknown[]) => listWorktrees(...args), +})); + +import { getWorktreeFileUsage, listTwigWorktrees } from "./worktree-query"; + +afterEach(() => { + vol.reset(); + listWorktrees.mockReset(); +}); + +const MAIN = "/repos/app"; +const BASE = "/repos/app/.worktrees"; + +describe("listTwigWorktrees", () => { + it("excludes the main repo from the results", async () => { + listWorktrees.mockResolvedValue([ + { path: MAIN, head: "h0", branch: "main" }, + { path: `${BASE}/feat`, head: "h1", branch: "feat" }, + ]); + + const result = await listTwigWorktrees(MAIN, BASE); + + expect(result).toEqual([ + { worktreePath: `${BASE}/feat`, head: "h1", branch: "feat" }, + ]); + }); + + it("excludes worktrees that live outside the twig base path", async () => { + listWorktrees.mockResolvedValue([ + { path: `${BASE}/feat`, head: "h1", branch: "feat" }, + { path: "/elsewhere/rogue", head: "h2", branch: "rogue" }, + ]); + + const result = await listTwigWorktrees(MAIN, BASE); + + expect(result.map((w) => w.worktreePath)).toEqual([`${BASE}/feat`]); + }); + + it("preserves a detached worktree's null branch", async () => { + listWorktrees.mockResolvedValue([ + { path: `${BASE}/detached`, head: "h3", branch: null }, + ]); + + const [worktree] = await listTwigWorktrees(MAIN, BASE); + + expect(worktree.branch).toBeNull(); + }); + + it("returns an empty list when only the main repo exists", async () => { + listWorktrees.mockResolvedValue([ + { path: MAIN, head: "h0", branch: "main" }, + ]); + + expect(await listTwigWorktrees(MAIN, BASE)).toEqual([]); + }); +}); + +describe("getWorktreeFileUsage", () => { + it("reports usage when an exclude file has a real entry", async () => { + vol.fromJSON({ [`${MAIN}/.worktreelink`]: "node_modules\n" }, "/"); + + const result = await getWorktreeFileUsage(MAIN); + + expect(result).toEqual({ + usesWorktreeLink: true, + usesWorktreeInclude: false, + }); + }); + + it("ignores blank lines and comments when detecting entries", async () => { + vol.fromJSON( + { [`${MAIN}/.worktreeinclude`]: "# just a comment\n\n \n" }, + "/", + ); + + const result = await getWorktreeFileUsage(MAIN); + + expect(result.usesWorktreeInclude).toBe(false); + }); + + it("counts a commented file with one live entry as used", async () => { + vol.fromJSON({ [`${MAIN}/.worktreeinclude`]: "# header\ndist\n" }, "/"); + + const result = await getWorktreeFileUsage(MAIN); + + expect(result.usesWorktreeInclude).toBe(true); + }); + + it("reports no usage when neither exclude file exists", async () => { + vol.fromJSON({ [`${MAIN}/README.md`]: "hi" }, "/"); + + const result = await getWorktreeFileUsage(MAIN); + + expect(result).toEqual({ + usesWorktreeLink: false, + usesWorktreeInclude: false, + }); + }); +}); diff --git a/packages/workspace-server/src/services/worktree-query/worktree-query.ts b/packages/workspace-server/src/services/worktree-query/worktree-query.ts new file mode 100644 index 0000000000..557dafd8d6 --- /dev/null +++ b/packages/workspace-server/src/services/worktree-query/worktree-query.ts @@ -0,0 +1,115 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { createGitClient } from "@posthog/git/client"; +import { listWorktrees } from "@posthog/git/queries"; +import { WorktreeManager } from "@posthog/git/worktree"; + +const execFileAsync = promisify(execFile); + +/** Current branch via `git rev-parse --abbrev-ref HEAD`; "" on error/detached. */ +export async function getCurrentBranchName( + worktreePath: string, +): Promise { + try { + const git = createGitClient(worktreePath); + return (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); + } catch { + return ""; + } +} + +/** The local worktree path for a repo, if one currently exists on disk. */ +export async function resolveLocalWorktreePath( + mainRepoPath: string, + worktreeBasePath: string, +): Promise { + try { + const manager = new WorktreeManager({ mainRepoPath, worktreeBasePath }); + const localPath = manager.getLocalWorktreePath(); + return (await manager.localWorktreeExists()) ? localPath : null; + } catch { + return null; + } +} + +/** Delete a git worktree at the given path (host op via WorktreeManager). */ +export async function deleteWorktree( + mainRepoPath: string, + worktreeBasePath: string, + worktreePath: string, +): Promise { + const manager = new WorktreeManager({ mainRepoPath, worktreeBasePath }); + await manager.deleteWorktree(worktreePath); +} + +export interface RawTwigWorktree { + worktreePath: string; + head: string; + branch: string | null; +} + +/** + * Git worktrees that live under the twig worktree base path (excludes the main + * repo). Pure git query; taskId enrichment is the caller's concern. + */ +export async function listTwigWorktrees( + mainRepoPath: string, + worktreeBasePath: string, +): Promise { + const rawWorktrees = await listWorktrees(mainRepoPath); + return rawWorktrees + .filter((wt) => { + const isMainRepo = path.resolve(wt.path) === path.resolve(mainRepoPath); + const isUnderTwig = path + .resolve(wt.path) + .startsWith(path.resolve(worktreeBasePath)); + return !isMainRepo && isUnderTwig; + }) + .map((wt) => ({ + worktreePath: wt.path, + head: wt.head, + branch: wt.branch, + })); +} + +async function hasExcludeFileEntries( + mainRepoPath: string, + fileName: string, +): Promise { + try { + const contents = await readFile(path.join(mainRepoPath, fileName), "utf8"); + return contents.split("\n").some((line) => { + const trimmed = line.trim(); + return trimmed.length > 0 && !trimmed.startsWith("#"); + }); + } catch { + return false; + } +} + +/** Disk size of a worktree via `du -s` (blocks * 512). Returns 0 on failure. */ +export async function getWorktreeSize( + worktreePath: string, +): Promise<{ sizeBytes: number }> { + try { + const { stdout } = await execFileAsync("du", ["-s", worktreePath]); + const [sizeStr] = stdout.trim().split("\t"); + const sizeBytes = sizeStr ? Number.parseInt(sizeStr, 10) * 512 : 0; + return { sizeBytes }; + } catch { + return { sizeBytes: 0 }; + } +} + +/** Whether the repo declares .worktreelink / .worktreeinclude exclude entries. */ +export async function getWorktreeFileUsage( + mainRepoPath: string, +): Promise<{ usesWorktreeLink: boolean; usesWorktreeInclude: boolean }> { + const [usesWorktreeLink, usesWorktreeInclude] = await Promise.all([ + hasExcludeFileEntries(mainRepoPath, ".worktreelink"), + hasExcludeFileEntries(mainRepoPath, ".worktreeinclude"), + ]); + return { usesWorktreeLink, usesWorktreeInclude }; +} diff --git a/packages/workspace-server/src/trpc.ts b/packages/workspace-server/src/trpc.ts index fd269cfb5d..6b59b35630 100644 --- a/packages/workspace-server/src/trpc.ts +++ b/packages/workspace-server/src/trpc.ts @@ -3,6 +3,17 @@ import superjson from "superjson"; import { z } from "zod"; import { container } from "./di/container"; import { TOKENS } from "./di/tokens"; +import { connectivityStatusOutput } from "./services/connectivity/schemas"; +import type { ConnectivityService } from "./services/connectivity/service"; +import { + createEnvironmentInput, + deleteEnvironmentInput, + environmentSchema, + getEnvironmentInput, + listEnvironmentsInput, + updateEnvironmentInput, +} from "./services/environment/schemas"; +import type { EnvironmentService } from "./services/environment/service"; import { checkoutInput, findWorktreeInput, @@ -18,10 +29,113 @@ import { } from "./services/focus/schemas"; import type { FocusService } from "./services/focus/service"; import type { FocusSyncService } from "./services/focus/sync-service"; -import { listDirectoryInput, listDirectoryOutput } from "./services/fs/schemas"; +import { + boundedReadResult, + listDirectoryInput, + listDirectoryOutput, + listRepoFilesInput, + listRepoFilesOutput, + readAbsoluteFileInput, + readRepoFileBoundedInput, + readRepoFileInput, + readRepoFileOutput, + readRepoFilesBoundedInput, + readRepoFilesBoundedOutput, + readRepoFilesInput, + readRepoFilesOutput, + writeRepoFileInput, +} from "./services/fs/schemas"; import type { FsService } from "./services/fs/service"; -import { diffStatsInput, diffStatsSchema } from "./services/git/schemas"; +import { + changedFilesOutput, + checkoutBranchInput, + checkoutBranchOutput, + cleanupAfterCloudHandoffInput, + cleanupAfterCloudHandoffOutput, + cloneRepositoryInput, + cloneRepositoryOutput, + commitInput, + commitOutput, + createBranchInput, + createPrViaGhInput, + createPrViaGhOutput, + detectRepoResultSchema, + diffInput, + diffStatsInput, + diffStatsSchema, + directoryPathInput, + discardFileChangesInput, + discardFileChangesOutput, + filePathInput, + getBranchChangedFilesInput, + getCommitConventionsInput, + getCommitConventionsOutput, + getCommitsBetweenBranchesInput, + getCommitsBetweenBranchesOutput, + getDiffAgainstRemoteInput, + getGithubIssueInput, + getGithubIssueOutput, + getGithubPullRequestInput, + getGithubPullRequestOutput, + getGitSyncStatusInput, + getHeadShaOutput, + getLocalBranchChangedFilesInput, + getPrChangedFilesInput, + getPrDetailsByUrlInput, + getPrDetailsByUrlOutput, + getPrReviewCommentsInput, + getPrReviewCommentsOutput, + getPrTemplateInput, + getPrTemplateOutput, + getPrUrlForBranchInput, + getPrUrlForBranchOutput, + ghAuthTokenOutput, + ghStatusOutput, + gitBusyStateInput, + gitBusyStateSchema, + gitCommitInfoNullableOutput, + gitRepoInfoNullableOutput, + gitStateSnapshotSchema, + gitStatusOutput, + syncInput as gitSyncInput, + syncOutput as gitSyncOutput, + gitSyncStatusSchema, + openPrInput, + openPrOutput, + prStatusOutput, + publishInput, + publishOutput, + pullInput, + pullOutput, + pushInput, + pushOutput, + readHandoffLocalGitStateInput, + readHandoffLocalGitStateOutput, + replyToPrCommentInput, + replyToPrCommentOutput, + resetSoftInput, + resolveReviewThreadInput, + resolveReviewThreadOutput, + searchGithubRefsInput, + searchGithubRefsOutput, + stageFilesInput, + stringArrayOutput, + stringNullableOutput, + stringOutput, + updatePrByUrlInput, + updatePrByUrlOutput, +} from "./services/git/schemas"; import type { GitService } from "./services/git/service"; +import { + countLocalLogEntriesInput, + countLocalLogEntriesOutput, + deleteLocalLogCacheInput, + readLocalLogsInput, + readLocalLogsOutput, + seedLocalLogsInput, + writeLocalLogsInput, +} from "./services/local-logs/schemas"; +import type { LocalLogsService } from "./services/local-logs/service"; import { resolveGitDirsInput, resolveGitDirsOutput, @@ -39,6 +153,12 @@ const gitService = () => container.get(TOKENS.GitService); const fsService = () => container.get(TOKENS.FsService); const watcherService = () => container.get(TOKENS.WatcherService); +const localLogsService = () => + container.get(TOKENS.LocalLogsService); +const connectivityService = () => + container.get(TOKENS.ConnectivityService); +const environmentService = () => + container.get(TOKENS.EnvironmentService); export { type FocusBranchRenamedEvent, @@ -175,6 +295,423 @@ export const appRouter = t.router({ } }), }), + git: t.router({ + detectRepo: t.procedure + .input(directoryPathInput) + .output(detectRepoResultSchema) + .query(({ input }) => gitService().detectRepo(input.directoryPath)), + + validateRepo: t.procedure + .input(directoryPathInput) + .output(z.boolean()) + .query(({ input }) => gitService().validateRepo(input.directoryPath)), + + getRemoteUrl: t.procedure + .input(directoryPathInput) + .output(stringNullableOutput) + .query(({ input }) => gitService().getRemoteUrl(input.directoryPath)), + + getCurrentBranch: t.procedure + .input(directoryPathInput) + .output(stringNullableOutput) + .query(({ input, signal }) => + gitService().getCurrentBranch(input.directoryPath, signal), + ), + + getDefaultBranch: t.procedure + .input(directoryPathInput) + .output(stringOutput) + .query(({ input }) => gitService().getDefaultBranch(input.directoryPath)), + + getAllBranches: t.procedure + .input(directoryPathInput) + .output(stringArrayOutput) + .query(({ input, signal }) => + gitService().getAllBranches(input.directoryPath, signal), + ), + + getChangedFilesHead: t.procedure + .input(directoryPathInput) + .output(changedFilesOutput) + .query(({ input, signal }) => + gitService().getChangedFilesHead(input.directoryPath, signal), + ), + + getFileAtHead: t.procedure + .input(filePathInput) + .output(stringNullableOutput) + .query(({ input, signal }) => + gitService().getFileAtHead(input.directoryPath, input.filePath, signal), + ), + + getDiffHead: t.procedure + .input(diffInput) + .output(stringOutput) + .query(({ input, signal }) => + gitService().getDiffHead( + input.directoryPath, + input.ignoreWhitespace, + signal, + ), + ), + + getDiffCached: t.procedure + .input(diffInput) + .output(stringOutput) + .query(({ input, signal }) => + gitService().getDiffCached( + input.directoryPath, + input.ignoreWhitespace, + signal, + ), + ), + + getDiffUnstaged: t.procedure + .input(diffInput) + .output(stringOutput) + .query(({ input, signal }) => + gitService().getDiffUnstaged( + input.directoryPath, + input.ignoreWhitespace, + signal, + ), + ), + + getLatestCommit: t.procedure + .input(directoryPathInput) + .output(gitCommitInfoNullableOutput) + .query(({ input, signal }) => + gitService().getLatestCommit(input.directoryPath, signal), + ), + + getGitRepoInfo: t.procedure + .input(directoryPathInput) + .output(gitRepoInfoNullableOutput) + .query(({ input }) => gitService().getGitRepoInfo(input.directoryPath)), + + getGitBusyState: t.procedure + .input(gitBusyStateInput) + .output(gitBusyStateSchema) + .query(({ input, signal }) => + gitService().getGitBusyState(input.directoryPath, signal), + ), + + getGitSyncStatus: t.procedure + .input(getGitSyncStatusInput) + .output(gitSyncStatusSchema) + .query(({ input }) => + gitService().getGitSyncStatus(input.directoryPath, input.forceRefresh), + ), + + createBranch: t.procedure + .input(createBranchInput) + .mutation(({ input }) => + gitService().createBranch(input.directoryPath, input.branchName), + ), + + checkoutBranch: t.procedure + .input(checkoutBranchInput) + .output(checkoutBranchOutput) + .mutation(({ input }) => + gitService().checkoutBranch(input.directoryPath, input.branchName), + ), + + stageFiles: t.procedure + .input(stageFilesInput) + .output(gitStateSnapshotSchema) + .mutation(({ input }) => + gitService().stageFiles(input.directoryPath, input.paths), + ), + + unstageFiles: t.procedure + .input(stageFilesInput) + .output(gitStateSnapshotSchema) + .mutation(({ input }) => + gitService().unstageFiles(input.directoryPath, input.paths), + ), + + discardFileChanges: t.procedure + .input(discardFileChangesInput) + .output(discardFileChangesOutput) + .mutation(({ input }) => + gitService().discardFileChanges( + input.directoryPath, + input.filePath, + input.fileStatus, + ), + ), + + push: t.procedure + .input(pushInput) + .output(pushOutput) + .mutation(({ input, signal }) => + gitService().push( + input.directoryPath, + input.remote, + input.branch, + input.setUpstream, + signal, + input.env, + ), + ), + + commit: t.procedure + .input(commitInput) + .output(commitOutput) + .mutation(({ input }) => + gitService().commit(input.directoryPath, input.message, { + paths: input.paths, + allowEmpty: input.allowEmpty, + stagedOnly: input.stagedOnly, + env: input.env, + }), + ), + + pull: t.procedure + .input(pullInput) + .output(pullOutput) + .mutation(({ input, signal }) => + gitService().pull( + input.directoryPath, + input.remote, + input.branch, + signal, + ), + ), + + publish: t.procedure + .input(publishInput) + .output(publishOutput) + .mutation(({ input, signal }) => + gitService().publish( + input.directoryPath, + input.remote, + signal, + input.env, + ), + ), + + sync: t.procedure + .input(gitSyncInput) + .output(gitSyncOutput) + .mutation(({ input, signal }) => + gitService().sync(input.directoryPath, input.remote, signal), + ), + + getGhStatus: t.procedure + .output(ghStatusOutput) + .query(() => gitService().getGhStatus()), + + getGhAuthToken: t.procedure + .output(ghAuthTokenOutput) + .query(() => gitService().getGhAuthToken()), + + getPrStatus: t.procedure + .input(directoryPathInput) + .output(prStatusOutput) + .query(({ input }) => gitService().getPrStatus(input.directoryPath)), + + getPrUrlForBranch: t.procedure + .input(getPrUrlForBranchInput) + .output(getPrUrlForBranchOutput) + .query(({ input }) => + gitService().getPrUrlForBranch(input.directoryPath, input.branchName), + ), + + openPr: t.procedure + .input(openPrInput) + .output(openPrOutput) + .mutation(({ input }) => gitService().openPr(input.directoryPath)), + + getPrDetailsByUrl: t.procedure + .input(getPrDetailsByUrlInput) + .output(getPrDetailsByUrlOutput.nullable()) + .query(({ input }) => gitService().getPrDetailsByUrl(input.prUrl)), + + getPrChangedFiles: t.procedure + .input(getPrChangedFilesInput) + .output(changedFilesOutput) + .query(({ input }) => gitService().getPrChangedFiles(input.prUrl)), + + getBranchChangedFiles: t.procedure + .input(getBranchChangedFilesInput) + .output(changedFilesOutput) + .query(({ input }) => + gitService().getBranchChangedFiles(input.repo, input.branch), + ), + + getLocalBranchChangedFiles: t.procedure + .input(getLocalBranchChangedFilesInput) + .output(changedFilesOutput) + .query(({ input }) => + gitService().getLocalBranchChangedFiles( + input.directoryPath, + input.branch, + ), + ), + + updatePrByUrl: t.procedure + .input(updatePrByUrlInput) + .output(updatePrByUrlOutput) + .mutation(({ input }) => + gitService().updatePrByUrl(input.prUrl, input.action), + ), + + getPrReviewComments: t.procedure + .input(getPrReviewCommentsInput) + .output(getPrReviewCommentsOutput) + .query(({ input }) => gitService().getPrReviewComments(input.prUrl)), + + resolveReviewThread: t.procedure + .input(resolveReviewThreadInput) + .output(resolveReviewThreadOutput) + .mutation(({ input }) => + gitService().resolveReviewThread(input.threadNodeId, input.resolved), + ), + + replyToPrComment: t.procedure + .input(replyToPrCommentInput) + .output(replyToPrCommentOutput) + .mutation(({ input }) => + gitService().replyToPrComment(input.prUrl, input.commentId, input.body), + ), + + getPrTemplate: t.procedure + .input(getPrTemplateInput) + .output(getPrTemplateOutput) + .query(({ input }) => gitService().getPrTemplate(input.directoryPath)), + + getCommitConventions: t.procedure + .input(getCommitConventionsInput) + .output(getCommitConventionsOutput) + .query(({ input }) => + gitService().getCommitConventions( + input.directoryPath, + input.sampleSize, + ), + ), + + searchGithubRefs: t.procedure + .input(searchGithubRefsInput) + .output(searchGithubRefsOutput) + .query(({ input }) => + gitService().searchGithubRefs( + input.directoryPath, + input.query, + input.limit, + input.kinds, + ), + ), + + getGithubIssue: t.procedure + .input(getGithubIssueInput) + .output(getGithubIssueOutput) + .query(({ input }) => + gitService().getGithubIssue(input.owner, input.repo, input.number), + ), + + getGithubPullRequest: t.procedure + .input(getGithubPullRequestInput) + .output(getGithubPullRequestOutput) + .query(({ input }) => + gitService().getGithubPullRequest( + input.owner, + input.repo, + input.number, + ), + ), + + readHandoffLocalGitState: t.procedure + .input(readHandoffLocalGitStateInput) + .output(readHandoffLocalGitStateOutput) + .query(({ input }) => + gitService().readHandoffLocalGitState(input.directoryPath), + ), + + cleanupAfterCloudHandoff: t.procedure + .input(cleanupAfterCloudHandoffInput) + .output(cleanupAfterCloudHandoffOutput) + .mutation(({ input }) => + gitService().cleanupAfterCloudHandoff( + input.directoryPath, + input.branchName, + ), + ), + + getDiffStats: t.procedure + .input(diffStatsInput) + .output(diffStatsSchema) + .query(({ input }) => gitService().getDiffStats(input.directoryPath)), + + getGitStatus: t.procedure + .output(gitStatusOutput) + .query(() => gitService().getGitStatus()), + + getHeadSha: t.procedure + .input(directoryPathInput) + .output(getHeadShaOutput) + .query(({ input }) => gitService().getHeadSha(input.directoryPath)), + + getDiffAgainstRemote: t.procedure + .input(getDiffAgainstRemoteInput) + .output(stringOutput) + .query(({ input }) => + gitService().getDiffAgainstRemote( + input.directoryPath, + input.baseBranch, + ), + ), + + getCommitsBetweenBranches: t.procedure + .input(getCommitsBetweenBranchesInput) + .output(getCommitsBetweenBranchesOutput) + .query(({ input }) => + gitService().getCommitsBetweenBranches( + input.directoryPath, + input.baseBranch, + input.head, + input.limit, + ), + ), + + resetSoft: t.procedure + .input(resetSoftInput) + .mutation(({ input }) => + gitService().resetSoft(input.directoryPath, input.sha), + ), + + createPrViaGh: t.procedure + .input(createPrViaGhInput) + .output(createPrViaGhOutput) + .mutation(({ input }) => + gitService().createPrViaGh( + input.directoryPath, + input.title, + input.body, + input.draft, + input.env, + ), + ), + + cloneRepository: t.procedure + .input(cloneRepositoryInput) + .output(cloneRepositoryOutput) + .mutation(({ input }) => + gitService().cloneRepository( + input.repoUrl, + input.targetPath, + input.cloneId, + ), + ), + + onCloneProgress: t.procedure.subscription(async function* (opts) { + for await (const data of gitService().toIterable("cloneProgress", { + signal: opts.signal, + })) { + yield data; + } + }), + }), diffStats: t.router({ getDiffStats: t.procedure .input(diffStatsInput) @@ -186,6 +723,69 @@ export const appRouter = t.router({ .input(listDirectoryInput) .output(listDirectoryOutput) .query(({ input }) => fsService().listDirectory(input.dirPath)), + + listRepoFiles: t.procedure + .input(listRepoFilesInput) + .output(listRepoFilesOutput) + .query(({ input }) => + fsService().listRepoFiles(input.repoPath, input.query, input.limit), + ), + + readRepoFile: t.procedure + .input(readRepoFileInput) + .output(readRepoFileOutput) + .query(({ input }) => + fsService().readRepoFile(input.repoPath, input.filePath), + ), + + readRepoFiles: t.procedure + .input(readRepoFilesInput) + .output(readRepoFilesOutput) + .query(({ input }) => + fsService().readRepoFiles(input.repoPath, input.filePaths), + ), + + readRepoFileBounded: t.procedure + .input(readRepoFileBoundedInput) + .output(boundedReadResult) + .query(({ input }) => + fsService().readRepoFileBounded( + input.repoPath, + input.filePath, + input.maxLines, + ), + ), + + readRepoFilesBounded: t.procedure + .input(readRepoFilesBoundedInput) + .output(readRepoFilesBoundedOutput) + .query(({ input }) => + fsService().readRepoFilesBounded( + input.repoPath, + input.filePaths, + input.maxLines, + ), + ), + + readAbsoluteFile: t.procedure + .input(readAbsoluteFileInput) + .output(readRepoFileOutput) + .query(({ input }) => fsService().readAbsoluteFile(input.filePath)), + + readFileAsBase64: t.procedure + .input(readAbsoluteFileInput) + .output(readRepoFileOutput) + .query(({ input }) => fsService().readFileAsBase64(input.filePath)), + + writeRepoFile: t.procedure + .input(writeRepoFileInput) + .mutation(({ input }) => + fsService().writeRepoFile( + input.repoPath, + input.filePath, + input.content, + ), + ), }), watcher: t.router({ resolveGitDirs: t.procedure @@ -206,6 +806,91 @@ export const appRouter = t.router({ watcherService().watchRepo(input.repoPath, signal), ), }), + localLogs: t.router({ + read: t.procedure + .input(readLocalLogsInput) + .output(readLocalLogsOutput) + .query(({ input }) => localLogsService().readLocalLogs(input.taskRunId)), + + write: t.procedure + .input(writeLocalLogsInput) + .mutation(({ input }) => + localLogsService().writeLocalLogs(input.taskRunId, input.content), + ), + + seed: t.procedure + .input(seedLocalLogsInput) + .mutation(({ input }) => + localLogsService().seedLocalLogs(input.taskRunId, input.content), + ), + + count: t.procedure + .input(countLocalLogEntriesInput) + .output(countLocalLogEntriesOutput) + .query(({ input }) => + localLogsService().countLocalLogEntries(input.taskRunId), + ), + + delete: t.procedure + .input(deleteLocalLogCacheInput) + .mutation(({ input }) => + localLogsService().deleteLocalLogCache(input.taskRunId), + ), + }), + connectivity: t.router({ + getStatus: t.procedure + .output(connectivityStatusOutput) + .query(() => connectivityService().getStatus()), + + checkNow: t.procedure + .output(connectivityStatusOutput) + .mutation(() => connectivityService().checkNow()), + + onStatusChange: t.procedure.subscription(async function* (opts) { + for await (const status of connectivityService().statusChangeEvents( + opts.signal, + )) { + yield status; + } + }), + }), + environment: t.router({ + list: t.procedure + .input(listEnvironmentsInput) + .output(environmentSchema.array()) + .query(({ input }) => + environmentService().listEnvironments(input.repoPath), + ), + + get: t.procedure + .input(getEnvironmentInput) + .output(environmentSchema.nullable()) + .query(({ input }) => + environmentService().getEnvironment(input.repoPath, input.id), + ), + + create: t.procedure + .input(createEnvironmentInput) + .output(environmentSchema) + .mutation(({ input }) => { + const { repoPath, ...rest } = input; + return environmentService().createEnvironment(rest, repoPath); + }), + + update: t.procedure + .input(updateEnvironmentInput) + .output(environmentSchema) + .mutation(({ input }) => { + const { repoPath, ...rest } = input; + return environmentService().updateEnvironment(rest, repoPath); + }), + + delete: t.procedure + .input(deleteEnvironmentInput) + .mutation(({ input }) => + environmentService().deleteEnvironment(input.repoPath, input.id), + ), + }), }); export type AppRouter = typeof appRouter; diff --git a/apps/code/src/main/services/workspace/workspaceEnv.ts b/packages/workspace-server/src/workspace-env.ts similarity index 97% rename from apps/code/src/main/services/workspace/workspaceEnv.ts rename to packages/workspace-server/src/workspace-env.ts index ce925d2cc9..c017dafd77 100644 --- a/apps/code/src/main/services/workspace/workspaceEnv.ts +++ b/packages/workspace-server/src/workspace-env.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { getCurrentBranch, getDefaultBranch } from "@posthog/git/queries"; -import type { WorkspaceMode } from "./schemas"; +import type { WorkspaceMode } from "@posthog/shared"; export interface WorkspaceEnvContext { taskId: string; diff --git a/packages/workspace-server/vitest.config.ts b/packages/workspace-server/vitest.config.ts new file mode 100644 index 0000000000..5e398e4eaf --- /dev/null +++ b/packages/workspace-server/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/plans/2026-05-27-workspace-server-vertical-slice.md b/plans/2026-05-27-workspace-server-vertical-slice.md deleted file mode 100644 index 67141ffa73..0000000000 --- a/plans/2026-05-27-workspace-server-vertical-slice.md +++ /dev/null @@ -1,179 +0,0 @@ -# Handoff: workspace-server vertical slice - -**Date:** 2026-05-27 -**Branch:** `05-27-refactor_new_package_architecture` -**Status:** First vertical slice landed end-to-end. Diff-stats data flows through the new architecture in apps/code. - ---- - -## Goal - -Refactor PostHog Code from a monolithic Electron app into a multi-platform-ready package architecture. The load-bearing claim: `workspace-server` runs identically locally (spawned by Electron) and in a cloud sandbox (reached via a relay) — same bundle, different transport. - -The vertical stab validates the architecture by porting one read-only feature end-to-end before generalizing. - ---- - -## Architecture (current) - -``` -packages/ -├── core # zero-dep pure domain. Empty placeholder. -├── api-client # talks to PostHog API (Django). Empty placeholder. -├── workspace-client # tRPC client + React Query provider for workspace-server. Real code. -├── workspace-server # Hono+tRPC server hosting privileged work (git, fs, ...). Real code. -├── ui # React layer with feature folders. Has diff-stats feature. -├── platform # interface-only host capabilities. Untouched in this slice. -└── shared # zero-dep utilities. Pre-existing, planned merge into core. - -tooling/ -├── typescript # shared tsconfigs (base, node-package, react-package) -└── tsup-config # shared tsup factory (created earlier, mostly unused now) -``` - -**Workspace-server lifecycle:** spawned as a separate Node child process by `apps/code/src/main/services/workspace-server/service.ts` (Inversify-injected). Uses `ELECTRON_RUN_AS_NODE=1` so Electron's bundled Node runs the bundled `workspace-server.js`. PSK auth (`x-workspace-secret` header) between processes. Health-poll on `/health` before declaring ready. - -**Renderer access:** apps/code/main exposes `workspaceServer.getConnection` via the existing electron-trpc bridge. Renderer's `ConnectedWorkspaceProvider` fetches the connection and mounts `WorkspaceClientProvider` (from `@posthog/workspace-client/provider`). Components use `useWorkspaceTRPC` from the package + `trpc.x.y.queryOptions(...)` per the official `@trpc/tanstack-react-query` pattern. - ---- - -## Current Progress - -### Packages landed - -| Package | Files | Notes | -|---|---|---| -| `@posthog/workspace-server` | `app.ts` (Hono+PSK), `trpc.ts` (router + diffStats schema), `serve.ts` (child entry + watchdog), `services/git/service.ts` (`@injectable()` GitService), `di/{container,tokens}.ts` (Inversify) | Inversify configured with `experimentalDecorators` + `emitDecoratorMetadata` in tsconfig. `reflect-metadata` imported at the top of `serve.ts` + `di/container.ts`. | -| `@posthog/workspace-client` | `client.ts` (createWorkspaceClient factory), `trpc.tsx` (createTRPCContext exports: WorkspaceTRPCProvider, useWorkspaceTRPC, useWorkspaceTRPCClient), `provider.tsx` (host-agnostic WorkspaceClientProvider taking connection prop) | Uses `react-package` tsconfig (JSX). httpBatchLink with placeholder URL until connection arrives. | -| `@posthog/ui` | `src/features/diff-stats/{useDiffStats.ts, DiffStatsBadge.tsx}` | Camel/Pascal names per React conventions. Wildcard array-fallback exports handle both extensions. | - -### Apps/code wiring - -- `src/main/services/workspace-server/service.ts` — Inversify service replacing the old `lib/workspace-server-coordinator.ts` (deleted). Methods: `start()`, `stop()`, `getConnection()`. Concurrent-start dedup via `pendingStart`. -- `src/main/trpc/routers/workspace-server.ts` — single procedure `getConnection` returning `{ url, secret }`. Auto-starts the service if not running. -- `src/main/index.ts` — `whenReady` calls `service.start()`; `before-quit` calls `service.stop()`. -- `src/renderer/components/Providers.tsx` — `ConnectedWorkspaceProvider` fetches connection + mounts `WorkspaceClientProvider`. -- `src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts` — swapped `trpc.git.getDiffStats` for `useDiffStats(repoPath ?? null)` from `@posthog/ui`. -- `src/renderer/components/HeaderRow.tsx` — inlined `TaskDiffStatsBadge` (uses `useDiffStatsToggle` + portable `` from `@posthog/ui`). Old wrapper file deleted. - -### Build wiring - -- `apps/code/vite.workspace-server.config.mts` — minimal Vite config bundling `workspace-server.js`. Entry via `require.resolve("@posthog/workspace-server/serve")`. Externalizes all `node_modules` except `@posthog/*`. Output forced to `workspace-server.js` via `rollupOptions.output.entryFileNames` (vite's `lib.fileName` is ignored under `ssr: true`). -- `apps/code/forge.config.ts` — added third Vite build entry pointing at `node_modules/@posthog/workspace-server/src/serve.ts`. -- `apps/code/vite.shared.mts` — added regex aliases for `@posthog/{core,api-client,ui,workspace-client,workspace-server}` pointing at each package's `src/`. Enables HMR. - -### Catalogs + biome - -- `pnpm-workspace.yaml` — catalog entries for `hono`, `@hono/*`, `@trpc/*`, `@tanstack/react-query`, `@phosphor-icons/react`, `@radix-ui/themes`, `@posthog/quill`, `inversify`, `reflect-metadata`, `superjson`, `zod`, `react*`, `typescript`, `tsup`. -- `biome.jsonc` — boundary rules per package (`@posthog/*` glob with `!` exceptions for allowed siblings). Smoke-tested that violations fire with the right message. - ---- - -## What worked - -### Architecture decisions - -1. **Separate child process for workspace-server (not embed)** — pays off because the bundle is sandbox-identical, native deps don't bloat Electron, crash isolation. Spawning via `ELECTRON_RUN_AS_NODE=1` matches Superset's pattern. -2. **Inversify only inside workspace-server (and apps/code/main).** Other packages use plain factories. Decorators kept narrow to where DI pulls weight. -3. **`@trpc/tanstack-react-query`'s `createTRPCContext` pattern** — proper provider + `useTRPC()` instead of manual `useQuery({ queryFn })` shims. -4. **Generic provider in workspace-client + host-specific connection-fetching in apps/code.** `WorkspaceClientProvider` knows nothing about how to obtain a connection; `ConnectedWorkspaceProvider` in apps/code does. -5. **Non-blocking mount via placeholder URL** — `WorkspaceClientProvider` always wraps children. Pre-connection, the client points at a sentinel URL; queries fail until connection arrives. App renders independent of workspace-server boot. - -### Resolution patterns - -6. **Turborepo Just-in-Time wildcard exports** (`"./*": ["./src/*.ts", "./src/*.tsx"]`) — single line per package, no per-file maintenance, no barrels, no build step. **This is the official pattern.** Works with `moduleResolution: bundler` because extensions are explicit in the array fallback. -7. **No `index.ts` barrels.** Each file is its own subpath; imports look like `@posthog/ui/features/diff-stats/DiffStatsBadge`. -8. **Vite aliases in `vite.shared.mts`** for HMR: regex `/^@posthog\/\/(.+)$/` → `packages//src/$1`. Vite resolves extensions. - -### Tooling - -9. **pnpm catalogs** for all shared external dep versions. -10. **biome `noRestrictedImports` with `@posthog/*` allowlist exceptions** — enforces package boundaries. Caught real violations during the session. - ---- - -## What didn't work (avoid these) - -1. **Wildcard exports `"./*": "./src/*"` (no extensions).** Tested empirically: TypeScript under `moduleResolution: bundler` does NOT try `.ts`/`.tsx` extensions through exports. Returns "Cannot find module" errors. Use the array-fallback form instead. -2. **tsconfig `paths` pointing at sibling packages' `src/`.** Conflicts with `apps/code/tsconfig.node.json`'s `rootDir: ./src` — TS complains about source files outside rootDir. Removing rootDir works but pokes at unrelated config. **Turborepo's wildcard exports are simpler and cleaner.** -3. **`useMemo([connection])` for client construction.** React Query can produce new object references with identical data, churning the client. Use primitives `[url, secret]` instead. (Currently resolved by the placeholder-URL pattern — the client rebuilds only when the URL actually changes.) -4. **Conditional render in `WorkspaceClientProvider` (`if (!client) return null`).** Blocks the entire app on workspace-server boot. Replaced with always-mount + placeholder URL. -5. **`httpBatchLink({ url: () => ... })`** — `url` doesn't accept a function in `@trpc/client@11`. Must be string. -6. **`staleTime: Number.POSITIVE_INFINITY` on the connection query.** Stale url+secret persists forever after a child crash. Now `30_000`. True invalidation on child death is a deferred improvement. -7. **Keeping the workspace-server child entry in `apps/code/src/main/`** — instead it belongs in `packages/workspace-server/src/serve.ts` (it's the package's own runtime shape; apps/code just bundles it). -8. **Per-file `exports` entries in package.json.** Tedious. Replaced with wildcard. -9. **A separate `WorkspaceTRPCBridge` component in apps/code.** The "construct client + mount provider" logic is generic — moved into `WorkspaceClientProvider` in the package. Only host-specific glue (the connection fetch) stays in apps/code. - ---- - -## Open concerns (from final review) - -### High - -- **No connection invalidation on child death.** If workspace-server crashes mid-session, the cached `workspaceServer.getConnection` query (staleTime 30s) holds the stale url+secret. Calls fail until React Query's window-focus refetch or 30s passes. Real fix: emit an event from main when the child exits, invalidate the connection query from the renderer. - -### Medium - -- **Schema-vs-type drift direction.** `diffStatsSchema: z.ZodType` catches type narrowing but not optional-field additions (silently stripped at the wire). Consider inverting: `type DiffStats = z.infer` and assignability-check against `@posthog/git`'s `DiffStats`. -- **Failed diff query masks as zero stats.** `data: diffStats = emptyDiffStats` silently swallows errors. Pre-existing pattern, but failure surface grew (HTTP can now fail). - -### Low - -- PSK comparison non-constant-time (`a !== b`) — should use `timingSafeEqual`. Cosmetic for localhost. -- PSK visible to same-uid processes via `/proc//environ` on Linux. Document as acceptable for local case. - ---- - -## Next steps - -### Immediate (small) - -1. **Connection invalidation on child death.** Add an event channel (existing electron-trpc subscription works) or polling. Renderer invalidates `workspaceServer.getConnection` when notified. -2. **Schema source-of-truth inversion** in `packages/workspace-server/src/trpc.ts` — derive `DiffStats` from the zod schema, assignability-check against `@posthog/git`. -3. **PSK `timingSafeEqual`** — drop-in replacement in `packages/workspace-server/src/app.ts`. - -### Next vertical slice - -4. **Port a second feature** through the same pipeline. Candidates (in order of value): - - **File tree / file watcher** — exercises subscriptions (a hole the diff-stats slice didn't fill). Long-lived streams over HTTP+WS. - - **Git status indicator (sync status)** — was the original first-choice; rolled back to keep diff-stats focused. Easy second slice now that the pipeline exists. - - **Terminal output** — most ambitious; tests pty proxying through workspace-server. - -### Medium-term migrations - -5. **Fold `packages/shared` into `packages/core`.** Both are zero-dep utility packages. CLAUDE.md still references `@posthog/shared`. -6. **Decide auth flow location.** Currently smeared across `apps/code/src/main/services/auth/`. It cross-cuts platform (secure storage), api-client (refresh endpoint), workspace-server (acting on behalf of user). First domain that genuinely needs a vertical-slice package (`packages/domains/auth/`?). -7. **Define the relay protocol.** Today workspace-server is local-only. For cloud sandboxes, we need a Django-mediated relay (Superset has one — `apps/relay/` in their repo). This unblocks cloud parity. - -### Architectural housekeeping - -8. **Cloud diff path will collapse.** `useTaskDiffSummaryStats` currently has 4 modes (local/branch/PR/cloud). Long-term, all roads lead to workspace-server (local OR sandboxed). When the relay exists, `useDiffStats` works for cloud too — `useCloudChangedFiles` deletes. -9. **Document the architecture decisions** in `docs/architecture.md`. The current doc predates this refactor. - ---- - -## Useful references - -- **Turborepo Just-in-Time wildcard exports** — official pattern, the `["./src/*.ts", "./src/*.tsx"]` array fallback is the documented approach. -- **Superset's `apps/desktop/src/main/lib/host-service-coordinator.ts`** — the spawn-via-Electron-Node pattern we mirrored. Theirs has more features (stable-port hashing, manifest file, dev-reload watcher) that may be worth borrowing later. -- **biome.jsonc** — the package boundary enforcement. Each new package needs its own override block following the established pattern. -- **`apps/code/scripts/`** — the smoke script (`smoke-workspace-server.mjs`) was deleted at the end of the session. If you want end-to-end validation during dev, recreate or use the running app. - ---- - -## Files to read for context - -In rough order of importance: - -1. `packages/workspace-server/src/serve.ts` — child process entry -2. `packages/workspace-server/src/app.ts` — Hono factory + PSK auth -3. `packages/workspace-server/src/trpc.ts` — router (one procedure: `diffStats.getDiffStats`) -4. `apps/code/src/main/services/workspace-server/service.ts` — coordinator-as-service -5. `apps/code/src/main/trpc/routers/workspace-server.ts` — `getConnection` procedure -6. `packages/workspace-client/src/provider.tsx` — host-agnostic provider with placeholder-URL non-blocking pattern -7. `packages/workspace-client/src/trpc.tsx` — createTRPCContext exports -8. `apps/code/src/renderer/components/Providers.tsx` — host-specific connection bridge -9. `apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts` — example consumer -10. `apps/code/vite.workspace-server.config.mts` + `forge.config.ts` — build wiring -11. `pnpm-workspace.yaml` — catalogs -12. `biome.jsonc` — package boundary rules diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5eff7b287..93b8bd5bc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ catalogs: specifier: ^11.12.0 version: 11.12.0 '@trpc/tanstack-react-query': - specifier: ^11.12.0 + specifier: 11.12.0 version: 11.12.0 '@types/node': specifier: ^20.0.0 @@ -69,9 +69,6 @@ catalogs: typescript: specifier: ^5.5.0 version: 5.9.3 - zod: - specifier: ^3.24.1 - version: 3.25.76 patchedDependencies: node-pty: @@ -109,96 +106,9 @@ importers: apps/code: dependencies: - '@base-ui/react': - specifier: ^1.3.0 - version: 1.3.0(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@codemirror/lang-angular': - specifier: ^0.1.4 - version: 0.1.4 - '@codemirror/lang-cpp': - specifier: ^6.0.3 - version: 6.0.3 - '@codemirror/lang-css': - specifier: ^6.3.1 - version: 6.3.1 - '@codemirror/lang-go': - specifier: ^6.0.1 - version: 6.0.1 - '@codemirror/lang-html': - specifier: ^6.4.11 - version: 6.4.11 - '@codemirror/lang-java': - specifier: ^6.0.2 - version: 6.0.2 - '@codemirror/lang-javascript': - specifier: ^6.2.4 - version: 6.2.4 - '@codemirror/lang-jinja': - specifier: ^6.0.0 - version: 6.0.0 - '@codemirror/lang-json': - specifier: ^6.0.2 - version: 6.0.2 - '@codemirror/lang-liquid': - specifier: ^6.3.0 - version: 6.3.1 - '@codemirror/lang-markdown': - specifier: ^6.5.0 - version: 6.5.0 - '@codemirror/lang-php': - specifier: ^6.0.2 - version: 6.0.2 - '@codemirror/lang-python': - specifier: ^6.2.1 - version: 6.2.1 - '@codemirror/lang-rust': - specifier: ^6.0.2 - version: 6.0.2 - '@codemirror/lang-sass': - specifier: ^6.0.2 - version: 6.0.2 - '@codemirror/lang-sql': - specifier: ^6.10.0 - version: 6.10.0 - '@codemirror/lang-vue': - specifier: ^0.1.3 - version: 0.1.3 - '@codemirror/lang-wast': - specifier: ^6.0.2 - version: 6.0.2 - '@codemirror/lang-xml': - specifier: ^6.1.0 - version: 6.1.0 - '@codemirror/lang-yaml': - specifier: ^6.1.2 - version: 6.1.2 - '@codemirror/language': - specifier: ^6.12.2 - version: 6.12.2 - '@codemirror/search': - specifier: ^6.6.0 - version: 6.6.0 - '@codemirror/state': - specifier: ^6.5.4 - version: 6.5.4 - '@codemirror/view': - specifier: ^6.39.17 - version: 6.39.17 - '@dnd-kit/react': - specifier: ^0.1.21 - version: 0.1.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 - '@lezer/common': - specifier: ^1.5.1 - version: 1.5.1 - '@lezer/highlight': - specifier: ^1.2.3 - version: 1.2.3 - '@modelcontextprotocol/ext-apps': - specifier: ^1.1.2 - version: 1.2.2(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6) '@modelcontextprotocol/sdk': specifier: ^1.12.1 version: 1.27.1(zod@4.3.6) @@ -235,6 +145,9 @@ importers: '@posthog/core': specifier: workspace:* version: link:../../packages/core + '@posthog/di': + specifier: workspace:* + version: link:../../packages/di '@posthog/electron-trpc': specifier: workspace:* version: link:../../packages/electron-trpc @@ -247,6 +160,12 @@ importers: '@posthog/hedgehog-mode': specifier: ^0.0.48 version: 0.0.48(prop-types@15.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@posthog/host-router': + specifier: workspace:* + version: link:../../packages/host-router + '@posthog/host-trpc': + specifier: workspace:* + version: link:../../packages/host-trpc '@posthog/platform': specifier: workspace:* version: link:../../packages/platform @@ -265,12 +184,6 @@ importers: '@posthog/workspace-server': specifier: workspace:* version: link:../../packages/workspace-server - '@radix-ui/react-collapsible': - specifier: ^1.1.12 - version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-icons': - specifier: ^1.3.2 - version: 1.3.2(react@19.1.0) '@radix-ui/themes': specifier: ^3.2.1 version: 3.3.0(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -280,27 +193,6 @@ importers: '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.20(react@19.1.0) - '@tiptap/core': - specifier: ^3.13.0 - version: 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-mention': - specifier: ^3.13.0 - version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) - '@tiptap/extension-placeholder': - specifier: ^3.13.0 - version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) - '@tiptap/pm': - specifier: ^3.13.0 - version: 3.19.0 - '@tiptap/react': - specifier: ^3.13.0 - version: 3.19.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@tiptap/starter-kit': - specifier: ^3.13.0 - version: 3.19.0 - '@tiptap/suggestion': - specifier: ^3.13.0 - version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) '@trpc/client': specifier: ^11.12.0 version: 11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3) @@ -310,36 +202,12 @@ importers: '@trpc/tanstack-react-query': specifier: ^11.12.0 version: 11.12.0(@tanstack/react-query@5.90.20(react@19.1.0))(@trpc/client@11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.12.0(typescript@5.9.3))(react@19.1.0)(typescript@5.9.3) - '@xterm/addon-fit': - specifier: ^0.10.0 - version: 0.10.0(@xterm/xterm@5.5.0) - '@xterm/addon-serialize': - specifier: ^0.13.0 - version: 0.13.0(@xterm/xterm@5.5.0) - '@xterm/addon-web-links': - specifier: ^0.11.0 - version: 0.11.0(@xterm/xterm@5.5.0) - '@xterm/xterm': - specifier: ^5.5.0 - version: 5.5.0 better-sqlite3: specifier: ^12.8.0 version: 12.8.0 - canvas-confetti: - specifier: ^1.9.4 - version: 1.9.4 chokidar: specifier: ^5.0.0 version: 5.0.0 - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 - cmdk: - specifier: ^1.1.1 - version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) detect-libc: specifier: ^1.0.3 version: 1.0.3 @@ -361,27 +229,18 @@ importers: file-icon: specifier: ^6.0.0 version: 6.0.0 - framer-motion: - specifier: ^12.26.2 - version: 12.31.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) fzf: specifier: ^0.5.2 version: 0.5.2 ignore: specifier: ^7.0.5 version: 7.0.5 - immer: - specifier: ^11.0.1 - version: 11.1.3 inversify: specifier: ^7.10.6 version: 7.11.0(reflect-metadata@0.2.2) is-glob: specifier: ^4.0.3 version: 4.0.3 - lucide-react: - specifier: ^1.7.0 - version: 1.7.0(react@19.1.0) micromatch: specifier: ^4.0.5 version: 4.0.8 @@ -394,9 +253,6 @@ importers: node-pty: specifier: 1.1.0 version: 1.1.0(patch_hash=4dfdf785f5ac51a03f5d6032371cebe89036381acd403621f250a896245647c5) - posthog-js: - specifier: ^1.283.0 - version: 1.340.0 posthog-node: specifier: ^5.24.10 version: 5.24.10 @@ -412,27 +268,9 @@ importers: react-hotkeys-hook: specifier: ^4.4.4 version: 4.6.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - react-markdown: - specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.11)(react@19.1.0) - react-resizable-panels: - specifier: ^3.0.6 - version: 3.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) reflect-metadata: specifier: ^0.2.2 version: 0.2.2 - rehype-raw: - specifier: ^7.0.0 - version: 7.0.0 - rehype-sanitize: - specifier: ^6.0.0 - version: 6.0.0 - remark-breaks: - specifier: ^4.0.0 - version: 4.0.0 - remark-gfm: - specifier: ^4.0.1 - version: 4.0.1 semver: specifier: ^7.6.0 version: 7.7.3 @@ -442,43 +280,22 @@ importers: smol-toml: specifier: ^1.6.0 version: 1.6.0 - sonner: - specifier: ^2.0.7 - version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - striptags: - specifier: ^3.2.0 - version: 3.2.0 - tailwind-merge: - specifier: ^3.5.0 - version: 3.5.0 tailwindcss-scroll-mask: specifier: ^0.0.3 version: 0.0.3(tailwindcss@4.2.2) - tippy.js: - specifier: ^6.3.7 - version: 6.3.7 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 - vaul: - specifier: ^1.1.2 - version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - vscode-icons-js: - specifier: ^11.6.1 - version: 11.6.1 zod: specifier: ^4.1.12 version: 4.3.6 - zustand: - specifier: ^4.5.0 - version: 4.5.7(@types/react@19.2.11)(immer@11.1.3)(react@19.1.0) devDependencies: '@biomejs/biome': specifier: 2.2.4 version: 2.2.4 '@electron-forge/cli': specifier: ^7.11.1 - version: 7.11.1(encoding@0.1.13)(esbuild@0.25.12) + version: 7.11.1(encoding@0.1.13)(esbuild@0.27.2) '@electron-forge/maker-dmg': specifier: ^7.11.1 version: 7.11.1 @@ -514,10 +331,10 @@ importers: version: 10.2.0(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@storybook/addon-docs': specifier: 10.2.0 - version: 10.2.0(@types/react@19.2.11)(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) + version: 10.2.0(@types/react@19.2.11)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@storybook/react-vite': specifier: 10.2.0 - version: 10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) + version: 10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -554,15 +371,9 @@ importers: adm-zip: specifier: ^0.5.16 version: 0.5.16 - drizzle-kit: - specifier: ^0.31.9 - version: 0.31.9 electron: specifier: ^41.0.0 version: 41.0.2 - fuse.js: - specifier: ^7.1.0 - version: 7.1.0 husky: specifier: ^9.1.7 version: 9.1.7 @@ -596,9 +407,6 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 - virtua: - specifier: ^0.48.6 - version: 0.48.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) vite: specifier: ^6.0.7 version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -793,6 +601,73 @@ importers: specifier: ^4.1.6 version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + apps/web: + dependencies: + '@posthog/core': + specifier: workspace:* + version: link:../../packages/core + '@posthog/di': + specifier: workspace:* + version: link:../../packages/di + '@posthog/host-router': + specifier: workspace:* + version: link:../../packages/host-router + '@posthog/platform': + specifier: workspace:* + version: link:../../packages/platform + '@posthog/shared': + specifier: workspace:* + version: link:../../packages/shared + '@posthog/ui': + specifier: workspace:* + version: link:../../packages/ui + '@posthog/workspace-client': + specifier: workspace:* + version: link:../../packages/workspace-client + '@tanstack/react-query': + specifier: ^5.90.2 + version: 5.90.20(react@19.1.0) + '@trpc/client': + specifier: ^11.12.0 + version: 11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3) + '@trpc/tanstack-react-query': + specifier: ^11.12.0 + version: 11.12.0(@tanstack/react-query@5.90.20(react@19.1.0))(@trpc/client@11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.12.0(typescript@5.9.3))(react@19.1.0)(typescript@5.9.3) + inversify: + specifier: ^7.10.6 + version: 7.11.0(reflect-metadata@0.2.2) + react: + specifier: 19.1.0 + version: 19.1.0 + react-dom: + specifier: 19.1.0 + version: 19.1.0(react@19.1.0) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + devDependencies: + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.2.2(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@types/react': + specifier: ^19.1.0 + version: 19.2.11 + '@types/react-dom': + specifier: ^19.1.0 + version: 19.2.3(@types/react@19.2.11) + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + tailwindcss: + specifier: ^4.2.2 + version: 4.2.2 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/agent: dependencies: '@agentclientprotocol/sdk': @@ -885,6 +760,13 @@ importers: version: 2.1.9(@types/node@25.2.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(terser@5.46.0) packages/api-client: + dependencies: + '@posthog/agent': + specifier: workspace:* + version: link:../agent + '@posthog/shared': + specifier: workspace:* + version: link:../shared devDependencies: '@posthog/tsconfig': specifier: workspace:* @@ -898,19 +780,80 @@ importers: packages/core: dependencies: - '@posthog/shared': - specifier: workspace:* - version: link:../shared - '@posthog/workspace-client': - specifier: workspace:* + '@modelcontextprotocol/ext-apps': + specifier: ^1.1.2 + version: 1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6) + '@modelcontextprotocol/sdk': + specifier: ^1.12.1 + version: 1.29.0(zod@4.3.6) + '@pierre/diffs': + specifier: ^1.1.21 + version: 1.1.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@posthog/api-client': + specifier: workspace:* + version: link:../api-client + '@posthog/di': + specifier: workspace:* + version: link:../di + '@posthog/platform': + specifier: workspace:* + version: link:../platform + '@posthog/shared': + specifier: workspace:* + version: link:../shared + '@posthog/workspace-client': + specifier: workspace:* version: link:../workspace-client + fuse.js: + specifier: ^7.1.0 + version: 7.1.0 + inversify: + specifier: 'catalog:' + version: 7.11.0(reflect-metadata@0.2.2) + reflect-metadata: + specifier: 'catalog:' + version: 0.2.2 + zod: + specifier: ^4.1.12 + version: 4.3.6 + zustand: + specifier: ^4.5.0 + version: 4.5.7(@types/react@19.2.11)(immer@11.1.3)(react@19.1.0) devDependencies: + '@posthog/git': + specifier: workspace:* + version: link:../git '@posthog/tsconfig': specifier: workspace:* version: link:../../tooling/typescript typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: ^4.0.10 + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + + packages/di: + dependencies: + inversify: + specifier: 'catalog:' + version: 7.11.0(reflect-metadata@0.2.2) + devDependencies: + '@posthog/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/react': + specifier: 'catalog:' + version: 19.2.11 + react: + specifier: 'catalog:' + version: 19.1.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@25.2.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(terser@5.46.0) packages/electron-trpc: devDependencies: @@ -947,6 +890,9 @@ importers: packages/enricher: dependencies: + '@posthog/shared': + specifier: workspace:* + version: link:../shared web-tree-sitter: specifier: ^0.24.7 version: 0.24.7 @@ -986,6 +932,62 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@25.2.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(terser@5.46.0) + packages/host-router: + dependencies: + '@posthog/core': + specifier: workspace:* + version: link:../core + '@posthog/host-trpc': + specifier: workspace:* + version: link:../host-trpc + '@posthog/platform': + specifier: workspace:* + version: link:../platform + '@posthog/workspace-client': + specifier: workspace:* + version: link:../workspace-client + '@posthog/workspace-server': + specifier: workspace:* + version: link:../workspace-server + '@trpc/client': + specifier: 'catalog:' + version: 11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3) + devDependencies: + '@posthog/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@tanstack/react-query': + specifier: 'catalog:' + version: 5.90.20(react@19.1.0) + '@trpc/tanstack-react-query': + specifier: 'catalog:' + version: 11.12.0(@tanstack/react-query@5.90.20(react@19.1.0))(@trpc/client@11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.12.0(typescript@5.9.3))(react@19.1.0)(typescript@5.9.3) + '@types/react': + specifier: 'catalog:' + version: 19.2.11 + react: + specifier: 'catalog:' + version: 19.1.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + + packages/host-trpc: + dependencies: + '@trpc/server': + specifier: 'catalog:' + version: 11.12.0(typescript@5.9.3) + devDependencies: + '@posthog/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + inversify: + specifier: 'catalog:' + version: 7.11.0(reflect-metadata@0.2.2) + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/platform: devDependencies: tsup: @@ -1006,27 +1008,258 @@ importers: typescript: specifier: ^5.5.0 version: 5.9.3 + vitest: + specifier: ^4.0.10 + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) packages/ui: dependencies: + '@agentclientprotocol/sdk': + specifier: 0.22.1 + version: 0.22.1(zod@4.3.6) + '@base-ui/react': + specifier: ^1.3.0 + version: 1.3.0(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@codemirror/lang-angular': + specifier: ^0.1.4 + version: 0.1.4 + '@codemirror/lang-cpp': + specifier: ^6.0.3 + version: 6.0.3 + '@codemirror/lang-css': + specifier: ^6.3.1 + version: 6.3.1 + '@codemirror/lang-go': + specifier: ^6.0.1 + version: 6.0.1 + '@codemirror/lang-html': + specifier: ^6.4.11 + version: 6.4.11 + '@codemirror/lang-java': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-javascript': + specifier: ^6.2.4 + version: 6.2.4 + '@codemirror/lang-jinja': + specifier: ^6.0.0 + version: 6.0.0 + '@codemirror/lang-json': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-liquid': + specifier: ^6.3.0 + version: 6.3.1 + '@codemirror/lang-markdown': + specifier: ^6.5.0 + version: 6.5.0 + '@codemirror/lang-php': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-python': + specifier: ^6.2.1 + version: 6.2.1 + '@codemirror/lang-rust': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-sass': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-sql': + specifier: ^6.10.0 + version: 6.10.0 + '@codemirror/lang-vue': + specifier: ^0.1.3 + version: 0.1.3 + '@codemirror/lang-wast': + specifier: ^6.0.2 + version: 6.0.2 + '@codemirror/lang-xml': + specifier: ^6.1.0 + version: 6.1.0 + '@codemirror/lang-yaml': + specifier: ^6.1.2 + version: 6.1.2 + '@codemirror/language': + specifier: ^6.12.2 + version: 6.12.2 + '@codemirror/search': + specifier: ^6.6.0 + version: 6.6.0 + '@codemirror/state': + specifier: ^6.5.4 + version: 6.5.4 + '@codemirror/view': + specifier: ^6.39.17 + version: 6.39.17 + '@dnd-kit/dom': + specifier: ^0.1.21 + version: 0.1.21 + '@dnd-kit/react': + specifier: ^0.1.21 + version: 0.1.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@lezer/common': + specifier: ^1.5.1 + version: 1.5.1 + '@lezer/highlight': + specifier: ^1.2.3 + version: 1.2.3 + '@modelcontextprotocol/ext-apps': + specifier: ^1.1.2 + version: 1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6) + '@modelcontextprotocol/sdk': + specifier: ^1.12.1 + version: 1.29.0(zod@4.3.6) + '@pierre/diffs': + specifier: ^1.1.21 + version: 1.1.21(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@posthog/agent': + specifier: workspace:* + version: link:../agent '@posthog/api-client': specifier: workspace:* version: link:../api-client '@posthog/core': specifier: workspace:* version: link:../core + '@posthog/di': + specifier: workspace:* + version: link:../di + '@posthog/host-router': + specifier: workspace:* + version: link:../host-router '@posthog/platform': specifier: workspace:* version: link:../platform + '@posthog/shared': + specifier: workspace:* + version: link:../shared '@posthog/workspace-client': specifier: workspace:* version: link:../workspace-client + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-icons': + specifier: ^1.3.2 + version: 1.3.2(react@19.1.0) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tiptap/core': + specifier: ^3.13.0 + version: 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-mention': + specifier: ^3.13.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-placeholder': + specifier: ^3.13.0 + version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/pm': + specifier: ^3.13.0 + version: 3.19.0 + '@tiptap/react': + specifier: ^3.13.0 + version: 3.19.0(@floating-ui/dom@1.7.6)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tiptap/starter-kit': + specifier: ^3.13.0 + version: 3.19.0 + '@tiptap/suggestion': + specifier: ^3.13.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@trpc/tanstack-react-query': + specifier: 'catalog:' + version: 11.12.0(@tanstack/react-query@5.90.20(react@19.1.0))(@trpc/client@11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.12.0(typescript@5.9.3))(react@19.1.0)(typescript@5.9.3) + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/addon-serialize': + specifier: ^0.13.0 + version: 0.13.0(@xterm/xterm@5.5.0) + '@xterm/addon-web-links': + specifier: ^0.11.0 + version: 0.11.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 + canvas-confetti: + specifier: ^1.9.4 + version: 1.9.4 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + framer-motion: + specifier: ^12.26.2 + version: 12.31.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + fuse.js: + specifier: ^7.1.0 + version: 7.1.0 + fzf: + specifier: ^0.5.2 + version: 0.5.2 inversify: specifier: 'catalog:' version: 7.11.0(reflect-metadata@0.2.2) + lucide-react: + specifier: ^1.7.0 + version: 1.7.0(react@19.1.0) + posthog-js: + specifier: ^1.283.0 + version: 1.340.0 + radix-themes-tw: + specifier: 0.2.3 + version: 0.2.3 + react-hotkeys-hook: + specifier: ^4.4.4 + version: 4.6.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.11)(react@19.1.0) + react-resizable-panels: + specifier: ^3.0.6 + version: 3.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) reflect-metadata: specifier: 'catalog:' version: 0.2.2 + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + semver: + specifier: ^7.6.0 + version: 7.7.3 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + tailwindcss: + specifier: ^4.2.2 + version: 4.2.2 + tailwindcss-scroll-mask: + specifier: ^0.0.3 + version: 0.0.3(tailwindcss@4.2.2) + tippy.js: + specifier: ^6.3.7 + version: 6.3.7 + unified: + specifier: ^11.0.5 + version: 11.0.5 + virtua: + specifier: ^0.48.6 + version: 0.48.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + vscode-icons-js: + specifier: ^11.6.1 + version: 11.6.1 + zustand: + specifier: ^4.5.0 + version: 4.5.7(@types/react@19.2.11)(immer@11.1.3)(react@19.1.0) devDependencies: '@phosphor-icons/react': specifier: 'catalog:' @@ -1043,12 +1276,33 @@ importers: '@tanstack/react-query': specifier: 'catalog:' version: 5.90.20(react@19.1.0) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/canvas-confetti': + specifier: ^1.9.0 + version: 1.9.0 '@types/react': specifier: 'catalog:' version: 19.2.11 '@types/react-dom': specifier: 'catalog:' version: 19.2.3(@types/react@19.2.11) + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 react: specifier: 'catalog:' version: 19.1.0 @@ -1058,6 +1312,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: ^4.0.10 + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) packages/workspace-client: dependencies: @@ -1092,6 +1349,12 @@ importers: packages/workspace-server: dependencies: + '@agentclientprotocol/sdk': + specifier: 0.22.1 + version: 0.22.1(zod@4.3.6) + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.154 + version: 0.3.154(@anthropic-ai/sdk@0.100.0(zod@4.3.6))(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(zod@4.3.6) '@hono/node-server': specifier: 'catalog:' version: 1.19.9(hono@4.11.7) @@ -1101,12 +1364,36 @@ importers: '@parcel/watcher': specifier: 'catalog:' version: 2.5.6 + '@posthog/agent': + specifier: workspace:* + version: link:../agent + '@posthog/di': + specifier: workspace:* + version: link:../di + '@posthog/enricher': + specifier: workspace:* + version: link:../enricher '@posthog/git': specifier: workspace:* version: link:../git + '@posthog/platform': + specifier: workspace:* + version: link:../platform + '@posthog/shared': + specifier: workspace:* + version: link:../shared '@trpc/server': specifier: 'catalog:' version: 11.12.0(typescript@5.9.3) + better-sqlite3: + specifier: ^12.8.0 + version: 12.8.0 + drizzle-orm: + specifier: ^0.45.1 + version: 0.45.1(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0)(bun-types@1.3.14) + fflate: + specifier: ^0.8.2 + version: 0.8.2 hono: specifier: 'catalog:' version: 4.11.7 @@ -1116,25 +1403,40 @@ importers: inversify: specifier: 'catalog:' version: 7.11.0(reflect-metadata@0.2.2) + node-pty: + specifier: 1.1.0 + version: 1.1.0(patch_hash=4dfdf785f5ac51a03f5d6032371cebe89036381acd403621f250a896245647c5) reflect-metadata: specifier: 'catalog:' version: 0.2.2 + smol-toml: + specifier: ^1.6.0 + version: 1.6.0 superjson: specifier: 'catalog:' version: 2.2.6 zod: - specifier: 'catalog:' - version: 3.25.76 + specifier: ^4.1.12 + version: 4.3.6 devDependencies: '@posthog/tsconfig': specifier: workspace:* version: link:../../tooling/typescript + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/node': specifier: 'catalog:' version: 20.19.41 + drizzle-kit: + specifier: ^0.31.9 + version: 0.31.9 typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: ^4.0.10 + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(jsdom@26.1.0)(msw@2.12.8(@types/node@20.19.41)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.41)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) tooling/tsup-config: dependencies: @@ -2884,21 +3186,12 @@ packages: '@floating-ui/dom@1.7.6': resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - '@floating-ui/react-dom@2.1.7': - resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - '@floating-ui/react-dom@2.1.8': resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} @@ -5583,8 +5876,8 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -9191,8 +9484,8 @@ packages: mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} - mdast-util-from-markdown@2.0.2: - resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} mdast-util-gfm-autolink-literal@2.0.1: resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} @@ -9221,9 +9514,6 @@ packages: mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} - mdast-util-newline-to-break@2.0.0: - resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==} - mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} @@ -10825,9 +11115,6 @@ packages: rehype-sanitize@6.0.0: resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} - remark-breaks@4.0.0: - resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} - remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -11388,9 +11675,6 @@ packages: resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} engines: {node: '>=0.10.0'} - striptags@3.2.0: - resolution: {integrity: sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==} - strtok3@6.3.0: resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} engines: {node: '>=10'} @@ -13355,7 +13639,7 @@ snapshots: '@base-ui/react@1.3.0(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@base-ui/utils': 0.2.6(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@floating-ui/react-dom': 2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@floating-ui/utils': 0.2.11 @@ -13368,7 +13652,7 @@ snapshots: '@base-ui/utils@0.2.6(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.2 '@floating-ui/utils': 0.2.11 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -13695,9 +13979,9 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@electron-forge/cli@7.11.1(encoding@0.1.13)(esbuild@0.25.12)': + '@electron-forge/cli@7.11.1(encoding@0.1.13)(esbuild@0.27.2)': dependencies: - '@electron-forge/core': 7.11.1(encoding@0.1.13)(esbuild@0.25.12) + '@electron-forge/core': 7.11.1(encoding@0.1.13)(esbuild@0.27.2) '@electron-forge/core-utils': 7.11.1 '@electron-forge/shared-types': 7.11.1 '@electron/get': 3.1.0 @@ -13735,7 +14019,7 @@ snapshots: - bluebird - supports-color - '@electron-forge/core@7.11.1(encoding@0.1.13)(esbuild@0.25.12)': + '@electron-forge/core@7.11.1(encoding@0.1.13)(esbuild@0.27.2)': dependencies: '@electron-forge/core-utils': 7.11.1 '@electron-forge/maker-base': 7.11.1 @@ -13746,7 +14030,7 @@ snapshots: '@electron-forge/template-vite': 7.11.1 '@electron-forge/template-vite-typescript': 7.11.1 '@electron-forge/template-webpack': 7.11.1 - '@electron-forge/template-webpack-typescript': 7.11.1(esbuild@0.25.12) + '@electron-forge/template-webpack-typescript': 7.11.1(esbuild@0.27.2) '@electron-forge/tracer': 7.11.1 '@electron/get': 3.1.0 '@electron/packager': 18.4.4 @@ -13907,13 +14191,13 @@ snapshots: - bluebird - supports-color - '@electron-forge/template-webpack-typescript@7.11.1(esbuild@0.25.12)': + '@electron-forge/template-webpack-typescript@7.11.1(esbuild@0.27.2)': dependencies: '@electron-forge/shared-types': 7.11.1 '@electron-forge/template-base': 7.11.1 fs-extra: 10.1.0 typescript: 5.4.5 - webpack: 5.105.0(esbuild@0.25.12) + webpack: 5.105.0(esbuild@0.27.2) transitivePeerDependencies: - '@swc/core' - bluebird @@ -14715,7 +14999,8 @@ snapshots: '@floating-ui/core@1.7.4': dependencies: - '@floating-ui/utils': 0.2.10 + '@floating-ui/utils': 0.2.11 + optional: true '@floating-ui/core@1.7.5': dependencies: @@ -14724,27 +15009,20 @@ snapshots: '@floating-ui/dom@1.7.5': dependencies: '@floating-ui/core': 1.7.4 - '@floating-ui/utils': 0.2.10 + '@floating-ui/utils': 0.2.11 + optional: true '@floating-ui/dom@1.7.6': dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': - dependencies: - '@floating-ui/dom': 1.7.5 - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - '@floating-ui/react-dom@2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@floating-ui/dom': 1.7.6 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@floating-ui/utils@0.2.10': {} - '@floating-ui/utils@0.2.11': {} '@fontsource-variable/inter@5.2.8': {} @@ -14777,6 +15055,14 @@ snapshots: '@inquirer/core': 9.2.1 '@inquirer/type': 2.0.0 + '@inquirer/confirm@5.1.21(@types/node@20.19.41)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.41) + '@inquirer/type': 3.0.10(@types/node@20.19.41) + optionalDependencies: + '@types/node': 20.19.41 + optional: true + '@inquirer/confirm@5.1.21(@types/node@24.12.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@24.12.0) @@ -14791,6 +15077,20 @@ snapshots: optionalDependencies: '@types/node': 25.2.0 + '@inquirer/core@10.3.2(@types/node@20.19.41)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.41) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.41 + optional: true + '@inquirer/core@10.3.2(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -14904,6 +15204,11 @@ snapshots: dependencies: mute-stream: 1.0.0 + '@inquirer/type@3.0.10(@types/node@20.19.41)': + optionalDependencies: + '@types/node': 20.19.41 + optional: true + '@inquirer/type@3.0.10(@types/node@24.12.0)': optionalDependencies: '@types/node': 24.12.0 @@ -15500,14 +15805,6 @@ snapshots: '@types/react': 19.2.11 react: 19.1.0 - '@modelcontextprotocol/ext-apps@1.2.2(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6)': - dependencies: - '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) - zod: 4.3.6 - optionalDependencies: - react: 19.1.0 - react-dom: 19.1.0(react@19.1.0) - '@modelcontextprotocol/ext-apps@1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) @@ -16434,7 +16731,7 @@ snapshots: '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@floating-ui/react-dom': 2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.11)(react@19.1.0) '@radix-ui/react-context': 1.1.2(@types/react@19.2.11)(react@19.1.0) @@ -17193,10 +17490,10 @@ snapshots: axe-core: 4.11.1 storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/addon-docs@10.2.0(@types/react@19.2.11)(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': + '@storybook/addon-docs@10.2.0(@types/react@19.2.11)(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.11)(react@19.1.0) - '@storybook/csf-plugin': 10.2.0(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@storybook/icons': 2.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@storybook/react-dom-shim': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) react: 19.1.0 @@ -17210,9 +17507,9 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': + '@storybook/builder-vite@10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: - '@storybook/csf-plugin': 10.2.0(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@vitest/mocker': 3.2.4(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 @@ -17223,15 +17520,15 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.2.0(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': + '@storybook/csf-plugin@10.2.0(esbuild@0.27.2)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) unplugin: 2.3.11 optionalDependencies: - esbuild: 0.25.12 + esbuild: 0.27.2 rollup: 4.57.1 vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - webpack: 5.105.0(esbuild@0.25.12) + webpack: 5.105.0(esbuild@0.27.2) '@storybook/global@5.0.0': {} @@ -17246,11 +17543,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/react-vite@10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': + '@storybook/react-vite@10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@storybook/builder-vite': 10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) + '@storybook/builder-vite': 10.2.0(esbuild@0.27.2)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.27.2)) '@storybook/react': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -17433,6 +17730,13 @@ snapshots: tailwindcss: 4.2.2 vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + tailwindcss: 4.2.2 + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@tanstack/query-core@5.90.20': {} '@tanstack/react-query@5.90.20(react@19.1.0)': @@ -17767,7 +18071,7 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/debug@4.1.12': + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -17962,6 +18266,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@vitest/coverage-v8@0.34.6(vitest@2.1.9(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0))': dependencies: '@ampproject/remapping': 2.3.0 @@ -18001,7 +18317,7 @@ snapshots: '@vitest/spy': 4.0.18 '@vitest/utils': 4.0.18 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/expect@4.1.6': dependencies: @@ -18048,6 +18364,15 @@ snapshots: msw: 2.12.8(@types/node@24.12.0)(typescript@5.9.3) vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.1.6(msw@2.12.8(@types/node@20.19.41)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.41)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.8(@types/node@20.19.41)(typescript@5.9.3) + vite: 6.4.1(@types/node@20.19.41)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.1.6(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.6 @@ -18057,6 +18382,15 @@ snapshots: msw: 2.12.8(@types/node@25.2.0)(typescript@5.9.3) vite: 6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.1.6(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.8(@types/node@25.2.0)(typescript@5.9.3) + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@2.1.9': dependencies: tinyrainbow: 1.2.0 @@ -18067,7 +18401,7 @@ snapshots: '@vitest/pretty-format@4.0.18': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/pretty-format@4.1.6': dependencies: @@ -18145,7 +18479,7 @@ snapshots: '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/utils@4.1.6': dependencies: @@ -20835,7 +21169,8 @@ snapshots: dependencies: queue: 6.0.2 - immer@11.1.3: {} + immer@11.1.3: + optional: true import-fresh@3.3.1: dependencies: @@ -21739,7 +22074,7 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.2: + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 @@ -21768,7 +22103,7 @@ snapshots: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: @@ -21777,7 +22112,7 @@ snapshots: mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -21787,7 +22122,7 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -21796,14 +22131,14 @@ snapshots: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-gfm-autolink-literal: 2.0.1 mdast-util-gfm-footnote: 2.1.0 mdast-util-gfm-strikethrough: 2.0.0 @@ -21819,7 +22154,7 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -21832,7 +22167,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -21847,16 +22182,11 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-newline-to-break@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-find-and-replace: 3.0.2 - mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 @@ -22283,7 +22613,7 @@ snapshots: micromark@4.0.2: dependencies: - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 @@ -22451,6 +22781,32 @@ snapshots: ms@2.1.3: {} + msw@2.12.8(@types/node@20.19.41)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@20.19.41) + '@mswjs/interceptors': 0.41.0 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.3 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3): dependencies: '@inquirer/confirm': 5.1.21(@types/node@24.12.0) @@ -23902,12 +24258,6 @@ snapshots: '@types/hast': 3.0.4 hast-util-sanitize: 5.0.2 - remark-breaks@4.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-newline-to-break: 2.0.0 - unified: 11.0.5 - remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -23922,7 +24272,7 @@ snapshots: remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -24581,8 +24931,6 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 - striptags@3.2.0: {} - strtok3@6.3.0: dependencies: '@tokenizer/token': 0.3.0 @@ -24750,16 +25098,16 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.3.16(esbuild@0.25.12)(webpack@5.105.0(esbuild@0.25.12)): + terser-webpack-plugin@5.3.16(esbuild@0.27.2)(webpack@5.105.0(esbuild@0.27.2)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.46.0 - webpack: 5.105.0(esbuild@0.25.12) + webpack: 5.105.0(esbuild@0.27.2) optionalDependencies: - esbuild: 0.25.12 + esbuild: 0.27.2 terser@5.46.0: dependencies: @@ -25310,6 +25658,23 @@ snapshots: lightningcss: 1.32.0 terser: 5.46.0 + vite@6.4.1(@types/node@20.19.41)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.41 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -25344,6 +25709,23 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.2.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + vitest@2.1.9(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0): dependencies: '@vitest/expect': 2.1.9 @@ -25456,6 +25838,35 @@ snapshots: - tsx - yaml + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@20.19.41)(jsdom@26.1.0)(msw@2.12.8(@types/node@20.19.41)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.41)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(msw@2.12.8(@types/node@20.19.41)(typescript@5.9.3))(vite@6.4.1(@types/node@20.19.41)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 6.4.1(@types/node@20.19.41)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 20.19.41 + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.6 @@ -25485,6 +25896,35 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 6.4.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 25.2.0 + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + vlq@1.0.1: {} vscode-icons-js@11.6.1: @@ -25532,7 +25972,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.105.0(esbuild@0.25.12): + webpack@5.105.0(esbuild@0.27.2): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -25556,7 +25996,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(esbuild@0.25.12)(webpack@5.105.0(esbuild@0.25.12)) + terser-webpack-plugin: 5.3.16(esbuild@0.27.2)(webpack@5.105.0(esbuild@0.27.2)) watchpack: 2.5.1 webpack-sources: 3.3.3 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 782ebeac7a..2bfc1066a5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,28 +4,28 @@ packages: - tooling/* catalog: - typescript: ^5.5.0 - tsup: ^8.5.1 - "@types/node": ^20.0.0 + '@hono/node-server': ^1.13.7 + '@hono/trpc-server': ^0.3.4 + '@parcel/watcher': ^2.5.6 + '@phosphor-icons/react': ^2.1.10 + '@posthog/quill': 0.3.0-beta.1 + '@radix-ui/themes': ^3.2.1 + '@tanstack/react-query': ^5.90.2 + '@trpc/client': ^11.12.0 + '@trpc/server': ^11.12.0 + '@trpc/tanstack-react-query': 11.12.0 + '@types/node': ^20.0.0 + '@types/react': ^19.1.0 + '@types/react-dom': ^19.1.0 + hono: ^4.6.14 + inversify: ^7.10.6 react: 19.1.0 react-dom: 19.1.0 - "@types/react": ^19.1.0 - "@types/react-dom": ^19.1.0 - hono: ^4.6.14 - "@hono/node-server": ^1.13.7 - "@hono/trpc-server": ^0.3.4 - "@trpc/server": ^11.12.0 - "@trpc/client": ^11.12.0 - "@trpc/tanstack-react-query": ^11.12.0 + reflect-metadata: ^0.2.2 superjson: ^2.2.2 + tsup: ^8.5.1 + typescript: ^5.5.0 zod: ^3.24.1 - "@tanstack/react-query": ^5.90.2 - "@phosphor-icons/react": ^2.1.10 - "@radix-ui/themes": ^3.2.1 - "@posthog/quill": 0.3.0-beta.1 - inversify: ^7.10.6 - reflect-metadata: ^0.2.2 - "@parcel/watcher": ^2.5.6 ignoredBuiltDependencies: - msw diff --git a/scripts/check-host-boundaries.mjs b/scripts/check-host-boundaries.mjs new file mode 100644 index 0000000000..1b0f0c88d6 --- /dev/null +++ b/scripts/check-host-boundaries.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +import { execSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const ALLOWLIST = join(ROOT, "scripts", "host-boundary-allowlist.json"); +const SCAN_ROOT = "apps/code/src"; + +const USAGE = `check-host-boundaries — enforce that apps/code stays a thin Electron host. + + node scripts/check-host-boundaries.mjs verify: fail on any violation not in the allowlist + node scripts/check-host-boundaries.mjs --init (re)generate the baseline allowlist from current violations + node scripts/check-host-boundaries.mjs --prune drop allowlist entries that no longer violate (after evacuating) + +The allowlist length is the number of files still trapped in apps/code. Goal: 0.`; + +const RULES = [ + { + id: "injectable-outside-host", + why: "Business services belong in packages/*. apps/code may only declare @injectable in platform-adapters or di.", + test: (path, src) => + /@injectable\s*\(/.test(src) && + !path.includes("/platform-adapters/") && + !path.includes("/di/"), + }, + { + id: "feature-ui-in-host", + why: "Renderer feature UI (components/hooks/stories) is portable and belongs in @posthog/ui.", + test: (path) => + path.includes("/renderer/features/") && path.endsWith(".tsx"), + }, + { + id: "cloud-client-in-renderer", + why: "Cloud-API logic belongs in packages/core, not a renderer adapter. The host only carries transport.", + test: (path, src) => + path.includes("/renderer/") && + (/from\s+["']@posthog\/api-client/.test(src) || + /getAuthenticatedClient\s*\(/.test(src)), + }, + { + id: "router-with-logic", + why: "tRPC router bodies with real logic belong in @posthog/host-router. apps/code routers aggregate/delegate only.", + test: (path, src) => + path.includes("/main/trpc/routers/") && + (/\bfetch\s*\(/.test(src) || + /https?:\/\//.test(src.replace(/\/\/.*$/gm, ""))), + }, +]; + +function listFiles() { + const out = execSync( + `git -C "${ROOT}" ls-files "${SCAN_ROOT}/**/*.ts" "${SCAN_ROOT}/**/*.tsx"`, + { encoding: "utf8" }, + ); + return out + .split("\n") + .map((f) => f.trim()) + .filter(Boolean) + .filter((f) => !f.endsWith(".d.ts") && !f.includes("/generated")); +} + +function findViolations() { + const violations = {}; + for (const path of listFiles()) { + let src; + try { + src = readFileSync(join(ROOT, path), "utf8"); + } catch { + continue; + } + const hit = RULES.filter((r) => r.test(path, src)).map((r) => r.id); + if (hit.length) violations[path] = hit; + } + return violations; +} + +function loadAllowlist() { + if (!existsSync(ALLOWLIST)) return {}; + return JSON.parse(readFileSync(ALLOWLIST, "utf8")).files ?? {}; +} + +function saveAllowlist(files) { + const sorted = Object.fromEntries( + Object.keys(files) + .sort() + .map((k) => [k, files[k]]), + ); + writeFileSync( + ALLOWLIST, + `${JSON.stringify({ note: "Files still trapped in apps/code. Remove entries as you evacuate. Goal: empty.", files: sorted }, null, 2)}\n`, + ); +} + +const mode = process.argv[2]; +if (mode === "--help" || mode === "-h") { + console.log(USAGE); + process.exit(0); +} + +const current = findViolations(); +const allow = loadAllowlist(); + +if (mode === "--init") { + saveAllowlist(current); + console.log( + `Baseline written: ${Object.keys(current).length} trapped files.`, + ); + process.exit(0); +} + +if (mode === "--prune") { + const kept = {}; + for (const f of Object.keys(allow)) if (current[f]) kept[f] = current[f]; + saveAllowlist(kept); + console.log( + `Pruned. ${Object.keys(allow).length - Object.keys(kept).length} evacuated, ${Object.keys(kept).length} remaining.`, + ); + process.exit(0); +} + +const fresh = Object.keys(current).filter((f) => !allow[f]); +const evacuated = Object.keys(allow).filter((f) => !current[f]); + +if (evacuated.length) { + console.log( + `\n✓ ${evacuated.length} file(s) evacuated since baseline — run --prune to shrink the allowlist:`, + ); + for (const f of evacuated) console.log(` ${f}`); +} + +if (fresh.length) { + console.error( + `\n✗ ${fresh.length} NEW host-boundary violation(s) — apps/code must stay a thin Electron host:\n`, + ); + for (const f of fresh) { + for (const id of current[f]) { + const rule = RULES.find((r) => r.id === id); + console.error(` ${f}\n [${id}] ${rule.why}`); + } + } + console.error( + `\nMove the logic to a package, or if this is a legitimate host file, justify it in review and add to scripts/host-boundary-allowlist.json.`, + ); + process.exit(1); +} + +console.log( + `\n✓ No new violations. ${Object.keys(allow).length} file(s) still trapped (baseline). Goal: 0.`, +); +process.exit(0); diff --git a/scripts/host-boundary-allowlist.json b/scripts/host-boundary-allowlist.json new file mode 100644 index 0000000000..32ecd733b9 --- /dev/null +++ b/scripts/host-boundary-allowlist.json @@ -0,0 +1,30 @@ +{ + "note": "Files still trapped in apps/code. Remove entries as you evacuate. Goal: empty.", + "files": { + "apps/code/src/main/services/app-lifecycle/service.ts": [ + "injectable-outside-host" + ], + "apps/code/src/main/services/auth/port-adapters.ts": [ + "injectable-outside-host" + ], + "apps/code/src/main/services/deep-link/service.ts": [ + "injectable-outside-host" + ], + "apps/code/src/main/services/encryption/service.ts": [ + "injectable-outside-host" + ], + "apps/code/src/main/services/secure-store/service.ts": [ + "injectable-outside-host" + ], + "apps/code/src/main/services/workspace-server/service.ts": [ + "injectable-outside-host" + ], + "apps/code/src/renderer/contributions/app-boot.contributions.ts": [ + "injectable-outside-host" + ], + "apps/code/src/renderer/desktop-services.ts": ["cloud-client-in-renderer"], + "apps/code/src/renderer/features/code-review/reviewHost.tsx": [ + "feature-ui-in-host" + ] + } +} diff --git a/scripts/refactor-init.sh b/scripts/refactor-init.sh index fd05a8be2e..11c35a39e0 100755 --- a/scripts/refactor-init.sh +++ b/scripts/refactor-init.sh @@ -113,10 +113,13 @@ Next steps: # or just the desktop app: pnpm dev:code 4. Work the slice per REFACTOR.md "Per-Feature Procedure". - 5. Finish per REFACTOR.md "Agent Finish Protocol": focused tests, real smoke - test, update REFACTOR_SLICES.json + REFACTOR_PROGRESS.md + MIGRATION.md. - 6. Before committing: pnpm biome format --write . && pnpm typecheck - (Biome formats REFACTOR_SLICES.json too; commit the formatted version.) + 5. Wrap up per REFACTOR.md "Per-Slice Wrap-Up": focused tests, real smoke test, + pnpm biome format --write . && pnpm typecheck, then update + REFACTOR_SLICES.json + REFACTOR_PROGRESS.md + MIGRATION.md. + 6. DO NOT commit and DO NOT use git worktrees. All work stays as uncommitted + edits in this one shared working tree. + 7. NEVER STOP: immediately claim the next highest-priority todo and repeat. + Keep going until you run out of context. Do NOT set passes:true until acceptance checks AND a real smoke test pass. EOF

3Xn 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 HN@xNA(QU7k diff --git a/apps/code/src/renderer/assets/images/hedgehogs/feature-flag-hog.png b/apps/code/src/renderer/assets/images/hedgehogs/feature-flag-hog.png deleted file mode 100644 index efdfafe05c15cab2579120eaea956c5ebc3eaf5c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36683 zcmdpdRZ|>H6YjFO1`qD;1c#sjg8MFudvJmT2}xjq0KpxC2AAE%S=?QMI|L7I!5z-~ zovZU3PFKzJRM%W|S4&M-KNF#&^&TIG3I_lH;H#=A>Hz>i`2Q9b>VJ}rKH;AK*gqW& z1Ev4?|4XQ-03Z;E@*e>J|91nBF$uHEn2VW2_&{Vby)6H`jD?OygojN{jK@Mj$VX2q z#zv{YMXe~sq2vMA3hdJ6r_(nQ14YdkDhNPCSWHqjtsJFoq}Uy`#9ifhJp462DG7d2 z75)ZcghuIvSSv^ANyQ|6jCavUkY!9amd|wM%<<9wRpOs-sZ>x<08?X!yXsd2nN$Th z)&&YQg?Y9_+jjmC{yj1@p6NJ|EWYyDU^V;gnw8Mst<7zO|8BVLUZKo!soZgU>UoFf z<>lpdtldr1_q*Zzmtp^xmzVm?q_O{OApE4Kr4PWR;ghk7sF~Qgdj20_@itEZ05SKf zit+}2OGjq7`pYV{_}a2=DQD3+pnOCmkFB*$bQ%cYyn3BA$mj zX@zC_VWo{4GddyG|K8*Nvcl?`YqfYcuI$wBMwmE5)}!wFE2Zo zFoGY6C167MGp6D^QPAe2(-*WbSFEOQiaHBlOIl~7<0rWT3jQa@yfdoEIyr9hZF#`_ z5*{A@@POGwax6L}OG`&{bb54h)Zh(SSzK8W*%P@|PVK4UWYb(!FZVgn^a|&qaw77y%wkjoQ}vK$6!|7g=?(-FyGGD8vcgZ z1za(kbDV=Fa^;ob@6lqNKEC)aG35zHpe`Jf_BGk&rj~uiKTRaR*-I}rxZlB-k4a{V z=gz*h9BEQ^Y|EW&Qwf17D^r0Y==+mqO?UOjQI#~_>+@^T;nE$2k`r$iMi=>4Qc>W7 z$3cM%j5eoLMG_U&nT!WJ6-%*h;BQJqoE(fOc@82sCT|Kle+qtZbo0V`Zmir_S!}bS zN+S^Le(zh^K+WCrJlSZUl3M%QRj}9t-rGTD_mbWAZMJBW4&9+|zhf!0n}U40@TmCI zAsww1zxs=xpXHKqzn{a>?+_GC#@WMpxed+n(*)bY9m$5pE4#N0FCJdf)88I+D`kt< z4)!0NLV~?ZSd$uJo|@gRs;~%suc;+pHxQSJ)gE#Vog5FbHt605zk7*SNDV08dHB+F zp=?}tB3w(1XwvNe@t-oNO&h<`&UE|Ae)(&kk8j)k{N@PA%|Op50-So^Z_^H3hYvqQ zyXx&UQ{H?2Tax_4eL%$Pl@?7THN(2iAw5;gW?&IDb?6M}8Bq<0mdZELoFtGw-nNqq z2oqsU2wJxokd}=Sa&x( z(WU~W0vD5>9XwRmvi6P?mgk$tJ1?mqu5ox zm+LHQ2V8NUgHp(zd0viz-|;IiC_U{Rq_F;HSZC!=4chu;djO+FL zIM%y)p)R}Froi9R9LQ=M^P|5qUZz^qg!v9LTwf2ztftqm)3{3Ouk{8aIq^pP-3kF@ zxalOC=j`*u!a)|&iB-5&OG)RYTA$g$R(46(oo>@A1W%Hw@9MCp1OJ@fe%|}(ug01- z+{#adHTzg085c**0@tnHapQfe-H#=1!o zmLI2z1D%gv|3Crgwee&#((2#t?DB~|WDc`>tuWR}ufx_Ee#f-n)@Of@dxWdhD#U-t@Dod2)@iwR{TwJ=R>#8*P819+oM&lXA1; zG*9c)PIp0rBa32~@nfLX56J>uRUPiCVMjgYvZWcDyRVu*KcxQnnDIoKQ$@zF#n#z{ z18s{SMAT0s`-}qlO<~?v zOq~{~zd0JDGGQxTcIB_ta4)$ku=x1iE9WJ=Nq}Db#fz!ET*;KqnCT&LN1}b=&8P<# zGw(On>gK^rIR)~&cKH7^8#(ev z+GhI5TDM6NEIQYpx6?n!E|cBRBu4VBBMibTV0! zG9!6EhB4ddeN!-$JnLpWS5m)32SR-%nY>gw-)zj8WgN6e6`}dv4H0_Kse~1t@61jf zxrWDGr7Fx(BdkK`oXIvfPEoak(isyeWB3m$<|~#>iVxm*kqc5AvY9f()RVT#zk)4p z1%3W4MKt~>-@pZ>2+dV?0nYszCdgwx2d!Foe6_a=I%LW|a5m#YOPulHN@NDF^FRNY z?Jftr2@DVZt#Q)3l@=$MS+dr?&z@^u1~V_5)ppF&a>+9OY3iE3S6q!&p8{MXW3HcN}u? zN)6UzQhUj4=%HJXF(?m(&H^)kzbG+afqA)tmj`QydlwVl!;LE_ZnX?1aZ%^!sbQm+ zpZ>an@^uC22yh8^x>)zxF@fMpZxJr{j27z+x}3WsWTVM!L&hFi2rHIx*N^v=371EO zvQZ(|9j!uMz5{5Sv#AmW%J72-j_7Tcr{V!gkeC?j=MG}D+z6`Rny(H}TTM~3W2^0a za$mV82L^0nhA#qVx4`@t7ir6m$+yBv>s-qu3xNhjo5oxL3{se{*V) zNj(+xZEkOG^PhM0Er6m>ud%Kn%pSRNL?2(Y?HY?a>JN_OHz6tcCB(t1!!}C!_yZ zm6OX^po01rZT4Th9Ze!osNFR&a$cvrGG092Tl=2(?Qf^o8l9TJwqJIH!n_+L1buZj zPy@v)O&gyQks79|QV1|jDj;5Lj+l)u<-#{Nd80H-UL_UzZoYcO_6;n*B{FO?TsD6X2$Gs3T__#2 zXTz4w@tFIa(JL>m7geHgm$G9IF!F1Bgm<@pd#ER633e&B$KNJB?s)fVAt+J<1MV+H z_#VFEoJx9k4}BJj_i;Jf zL_?)CtfHQqSHY%t5O_21RQkc4_FO2qjt_$%i+;VUQaZO(Ipjq-49Psx7-KOjGj{p+ z?4z5%XblOZ{ggbgHLSoO>4-d>%4f*Z+n!Vk=Z=2@p8(6F=teJMChcSA2ho3XOB9hREw#<_3$q7+DWz!dh(X7k8 z0J=J47D0xxzwq1>R=*;}MIwDpWdU>2x-v6(f54yAc*@B89y3sL{ZTIhB1l9cfG99# z?Y*x|$ZADcNryGi3rL^xo znpU_J@}C`V zX@JRZd+B{zM`@nlKPs4uXu~rzKhI^atgJk>J*{LLGO6b^zLXe6t;uRgi@#m4yN9WS zag{z_Sb~fnL)g4?owBo@l#hBI?$MH)s}IDod3P^9TOeEnnAC}tma6&duG3p;I|AMP z7Mzd~NUHt5KP#l>QeQN+M*G+>bSi{V99t%X(VsVT0BbC}EKOoCIi&@&qP-r4cP|ay zTB{uc)N-={Mz6{wAolG9u7 zV-X+h2F@UNTE7Jx`{o$#`eJ2PEQBuoL5?nhPAXFl`K3YB;6eZ@faHvS(-o|zz>GD?{gM;;WY?3y2z8} z0eZckIoUK1ndpF(XLawZjv1uZViFO79=CUS(hvWB<+h9a+{p^t@qC}B(NL)`;oV_U z4}UC?l0U~Cp^)Xw)-di8<>6KX9=(Fbep@0yYha+KPfhx}QCSVghJ$5K(UTSeADY1C z2{|7L+YX(qAW-EVF$AmZKYiyun>ihSpjGP5Ug`PJ!mV(1KtqY(V%Ihy1~z-h( zwAY)Nm&?f@RAaY$v#-Rb)Rpm$vMn(1$i%_*I}=xFM68-4h%WdMKBl1LjERQgKQ4(C z?;b`^I$u2LaCCZlRHh4K`elj%-ZZ`O@g0%xsI9!;U4M+Ux*C9^9-W-DQ$kGg2`5BK zyqClb-iI0jcorzvjfNB}s3w#HpFgCqcX)k0U-kXA=9@f4#wD40ogIXy5gnle_~=+^?o> z8LLy5)1`!VIB-~}5E*-rR}*Yv8Aw>Z60_6{O2uMcWr{~8#S1kDXdK7=rmMuPHl#|i z-=3dIs&~B63a$5gemFIpsC;%fb0QjOXpjj~AD#J<5qP}2;7c~MGHRpD zL17soU5{(odtkJ>NXaeKL0Bi>;PCcoXeeCBni8_e7%Oy}vo{&(J$az(orDYZmigri zAtZb)z{NrUb=Rr3VxDQ~U3p976ftl`&wA}At=1}X7K!+qAb)l6h`DVf7!={U$9Hr> z>#)f(gQA{yZg8-(w^O=y(Iz8*Bq3H!tSB8|dwjQyy*eouEMAg2u1 zesOCB!rUrlY54oQb3qoJ2fyh;*+X;Px|#s) z{2&x8ba%%Qry&9Rs}GzMyS;II1D)zxjJ(h@U_1v+=N~RaH_EDW<$D|Zno%;IA|-Hg z&U~Q3^41p81hZn0Ao1{sS{%GWPX?Ddc6A6N&IRM7T;|3f{QSZx+@ADfQeu3K8$-eM zJyn6v4E~7(abOY;`k^3sH(=Ab$$8Ws_hIDq=jNpkjx%o?fNA=vGh67r2O4EzN;=(g z6b!b_7|)+1%}6&;C81IKJ!?V_Hbv4hZ1Uc?Xicc8=^mx#lOgNj(-vDz_dENuW9Bw? zaP(JA3ZaC~J_pY*v)h1O0)|vj9*2Pu zuI12A&{$$9plmVn-0u319oZ`ubfG$rqe4iwHf6;3P5A*ZP^Hb{mek6qN z9|^iaEUy^?C=j9+ZL)uJ!TRwkihp&Ee?ST{*n^nji8)4tbaTb4w}?xhuTAdH1*4>7 zzv`HBBkDI-C)LB|U&HRia&X;5gna(+Gk^y+Pv12I7kOMAtkMHSs3snrG-#aTxN_^h zyJRnH(R&}Coq25PWmIh62NZaHO6te@ryhU_i3wOBB6q)cVIiuiU3QosRlbmu^xo_fEuP0CcJVVASD*4CR=UF1Aa_@YhvtDT;69Ef;-a@=Pw6i7F zOoksq6J8G&$t8=x|NfZ%fHtn1&ZcONPKD)KviWyJz^}N^3EW~m3%$XC#EB$CnjMkb z>ZGUu`Y5400JvAU{bvci5plypBe_6lVzl4A?j#D{_yf&TXU}~9Ju9j_!8(6(+k76J zwCSR&f+v~FPq1LO27DX-R&(sEUtT+k5A@}Guf_~L!^4!~ZBnxC`J z|Kwv)hvT4Qt!QweC89x2gmq^mD$Xo%Rbof3!+0P9T+Ams*? z+vrD5wTzIZuLa_vVDG;ANX;Vv)X+G8^0gY`^HYrXoZHY1)~`b8I%YZWhPmY}S}x=X z3L3}=nMIBaeFq03`r&1opM8|x0eq5QXFLz|e7tzH z2LTQ-!F7MqS~$GN!m?N1Irev>eBLd{2pX0@aBqf?UiG#9$|%xwQhPI%P-c2Uy5iX| zTy<~01#z74|9bRbC!%IAt{(ze;)MJow3NBo-;(6;MkEb#8HAdyLw3D-sjm&+K@1f-oBd$Zc?inZ?t{6dSn8XEJRFEQF z=sF}cm75D*q)mnh;HJyr^o4t=)msOMsCFv?xaa>UV2@Pv=hDkDLE*eobMH6W6dUes zB2hUYO64KLldGg)JZ5u2=@=YN z2-l+q0H7o2>>Nmw_ncd7%| z4Vx4GrMJh~Wm9fXowL)n?;?*>e;b-hERQLbh)s|FF~iVMy?b^|oXfR|QRl#j%vk0; zZK*ni&K;gi3{8|LUcT&A?Se51`n_!#9tPL_Sa+yyu69mY3sVN{VO9YD^@Em)Mcz># z6c)Nc*);>`oy~;i!pCfol=vvNjwu>;OU*oHL%RV0P4A*yepM8Eg_mCgXuZm# zLA>>ll1ZiT<>SEMiMm+D)XAQHPP+XjKiR%HPc~54n^AS8VxxeQySf3xL&R00b9uqF zZr+a}cw*3 z_XA6#zO5f#Z(TIx}sElL`ni_ zQ?0yd*X~Q7e9AB;lw=R)KQv2@L#dDwM95H$0A{2Q$W_qYxn+5r6-g%T!qWC{F&A zmW*O*G;htMm(2ck59$1RfyEVele?>kIsv<|;6N8&{3nuVt5J1)&quxLgA6fp+fg>JOaP`eD!&eh@+;M4j@Ry{RzR+k}-)JGzPEA*t)8YcD(wMqDsp>bMGOB zN#B|9=OQ;O7@MGC{Yi{`^l-WxP1W@ zXqjtcH^X`pOo>leWl|zTZW(vMfU{`D<5Q{3%-5@Qk1$mysVQ#-LAK*rOIcf^Wa$h8 z?^2Gy%d%CuA_bMCTU#5E4^Cz>6||CM^CLTuB3}-y6;MGsf%LE16IqQ=@oD}z)G=WW zX`kU#gVYzfU-@Jo)Q#>UCA%CmbceLP{Yk&eQ}loW14{>w`1@_XCC4q5d zv$ssyvl|l|%GLp@G608V z03l*O*>cadFyWDh`R_U%T7rO3dPyK41&(;HB;lKuit2G2=t%U;OOm6>~J+(_+IFk2d#q zPC{K|ENE6}96?-4@;DY*5BBc^u_;Bo^F_-ZIx0oikm^ntz>yS1z-M%N`p4|0Us18B zY!$dsJriEE{FAnZ9^H0jH4K)~-;aFYDs8|X*>ZY1*xQ!wT`sU;g9i9YljQ&{tcm{` zTZL63VtW#D7TZ2S&Wk3XSa=YWaX$s32%T~nu$$_!BrbW!2{=AmOMklCT_8h8B{5c$ zuik^aSN+>PzDSi60PWbWArpTI@h%;b)$`fTve>6T=32E_=^rA?XR!(JBDVwXjkN#x znM4MeMK4P|zJGqWis?m95m{s`0Ytz2-e@k|ZB+ltrSt0(rhq|;-8A7aMrHrkcspDO zx)zxzQ)Y`Sib0p2(dhx~h{nIrtQ{|_>FK$#9~ot2@1d-3(co15HygJMDv9s}NHtr} zpFJ2ZRDVn(C(#M#C+MA~K?s}F5fjCW;se%KOb;brM$J4l65SdbkD2*`24^S<(hC1~?}KrNrX1MJVEDIf&R$ zv`Ixl%mlHPF|;bjm=^{e9$9oWX}J$@(D%!OL5TM^fF=jinAr{o1eAq}e%&aW#>9!J zgMd(5>&@7#s#dEc=R5dnQ*k)zZ4?h7g!C6Pc`x2^VJX_+QvWAb<6_r7&(WAFf7Sxw zj#UC%?nhxAP3AdeLno}PkF;b6xGzASRdG+ce1vG>+;ixA_Q+8vnZH zT1rDZrEF3}AbB>=YmFayP*8c{PWS)|1S=#I9c8W=(#9nivG(~^h}XjI-eSqPQ?!ao zNUU2D;diFjhMNug!y52u!*FG$gsFNI6mb3PYpMNaSWs(fk!oGm{?|FH$|M0xc~tOl z*H!QjxCy#K3g0GPYK0Vr8j8oLvjsy9TIsGeVnyb$=^E{{paBSnB%XMTu`z8vq;MA- zQG)xqz!A6KcZ^RoWfAs?A)sZ1V_|}S59L)VtzHusT0G|8#sMF~s9rgrZz~S*oyy!2 zjyw7W%hl$frMtT(;q^(^lNT56awAg9kCX_U&))4EvDcw}u`XZk)<`9I(e5{2CO`if`Bui}r6ekA(00VW44&mAyZeAzbV{Oejth14hg}~CJ+SwX(i3(<%oiqqt#+0Qs$moer1+bG`vabl+2;3wi0#1! zf}6Fc=zeDu@l`fR-U;Yml9Ro~w z1aGo4b_~))38yqtdwh%7^Xr5A>58_~pIRo2xeHhCM{7}s-ya~yHQ#od<~XO`T`69= z53U5imo}lqMnG<3n;Zlpoh`jt+AYX?4da2A9IV>`v~9v@|>lBj$o?T51a;3M}rlD)C}tM8$2yFSBoiZi@vUZ%I{Te<@O9a_N#x} ziKclP{zH7?E+J#5gPtr=-9e`}-&uxdcui@RPyaUaIv#JfFY5^$^nHfq-1C!sN>gk7 z0R~?@(sEoN8d8)$!}YRyujI}}hWh>I)$HG2ScPC=fy}^6=UQi92{0z`@5P$=);|mS z;(I$jX&4R~n7%%6u#s_b8_7N(C5;D?V#Xk+7Y7#P%4%7 zb<(IgcfaaIq2lmPxL-az{&{22bY|Cm{7mR+!r{T{RSqQ5NmSG*2obk>7ko#|DA*D# zGt2o(I}@!~s-;EhG(dV8HNRx_&Uuo{2Us%$YaA7AeNNq4T9N>dJq`Gmrsh1`+b_!< zd=#!$pe@D6>IOV*DiYV@pa(KU{M<1vP}T7KEY{W9J8W;nLZ3&=u~%GC=h4`4!>vFH zCG!Jw&!yzCmfU?gJ5BlC$%D+-&!k9w?e$CK(1i5@UImMcGrXUvebwPQLeme)j_7xkgp73@X4^Faf43?aA zWx8q*ib~V5gP$Xsv;cOA#&p{d&B9Mplzt2?))y!UGG=txaet6}S)vuva^&gpAmzj4 zo6cyYJqJdrItqYvNVK$~8*$(ls{iB)_vy#U{d&9|e2k<>B}@2;V{!yZJtkiA-T-5& zfIB0D>StYuBIV_x73XFuFDD|;A-_Ea9 zo{q(?lR~|_@erSWpf8diJqELOhp?zxcj90f+#FU7px7Gb6`rQyYf=_gj8)Zb!;#Sy zcv?NPoWC<^mtHK?h@gj6?kSi61_SBOhdma9a5*$N3Q_gX_?g-_#6{zfpDd{5WsuP{ z905A@e!MzM=6({%5{#s)U~tXHr^l~KpZv{JJ+g&zK170U(Eu3EJBSa_GAUn357A~| z?nwO=Nj-@h=sdvl&4R)bwM(sbS&g9*&IpzcE>8FemkCN%JHhu+PR%5Z*^juSPUB}y zk&_n}gVpX-DWCtY#kyiv<1N&@fucG>vw&);c@x{6Uro4PfwbGhwWYVdyQwcu@|`vY zjEkeXnc~d7wfrw6eqiX5YC4>9eIM@flA-TpeIDJn==r+>|A9w5@I6xNnIDfvnvvFO z1RKR%dcq1lLLft-{~-9|x>Pab${VHASMGo^YY%7!Wqpg*+0O7=1+|m~_+MI@uO=Ge zst^j;#Ln6g$@MTG!I8-233*+eCeGLQb`EymI~ReXU^5Q^ORW|(@-_1fnE6#o$;)DR zQtjG*tu2;*ZeguA(~R&W;SR{!Z&YzXYtK_b5286;CLW19p{z^MsAxiNgqq#|AL3}K zhAc9W{M*{;Yb0HrXqrG=s5^2weat9agj$cd1ia@*V`Q&eit>Q-@?z^%;_u_*xk;jG zIWzjAR9OCWvyP8du4e9n{snHi$oegUT+zt=aIw!IBE?o5EyhMzPo#pGdg5Uq-c_mV zqbKY%WNrqT^G;#iS`bsWPowvM#=Z1w^(*JJcSt_1wKOdFhvfyDmxY@r4I}EIt$1<5{)@Ic!r-=iwS2_EAP2^)voYx}4aC8`j(=t#>l&2wTKG^{*H9 zfs-9M?{FYUK7+7?i2GPLyVY=qy3;e)T64s-be0hzi!T4C#=?)+vpRWf4?L?+7h%GgO)*Xqg1>Z*)Y{%I@s z(={RCc@Y<+i z13IWJ`YwfoNl-aYVJX>XASWO~*o~yt4Q>{1p`g_5PZ)kr^jL_=4$idAP z_b;tbIH=C43a^r-@$r8;6MiaLyD;;nK#_W?4mKcz;lbnh<9mcts5%lUpouzhrV+g1B1-Km zNwVSvj)4pVhA8g#?I_%1__?GP7W5obcv3NfoLRVi_ewac4^}M6NTWL zxg?2v-7kMNcuj+*m`QFugfnNDrcgJ0qi$1fC~flauff09D#Zk6@ec7=i5xb;jqp@Q z1>2K>*^>wd3wI`Bp#;k=AJTCSP0*?|Tjo%r_>ive# zK@ZYlqf1DsS{#~Gs4uI*=LqC2*}k?zC^%Rq1BNqYsdZ=j?SFk=9{oi~T>_|UhnR0BtzTNJITMdwC!%NKEaJ%h} zZZM7e#v^$ubsaJw7aS^Js1fR|&I|m>#VQ9+xA9ic?#F|xa139U_GBd2Ge30Vw_pVDntJ~%mp+`B;rd05pla>)`{~9M7$j!s6&B^dxk2f zp(gZaL~!VMBkCEAT)ENq)ZCK4g!Aw~Ay+DmYDI`hSNP!TGC;cy>Yxc6ndi^OG;&yyu{I6*BN~Uv_adNo>*GzGT-(Slnrug27-Daz|%{17sEM~UXq#-|2 zl7QFv!}ZA!Bf0;9wE%qfIb1Xr9GO@_UXC1e^2;Fq9q}^tJ29^aLi*os^BfUUEoz zYnte@T~$Xfz`ehnuP{q&P}+)Z#7sj30vm<%Vk91!rn$*(S<@U*|8AGGcGWW@D$@~4 zjG(pw#sI@)MzXzpS-%6wqX7pBvqalBhFzZ+`+u>}m?Z|<*S>)BPGJu3A4Y0^%<|`* z?ogrpbES!4lJZ7~`XWq=>YIEg6!uD%pKd-v1h?|lzq#7!V{6__lV)DEo?R{j2@NT| zWaOl_q{7g zlUs>v5ici>2nt>cm{y=HnQlSG9bQ)lYo7Hi#d+|BWgS?PX$c{~$LJhhh-QY)$c@WD zqNiR-gceJfv8X)b)>>U<{gxrG85{%WPc9t?>L=BaCl#Z9KfD!KA|$(9@BK9F4qNUC zJhDTEh6un0+e_mcs=LO^3|LloeH@ieW}ZbM)%l)tFPh<%d2*ks)@0zn8qsl4ipbC(JYaP>8-2a*~or? zV$2wu_T;*!^6@2+`d-l{B|l zxVVqb{wq2jEsRfLka1Sp4l;cYg~ZW6kcS_iRhvc+4A@8C1@eqNnoMU<&W>0lw(b3s zO!Y>bh%l-}XhL0Aq^)6VvnAGirI7qvjthLE)Ojfujdokc*Lb*EL|64P zUhutJNTsY*$BVm9)-$W#S|NcRS#Ij*`J&314p{zbC`S)Cj|{SBGJ->XcReESP#}z1 zFy#zf9yTzva10&;u>}f6WPL@tQdu<m}VccRUdQyGS3qXj+-aC=vsQTP|=@-e?az4!5ST1JXIqR z*dt$1<*;a+7ga)cWOiCXt#cfKm0|i%@TK81!%A`P*qZo1T=|F7K-CZtEi8dP)tsP- zR}=yRT*Ah>Jq#xz5GA`BYm#)5cq?Q9yC1_6_JSfyUzu*{{bA)*wOUzOlq6|zsDX9g z+u#lSHWp7TRBHC#P-D5J045P*X&HI|tK4!i`9B;IHi$JZ*m1h*T{lQu<4+%X{>3u{ zS9xUr)q@@E-HD&~^~?FZ1$9u04w^H0t1v4#y=xe~vz#XZXIi$zL+@v)3%9ZjerN!| zDZ{vvo90kHX;(MR7!j5%R!3+LIeR?qcsYr>cdFMashr56aQ3;hDk*&ENY&;YIG_SQ zTB?s*#?UgtD9NOZrUTiG)18ZB@B^es#Ge0ua61a_|l(G zkZhF#6bEtCzo44=H+=^+gD+9}2h{=?!Qb#H?0e2Wr9KN~&rY3lcI~gk|0s8MvREuw zkFV*rZn7eT_ENACb!$_FroKxtZ;Tf+-p!o~U#e4cV5@Ejn#FeX`cjcevK~2sYo84z zn8(z}yZ1#|(MouWwE=5bG<_MCoE`CWs9dE_qFd=%^bBFJ?EfWZJE@emsRlY?C4;mj zJ0R-SY(yTRu2v#w^7g%7BU8MAkaZDOou}HyN~IReyUNsDh=J5>T1)lEa^@~c_#S{q zodtDKbdf{%{b#+$#P^Lj@B(XvOnuZ)@imUx4mx;grxR19PcBSM0I85}^3(!pyf0_> zEyy~9C&1*c?Dp1Any*a$#&`qCUkZw`fet>b@xY-ahVQhJoPf=uAqP*1gtDM?Kjqh0 z+??-fif5Y9I}X$!yprGZ2rG;!4PJhK%W6pW`(D~Xq<5tVV@mL++&obZt5U0crmreG zf|LQ(Ik?$T@7DhW#coa@J)C?9Q4sy9Fm%3~;!(xJusNW}J0duFRLh%lF5DJ>N^shp zrbmZr2oLq%Jt%>f%Kq=ZGYP=O_DhVix-}{uuqIhL(h?)<7`Yia z2hK66PBtPXhRGj}vy_rYC35ZIs+7)vOrHJjy1(-MvBU3$k#FS?i{4Tir)@)u;&kxa z*0HCxNU;%Vf*%(JCp&Xm2ihIo8-k8;*xQ#Zi=NW*RLuJX8WO!VopjDpeO?uF=Nj&iJxNR_) z3LMWQN#>ES3=qbax}84*w~6NrmBdG#O7H%zIq<@&n@QF81}!>M4?Pzsn{%&oAFM8N z{)sa>Uw7ajhDT7l)eu^yFelI!MKg2Ah#{Y8YVu?=VVU(cKJXE_4qv)E_C1!5hS)^{#+} zr7~VYZWCReLY-uRWZYwO)5v#$rwQRYE1V5Q9_ySM+DUVpXq4;yOZ@93IDbhXwZz+I z$2@s4<$hK4s>|U@9?6|m&F*u`|J7(QFn_$$SU^~;@boM9>2gdm89fNO!9 z#0Q1BVnTwXsblaTVop?kc1b^in}`?nw-UnVN%qBDVOZ*>Q6x7*`(&u)Pp-u`iD1ng;~}~X`8BQhqs0!+ujR^~`|wvO z4~<+{@s1mFaulbmp}VTAV5AH8hB2kqIz|q1-PnYAb%Wr@h4TA03(Bqq54j4EwW*d? z-4Fx9cu;Gqwb*rZP&XlW|B)7@aP{ibdB=q$4;O{r`pADj*f=3%xWte-oUG_N18-6K z|LP*h!4JH=Z(`*?8(P@6$?Ix#OMYdqSQ#jKc=N@*#;(Z3>=T-j7i))c_c?*=lZE+T zD$)=sMor$=cZ8Pvapu6f%|lr(IQaaN91Hf>7E^YIHGKTHUNyp55O(Mi81({>&yy+)7gtHTmt z`LxwIQpbmMq2lmn0QXpf;r8dP@i_L+kqq+1*O-as?riWZ-o`;Kh82gRxAgIxm^XGC z>)9d7J-73kcK~KHS!w51f&ZY$Dmt+x(|4aR#x#6GO|FCydwQjk6`6-EjCP0qH%Gr_ zWKyO;JR`>(ad?9SXEtQu(C%-=N0jm@zghUJ)iU{{X?G>RCp!yTyC!buVb=e|`0e<;CTm`@@6@S>;m9-l1s+POdk+Y7#HPiV= zHCvw8kCf2;Mmn%M6zirr&biD2k8S?Hs%;eNVtdmY*F&uMJrM`tf9XrcYKWEqU#Os= zNWMoQr-V%NO)B(PSD4r!`~Khzz{^#+Zb1Ce;|+*g#jM{M59`%kd0 z1H6RvOfYd|-us~<2=atG{ls&|&&vb!V`#iNwaWZ^*@3g;1^z_uJ`N9I&;O=v4&~Ss88>=PzjTfOznqqF*xJ+-loz?Kf`OxI*^E#F-PEo(55a zFKzCc{Xije9IDA!y8Cfv1;jj zvx3R@t0XI3;Q3Y&J}Fs#3?pa#ikH@+At)ksKfz0^ebHxCC7BcX`oYnKaeOZgGsBi) z(PTdn&_hcWE3#BfVykV!p^BZ&&oiPb<%eJsvzXhMtha?_DIbW1v}+QXC_jh=8b^Yf z>%KwWbPTs<&z|B9JgvHmmLw>|S{K%@;|^4sMT#79O!E(NDcFGblJ~H1hboOz?J&6~ zxd@FcCy|5${bq8I)2NI6Vv;IBFuH=JF2>+)C@Jzi?y%UDxZsWZ5`!h@zbb>$ss9>E zX!xhT>yeStuT+As1SE&oQ<7fQXdr3LEBNqG!dp`;Y2BMT6L^~M383Uoo5)O~Zb}i`M ze{o4Q9xXEW>5`3Y2?_;+K<>CIoN)~QsC^Y9_M~2(eT!A~+qYPguCcZ<1 zIc*5z8ot)^ol(b#(1Q6Jt0K))c@0Y*N_Dhfi3hM1SSBl23R6{WwYdnAyH_xo)#LM~`MPIKi1Ed``PqyR6)qdT1`}#A<^1 z!-I`;#=~~1?{QP)yup(b2Yc~l%2h%MPm-oJZ0XFeQ9eLof2S~`Ni?xguQ3uHyhf@C z&mkrc$SW2#EI&d)>xP(WMpGFIk|yHuVC*5*-pcwY8oD$fdmrVv=T16e^3&IfQ=3qB9*(!-?KcwyXY z4J#J1I(q4_US6`|imU^Pl+m>`@&Ie8^wKxbi?+`EN?kSO%iX+>t9d73y@tI$nBWN|8&xh#SJ50V0kQZ-_Q*Mr@IgGS@x+Qvcc4*g;Z_96}Z+ujgqsv z)k4HN4MAD!zX&od+jXLh!YYbg+X=4+Ok&Tn+=wPuNoZpW3S82%l}r5s&2Eh->+1BX z1^exTBuUb{!8SE!;VRA3EqMQ;FbHF!N*bv%*K0V|rCpU*@kQx4jfE9?D!mdzQg-@u<_q8PZk3YfYN9(i+8?B4sIB z$A%tI6RY{emAo&oC<8D#vV^!BH}x^l#lV8lY|ZS7&b4U4HFiX(=6K=_d&>TIUB|*$ zqp6{TzoSqY$EsH?5y^G}j!QQmOox2|F|Hkfa#byB87$VMTLn%6u}XU_po@(H?lz$A zbA%MB;KiD5Ra>s&b*5oy5UV%8YPW`x)jRraar$h|=5S498WxgN1SIgTFw+)T3Dx}u zP+uEKtoMFa1{h1{A}ohT0gStlzh1yfse-!ra#-4$nymnpQ>g<#wX{Nt%A`kWIW3}BdP+e68Xtbds3BzhYMHomKV5qsac5(hocLT2%1khou%}M7IFV4z%V3}=XBju<fDhYquL7#`zxnk!_(6^{a+LW5or9E84X#ZP(Mh( z^UwaxU1~Rk=7P*T>pGcL6J`UX6%a*%YukJ-5C}1anI9waBe7U5=)$dmoizg1UP-}< zHCC}^1tF{VpI@bRPTE8k#VoFi)p9-zvV-XK;^J1ci__StIL7g+F+%13>tvw}s+pz# zb>1!U(4|p;9(g#BQJUF(hS&75C~?B^3aGLtqjv+aI*NH}YLo+Ofc3D+r>!C#d6hac zPdj$(Izc9$6V-1AWQ%(U*Lne!Fm(sCnpO>2AQE0dJ4a>FZE1d`33ekbP*Zz)AV%D) zGQ&kur&R-4ECTQ;RPTz$3t#wd*0k3tu(lairGqg~ttcli8r0Jv8CGUjP@k`(pF*M4 zD^e;zV>SE3=M!Bu5rRdyH!Wk3}i?8Svxro++d*+I^>W>CE;7=tlC$(lyiVl`OX zOoCNnrTRkGYL)kJr2gh|6;%JQwce9etCTPn>iwRJP(8;L1K1MaJG!IXIxRuxX%wCY zf$Neews=LTKUm?a3&SD+%%FP5*sY)qYtpOQ`PD?$v@BAqnplkknw!i^g8*@D-y_oX zR~fY$%ZE1na1vA*R-F}Copl_f?L}#w>cwefDM3Q|zf)nWN|k3#;AEP62i>YX=i2Qa z7_inP1dz$gP`EWiRdx-W`JTggS8?cZr4!a?s`je?``ChJ;=3dEI?bh+D zQ@w8kK{q5~}UK*Lsvbdz%oeTzz{K4O&%z-T4>EbnVUg8*31IrXlj zWHr^kiR}+5W6PDH>a7D+^6W%j*4x5?SWgumj2jJ9g(*waZnaU%5UwgptQ4!#qu#dN zej2aZOwvK&6K653>ae_eSSiIlG+3e8vyW^0NH<4N8NMhs#IiF?r z%POq?ft1QrN5ie^2wPF$$Q4TS91&DXb#>PFbo<-v{&*@dVZDk_6rd@pbAZmuP$YUn>^g+O`Z>aZ>32-;YSIZ}w#{Obeo=SXN>NfE2;SBrbAY4v-` z6m|CYy=iH0R2QvIhdrG`b^CydWvem31q@ZOd;0K&+0!W!vn7p1dH`kwmrG^sJ>?M! zJa$73(A!~K@{>uWY`@I|IqXorF&Un-Y%+|LNE1xU3anli>MCRcRuL!^6N=ocr7I$JyfXDw4cD@)W4D9;@w{8NvG8 zm4Yr5RflR;Mpr@Nm|Vh`aD_|i%ykj#Dy$C61XyOX>1_1`3+D8}*;tz!VXr^c^9y0T z%AZy5;fn;-FJB%W?$g(vA3{g>F;F2Zkc5sKCh};YJ&{&2Fvo!6!1P*c)>au3W30^> zKs1)bfkd`~Z5lbZuZmZtiYQ}Ht?KDdbtrK-Qis0o3$U-LXVE?fXt190EJ*<>u;+(| zTV+1M5eu!166UdiHUX(TGYEMkWuny;wN=Vqj`_M(FB;t3+#CsYNgS@CX}D6)X}Qv` z_GKZd)x5s3x(<$tTFeM5MdMSg{iS0I_Ti^>U)2n)&SFXow@JZ3gO_Esd*u+wDv{;E%U!H~T)L=@FK`RS6 zz0Q=x7Hl*rKzIq@sLz5O6J$RQ>vHmFHYDKpN0x{m!s*{+j;Gm<6l!O}yt? zSp9M;{lHKmts+8Nz2W}e@}ek>`LW!i<&M}ytW|ZZ;{MPFJ6|Nsv3l2|okW@vVBPJB zBbPBl$E$;Ykq`sS)^n?pkM*@_e{Y3Q0Gl$rxV^uBfNs@&st46CPf`?+EmllIFJx(x zbwtjlM3luu+6ndujtK0SuvHth7FQMXX%-LzK(OZFsW|K&(vSeYYR^|X)@y-r+L1wC z8>k&u*P62WtH%VXTXpVJXx;<2if+Huwc7%tY-g6^W%NZGLn7eyrB~hUiiLh_`{1D2 zMn>OCS7a5IgP@U$)jPTiB2YxFbFN?_9l<;ZC@r!6A=J0rH3w0tN7eDCklU&AHdKQE z-4h21>s6=ERoTDrmk-6~J}qmW9OYvGBYQ>NIxADtiQE_hh*efl zB2|CEN4Wa7wl)Y5&{~YJhV0G^G z?LIVQ(^ZbX*Ht_)aYEV`$nXMIVYLOZ8nPt~0INsrD!K{aS|Qs_^lYKAHVA-VE!Im| zLQ#HPAE&{e-aHF~a(Rf~G!TP+mx!BuG`mk_Crg&;!kzNUet!DyOhM6l29Dq z$ilpMG02Uliv0K~5Aa8dZxD36?(Ks#-OR zt21S3Wf7zf*8=Kt$;96g2~MA32yj{-s<#3&21zsu)Dzrl2G%$ZL)YPo9MkC#Op=2V zitVC4hycqQ@J4ct zMdMlvt(-(j=@*qMfi4dLPzC0u$*C6Ew{xf6)*7vK=r((`n3Pu2M z2<2d<7n?7jEQwfMI`&Gd^24%%zpG`W_NQwB6?_V|jx{f(W+4DA1Jq8fO3GBCas`M0 zm=bd!iQ5dJm*6#()#S40OO9X zc>Uq`&#F%E0GUA#w0bl^26^W!wLX305EXXhF#OF96*fAvS!pZ zC1F8t7M?14q82N7$D;HG@KPy zueDI6ohcUk6O~&k9Ugm#6t1{}upwTHo^^M3za`KrCHYDu=;0A^fUb5F>u3SJ6~J*| zVG^_tX4-XA*cr}Vjnr>^txQmy^`c)z+3?l<(~3ag`JyY&OHA_7Nbg8#0jRQoHme5U z;dDv|g;0)tp29W^RAnmy0asU$a`}u4`6HnUj;#``Dj=4drjl=+>Adh%&q#Hrv-zum z`t2L*t#)ndI}5B{NK*pUVJmH~V)kGY0ox=Dql{dABSCc{6-grA5a`2xKwjWxzrTTM z8PSTVs$h@a0j_jg$c$!f*CT~G90=oZtly|^Qd=w*%gnwlmuwdS;Ad+YskYylZUd-k z0KkWDSk3=Q6tQ4ptoK!Dy+>VXvIq3~E)|nzTW!!0ARF(dNZ_tB3X93CN{Ck7I0oJA zC`sbbmWisPoFi~E6q~Naj?%Cw(Z^hOykETzMa;yex&jq)hUtU*;WNm}FcI6^NY!bWP+lus(#l7P zL%Y}n%|fnfO6m;!2wz*Ds<{bJB&MudR7eitCmE}3ghfMcrCp^(sVONDp;9oo&aX5~KnV zjQ9ymrN|T4>Yj`H2lCIubG|$%g>Rrl_LG%fl~qaXK-AhA0=88m(OXR4O7654L1$Pt z4it+AT^0ra6+Ru6#j6F@ae0%H$o^~(s6r-(PGL>zLDmOCQOQ!43t6Bo7}N*>CK3$* z%c-f$dsp^A(z`Z}RjgVaG^m23)~8xQBIgg8X9a4}wz}T|WMx%UzY&vg z1()y_hyNH>FW5(pR_wWo6!D75N~l(hvUV8d+E%nMG78IqiCc51Hrw4*ilXuj$%kBz z_*}jvgEa;!y=8mSXR~onht-;cs5OJsA^=$M2T__+eNu(jx~G*;y}+;oqGeGkmDL25 z4hD9u>9puja7`@w^{t)Vy@v6xC6oZ$QHQGKgRYp!?&~B+2C57>>J5ituS7S713idDw0(ma$7i(vcHu`PQBn&=j; z#83oTaa9-sg1aDu9nK@kv)=f6um-w}-X0EzX|6OL&pKX-JT-FqYoGngYMf>^15Q3v z$kf1Lazr^5V8w#APgS_e=+(B8LL!fMQ*ocp2nMn?7#nIaP^Iev#OMjA^jl)bbs~;p z-GI?HH!1O|pbCaCLS?kiHK(R3gH(Xi)UA#ILQWN^=97Viae&~{Rcs zf>Xt@mg^@30k^jh+nT8LqO0_*Kpd}QSM$?6*xX>n6B;*V$ z&^j8arDj)c?@3eS9^cvo0x45nRhq1Pw33)u$4Ydbm4Ve(t~XqNk*G5slq_q#p-cVi zuVo(u%+_rwZAx;ix2MAIgHIt^g^UN5l}EbMKi9BkGyu6@B0t79$iqDD-wide1gpIq zs(tAy##+x>2(ujZ73Ev#Jg_|3=f<=; zVCFd*jV6=HXfzUk{Xi$to-2WN)i-0YZJ30CWu&btvD(kED)(EZB`i`o;07%8!%@W@ zu}@DgPA}41Pcz{DHoB-9pZanmRNuWy{a@L!-d;#Kp&b`+y-->gQBu8|Cc!0|3W+Dx zmJ`_C$~st{8M=~^tGyk7Rcn}I)s~A+)nElJELILs1l!Yh=MD<3wIN`2b4t#sAlO!J z08+pz#R23nu%i^On)Z|BRiQO@)D&My^ih-)!@yYJ3n!*vC6cTft#(@^{Y=>i5DrTH zB~2!ZIN-+9RbCQ7Dh)@Pih6f-VYQM$r91tXN5WAZ-G5m%NByAY`=^#l|9zubtdZM&92)sv>^e_A3&-9@o0tI=uU8uLdr!();JHo}4AR)Jp*CASb1H7kQt zIaH-xE1p^f!bL@{zTtX9+X7N927*3ILk3|$uBZmAp!%suUEK9VSe@1sQhCt!sI)Z6 zu6MylUc|u(F{!k{DGyiyp+IMaQnq<;%bfxxuo#@`+Ezul>hqR&5`emW2|GXqt0$=8 zgxbD3T0wDLMfFpU|GSPv-oh8PrPTCbJ=JW^%9mnF5v}wf6PXVf)FvPv3P2-T3U(tK zp}gjj#C+mC-yN{`tB0+7TC5fnwPN%Q4hQMMT4Y#oe`_#e|81RClR(HVn^j9t)p4pX z%b|oT9IdgZLJ!vTQ_7J5-8Fn6X?Hjwl}pOEDoRy;2X;$};uH#%nrobg6H(s8a_ORr zMx$+tSBGs4Rs)d`2t1@%XdOus*f&EBZzU|H*8xHn?yNJ9qH(IrBYC(`!>L+3X3c$T zB#5WS0LF?aoZ|(7XKQ@1D`%dOYnN3Jl049lWQO)H&$!-ApV@{OuXf~lA^=u#owLkn zy}OYkJQP;3mchMn$7>)}bgD0ReFYvUeEPDkJ(Q0Eq<}4cguoHFaOyJ25a6w@sK|Qk z@=HOnnGSfjvvVjiS3-%Q7DXVVUj^2lTz{+@2!Zz+^~TeBKm|yBNr8H#$_iMg59>p< z3ar_O4Q*t37pnu>3iFaxNagPrB+^M?NUV^&l4I3k^D9Xl23fp@OG$zpteQ$|-W#X_ zKqwS2d$YwlP+cDNU0w1MZvxcSU@d`g74Z=Xe~E&XP8Gm2j>R`NN^D}~io~llMX`ix zxC>I2MDJP)uqx*wu5zuScrJ4M;i6vpzg%eLFOa)#oGQeu_nV;AD?zIj2^l1cRzVd` z12|mxE{^~VR7I@z`{OY^#YG1xXobXDu+lI<4dTLz@g=35*~PsSuH}rXm&M&(N3YDD zdaj43=P%EjgY|h6*Lr&qYt|TA@r6Lma#m3X5~vhRriZ|VY#x|SYhJl(!(5h$TME}d zfrKfchn0+^k->@Di>#Wpy9jl8bH6j36%xalR$C_8wpbFe>++ec=oK!Ng7x`+BeaUE zzLCesSrDLM==5q#DLZ>Lvp`lw-tmD%`a9Z~?%jO|kCpGk86m z;_hnAb!xz$Yh z4yxZ;Jl!mQUzV&`R$IFflqN&US z9)?v*p16Yim!i#TVgOoURds?~7Dx#kqS)0JU=jdU%D9NhJ^%7xmkfr=j_~>RBCyxR zE5gBPsZ8qY!qy77KCM|VIOXY;3o`(9^NQLjuuN@L^s5S1Eq%@!a~)t&c>%gs>Q;+9 z=Q+{@tq(eUr3w6f=+uWRVQ;+wOjic3_Z1!4YPB}xXuJVv3c4WK3SFBFblC>0q7_O( zQDkEs`!A33GeQQ&xXcR_U>(RBXV1LeZ?Bd&Ph-Vk<)F6X#L?;P!}`wh;b}c5{QRWW zu<{Ulz3P^lJbEhYni30R&!OCxgeF(bG+;Gxz`J*Qdkq#r#pwgBEm>P#jhu?3mIEeK z?uqw~o_OEiu9X8l+@1!3qsjWy;MsPgi>IgcOQ_GcvFACq<(*zUJl{V&Z0=pzFZT3s zzjhP=HG;%dH>^_thbd#~c3XS9O%t{5HMoqvU8kXv28rF0VSx#Y0P?-O3@3d5$$iZR zQs&d{FHTRR(~I=qr1f?2aQpDIS-SrKMYhtj0oxaA^kJVMDxoI(b8WcV<;?87$-??8 zi7iUs6&JgWOzBFbTjMgUhC;vHRrT2musT{yK0oj&fcvL4&u!{MHx5mo?{$MxX;${M zPB8F9vj2PK(^pjt9(0ZsDQdN$Z8@CD3Td$s&=}0+SbuG|DymwRHUPyMfhY8>HPe|g zA^+ayywWe<*G|2%T37I}Zf!uD66(Dx33eVd<1ly?)yjrtK~XO77su*uQZ%@OjM9qD8dfL)Y65<513FBED=H{(d zcLi2sIz$Y0+f4gPAO!OGJ`b7lwmWYHMqwO=0WG(JWL?Fxtw8_>tMmMQkopv@tk)vs z-EZ;H$hNeAHAAogc8coKtwPVT?3^zk?#5~V>e5y_4J21gL%k-bG^WWb12n6puUJ~o zd2dz8pl-eKbn4dY+nzqX>BmrutYXx(!pFjgt!0X=I|p>Xg;j_##dWX>G)3}q0BaLi zC5QZzTNcf38zJ`>x*9FAk3W7su}S3%de-|l{V2MQKvWy6p!L#h>>E-*8nYHnRx2xz zP9`H%qcR{&xLV+{qEhiF0M_R>bY=G`P?f_U`yW63L+tCPkDoq$0;4v;o5s%%8r5vp zY9Wj(yV|ibE`gz17p^rg24LFK56cRugR*@q7AFRWNaN}|Krzx z1pE3Cmn^*6x{uck>;!rKYL##R1hCR1z;vwytRhB0DX6N*832K1<$%)CPAk)Vrnz?U zHXilV(oMCTeI`i#{PFDUwd~oX3u#9AIVND6EmzQZa z8U!On`c}JjRX|4dji;)R!Pb4*!-b>qsMg8Z$ImG=KOM6){Oog%RtDBLcC5ONfDI;s zRt*d~hP|R|UWL_rnzXWv>#6cQ#cxq$C4(nA?I~Q~^6N)oob)XBtslQn5e-HG8a)9d z_6Fi44C7eSGBoM{Vv*IW+I|ydu-yE!`^5+coeQZa;s`0BL zR-N)4xWZH`mUS2C6$bpyGP_6HqXj%a=?1ls+xz$#+7tmjvIt5_!@VZZ+@ z)~7yd+f^UQUs1=3-k74GE3$U6BXZP)R5ent+BQY2!mlFHuTJtj71;ruryi(Jy;UXx zPz~0LS_OlZu1Z^;>YaW1DpgWHBOOb;i-}|Xj5TcAx3GIP3UI2ZY!=hENX&Sk9;+<3 z8iDIpNL6@BFH6**fRlgKU@hJ$-rTW1YxU9b$)|r*J7Hq)%Rm48b75;8tM|4+fKX(0 zM5Xm49>iJVpI~t*OToFY=*tHOh7gd&0BzB)LdqxYW@YR;gs|mkCNNPf(TuB3#8(CZ z&pMY%lBv%csM46UyZAe^Kk2X5`wHTNH?9N-Q9ul*17a_q)Uo*Ng%!oR2-Y+NXyYo! zDyGc<$X@Y4f)#^}fJw~e1CXc4t5wvlo(uTgZ|0#?AptARrKBL0hX7Pr_V2>}r1MQe z5b(xq8g1Pw7y!~@l5G^QoB+(YC(09PG%a@b>2v_1*%jxb+Y^0PfaEoZ9{)N8pz=T< z+7us}-W0-TA3y(7Zq#%BNwNC(uy`f=F290t`&OlZfbqWc$&`+7(KQKH=2IK+lEk#f znpdTftLX};S!=0O)pJF5<*d!G!)mMlDfLMgyn*}WR=q)KRPE(3_7c!0|zOZLkxUa;>?5Cu%o-`LdO`<0>9iV0BcY7?3Y0t5hp{Aol$H zbf5m`)5W@2eGs8)9e@6(rqSGL#<1EE{3`xlV>M8w&MPhbD3ZS;_*HLJfDU6@s;Z1abEHpSA2O8?DJa z>z_jwMC4?_;!kfLt_qV^%(2!Qy$TG06?Cl{&6Y5-qPo^JQuTmLJH5mSoO;EzJ!^5R z+c)+$ges_5 zM62I%v&KrI$|?liRK5KbaXF~P${ySjxNh`*tnP5rs^GYrc z8ox@h+JYr%Nt(t{o73VJ5x`=JRzsBq4fWVf)CC>?v!UrofhYOM75YA1D-Bjl9jirF zdSJ`ua%}naLWXQjtqE(=QvDArx}bvYbs%hYwoWfrY}a9LTg#h07iYNr^`Gs5HN*Ya zGxYXy1trN9X9ZA!V3(|!v7xG+E5@q^t5TIU5~b=yHf2yOd`7}@CV9srLT<&PzzhD~ z;X0*N^zom6wg%SsnN#(-&gS=ks-)T;FhnMCz-SJFx+-V66s;E(P8HV`mi9oGX>!#%BZQIt^%EFvb)UWqR^@FN zuC6=*2OVwk&_b=ILF>Dn!$zY`^~r-{e^Q_N`OI2F(%`MIdaAo-JS#ws8eg>yRvWqvA#vmp!QeXeKgv}?n#GfD zNc8^W(?Dp+;^@96R*SJ${P~~l*w@b=kFmIY^ZDPw>V>Kg=jB^i9Be!JaDYsL{N>3j z2jWFTgLN$lf+z~Z*ew&}Rb6iFg+CXst^$a@^+dY;rw`u=s!|<5Wm{P)g(Z?cteLw$ z&{e25v8dh-HGEp^Gg~9IvbNz@Kb^_*FZRWE6NFc{x19!**t?BDV2)0Z#*9#(I;d~Fz2QUMCDY9<2(qE)5DViHG5 zLWlt+a89VC%7s(zRD&9F7w`^S+sLg_psE5br}tmJ{Hs`XDrT*ins5|)e089)cYB9a zh8pdEs>{8>~mtT#)pzJ5Hm^$*SccZRB(Td_(& zP)jV>1gosZ+7vg1S1bnrU02@CN_cLCcvbb{;xuwCY(|d8-7eBC za=(54^5x&hDh*gGh3*+?$Y=yf4l zzxYo503B9Qjj5~1^sGv{SCy;5k>_Q+X)`toWb&-!7A2J*GsV4G`SVGwkE@`3Znvy(%bB6cAu4Cb<@uKZMSB2Mb3WB z#=z4D-A{h{a)EJ|e|YQRZ3Tm$HGA7>a)pA(v9WCjQQ;6`GsapOu(rT-V1(1e-V3A9 z^TJiMRTR3GWu>^KYfjOHE=+yeSZhqUdtUP1R^P2_(y2sa3 zS?VN}!j|o2RX`$ZZOW^vwZOzxcV*KRj1OXRO=I#8()rbg6sFIwsb@aZ4Flzl_4B6_ zA1O#h&Z$QF?fEqQ2W_xNZtLY$KF9)(0ZvzsZ0-QM2L`ONU(Fj=!qr<@!b(IwfQ;4G z-(@<{_ql|Ya;B4Hx8qyYmp`4}JE!Hmnm5#9f36Tp=xSFQNY9=bWL08-cp&Ij2z=Ir zkFF<3)5OwZ_4Un6t`P857o=)R{>Z-2)u-ZHyQ5%0^lJCtu?(Q&TYD7@HIrQx#J~fn zX5s2$fY?T9AeAP~o*M$kYRPedRA1e~-MTz@E0@@Z@!rXtq23d)NUi zM)YQbf?BM~>c=<&y%i;-G3*s$yNniyio!p?oe_hoNC-%9*Y+F_aD?^o>K1zM4<1{k z`ib`XiAQxc9?}Z5_0lOG$~WIdL%Ku2>at(%_Kgy(2Q5|sGA%ENV_pQrK`8%rCSoPE z)uud@o)nxuAY&`C{aIjuomzy?qWdLXq!qt70e0bb)CE1n)v^~LgXv4!Q~Uk%&K4>Z^y9OPsPBij z!yCD~^?b2(l{8q@T!jmlTn|8R#MOI0RWGFjE!k%_h(co5Vs+eL&Ek?Ovf@yp-P-TB zza-7eZ}$7)aF9_o`Sa~9|7krW0lVkwxaxxh)<+=tp8ijeOsA?9`7P zO9iaY-F)qL0haVpgRP9H&##g`WNA;BWo5#$qxz z@tOqrt!r|`+RRpyOw+B=TcO&;fbQPZ{VsM=1)rUKSn*DxGT3iky;#g=jkllgO@Mp4 zbn#kE)rx!-!xF136QBoIA#QBMBFP}Poi|vi_dThicE6-*n0>HwHj)YIUtbT6nEL*f z`O7vmhi~1mDs2-{7zRNYs!Jd;KDTv~1rRJ+-j+a3m2a)fd*>ZgWvzZh8a z-|n^V6hP@t=Q(+sn@YnFNCEibs%GojQ``&^$}Bzy=!>g+SE@|@dh*jx6<4VHa~F~( zK&+HExTFK)n)FHlkd>PVR4=OsqWN{Yx)zFTz7SVmZChMc`gZv7et170Ka8#e>uZAq z0NJkPICeU{CPx)b=I0;~1hR28`z%>ad}~uVR+wf4sY1Zzf6C?pT;1Tq1`Sm|=rDt%0t3%#3d_u>`Rc56Y@Z}!jk z_V}Ecw%{JAT3)tAi3rvcCRtuE%xJ|fgA%@hk&YxM5_EBBSG|klI!n3! zzr^D8ov%KZV*fAZU&lQ2=fUlQuU*zwk_3RZ09h;xuKHEgtUei!Z(+!qE35>g8g_(= zyo&t_WR}wFu)l>Xrm}u5srvKFu`BvH?Xz0Om+xu6CXSl=`H!lltu^{lR}d9NM=545yD? z6@BnyzD$8Dm24)gT1zHYnq=cDq%H(RHG%-g!ibu4_T01Dm5Fsiudn=?YMaRVCI3BI zFJ4Q6s$Zt6D5L5ERn-SX4+wJru39-F+G*#%Y zpVV4EBL_G$sFJAKdX-g-s`ONqP<1({OjrEC;%eIb^9q)06*ZR4k5uhbmssp=*N#?n z20)UHm$U&Oldhnjf1T9=D3Xb`%YkBBbwl+^XG?+^|B)BTf0F%&;0#nRUU66>z^YS% z3a(mxa+P#eU#PPE=y=&GWj&C~ujzkryC#6xs@hQb6q+vzt_r$9zi zXjWQbw{Dp=5*^U&J_W3fQ{<{;SVcza&HCR3Ax`ZDFnzGNTc&R5Gj?Xl)S|TFk}Wo; z0_kd%c*QR6);f};KY5=*KS)0PX4wWX(?DR_jxX=8K6je}WdDvIhf*4ykTY{;{STR0 zS|`QSIL3Y{gmw0vwyNl&s!Ubs(u&OiSbIQyI2b`>mE@~>{V5g~BSRRr5N)7h#=7k# zTTQu2-qyc~iSU2&ANX+sQ@4mYHEZo!aCOI1HKsFFL^G8tt(a_!)8uF%1(5avwA%Ba z^!aK>c{()cZ!zGxFbYhKQu2?+E-nfDhibew;pzK#%o<;K_W4ZROKK|DK=PnkmR8tz zEz@$`OtHeK0CGTwRpjcl##n*9Xz42_EpC5Y-H`wPt~u6 zB^U^|@LrYFRCM`o;5oOlPxNM&w6Cpgf5jE~2;P#& zdB|H@V2+6RiRt`Mp;i#H!tdItHb-xpW@Xvv~gNwDz6er72SG5d{9lOx~vcj z(}!?$psI|jV@;yACi3*0oJZfYuzKlQxE$D_`x>US0Pkcu;NOI-pKG}Kk`Pt0nIBls zn-(AAbIqcv7*v-l5>};47(^O88IMO$SV=0Y3H8lP0&5t#f+#>Ox*|WhN3L$kVKy@z z`WHtc|IJiZh9MiW9)7<4{3)5qhIre;c74B-ZU5owpt@Y&k;)2Hi~^?+QRNuX+W{F@ znf0`wxfiRz)$SSr`wbme4+Zu9=kNdfAF~*+X_(XEF`=l&)C6y~?N> zyf8$4a~ug#lkX_6V~t80R6XWCs3fRnR9%sV?t?PxH^z_xs46Q>c<=r4bX3%Ysw$J9 zPMcO6MojfXrU|6?Qvb4Tndep7plbI~$$~1!)VYkQ>pLo}GLB3UySWZVmK8FqiJ|8v z0S?>xH*0d$=spU(^lqD~574QV8Dc2;N#eR%mi-4)MpSd_wI%AS?D=OmB(Q|3%Z#aa zD^wolD(5WO=Q)1@bjVh#(tyFuV8XokVLJauifdM(>tsKTT(>JWu6m8DMT^z_@h*+5 zKS_@IE-9u^5>dThZNGPR^@*yKsjIsj3vtKY%5yI=?0|iM+HQa__mf;>i2~coHe5~N zP^)a=s#~sZT&fe0woJcEqUy`RF(h{|E#$T(>V7AUsT(FE#*&h%2?YwklUY;2HK z9ASlhfZD9JL4%6EX&W#a0mUL~w`%L1%PXqiiuC~dXu$IF?ut)8GAtwh)%pXG5;aw+ zR8LgPOf|*Q5V-1bKLowi`aD2WX{aLUE?^`(ZXt*QyWI>Zb%9i8>hssHKVCi^4#zkv z$x&?-ruSRg##AX&H|%Nzu3_rkS`$5)IIikYSvBRV$v*k6ojmzjBP-+_!&yzMDz0|A zc3mDYIeh2tRQ`DT`c-CZZfy1)dgS)yR#B-H64us0VO>9I<;5$JjcFAi zGw9B$E^*;1?v<;%GP2utS@a*izez_^8@y>BQPnY(O@X8_m80rPFB(=s=#f5v$A1gJkNClRSwYcDl29Ey4h@h?O$JGcZ(&pl;N1KoBgj2%&C-l zs!`WbW#50Qw`x9crvM#^uR7#pVx zqN-wQ%=viC8wAxP10Wy#{exWk1C4;DNkH~MX<^8_S?A`<$306d*RQIUSi1HJ+6P;s z4ldeQ-F$~AVruBHfqb@X!qmI9BCPWD)fBb?YB42Qzs+*B*cJ$V$5W{TOsP>|_dY@U z?ar=bD&BT>P1ZtWB z7((4&B@95N(j|(hu8Sl2D))0ico)a<>n2WK{Vu;5lp&R4D#A*p%7W_MYEF%R&BDRb z_X8Jn04;I=y>7uOawuuGm5%32`!dPbxtEJCrh$;`Nck4Ox8)RfX z_N0p+2i58cqX5lN`L0F*qJAtt+McgsdDW(t-H!wM!hWadQ3>~c(QJ7dQ>o!9C0u0{ zm5{8S*0Y-GxkFvVov)yUts%SN$~HTZzP(^z?J}cyRS}i?)~qh5DyVulk(gLApEn)!(xb-RS;Ep{FR-5MNgHeR`o*6SRM^w`fgyc za&lvad#y=puq?53zyf7P!)P1WNv+wmJ9b-Lx&DqymDYJrs} z0dCp^3=FCHN_hMlnm%pLejkxAVQ+t*jJ1NOv1rR}*^0GQ_C79F%Re zRGh!a6V`Bh{aHW-Q9UdWwd(P&Dyj0QDi|=adKEO8Os8X5TRFM3q693*C=dy{HR&9S zPQRS(l?PUJNR2~b%K*<8l}ugNP?bFpm@Qs@N**Cxo& zzihmQ+v_E(GN#Iiif4-!uRosvl*jIdO!Qw7$K zP6Jd#jbq8h?i!*th^aJHP2a@Gbb{0fjNWPhs&FqO7hv(g6iedHh8f7IgzxoVp>fR;euWn@S^Lz&?!y-kR>YV5AAoC?IH z?V5V3$?Km+)rFu3=KfV0NUK^!)|=tY5b?@Da>&M(`GuFMfmbC7_^8&^KJ~j$y#1Sv zk4}C2>uZ0(Wli^aB}FDzf$o2MzK%=BDyDC3s}3^e3xpw3c?4WAvhEvd{WvG;N=M2w zn3@3dWh%o}4Mcz)iUR25)WHTWM1fw!aJBHhCM{Sqt|IH}{(>+6vwDguT3^oU-u_dV zS{w$z6WO^{FahW&uujCHKO|#;G#Vl5#kT`-Y*@+FB(&}?LR6*HFAhYa)|XQ!GPQKC z%HJoZgaf&1*L~I?DP7ryYF>%>d8|e3h}*C0u=d!z+MlhSuAigkwf|{ME&BjI&(!2! z2wd$7d0=(Z4AhFm95vHj`@YW+8IcPTb?7t^*!|iwYl6xZzisSbiJLI4fBPGDsklx6 zDM=%-e+wx<$AMLMm{gz?2TK=SsnQ|b*0Z%USZ>aWOB5;c|+wNXSpm#Ly0;OMNTAy``NHn1`$b~)ok zBnzv|XaH@tP4L=jl~u8dYRb&o%$|uVUI@zkDh9(5WB@${R^k#;++6i44hI@lTUY%B z*Zr%)*eBk2GM_f2HzI0Frt*OmW~zO*tr%%lRw3Z9>UOZvw7Q~$>%O0W8cS>b0rq!1HUH1XU%T@}&0cx;DQ=KoydJ zDa6#?QvmjcP$+k0D>?*$eQFXw*!sHL?N1D_>I5|oZCNR0XtPE{?To1ja&ZOBsrtfT zDqFTzd$HL?YU>#_+X2;-O*y--VD0|bJ_)5sEPYmwPhC^~@@tbtL!x%a)M9uAmH#Eu zRa3!F-^N6W<(7LdMLWRTwnD<%gtsYiQ*I_f_cdc^^{RSONE)1`KWnNU~(bYziWJVwGhC z^gT_ssSs-yOJs**jT(80LSv6hA+eR4J4xR#oy zv-zSaPtT30tL#_T?6J(i1?W;-y)9OTQE6*g?hFUC7Ms{$Q*Ib>AF(uvrY=TEeK)XS z54F|PzolFEKhzK4ejm;_N7fl-RYshZaoMipY&vo7?3ukXO5#KICvs+1MmXD zB$9C)zJ;t@O2#K-eEa?l-{<-5`QiEbdA*+J@oSOlwpV^38ZTV#lFqTbh3$DF(v zZXv>qS{1)5k}f;$Q2ow|f0Q>w%6gW=)_!(Ws0+gS0z2?1?=G~ZfM~l)Wo0A`NThZJ zvRx8`$Mj_r>x}nROG)FwN6lNa&9iAjUkCu=;B(*P5gR`Z2q^D-Z?ZbH(f`}GB-qVS ze`=qmC-baxlGs@ArN+)2J>`&EF92_@1wE}S-!Eg;9q4pCT8lx8QIb7-?&`Sn!fH+pWg^w`s%UxK_ziD zIKiTu7J!<4i_PO5kcd&f^-;uNA3?%x7*^%AN#bk7=wpiR^R!<(Z?`+aQ;XBjk z{_WS0UNLclCqknvN7L8J&Za3p?8#{``W`;FNW7yTVD=!dUV`LpPsd#YJC%tDmLD}b zrK5+Rjd|@Mh$(u!++ZYC$mpZgmhyw6Btm$#=if+GCuMT?A-5Mu;1Zh;x+BNZ zLDC<;mSmoBZ_jSm2(=)*_qEJdp%))MJkic_Mqh)PaoDf#)|(b!XyMiSk!4eOmZpOt zKRJX@T0x7S$!trS91t;1Sr41eaXC{puBfMj{5uPWrskoWKNnJJYEn+bkZ+@UI%c>C zkKJyji7-F!UVPv6kd#|fr6HN~=GxXdOu*WrxbO7naDP8nQeV>tr!dEb9PBUu_{SwB zwKGU@C_zAqu3?Ry>BTm z$veDhJy~D;Z{b$98Gj~R_-wrJct5uWb&(Gzz$o8m*l+J;(M-P0f7IUpAgVgZw5Vch zYUCPQg8q{r&&IU|nRi$fthu~3Fkv6dg}XBkgmUI*H2j{v{&{HTm#X|R5fowIljm|o z`LL&$YCV+UG%{WD?qmU*(gzm^VDf47C)WS|=*%yBtK|7n1m7P?DW`s?eXcF$Vi*xMH zlfrMHg_D%bW1?i|?}bF2PClUgvXhI#sdHHaIDG#gQA^7?oZG_TS2#HrX6>UXI&4;U zxz!l|OL$gQzuWNl&hYJnN7}LzOoyB6uqN*PM7-G*`I#Q&b^5_pY7V_-pYT*ymrm5K zuuO(YM)ijzd-+%2B0dRDw)S(vncI{-&VoVldYsdow#O?Q@9<8!CxH?q7p)J4%XU`8 zszxW?EmGS6zFkiD?9l1PdJO%=%gxy>XYbvn5#@*wbFR%>ZxSfeC`cUAFNVVdG%EkL z-il(3m-8z`no^6fFBQfnY}tJ;VT;o*en$}!T?{v4R1~m18OAe4P@OoqIDI-(u+7*> zUN|UjCbv;Uvz)Zi_9_<<)Z4DNj5w`b%<)^LG1vvpd5|+ofu=npSc)C$m+(>agPYZ+ z{&f;YB5JWuh4zI0`|)us)zqFQN@4uo>}b)4ZRqsv5+aw6n&eXjmg^j~^6u4G!be$( zc2O`Vw?bW==D)||gQ#ImHJdPyA6%SEgqDGgIR7`!(@nPhyU60R(=VD?;rlE(yDlEE z0QqEqbsjsL*`d6}5u_$Az@Q#bg3fyJMH_kj+iXU5ZkXoi)c)i8L3)a;+MdBut9Aef zV}rjS)aRu3dPg0$vht~#9>HWwF1c+F8~kEAH2ICYsouewcjZQ81!-tA`q~JS_WL1x z3Umpg#IHJ%J}kSdB=zT+XB4mcqVr~b3LjQsw-|ap)AsJc)JsKFOc~Pq;G($8Va;j6 zRZFTnI*U-)?6XlwvFE|?R%9G|K}$N6x0%bgU_`(gQ8lML3h#|sYjd+qPl(wt)ed+7F%Anx^3W{$IOLoPHs#)2 z%^5O=6i*XG4v4!21tn`ZW!-zoXim0-b~96dk$(qT-<8mU1S)h^*j>7CzN-pBOhmV8 zllDI`o-M<2}m2@#iu$}0AzlxEr&?d=ue=@Bv#Rq3o=u9rcXM8CFV ziy`#=pm%vuJ-1#QIKb~LP_S_w9yznTE9=4{c>Vl~L&>GVSCe87LR$km8`?;CrQ!E- z9R2EmoukMTsc2=_r(RVE5$}?(rP=U4zekfNu&*@j5jI)HQWE^PGgk;qd=#fPc`TZq zgs5B-``MZJ!8OgC^NIi#?;w5NS&=Pw<0*#~o87qLz>!FfzJkn-4}CS%b$4wI zhSl?TYPV4Tld20W-KSX8v6GiOgwrctSKf)gSAB`q$xIWXGy$lX^Nj}c9N{c3D`6OqGPZnBH( zN>~ml@ZVyQ4n2re^?Bj=!CMf<-h^=gSt@qDy#pyOxi7z9Qun2F^Y;8T5^1(h1{r0r zU?6z)vLrK!Vg61tZwVQrDNI1ClsNht``XPKc~h~XdQ)CA?p|I{tW6+mH^gT_N^n*> z`$2Wc%X!^fw|c&YuLURbO2igss=zp?YW(6ZU`N2iaOClX1|ygw+%}Hg)w2VvZpxrA zt*KsI*^4NVI@g*V3E#~l3&p3ik{4`R98Nro7sG;2jWcx z;ka>OG35>GeoQPF&;T25Z*KdS7{2yKA8?!X6v;9MheF%VFRjKt zJbAZM2;P5#{bMyW6IY8%dA+b2EA6T-$jNiY$&7SF3Jg0bf6@`N6cZB_o%Cl)?EnQi zctox$M2~~KL)8SDUB6rq6a7oxC_}3PQ>FyU!DDuzsRhgH!jve`l8w2LOTg%0T^M*J zV$LUefrU9q019AIioMd}r#=Oi0dE;(?KFpFiBhAu$q&rA9$V`CEK=eBu_xh!O?PO zc6+uMTYV|uG7Ja+S1_Di>qRYJ2tSpNgm51aJn3U0hkfJ|05g$C7V)ma>Z9+0L2G6G;= zHPHx9`cR?7}y8fjMmTGpoSP@ zH3<_L8L*F_GAtN0I5gNBPzf9i0`!IUy9^F0V}tz~8B_vO0mu1M)&Zydr+Yg&CHfPpcstF`SfC@^kUPEgU>*ioO*)!NF&fzy?T_^%$Epz^P3 zI%2}Vx;R?$5Ua_^6AA(BjR{$4>1pYSdEp5O3Ayc!OgI&UMSp{Xe(?~SIXc>M($TrN zxX`*V(E{vE=@>XTIOymZ=@=PlKs{(2+-w~6U1@9_Nd7a(-{S}yI~dxV+d7&9YzTji zt8V~sa^xW<{x#8`zyGY$*wy@>nQR<>qXj~c?pF&P11&w>{|n5>@XzvWo$RgtTAYy~ zow1d%wXuz(0|<`c{|V=4Zt{Oj_p9aqoKC{l(U2KLU=ddk8o$W`!q4!pc^w>$=>FA# zj**_8ora!~hLMBr4_QE1bN@n!Q{LRw*h*d49JEXa5P5hR7@1kQ|HAcuHT^@ae}Ss~ z3zV7uzd--h^gls`09F8dMO%GCV_t^ev;C{;U%ibCIUUU%t&IPYi9f9ROE?6q9RIua z7f!#pYi0gRXgIC(ZA^KHU1^MrP4u0t9Eo|AZT=d{h)}@Z(FI^{$xZj4-v2$ye`^1G zaBjN)$2z!wafnk*!Po&{<@8Imc$w(w=|JfGulj%C^H;DDwFX`j_O>CvVxcI+W@PBswJ=}lT@vja1AMNlL>p<#(7oMB$kE-E?_lf-u(sTS^ z62byXuHZ*rFnYLibJsIBd23|LaUQ370oL6?2JQej6j&mnz;|d0a0E}FLST%%0&HNQ zEG(Jte*ZwTi{?qA)#5?NNoC^!pJ_pZY8z8oBK2b26Rp<7c)IGucZ-QOhjlMO@mN@K zNKyzi@K9f2equt@nEyY1c6amh^U=3bc~Jc`UbmfKcd)ZGo;8%Z z*qTa_5O}!)P9}kzR6LjUGtk~%9-b_h&i~~DHZqIQr<@}$3b69{J%OI+=)dMvLxFvc z8;H~0{TGEd?ufeA&0FgV_|2iZ&?FUj!6oCekQ(@fB-mvi6Jxd&*)clFHaHrfS`k{o zH4qCYm*mR$LcIhB8SbFL|3U?vm=Lbr^X(swO}?V!SdIt-c_!;}N$W`WXTN2`yBQ^S-jCl$j=tb}F-0( zq7TYP(AqapfK|GU;4lS8)qjZT{-s-)@|Kag*-hd59OQ7znx0vmTE)w31a8%unN^*d z6?oHfAr6O4n8R$Jd1b^A5pP{T{tyArH*021@wF`tZQ0C)<0#AWvU#>yC9DDI%*~7X zM)yn6$TC1_rDJhHfl4^5+-fD#Xr`TYXHhV@SS=6l@DTw0CR1~PLOCJv?F?2#aQ$pV zQN#*&)417HrOv~*z@N_^ap!Np?KyKq${=0Cd1o(xO%uQ*5iFeap>01hAlK0`PCv-r zVh=RS!(lRk*Z2R9TzWg}P=ibFn-LBw_6jxG~(k^SDm6mSrx1{nlGG}@6L=Fb~9 zx5(QRrWrf7ipHJOsFS+=!>i28spwdsA-owpk4Rk%BwN=H&-<_^9yAcVFIi+fdQJ}{^boYzkjzW5Nvg47z{Z?zWuy@(FfBnGfVp%qxm9h;et(aF$ z!eeb@W;;-KeT^DRdN_~-aTc)Jeu2K4>(!>njUNp+?-N0U(u$(oQRkH`nS)tb3)lMY z0W~51V<&3vXe8++n1JN#ug(ABKxZWPd)I7i3le_lEsVawt$K{I^KnL!-;^FfezngrS)5K zWep%P^pCXku&$_1A%muj-acK_B!}&0xv1BHdmm=fzU_%8Y=L30d*rT&gmJNPcGZsT z7tvHnkV|DgIYXD@uF-qEIZm!`Y&7xzUS=>aR*mA(I+q~0Akty0 z4YTly1hi{T#n^^vw!dAm#Xq|+;FHSlcr@E)eJ~a*_;O6q}FI6 zMPE@Sn_4|aGX{FiGln~Gu|ZkLe4wlM`t37*8Ze#vz=U&6v$RA2)i zY{xo_dj6ly3ylYHwe|ApN>hWRuAjn<;IZ7OVG)C1@=!SAbF{W-jcvmH7_>e=)YA&| zC-=u5g$xq-0%Gw3D7BWTdiFr?Z~ls86=QfP-c`DHNSwJs&UmAcF$BBYtZL2I1MIn` zY3tEJrR4;#)z;fEE-LjM_JtIZBqJ0$mVoExXB__Q(gMhSRs~u6Wva%Eq{Al}B)mh29zU4e4xvuI({xa(hbeMeQM;3Go z5eI^{tvi%or@OC5p+ha&pW_^n5psOcbuFm0)y>xKz{%y+d*Wxhw;F21NzZw_K>YjQ z7qfU{n4kqJX~m0REg{A#XIcGS6u2bQI0cw!c`_c^K0ngI*dL#m-=RZf9LiHseBjJ~ zOG+T771{fpa6AwW;(#B{4-1?e4v&V3@9@-G;My8U(czx}CnL5D&b%uc4lQkQmC{CN zAxl_UR4&}~GoP(unXN){vCB|O(7~`Hx3J{fkC4EuSnIIdtISsfM=0ilj&3I^G(TEY zU_6~-a>WSZAM1qu9wle}(S$O8;_FDW;&|!vCi>|~JsIir#KRXzIM(57xL86>*v1J5 zPm?arhl1@5KKa08rJ=TQm~g)GEzwT6$;0J!a?0)O)TMBkkfIENs+xvY-1;vFfg8-T zN{QG>H1~julL|}gLXjX9Q>i+9eVWI-PZ2Z|DVK7B$b$Piy+PHW!+llMcb$dm&#Gcz zuV^5iI(G5~OEktvi%eTy&(>ZVRGP|t$lCH2bMDh4Z^`i0gr2NPQe^Ioa)is}{@Bx_!DB+}EJAf;vz-1%lF zSfPF%bPDzb@&ou17+5|sN&IcQZIBA-F%D?vu$Tl#JS{S1TVrz>>PQLWzbGK1UEnh` z>In{FzPCrduz?6|!U-BKM~5z;8vY(;C1_Z z$AXXt-OIoPR+_vraI)gi5bZQrGqkMmb9P477LR1=e(&0~Y(;72i2(7S#HQMPZ0|1d z5*YVU1>vRRpx=5&?v{p_O`&-9i%@cU>4iaWGC_N4^Fh2zoaLucX~H|vuJu8gBKI=u z6Nu4kR5~H7sUlR+>6{bRZRc^PteG78hd1mTDC`KmTJVw88wb@AGbE>{x|vz=dvIjq zVtSi&PA(k8;)R2&rJt4>hFJ}!OQLFfGF3DU;sCwHfWlVXG3ZR4=%DP@#)ne0R@*PN zJ=)HlP`*LxfEtQBd$9Br`-?gSZA;ad?@oAiU@&}Z@$-k@ ziI3u)Op)Z}H_e4QHLI~5Tn=DWIG>$3JJ*uo@?+lfMMuHOEwrSwaRg}YB(kz@qcU-%^;(Vo z0Cq*_$XAbry{{1@OhrKf;q{V}ol!mVS?Z6Bz(SBZtp}89Y#fJDWreIQw$O6Wq*uP< ziEAxY*vi#TKfYEjEQ-wTO!aov70AO8&YaiJUdWFb;qR8SWM~vUII#lL&Lcp0OFb;k zag6TU%!=1D4*B%Lp5MPKCob#q*lv?KY@rD8xoB^@jq!4z*{=oFxn7W;DS z){q}!GaIIJwP0r^VWs2ldWTcyPF<6(?EUuGhiR>G4C=sRclK}YdAJh0Eq6|zu2exM z-%gLJuWhYVU$0l~8{$E_znu_29Z}z|hAM$7l`@wDtPe_Fr&F&dti1*m&e}AQeRj94 zg>awNy?8Cxljeq5c0#PYQLc4}BQ0cZB(YsmSkkDyd4^j1F;1CM#=Pqm53-qE?5s6z z=x?v?KP*rfZ@YSB-AKR;dztk@DuixS4n*3;sbvTajCK0 z!S;pc6mB&JXmNRS=_9cmEK*GdbE9!bPU?s_c-HdXxaG%nY!nGwbl%t8DQ}S%E;Ce+ z!92O9!9o99tph1QPEu0m*oTtqz54!z#MPHchet%wI!;tYwZo}6V;E=kmzQvy!QP)) zE9-ENQ#ylZaNBEEHTP(WOe^0rW72l8cVDK8sr=J8S+t(xFfm?&QIf)x1>7C?w^O|FZS^^6CDPtg5i*dH@p^ zvg4`OW3s|QV`pEb4)K8xxF=6C3!Z2nM~HHcl1;jm)SK{+>m>+c!-Nn($&%jZDPrJJ zSG|o4<3rr@^862ij?16sG)N8P4Xx1H*h8)O-ZwYf%yRRSpQEU%XXP?oyoGMQoL6kt zT;xY)W~_%nRl|NZXU38@9u!rY8VS^yA-)qJo@m!F;`L3rkZG8Eps`F|PHw+YTFR=x z`H5wj<>0H6;_Ei)2z7-8ESnUhZ2M#!LqZ7mMA-Gs-u1WEu-t6;F&Ly`skq1=0k7Ea%-ImP^`}5>AI7;%uv9 zk!r$adXqsx_~ANR)v)t=Lf_py=N$y7!|Pwy9o_{P>6GE4-KT{q2pMWeZccXM?apO) zq&K_GP4^A7Y8*j&e9uTXG<`=BcL$RjVJ0NiksuHTmc{g6XATj>Q-v3pdzA(bmXH{` z_v^_Tl6ydIrf;yR!G6@ISFfWi{Zrn{$**VB4A}uCwXN*b!q!`P^HKh1G9i{6FTEr3 z&2E#7o`KM}{)z0nCr)f6Bzt`ng>DKC7g`Y+0ZmwA;gyfhiA7urd(bFf#MEc8L(UR{ zh2yKQjNX*k9&*zw>p_hNYA6Dk)|FH%Uhl>}#Ho5-sn~B;Ry3*pP=w-o#rX2j|)Ig5_^RdkKT`QT7PKa2U4_RAl(3ycCQ25$fKXkQR zmCe%8nAcY%9KTPvO&&g1#nu?VnDBjNerjfqIf8GeoLo33bopinrUW{lK~GNh772@G zn8W^pibjO1>4%s!@0_1&be`>UtE*^fD!Yfiq5iklyV9S6QW`d$TnuErn%=g_4RiaG zr|YfcB^Q(U>2-y}K!0cm-tXXu;3zx3%ZmnQ?i!nD5CdV82`O0ha)@RaeB(W^971Ap z78~x+N##-AMXPetNjy!}HqU484i?0a+{@pdaxB-yR9Eg96xGO3M z(zA7C%WoEPS3FN^SuAP#qyb&Cc%IF*&6DjO&(p%9Yz&yvev5m$ntMG4!+o>T1o>^k zY8Mg((LZLU0?sZcWkp&BLW9GtFVY@ zWLS(vc4>5}B|z5jy#}^r1XZfgYTG=WjEI#RIxC+7F~2>#&hhP=a|p&+T%sg z0r@4euU*C7nYvD{*&SF24*RV_3;129DX@pAi0Cu5D@|*+vz4jgw1=qX{j}Ab8rw9Y z`bq|)gZw?w%-#_M{zl%t$=Q8f_f>;TKm&&eA}TN7v@?({2T@ENIgye7mxA_PV_&rX zy--Ygkb=HBodlFzv-7a6-E%|MV)Xg)a|BTDaL(YjC^J+&Y4W z{`1G6+o}y7n}^2z2?Ca8Qb)R96OwcEa-r%P_LWfq7+@uW$-?KU>DDFXd_IRlxf<~) zpxPzz%bVNU;;fY0hKD*qudA~WQjCKr6GTE!N09U1-W2q2+1v1kxd zv108dWgs%6N2H1kwt4HWh0D@jw2_U7Y?W=lzAoJ)(?$9EOK2VTo{p!Xi0Fwej0=}_ zX%(yf-i!^ZYV&iSOqzoY-aNKz?{d^NZbZ`+PlU_3aI zmfT+3Er(?Z;J*AWfCn(lxTCy39^L~UgiEfNkHX>mFJ+GlRn{vpX*|?%dKap#$<(Kg zQlFo;^6y&8uB&tlawkqyipS@szw*_4gj?^#ePBdBT}x9_{cI)DnnkKzmhHAx#PV`i zf{YNSSm&8J4`KHH3q$4!3;Eq}uY``+2b`mqCBuBmQ10Nb5kwdWJnKe!hLpW){*f48 zo%+Q9&8cJR;--|myfwOh8jS_zXzzW&07$-Aavy@2afzLoS2mqYiqw)Zw- zJm_m%(K$Z8({ef|C-5i_`By-q!{|4$UU7dDfS*r<5bimz>m0Y?#TXImqL0%lP`GxM0@SYC zpEZow#pCC8Z+l)>4CJmrM6vVS8H|1xmF3<_LOc?=loREEy-wR5z1Hlu$VD}}{-9c! zUosCgx1N*Zy7WKzbn3Z5KMzuA)4cELO)?k1TA}o>>gea362%T?8l>R6BH|HU)Tr%n zuxsqLNX%-!dJ|jUKLQGvS!S_4*v)9a>OghCjKH#5$PvaBW>7-<`(Vo|J=SXfv)h~@ z(dfH(CigAgKylc#oE9o;)??IsWTubcomzTXAN1ik3TMm0561inO=DjVN>id#LOue$oDH(Q?s^fmqgq7WS zZScU*JYS!)zb?cVauT2-P7ZeLiNybeCT|P&+{qs zc=6tF1$6<$G6C{I#2hCA?pL#$be|SL#PZHjNtv*)Vh!oGT*{Q~>!@WDHp;8^aCE0q zZz9wQ$1ct2x)cpp_2`wuM+ulKIDJGUE5eM=5u9A=C(a)fOF=v2ZMui zQkam(mV|DX>tU)sLRHJwnelpOlkn(Xm%}iAR;J#?xK6yQ$8*nV0INq3PLZn@H}%D; zQx(zvo-MWQDEO@jX$II(IvO|yhU0FAm&9m9OL)e6vXF>nw#s_Xr-5*)wx)6kCDy61 zLjN0X6Wk&J4pd|I+1uPSfPt-yBms%7mg$KXH->u0KPr&~;!3C!FRggKrTmE@N@T&i zEsqOhD}al238AS{BAA86M5RUd?^+ND zhE73HmhqpEPuVgX@0ONq8u((E0>&OUw6`zdvSHvyJu?g>PJ=}{1BM0}9LbJP%jxFn zlb~0sw(AjJff7usmvrs5c0ge0_`FJV7s%XO8lz7jy-?+E$qG1OZV!nCM8!}R0#G+B zdH&1p#e)d_>RzvM{ljl=3h!dv-6YdP`8aU#(vttX*(%^enz>MdiKcL(`PP(C<3kCC zhrb+v9@>%fC<;4{O--cU@S}5tw&#p?|9liutcHH*sZEzUpAfIj-PPF9!StIVvty(I z%leVFH$AFbXi@MXW;WLdbTh3?Dr?5HF~&b6lb~X6w}`%!ytTp+7zd6!R^&?XAK>kVd5+$R%Jk2~#X)tDj3CVBCvN z-`#jy990A@WbYou#^mE@DdiRUEi2`qioJQ+AXM(`RO6O|;zM3#>32l`6WX<|Rh?FV zm?y~SEi0)UDer~!t>nDlGG&hDbMoFwaNA>nZcE{HY~}(S)erP~G}4dAHuYIZov|z| zU^4|rL}23;AwFAKBEYIT~AvJ?f5PkQw4ah9ar75qg#CK778L$Yjh&nv(meiAUc+h0Plt!sNOxv4H( zZzK6iIy@5(N66%&jh)2~qBe2=RNpU+cNJdpBs`}dY)ajvThHWde`(}b7791Ly{QOh z%X;eCd}OVXE^Jd&wGP(a+djbqB22!Q|DYsEK!j4au0ha1SN2a*Qcp||1KO2?**{@Q zqjTc`kXVR}x2dA?v6Kk)vm}ioT|rPgxbKd3i9GTrYi0@Q;j^(0U~nnOY%POW1O$8% z?Y63URH(DpLuVrO?M<)h%$nQ7 zx6irrdTKwn#6KRENmhXD&iL4j*ezUM{UWAvpf69MNLH)~aQSUBkYs`j?Q+#rC*>@W zk_DeSW5-{$e}O!hQ?D^vsyg-Byu~fc9HFutpV36YP)0o#3&$|^+?jHoR}GB!L#qz* zPI~6S+qw3MX4=9KSm9Tbn`#Ah>3=fLek8C3xcBdF{7#5yzNpHs8_96s&%2Bbv~}vq zNuN6ePyj(GEWAB?=EIuDvg{-eF~yF~+@h5skpz(U+p_%h(yaDWfqcAZflPL~+0ktz zaC@WJg~jkXvMi1U6FKRAat0ecL(&e*V;*Q38C1~D^@cRIm?ay-P59fUxBOa;a<;Ta z8Do+ZGh8?=&lJzCf7Ps93QL#U;ohs1&&#b#^tW9-jsDqI(S@|O9Nhq3m_m=SoK`u^ z3Ap5wl^FCj^~HZu=!pD~Z}TQf5i@Nl+`Yu4of`ad-`yzS(f8bex_$&Y3)%A-ZTHQW z>*ud#6!ZzMas0qSpes?tSQ5qJkbd;CS-ie)r-1Z|0~D*emS*G?<|;Rvr4H)F;d@FB75ZL^KRkmv)Jwh+iPlKRM5ci{-W1> zlFI8o^p>A4*RbcO9=QY5+rjS0j8=i*RJN3CCf^NH+-Q0WG`B28*UWJJ67qNvL_CIh zhmq7hR*Iqmk96zgqx6WW5G;d0OY^cO%vD3?D>wsIXxnPm>3XQ>p)s=7EQH zGZ3FCCitjTywzaoI`8IdnrOQ#T2iZ+vA9MYU~b*2?}jqnq5u@0s6Cp)=N|(%!`6nNL0Np%XGl?Zm3LjEmlFyjnVIZn?t{Hy;9C~JSLqQ9Mt z=FcB(<0Utjb00YSHR*Uk@*1jmY30%~gkzDp{n~&sy!+AH7o% zi(fNpgnE0tXb?TKFEU^Kxgx)4+@^-2-~nc}fRI!nid?6|c5_b5C++r|gAXn$wz_H0 zQ4kB?{UZz)kbD+F23PU@3gkz)wpIfd6~%IWlIW6eF+IFpbd~jH+2`S8uQKkpi9SZN z%1No!xoYB3UkOrUCpanUCj>`x{~|RW?2t(L#pgd)LHO5+_&?<5a{Y{@? z=6b^&q4y2pzy@0{45TRCOygmiss_iKyjENz<_v2rbJl3I$g9qlYJOb1qPg`&14ap0 zzBz2&o>sh!;PF+tJ!kt!dR(qYo6ajI4hn9&z1hk|=BEo-16xH%oCc*ZUeYJW-VPuA z$rfM=!=Aa^Kma`~4D;x2PuEsESa~Su4uVBDjeQ=kp%k3~cf>T@JWt%b9AlT6>s_s0 z7a0y|Jyqu!b}O$>tE1$qN$&<=v_fVdU~CQ$QCM@ z2+IhvZtbIHUVN(93N>;UwnCSi4D)y5*>t7VU%uf`U9%*vE+#fWCcZ$nLN4Vk`ARE^ z@M~-+>&O0+3jw}|G^<1U7^*@+M8v92z0}!FjcRi3#LNb~wR(s{1xZ}LAgO2xzLi%*ysS)pVI7uRNG>nm8p&qU>H;=X z89AK0{NgI{6oGgmgoQaRa>~deo!u}h+jXy#-$mw8= z!>t%_N-W&9y%>ken|R%ALnj{cFk5EuG;xzFQ~VJ%5}gWs*$nXc zq<*vJldkK2@eMaQzi`y_os(_tl??FxWK)h$;BgU(G4WQ~ ztSQ=)Jhs@mFBY&ng@f{AqX7VGcXyb-(*Aj^s)y}Gtjp`*EP^9E4KbMV5f^vA;erV7 zHEsyt|H3a3`)Wpk~=rlJ%7@g<`aOiRaM{}Xr1X)pcXk;4O zSv;6it7E&c{+DY9D5nZ}&m;@-kW(^bu0?6P1Ebw9u|O`J_ZrfAmHegD>%B3$oIu8P z;1}6z1;O3wsVK3v^(LK)R+B#TuFLQBOi@|^k(lXx_jQ*8?6O@O4`EMmOnCW4S@ZV9 z7$cyqj_qT=37n~UG%F5$Pa*B|(r#pIzjPiA;{#cY@0oLtHzBXpuAX9b7AuB(&udo! zcZ!`q_bbo#E9vQVf7&&=(qJy*Kc60XR?EKEuE$=F`B4N4d|xmhB24<9AO#%}|3hYp z@(0*<)%f;&d9XqfnQzApc7rpQHctXOiwo=OLE_AIDiE zJ8f@M^>j~O4&(d>Pg~v5dgRDsxAsv`2TxCJ_j2LbDB<7qNtkJzr467OaoY}C^U+N= zQ{Rvr$P`km>Nd|pL9WnTKp_uKaRnuVDDGZO{*QyHkXxhBqcs}b*%O1X0Z58d%iUct&WCw znC<*3DDwR4-i+!?+MCy#0#JZnv#f4$h*zxa@u}(=xbgI<>vKh-DJUr`9GN4O{jdaI zjOLB5Praqu`RJ)Rer7j@Q1$G*pU@JHQ+qi`X*gT@sy3Z|Sc(Yp{wAi>OyMhF(lPp3 z=9MsK94=un1gG-=JN_)LcCbe%0;P0m!IO{=Iqq;t{~HCr<;`s#uSXp6qCDyJ}79 z@EGRiDsP*Zn?`fF+D+4)W@?)hC3Yna(k;rUudtApjt0k5G(Aqcx~L~>pJw8GE**QH z&e#uYq7o}P7M{;s9!;s+>jlQT()0-z<;rMu+(=L@JHC0!Tq_5+eTe49b8;k3b<70? zjOM1;R^e3``J>KIr?IYoyc?k|hsG6ve@yrg*A38oEIj6udnQ8XY9)7KK|S(4$HnEt zA&kLqvT=52kJc=!CHkX0GjL&JgqP}rM=F4*2%TcWie=-S(rlW?KPJti0!EN>wBFKo zi_E58^cGF`WX><{>1Y!;?o1tofb!=g=O%6y+*8wKVYkc49p{d+Yj^vyN1V&UXk8A^ z(}5r~pesA2@M|IW>@xH+ObuWHpT zZ*vl7f!;E(DF(JVGY`s!B^WZl`id-eZ&z~Ah-#>AcAvpQQ`ZoBO*>_n#i z?ACzxc?rZCKdGM~xHM3t#519_D`C}VW7IJxfUh{OOfp&BJB=ISv!LUxW+RbwXxdl` zp9DWI`Z&VwejHcdeT$t?OZ>O#z6Wa?GGv*n>o?8!gaovllvUBna3LdQz04`*rwg9v zmzWOE*_?Nr20UDpN_t8L7mpFl zoXQvs?JNx>O$lE22p(RBgh0rJJj2V(o~ubaXAufo1b z*1_ka!T7$7g3-#A0#Kw7-(V@j&r(@W_HtKaYlfcQIdU`Kkv}?m@}iew+?)_jD+h6< ziAK`!5}M~6$djO>%9w1=E=E0tGKE*(jX(DuDElBbLCY2dyPGzRNgNHE$AjoN!?!M9*kJrveYDUY+U)k17t@ow zk`p-`6Sk%StBTCOoChGWv9MCQ-?oL2yykNcOcOTDszn@4PgqDsjgS9?JEhj(Q>w5x z@Qk6etE*}AOvW^lfq8cBYY=NgPVQq4ALlMKj0BXIJrgt>t3Br5X=wu#y{qVuT&A4* zp$flgs6xc08C75gqHRD*$)x;ZSbWQ*+A4ur|107M=I&-!ZWsU3Y0joS8yY^G+_syB zY}O_6>9ID!)y(eOk}GlsYysJTgc5Wa#_SBHM)^n_aKlOpU+q6i|K{YECtsf4OMcS+&o-?>!Eu zz$kxEW4bb}J~0zJ@eRkkS(_PeEAqTp>A$WUZ^_ef_zb8IlRabM^^d?I0K6kdFc@OG zGSYS4AKS|kce{8w-LtG0a49h|D_>WRw15_~zaz;0#_*8?{OTLAa6zb0oEl#M{%x6Tji}hpxEGz1Qs<9-HJBC-yOY1tSZM zj$xSDoBu0y=&)Hlx|rv29ZP^*#aIMF8h)du!05o^ z^uC{*8f}htU?LtSLn~7y4ZfT!Zvm%|rT%W&ZCWB2`ZB zLn3W(H(gG`=5=W!ytYDh-!3;9J6-h6OjNdArFT&uCvhPxJ{a!MR$tg1*sWfq^fK zK%boY698Mu}!K=#WMqi7O`@UdU_;c)*;&?eVW2p8vwp|A9Pa3#?u2y8#nII&WOUK#<# zDVu}hiZKW4a*72%@Hcp0s_ix9Qj1w~CptOg*}_Uf=pvBa|1_;|7eA4BQb)dYz$L}W z&y9ufPs?O0N(`q~rFCQI07D=aVu~FCs5g>Y?gbmaM>nOhdfS2VbQC=IGTc^``qQdZ zwLxtfUJhyZ)-aT~{MtQGsQzq-SABZ=tO`pIwMs@KLn##J8R+=aK_1w6@d>yrKkvi9 z{3ZAJ+F*b6vKGsMBF%fe@ebX4`Lm@VIGg^W-sBK&Gh@rO@})&{~{#`uE95 ztq9?UTBJpaQ?=H+1vUhN6l>;lca3WiF1E$q zt$DFeGtpHIEFRUWNvd}!Hq*hj@ulclSvTu?%#E3yXFb7Wbgx3!9s%yzfxK^!ULyC+ z72mXG90+mdh>>#rO*je&aE;M%zcs|dwoy@Fc1~MPSpVC>%E0Y($Tc`JHp~S)yath}V($^nIKAJ{CtDl=K*! zW>4diH3Y#Up1T}bRyf>j-4AnY1dpKr|JtyG-T^X>>E2^JZhyy(c{HbS+bZS|l}SAo z55$QwC>h-LopsJpJB}om0>hCh`N{KtomQm@uP2icDAS);!kU~CRm2lO2>uS;H=wZqb=-@f>~q~@ZI$KURO z19^B#J!9S}Jm)it50Hlt{kEnDVZdLKw7{Nu61I8nmTTd&eZW@1kP@+5K1?qC| zCr5LcaveoDr~+SBSK!gZWt1s!NQb&yo%f4S(d9M^$OTo}pS2$NPCAV3`-0G8`Lmt+ zgiW)Hvz_deLD|W-)I|;7wOq|<#5-Z;ADtnV8Q|DawU0_=XrW)Gem*Tmoa1NR9$@A7 zqc70H%BhS?tVnERtG5W1b16}2bi@v9-mCgto-Leu9CY)ie*gMbi41jH8#&uG9x2x1 ztRLC?R@vT}ZhzoDe=r&E<80awd!3;#%%zpatCr?^yDFK+Z@=db>HJPFcEbs8Fw~F1 z(&X}Ow6IqNRQgS?r}brGm_Jm%5L~%TF6S%=eh}Ih%{v&4yb_=yjVL`I=Mo*o6WgQi z?1T@O_0sairUfyM%Rx-WqLh}3nJXP9(mR`tQPDM*mwQhWsx|!G>2gxD_eJ6r&Cw)xv$4uCuU( z!DjTJP)oj&{E18GS^D_ra16$|Zx&)elHu=bfPOk6dLJ#?K49B4kf!1;$G)KBV#%I! z0<9>Ac`h$qC=;%>lq6BW**rHu`jW_WH2IW*#H4)`JA32y5%VS1W3A~m5E+kW*lFqK zVQILtO}#Sy8UQ&-IZ98_(DZvnX6HuS0hXz@R%Sx%szu|#Bj}YMFsFO*=Fdce^Yz#J zJUO2rR|BgTKktsX=ngG4xfOQgR*vuwKK5N%Ny|RK;4OxY6?A$&t_xLzD?;KojipnQ zn-q&=y3oIH(VQLTZuSh%t%L*0H(LG9ET_g%6{e~^o@)a;(z~Rq$qMeM8}EFVjA%9R z2Dj3XA+t#F8OX0wCcr`sA9dPrX<7?UJJWhhmGWPo+AdAan$BjwT6U935!`{hj2eaf z$KK5*?uN>|Dys~uc^`N6j*{lcA}&Zc&ZB}rzk#tMOO$o0Mn$o~292(=X)ZtDj0w(7s@zV?P7r&iqfA`_u?=m2mrCL8VQzK+DbF|onF-bC;jU#)uypB-3& zXA+b>*wn2GhgaKEG2j#OX`Y%cGCnl{yckHZvT>!^j;J?$@Y*u%Zy}**SfUpQ9f>gn zl!$SZNxoH^UHfvjpTa=OQ&g$UTISA0^~{i3F{RoTeC@tE_$uSJ6H5?XQbt`9SKlkP z-gRfm_mOjJI=ytJHpI;z$2aIb7g6z36njU{;ecugss&Kx%?hk`w9zz`gSJEoR_8;S zWJQyG{dm{gj%G0=#4|x2XlLJOO=sQT0eDUlestiX{0rtZrw+t3N&UN0QO>(ZR7a>g zPF_AF9wHMZJeQPj&PUrD`x0=(6f6?;?(HKz68SoGjB)6F<#r@}%Vn(L$0E40rdWK` zVoy)42*LrqWN6kHWIo}eG`JNu)zR#BIe8lIR2M>d`rqf(H|iuPEx1(XY(>QT-h>LG zBmv{0=OkNWzvP@)R}VZCO>`B&(-O2IWp@svc%a55WUW3tg0hBnJ@z(2f znWKy4mi>8)?@!xf)HRxCI=l2_81tv^I&`!N%@WBK4~_NPA3)(RoQc+_L*uhdm&i;v zlhM$#_qx|>qvk%Z_ehF8A*OvyH|NUn{Xs=RB$o**n z?>CiUX>YvFD*Tad0=M&-dcSCip<9d~r{lNZN9;H3^rOZbdMK2du0*&wC9m4O>Tm3F zqpIN@mpo9MtfX1IXZ?QW@tx)M_DNTPpzE~Fv0?^|cLs3|8?IR;R3L86P#$LZZioK2 zKc4dpb0ZCQj*)=SYo<&gl}`ZnJfrHEv6T~ZC5^j}-K+XzPe`2=+MMEo(kk z;#M{+p{`gC2n!v%o^|RuRnw>bgI$4?aj-Ato3;*A2vAY zo6^4EYs?QZ>1~y>?CPqhV@O|3;UL$oxr)z$d2~AY9!ls0{(1dp;7+HkD@sK{b-Cm$ zYr~hZpb!yrnT`V^!us{W2M&iHloUT)jmmh&) zGfJA~a2o1B*-Cv8>0Y+r0`2cjTT{ z>i##cBWksdeP?*xM8&(qk5G*r@2~AlHfLbT7S%M3?U)z#(G?l<*Aev2rcb@;OFvzV zcY;|yYx=y-rO4^H5&LY;t$)mNm_lOV=pAf;KGoiP!Cz-udr)bfQDT@i;=b{+SXXP( z%p4lR+tv1*quG(txgVsD^nZFYnPX=yPjUM|ur3gpgq!h~7l@ewI~m3zYiQ=_^-MU6 z&;gF!DgR!A2mK9(!GI=>fs#$pm!_2deRR>V@&r-_j3g&{5(e!3SJzjBO(YUw^U!mH zHPW~f=L>wx!wg($d|u(0AvVs8Gbgq$q#n1y@z{V5B(*dB-Z5wLH7U|R%c(r61WuM) zbUhJbqIeal>NdxPq@FfQHSG4{>HN1!?M2A|fG0)Jg=??MUL98h+7ajRp(1Gm%? z18C|a(iy`L6Dk`R3*of+^V+5LX-M(E{}%xC`DXAj;W=+V9yt@oXHTzl?pQ?PRrNbA z@4%TLEzUIjC*e_Uubp1dyL{g?{O^|)mtV&uq|n&1xm3Athd=hyWv!&sa_x<0kct}q zL@l$u5d74R;X)D8ZhNCtQ&_F`Z9`4m+sQokS-0!uhLlN=Jt~!<>ZP>}pO@Y0iP;nk zK?5nca#hY^%+ zLTm!HSW?ZStZECN!f%!iJ|g3QZWLWrj|a4|4wTU_lAT=5y-sBNFkp0U?WTL5yM8Xe z{VC2n3urJztr(GEu&XscdJ^3e|NnS;=Z8$Y_Y1ViO}4GcO>WXmwrx!HWZSlFPPXx6 z+pZ_ucAY-&_nh+w+`sI7@9SE7t!u5_8jDS;1C?WzZcW{4SAx|D}ojy#`A=E zTBiYWXt76iZLB6At7n84$h0JbdmC{c+IRgw07e1cDEGQyknz2IR?K$ym=&G7- zc;BQ&AD+jmX+)(y+~u_GS^G3U@?FCOwBGP{09@8ziu!bC4^HGn>5QU z2GH2I7ba?aYbcug{AhxoL|fb1yy(dndD+R~FbJ$k$4I#K&R$wH%oF8m=?&-1BDAqeU;{^z$VIiAz}6Nj#s4*TG$NXtEdWGs+12+PA-_BWnj zDa$;;vA0o|Y-0QO4Ii$*Y-!J~{fs4>E3+k|5$WsvvG&AG1pC@*TABJ~*8Pgg=)tph zN4aLi4cE+|1;lnxuI@dg6S>Id5-H1Gr~dN0KUU9 zN;n+ZFnD_mVBVV0S~I0XsW8^CI(p=dNlp)CIR!~Bm)eJ2&|FrpQE?bum&~1GrDXCG zTE&E=G6$w03LsmB4(6209WC$FKpQSmw;9BTg_3#yK^MGwc`MA7Cj4X)7_ z>~3nI+QMGagKW-mo&*|P{<$7#>W~yBjAm-EM40&gr6}v!0c?0x9!v8O;i=H8TXsK$ z$WbOJV9_>o7CoQLPj0(f9sI{;3T>gDiXxamt|sbkGem20IWM+ia)LPVPjq|4S-OrB z=PH;Xj~T>Wr{N=!4QMA=eRleN)R(v^o|U}I)J0hy?=G~>j6O_OYvIT(v6?E5R~wHc zw8*fYg}B;{{WHSN1tJ^c(HWQE+?SHluDXNa7JDZWfr0@zD{$rBC2xlSJsd9`kwRExu4~p{Jq?dd6r_r0jPC`q(%*?9@f8-us4*j{N?vhKnXdHT6G@!2K_Vh3-4`p^*bKVsG zymWhMS(M8SxsvYgb~BkClgX=7lXMXCUdFHwL`KQB7kU<%dScsGrmL&~p*l_VbnsgW zfQj%xgJ-*K1h*)bZ{3`l2vxyeeUU?iXR^b&Ro2VqRmAhzw1j|J?F`i zA{anLm6!i$icWWT=fiFmt>j%5K|shOUyH8|n_F4(DJI~lZz?)G;31T8TAo*C+ETkz z_uSP9!QOPlCMPAtOw4csEwFoVc*gm9JD%XP9^VBwX&maR{5+40z>7Sv*4$F~I4|~2 zz~(nu%mBs=OWty6lqLR2Q4=tWNES$Kb5xHMiuh5Mw9)BQgARh4raU+%eq0 ze@$&B>CM~bi|kIQ<&ERxc0z^ez=iE)!W9UcNaojpIrTJLlO&vb?`!M3udL%54m@~; zw&hP$0YBvW>&iP9LH*{p}d;PU|E#wxQ~q=Os*CxUf<#eTmVT4MtXU6b^k z{JtpPA|26q3Sso_XQ6oB#qY@AN!C)>3afDd(uo8S>Ofk zYg2_}>X)rA3KiQU_BVFMZP!EJ_m|NnHLR3n^tt!wB0Mk2MP|m#Xq2|W_2(mt_T-Ka z$3?NJPa0xt4$-H1{li@0&Hs&E`fj>}CFghx8{~ZZOiTmZ!1~(CWG! zfnRt13)<1UJi`c~OUKN|r>}P2%U2h@I!4Z;dLAqRF=VE3#DPH*pP%Mc6aWUIE9`3e z`!L0=0Q5e+8v*mb7MIL)K7)=J!aye19kXPuYYYCHN2Ct{&HTQ`9Zhi%* z=Rg>{6S1AdP*;h;>S&6u``8Y&juweE#LiezY2&CIe03Tnf5+gV0>t<-7p#w(Im*B) zCWPAMJx5Y9nWh$kgl{jkA({m|DV738&uYn4r^29sXPnpffP1K&)#rMD2~@E*5bs;g z<3Jl9OpLY>Zl^*nyu6wqu}-p%ZjIWt(xI@)WZ=w^2L*pcge#7|6;VzKc}tqI+OCp? zfRx6(NAT0Nz(>I^>qeHD*&0`tiz|?SKfrBS!MKO^+oeP?J1uqGp`nWIMYu0((ffY$ zr{3|v=98PoOf+KC-Z&{JF;e@oR#@BMRYqOEe6b_XcKyK!h(K9Jxpz+WRC?Q42A5O4 z&m$Q{=aNJz^JafNUVFIV{6A|@uty>xd0f}J7t}A93P{^a(c!w%AXdK5(XL% zqfvt>qjs{pD$sboTd-3j69s0_^VF=dn7j~#Xl_Qk-~l7SufiTR?*%b0r$6@gqOOka z-7Cyg^&WkJvoQP^%+DJCFLeQp0gj#DT+rt(eeh)QgW=y>+$)FjoD0rU8-^x=1Smkk zC?7Rl-*`2VI=TtX-|%-CEs_ zk9Dduzlw9D-CatjINmWPdy%IQql(H7C!@KxjdzD#2AclLeDTYBIoobB;#U>b(=d%q zP&Iey`7pDMujZX>KgzA9qT_ffH^z-M*~`o5B=9CIP1Z}v-9s;%7E6=-_N=I&xk1+X zc*Rm{=c3a8ckLc-sNyo*0j==l+uqHkM zICn#os48rbM<>ghzqoEU0^T#srYV?$-PR3|zdKsB4cu?;A`O1&Ldi;(zs8ch|6 zaeMbV78C1oVD+um+xS@Xr^Q()+Nr-2 zaq{Qb;iRzVyFz|`)gMcd`ApK` z+Mh|-5qb-490t^kPTW1}_8aU>a zuVpl-NgGrv-saJMB_dBrV0^YbTH38neD*l3yXjEp{98N_4J^47c!a}p6=*vi%2%E6 zqcNbU&25Xg{D!@C+gbXu3^3rBo@4)me*}^6p7NiKiy9UDNk!EYNP(&()}-YaU}i3M@k^3rQlucx3!Jf=5u_2z2+ zTa2FR2+iK0+y+q#2?!1wveF_Cxt1pgZ)3IjE640Ps>u!IMs0&s(}@d+{Cxgd%1i^| z6-M$AfCROxt)S0En@PxW&1WpY(m7m&m0I||r#S#vOx5o~b_G<^-umS(UesIefa0AX zne!nj{gbm-B}@r=crdUqDVyB&W@qnVtzFx4?p@-M=Rmj3a&Az5Wf>lbYpv|Yt#ILh!dNHylo(9X6 zs`PV1(S6JLmy^0BVI}8&%MUnDw!l61|8Eea8P?M;+m5w}h(Yq7vHL+P?{(De6hQGsA?nWLosqCZ+^ZQ#vQgozG%ywYHq1NA^{9 zB5>(J$2ee<9#XUe)w-@)$H=euv$O0wVLUeS9Nj00t-FEkeIV}{&+Fw+eoQA<9>H=- zY($^i-ufEZyj$6N0vMy?&Hr(BfEUQvCmmRcRx#Ha1+29bNiL$7;m;@k83&vmDo6jJ zx^*~=ZAu9DFRQoEd=hB^HG2y7c~g6O#t|{uY+VRxo4EAtpFW0!`1s)@bJZdJ~%%j zlGtz-hdk|?Rfg^S`Lu%DB$;HK6y6_io?~ve{ckk8!jztUb7;FF*5+sxzRoJ>0&Zdo z#!;?2J~=cQ$WW(lXO(gokI{33F$h#z&1mtV0LZV}s&GbADsPo+oUqH209c453{ydz*oph}E(uIVI4HoMDIZ%5 z&(Zbx!(2KS_ZVw;Ox$))dp%M_NCp+i5A$e_=G?XrZqnC`LNBx*UETGzl%cSx>u8Qb zt$>B`J(!`eEsG)e<)lvbV{dtQ1Xe~n0Kj?mj08-LznC1w-{sw{%NQG)_JT9sms-X>J*?+QQ3mHF8B$112d_v?_B}k^hHJxY5OGZP4nX)8 z6=@k<_&PA9>Oyu&FO+k4D4_9A8m&zea`Y{dx_Uon;)u;umZ%(8GhTQV#W=5Y!4kvS zGP00rz}AVhRV@LP``8sCuk$JCxx+i>0DR`Jbl?*end&o&f{mmFIkuoRpM3t7VH8?Q zcHz6l;ke4%lTlB)*G}Le@uklU!wu1?dj0{#&s#UXwwE%Nvr$cxjgIXIwl7xwaFmDm zhp_gOWF;r1!YS`y#24K>k?9zy)>q6ohZjeXEtAO2>rbI8gOhcz4mYwB;U$v~HMzNw zB*MDdVF7+2diJqK<_Ila=md>wTBEO4NYUTLNyq=>m`6&Zir&I(P))DbRoe~odSrqA zK@|g#VTQA4Nz0n@8#-V7vt@=6`qnq63n%1rlRh{cz<^j~v?RB;g;z}w^?x+t57=q! z6ane=Z7~^9^6Tbly4;NyEm=jkH7L`t`fb5Kr2B06^A5W0Raj5qnecT6xa?s zkLpSQGufb^oR3ZOx~>Yz=_IN@ zo{n@~x4jFy{}F}d7jKxobk08HMMlTFofC9uy*mE>_fg`CIZsg4x2aG9C=A>EH;;N* zG)))_p)ep2-GfRiMsH=30I!#;>Ttw2X)1_p6urk}`#jd2b6nVtrOJpk~)VPq}(yl-4i9*Xm73a)@5TVBro676L#~6q++i^zg!rm1EL* zUh%$hfy1Y_wkkJ-skx>ZaX%Uel`?%kUgmwt9W&l2(f|B@7*}3Pl+`mUMZzm7F+j)Z zE9*d%H=Hn2MO5oDt5vu@{kqO+pF+JIH0~WJEr$&|C#!I;1p^DIX-$tHt1)u!cjMR-*y((~4MHe66>QPxF-Hmw+Q4JUK|4)>AMS+bxZRR!y zvE5;MdPBc}Ig;KX{n#*>$0cWU{9=y&M-$YMBsSC-1^gpNxE>BES+Ez%FC`BN-!ZQ$ zh)R^>JXhop`mU#po1Rcbg%DEm6%t0#I)aD$Wa7uu?HyK=upY;*%~BxjZxEOkG&ckv z9FKs_P)>gvw>&#q#m*rF~D&(rv6Be6pYiZv3+C!u8q?3=2#+md2Aotp_ zG8_%fGyLZ4wePoPEbcWbp{k*XlJZ=Cbms#PE02no*pndLtSH7<}Qf3I54U|QK))lMisa!ds z<5ST7rZt)rL%A6k>55<&n-6ZQctumGhUKygY!&lD83p(XThQX$h&o%T@q+2uQ*WL5 z#0{|fWA;!t;_BBQNvT>~B{l=kmmF<}PPASY$2Uj1G)}ijS~#Rd7gQ{V;Do=?rf2dG zG^unq;!A|VmI&C%S&0IW>cM&n0@XHl0L{~(JY@6x8m*oxsEj!{h#T`S>^&GSCE|rm=bu%2V z*TE@mc$AaMKT&H(Ov_@XXa?`?o;&s(EDEaP=Lu4tg|dc;G{hx{Ealnr3^&C*au~Y* zRJLd)a5kH=Ao^|O!)5h$K>@RPg3s)yl*9HNZ2-=ZI)TCKIa?* zy%V8yOAM)$*1Q=GLCeO5x{MsIbz$z9#Lk+@n|fU`(n17o4DnOR;uxEntr`pdlbg1O~C2ws$@TO zX$h6_wz(SU@^?ol6BqQ8y3*^~VE+3rMiy&+AqI1a1_{V9$d!dbFOeMx`e4MUVIhI{ z&&pMLtcg@x3MBrKyQ>PvxTJX4rK|(;XKvExFZu8L4h{yW9?rHMFQDb7;XvSZ`@8Ys z>ef9#tZjf`wejb3d`72Ue6~R6$T48i=4~%3GX17+aW%Y8RZXfuAq*Qi-Yi1yCfja> z;U9VN_@eDW^x|x-&fKxnx)9wCQp>wleo3v0^jIj-Ug_#SpFMJ~M=yZZJTB7zF%kc# z7tkPwfBj|3J$1-`Bf1TZ_+@1}pBn~cOTpf`Rv&P7!#Cv!0iaG;m(NLi0>5jLY?71kY{|%I~z{61FrC|6cHTDw@ zONe&%dlyXY^07OMgLLPO2`Mlq@324QxzlBZ?D}9-CKPQ)iHZfGFAe$gT0O{`i9*y5 z_B)Ls9_JdVg3p%_AvWv^SUWT2y50#qF;{vV~be~qAe0);Y zccdG1i7l9C#N@vJ<+OY;GAc*sUAY2Bvemd3*R*Gf?mcHTWQYpdg5*Wx8El%7_~}p< zKq(E9v?u`G*VF;3f7QE7n(!I(+hU(lMM6Jo(JMr=He*W*8y4v|pzR`tf^CiZ0z-h^ z;<1OEJ)w+A3_huKU$EjA%?*7$Z5A4(Gk%F|k=5mAr?uaBOGDWyBH+W0Y+nBo00+$mwI ztt|#FhkpA%cVIsBn30$=A;S(utjX!MhNp(Nelx8U6;DI2pm*R1G!L$`qduicnMRgs z9M>g7CHA2#KAvjXd*WW>$iGhHZ`A`g1aaLK^m*OC5WTbv&4Yt|WDXe@QV2Qt?h{Un zg5w|ckp@3)%1YU)pRX3Q2XC5J(L9AjWh^xv-(4i-v^=e{W*jZ!0y4d~5o6$NerMe; zB%O3OxkNrP*yHc%7sDL?#3KoC9JW{gKW=lAth;N3Z7z=0aR56}?G$~h$$kO17}h$m z5R;#oFoLu6oDnsv`~X~}vgI_S1|{)hZiy+i^5sPjQKU^H_Kw4dddb#+{{E|v&nLxJ zXgjX51tHil(JF*eO1#%ZUc_BjtsbAWZ5S$W)Rqk%NRHGZ2oRQuc|VS z0Z#s4HI*TZXeM2YR-gVBRJVe*q1uK98Y%{96}6%yU$Era-$?*IF(@9z=7b!G-=%kS zgC;uMX&aON6;XRT&-Jr10-M|)o|n`j`J5DlVhdkAL0tWYOI}y`VnEl8(j^?)| zH1!$O2=c|V+q5F~Xa#;+_s&%Xwg%0wPxsqCv2$I^DWXmQPR-MdGQiJhLd|2l1g za_j;LEeFO$v}I|fF-#SpM!l~IA&*f!e*|e?Z};=((Zc+4laUFmoe|0jKe_9q1 z!{_t`Eqd^aY0fzMKbvjnd|c3_d2m+C0=Ev@kr`SZc=kPEAFbb5J{APt9hI-MD`ClT zmzum5sBz7NZn8Gt`z&}uqOxX-y(i&l%yj?V_Q*ODAd)Oqe&%$Xf<^9@HOc8{-h-?p zlC}3hTA5t6Svz-m)md4fZ4Lqzix*2I%w--E*y1wFi6fn8~=f(E9NA5i@^>Z|tp{y-J z-3(z!OD3b)nF)RQc$5NtfXcY0oInw#mAnkK>$j`zyg#XtXDJ)hN&k0X; z#Y;*nXB@mLsBzX>j;`#{A11p#Vm)@YCYLk1?hqV#oHdLWyAO&Nf%y`XW3x<0b;Cl6ZeW~yB|;XpE(|*3LpBc zj~(}gz%>Cp@xXuQ-C*mktiBlc`=(f~ zi`JQtNlkVqvW_dQgpn!i?NqKyk!^VspX0M`t(>##?eA(>K>H3VOQJ7B$)*AJ0%eGy zp6A&RcQfCe0Hs}+nK@>&!Vy4VCbv(g5ixJI*O|Y%(oel{ejz}a8KWv4KTX}nacRi`tQ2>!wHWR{Ya2i6%inN;C^$m z$jPJ+1TRZl31Y4jQKZymKW)&8Rna}c!h?m@W19@1WP`(^M&&T=amI;?`_lrnM#+8z zdDvX7zGqmVKWXDhJ#A_TMyVIX`1>)bto4$9wpqDZ3J%Tlv9s!sI{y% zDBQu!;_BH#wO0y}Z8Y|B(~A%YXO-f8UzzD`f$wgR`!qz%r+&4X&^1y~`Xh<}{E~dd zR*7J(k(-o)>9zL4#n(=q?7+xcIC6SV1$(<2o@eXH5eom#JpauQy?nPBnKty1*Z zc5$B^fX$LeO;|WAR@6BX4wlD97?(PT@SM34E_8?i6WA+xOi$wIq%PkIG2&j^%8|M{M&YL zFz<1_o!>AJg{;PK5;j(^fr!X*JOo1)B|oxWUv3}n+KVxmw{i=JGe*$ zwOjzXb^VH4z43%*nd4Wz>^CQZ8xK4WE;AE7FRLvI;U%hIwYePPpCA#`zQ&9C?2q0L z@&w+Lm`Tur{Bjn-^i>VOuZgb=F2-C((65VuJqxCsk89h9lSl$C2cadR-V2Ax0Yd{! zTRt1z-de~h!2@m_*lJ6ntgY+-}@ z8zYs^ee2r(--t)DQiX#X?r*IIE4;2OqnL7Cw|=gsQwA|4sdlhsqtUcc;w#1SPw_UD z)kqQ1GV%_>%7l+b#2ZwiqC>P}R6$gKj55tD^9hx4_#J3*ItqYugg|6W+loA~JF{*V zNo^ceLv09KjnjQ+Rr{9t-ty=kGFRsDh@ZAP&oiOP!RFewE^a$j5d(CuWBnO0V74tgi=<7P zQ30w>Xt1}`mPkKYNKq1IaMNShSclQaXIBbQlKf&w^B(@NJ3aBOXLl^~TsW`^yx-uI zFH*&8qC}^sE}^hsCg1hSJAIwgR_>_DX=KgYCp!D0`XKuut2%vccL{dUW$ur8lRtCh zfXoCVNsif^E@vsZS5ixQXW&Rtb(J~oHHu$p*lpP8D2nEuUq!Nz4;J1G)7|dcNyIl> z^JoeLc1a&$^!hLva5c{=gwdPxQi>tl+oG9|=8nqp>ZrEA%`fYEB=n)iRFOpFy1c|h zo;qhQU0AUMa?=U;hUa8wXGgrVszm;|kI#t8nJpm4dv_!NHDt^N-W(6{GmNGgHO&M%jc0n=+NBL{}u+fA6-d9pw>QZNR&LHysG@KS=6C z`i>M^{T$4y;=dU~!lU{N!N_1PZ@Ze1K_(A^syS}ZPkvpi^-A}C*CII_i5GigtL4q& zP~kelsa;qq<-?r_R7*NDvxZJ^!3~?B5Os?lFm(M5KU4|wWgl5mT-IqiAkIQGg<;bA z4$*Gpmoo=&6V<4=+AsURE%HO< z+4N3IMa(&y7e+^^Ojn{eBi&1~S@uxpZMSQ8J!_1TMXWf0ihkk!&b|*>LTivqO~}P| z&OjuJ;nO-IB7(k_&f}+*M8eA-ox){ zghF3lNJ5gx!~sJPk%EFz4g|HL=`#*RH#y#CyH{+_`({|!dsE|(IgNbpuH=?*aNB!s z?(h5b^jiM+gPNdAqh2*}p|x66ed@7c;x+Lu&&GdE4j&xT_ukPPw%g;nH%Z10t{AyH zBuEU&xq*XbVIA_~n%{iOgFWHLLyTxqS*KD)M?t=l+a=5AK0G>Q`aPRy;Uqz+?P^BJ z&A-2gE>Jz{s&p-;poYLL+v3RaF3MMV8(dj+wF>ZXdEh{8bJ`MFxwtfgG zw*9{M$W#q;2^5{?nH={g5N6XMp#p1oCW8d-b(A-JdQFdC!o+xL>T-m$1kZLT&l*eg zD8Tgyj9sg!Cy##eu+Mh_wqjHkBCt6wmBCs38}+rzl^7tm5FCx2E>pjeFTVw&J`-%0WN6Uv~eDSwidMp>Lz>e@*DA0$5xREpZw+?fYg#_1KaoGac`K zZ)fbdy`7bc+MlLbJ2SU|8sIm~ZC=|57*8zx;r1C)GTrdtoQ6 zJnxJBd6VgCML zyDyheMzgoF1V<4XFWJl~2>myYDfex_){VJKFPskRm4A;X)4P7O4oSmR)wOEbrudKJ$SDVU6 zjZpOu6@V)5mZ0iuMo64hMV7FIJ48&H6}ptMq)wgr_h%|LH4%<%+ANykz}NLbl9{%e zI?wNt6~Ec69!1KnEQ7N(4v2`-0q(Wk7BSHG2-=(b8ya;2sEXj~w9>Jk2GefLqzaRf zblTEo6{zlG-^Rl3^mWNbu|OpMrTv|YXtuA_Y-n07xy#Xi)?4+U#1cnUv^~dib#Asa zr|{RAR}V4pTKXX7LgVM57;zWipxo!#O>|(!OJT4di1qeJ&ci{O7LNbX<|s&YCNTd< zbjiNa(PP?tHZA+fw(Hp`Pc<0W*UMl~KY#>Du6b!2u-=8z{d)N_+mY#DF83S4M^>lm zx6yy#z~L`sOW|p=JkPTV*yA5Orn=H0JPfQZLsQ*3HURQ!i9=fbF5Vu#AqXE|6F)OgU zBz{JL`_GMt@&#Faf?3J&`pViYgPw4(;mVqreb*u(qgG1}_|~P;PQU zxoOu>-k7CUv)*~>58Ue1P~bQoP(tO>4_ze{wvG00lxODnG#>@~5h&25=(aOyvtflh z_)hw7r)fi)U2bLT&$Fe?xBmWi%?!chO?w|G-W88g{xYMursa>}5&(=>nihQMBiLxA zn-;rYW~z-_+)JBLXk%|}cpx^Ojp5KzS^5HiN@xO4oS6df-Y2uqwy0qs6h zvrdF4r~~+bP>Tg(R5E{_)tX;uJE+|b2ZD%TQxBCyGWUnG*6&OQt@9DTwf@_fZrDsd z^6r#j%|egZU|q3n{Gk*_1!rh*5(eyD6f0#!kjCI}dzhw1GY@VvSj4Q5)Bi9Sp zH4^z(Sfoqf>oNX?;_~kB{&vXWNI3Nrf~PI(>II&^k7-?gla5)}I=F2ibAY3Q?u1q2 zlC;31=!kd#Z%cv}jeOs=$Y^jXI{|M&aDN*@mOi>TgVnHq=S#AKJ4U*V5|OFv0?Cfw z+X9&-l%rSg5&_S9G|1`jJ&!NeA;&a?+`~6cc&-PdfyKBGk?8e$^oqS1QJa6b^>8@#&lHD;)CMf+yks;1!fu#Q`{eep4e zXrLEg$7c#kL@lE=g|7ZsXE;H%#Q2Xyap{hy_g22D-mh^iW(el;6_K}3 z#}=QdDfbltP2PGMl$;xZ#yYjOR8%b4dEnF*ce+ACCz=%)(>Upai;sHkisqMiB z*pySmpkiiy1o&oZzFvXAWHv;6T;zTSOXpz=?)31c9g-J=(1DBm{XbY>3oW)qHJ@}W z<+Vhg#ZaZevX)Z8NaDY`tt|hu40HXutO!fO?;6t8t87||3Y_;v^iWEhGID-peTGJ{ zXX=51KIATD>07cyO6~X7b7-|C_wi+Ga|cd-w&)ROw7_To-pGWjXOWg_;A{NtU-^=t zY0FnKHZt}zw2J4U;$kjDr;hY{D2Z5WVcW)JC2$7LAz`SkO1BOx8$RU|4mh?+(gjo0 z9SB!6-B;}9GqkRNiGW(oKQg@E+`Uc0x55LQmP`LFZLnqn>;^=rkwW;6p2d84`Dwl^ zN2qu!uV*%0IdZG;Jzj+jgLVUadJL^wFCHg`V%xPv#RU_H=+}#~(O*n&rPUr^Tk#(+ z;v0}uluF7R)QOZ>b$XYqyNp)KOKPjPED_$?9S6w)@WZo|4-lu;eo)#v(;m~H1;Xeo z!nAQ1=hQNn=YQwqYZqj1VdV!NNM@Vk?r4pb$d3#}Os@c=2MD>aIek#(+_-L`D;p|?1Dqu7_M z^f^w)fow#wuD~!t?{{RCX&QxX;(Y07hKN~dH}im^_@Ge!aiL(VPR*1as05a7*!fpl7bkjgiBd* z8hkSr$+A)Rt693z;ACD`n3qDF!S%K~>~sr)JP?E?HO*RFC$z#g$KPf=JYxVf+-#oF z_fq2OPrd@)TW*^;F(?A<=e?7;@(9Al3~LQCR~IruHlzF;owPQKwKI*snZW;dssCyZ zGpM=l=Mmxpf{Nv<7!%yK`I>AEw2@4F9aZYz0MrQ41>nh$aqP@!yy${$fs^{l=Om4S z#aXP)F>hAxn|bZxtS#i{d*s;Tk0pN8%ZVVhb;piLJRBJ|nn7^k@uZyIO9JF10k0;z z(H7~PNB}2^In9RP$=~fg&-1?`F45)!-)5g@2PpE=%g2MphQ|Kn{M3VAA`m?L<6A-e zc21e6MMDCU9lSU>n^jay5F(O54T}ef-}?*D{r(=KOHCcX$)xv#-(^Cw$3u`B@;VY^ zj%mIVbLl3qh%^bjPc1&LdPqiz4pCVKy`THWmW12A#8}$e_c`yj&||N@jrDZ#DLQ z>kDLCp5Z}gt8_C$S6c97u@Jp&Iv!W68`C4|LUD)Z-%WsA4vC`GVmkeaX3JQ#WVFi(k(eLOP7}1_{jR^w8@anHRRv}{O zOa)-JbVXwTheu`dtRJi`ZF>)^*Vo?1U3JBo>z)FBuVap_3_>#((l;p zIK}JP(t$BZ9FtgE$c@9zwq6d7IFwGh|9o5F^m6_jX+jHlbao2-eLGgoFZJ@2uUlb> zfTVtOL6Gpdp^AFKmiJpwk$% z5N7S5s&LMNgprCyA~4NO!3$G~^9jGsbn}l`&f%76e_dwYS?FWjs~ba zG$Gi)PWRjW9o8<--k{!Yd(*u`oRwe!Gn7{1L>Tv!w8R2u5w6UYAtp=CZx84;R#@jc z7ngFPu>u* z{6H}4f5wi4J4ZQ?8He%xni1uSRoowmn8?hx8zoUH`?y|p1b>?5C&5O?S>VDlDGt-~ zeo(z;(biM#Ht~BTZ@dJpTXnX7rjn>gQD;h*A~;9Ari=D+>M+ac(lyPs#rIo*)YxLZ zp3;`IFKl$O)``*3SXu!0mG=eL;t4A9>)t!lvYf@wURS>M`8^BpquU48*Kc9bevu`y zxo$LZe>$dXOocz}EIXJD+73lvw65nA{LOfDo=A7ly182_7>kgVewDX_=&0 zGLqOiK^)m6A8OTS_po~_)Kvt57F2|1aMg#`i$pV1!L$@Evyl(p72$!d54#ucRPgMW z*g5Gl*hFT1^m#Mvo5NTzavRTayhayVVBImTQg}!5|8;FVQ4$DakiR1jn%>oEY_`y< z{~e1WX}oGCTPzd{+CHAJRHgr=qdD_&!E5mCp_Gekt=DdD%p~a!dG!E;nd7^uG>6EXnHb|?&1Jigbj_usSGj}GJjzgi+WhN0fIySKDD_D=FeLo)^IpVlv+O4A5pzOwRv8Eo$#BbI+BCcS_1a5! zssg6##9!^|A}41HcjYcoziPYSsf&l8&Zlr}v~`*_YHdNJ3qNZFg!Agwnqr^ab_$QO zS2W5x{)$U(@u*=z z+MyY7!|iv&6C|$13fX^9Nw%!d&ys(q#HozP-9MQNLV*I&{kpvG(esHuQsL5-KV~;q zYxQhz^ntJv?RnSRgyqSug_1ZuHjEadxX0L<>DZYfZoM4KIhDsyYM`nwZpXlG&=NvT6 zBc`4j8X8Rq{t-x8x6eZSF>Lh^-aSq`an(6HTlLW0`^LJRAb_lGXCs&aAzOr_KZasH z_KCg}jX|lXs=PC?QEZ3Ax8*w9smEed*77&|l8qSj3G9_!9-KlYr9268aRHxy4xU=Y zFSF8#N{Xwc;pVHNj7lthP5$Yf54!s85+C!5yx<(c^XVwe+d!-3=!d2hOF+u9EB3VS zwyfHi(kvm+umz$ZrBb%&ALXg3AIf4$NFZSNxO-ujU1d!` zPaK%We$CiQ!r$5$7XB@F{`CX`s9Vd;tKjJ)fvK0e(E&HZF-?#Ev8cqt8nZc50K0pM zbW&{i{(1KgOunecG%1Rg0$UErKPVLN+!U}T7&_!lAGe4fX125va%@fEeEV0}$S9t= z2Fbpyxih@a$BuL2-wr}zz(c?AJn&?r&1CE^2Q;1 zeAYeXEq`fJvTx$raa?cE;RcSlH4^Y$_51zB@`yqAbZNGY45lJHV(l30K0>1d6Dr3_ z{eg1U7t;#)MtW6E7swn+zW>7VN7-5~eg+?*3d2gNfrf!dPA=tB0I_Y+T7cqu&}+I^pg>cm^` z)JH25yN@}{pC42K>iuw3ocvtBIR2ca8gUrf;M~fOey}^}0%B6S%@>x4 zO7AV0t-00K5-uq*Ke2D$bd7YHV_r(QI)Vlq>r`jnA`qRPpFdbhn3kX=_m!qUm;S@f zsHuUKW96EZHH^JOA%+=BhXi|5Z_oGp_>ZnHc89E!*N^WCn(ZL#X_s9)uH^N%b}xqW z3(jWh{d(*{Ew+*_VeEhsOGxd2a@k{N!sXv#%s(Y~sDd&@(Qfjd7(!nbaUhg=$TyHN z)f;K3hxMFuEf<4gzeqFy-7ORT<|5)o5a^a`i34OM{zvE+qbnfKc(DI3LcGf_ryk)} z&n5xYL3iU3`!O*b5$rP4QyxMS6gga?g&ekqB^?hP(RSNHoGl-WynD9Y_-=5{ALAT_ zv!-|}mnmGsEkG`62_i;tNg#Kn{D$6h333h^1`B~6T1*`NPD}OzP6XfedM4GQ94L%J*zWOQqAcc<*qXmorXjH)6@d_NEsM6TOxkl zg7z!k>C6)z?5Wm)0fO(pJH6}_>=l%7l6hJ6fD-yglon$Oh_B-Cs-qBsUQ`$F;wKt4 zUm9DJi#X%sDEGjQ5iG5+OW4PEzb3{KbiRMQpBG5A2+#o?S%mq`5UXr}u{=bK2N012 zOe@m`(s>gjCs_69I>grFAC0V&*pffE&bHQaP`+xaTSH%>&~atJzBP%f zg;`dMyNREi*L}e+ZV2S_WX6^yBY7H3`2Y(1N6zx2{QMCONBNW0OK^G zrlSyH29Sd8XNj{i3rOlIjWi8ZCY-HDu;K4awAfo|cQRUkyzI1fwvofu9^)dfsPZ|O zu~QLy9Pr$o!gtY%(&lzEuV6%+%3|wqxZgldP9|8!(z0`vqwfYr@gE##&;CCE!9YI0 zDEm}?5+GN$^ohz3_D8E`R;)Bh;~vu7jxJ0JS~@h>9T{7y5P&HiT=g)_5lk~UPAC$h zK?Ora{BD(Q{B{`E!+MO{4e(||gI<*chlCEqC#8D=-tMO2N#A!wwn)c0$XolXifx1ABIfEYL(G)y0yL z8s&NoH=A#LU$k^AlE##9!)Gboxyb-Wjj#=f^)c$DvJlNqy3~C8P$eb_ouJnwi5WS` zBHDwCL{28!6rAB!7~FEgVqI-eRm;A1B5!k7ujJ6D#oC7HH>&)`z9<-M&dyC0lhPCA zbtce_f*pmS)gS10YCC&7LH9L)81gXRiyQW7eX_IssPJ%k4;gih_LFuUoKPmh1?tgC zuolpc^QTV~6JfaPi=e4u-L6B@a}f=?xy`P8aHtesl~xb0G?9jL*@7A3h0QzNVhM1S zCDs--{EVpTbpw5JTp0}?d&?TTXME>g-Ins!IPb4*xdw7OHe0-mRXr?DKHyUL^CW9%?!F4|%w3=YHn`JqV0IU<3jqa0*6%FN6<}sNwOr zmQDULwU7lc+_Z|XZ+Zf;t%pmXO>^~ZL9P5V8@7on;Sw=evZbvxjBF3TPZ>248w1dy z8CY*5*T}c%@DLG?qyq2EC0)enUW+I=(Wdu8F;a_@a-;K4&z1V&cAHN+$?w#x{^a%T z;%I%VEa{yiRbpX&y0~l66k!8cwhFsR(5(_4d%v_+Ts0$Kby)6WeN`=;V$Gfsu@$Qg zVD-aqzz%?enCPlTp&H=po0vQoyckh6~V=ntJJi zlkKJ#AqFdBl-@P(?F3lwLVJUt?T;0ozjmdVkeMbP{^x6A-R^@v-#h&ddvHf{gWHAY z`0!aVOK5oHKyv1|-lUA2UKlY1Q5Q|keRo%9aLMlXW1AbQA`mnx-hncEI@&@}Ku}+A zPcV$IeSO@`)hWNTzyI9O2rog2h`g*^kNeem#AJ|7&)J_9M=mt za%@a~bGwPhWG@4q3XDKt1Og*4`Vp9oQ-6iz5xleb*3K@mcGp44dnW~DG{P4){dQZh zge;15n!Tl!V%CIlZbc6Q0n>Af17Xg4JKCj-MRKm`C4~l*k2rrTgVU67d~ zX*;aI{?>)?#n=!tT6bK12!L4k#0;wf(MN!+7=VvKXu8fmi~Tw z(EL?5)Juwu!yW_=N`sY0_t|!?Uz1u_e%#jA+Z|%icdTXly3Kp-Kc6TUe_vZK?!6*I z%qWOP6kE7FwcHGma6?&EtDboBKFl(CypeUAy!xcrEQOgGx3#uI0(6}(_*hK*>26Xb zafvB?eSJN_5w;k02GvQ%g|{3F!uVTuSvNOf2UkdaughA(Ikp`D@aNbC3p6({0)Y`Y zqa!dKwf!Bb$d_6Pc<8S23WT7am_s^&=56GqFcnbZDFm%2>YBv#yju4pq_OL7h3128B%zKPla1N3}=<-ol zzcoAi(FRHo4NE7cB?x+mF@T>rFZ=!X4-U)zTlc?ryhdED(WH-qYfGKfcXUf&4#I*m z3nx39j#bM%<_u*`?P70LqgY(PkOIC5>)Kb7Vm zSU6dvgHKPPVKRmH4U>TeU)WUw?)@Y2sx;KKyCH1vpy1c(ahPWXaN%4ZoEa9N@{P!nr6kq$pGh%m1h53;3ds2E%cTVASH`2B( zI<7xCEw`__>|i8@(WqWohOrajUI#;>!dwP@!i`(b5pMW8Iq6 zw#M2>Ob+0Q)PL=KFWfZlF6k2YJ;Lb3AyONE^YRo4=8d+{ex!K34H0$Sz0lBzH5#68 zbke7Z;A-S|25C|c$&nVg#HET~A(7E>j@bBQZ<=%0Gg*zxh;n;Sq-N#2cWmTLo{@{R zZ)Dfl{}ur7WB(cldKegizzB?P1oCm(qewo1ccThw5$ST#3EZg0otu@7gc2vfYaCd`XiO9!;=0$p0!S>o!3QL*^POTiYO>-PDALzlq}8cjUqqB#fD{!j>8zrW^v`cicw~k&~cytWyDc zjZRo+7z@zIwmQ&pG%{hv;#S;rnxmTCs{4awcxVGkVkJ|NUe%O3CU@(9vBzY01Dy(t zKwty{BjD2r(C7O{krw!r+mwl5%M(a3WmSIVyQ&evYjhIj=oZ~=a0RH#pxHkI06rlq z0u&b4Wp zz(0@a%4RNJ2d*sq4J!xK<{SG?K!gA1Er+Cs8fxUt!bx9YTD;g@*&B<*0=1wKr6X#@2cf5fcYmRZ(#V4oPyF1%M zI$Ih;89=JDwK=r6yCWE@T%!W@2<|mlICF9P;D94&%7XJ-9es|Vmb!|F_SUA*(*4_F z_ilb86;Xva&{~vEtVvKCwC-fz*-_Xw9Ae!?OtP(__`(oxfGn;HFYO z--;iu{CIdh@^FFod2k__o;$&9rNgxsiY?<_GCxFU7wk5?jSnAmo^3u;QNh^R6#(#K z=UN7O5*UHN2>3Swx*z)-6!Sf#yF3*~?r#@@67_(NV7&LJ;mn7Ny~(+mo=(*sV>$G( z?I|`Z!Rzu#+9I?EsKB~*c7ga4f)kmk78&4zUjz-d4v@+MaV{w?D&x+i=t!yM=5cRQ zt)cMnyv6w$z*D>=dZE@_<%Y5gz?eSUZ=FBUtVykE_(Qr1pqWm+W&iW%e*1edq zYvU_5GncJanuRd(Cz2yBF;&iJ1_t{@cYAwCZ%?NL@o+5}(bo<4l+s9CcNe+02qbx4 zMg;H*A56(9=s^;BQ)YGM70+#d|Fx&mYfl`Frjf1tnfg7{$UpqgD)GqkHEvrFgs z?D%18QDc2O0N}^^6%O<-Fam)Q(2YO}4$MR1`(g_c)mMB;d(6<6w4$$(VY=8sr;GN}1hB07~J2iy{ zrJi`_M%!B89frQ-twK~p&oBHmUo?UXYT8vouPyA4idUWvPCX;K(qoNP;y~5VX|wwP z&e=wtaGdBSQYX?w3;e&cFDhYI2-dAa4e9K0snXE+twR;EU)dN_(x#?mC2J=}oNPSy z?g5YBoqqYRpFP373<>D%qPw+8CKzzaX3==tmY6K)r#?6|T(rS8=&@?G?|+^^-usX5 zxj}lA;j`th9ImKAzdd0)_Y$c|f8;g>>ol-?)0&USqKK5%5YR);Qck6#i&gpIi8Xp*eb7BNY)3^dD2Z=9+9Y}m8i5l{d4kGc* zL@klmmkja*#P<;xhpaSCH7GLW`2H{w$NGy%T0~j|U)MkS_zlP+L|S~Dm^U#`2AYw9 zlJIh|!9AZZ5hVE@B8R;PMub?e7k!A#Nsci!VJn~!r;(*uQ!XpSA9Aoek#r!^$fx=w zKDvBbo-CihmcZPXw({kD`Sd(d3K|FNrVum|bWbHflzY7YAyp>^q1)R{KGvy?rk0qR zd~fpH&g0rFsftO6eAlyair0D{jQ(`t<=MAuW=;@$DjQuUE!@8#FHJjPX$8f{I>|8_ z%!vyPSaoB?CerQX_7nB0=E`EINhh62CEU9Zs~A(@s*;lvPL{YTqVtx+`|PQ0N5GBN zPdM1->j2(;aT97pnH|79xUIHQ(rlvQ62&0=j??>$O6Vr?!J94}HF#&4bZ4oW6!Lkb zpZaPd6~JcT^^t&dHGho=2=FS@1~+V9BnBfF#U83lfPM`}(%Z${pv9$s^+gu<<1 zXj~!yeWC>PX)8ivsB{V?+Ft#UI5Fb=`P3@Kv(@QU-x+ImG(k9zWi$&NiXAw_*~ zxODd~9B!$7w>-05?XB)JSJ%_0VUwM-oavfrENuk<{8+l0fxeszBS4fwbtg5y&mfsq zBh&;Nzq0%h;>&@-M%E*}hx90t`b~%f0r7bR_3XTaV>1&;Uy{oxV^!N2j?=ePs{3IBm zW}Mt*D_@<*N$==}rZ?6*-eq>B5V#xSqDz(*Q|E)pt%`W*`=NpB}jh!C0rw}jWt znxF-!U5Pc&8`*wrZDF#*BsSWB+~ z7aIM00B#?Dw_&ezeaZS40f1jNb^P#6W38_o6C7Qc1i`8{8f6ej6TFjSuM*xD#Q^6X zsZKbN_l+-JfoKHG35rhmV9?-}lY-F*C!<|lQNl!t6Ss}hHR=eqVt=k*Hdib~MAws% ze8gO!@hbyEaZ*Cs(6VV0#XYOfm&HEx=+mO2UakK?pr_DBzxmOr+m`>U_VA94i3r*R z4>bG<5F$OdsPmQwe|mKG1y`tN05X^JkKrbf?kN*eGVP$o^b*W>f#crO-5HFC#-QHx z1!1zlVf839fGu8lQOm^HOWRr+YQm50elM=_=-$|d>Jw4^7`&P*`O}tqtSH&U#gh{- zspBe``6xB;PMeRBt21&u1`|Quq>;w09nk!%WcqoR>4OreP8ixZJn6RN35APK%P#BY z3G86cyydf=cg}8){EDBmT?jNhFal@Q2pB&ggjakMlcte{L?Hetq}fQ^SB&IB^RsNT z93E%nmur!(M9N3{5|Yj^$fz4x0^(r==&oV~635Fd)bMDvJOl;|(nOk!Rru0*YW@3? z>q{Dh5vlT1S1gxaQY3K4GC#HLq#R&*9IqMudxl5mfD*A5bO>uM(oN{o+bqmu?Tq=j zr|gfq*;Y79Af~kbwXbK9vGAABo){EJQ(VpDxo(Y^Gb-d+=;kG*Uv75;j_NY z)^k+bQK7-&l1W+8vl2b@aKadh3GS*{lg0Jt&5@6R6;N8sA&4+%-|na(?|$9C{*YZ$awx{%2(YD%z2cU~j)>bAq>67`nvG{A zQchs`6U4C(Q)t(X&e;Iq?HUa{uch^wGtL|BC#M7a5q24H)PqRG-l{4bI#oTR7;S^j z;`0Ik{w%&Yfu@}kBS0`%fW)Ojy0NN7x(8`G(s(3l+nI;Ynia5Pm(RlUIRTh~bT`rw zB&)dm=2;I0wueW6>Ry*H>cdAgU-*){|JqgJ;<>!U?(**bW8&#|J`i0naHQ~);oq~8 z6U6FS6A@U6{kV0`O>Nz$tby{PaEzP&gxxO!&3i&qF5Ep7(ghWW;W zVlfN6XJ6pjD%&`2d~$54sU+YfIJU~FDdRj&cy?Aa z2)jo6&It;^^k`^P)^-@%T>Y?IUgMCU4Zz2j7JIaf;7S3#*EDwaK!Y1CiZc@3uC-Ah zsaE$aohn^y(k_86EmLD{qNl1kxKW%YXaLJ|GT|Sd$t;{qEuJz#d>X4}zj<-37R2kG$;xB4e zL8^_>9)j{nXxCH02YUpdq4o7+irp^FU>EkE%tp4Z&W#xx<$Lw79VrqS5iOnA% z&uFQSZ>|&1?P?MqnVXF3WfM2dPQnUW2v*g)T>-PvXndyI!bCX6Q6;2jMPh=c^aF3b zkg5U{`?xYTJZQ#ol+_CR0Ao}0nT!2gh9eE~Lm>eG|Dm|dfli)+5g@ICzbgde{56}2 zbP>{xNc`a-BH?l31*X#_OHlAeq&y^dlz^Q9@j3!r+T}#cCm}Gqblx!TV3& z*deMxEs-}K+PV?o_@hgT-CQaf=NO+f27M8Jzs*{0-oUKuaBZ_kx`$DQ^^^Q>x-#U% zAOVyS8d9zXcYZXQdTZGI$fjIe3CW0v8YatS^wyWQ>=u7~eUs=zpfN*go7=>5a2=TiV{u<7IG4;V7O4rb;@f|I z9@oWvb;Kyw`Wc;&WDk#sIAt1LUgKb=hnxOr0D5rU`|3^{iD+r83F`yZ2DhUzG}flb z?bw85$CUXOv=%SCu;s*|-O)#Oyc>UL+dE0U-Ce+Py1U1Q zxpGkyy<9Y$<}b+QE1U0-Ly~Kf8k7zns{>1N!zdr z%Xts9n)BgZ#$S02?RjNy=`fTapPri9Yr&P*8B_XN2cxvoC$~zn5Clq9K8KsMleOU7 z8z7bEa|K~xUb^ynsLD6*BW-}xlTv_KUe9q!F`yj5ANo+Hx#QEsZ9S93_VPOEk%p8f zdU5gOsfSvAt|IXIS_3I@#bO@H%t0SdrLgTZP$jTFScA9%=m0tPjBLvl2ctjMIyu4n z*;_k@jdeM$bba~Ko>Fna#B8k%g;AdIX)TxbAYE`%Nn>t$w4pQA$2RN|Z|^zka_x-s z8rwTS%jqyS<);e!@c<_Ya|*|ck6bWcJh}Egl;?7qa)XgnSKjoQ6A@9->YGk^mVY~` zq0cIq(vvo>u(zwVIiwmI`Ram8@+N22^UJAGy@IPtrJct zeu(sCB#rYh-Kl~77=c|#HAs0#9%aw*3XjM6BAj11PQ1ExkLyck=qv^wdUVr%@zBL{ zrD3E|SR3}gythoeUkdF#Xaeb=6kGxgCMhsxu*Z8sWmwAnwE*BQFS?<^t6RIEmG2UX z@a&>N0+Rnt?-D8@IPHPP+idKr%4gokh@n=_KDGRGYe-jylRapl&E@F6lkSoXBVsFW zn-iOBXBEhCu=bUKtL|JhSyFEf)-+2GKJnPcsI9Z;+k6yqw60bB9up)^e)z1WK|6j6 zTygTc5frmM7f#BSpc`MMCm7Yl&06)!DY*zx)hZ23Iq6|vcpq@pg?0m~35vB0f47sd zV!+82S2q~cik^vd_3%#F4VusIHtu!12CSF&If49m&}Bv~7*9uUkLc^{L_Pb&Ry-T( zJCrrJM_-lKpC_1Z1Q?FW=Ja*8i50V_h=0IV#k@Kq4_g1=!p&cKef5f4?(=!2&RT>& z+o7RhgK^1ej_Aas&Zg>$FnY0J@F?#dXo5v-QnG#isv8?-EV;C~bpQ64?eDBftt~qe z?TKS=ZJX|QfWxRe#xgo!OwS3eN>Z}(!2QE9J_LS>szmtYx1%gBdFl!zT{QsU{T%B+ z_P_|7D?z{xp46L zZnF1uiEen4Ne}9lE)~n5om2^d&AP)C!$l zMxAFBZ{?bg6DTZ%SCq<@j^Qz5%ccW3#{%f;T1oJHJM0NMV0g=kQVJ$D#srD&;<(et zJtqh%4=?8N=A`DS?MEa~HQL5&`|7?j30#whTw$_KMsxK?I7z#A*)-f2mD0-)S7o?L za4LE4*7g`dtG7l)F0jtiJ;Kv zqy^8EMh75H9NZDp55vo(l~lxyTJx_d_pIHm zzcOcSlLHM4jDTMwz@G!&=aZ2ZA!QA16S$8-~jhCJ!@Rw1()C0 zoHc$z-{hI|yIY$ZL&g^s_u!@^sN%&ptVuN9XJ#;IJ(qPD!hF>5kEo^1XF795gGNGn zh6<3;>jWnw^ujVC?qwLq+R^R<)lJ5x%O@u*I^$E9jEW7uxDD+!!6Cii(6h{@W7Q%a zHUW8{dfW-umflthc$_K4#F#oz(8XSD1Q&bPNk;VH~Yq}w&t+E{OJCI&p-V1A(ujQm7U`K zyyp{AGwpFH8TRh>mXPj_wvf8Y4YvqRlgq`v)IHmdkDPm=+5OrW4v(j2UWIy*0`Sb!i z_q!SB!G~}J_$J9fqL%dwNLM29;XZ8-xKmRZB;pd2?2~6M?Ciu3Z$fgKJs5^}SKfAS zW0Wm=V8WD{Jr27)C@LmypaY!8xP+u(Z9z(Uwo}`KP~?<^l=S`|JoaMg$5s|kswg?4 z)1=Tc?$=0DkhH1^92?8R7aD)sxpq$#IIl6^I>zrC?F5XUe(rw|DHqAB@R}Bn&y<>K zejfy|?QE=({;lcf+AQQcf8(-w;_J_Eb_3?jN3HfjfX4aIs@n15>(;nLBygr@k8B0! z+5G0Fc1)*1XMNPb_hB;VjNEBHlSeso4U)WTvk|NF2nNgarsTjm&vtsCF$PlZ#{p)g zR-A2C3EN02i$B7#DuHHP`n<9wqtw*Hx z-8hd1!M}ZHkM#Xc?Kj03H7?rodk4U!%4m=KsG**Q=&dR(zAVi0hv%#ZH~*O($E0@@ zUMB{6x^?~}Ese!egkPJyrUYB$+WRBC&W~^Q9IcgoCePp3zU%5|T`%n|^}1q1n}AgX zsiLEpo^=!tAkNu==^~b(aZA>Cn`A+9P*2`c~ zOi7vJ`Z`+bLmSFVBidT(K(86TJ(W#}j!$+hyXK>{6Q(a{-@EzE#G|`5C;DUT%0hXM z9&hatSpe~LHEAAVUaOMU-r*lsb0hFuIaoA%Y3ryo@GRIW7zc9dq11rm9f$4)rXvY+ z6Ro9;xozVGH|9DP=+K951jtpVx^NECVx*5CQQOY_r}UwA0(GFnu<(KG{Gy(vtFCH^ zj)@-#kBl5hO3if4T695YOkCmswdl~a2Zcf-f;J3GM5OaK&kWv&4_dzP+%Np#H|0P5 z%01(1DoS-Ih>=jJRo;i>QkSK;vAD_KuzaMek!B+iMbe^lCK4w%2a#Sw+J?mOyB_II zB&9?}go{{!%`Rw^8Od?#_$=v1Ix#jz^uoBVqrOIRbR}w4lt3>Vmm<*5E8w!~jpEY)CD+r5y5e-v)2|Bl9LAdZK>5$(Oq)dIdS(j0{TuqRtQc68Hv~UoiU#?n(qt-R*Jf4i$J=M;oXS!_#*Uh)QrGgI^q3s>C|yz zX(4}ZPfkdWP+b?JRIV7&C|MdsJ7DzM(ktL!U4+|Wge@^ugu?&3Zrm13$`?<(`L5_4 z@QoEi4*7Gx`Om_{^z6RHm*3XN`}p4}tUoR>&0&j4?8gd0NX7AeQNDPt$(k^=D?M*w z_xx4Y*EiObN4)p)lbH=w2nyvzlh3}k_w|c=-##X8DoPO_o0=vj#EV z5!JusirYs$W*lnkV-|xbwZXw2HTrz$l98ye`A|pzz<($%^O)=;ko@|Mip{JzwTYgeK8B*tB3T~%R%P``}=LATabKvJ2}rg7yl9o(tcqY z62bU z&B8LSnOPt;k@UtwBThronv?-7d7~#2OO2~1xj#FH-5nk@-p-`Re>W8EWxNTz}E5z z-gU>UdAa>2=o3$FJv2A^iLZr&pf)d!yL!iSj`l#wg zslrNw>O7XuHgcS;eKoet<#8WfcyfYd7XF4X8tVn{0OTpSj`^gZy_+HfcX#6;1|j#zga?$vG|)J!?K+w>cSLRNnodW4VqDe@lbq zvqeL1^D4ps+)MVfwIJ3)yYwd?lLGHTp@=MM9&j`uCpA&rynLZ}^@Ba40p4rq^(hY& z9G1JktV-EL26_7X|9qur#(9^v#wMhk6~C=i4AJ1VFgmug5gd6$XboqSSKK<59*=Y> zBqVGwF+Ha*AuX#fZ_4b>y_?@i0KF!ywV`GNk&k3|`9>qwClA$&^_8vSM@z?xIjJ#N z^>V)Rbm|3LOuSFxh(gkG^7>NJvK$#X zdG2qqq2f>0#kD`21gyU06U{UR`sqXWE@MCn z*P@KQ671p-LCsYNwlq68MFvG8AR{Qb8o=J*NE2L42KZigsL~C@u`zAXni5EoZe!HO zdPphh0+1qYgxX$tFR(Dh8;^kfN3U&@K!*1w!6CarT8$_EJnLs)r~%)2v>FtgV0gg^ zg&WLxsYz!$*hji`q`N_aQ1a4sGBYPt^2>F{jr}Zl9|8mY{e#29;N3XJ$w(EWsv3h~ z92mtX>+z(0J^|3niH|{}F{(qE$hqXym*Bs~nspdXpU3L5xFc0qw|GRboHrev&o=x)mO8<*JVJ zw|7ZFCfZglTJa1R!U@DTE}bVM-5O(P95X%#5hP6l`ObC`85=L6lZWg`hB6Sa->_`1 zxM0Ra(F}vuglPEIN1!!67oYyc6XHaj8vD@@^nuGJEX*sO+q&}R&sNS~d0q1|9Ey&O$D^^ zygNNhWk;X**c(U1jnKw_V}6bV@#_HURT7hs)EDY6cxORq22~!^Qn|DU?dxo93WXBI z({FwmP8+#turbyp0N}^^6+Uj{j-c+-BKTDbLO?unZIty9fECYO+!ddcVsB}zSMd?Z z`A4ZDHJ2Z^fHf!m z#S9>LvG&3MaWg0$VE_nACS(BYAQ%k*2l?3qKLknLSc;B;(WqHw!i`}J3^C0>#W>D# z*TQQJ1MN8Q@}lo;8g5d1PO1h$2Zg{^_wwcb4p4zcBP^XX6DC96Jznhuptu*wYiCd; zdVe91Bh5l31frAD6Qq`N_&5tU58}`PMv|p3aoyyCs|V~a!qGaeLJa6v^tjUzY?mOOWsM!oH&^CnvePNoz$px;MPm8K$wUq1=o;Xax|C1A9%NC4E=0tsta@5 z?^;AQHu=t|aos(3w@x7t8oPrO=-C+^0se^0LZY_y^GJF>csMu;8H|XG?9Ir@@0m7l zN$(Z6-rG!|KCWPLA3>2*Q%U!Frwu21Y%*SC=69J(-tiNr^eqB~=hdhGnCf!Cxxofs zjzoi2FTZon6VBql?8_>o?;_1b^7>*yo|BgLt{(C8XWtNyzxIw;3S&b0-=>z?EarJK$A=3Po2bJfRf^^grxzc3oAIW>=Cz`x+|kFC6<1-O7)g~}?3S)R^b^?Pj5cdF0&l2Zft-HQJv6^LML2RA^l1{g3hxT1y{9-VJ_2#_DzRxe)p?j z&NcF#v?WqyD4ayaBg(GXHew{F{u`+{bpYhAz~$yPtV-EY4#E1}i>J7O^JJ`MeER%p za+1T<9)Bkad{zb#7^=AnZdx(tPfAS}wzx!4Yt%m_McL`%ldI1czh1LeYs&a!;-d$5TdvWWI zx1Ucvy8FFE&n_$Z+z*O>9%&SHU04A$m1X{#4G%cxZNI)7H>IWU#Izi67(L239QD{= zk7u8Q2Lk~99K2{_sL6a(jYJJH4NyOdL`}O2q++-Kf)^*7mR zsOT6s?K;6b#TW37-xeib-T&74cgEDYOFA_Go&_yK`jvkro>jT|cAboL7cRy9cn6a* zbw-8nj8;$u7+{I6{T{q{j@E-p69E54&=90nc&L@6E)YP?$WD>2AdP!U)d>Q+g4B5N zJ~`~@tW^j?Jb`0t{1i5BD``ZW0wcv^4NM5*3 zIDujmSZd0-sxfz5sA^_2k<@bJNvp}qpVFOqcTss=MM>Q&e|k8txvmN`W&J(GBkL>M zL=>JOl{8kC4b8MwM@P3WZQO(&Qd=FEL}^MOo%WR1tEua(*c|}yXT@dsP&R_ z*AFkegHWjY{}vlf#g=n2a|`T@+ULLzvgHsrIi3W)XXT594b(}F5K%Ru!Joq*e+3%K zWCfnpovPe#03a1GR-T~PNzW=jf?s#?e9hp($hsFCW@^T*La_4W*27{-X5ujT=S{~8 z#!qtC86}lqjL|&DvarA8P19KRbpUK8UK=NeK2&Pw8G)2^qb;BzF~3SkD`6B?l@PG~ zdi`!maUpQ!I5Q&bcdl6A;);`)*to9IdeimZVID3>) zXgR*Tzd4q^YQ=d?X&dlsw2hM^tJHxOMuTeAGiJ@}ZJQI`|9NwVXu$nuJo`HlJ(l;# zy`X$eviFLpgk%u|etZylU?w4OKLTIz-Xt8s_L9=askt`ZPCkRj`JPBYK&ZH+G)HrN z6*TgPA`z9=wbQ2d(0_dT_@bU$zWm=uHox-swEbJ&O789M^oSAZ2VN!Z%}Sc5Dyz@? z+8S%Z!ongv0eHHV(t&%Q>p7P)db~L2Qbz)9J?lq+4;J6Rg-A3+ycH=EiCS0fj|z@? z6Zy^uXzz=Tj;<~&o>98|vg=x}yZipW-u}Vy!6BjaPy*uY$q5=5L^rsw<&$mYJM832 z9NpPP)8_OH`;Utq2g^irTZf1QN4cuLS(H{em+y@iyr;6M`TXe{+r@p$XSf+Y5>%>R zUrr-bx&)+eY}P}=M0$Rq#-5r;0)J|1N7G+V?L8NcaXh$c!9*99y8y7Lk!R#rop43N zrichh4>kV>x(g>;hHy+dwohz1Bqv|SgNkaUC!YP_vHyP4eyN=`&N&mG1i$3a>x7gP z2Kq7^&(dgijm0j2;Qs?eDP7$>-i3NXDi?vUCy9qwqt9J9V>lA6r@Y27PIfriq|bUz z9=JUfs{r)WGbMv2N}ilr5ncD z6GtM!CHLq(b72y^ZG5~NQf6(OB0HDUl$@%Ds-#A9>kF4(wa z`HJ$i{7EU9`P1^_(sOeGs>ay?u!B_)7nPrk;N3OqAEuM3*EpAa{g}9TN2eOVv&V5r zdL(2Nbe4SPe>tk7Glzfz~m5a-K*}iW3r&; zO&wkpGRo3@vMk0CAa&=B11DUx=QO4!nE%nWOI%g~tYz`KKE8RsI7II~8nJ28#xRg68ANjl|4dhFU(CpJ4t1 zc~qf8ivsU}6MdhVp3!!|{9EAY?G~+7Wnw-0c>-RP@00pZnhN zH=cYX=lGs2iK_d++7<>|8t7M2Jgr%k+q&nuvFb#45Mr_@Pjc?$`_JR+)5E#ve3^tnWA z4mgZ)Vi#18+DG%_kM_BVR{-sH#;M)m?bn9}O1TA-dUm|~Hpkj3oH&i%`rlgG>D#8U z(L?Zd@Ia@-;nVVLHZ*iUxp%c#F{e<(q-2TU2%7|{vWdnWIMiOtrpI2yfmeO+4J*Z+ zSDr8avSz*b($D@Ty<@0OB7d7)ZAOALWX0rMu@UYabper#e53g^wCFsSFV1vm$}a;& z=#I;miK6it!U65F4U~vhc=pHvaPNm}m{5Riu1-)0u8r?<2mU0)x&vVzW4?6wy=R(| zpDpXV9IG32t4Deg0exLlt84=Segpy*oT#c5yAK{0Z*4swN}!GHhQ_!?(u3^UF*MrY z&*zc47RT1I`o5Vt$q^XmkTmpxT6OGK(j{bn08|)Ii`V710C>zhTH$rzDgY;tkpRh5 z0@9B0dS=O zZJ~)@9y{L`H0$Vdi>GJZWw_-zw@~6u-v~C&(bP9LmXRwh1d4+cMo_4 z@4S915on6;OkE`iUWj@{dK6ddcs2c#XAz|pF+xI*?b;jehcr^FF<8AqC(WPgMjI}Wz)oe+_gfC zN8lrQ2RPb9a5xtr_*;z^KQvz>Q#iV%`%*Ii06+jqL_t*Q8@fS=3CxGEsS>{6ZIKb; zlQ&*0vJsi_&TsxkbeMw3n9q3(poYMSt`Ygtr^~@iXopV~fM;9c8FBRdIpPYWK9s-z zc%`^t?qpFkE>mQuB@V9u80|`of{u zWThpG1v93IPu_fm=mia-wy_zL2KcP6X%sJS+$lEfI4F*l*NWE8Zs|Hvo-~R-;K_&n ze&b%ogB^hJt0O(m7Bnk2HH4s7M}^6N>qctZzx&erF4PZSs;95;1dM4`YJ$85=g*lW zaxj66phLotP|?udClV46E%bPmCw+DJGA&Tn5~A^obD(W!yPKU+E7F9tI$6^eXtXtdlEhe z=^B@pq%6@W*;5(W3DM#@Oe`4j*I(fnzlZXhunOQ!NK3kWTAo~uQRSuo{sc^fX>4o= zZ~r)3lvp)qs(5zOcI8UbK))mC)j$7FzHts{CZg=Xj`;FJyJK(o{0~Zs7hcphnu&=~ zp0`grGU#e=4(Vua3_ZB*oj6BtPf+2E#U0~{XLoTWz}LFN!XpP$Y3vF=^5uv3M0K<_ zSUB=u5x z`yMhgP=J0J)x(MB>LHI8;au%hpxvi^1d>q$T^4>5X$sOwfp&m}{)B{tLs?nbhwlCK z{clWIbn#?cO70vmV)F2`77(AEnx6o)bV!Kn&ef@UbX>eAqg>KaI9#57=!Qn&jDKXH z#+#A8jQ6icL_q*K1K{JoKebvcn35yH!-ih+bl!=jm%;WL+;vGJDBODMnYxXye0L1* z;TJDA(&fwMi0A+J%i`9rJtAtG=+wYfsB!(-TRR8uzi?&{ImfSq8vuepeZPDvz&pYA z_^cFhgf4CIQ7gC8xJ-Yj<^TkIlUM%8bY$6t%aVtHG zW0u963X+o!zB<10p7{F5uf}*fCp=vFsHkleFRb4#e);#;L{%LpHf*YQp>eY(Lsyo- zfM5&YUp^F|-7Exb1njfmvXYmNhP(_!#qsb*Xt2s?y`F@VgDCt+_mxL zGzYcjy!mjQajgfVqT?JBrY~r|`1UVUPM&vuJ68huJK_z{Ghe}sg`F+%SW|WEK$N== zdAggN)P2dFUo2hw&)?;=HPmQVPdN@vb(IkgM_-WN5m}Afl^5A0NpOuBmz>tyP*rCA z(Km8Ylfh%|Kio!+G4^Q;)fnqmpgX5&1d34Z7mzMP%0n9M{R7%Me6vSHG)$Z_ZTnY$ z_{i4G@kO?vsQALc;E+tbeZuj3GupDztdkS`ZpfbwhqE-zW3M(am^PV7M1mC z>IGcr0ZR^q8%zUvXRnM_af{zA zeZ?kyqSB4ta1LZM&a$4vvWX8)R;Z;Pz--)(3D`egdr$o0Z?B1? zEU=1VJ>=K+^9Z8$94I;}EkCb6;|5#<4L^g4`62|l@;1kxP)z9HjYZfJQ{_bDi~sj` z*nH?GB-ZkiGjqGX{`fmP0pi^)+^w1Y*zR{Yf`0b7E2r$)^jex~0`5{29$Z^Yygg^a z)b<6J-c-Bv$~zip$m)_uvoSE(FWMUG!my%XQ@Nf5$Pa(}AHU5mJGc`+zH-!DOG4cr z|IY9CU2xsU>s=3c-;%#K%w{PTE-i!q6?!9B2t#OQf3uq{XctG0T@@6wQt;Ao5q^d-KhXoN?W9~h2ku31r}#vf7r!g zk;PeL7iV#IS)4+FQg=(6rb!$3Ol1D=yOWo^d2`>JnOKvO)85Q`_g&X>&pjd@yZksY zy1%PI8)aNJb^wgCgp$R^lCbX-o#?A$D9GX9^o=jMV2?QTtiOxB;Op)PAhkLrAx4~Z zlZ+-*Lk=XsrMzuPQ5}c%p}JFQb|Dp? z!1_?t=gAP_9t*_V-FW3XMFCf;@+^W1M~3I&gF5cdngZc-u!+ewl&QDv54H5%2c7RU z2BiVpXi9dnm@_0@$`V*nogMZG>f|jKsql_|)KYT%vcDsb7 zdUySAkkn_g9R?3ywPfHgpTCjf^@$qoPh|e6v#!}OX2yb&)c(WVo<`V*q{i>rvNi-% zf8zuZ)saIYmz-AkhQqoJO&0k!4 z!Z}wJM#Mz>1O$aZ60B|MnPrHjLoSs31!CS<-5_9kPHv)hBGUCRz(l9T$J*osa>I4< z>6jO+XRuZ$0wRLIhDtfO4nTvx|McyMzg@UE%LIXc3ngFNF{SU2R5)%9g?k)ss8x!j zNY6iHkhuBmBg8M+RyL>(141^wNZZ;r8%QW!h7(lHlLMYQF zJ_axMxi>#4svyMK79e6cD6@}PICFy36tiT`B>CHxv|8e@umlcb*1<5A9UnXHugrpT z3I8PG)|?-o@#_0&!0iIGr0 z8Xpzmq`}D1n9XgsQpby&9=1{ znI#4KqN%ZmN|qUK3S$Th<4)CAKKkLB)a;=i@uydm7W(IIT^mwcRp~+&j)3K7M}o$k-&?kcrdF297V@7G&|07$9>&An1V)DK=~?nF zyUi2YkQui_dV%y!dmQ%4CP^r2!hT3a94#v?5qO%Duv9!{NV_v%gMrA$!h$@CM4Sl! z+Xi2M_bOf-pfFQ{q<)D~y`1nZV{gGEb6A!%fv1EfFCC(clPxzr@21{Yo znG=RV05#cwD0-TjqdW{PpITo%O`yZVIkI-9G#eAwif8;ahBJHL5A~GP(|_wzZ;P*g zfw3hZejN!z`lX0RuRmK%8rfe#b$7I}H+Z}Ivauxq4WRT^f%1+8s~U*tGavsd9s!%m zP7D3$j);!Gfk2$k0NY6>1Y8rsgB?PtSVh{ri0Bh{^GxKGLb6>6Y_)ROO(25)%(4xl z1V}gixQ8qm*I&#VmLU(5^rH-db||naC5?u?w0x7K*lX!&bt22n!zWI2GNQG;uuMGo zzg5!Mm=?~fU-l2~VQCRHIb&eu;!AGbo<3wub$oJ~P3{0*B__nC72k&6eeMxs+l#<6 z+sQ|ry62+1Ufkko5$dWdeRpnH5n5eYuAq+a651C_uAe<6Ye-E(N;}zxv`%cEI$9Zt z!2jznrw;h})7Q*yae1pS3T`W$A-0yv!S$=?jDxF!y|r=M0>qYxtdnp05;#)c+XT>m zTek45OLi?d{bG^OC%wrZlJIRxND+z1I{(!)i3S*KtZVQQO=NokOAJvfM?ReRx*Yk~ zU66NHsTQ{adn-jfDDr_Z!45>8{@%jIRm(#q^;3;h5>f6h2t0We?}f8O-V>qC;^E7V z6_e$rj4ssmp;g;xD*RUBx}R(b;Sh|A0&+7nyH?Z(M2L`(U=ahAgOY0Ok`Ojmu-)KZ zhL%}`-9N~YFEZZ)Ypqw_`+|7?+ZA5%XEPs`fmHU}E;&(ZlW}Fua4Cq+gB90yFFeY2 zz&pM8WB9C#*d>pSl9B3e4l)b^peZCc-r+$6&?;hclA50W7jIuGa`Fnrb02&weqOy* ztlqNAD;{KH35np{ap_6oA_xdm%ZwgaR&C6&ydD%&AKm>k$5$=bJembrsjvN_1(U?g zvDxDG7rp{DegkeGB#px~Eeh|x4Sw&FCk&93ds3mvD#WtNHa3mv*$48hSJgF0YCD!x z5sEg_=1o^4?<_Gj^1KMEs@uRGqwf#0b-cb}tE86mCW3V*^6d_2Q0iG61lGY4u0B02 z9@mn0z*Bz;ScEjFM0=Fs5Yc8pS$RuZZ>#g!y=6`01D7ux4YiH+6XqPh_ozQzwFgLj zUCTuAAd`sb1c+5&=Tm^iexL{~#ESpDla#Yz<(`Zoqdk(93JMKx${aGbdfS>`!s=?P z3=|gdm||H735#kB4UcZQ95a!e+xkBq2nELpPprow6(@ z8vwEd*cVJITZIIeCdncP5~!#C?{J4&6@p{s<};4~O#!FyE7=Kj^|39;v>PtVg$jN4=`HP6Wbe)kFXNL_G59XHZuOq|TFpqDzy;tf-pP z3VGKBZ^vVEN0G8%tAYc5)I{W*{*G@%w%8IwV4&(h*BvijfWfBgp7_`;n-*C=UWZ^P zsn4u_-nq`PRcv}^1F7006F$J4~5*5FXVonJpB_}0n{w6 zD_9}k-}EBbjauabRB4lnUtUt+75+yge(1#M6;w4@ynjz1?0Cp?1Gk&NkdUU>FZA|X?UzL0^Ia1>czwL;z=O9L{xCJ;l;E;QD*Nql zOW=57^%m^GAt8k)pMTBT!DFW+4;VJOAu=ujh`ZI?QfGnrDfePv==DTwfw1!gFZUj> zG{7cAb3DUt#EqsW1K53<@~cF7WFS=R28uvb&rm~k?TGM(MMP??q6#z`o}Og;Ylo+!>Zax4%91S5&rq+huSligq~49qVb&!@^#z9wGX~3!+V1qhCqk(oXI1^1J|4( z`ou@Oq^-|~&oXP&k%EoQyPs?zvhPR=JHEA)&ZI4jY4(iwmRcE#%SxJk!{7nso`)mC z)n^poW(V4j-=6|4zYFQl!`5*6NFmyTB7p3-zsojeO$N@Dh*P ztivEs-c`UdY}i{OUWLFY)oGeRD)h(GvZ^P*G-;`oI_a%|Z5D%rzs8EguoP@f$L(Ma zR7-X=RF)P6{_piCGJGJfbJ|}X*y&07wbkH3Z|$a*C~={QVqoBqz(Ee>jP`*p-mCcAs+&x zP;AEz0VZQ8c3pLaZ*6t870${*(JCO(AqDB)iC8s8_bz#F>AYzHPKPYGX>fmr8vtk2 zmj*9Ou!GuO93K~-bNsoN?H@b+n3U{6Bcb*~@YL%SU|!0pNo_B%iDIKF#wH-^(7Go5 zo%T3wJ&<3#%BI@~=AKzrCu%C|MO`9p#Ig2yq+0@3^Q`7ARcP_M`3~6mEyG^B2hLoY z3?HWs&k%Q>H&-GY+-S9~HWsfERK@>%{EK)6{HSYyz^f2SKRZ0S5)^fu*#gZV4Bq1J}{$?1*=X=R`P*;PSEx_)>(QT zgyUOQRQZRMMa^Z0;}2eCJIFuE2-1@Wo)OR-GbAeoho+I@p65OgPrmV`#|D!q9J}z! z`yo&~UfjBLiHL@-cjIBsJa^|Wb+{D>z|z;$U;~>T&eM%xwa_}(IjGZ{>}J$cV~1iC z-WpxDc&g|FE5Fx1@sTJ4kDwLgz&^pIf)cQU6f2QSs#W|Ahu1GQ>=s9l$aG4IHVa<) zb&FU7Rub~!%knsdaJ*<#Yi|*FrpIn<3;cwG$cxK2S|aZ>UUl)bksjxKwG$j zL;59&k$oNd^R@C*7Ka*tsH&1i!>A#N>^dVLV4qW5X)mm()`GfAUiqCo-9LQrLduC( z-kk&XQ?ssUiAzCJ6*8U{#RF+8S4(72w76>OUE<`C7mMHb{wN+_errn_Ee{wYzP@c^ zFeSx5G%T_)I5eU$Aq7pqL98ukEg7`q0ZD4489ntUG%^+}I+67iCHu@|^LWyyfNM@W za_>=5tq_48RgvDpDqDajY?4q&Z9g8)Y~bUQDbxoB1{Iui=D81@a^YW-L*n~P0O6CY zH&#?&Wn!4-cCy6O){6#E+-=kf)3R!D#x^g9(og%K{VG#DI6Tpwk{TZ$0;7a}@`l8; zV$`F(ee+tY!xwk|1Btw|>=ay`#0N9Tgf8jlu0KJ9;F_9Xw*LPHYU91Hej`3uw$UrZ zU6n6cFP2>Up!oiozY8MWpDkN0{_@Z(($cA(0;!6t`H=N71fM4ksg_A#~Okd0^4&OPG0Usj8q zxqCs~Y!ZXh6U4ZIeWlhUBI0hLxkB=X^+W{LG6XC`ryVgy^nsR^%O8DTYzJ~~25c*` zggCXCBJd6fVd{ZGoNoacUB0uRl^0PT!0^moA|mf8Rka3`d-9A^_%jZ=-l^|B0qP<> z0haB()!U(}QO!2PG$_b>HdIj3f~%)cP*DxUTeh|`iXZ0UFj%l%I67O(6to0F<;mzY zqHi3Gf`tic!eQ9N$Pgg$egX!cd~SsxGRHuBpthQ zvY(50RB)`I+pzz4Sq~S)A9bmKo_8K1+NvF=Wn9Sh%%uoz_F#)j#GM0B*L7i_@JGfb z*a~)UHA|bDNjroV%#!WC{=Eo%_wAeZl#DCfG`Mr%$lvZQn~dGh&#!Xq;!!2~Gq-Adrpyhfe#g`06< zji`zb1riTCG-kP!7wz}kw`Z5xL5yo($0#i~D}Z}{F1t(NOvfCpUY-Igj)~D$@<>Ab zw0@U3^RCxL4ycVC1w`^@UwDT&Z_!Ng(955AOm!!#40X3(4u1OK{h=ub2$u@D-$)K; zbSN8w0)hOMz8$>L$z%_fKWrpAM+3?>@}6;e(nq`l@=8QRP4=HvWHaGnJ@XN zK<-%wKcx=Hk)B-a|+gV zOsh`WI$lCUL;Kk3YLBsEbD4vU`0^7DmDkZNW5AHsYOuVB|RT`aBPYN?ZF{-Qhn zBff$vOC$25h2bxeiW;z@?1s*C3ZuUC`R`&B43PaDg4wg6Wrj$(8FioWSvUO%Vfe$L zll+0D$A}xB`AnwEaNa^F)tI6jdZ+?lc{mW&+Dc1euZ%JjROxBBS+FWQ15!&2RWrJ1 zivg8pPidui4jOhYpE+9cm+Q-7d?w{S5^QN@Fy>_hYVA1|Z8Cdsn(>p{_kj@TWj#K{ zjRjeY*w)kxMPpBA;;_Z`rqhGt4Lu_t_{T8<^X_jfK5O-;^sMv-oTe)*Vn=?&l%n%J#P^B5>x{DT8^T6hMO8|?8 zKG~xtcRX{yu_4;?ZfN! zn3#sSseSpw|FT_5YC(w)7U5vQ^fNX{RBU`>a7d`@irgA-8y?wrR$*F0ckGkDt+w_$ zSU5DJkcbrU`7T{JNkm|?R*eB7M4NW+7iZr2n#agHk&I|i)0lREzpvN>6?88}1enmf189BGzTnuyn*dLR@g)TafH`k8taYNdeEx zW{i}SXT5)tq$G#JGO9zTy8auV+0K#B#Bu_t(v!3LxK+=%2Nd&%zOvSno(~OCMC3^c zw*s=oj0HQ=cW66WNF|W!J~g|KmW5o^omdx$ z#81ge78zhwa(6h9g|L{&q{351;EN5r96>Gqs*raE4~>kr1zE4>GF7sV)QD>3suBZ^ z*7B`3SPS$O;TZuiM(wD50|cr!?59YSE5QDRy&Nnwu3x%|k7K$!wLbOfMfYQ znF>0T?c^Nn;CSmr;CpsIQg{1xa3kTon~c~bFd>=})3U2i{qrrklc&w9i;RkH0yToO zUt9n1XbqStaG87xVV|wT( ziT>G}yn4fW(*lko6c-$}w_JD>(aDQGb<_ay&C@rDqc8i1*s_aL!Wt@TYQ@#}zbO9w z)|X;4!n@PUZk-w=`$3g`5!CoEpbqis6Au^X%%1?ZD>vIMuX6xA7}7c|52Vw_WQ)(B z;?WbJ8pNTnmU`6iOpyu%m7uj1pTmLJFBvyLyap{V8==ujYd<2&Y0$R9ctgN;qh}{! zJR^{W=!=cJq3UsqROe{?u79V{XH2seA-%uxS!&~fp{i`mV_H?^4FbzTHXc5E0-I2$ zbyzpZDl|SLQL58qJgx^5u}(~cA11Tso12?VNx7dpG+nI9Efo8~Yi|X90noY#)s4n5 zq=e&EPwNWfXQ%f@GNURW)GtCRLb8=LiP8-=5!M%LAdq<&RubiPC5jmELfJEijWg$2 zQpf62UtYMi4ASPD!MATkpkxep2!5B=p|dmdY-V(JDSFfQwgsjl!i{jV;JoWYOx##d z+ovr$d(Wi9XVs2AWNJMPE@9+z@Y8t&SaBqaOld9b69IWgBs=)y9Sf01%aKRf{P6-c z_W(EXDzYA-1@uOr_U+y6Us#Z5wun)HXZrzaG>*fIfWSwa;!7L?NLe-mBH-hp zQ=UjFDd-d?EdVci0x0f8UNvCqQ3#d?@SnhwzX*pP zQtydq%XIMDk-+w&y3Jw>Sp_Y>l+1yZySJ=$;m?kYORU=t<48%F18PZO_N9XrX(MlX zX>oyneO-;qjV#me8=R$O11+X@QIS&*P8jL~vxOT_N>pcsst7eS$6 zjXZqFQ?9zqtJK~Hr>}R&-@(}iJ*ieCtK0>5Hk_%DpsGS#)Z$Yzs*gJLqWqIDxTeHM zH2MK?*MfSrvVpy^smvy7VW_UY1!>n(>iunB3mBGE+-lg!4<8!fV2!G-tnh27uQ%(j z{m9%JOKD%}tYS0YORWNoz}0mubSta&Q6ebMfBKu)3~H7Y5COXHqNBuO=(u)V76B#_t9R#vHeZd`H)hh{vm6t(M7>Eha5db;i}Ew2u^C)e!brXgwJX zUUq6@GJ`-+T8H$Bk;uCihR7u;<tM9Mg;TCx( z(wq(C{q#cyNdrk{>8bpAAn_E)5<@kQAFM%{P+tOX^2k&F9uDmT6Jxy6T4R=P>xgoM zVB6jQ#Y(Ah$1INK9~vH2pOi7EN`=7h+PEs}zDtf7>FehU#S#AYhQ@l|*u>PDldrya zTYO4pU2IZnJ(2l(XsR(RZ!&qRbW9G)(6Vy;B487!YL&Qwtf^6JW+G31w?^Pu6GMf0 z6b%jyhq7tI2S=^7zmx;q1ZNpY3*6IxTQInuUXg>m`~y(Go8cC~DJ9X(>JKq<`o{AT0nuHDOVnZFQh|*RG^ZcW0k1=caHu-D&mTu|c zBY5I#g8fAR_BEf%vJz@F&_WusQBr7(fjZ%xdfu&g)=5$S&$Y*kL!sMRL*AJN)9Mo&De_>{hCuBhd!Wkhmgm2e#-F68#UHAM zJaWoN@#5`EO|{EdMDP7{fLefaLrw%dkUlM+TmQ&Xk%}D?5hiuNPa4r*eEr)7@wdml zGXlkgXyAt|FjQeltuti#uz}KD1j9;J0^70>_``8cI|gL)x)%pfvV5t4;i&1xF%Kx) z5h67HnLq*#x8ltLhuYYp2?zg`%_sooi<7IDev&+`N|*=RQ!%xT50 z{nB!+Dz4G58mb}-ayEe;?h0Us^fBr+FE1^F)! zvYf%b&Kh#GT!P@AUzel3I{iW&V_iUid)@O+k-GSWL)pkigSNEVkxJ7WD_Z3!gSVwEf8a}@4%xmx6J$%fV`XG?oF{*XTJ5uxj zB;pR8?Nv0m1Qn1iYlxg(m?^I7rU$9^22r*E+fva(@U~afi`s|)dq{FxCUt)SNhP<8 z3s6$*KrLKkZ0Ex10z@iEF^`W1f`PA%23@42Hhuy=UuiwnCAPzZ2LK}PRMn^2jEUUm zLl}{1e~S(Mfj7Q)Ynq-Z(|FG9FzT~-&g5R?-K6citG9ltNW|4*Eh6HbmK-Nen?F?y z16BUS>mC%l@{8P7jp+{0OcvLjJWWJ{C53{~VHk^~YV(Gt9IP%Ify_Pq(Jz+cSIg+# zA2!JG`}pMxAsDT`M6HqrmRfnWQ$fid0s+tUIpzk5z|(FYlpNy|VPIFOKw4qJ^&%oL z&wZmd(&j>Ij#&eIswry#gCpX4d}rSpppi zajM9?>8NTf7ZLunspt%Ob=&1PXJ5w~DB03-s_I*J@iSJqzTW0ztEsj^;8}FyNe5~V z?DD4b94YMbqWxhrBoanS`vY(^n50CC;O_!lu)ro$HRt4|d7~#ys~$6MLL32-y>~bkhuSc6&fbGh5HncDBhK9&MAA7y6P8!~5z+$FI zp9_~cv#cUQ2YGQFIo2#K^1e?Up4p5*xKhKoLH-8_kQq>`#O>!EDGndi&lD*Lg?haH zFuL;d`rYD*_kZ>Xc^?D;xp!_mL-b2e6+Rvc%9)k1*Z%;wfMLFvm4=JqnEpxP{kzT< z=ic>S@TTuErED_|u6yfp3dDy$Z4_ri5LJJ36a~IyX3WZH&5 zloyzS{-z+W2JA1&A5^2QDNA95MyNnE`g!wC6^g3xY>!4-0#<-8F52tWgdP!}npNX5 zVxdU8OT1WPR-W2;ikiRKEJR~#U`fI z%s%nLyi+f_x;WU+FCf5SXvQ)Pj&^+jTvthA2G*cPXp^a}1p<#Ct)QDst)9J49ohnh zMN3PY2C!fVtD&~0CK#4+o9b$+^=PwE0uq1!>)(m{U;9p}%Ui21S+ZXHdW{%2 zC{>IC;--QmAhwVF`8cuXv~qFH;~$F8e%+!9CjE${fBMTNaV2aaMB)IT2h!b%06M73 z4(baJu-RZo8JHR`R)W<`1wh&ZSM1s+lECv$ttjLHCur7_@OHM1ao*?h!^XH+(9q_>O&6YjlOCaRAI24#| zbF}JOjDryceyP^-H1PMY+2dGhX%LEsqz~8Th_`;<>MqL;`yS{+dY>Bhny}9>bXx!6 zmCY0tb>su>i;k8yHgx4^sH^pjh>0^l9Xrq&^9IqSb+A14BJc;kCy6!mgS#2-XgJUB z`>+s4M90^SpS^J3wE0U)Crv%9F)+}?L?jY6f`$PSaR>fluY^(p8eNj)cc-(jF=p?3 zSGNH6d{GQVz|l!*zBScVW|(FG%2nINW#gX8`&%>h8e%*UALt!!S&;Vu!Oj5UOd!(z zDGb4ALVMCv_lL;KZRZ>*5`Z9BK?x28S3UNDTb_6FGE=4BRZ9+Q6(%HNS5qQ67{TY!CBRfxk{>+}c*#q`>frb#QRe|`EBOVyDapVvS2iCDT|vN&q;V3(GlaCR8d zBa;2hbti~N-&rQ^diiT5Z#E;Z+LkZg{b9X09V$@uPy@bo9vCE&&6(AVO%mxJm7QXW zz%z{%P{Haz{Pii4LMy-{UnL&@cCEN^{&>*`yz(lZc;{H=Z^7UPKW5l5VqqYH)uM9iU#fitF=H z*5(0Z$e~Za!D{JNL%0L>swg%LOE!gsSsHR;w~%Y$4+Fgj{DJFg4z@9H=fa%?=fG(2 z;0eI=a{W=KUz&UD>6a8IB_;S!z!3>DO^*QrsCH;pYb*wv4EBsBtYwO6<8gAm0Xw1v zEP4Q+>|1j3>amG2l&Y^uov=uuBMRt}2Fr0GoatJ^jgNyw5 zJQQZba;XMH<}X_`Rs6CsS9}g#=_=q9e)5Cm;>4Mwq(Cgg5J}$(iY|ZU?A9g>zH$DL z`p+@1V$ooH$FjgDHIO3z@j4i(8b8pju6K0`&b~z630gZie|7&x%J-{ipXIRAKqQ{! zCxT3h{;WZ1%0mXN$&&Be3K+4S8Ue*OX^!gy?3F1p?+H4UWGene@Y!v0?+eKx?ZZr8NWjaH`$0_}>or_|d+WrO9 zRNAQsp|mL9KQcC{0VZO7{QU!Y3R9+apgg4jD>K*4gT%D{RtFD;v{uM}peoZz`8w}n zos>y$vMwz^KCY|amcprz&n{6M9uz*AeAG#M7o2on+3>Lw>oMnm205)MCNW#RG0N>& zyX=)^dq+YSF#5~E3=4)*39^>J{@G7(89yNVu& zppc?Tea9Pd?O$B3UX!^w{u!EU$ltC98foeWgp1TW>es&R$&bbI4XnC35K(*hvIXMU zDMMNif2YtbRCsa&DXZcXy!WMtv_M0hXsU)TY#{MgFbmc?AG`cG@z7g8$}wdYmOSYH z1l!F&E|@P;ARNi@Jpfd9J#ReYl5hRa^XH1+uGwQM+`49azQ_l!b$XIRtz;z*qI41@ z(!xKEvB;aK!4PB}067mUVOB?E^S`9X?gJ}Gpno{{;~faNdFamj!W)5qLuO#hzA~qS z2ZdArhl9^7!=YU(2@gxOqDmfr*+kk0{8DA+Wiv)eHX?V(JEIZdUJ0wuo*?h!@n1AL zTi0gnm(~(c*!4kZ8On!NBG17`j2U->h>IVAaTF*T8mfh@u1sv*@`~8J=>;J3yU;dd zr&5bGk=<%A5P0?}pPj)+6oLY;f+l;D&+8BWwf}$bxym;zI@UIM-YNNmCrmHP8a}Zq zG{Ta?txB$Q{7@a>C=$8RXRvq2CMzv`Y^Yuq8hwNHK6hvf5ECJ?b2Z!qIOD8?mn%>L zJvn1Q<&?Q6?4N(~xg{xm`uIXHka11p8~NM$j=<0}i~X!o*eDcP-*jN5wHq56zVZLc${(ZM8MN{YOo%+_QCE$l&pZR|JKGAxeV} zsR+DQb8WGM*C!%6t^t%PvwkO*(z>g3O*5n;n}KFo1wi`;1^rkkt3x*^-rq?`F_Sh3}>tSAj%3@0o!L7+Wf4^LnZZH3+q6dwKoC7Q3YNi?~b_9F@q%X&NqMmQ0YdD`m@OF zcP#2wBUY`rN9@@CPHRTGr_9hHVq)|W7$Vxr+WL`xslnN;9{MLNz*JR9ujuok)jmjXA zQ?mkM;-dccfS}+;8odB=-;g?RWciqBM;A?)b9`|~cvK^;*>!F$4s7ZfgU48rc&y2c zom2E7NY<3to#$NeWiZxZK1CWD5<$O`5Hy48$h*-U@D zOFV-|aDbn*aGD^}IS zO-LQ*VT9~6-T>=Ps(lU)PDWs#Qt$iGBl<}Tx5jpIAkD3vBM%+MMBKbf_8Y}sM|q*h zC>He?i4L2od{mijgRbCRfW8JTG(I5Wj?#6Mmj>|vw zFAwa9j7jYBeTC|&jd(gO#>XV2kk_iU;LcA(;7x6jI0rpA-nw@79vp4vO)a<%jD))u zZV8;n?F}Ax+u1ihvUSGndA6vS7#K#Dhf=e>0!h09)j!e*5Ri6q8<>uI*O(pT!-{=7d6@tI{Zi+gy=elfBWWuBJ-1BdG^P`MX0_efLq)%lBN> zm^pMzX`g;Ws^LS6Xc|7pB09Vd^6Cz>^JEM|&!&#OwhSsIK~eSfc&e(h?e#lQEkKro8n9*9poxg%tqnl6kgxy!R59_Q zM=XV+ssDZHgYUpH@X?Z;#+Vhy=@{_J56w&zWS3FF*1RH6^ed!Zc^eRFJs6h)4&grF zzvrQrG9*^O!$TlcNKz|JHjG9MOo|aP(8!`uR7pWDfx)PdK!EiK(-^pfvU0kK$c z%~_yoo2o^TO+Dt62^u`9gZ`&aUQOAvZA18ZxBh2S>VOe7UA>RW7DnT?G$Govs;tCn zVdl4;hN>LYuU3t5P*=PQDwlvf{swmjob@c#6^6*!g7dH6e&Pk!7Q>7-ZgyoCagFd8 zF!1fw&?W=qo&3<|D~QJ_dKVA~w(siiKZ#oQ(MyS&SN<4VUbHW$bryP;mPes6!)>+I zew$bP5J&gTn@^{cN}n-kblK78{AJhJnG0}_-b27mZ*5!ibC`Bpp{P+M{J5%jB?7+! z`I(N-;m`)*00hy6sspbp9r@$AAxpu%=8)PCsqA|}Wus;fJy1CC@<-pd)EYuw*`p^9 z5syP?mCqgn#*)3ZLimJwB~$Kk^1T5&u?290(p0%0s*SAKB0MPp51rH|v*^YbM2QKj z2a&UPpwpdJfT<(Cb*wvbAOc6q`UGfUGDF}w2c(r%L@~|*<5ECGhU{bHx37VLCKZqp zJqYY1fnZ(h8xtmxTn%YAhS#jBmVhcwc9yN+M_-Er1%ZfY7!;Zf2DN(zZm%LbKt<#( z+*(leq!kb;4>!oXScPxzw{egwhzB))>;5vS5_34%Wm15^2Vl+6EmtPlIFNUKWSjpV z#=r{j)K{W!LU7|TIjfJj@AKawpsOZQ`1u85TWkWNuU?ZjtsmfEB{U0A z*JOQAGK;7ALp7bxu1zbW?mBPw=-DS&I6gZE-KOnFvW5}4YbN_Nr?vNqF?U6AN&XRj>Qo+cTW4!?^ zG8Iq<36_~I9mwQKhk#}1x@Dh7fAZo3nKc!q0anxpH`QtP=lZ%@zpbl(ihc0%g|Xn} zE4ko~=QqYArqq*vzTJs;W&)~JOP619!JZ(rtJ&Vdy0ssB(dFmCV_$?(?m)m_BufGZ zCazpu7USH)a&gGebjg2Q2Yz@XFkX&f!zWwA<&V5Ca0eR;iy!2QxAKGR@P1Mnu z#ihU03vUP4o5{v%6}R`>0crsrysChFb^!9O)nF3F=F_nLd%=BgS=tu(7Yv7O1LFJE zt*3*6?K~A|wRGviDdM?LS18$Nh_rJo4+F*59dJGc0(s~7HbdZv08i?dtYksqe9|u* zwU~3SLkr6$%rWX!SFP9UP%r8_zhUk;k8iQe>OmdrfBf*}I3uM?AO3(pTXh`jvSIm`(H4vE$}TQH@c|xI^>{%` z5s1u0hEnYskSj%q{a~Y^YRA4Nrq0=Sw5rODkXmb~Y39QF-h|^ zqB9Q1ZXhVKDj+UJ%dM`+GZq5HoC!iYMBvS!9=wvC098u<7sjSO$DE>-QP=z(sc<>k zysuOgQn0k0Kx)2#;Uxt%#VN9RGM3RH0l?vfg=XLE55*_JoQ zf~{J%d3Z~X3{!QZ%!qAnew%6s@}2{>CRf4+BZvD%iMl3rpCDBc@o4l>eG;;ARJ0m4&qs*y0Y+{ zlvl^jlujyS4@j1hB+tPuhV!URiMXd^4X!%>`hVn3o;kM$BM-XV!GPxlfSAz)!PI&f zU#i2p?b*uhMf&c7?%%UB@cBEIX0Q4F(?ko^hVGK-n(tUFas3wluj@}6;bQ$K002M$ zNklkeI9K-V`4HkbrZKnA9vtKBw=&T-z0vA8@7p}qnnI+z z0O}DF!b7wq+Vo3)d>T4h26+JOph3aYxzH*@%DbOOY%A`wrg})WCVB0@-n>U@tKk8J z)OPA}AA>#`0d<@m1IUU073&+*pFJc^d<`qFBtjUWuC7?@-u0=->~{=4vBY}dyV0-p zdwgJu2=-IU08qfTq#mqf3h)IIJURNbi1_3zF>BcQVr%}-qM&S>Sho2UQ3$n_W(Ni) z1$LT9Xow4lO-}3l+kIAD8~$3`VvYocguBCY1|z0PHT&Fg`Jt;s3 zgdvanx9>cg@Y+AG8=wi4c0jUFq2((DuVN#ECEqeDM(NuK2w&yytAqiQre-ta?Z^#j zZv6F|_&d&?7B%JQ)AEkL^tN3P7u53dsJy@!y|`r<0?_W|)Y#g6X5P#It`nLF(YP3g^i;3PDO? z)?PLmPnzohg(31@;t*Kv#1e3EK6~DH@iVB^AO5saIZl~*YHE-wUkS;;sU5hTqHP~S5WZl$A4;zG> z4}TcWl3oN}IaJ+xY6In6ge2l_K9jVhFroSl9b0(j3>SxtNCt8jEap$m6vMOQ#O7TkVrW)e z%QY*tGH|jod=(aBQn5&-*}11&#K(k)S3lb<-u-5~*tiqd=Ux`2rm{TX%U2%DSoXn7 zDX0J6qZ_BrKc%QAP~(F_!`Ud!)H#axy^eC*oeNxp zs9y5<8tI@2#ir7nutcW!mO($hAzkge@t1nnXADdgksb)K)<7VahKxw{ z{tX7&NOc|$-hLvmTA8Reb}SAzq=IX;HRkE54f;t#FfGE4y;1XHqy)j*RrL_7EVJjp7aP#+xDhTo*b1TakC> z=$I&G(>xd2V$2{4h_)4FmhlEcO(rSeRzT``Dr`8+8Impzfl#a&ke{4{79zzjF5d*i zxXP)n-*3&6{O6ZKCWS048kj5v+Gme`OTY?5tFm7LX{SZiS_PYmDj_0*MHcF7^>Hdd zINDKECRXPbiQkb2^W@DUfrl@)<&tp&L>7c#&FVyBcGIBA=Uo%K0z}dq8*4>H`A+fM z^1q9akVMfhYk{b!+@&u{%QO4eody~TxvGFHGQK|MXdgA=1p0?45qKC)Yz&QzHa{Jy zGHk0K8x#A7aU+XOOtFy-$N~r9{%zJ3Z7of=jc4AI-L}XFtYE|-$G|-gMiFx9PSPUs0Sk+X9dmk)+&h8HzM}oeWOGc+=6LYq6+GFKKp5x zxb5G|Mc)1@r?6%(++4qW<EvWt83Z=$}2aA5tr|%B-;xO_83~jOe zWaTpgZ&lcayltTRPNT6WEj$r@8lU=L4Rq|A+{9>M>p?Wwy7^_1nmSX|ROiE6y(1=o z2i^*DYqlvr!;T6Lp5{0>g<8$NheyZTqT`ayBP(-kho`oa=>}Fs>3)A;@7fnt;(OuQ zb+-hn5_F+oF9Pqru{l^W;ogB80cSOM@R=ceMCRZzWw*Wf_4!hp~(oRKZ&P0bLiHW!K;AOA_L1y$aY^8fV7D=Ax6 z{t$Ee^$&0Aj=JMX@$-c>I*WGTop6`lAT%_H0&M&?Y@$jmchCNE=u`$`?WQBcfh?SM z#2880RBe{3I+I1=l0{R*obdw;6iijRQO(Iyq0 zNWZ2=KuzOZ2#%BgoJcAu<59T9N{a~>6JWO?5bqkxiP6~9}q6$;zl93Dh>=9HCC7=Mc(%xOFvS65B7}_d0T8EHm*U0MB?@* z2*?Ss6CFSyh3>8Ok+;=#zzM*{>3W)J4c0MFsCHBHM9||$b=B2=ywSCQ5{qvK-6wt9 z9CVfIfC}+|a~0frxRG$qBOM+_pmv#2Q|Ih|;LY#1q{Kyp1o(h^z$Bg?d8cqH1yVt6 zuh~~6DvN7HHP$^&dDRRyX4~QKL>AwAy8pCAyih1&EAtyJZo&WSa{fQkH^f>lLw2d!8+s2exNKyn?3C| z@>qK`blRLpZ$!^pz+PV~g;u+Riq8R{V83$HDL{11XXRVpt`)q2GJ_Bx(kIUvoi(4YjCQV2}4ukGZLiHrXcvcL*A)^@`>-(iaj16 z@2q=2929Ke{p=_pa!nQ>Z)YVb@25-{D5is_U$XR>6o~15v2nLs>5_>h4R z7zh)^c?}|OvrX*XQZK42TKmQy#-+>*OEp0TE@#s!wdxx!^A7y=MS~e2VCLPJB`-fn1#QaH7; zR@k5t(uOhX$vk(-zau?Jd4J=<8`3}j?<1L#{_iGnu37V@W{QWenJ$8Zu$buSmH) zuYJARa_FUk?ckQP=ZF}n;OkVN5E&3h5zxu`bZR9Ig2aK8*lU2$m#@UoliZS$n1F7WAS8`+s3?mUKbB)Dy zTEA8|fU>Cw<`R(>TX%vb#uK2XmxZIVQ3ki$p(n}7F#ezzlU2wQ;GrfJG7H%Wk>bdq z84x@TajLT_jUo^nD2S`vdkhgMKv5G&bWf_&BMRgjK1o>Qr=x=HNMsTbj~z9KrwU(_`~-6jNRRRGGj zPf&0d8bU-nay+svHHeH&tS1jVjXG(5ieC;6^)-Sba8LH?xLMVcTftFwIact<(K{FJ zX*g%8N_ZH76LAn{4#DCak)TqgyEO0GWDRk*HP1$G3^8K4ucIo zFWi1{*7xtdkm^bPDG6cX`m-j8#WVXWHwk?jRx?f?&h0Z2YDtTeyX8e4lbh7Aeo;Q} z{$slThHZu7iu=D2JD|zN60mA~{_?|_V5y0|=84bOMnqfcU$;!E!#<)OZYae`L zNC=d~4}e%qRz<1CCPs7d4$2f&+*x-QZ%%T-cB5akjp>r&b2<-Prl+r@d)#9yss5V4nDKU5;D(1jckDV8CqV7{!{2 z@#dH-#u(TvFnMV4)eK0P{&4Nimd~W5M*w*zrTwr0DQ*#0u9IZ>Fw36_bGbF+^=`?y z`5^=R{a1r6j_f%d2`t~SBeTRm!NOz4{~iouP?Mo^{^${zP-W@ovC2r+j|U$sO*UFx zxDL%5o+)oJG(X1IFn_Y&ST1B*q~_lssC67S;U*Cl-dC!YtgBU1;Qb(k8W)gch~4^1 z1Rr9ttsc6kI8xvZ&zft3DKCcJ$2=6sJ8)i zl3Mr87Gn}#y9F+#D|V+ndUlNMwCuZ8!W2-w4DNThx8d9(@4=zr4X0iA&y9CH`_Yb( zLk9(zA@AB~(ta`WD4bfiw@OriRi>s}An#bX^K-TY-E+Z_!#qLWx!7NH{7CWbQ^$LR zyrVrk2iTY%rW_gK=X1#LB=Py93&lALhPpj)Fxj2!S47{x^r&IpEW{obr%l0fx30C) zlc=`d(Waa{j?@GyJ8uP)-OvCbj?KFZCGvqG<`xCs3n9IqH|#RaoSXL(-0WtNb7v=n zDOYc!V54V1fl2B`{{cGDe}F+WcR=Kw>$(;2E3KyXg&?d7sP~<^&rgTo`rnT}q{ESS zEzgl)VfyR*36hUL3`rWA&=GJ2l5sN_ir%SEDtg#y8;ppJ(JoyZV+VASk45ISy)xWFqqoC_; z7f%sKPKXhIx@DQDv1vyuRcIl$t^O(I-?vZz=%VG*Ms>}L>$v=U%7%bV$~fH3WNSRoAoW!elHfbg@G4OzPcCz5~N zoN?k`&~Kj$I}g;4-WO~t)E6HY77Ue&P+VY*WVxj2&V#oLEE^998uSMn9g%3NcI2&q zrmLRq>aGr28miw!96+X^kKX)!t5R#s?7?Z`L=q58>&SM5&T<^?C|^K+dQC8&W%C28 zj17bhBT&WT+SQMU@e^;x+UP6w!W+Zzov8;~u+T6}U6Y!MUelna>N5@@uktq_@M=Xf zF-d)E`j4FCnQAvja&7)g0ao(B%Z}tP0RuTIF1g;%&mS#S*E$d!U$wt1JO{bD5?ZK( zTiGU<~d>j1XQ~tc)DDK(H~;hK{U7io!6>HwYSYj3pS;uf2(dk(oy zEX5xyX(_Y{eeW#;q?=a1FEqd0eC842CkQLmY z5%=%c?6gYS@*5Fgchq%;;X%P1F>TF)2=kS*#{dy+lDh80sUjGkS$;+FTO)e-L_t{^ zhRVT>+y=C1aTR&9eZ@4e0u6xfb*+pnho>w(9%e1ePfayE?7X*nyQl;kAa64QkpHY^ z82OzE$_=9)e%y?8nO1~Nsk^KDwnpbSAl-l=AiJhZ8p$W&`mBwJ?Y}GyO zD_A#+{YOUNS@#p*9)WXvE#w~%*tF#GJ2xM3!nwt136UWWXr|m+!f5=e2!UaFAHDW3PA})ORZwnVl?7nKjx{^M_UZc6}oizOG961a~{-MD40p zw~fs9t}ez^jVqP;%FpgOM~uJl5m63gQw5~HlJcIO)JiMjq?OnZ9yk|NxCiIY z>tC;tR*hBZdg7NH73#$5p^8JHTjr%9$Eq+!KT^c?RPpv;PDGR`__+nDGQZohM+(Z4 zokg}!bH>ZI=YusSODq_bWh_dEzOzm&pcBSZgLK1YEKCfv`Qh|z#F@@jXu#G^-Y=m@31<{9E2 zgM*$gWR+a!UCySJk+zx|U;l^z*N<8P9rdf83Idj3>jQ&B8|!MS%+*tp4}@t26fQ*c73zL96fhA&KJ6e;nn4ytjtvUQbpXQ~=AZH~s4; z@xvs}bY+0*JLN$~6?=rqO#xphymr~tpN8Bn&cS2M8zudOF^jhuDih!MW=)$eBA6zHNGZ(e4p=_3 zGPsM|*6*x8Szt(^e)OBw;%^`PES`djN>bfPh1a(a^WY)p-PJol#a17Z+FA}(JoaKZ z*#5}x?8qmq+BuIHl1>m96w*YCuuh)NFU0N41sI&e_V_n-Pxbg} znO1;^>IHCX;7)?GtUrV$?1RToE4u2z*VoOQbwp!`p9sY0bvuAD=Iphgw(qZooexmn zyBp$;dUGWdpS}2CGFe09yTzXg!;&P=drYM3wJ)Q&Z4isJyZRHU9?!hDUcCGD_BLf* zw0BqNbGKcba|Tn1kcI8lUEJThgWj<@&C0p3B z)A{;=T4c@+r;nwpj`-g%x#H2+R^y7$k%#`9r_Vor_;TMFCtSF9!05?cBG99iD{uS8 z;QG3{1nu4M%Z0ToJIRo}0KbE@n{c-AgZ91;0&n-d`klDz{COfWtQny|*RI}@4+^{~ z?k|p=GE`byC8E-Z1DLNDnjN&fJLH#0C7s_NhQQMy&BjDHmp!5GO1}3Rh*Mzk?1?Y{ zhsun!`a#MLhgvQ};8oK zAn)}^lf2f{l`fHSBrHoySk zADSe50zzBE8!E=6S8m=99q`|_Xaq0>X?YEVu?kwktFNn7woellk!NqYV9?F4{q}oT z2=vH;p#s5SVL;ODW^piKyyVc zC~#yesE35N3fRYI&l@j41psmP)F_lHt#}9oLFsyC*?O;$cOu#}3}vQGhb>9i4~IOCY#cu=QS`_=EvXjO zi0b;r9x!k)Nnd~9hRl7r+s#-pWF2QuOcRe?H$w#B1fvHodaQd}7I`A=cBl+*tlsB9 z+FJ~>n9oNvt<0XtyAkBR2Z|`P-op4qg+%0i10#eVka=H!s)KEX{iQYHrpK3w;xcRA z$?*6zG5p9&eQU}J?5jU|0JwyDE!e$fP2{t;T$pw4%};OZl0^*^wVJ9*UOS)>P1|cH zYRs-N4ba*m9sa~Tcfc)x`w}i5P6_$YEOEz6Ux|wrOafnguGnNjVN3+SDAi9A!hsx- zdUfCcO;17AKujGR9DAVyTL5FaJ6T$7PtD?>hVarwQ^bY$zipZfS;ok2lR$my3d`_C z<<;Vr=e`h}D`pTJ;3tJFjo-;WK{~o>rjSTN%D2=}4uq8lDS6|!ptRHa>8I;+Ky?R!$b7AoizEeS|NX-{AoBiV zTzaBevX=g}AU+gzioOLk)u`Ojk4AtAlrpuVFWfic4znv(|#c7?f+Y-sn4~ zbO$_@gLM~TCz9$;mYKi6^@lSB^14r$y(s^RdtcZY9}ye`T*<9^NMiQDSfoyOvdr`p zYZfM#=wWzs9^Ef^)p?NcgAT8W=Lvw6I5e0*1(_-m8f(Ag-yy^6?E6ej#z9DIiw-vVOn#>!Ux4O*z&o-h#rS#jrU`MMPqz2v5lH={sVU$X)fB zX;c01{`09bkG~*4YvjbvXQe^I$M?;sgAUyKh9;Yehl5Q(21i~Y9w^}OSPOS2+&yrn z2VhbsFMa4;1i^5y7*vzME6VT0+oNEV<$wckh8mz!639d^bW{rffvg7atxauwrlWG{ z%5?hojTaQWCJ(y`%B$)mZ~G0W&QzZNpZnxDw<;+|A&p(El+^y4NQtg*)qjo+%}jUGI$Un^8Vhc?XrxXq|Gp_+jodJIyFI--;;Q3 ziz4*l7Fb*TXzfl>fCCp1c&5ejvRx-+CW(2&GDMofU>EbCa4cDYs3Nl~;D@|_ibC+u zk54*RW*6cYjj`8CpbG#iPOXVxEOoDUHT#U~5d@fda7YskXj-&a8ou$Gp=%n|q99%I z>0sT3#-j251~(T@h3vGnZB%@6?Zy9idHrG2W;6!*iD0ZlM1(8>lhkelwVfJf8gcC9 z0@xFvytL5&k*k-CkSn%Hfdl+~#eG*C20g{*&E~d&kTzLe?Ddt51R@7JQ5ug3OOE#4aa>&k`V+fkOYOV%OE#Q6V9D88R3W~Fs>^MK7`ysLML;41MCO;l0xqA=oHSV0MN5Of z=w^BIkqS0KPZ4kT!5+*H2P5ln%J(hJ;5s4_w`MJd*luj=VUB?eT918v;*?-x9d{;8Lu5hgA1T z^G?n?^~$?;WoM@P19?|=x}H^r>0vzCYwAUP2~za=D2-6CwHVXW*!q;bduG}7z-&t@8^q1e zyGP!Mi1k18RFRN1UY~@Bj7=7kkG)uY{mNZV{`!}K+->38R{tC}XzZch-3RLv@$k8} zved7kzRqmU607g@GI&^2F7%heeGV6G6^F*1D5R%41;>E8c-V+c8QsDEZs=sIggfz@ z9qJ@~fVc<ux4S2>0h({}z&7K_enrId^9{SC!6c2+ z{lNcL0bx}WF_=F~O6oVM{G@)bg4)ZgW{;K#w^{j!?9=lSL*Y zE<##$?FS=Sq-J|E^LdD{K>*ELV7`mSWVww-(2+M882*5J-8#bXNGA_}!`S}v|*f<-9U)Lhy@L)`T|0GN;VvO2IL zgJp*4^d#7yyFKW;xBt^hR#6mw_QZ5?%G^P%#&(*+XiK8iB5DUOdVK}>`E0-;!DetE zffK$GR3&LvVGFF=t=fc(btUR9AWF3P31MX_LShHyBbM~2i5+}|W zDK{<#pm3l{HJWMUPW^zH00Hb?1iqb(jJeZ9jWg8jbC5w)Sg;@;`(=x#iXR}TNK2|J zcm>GuC?KqTL4|+htsliQSaY>P%t$3Zd_Z5R#l#a<73N6OEsp5cGrT?lHtGa*PHmSy7iWXKCF18Wb}LiU3;m=RzLBD+jmw88{|sy_e* zwMZfN1lWGO1(-7=9eJt+f%WN-RpveDpeJJPDNq|wU9-wdYQmw)%#}Fcs0bETX_DZW zl0CR;+{{IVha9K2W>?`xDi#-F(L93 zVD}3M^1R|!nR@t*_y#x}ocJ)=XXPdPt?)Y{?_F9>53AED!OW0>PRXnj6HP$cFT%}& zvm}erDAVDK&e^@-%&QCHLq!-=%qYXRqH=OH*z0gxSP9jTSl2vX2|KaE-X{I!D~~7J z>S~mAWm-|;LE_g^ zfB11{7b*jEkY{LQY$I5c%=(b0Ch~Q37bKok_`BhVp+5y@HH`4t8AweVlp5dS$qkBr z-2%u554JELgP?S`C|i#y1gIgTydR{-u_Bt)q|);6_(6Ro60hnSYNR2Be)YD!Vh`9c z+<+bY)J>QLe!t@2OJtr%FOgyLuVdL#LK-ZAQa3!2a!()@h@5|?4P z9&g=WYKg!z9MwTifI3B5J0&{~=Qok?wgU4d&p9>Dti!DXn?u_h_)O%Fd8EZeh*4Nu z`s2_IOHQlln>D=37n+4a zBV!td9x|(R$ix|?#reB~ayG0CU-#4JaV7img27?})J3bccR4jk>bsf(ADfuc9W4u19dUHYlEH^`d~odxyyeEH^Q>K4Sm;Gq2e_>X2zQ zi4i^#80l66K_*9D_i~%4t!T;ql!90z;$3(AgJ=gzO1Uq5AacBN7u6CGyS>F-aNX z)a##ZdSK}kUo%R_>kt05|D?Gm7gNZ_DX|WGkrrUNOY@4s6?o?CdMyF2<w&&sMfCJl~h-VOGrMf|R4tRrjUjXvF+ev*tNTC)w#|dOZ z846*>AJ>^v)<6DPO&Cx#jSqOQ{m6f<0jEQtpP!fnKJm$5t5NZBhX)3TSwqsmvXL+L zmXScI299JR?th*+x&@)-;ef)HITqS*jH$8C)Ud);%4&9SE^DeT$#T?QU|xM-;q-#T zhl}+v3bm=A6lJiB6!=kQfC8h`kg(H# z%p1#Duxo4hBUha;IBWQX@*_{XY;SZzif5-mV?y4%qaoRr`gRWp3Tg@tvlL>bKnXSM zwN4Um^6X(@+dpd#Y=udA{~XQ|dG`+pY#KfNn1XX|etK)4_~?L$KoQzJHcU&8K|_N~ zb&!3m1Cc~OhD_sh#}DNt`}|+N`-%aL4V=tsNRAH^r_38HDHQq;tTZF(D^FIL22k6j z?sh4ds*lk{&nysUHk}TLS8dI!6%V|X2Xp1tcr-Q6j9v79B0N4_mjX!GAt53?JzWF@ zIA0~kB=_@;@0%_5=WcZhuf3EP?F(N1+3T@~9(zWoQQ^UZ2xa(AH;P*J{3YpxyHe*b ztjx`GASOetG>5_U@if}(as4IbPYaK$hwg#AHy221qTzt^;6TxB4jx)#9_U{Rnb(2Z zd;o6jx~l38EtW#<;uxrl`~*lk^}2U7a0rrbomx~#WhZ6X2ppo+6`uxy$$YT&`~Ye{ zjT=$8l?XqVQ5u3GU;RWJHj;thYCt1U$BxPpFD>8Xk+3C^@}bGGlCo}Cp0y-9{g+l} zr+@%JEijyWl%LV&U|Y(>?I!d8*}D$-IEt%3DyQCimu%Ux<=(IX+h7dX#?;V43B~kK z5+D$gkPt`+AwWKokV1eYKp>P*O$ps#Omnw!mu$7*>N5c%`KK*~PzF7@I7q)olEqsr>l0rF0(s`xVopnm;L&@j^hVO8T$W8=%# zPu`4kr}O<{@V!TcJ0h&C9UEz20zo5tSF=Rk^>nfiXKS!b+79GK*3jA`k@qe8U2~C` z!lJ~)#g~YPxD1Dspe9Sqm@z|yggD(16sRww!sfrk!U zj7QegsroDq3q4xL>kgFdF1Wwg%84Iip!&Ueld|mrcoiKF6_Q33w+9^72b6{gs7-r= ztE@-Gt%0io4)eGEP9X14#d##d0H1V#WJ|pM%wxoz|9sb_aOzOngFMv-z=AUsnrRf_ zQ+0ZrL!_?Lk;j}m((BvJl6@o+6nYAal2WW)SDD6K2%0AWF)hXcg#6{C;`7kZ)zbr^ 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 ZsI4sJ6bUI)&Is_2jD(_inW&-v{{T#L#l!#r diff --git a/apps/code/src/renderer/assets/images/wordmark-white.svg b/apps/code/src/renderer/assets/images/wordmark-white.svg deleted file mode 100644 index 08c1e772d1..0000000000 --- a/apps/code/src/renderer/assets/images/wordmark-white.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/code/src/renderer/assets/images/wordmark.svg b/apps/code/src/renderer/assets/images/wordmark.svg deleted file mode 100644 index 7b9a6abcae..0000000000 --- a/apps/code/src/renderer/assets/images/wordmark.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/code/src/renderer/components/ActionSelector.stories.tsx b/apps/code/src/renderer/components/ActionSelector.stories.tsx deleted file mode 100644 index 1d472907cb..0000000000 --- a/apps/code/src/renderer/components/ActionSelector.stories.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ActionSelector } from "./ActionSelector"; - -const meta: Meta = { - title: "Components/ActionSelector", - component: ActionSelector, - parameters: { - layout: "padded", - }, - argTypes: { - onSelect: { action: "selected" }, - onMultiSelect: { action: "multiSelected" }, - onCancel: { action: "cancelled" }, - onStepAnswer: { action: "stepAnswered" }, - onStepChange: { action: "stepChanged" }, - }, -}; - -export default meta; -type Story = StoryObj; - -export const SingleSelect: Story = { - args: { - title: "Single Select", - question: "Choose one option:", - options: [ - { id: "a", label: "Option A", description: "First option" }, - { id: "b", label: "Option B", description: "Second option" }, - { id: "c", label: "Option C", description: "Third option" }, - ], - }, -}; - -export const WithCustomInput: Story = { - args: { - title: "With Custom Input", - question: "Choose an option or provide your own:", - options: [ - { id: "a", label: "Option A" }, - { id: "b", label: "Option B" }, - ], - allowCustomInput: true, - customInputPlaceholder: "Type your answer...", - }, -}; - -export const MultiSelect: Story = { - args: { - title: "Multi Select", - question: "Select all that apply:", - options: [ - { id: "react", label: "React", description: "UI library" }, - { id: "vue", label: "Vue", description: "Progressive framework" }, - { id: "svelte", label: "Svelte", description: "Compiler-based" }, - { id: "angular", label: "Angular", description: "Full framework" }, - ], - multiSelect: true, - }, -}; - -export const MultiSelectWithOther: Story = { - args: { - title: "Multi Select with Other", - question: "Which features do you want?", - options: [ - { id: "auth", label: "Authentication" }, - { id: "db", label: "Database" }, - { id: "api", label: "REST API" }, - ], - multiSelect: true, - allowCustomInput: true, - customInputPlaceholder: "Describe additional features...", - }, -}; - -export const WithSteps: Story = { - args: { - title: "Frontend", - question: "Which frontend framework do you prefer?", - options: [ - { - id: "react", - label: "React", - description: "Component-based UI library", - }, - { id: "vue", label: "Vue", description: "Progressive framework" }, - { id: "svelte", label: "Svelte", description: "Compiler-based" }, - ], - multiSelect: true, - allowCustomInput: true, - customInputPlaceholder: "Type something", - currentStep: 0, - steps: [ - { label: "Frontend" }, - { label: "Backend" }, - { label: "Databases" }, - { label: "Submit" }, - ], - }, -}; diff --git a/apps/code/src/renderer/components/DraggableTitleBar.tsx b/apps/code/src/renderer/components/DraggableTitleBar.tsx deleted file mode 100644 index 3cb21d6630..0000000000 --- a/apps/code/src/renderer/components/DraggableTitleBar.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Box } from "@radix-ui/themes"; -import { HEADER_HEIGHT } from "./HeaderRow"; - -/** - * A draggable title bar component for Electron windows. - * Provides a draggable area at the top of the window when using hidden title bars (e.g. login screen). - */ -export function DraggableTitleBar() { - return ( - - ); -} diff --git a/apps/code/src/renderer/components/HedgehogMode.tsx b/apps/code/src/renderer/components/HedgehogMode.tsx deleted file mode 100644 index cc3d795848..0000000000 --- a/apps/code/src/renderer/components/HedgehogMode.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useMeQuery } from "@hooks/useMeQuery"; -import type { - HedgehogActorOptions, - HedgeHogMode as HedgehogModeGame, -} from "@posthog/hedgehog-mode"; -import { logger } from "@utils/logger"; -import { useEffect, useRef } from "react"; - -const log = logger.scope("hedgehog-mode"); - -export function HedgehogMode() { - const hedgehogMode = useSettingsStore((s) => s.hedgehogMode); - const setHedgehogMode = useSettingsStore((s) => s.setHedgehogMode); - const { data: user } = useMeQuery(); - const containerRef = useRef(null); - const gameRef = useRef(null); - - useEffect(() => { - if (!hedgehogMode || !containerRef.current || gameRef.current) return; - - let cancelled = false; - const container = containerRef.current; - - const hedgehogConfig = user?.hedgehog_config as Record< - string, - unknown - > | null; - const actorOptions = hedgehogConfig?.actor_options as - | HedgehogActorOptions - | undefined; - - import("@posthog/hedgehog-mode") - .then(async ({ HedgeHogMode }) => { - if (cancelled) return; - - log.info("Creating hedgehog game instance"); - - const game = new HedgeHogMode({ - assetsUrl: "./hedgehog-mode", - state: actorOptions ? { options: actorOptions } : undefined, - onQuit: (g) => { - g.getAllHedgehogs().forEach((hedgehog) => { - hedgehog.updateSprite("wave", { reset: true, loop: false }); - }); - setTimeout(() => setHedgehogMode(false), 1000); - }, - }); - - gameRef.current = game; - - try { - await game.render(container); - log.info("Game rendered, hedgehogs:", game.getAllHedgehogs().length); - } catch (err) { - log.error("Game render failed", err); - } - }) - .catch((err) => { - log.error("Failed to load hedgehog-mode module", err); - }); - - return () => { - cancelled = true; - }; - }, [hedgehogMode, user?.hedgehog_config, setHedgehogMode]); - - useEffect(() => { - return () => { - if (gameRef.current) { - gameRef.current.destroy(); - gameRef.current = null; - } - }; - }, []); - - return ( -