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/APPS_CODE_EVACUATION.md b/APPS_CODE_EVACUATION.md new file mode 100644 index 0000000000..61ea854d7d --- /dev/null +++ b/APPS_CODE_EVACUATION.md @@ -0,0 +1,405 @@ +# apps/code Evacuation Map + +`apps/code` must become a **thin Electron host**: the renderer is strictly UI, the +main process owns only host plumbing (window lifecycle, IPC transport, Electron +platform adapters, the composition root). Every byte of portable business logic +belongs in a package so a future `apps/web` can reuse the same renderer and core. + +This document turns that goal into a countable checklist. It is generated from a +verified per-file classification of `apps/code/src`. + +## The contract — four allowed buckets + +A file may stay in `apps/code` **only** if it falls into one of these: + +| Bucket | What it is | Examples | +| ------ | ---------- | -------- | +| **K1 — Transport / IPC bridge** | The tRPC-over-Electron-IPC seam and the routers it assembles. Aggregation and procedure wiring only, no logic. | `main/trpc/trpc.ts`, `main/trpc/router.ts`, `main/preload.ts` | +| **K2 — Electron platform adapter** | Implements a `@posthog/platform` interface using a real Electron/Node API. | `main/platform-adapters/electron-*.ts`, `renderer/utils/logger.ts` | +| **K3 — Host entry / boot** | Electron app lifecycle, window creation, renderer mount, side-effect boot modules. | `main/bootstrap.ts`, `main/index.ts`, `main/window.ts`, `renderer/main.tsx`, `renderer/App.tsx` | +| **K4 — Composition root** | DI tokens + bindings that wire adapters and package modules together. **Wiring only, zero business logic.** | `main/di/container.ts`, `renderer/desktop-services.ts`, `renderer/components/Providers.tsx` | + +### The litmus test + +> **Would `apps/web` ship this exact file unchanged?** + +- If **yes** → it is portable UI or portable logic → it must live in a package (`@posthog/ui`, `packages/core`, `@posthog/shared`). +- If **no, because it touches a real Electron/Node API** → it is a legitimate K1–K4 host file → it stays. +- If **no, but only because it forwards tRPC calls** → it is *trapped transport glue* → collapse it into the host tRPC client; the wrapper should not exist at all. + +The third case is the trap most violations fall into: a file that *looks* host-specific +because it imports `trpcClient`, but contains no Electron API and no real logic — only +a pass-through. Those collapse to `HOST_TRPC_CLIENT` rather than moving wholesale. + +--- + +## Current state + +| Metric | Count | % of total | +| ------ | ----- | ---------- | +| **Total files classified** | 242 | 100% | +| **Conforming (`keep`)** | 158 | 65.3% | +| **Violations (evacuate + collapse + partial)** | 84 | 34.7% | +| — `evacuate` (move whole file to a package) | 36 | 14.9% | +| — `collapse` (delete the wrapper, call tRPC directly) | 40 | 16.5% | +| — `partial-violation` (split: extract logic, keep seam) | 8 | 3.3% | +| `delete` (dead) | 0 | 0% | + +**Honest read:** `apps/code` is already ~65% of the way to a thin host. The remaining +third is concentrated and mechanical. Notably, **40 of the 84 violations are +`collapse`** — these are not "port the logic" tasks at all; they are one-line tRPC +wrappers that should simply stop existing, with consumers reaching `HOST_TRPC_CLIENT` +directly. Only **36 files contain real portable logic that must physically move** into +a package, and only **8 files are genuinely mixed** (host seam + trapped logic) and need +surgical splitting. There is **zero dead code** — every file has a destination or a +reason to stay. + +--- + +## Violations by destination + +Ordered by file count — biggest evacuation first. + +### 1. `@posthog/ui` — 30 files (feature UI) + +The largest evacuation. These are pure React components, feature hooks, stories, and +UI tests that have no Electron API and no business decision in them. They render the +store or wrap a single tRPC query. A web build ships them byte-for-byte. + +| Path | Category | Why trapped | Web can't keep it because | +| ---- | -------- | ----------- | ------------------------- | +| `renderer/components/GlobalEventHandlers.tsx` | feature-ui | Keyboard shortcuts, navigation, task switching over zustand + react-hotkeys | Pure UX feature; reachable via tRPC subscriptions, no host API | +| `renderer/components/FullScreenLayout.tsx` | feature-ui | Wraps `@posthog/ui` layout + injects UpdateBanner / support link | Same layout on web; support link is `os.openExternal` over tRPC | +| `renderer/components/ErrorBoundary.tsx` | feature-ui | Wraps `@posthog/ui` ErrorBoundary, adds logging/analytics ports | Web injects same telemetry via ports | +| `renderer/components/ErrorBoundary.test.tsx` | feature-ui | Test for the wrapper above | Moves with the component | +| `renderer/features/code-review/reviewHost.tsx` | feature-ui | Worker factory + renders UI panel, no host logic | Pure renderer orchestration | +| `renderer/features/auth/components/AuthScreen.tsx` | feature-ui | Auth UI layout + SignInCard | Pure React, no Electron API | +| `renderer/features/auth/components/InviteCodeScreen.tsx` | feature-ui | Invite-code form, calls `@posthog/ui` mutations | Pure form, no host logic | +| `renderer/features/auth/components/SignInCard.tsx` | feature-ui | Thin wrapper passing `IS_DEV` to `@posthog/ui/SignInCard` | Web injects same flag via env | +| `renderer/features/auth/hooks/authClient.ts` | transport-glue | Wraps `@posthog/ui` authClient builder with trpcClient token accessors | Web supplies different token accessors; belongs in ui auth adapter | +| `renderer/features/auth/hooks/authQueries.ts` | feature-ui / transport-glue | Auth query builders, mostly re-exports from `@posthog/ui/auth` | Just wraps trpcClient queries | +| `renderer/features/auth/hooks/useAuthSession.ts` | feature-ui / business-service | Auth lifecycle orchestration (identity sync, analytics, seats) over trpcClient | UI-side effects + business logic; belongs in ui features layer | +| `renderer/features/workspace/hooks/useWorkspace.ts` | feature-ui / transport-glue | Workspace API wrapper over trpcClient + re-exports from `@posthog/ui` | No host API; collapse wrapper, hook moves to ui | +| `renderer/features/folders/hooks/useFolders.ts` | feature-ui / transport-glue | Folders API wrapper over trpcClient + re-exports | No host API; collapse wrapper, hook moves to ui | +| `renderer/features/mcp-apps/components/McpAppHost.tsx` | feature-ui | iframe host, ResizeObserver, theme via tRPC — **classified both keep & evacuate** | See note: sandbox seam is Electron-specific; the renderer-only parts move | +| `renderer/features/mcp-apps/utils/mcp-app-sandbox-proxy.test.ts` | feature-ui | Test for sandbox HTML generation (shared logic) — **also classified keep** | Tests shared feature | +| `renderer/features/message-editor/components/PromptInput.stories.tsx` | feature-ui | Storybook story for `@posthog/ui` PromptInput | Story lives with its component | +| `renderer/features/sessions/components/GeneratingIndicator.test.ts` | feature-ui | Test for `formatDuration` from `@posthog/ui` | Tests shared util | +| `renderer/features/sessions/components/session-update/McpToolBlock.tsx` | feature-ui | Composes McpToolView + McpAppHost over tRPC | McpToolBlock moves; McpAppHost sandbox stays | +| `renderer/hooks/useRepoFiles.ts` | transport-glue | Re-exports hook + `fetchRepoFiles` wrapping trpcClient | Query moves to `REPO_FILES_CLIENT` adapter; hook to ui | +| `renderer/hooks/useRepositoryDirectory.ts` | transport-glue | Query helpers via trpcClient + workspaceApi | Reachable via `trpcClient.folders.*`; belongs in ui | +| `renderer/utils/electronStorage.ts` | transport-glue | Wraps `trpcClient.secureStore` via container binding | No real logic; collapse into direct trpcClient in ui | +| `renderer/utils/notifications.ts` | transport-glue | Re-exports `TaskNotificationService` from `@posthog/ui`, delegates via container | Wrapper with no value; direct service injection | + +> **Note on `McpAppHost.tsx` and the two mcp-apps tests:** these appear twice in the +> classification with conflicting verdicts. The reconciled reading: the **sandbox/iframe +> isolation seam** (`mcp-sandbox://` protocol, sandbox attributes) is Electron-specific +> and **stays** (see Keep). The renderer-only composition (`McpToolBlock`, the +> sandbox-proxy HTML test) is portable and moves to `@posthog/ui`. Treat `McpAppHost` +> as a **partial split**, not a wholesale move. + +### 2. `HOST_TRPC_CLIENT` — 14 files (collapse, no destination package) + +These are the purest waste: thin adapters whose every method is a single +`trpcClient.X.Y(...)` call with no Electron API and no logic. They do not move — they +**disappear**. Consumers reach `HOST_TRPC_CLIENT` (or the host router) directly. + +| Path | Wraps | Why trapped | +| ---- | ----- | ----------- | +| `renderer/platform-adapters/archive.ts` | `host.archive.*`, `host.contextMenu.*` | Pure tRPC wrapper | +| `renderer/platform-adapters/code-review-workspace-client.ts` | `git.getFileAtHead`, `fs.readRepoFile/writeRepoFile` | Pure tRPC wrapper | +| `renderer/platform-adapters/connectivity.ts` | `connectivity.getStatus/onStatusChange` | tRPC orchestration into a zustand store | +| `renderer/platform-adapters/external-apps-client.ts` | `hostTrpcClient.externalApps.*` | Pure tRPC wrapper | +| `renderer/platform-adapters/file-watcher-control.ts` | `fileWatcher.start/stop` | Pure tRPC wrapper | +| `renderer/platform-adapters/github-issue-client.ts` | `trpcClient.git.getGithubIssue` | Pure tRPC wrapper | +| `renderer/platform-adapters/linear-oauth-flow.ts` | `hostTrpcClient.linearIntegration.startFlow` | Pure tRPC wrapper | +| `renderer/platform-adapters/llm-gateway-client.ts` | `hostTrpcClient.llmGateway.prompt` | Pure tRPC wrapper | +| `renderer/platform-adapters/local-handoff-host.ts` | `folders`, `os.selectDirectory` | Pure tRPC wrapper | +| `renderer/platform-adapters/message-editor-host.ts` | `git`, `fs`, `os` queries — **also classified keep w/ queryClient coupling** | See note below | +| `renderer/platform-adapters/notifications.ts` | `notification.send`, `showDockBadge`, `bounceDock` | Pure tRPC wrapper (all Electron via tRPC) | +| `renderer/platform-adapters/report-model-resolver.ts` | `agent.getPreviewConfigOptions` + `selectModelFromOptions` (core) | Thin wrapper + error log | +| `renderer/platform-adapters/terminal.ts` | `shell.getProcess` | Pure tRPC wrapper | +| `renderer/platform-adapters/workspace-setup.ts` | `git.detectRepo` + logger export | Pure tRPC wrapper | + +> **Note on `message-editor-host.ts`:** classified `collapse → HOST_TRPC_CLIENT` in one +> pass and `keep` in another because it also pulls in `queryClient.fetchQuery` caching +> and host OS calls (`selectDirectory`, clipboard, image downscaling). The tRPC +> pass-throughs collapse; if the queryClient/OS coupling survives, what's left is a thin +> host seam. Verify the residue before deleting outright. + +### 3. `packages/core` — 10 files (portable business logic) + +Real transport-agnostic logic: analytics, billing, git interaction, integrations, the +renderer-side SessionService adapter. Move the logic into core; host keeps only the +DI binding. + +| Path | Category | Why trapped | Web can't keep it because | +| ---- | -------- | ----------- | ------------------------- | +| `renderer/features/sessions/service/service.ts` | business-service | Renderer adapter wiring `@posthog/core` SessionService with trpcClient | Pure DI; belongs in core / di container | +| `renderer/features/sessions/service/service.test.ts` | business-service | Tests SessionService wrapper logic | Moves with the service | +| `renderer/features/sessions/service/service.recovery.integration.test.ts` | business-service | Integration test of service recovery with real store | Not host-specific | +| `renderer/utils/analytics.ts` | business-service | posthog-js SDK init, feature flags, event tracking | Web has its own posthog-js setup; logic is portable | +| `renderer/utils/analytics.test.ts` | business-service | Tests for analytics.ts | Moves with the module | +| `renderer/platform-adapters/billing-client.ts` | business-service | Wraps cloud API client + tRPC for billing ops | Transport-agnostic billing logic | +| `renderer/platform-adapters/git-interaction.ts` | business-service | `IGitWriteClient` + onboarding/session orchestration over tRPC | Transport-agnostic — but see partial note below | +| `renderer/platform-adapters/github-connect-client.ts` | business-service | Wraps `getAuthenticatedClient().disconnectGithubUserIntegration()` | Transport-agnostic; core imports auth client | +| `renderer/platform-adapters/integrations-client.ts` | business-service | Cloud API calls mixed with tRPC + `window.open` | Cloud logic → core; tRPC seam stays (partial) | +| `renderer/platform-adapters/navigation-task-binder.ts` | transport-glue | `EnsureWorkspaceForTask` with staleness/mode/auto-register logic | Orchestration belongs in a core `task-navigation-orchestrator` (partial) | + +### 4. `packages/host-router` — 6 files (router bodies with logic) + +tRPC routers that carry logic or delegate to a service. The router *definition* should +live in `@posthog/host-router` (using `ctx.container`), not in `apps/code`. The host +file becomes an aggregation one-liner. + +| Path | Verdict | Why trapped | +| ---- | ------- | ----------- | +| `main/trpc/routers/logs.ts` | evacuate | `fetchS3Logs` HTTP fetch + `readLocalLogs/writeLocalLogs`; inline business logic | +| `main/trpc/routers/handoff.ts` | evacuate | Delegates 5 procedures to `@posthog/core` HandoffService; no Electron API | +| `main/trpc/routers/file-watcher.ts` | collapse | One-liner delegation to FileWatcherBridge; define in host-router | +| `main/trpc/routers/connectivity.ts` | collapse | Caching pass-through to `workspace.connectivity`; no Electron API | +| `main/trpc/routers/environment.ts` | collapse | `list/get/create/update/delete` → `ws().environment.*`; pure delegation | +| `renderer/platform-adapters/archive.ts` | collapse | (also listed under HOST_TRPC_CLIENT) `host.archive.*` mapping — host-router can expose the binding | + +> Several of these routers also have a second classification of `keep` from the +> main-process pass (treating them as thin K1 wiring). The distinction: if the body is a +> bare `getService().method()` one-liner with **no inline logic**, it can stay as K1 +> aggregation; if it carries `fetch`/branching/math (e.g. `logs.ts`), the definition +> must move to host-router. Resolve per-file against the litmus test. + +### 5. `packages/workspace-server` — 5 files (forwarding bridges) + +Pure event/call bridges that forward to `workspace-client`. They re-emit events or +pass parameters straight through. Consumers should call `workspaceClient.*` directly; +the bridge collapses into the workspace-server client surface. + +| Path | Verdict | Why trapped | +| ---- | ------- | ----------- | +| `main/services/connectivity/service.ts` | collapse | Caching wrapper around `workspace-client.connectivity`; AuthService should call it directly | +| `main/services/file-watcher/bridge.ts` | collapse | Subscribes to `workspace-client.fileWatcher`, re-emits as TypedEventEmitter | +| `main/services/fs/service.ts` | collapse | Every method forwards to `workspaceClient.fs.*` | +| `main/services/handoff/git-gateway.ts` | collapse | Three methods forward to `workspaceClient.git.*` | +| `main/services/local-logs/service.ts` | collapse | Five methods forward to `workspaceClient.localLogs.*` | + +> `main/services/focus/service.ts` is a **partial** (below): its git/focus ops delegate to +> workspace-server, but its local focus-store persistence is host-specific and stays. + +### 6. Unspecified destination — partials and split cases (11 references) + +These need a decision, not a wholesale move. Most are `partial-violation`: a legitimate +host seam fused with trapped logic. Split the logic out; keep the seam. + +| Path | Verdict | Split direction | +| ---- | ------- | --------------- | +| `main/services/app-lifecycle/service.ts` | partial | Graceful-shutdown logic → `packages/core`; `process.exit` / `IAppLifecycle` calls stay | +| `main/services/focus/service.ts` | partial | git/focus ops → `packages/workspace-server`; local focusStore persistence stays | +| `renderer/di/container.ts` | partial (K4) | Extract embedded effect objects (`updatesClient` handlers, `focusDeps`, `taskCreationEffects`, `gitInteractionEffects`) into service classes; container keeps only bindings | +| `renderer/platform-adapters/task-creation-host.ts` | partial | Lines mutating zustand/panel state → orchestration service; tRPC wrapper stays | +| `renderer/platform-adapters/git-interaction.ts` | partial | Transport wrap stays; side-effect orchestration (analytics, celebrations, PR attach) splits out | +| `renderer/platform-adapters/integrations-client.ts` | partial | Cloud API logic → `packages/core`; tRPC/`openExternal` seam stays | +| `renderer/platform-adapters/navigation-task-binder.ts` | partial | Folder staleness / workspace-mode logic → core `task-navigation`; thin client adapter stays | +| `renderer/features/auth/hooks/authClient.ts` | partial (K4) | DI glue binding trpc accessors to ui authClient; minimal — stays for wiring | +| `shared/constants.ts` | partial | Shared flags (`BILLING_FLAG`, `BRANCH_PREFIX`) → `@posthog/shared`; host fs paths (`DATA_DIR`, `WORKTREES_DIR`) stay | +| `renderer/utils/electronStorage.ts` | collapse → `@posthog/ui` | (listed above) | +| `renderer/utils/notifications.ts` | collapse → `@posthog/ui` | (listed above) | + +--- + +## Keep (conforming) + +158 files correctly stay. They cluster into the four contract buckets: + +- **K3 — Host entry / boot:** `main/bootstrap.ts`, `main/index.ts`, `main/window.ts`, + `main/menu.ts`, `main/deep-links.ts`, `main/preload.ts`, `renderer/main.tsx`, + `renderer/App.tsx`. Electron app lifecycle, window creation, renderer mount. +- **K2 — Electron platform adapters:** the entire `main/platform-adapters/electron-*.ts` + set (clipboard, dialog, crypto, secure-storage, updater, notifier, power-manager, + storage-paths, url-launcher, image-processor, file-icon, app-lifecycle, app-meta, + bundled-resources, context-menu, main-window, workspace-settings), plus + `posthog-analytics.ts` (posthog-node), `renderer/utils/logger.ts` (electron-log), + `renderer/utils/queryClient.ts` (Electron focus manager), and the + `shared/mcp-sandbox-proxy.*` isolation seam. Each touches a real Electron/Node API + and a web build needs its own version (`webNeedsOwnVersion: true`). +- **K1 — Transport / IPC bridge:** `main/trpc/trpc.ts` (call-rate middleware), + `main/trpc/router.ts` (router aggregation), and the host-process child-lifecycle + routers (`workspace-server.ts`, `encryption.ts`) that touch `node:child_process` / + `safeStorage`. +- **K4 — Composition root:** `main/di/container.ts`, `main/di/tokens.ts`, + `renderer/desktop-services.ts`, `renderer/desktop-contributions.ts`, + `renderer/di/tokens.ts`, `renderer/components/Providers.tsx`, the auth `port-adapters.ts`. + Plus host services that are genuinely thin Electron/Node managers: `workspace-server/service.ts` + (child-process spawner), `deep-link/service.ts`, `encryption/service.ts`, + `secure-store/service.ts`, `settingsStore.ts`, + and the `main/utils/*` host utilities (logger, env, encryption, fixPath, + macos-packaged-install-guard, otel-log-transport, async). Also test files, vite build + declarations, and `art.txt`. + +> **Correction (misclassified keep): `main/services/usage-monitor/store.ts` EVACUATES.** +> It is `electron-store`-backed *domain* state (`thresholdsSeen`: billing-window +> threshold dedup) with domain pruning logic — not a host concern. The `UsageMonitorService` +> already lives in `packages/core/src/usage/`. The `thresholdsSeen` persistence belongs in +> a SQLite Repository (or a generic key-value platform port), NOT a feature-specific +> `electron-store` in the host. apps/code may keep at most a *generic* storage adapter, +> never a usage-monitor-specific store. This also flags a class the audit under-counted: +> **`electron-store`-backed domain stores** — re-check `settingsStore.ts` (likely legit host +> config) vs any other feature store (domain → evacuate). + +### Composition roots flagged `partial-violation` (logic to extract while keeping the file) + +Two K4 files carry embedded business logic that violates thin-composition-root and must +be extracted **without moving the file**: + +- **`renderer/di/container.ts`** — `updatesClient` subscription handlers, + `focusDeps` (git/focus/workspace side effects), `taskCreationEffects` (panel/store + state), and `gitInteractionEffects` (analytics + store mutation) are full orchestration + objects living inside bindings. Promote each to a named service class; the container + keeps only `bind(...)`. +- **`renderer/features/auth/hooks/authClient.ts`** — minimal DI glue; acceptable to keep + but should be the thinnest possible token-accessor wiring. + +--- + +## Evacuation order + +Different destination packages rarely collide, so most streams run in parallel. The +serial zones are **shared host files** (`main/trpc/router.ts`, `renderer/di/container.ts`, +`renderer/desktop-services.ts`, `shared/constants.ts`) that every stream re-touches. + +**Wave 0 — mechanical, low-risk, high-volume (run all in parallel):** +1. `HOST_TRPC_CLIENT` collapses (14 files). Delete each wrapper, repoint consumers to + `hostTrpcClient.*`. No logic to preserve. Biggest count, smallest risk. +2. `packages/workspace-server` bridge collapses (5 files). Repoint consumers to + `workspaceClient.*`. + +These two waves only delete files and repoint call sites; they unblock everything +downstream by shrinking the surface other streams have to reason about. + +**Wave 1 — feature UI evacuation (the bulk, parallelizable by feature):** +3. `@posthog/ui` moves (30 files). Group by feature (auth, workspace, folders, + mcp-apps, sessions, code-review, message-editor, top-level components). Auth is the + densest cluster — do it as one unit so `authClient`/`authQueries`/`useAuthSession`/the + three screens move together. Stories and UI tests follow their components. + *Risk:* the `McpAppHost` / `McpToolBlock` split — keep the Electron sandbox seam, + move only the renderer composition. + +**Wave 2 — portable logic into core (needs care, run after Wave 0 frees the seams):** +4. `packages/core` moves (10 files). SessionService adapter + its two tests first + (largest, already half-ported per project history). Then analytics + test, billing, + github-connect. The three **partials** (`git-interaction`, `integrations-client`, + `navigation-task-binder`) require splitting logic from seam — do these last and solo. + +**Wave 3 — router definitions to host-router (serial on `main/trpc/router.ts`):** +5. `packages/host-router` moves (6 files). `logs.ts` and `handoff.ts` carry real logic; + the rest are collapses. All re-touch the aggregation file `main/trpc/router.ts`, so + **serialize this wave** to avoid merge collisions. + +**Wave 4 — composition-root cleanup (serial, do last):** +6. Extract the embedded effects from `renderer/di/container.ts` into service classes. +7. Split `shared/constants.ts` (flags → `@posthog/shared`, paths stay). +8. Split the remaining partials (`app-lifecycle`, `focus`, `task-creation-host`). + +**Parallel-safe matrix:** Wave 0 ⟂ Wave 1 (different files). Wave 1 ⟂ Wave 2 by feature. +Wave 3 and Wave 4 are the serial zones because they share `router.ts` / +`container.ts` / `constants.ts`. + +--- + +## Enforcement (make it mandatory) + +The evacuation rots back the moment a new violation lands. Two gates make it +self-enforcing: a **dependency-cruiser boundary ruleset** that fails CI on new trapped +logic, and an **`apps/web` smoke target** that is the definition-of-done oracle. + +### Boundary rules (dependency-cruiser) + +`apps/code/.dependency-cruiser.cjs` sketch: + +```js +module.exports = { + forbidden: [ + { + // (a) @injectable business services may only live in platform-adapters / di + name: "no-injectable-services-in-host", + comment: "Business services belong in packages/*, not apps/code. Only platform adapters and the DI container may bind.", + severity: "error", + from: { + path: "^apps/code/src/(main|renderer)/(?!platform-adapters|di/container|desktop-services)", + }, + to: { + // flagged by a lint companion: any file declaring @injectable() outside the allowed dirs + path: "^apps/code/src", + // pair with an ESLint rule `no-injectable-outside-adapters` since dep-cruiser + // matches modules, not decorators — see lint note below + }, + }, + { + // (b) renderer feature components must not live outside boot/composition + name: "no-feature-ui-in-apps-code", + comment: "feature-ui (*.tsx components/hooks) is portable; it belongs in @posthog/ui.", + severity: "error", + from: { path: "^apps/code/src/renderer/features/.+\\.(tsx|stories\\.tsx)$" }, + to: { + pathNot: [ + "^apps/code/src/renderer/(main|App|desktop-services|desktop-contributions)", + "node_modules", + ], + }, + }, + { + // (d) host code must not import packages that signal trapped cloud/business logic + name: "no-cloud-client-in-host-adapters", + comment: "getAuthenticatedClient / @posthog/api-client business calls belong in packages/core, not renderer adapters.", + severity: "error", + from: { path: "^apps/code/src/renderer/platform-adapters/" }, + to: { path: "@posthog/api-client" }, + }, + { + // tRPC routers in apps/code may only re-export / aggregate + name: "host-routers-aggregate-only", + comment: "Router bodies with logic belong in @posthog/host-router. apps/code routers aggregate only.", + severity: "error", + from: { path: "^apps/code/src/main/trpc/routers/" }, + to: { pathNot: ["@posthog/host-router", "@posthog/workspace-server", "@posthog/core", "^apps/code/src/main/(di|trpc)"] }, + }, + ], + options: { tsConfig: { fileName: "apps/code/tsconfig.json" }, doNotFollow: { path: "node_modules" } }, +}; +``` + +Two rules dep-cruiser can't express on its own (they're about *contents*, not imports) +get a companion **ESLint** pass run in the same CI step: + +- **(a) `no-injectable-outside-adapters`** — error on a `@injectable()` decorator in any + `apps/code/src/**` file outside `platform-adapters/` and the DI container files. +- **(c) `no-logic-in-trpc-router`** — error when a `.mutation`/`.query`/`.subscription` + body in `apps/code/src/main/trpc/routers/**` contains anything beyond a single + delegating call expression (no `fetch`, no arithmetic, no `if`/`switch`). + +Wire both into the existing `pnpm lint` / a new `pnpm boundaries` turbo task so they run +on every PR. The `severity: "error"` entries make CI red on the first new violation. + +### The `apps/web` smoke target — definition of done + +Boundary rules prevent *new* leaks; the smoke target proves the *existing* ones are +gone. Stand up a skeletal `apps/web`: + +- a Vite entry that mounts the **same** `@posthog/ui` workbench the renderer mounts, +- a **web composition root** that binds web/HTTP platform adapters and the web tRPC + transport in place of the Electron ones, +- **no** import of anything under `apps/code/src`. + +Add a CI job `apps/web:smoke` that builds and boots this shell (headless Vite build + +a Playwright "renders without throwing" check). **The day `apps/web` boots and renders +the workbench using only `@posthog/ui` + `packages/core` + `@posthog/shared` + its own +composition root, the evacuation is complete.** Any file still trapped in `apps/code` +will surface as a missing import or a binding the web root can't satisfy — turning +"is apps/code thin yet?" into a single green/red signal. + +--- + +## Reference + +- Tally: 242 total · 158 keep · 36 evacuate · 40 collapse · 8 partial · 0 delete. +- Destinations by violation count: `@posthog/ui` 30 · `HOST_TRPC_CLIENT` 14 · + `packages/core` 10 · `packages/host-router` 6 · `packages/workspace-server` 5 · + unspecified/partial 11. diff --git a/DI_UNTANGLE_PLAN.md b/DI_UNTANGLE_PLAN.md new file mode 100644 index 0000000000..af421b732b --- /dev/null +++ b/DI_UNTANGLE_PLAN.md @@ -0,0 +1,152 @@ +# DI Untangle Plan + +This is a concrete, ordered burn-down checklist for untangling the DI port/token sprawl across `packages/core`, `packages/ui`, `packages/workspace-server`, `packages/host-router`, and `apps/code`. It is derived entirely from a verified audit. Scope by bucket: **0 client ports to collapse**, **17 client ports to KEEP** (verified, do-not-touch), **0 uncertain ports**, **7 bridge migrations + 3 side-effect imports analyzed** (10 items total), **31 logger tokens to consolidate** (1 deferred), **7 oversplit port clusters** (+ 1 strategy item), and **3 naming-convention files** to normalize. Because there are no collapse or uncertain ports, this plan is dominated by bridge removal, logger consolidation, oversplit consolidation, and naming normalization. + +## Execution order + +Apply phases in this order. The ordering is dictated by shared-file collision risk and dependency direction, not just convenience. + +1. **Collapse client ports** — none exist (Bucket 1 is empty). Skip. Nothing to do here other than confirm the KEEP list is untouched. +2. **Apply shared-file binding removals serially** — Buckets 2 and 3 both edit the same hot shared files. These edits must be funneled through a single agent. +3. **Kill bridges (Bucket 2)** — converts 7 `bindWorkbench()` module-setter bridges into normal Inversify bindings inside `renderer/di/container.ts`, and removes side-effect imports from `main.tsx`. This must precede any further `container.ts` churn so the binding surface is stable. +4. **Consolidate loggers (Bucket 3)** — Phase 1 (extend `WorkbenchLogger`) is a foundation change that unblocks all 10 logger batches. Each batch removes bindings from `apps/code/src/main/di/container.ts`. +5. **Consolidate oversplit ports (Bucket 4)** — depends on loggers being folded into `WORKBENCH_LOGGER.scope()` first, because several oversplit clusters (TASK_DELETION, OAUTH, LLM_GATEWAY, USAGE, HANDOFF, LINKS) include a `*_LOGGER` member that disappears once Bucket 3 lands. Doing Bucket 3 first shrinks each cluster. +6. **Normalize naming (Bucket 5) LAST** — renames token strings inside `Symbol.for(...)` in 3 token files. Verified zero consumer risk (no dynamic string lookups), but it touches token identity, so it goes last to avoid rebasing every other phase on top of a renamed token surface. + +### Collision hotspots — single-agent serial zone + +The following files are edited by multiple buckets/items. **All edits to these files MUST be applied serially by one agent.** Do not parallelize across these: + +- `apps/code/src/renderer/di/container.ts` — Bucket 2 (all 7 bridges add bindings here) and several KEEP-port bindings live here. +- `apps/code/src/renderer/desktop-services.ts` — multiple KEEP-port bindings. +- `apps/code/src/renderer/main.tsx` — Bucket 2 (remove 5+ side-effect imports). +- `apps/code/src/main/di/container.ts` — Bucket 3 (every logger batch removes bindings here). + +Feature-local edits (per-feature `identifiers.ts`, per-service `.ts` files) have no cross-item collisions and **may run in parallel** once the shared-file owner has committed its serial edits. + +--- + +## Bucket 1: Collapse client ports + +**No client ports were found safe to collapse.** This bucket is empty. Do not attempt to collapse any `*_CLIENT` port. + +### Keep (do not touch) + +These 17 ports are verified KEEP. Most are KEEP because a `packages/core` service injects them (`coreConsumes=true`): collapsing would force core to import `host-router`/`HOST_TRPC_CLIENT`, violating the host-agnosticity invariant. The remainder are UI-layer ports with load-bearing adapter logic or signature bridging. + +| Token | Package | Keep reason (one line) | +| --- | --- | --- | +| `GITHUB_ISSUE_CLIENT` | core | coreConsumes: injected by `NewTaskLinkResolver`; collapsing couples core to host-router. | +| `TASK_DELETION_WORKSPACE_CLIENT` | core | coreConsumes: injected by `TaskDeletionService`; passthrough adapter but core must stay host-agnostic. | +| `ARCHIVE_CLIENT` | core | coreConsumes (has_logic): `UnarchiveService` does error parsing/result mapping/branch-not-found handling. | +| `WORKSPACE_SETUP_GIT_CLIENT` | core | coreConsumes: `WorkspaceSetupService` injects it; decouples core from `HOST_TRPC_CLIENT.git.detectRepo`. | +| `CODE_REVIEW_WORKSPACE_CLIENT` | core | coreConsumes: `revertHunkService` reads/writes files via the port; real hunk-revert logic. | +| `REPOSITORIES_CLIENT` | core | coreConsumes: `RepositoriesService` (Promise.all batching, query keys); no HOST_TRPC equivalent. | +| `GITHUB_CONNECT_CLIENT` | core | coreConsumes (has_logic): two core services inject it; adapter calls startFlow + openExternal. | +| `TITLE_GENERATOR_FILE_READ_CLIENT` | core | coreConsumes: `titleGeneratorService` injects it; passthrough adapter but core decoupling required. | +| `EXTERNAL_APPS_WORKSPACE_CLIENT` | core | coreConsumes: `ExternalAppService` injects it; must not depend on host tRPC infra. | +| `GIT_WRITE_CLIENT` | core | coreConsumes: `GitInteractionService` injects it; structural decoupling mechanism. | +| `SEAT_CLIENT` | core | coreConsumes (has_logic): `SeatService`; invalidatePlanCache dual-dispatch + analytics; most ops hit cloud HTTP API. | +| `GIT_WORKSPACE_CLIENT` | core | (low-confidence keep) host↔workspace-server boundary; collapsing creates a circular host-router dep; `git.router` adds transform logic. | +| `FILE_WATCHER_CLIENT` | ui | UI-only port; clean file-watcher abstraction; collapse gives minimal benefit. | +| `SHELL_CLIENT` | ui | (low-confidence keep) `TerminalManager` stateful orchestration; onData/onExit signature bridge, not a pure forward. | +| `UPDATES_CLIENT` | ui | (low-confidence keep) adapter performs signature transforms (onCheckFromMenu void, onReady wrap). | +| `IMPERATIVE_QUERY_CLIENT` | ui | coreConsumes: `SessionServiceDeps` queryClient (invalidate/refetch); no HOST_TRPC equivalent for cache. | + +### Uncertain — needs human decision + +**None.** The audit produced zero uncertain ports. The three low-confidence entries above (`GIT_WORKSPACE_CLIENT`, `SHELL_CLIENT`, `UPDATES_CLIENT`) were downgraded to KEEP (3/3 skeptics refuted) and require no human decision — treat them as KEEP. + +--- + +## Bucket 2: Kill bridges + +Convert 7 `bindWorkbench()` module-setter bridges into normal Inversify bindings in `renderer/di/container.ts`, and remove the corresponding side-effect imports from `main.tsx`. Items 8–10 are side-effect-import analysis (mostly keep-as-is). + +**Serial constraint:** every item edits `renderer/di/container.ts` and `main.tsx` — one agent, serially, in numeric order. The `UPDATES_CLIENT` binding (item 1) MUST be added after `setWorkbenchContainer(container)` so `platform-adapters/updates.ts` resolves it at its module-load time. + +| # | Item | Edit files | sharedFileDeltas (summary) | Risk | +| --- | --- | --- | --- | --- | +| 1 | updatesClientAdapter (UPDATES_CLIENT) | `renderer/di/container.ts`, `renderer/main.tsx`, `renderer/features/updates-client/updatesClientAdapter.ts` | main.tsx: remove lines 9, 12. container.ts: add `updatesClient` const + `bind(UPDATES_CLIENT)` after trpcClient binding; import `UPDATES_CLIENT`/`UpdatesClient`. Delete adapter file. | low | +| 2 | shellClientAdapter (SHELL_CLIENT) | `renderer/di/container.ts`, `renderer/main.tsx`, `renderer/features/terminal-client/shellClientAdapter.ts` | main.tsx: remove line 13. container.ts: add `shellClient` const + `bind(SHELL_CLIENT)`; import `SHELL_CLIENT`/`ShellClient`. Delete adapter file. | low | +| 3 | focusClientAdapter (FOCUS_CONTROLLER_DEPS) | `renderer/di/container.ts`, `renderer/main.tsx`, `renderer/features/focus-client/focusClientAdapter.ts` | main.tsx: remove line 14. container.ts: move `focusDeps` (lines 6–60) + `bind(FOCUS_CONTROLLER_DEPS)`; import `FocusControllerDeps`, `FOCUS_CONTROLLER_DEPS`. Delete adapter file. | low | +| 4 | reviewHostBindings (REVIEW_HOST + DIFF_WORKER_FACTORY) | `renderer/di/container.ts`, `renderer/main.tsx`, `renderer/features/code-review/reviewHostBindings.tsx` | main.tsx: remove lines 15–17. container.ts: bind `DIFF_WORKER_FACTORY` then `REVIEW_HOST` (references diffWorkerFactory); imports WorkerUrl, ChangesPanel, etc. **JSX in object literal → container.ts must become .tsx OR extract a .tsx factory.** Delete bindings file. | medium | +| 5 | mcpToolBlockHost (MCP_TOOL_BLOCK_COMPONENT) | `renderer/di/container.ts`, `renderer/main.tsx`, `renderer/features/sessions/mcpToolBlockHost.ts` | main.tsx: remove line 8 (+ line 7 comment). container.ts: `bind(MCP_TOOL_BLOCK_COMPONENT).toConstantValue(McpToolBlock)`; import McpToolBlock + token. Delete file. | low | +| 6 | terminal platform-adapter (SHELL_PROCESS_READER + terminalCoreModule) | `renderer/di/container.ts`, `renderer/main.tsx` | main.tsx: remove line 6. container.ts: add `shellProcessReader` const + `bind(SHELL_PROCESS_READER)` + `container.load(terminalCoreModule)`; import token + module. Keep `terminal.ts` as pure export (drop bindWorkbench). | low | +| 7 | analytics (ANALYTICS_TRACKER) | `renderer/di/container.ts`, `renderer/utils/analytics.ts` | analytics.ts: remove lines 222–227 (bindWorkbench) + bindWorkbench import. container.ts: `bind(ANALYTICS_TRACKER).toConstantValue({ track, setActiveTaskContext })`; import functions + token. **Verify analytics.ts is currently imported somewhere — bindWorkbench may be dead code today.** | medium | + +Side-effect imports (analysis only — mostly keep): + +| # | Item | Action | Risk | +| --- | --- | --- | --- | +| 8 | connectivity platform-adapter | Pure side-effect init (no bindWorkbench). **Keep the import** in main.tsx; removing it without re-homing init breaks connectivity tracking. Document why if kept. | medium | +| 9 | electronStorage side-effect import | Must run before store hydration. **Keep as-is** (first import in main.tsx). No migration. | low | +| 10 | rendererWindowFocusStore side-effect import | Must run before inbox queries mount. **Keep as-is**. No migration. | low | + +--- + +## Bucket 3: Consolidate loggers + +Consolidate 31 per-feature `*_LOGGER` tokens into a single `WORKBENCH_LOGGER` with a `.scope(name)` method. Phase 1 is the foundation and **must land before all batches**. `AGENT_LOGGER` is **deferred** (already implements `.scope()`). + +Every batch removes bindings from `apps/code/src/main/di/container.ts` — that file is in the serial zone. + +| Phase / Batch | Tokens removed | Edit files (summary) | Risk | +| --- | --- | --- | --- | +| **Phase 1 (Foundation)** | — | `packages/di/src/logger.ts`: add `debug()` and `scope(name): ScopedLogger` to `WorkbenchLogger`. | low | +| Batch 1 | TASK_DELETION_LOGGER, SLEEP_LOGGER, WORKSPACE_SETUP_LOGGER | core `tasks/`, `sleep/`, `workspace/` identifiers + services; container.ts (−3 bindings) | low | +| Batch 2 | NOTIFICATION_LOGGER, OAUTH_LOGGER, CLOUD_TASK_LOGGER | core `notification/`, `oauth/`, `cloud-task/` identifiers + services; container.ts (−3) | low | +| Batch 3 | LLM_GATEWAY_LOGGER, USAGE_LOGGER, UPDATES_LOGGER | core `llm-gateway/`, `usage/`, `updates/` identifiers + services; container.ts (−3) | low | +| Batch 4 | TASK_DETAIL_LOGGER (2 consumers), HANDOFF_LOGGER | core `task-detail/` (taskService + workspaceSetupSaga), `handoff/`; container.ts (−2). HANDOFF casts scoped logger to `SagaLogger`. | medium | +| Batch 5 | GITHUB_INTEGRATION_LOGGER, SLACK_INTEGRATION_LOGGER, MCP_APPS_LOGGER | core `integrations/` (github.ts, slack.ts), `mcp-apps/`; container.ts (−3) | low | +| Batch 6 | TASK_LINK_LOGGER, INBOX_LINK_LOGGER, NEW_TASK_LINK_LOGGER | core `links/` (task-link, inbox-link, new-task-link); container.ts (−3) | low | +| Batch 7 | GIT_PR_LOGGER, SEAT_LOGGER | core `git-pr/`, `billing/`; container.ts (−2) | low | +| Batch 8 | ARCHIVE_LOGGER, AUTH_PROXY_LOGGER, FOLDERS_LOGGER | workspace-server `archive/`, `auth-proxy/`, `folders/`; container.ts (−3) | low | +| Batch 9 | SHELL_LOGGER, WORKSPACE_LOGGER, ENRICHMENT_LOGGER | workspace-server `shell/`, `workspace/`, `enrichment/`; container.ts (−3) | low | +| Batch 10 | MCP_CALLBACK_LOGGER, MCP_PROXY_LOGGER, SUSPENSION_LOGGER, POSTHOG_PLUGIN_LOGGER, WATCHER_REGISTRY_LOGGER | workspace-server `mcp-callback/`, `mcp-proxy/`, `suspension/`, `posthog-plugin/`, `watcher-registry/`; container.ts (−5) | low | +| **Deferred** | AGENT_LOGGER | None. Keep separate — already has `.scope()`. Evaluate in follow-up PR. | high | + +Per-batch edit shape: remove token from `identifiers.ts`, switch service to `@inject(WORKBENCH_LOGGER)` + `.scope("feature")`, remove binding from `main/di/container.ts`. No behavior change (all are already bound as `logger.scope("name")`). + +--- + +## Bucket 4: Consolidate oversplit ports + +Collapse clusters of 3+ sibling host ports (always bound together into one consumer) into a single `*_HOST` interface + token. Run **after Bucket 3** so each cluster's `*_LOGGER` member is already gone. Platform-layer services (DEEP_LINK, MAIN_WINDOW, NOTIFIER, CRYPTO, POWER_MANAGER, etc.) and core domain services (CLOUD_TASK_SERVICE) **remain separate** — only host/UI adaptation ports consolidate. + +| Cluster | Ports → consolidated token | Edit files | Risk | +| --- | --- | --- | --- | +| TASK_DELETION (5 ports) | DIALOG, FOCUS, PINS, NAVIGATION, LOGGER → `TASK_DELETION_HOST` | core `tasks/identifiers.ts`, `tasks/taskDeletionService.ts`, adapter (TBD) | low | +| LLM_GATEWAY (3 ports) | AUTH, ENDPOINTS, LOGGER → `LLM_GATEWAY_HOST` | core `llm-gateway/identifiers.ts`, `llm-gateway.ts`, adapter (TBD) | low | +| HANDOFF (2 ports) | HOST, LOGGER → `HANDOFF_HOST` (keep CLOUD_TASK_SERVICE separate) | core `handoff/identifiers.ts`, `handoff.ts` | low | +| OAUTH (3 host ports) | CALLBACK, ENV, LOGGER → `OAUTH_HOST` (keep DEEP_LINK/URL_LAUNCHER/MAIN_WINDOW/CRYPTO separate) | core `oauth/identifiers.ts`, `oauth.ts`, adapter (workspace-server/electron) | medium | +| USAGE_MONITOR (4 ports) | GATEWAY, ACTIVITY_MONITOR, THRESHOLD_STORE, LOGGER → `USAGE_HOST` | core `usage/identifiers.ts`, `usage-monitor.ts`, adapter (TBD) | low | +| AUTH (5 host ports) | SESSION_STORE, PREFERENCE_STORE, OAUTH_FLOW, CONNECTIVITY, TOKEN_CIPHER → `AUTH_HOST` (keep POWER_MANAGER + AUTH_TOKEN_OVERRIDE) | core `auth/identifiers.ts`, `auth.ts`, adapter (platform/workspace-server) | medium | +| LINKS (scattered loggers, 3 services) | TASK_LINK_LOGGER, INBOX_LINK_LOGGER, NEW_TASK_LINK_LOGGER → unified `LINKS_HOST` logging (keep DEEP_LINK/MAIN_WINDOW separate) | core `links/identifiers.ts`, `task-link.ts`, `inbox-link.ts`, `new-task-link.ts` | medium | + +Recommended phased rollout (from the audit): Phase A (low-risk PoC) TASK_DELETION + LLM_GATEWAY + HANDOFF; Phase B OAUTH + USAGE; Phase C AUTH (critical, auth bootstrap); Phase D LINKS pattern. Per cluster: (1) new aggregated interface + token in `identifiers.ts`, (2) one `@inject` in the service, (3) one adapter implementing the interface, (4) one binding in the module, (5) test. + +> Note: LINKS loggers (Batch 6) and the `*_LOGGER` members of the other clusters overlap with Bucket 3. If Bucket 3 lands first, the LINKS cluster reduces to the DEEP_LINK/host concern and the other clusters lose their LOGGER member. Re-confirm each cluster's remaining members against the tree before editing. + +--- + +## Bucket 5: Normalize naming + +Adopt the unified `Symbol.for('posthog...')` scheme. **Verified zero consumer risk** — no dynamic `Symbol.for()` string lookups exist; all consumers import symbol constants by reference, so these are definition-file-only edits. Do this **last**. + +| Item | Edit files | sharedFileDeltas | Risk | +| --- | --- | --- | --- | +| Normalize injection-token namespaces | `apps/code/src/main/di/tokens.ts`, `apps/code/src/renderer/di/tokens.ts`, `packages/workspace-server/src/di/tokens.ts` | main: 48 `Main.X` → `posthog.host.main..`. renderer: 2 `Renderer.X` → `posthog.host.renderer.`. ws-server: 8 `WorkspaceServer.X` → `posthog.workspace.`. | low | + +Layer map: `posthog.host.main` (Electron main), `posthog.host.renderer` (Electron renderer), `posthog.workspace` (workspace-server), `posthog.core`, `posthog.ui`, `posthog.platform`. + +--- + +## Verification gate + +After each structural phase (and mandatorily after Buckets 2, 3, 4, 5): + +1. Rebuild affected package dist where a service moved (`@posthog/di`, `@posthog/core`, `@posthog/workspace-server`) before typechecking dependents. +2. `pnpm typecheck` — fix-loop on red. Grep the error list for the paths YOU touched; ignore exogenous red from unrelated in-flight refactor slices. +3. Renderer `vite build` smoke (the cheap runtime check that the Electron app boots and all DI bindings resolve at module-load order) — fix-loop on red. This is the gate that catches missing renderer Vite aliases and broken binding order from Bucket 2. +4. Run colocated tests for any service whose injection changed (`pnpm --filter core test`, `pnpm --filter @posthog/workspace-server test`); repoint any `vi.mock` that referenced a removed `*_LOGGER` token or a consolidated `*_HOST` token. 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/drizzle.config.ts b/apps/code/drizzle.config.ts index a6b40eaa61..aefc09d1d0 100644 --- a/apps/code/drizzle.config.ts +++ b/apps/code/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: "../../packages/workspace-server/src/db/schema.ts", + out: "../../packages/workspace-server/src/db/migrations", casing: "snake_case", dbCredentials: { url: path.join(userDataPath, "posthog-code.db"), diff --git a/apps/code/package.json b/apps/code/package.json index 2a3af5f702..ba3521a4e7 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -133,10 +133,13 @@ "@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:*", diff --git a/apps/code/src/main/db/service.ts b/apps/code/src/main/db/service.ts deleted file mode 100644 index 853ef2dda1..0000000000 --- a/apps/code/src/main/db/service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import path from "node:path"; -import type { IStoragePaths } from "@posthog/platform/storage-paths"; -import Database from "better-sqlite3"; -import { - type BetterSQLite3Database, - drizzle, -} 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() -export class DatabaseService { - private _db: BetterSQLite3Database | null = null; - private _sqlite: InstanceType | null = null; - - constructor( - @inject(MAIN_TOKENS.StoragePaths) - private readonly storagePaths: IStoragePaths, - ) {} - - get db(): BetterSQLite3Database { - if (!this._db) { - throw new Error("Database not initialized — call initialize() first"); - } - return this._db; - } - - @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; - } - } - - @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/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..eb96d1f983 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -1,20 +1,199 @@ 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 type { HostGitWorkspaceClient } from "@posthog/core/git/host-git"; +import { + GIT_AGENT_SERVICE, + GIT_SERVICE, + GIT_WORKSPACE_CLIENT, +} 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 { HandoffHostService } from "@posthog/workspace-server/services/handoff/service"; +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 { 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 { 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 +204,541 @@ 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 { 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 type { FileWatcherBridge } from "../services/file-watcher/bridge"; +import type { FocusService } from "../services/focus/service"; +import { FocusServiceEvent } from "../services/focus/service"; +import { GitPrHostService } from "../services/git/git-pr-host"; +import { TrpcHandoffGitGateway } from "../services/handoff/git-gateway"; +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 { usageMonitorStore } from "../services/usage-monitor/store"; 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); +// PORT NOTE: bridge to @posthog/workspace-server/db. The DB layer and its DI +// identifiers live in the workspace-server package (databaseModule owns +// DATABASE_SERVICE; repositoriesModule owns the per-repository identifiers). +// The MAIN_TOKENS.* aliases below bridge legacy apps/code consumers; retire each +// once its consumer injects the package identifier directly. +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); +// PORT NOTE: AgentService + AgentAuthAdapter moved to +// @posthog/workspace-server/services/agent (the agent-SDK host integration that +// spawns sessions). It can't live in core (core can't import @posthog/agent's +// Node runtime), so workspace-server hosts it and its core/host deps are inverted +// into narrow ports: sleep (SleepService), mcp-apps (McpAppsService), repo-files +// (FsService bridge), auth (AuthService), and a scoped logger factory. All +// consumers (handoff/git/router/archive/suspension/usage-monitor) inject +// AGENT_SERVICE / AGENT_AUTH_ADAPTER directly — the MAIN_TOKENS aliases are gone. +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); +// PORT NOTE: OsService (host OS ops: dialogs, attachments, image downscale, +// claude-settings read, dir search) moved to @posthog/workspace-server/services/os. +// Injects only platform services. Consumers inject OS_SERVICE directly. +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); +// PORT NOTE: AuthProxyService (localhost LLM-gateway auth proxy) moved to +// @posthog/workspace-server/services/auth-proxy (host http.Server). Auth injected +// as a port. Retire MAIN_TOKENS.AuthProxyService once consumers inject AUTH_PROXY_SERVICE. +container.load(authProxyModule); +container.bind(AUTH_PROXY_AUTH).toDynamicValue((ctx) => ({ + authenticatedFetch: (url: string, init?: RequestInit) => + ctx + .get(MAIN_TOKENS.AuthService) + .authenticatedFetch(fetch, url, init), +})); +// PORT NOTE: McpProxyService (localhost MCP auth-injecting proxy) moved to +// @posthog/workspace-server/services/mcp-proxy (host http.Server). Auth injected +// as a port. Retire MAIN_TOKENS.McpProxyService once consumers inject MCP_PROXY_SERVICE. +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(), + }; +}); +// PORT NOTE: ArchiveService moved to @posthog/workspace-server/services/archive. +// Hosted here (single SQLite connection); session-cancel + file-watcher are +// narrow ports delegating to the apps/code AgentService + FileWatcherBridge; +// worktree location via WORKSPACE_SETTINGS_SERVICE. Retire MAIN_TOKENS.ArchiveService +// once consumers inject ARCHIVE_SERVICE. +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); + }, +})); +// PORT NOTE: SuspensionService moved to @posthog/workspace-server/services/suspension. +// Hosted here (single SQLite conn); session-cancel + file-watcher are narrow ports +// delegating to apps/code AgentService + FileWatcherBridge; settings via +// WORKSPACE_SETTINGS_SERVICE. Retire MAIN_TOKENS.SuspensionService once consumers +// inject SUSPENSION_SERVICE. Last remaining consumer: WorkspaceService (@inject) — +// one-line retirement once workspace ports it. +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); +// PORT NOTE: CloudTaskService (SSE streaming client for cloud task runs) moved to +// @posthog/core/cloud-task. Auth injected as a port to keep core host-neutral. +// Retire MAIN_TOKENS.CloudTaskService once consumers inject CLOUD_TASK_SERVICE. +// Last remaining consumer: HandoffService (@inject) — deferred with @posthog/agent. +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); +// PORT NOTE: bridge to @posthog/core/context-menu. Menu-content orchestration +// moved to core (host-agnostic; consumes platform CONTEXT_MENU_SERVICE/DIALOG_SERVICE +// interfaces, not Electron). contextMenuCoreModule owns CONTEXT_MENU_CONTROLLER; +// MAIN_TOKENS.ContextMenuService aliases it for the context-menu router. The +// external-apps dependency is inverted: CONTEXT_MENU_EXTERNAL_APPS_SERVICE resolves to +// the main ExternalAppsService until external-apps migrates to a package 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); +// PORT NOTE: EnrichmentService lives in @posthog/workspace-server (it drives the +// @posthog/enricher native AST parsers + fs/git reads + PostHog HTTP API — all host +// I/O). Auth + fs/git reads injected as ports (ENRICHMENT_AUTH -> AuthService, +// ENRICHMENT_FILE_READER -> node fs + @posthog/git). Retire +// MAIN_TOKENS.EnrichmentService once consumers inject ENRICHMENT_SERVICE. +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); +// PORT NOTE: ExternalAppsService moved to @posthog/workspace-server/services/external-apps +// (host I/O: app detection via fs + launching via child_process). Injects platform +// CLIPBOARD/FILE_ICON + an EXTERNAL_APPS_STORE port backed by the electron-store here. +// Retire MAIN_TOKENS.ExternalAppsService once consumers inject EXTERNAL_APPS_SERVICE. +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); +// PORT NOTE: LlmGatewayService moved to @posthog/core/llm-gateway. Core HTTP client +// over the PostHog LLM gateway; auth + gateway-endpoint URLs injected as ports to keep +// core @posthog/agent-free. Retire MAIN_TOKENS.LlmGatewayService once consumers inject +// LLM_GATEWAY_SERVICE. Last remaining consumer: GitService (@inject) — git agent plans +// a narrow GIT_LLM port, so leave this inject to them. +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); +// PORT NOTE: McpAppsService moved to @posthog/core/mcp-apps. Core orchestration +// (MCP HTTP connections, UI resource cache, tool discovery) over @modelcontextprotocol/sdk; +// only URL_LAUNCHER_SERVICE (platform) + a logger port injected. Retire +// MAIN_TOKENS.McpAppsService once consumers inject MCP_APPS_SERVICE. Last remaining +// consumer: AgentService (@inject) — deferred with @posthog/agent. +container.load(mcpAppsModule); +container.bind(MAIN_TOKENS.McpAppsService).toService(MCP_APPS_SERVICE); +// PORT NOTE: FoldersService moved to @posthog/workspace-server/services/folders. +// Hosted in this container (not the ws-server tRPC) so it shares the single +// SQLite connection; worktree location comes from WORKSPACE_SETTINGS_SERVICE and +// the host provides the logger port. Retire MAIN_TOKENS.FoldersService once +// consumers inject FOLDERS_SERVICE. +container.load(foldersModule); +// PORT NOTE: integration services (github/linear/slack) own host-agnostic OAuth +// authorize-flow + deep-link callback orchestration in @posthog/core/integrations. +// integrationsModule binds the three package services; apps/code binds only the +// host logger ports the github/slack services consume. +container.load(integrationsModule); +// PORT NOTE: commit-message generation orchestration moved to @posthog/core/git-pr +// (GitPrService, main-hosted). It reads diffs via GIT_DIFF_SOURCE bound to the git +// service (free @posthog/git fns + GitService.getChangedFilesHead, resolved lazily +// to avoid a construction cycle) and prompts via LLM_GATEWAY_SERVICE. +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(GitPrHostService).toSelf().inSingletonScope(); +container.bind(GIT_SERVICE).toService(GitPrHostService); +container + .bind(GIT_AGENT_SERVICE) + .toDynamicValue((ctx) => ctx.get(AGENT_SERVICE)); +container + .bind(GIT_PR_STATUS_PROVIDER) + .toDynamicValue((ctx): IGitPrStatus => { + const git = ctx.get(GitPrHostService); + return { + getTaskPrStatus: (taskId, cloudPrUrl) => + git.getTaskPrStatus(taskId, cloudPrUrl), + }; + }); +// Handoff: orchestration in @posthog/core/handoff (HANDOFF_SERVICE), host I/O +// in @posthog/workspace-server (HandoffHostService: agent runtime, workspace/ +// repository repos, divergence dialog). The desktop only supplies a SagaLogger +// and the two child-process gateways (git via the workspace client, logs via +// the local-logs tRPC client). No handoff business logic lives in apps/code. +container.load(handoffModule); +container.bind(HANDOFF_HOST).to(HandoffHostService).inSingletonScope(); +container + .bind(HANDOFF_GIT_GATEWAY) + .toDynamicValue( + (ctx) => + new TrpcHandoffGitGateway( + ctx.get(MAIN_TOKENS.WorkspaceClient), + ), + ); +container.bind(HANDOFF_LOG_GATEWAY).toService(MAIN_TOKENS.LocalLogsService); +// PORT NOTE: the MCP-OAuth callback server AND its orchestrating service now +// live in @posthog/workspace-server/services/mcp-callback (MCP_CALLBACK_SERVER + +// MCP_CALLBACK_SERVICE; consumes platform DEEP_LINK/URL_LAUNCHER/APP_META + +// injected SagaLogger). mcpCallbackModule binds both; the mcp-callback router +// injects MCP_CALLBACK_SERVICE directly. +container.load(mcpCallbackModule); +container.bind(NOTIFICATION_SERVICE).to(NotificationService); +// PORT NOTE: OAuthService (flow orchestration) moved to @posthog/core/oauth; the dev +// HTTP callback server is in @posthog/workspace-server (OAUTH_CALLBACK_SERVER). Core +// OAuthService injects it via the OAUTH_HOST port (callback waitForCode + isDev) + logger. +// Consumers (index bootstrap, oauth router, auth port-adapters) inject OAUTH_SERVICE. +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(); +// PORT NOTE: bridge to @posthog/workspace-server process-tracking. The service +// moved to the package (in-process keep, like the DB layer): its live-PID +// registry must stay in the main process where shell/agent/workspace spawn +// processes, so callers register/unregister synchronously. processTrackingModule +// owns PROCESS_TRACKING_SERVICE; MAIN_TOKENS.ProcessTrackingService aliases it so +// the 6 consumers are unchanged. Retire the alias once they inject the package +// identifier directly (and re-bind to the ws-server child when shell/agent move). +container.load(processTrackingModule); +container.load(workspaceMetadataModule); +container + .bind(MAIN_TOKENS.ProcessTrackingService) + .toService(PROCESS_TRACKING_SERVICE); +// PORT NOTE: bridge to @posthog/workspace-server posthog-plugin. The +// skills/plugin file-install capability (node:fs host ops) moved to ws-server +// (in-process keep), extends the @posthog/shared TypedEventEmitter, consumes +// platform STORAGE_PATHS/BUNDLED_RESOURCES/ANALYTICS/APP_META, and logs via an +// injected SagaLogger. Retire MAIN_TOKENS.PosthogPluginService once index/skills +// router/agent inject POSTHOG_PLUGIN_SERVICE directly. +container.load(posthogPluginModule); +container + .bind(MAIN_TOKENS.PosthogPluginService) + .toService(POSTHOG_PLUGIN_SERVICE); +// PORT NOTE: skill listing (fs host ops) moved to +// @posthog/workspace-server/services/skills (SkillsService.listSkills). +// Hosted in this container so it shares the bound POSTHOG_PLUGIN_SERVICE + +// FOLDERS_SERVICE; the skills router injects SKILLS_SERVICE directly. +container.load(skillsModule); +// PORT NOTE: additional-directories domain (default + per-task dirs) moved to +// @posthog/workspace-server/services/additional-directories. Hosted here so it +// shares the bound repositories; the router injects the service instead of +// reaching the repositories directly (removed router-bypasses-service). +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); +// PORT NOTE: ShellService (node-pty terminal sessions) moved to +// @posthog/workspace-server/services/shell — pty is host state owned by ws-server. +// Injects ProcessTracking + repos + WORKSPACE_SETTINGS (worktree paths) + a logger +// port. Retire MAIN_TOKENS.ShellService once consumers inject SHELL_SERVICE. +container.load(shellModule); +// PORT NOTE: UIService (menu->renderer UI command event relay) moved to +// @posthog/core/ui. Auth injected as a narrow port (test-only token invalidation). +// Retire MAIN_TOKENS.UIService once consumers inject UI_SERVICE. +container.load(uiModule); +container.bind(UI_AUTH).toDynamicValue((ctx) => ({ + invalidateAccessTokenForTest: () => + ctx + .get(MAIN_TOKENS.AuthService) + .invalidateAccessTokenForTest(), +})); +// PORT NOTE: bridge to @posthog/core/updates. Update check/download/install +// orchestration moved to core (extends the @posthog/shared TypedEventEmitter; +// consumes platform UPDATER/APP_LIFECYCLE/APP_META/MAIN_WINDOW interfaces). The +// update-quit handoff is inverted behind UPDATE_LIFECYCLE_SERVICE -> the desktop +// AppLifecycleService; UPDATES_LOGGER -> the scoped electron logger. Retire the +// MAIN_TOKENS.UpdatesService alias once menu.ts/index.ts/router inject +// UPDATES_SERVICE directly. +container.load(updatesCoreModule); +container + .bind(UPDATE_LIFECYCLE_SERVICE) + .toService(MAIN_TOKENS.AppLifecycleService); +container.bind(MAIN_TOKENS.UpdatesService).toService(UPDATES_SERVICE); +// PORT NOTE: UsageMonitorService moved to @posthog/core/usage. Core orchestration +// (coalesce/threshold/backstop) over a narrow USAGE_HOST port: usage fetch via +// LlmGatewayService, LlmActivity events + active-session check via AgentService, +// threshold persistence via the electron usage-monitor store, USAGE_LOGGER -> scoped +// logger. Retire MAIN_TOKENS.UsageMonitorService once consumers inject USAGE_MONITOR_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: () => usageMonitorStore.get("thresholdsSeen", {}), + setThresholdsSeen: (value: Record) => + usageMonitorStore.set("thresholdsSeen", 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); +// PORT NOTE: bridge to @posthog/workspace-server watcher-registry (in-process +// keep; @parcel/watcher subscription registry is host state). Retire +// MAIN_TOKENS.WatcherRegistryService once app-lifecycle injects +// WATCHER_REGISTRY_SERVICE directly. +container.load(watcherRegistryModule); +container + .bind(MAIN_TOKENS.WatcherRegistryService) + .toService(WATCHER_REGISTRY_SERVICE); +// PORT NOTE: WorkspaceService moved to @posthog/workspace-server/services/workspace. +// Hosted here (single SQLite conn + repos/suspension/process-tracking are already +// ws-server). The cross-layer deps it cannot import are narrow ports delegating to +// apps/code AgentService + FileWatcherBridge + FocusService and core ProvisioningService; +// settings via WORKSPACE_SETTINGS_SERVICE, analytics via ANALYTICS_SERVICE. +// MAIN_TOKENS.WorkspaceService aliases WORKSPACE_SERVICE for the workspace router + +// GitService consumer; retire once they inject WORKSPACE_SERVICE directly. +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(MAIN_TOKENS.FocusService); + 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(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..7b7e5a2aca 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -5,83 +5,72 @@ * 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"), + ConnectivityService: Symbol.for("posthog.host.main.connectivity.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"), + FocusService: Symbol.for("posthog.host.main.focus.service"), + FsService: Symbol.for("posthog.host.main.fs.service"), + GitService: Symbol.for("posthog.host.main.git.service"), + LocalLogsService: Symbol.for("posthog.host.main.local-logs.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..13f5ed0f46 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -3,36 +3,49 @@ import os from "node:os"; import { createWorkspaceClient } from "@posthog/workspace-client/client"; import { app, BrowserWindow, dialog } from "electron"; import log from "electron-log/main"; +import { ConnectivityService } from "./services/connectivity/service"; import { FileWatcherBridge } from "./services/file-watcher/bridge"; import { FocusService } from "./services/focus/service"; +import { FsService } from "./services/fs/service"; +import { LocalLogsService } from "./services/local-logs/service"; import "./utils/logger"; import "./services/index.js"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import type { DatabaseService } from "./db/service"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import type { DatabaseService } from "@posthog/workspace-server/db/service"; import { initializeDeepLinks, registerDeepLinkHandlers } from "./deep-links"; import { container } from "./di/container"; import { MAIN_TOKENS } from "./di/tokens"; 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 type { AuthService } from "@posthog/core/auth/auth"; +import type { ExternalAppsService } from "@posthog/workspace-server/services/external-apps/external-apps"; +import { FOCUS_SERVICE } from "@posthog/core/focus/identifiers"; +import { GIT_WORKSPACE_CLIENT } from "@posthog/core/git/identifiers"; +import { FS_SERVICE } from "@posthog/workspace-server/services/fs/identifiers"; +import type { GitHubIntegrationService } from "@posthog/core/integrations/github"; +import { + GITHUB_INTEGRATION_SERVICE, + SLACK_INTEGRATION_SERVICE, +} from "@posthog/core/integrations/identifiers"; +import type { InboxLinkService } from "@posthog/core/links/inbox-link"; +import type { NewTaskLinkService } from "@posthog/core/links/new-task-link"; +import type { NotificationService } from "@posthog/core/notification/notification"; +import { NOTIFICATION_SERVICE } from "@posthog/core/notification/identifiers"; +import type { OAuthService } from "@posthog/core/oauth/oauth"; +import { OAUTH_SERVICE } from "@posthog/core/oauth/identifiers"; import { captureException, - getPostHogClient, + flushAnalytics, 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"; +import type { PosthogPluginService } from "@posthog/workspace-server/services/posthog-plugin/posthog-plugin"; +import type { SlackIntegrationService } from "@posthog/core/integrations/slack"; +import type { SuspensionService } from "@posthog/workspace-server/services/suspension/suspension"; +import { SUSPENSION_SERVICE } from "@posthog/workspace-server/services/suspension/identifiers"; +import type { TaskLinkService } from "@posthog/core/links/task-link"; +import type { UpdatesService } from "@posthog/core/updates/updates"; +import type { WorkspaceService } from "@posthog/workspace-server/services/workspace/workspace"; import type { WorkspaceServerService } from "./services/workspace-server/service"; import { ensureClaudeConfigDir } from "./utils/env"; import { @@ -93,9 +106,7 @@ app.on("render-process-gone", (_event, webContents, details) => { new Error(`Renderer process gone: ${details.reason}`), props, ); - getPostHogClient() - ?.flush() - .catch(() => {}); + flushAnalytics().catch(() => {}); if (RECOVERABLE_RENDER_REASONS.has(details.reason)) { if (isCrashLoop()) { @@ -142,22 +153,20 @@ app.on("child-process-gone", (_event, details) => { new Error(`Child process gone (${details.type}): ${details.reason}`), props, ); - getPostHogClient() - ?.flush() - .catch(() => {}); + flushAnalytics().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,9 +178,8 @@ 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 @@ -240,13 +248,25 @@ 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(MAIN_TOKENS.FileWatcherService) .toConstantValue(new FileWatcherBridge(workspaceClient)); container .bind(MAIN_TOKENS.FocusService) .toConstantValue(new FocusService(workspaceClient)); - + container.bind(FOCUS_SERVICE).toService(MAIN_TOKENS.FocusService); + container + .bind(MAIN_TOKENS.LocalLogsService) + .toConstantValue(new LocalLogsService(workspaceClient)); + container + .bind(MAIN_TOKENS.ConnectivityService) + .toConstantValue(new ConnectivityService(workspaceClient)); + container + .bind(MAIN_TOKENS.FsService) + .toConstantValue(new FsService(workspaceClient)); + container.bind(FS_SERVICE).toService(MAIN_TOKENS.FsService); await initializeServices(); initializeDeepLinks(); }); diff --git a/apps/code/src/main/menu.ts b/apps/code/src/main/menu.ts index 63da89768e..040351d6f8 100644 --- a/apps/code/src/main/menu.ts +++ b/apps/code/src/main/menu.ts @@ -1,3 +1,4 @@ +import { UI_SERVICE } from "@posthog/core/ui/identifiers"; import { readdirSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -12,10 +13,11 @@ 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 type { AuthService } from "@posthog/core/auth/auth"; +import type { McpAppsService } from "@posthog/core/mcp-apps/mcp-apps"; +import { MCP_APPS_SERVICE } from "@posthog/core/mcp-apps/identifiers"; +import type { UIService } from "@posthog/core/ui/ui"; +import type { UpdatesService } from "@posthog/core/updates/updates"; 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-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/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/services/agent/discover-plugins.ts b/apps/code/src/main/services/agent/discover-plugins.ts deleted file mode 100644 index e30aca9e10..0000000000 --- a/apps/code/src/main/services/agent/discover-plugins.ts +++ /dev/null @@ -1,218 +0,0 @@ -import * as crypto from "node:crypto"; -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"); - -interface DiscoverPluginsOptions { - userDataDir: string; - repoPath?: string; -} - -interface InstalledPluginEntry { - scope: string; - installPath: string; - version: string; -} - -interface InstalledPluginsFile { - version: number; - plugins: Record; -} - -export async function discoverExternalPlugins( - options: DiscoverPluginsOptions, -): Promise { - const [globalSkills, marketplacePlugins, repoSkills] = await Promise.all([ - discoverUserSkills(options.userDataDir), - discoverMarketplacePlugins(), - options.repoPath - ? discoverRepoSkills(options.userDataDir, options.repoPath) - : Promise.resolve([]), - ]); - - return [...globalSkills, ...marketplacePlugins, ...repoSkills]; -} - -async function discoverUserSkills( - userDataDir: string, -): Promise { - return buildSyntheticPlugin( - path.join(os.homedir(), ".claude", "skills"), - path.join(userDataDir, "plugins", "user-skills"), - "user-skills", - "User Claude skills", - ); -} - -async function discoverMarketplacePlugins(): Promise { - const paths = await getMarketplaceInstallPaths(); - return paths.map((p) => ({ type: "local" as const, path: p })); -} - -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 []; - } -} - -async function discoverRepoSkills( - userDataDir: string, - repoPath: string, -): Promise { - const skillsDir = path.join(repoPath, ".claude", "skills"); - const hash = crypto - .createHash("md5") - .update(repoPath) - .digest("hex") - .slice(0, 8); - - return buildSyntheticPlugin( - skillsDir, - path.join(userDataDir, "plugins", `repo-skills-${hash}`), - `repo-skills-${hash}`, - `Repo skills for ${path.basename(repoPath)}`, - ); -} - -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); -} - -async function buildSyntheticPlugin( - sourceSkillsDir: string, - pluginDir: string, - name: string, - description: string, -): Promise { - try { - const skillDirs = await findSkillDirs(sourceSkillsDir); - if (skillDirs.length === 0) { - return []; - } - - const syntheticSkillsDir = path.join(pluginDir, "skills"); - await fs.promises.mkdir(syntheticSkillsDir, { recursive: true }); - - await fs.promises.writeFile( - path.join(pluginDir, "plugin.json"), - JSON.stringify({ name, description, version: "1.0.0" }), - ); - - try { - const existing = await fs.promises.readdir(syntheticSkillsDir); - await Promise.all( - existing.map((e) => - fs.promises.rm(path.join(syntheticSkillsDir, e), { - recursive: true, - force: true, - }), - ), - ); - } catch { - // ignore - } - - await Promise.all( - skillDirs.map(async (skillName) => { - const src = path.join(sourceSkillsDir, skillName); - const dest = path.join(syntheticSkillsDir, skillName); - try { - const realSrc = await fs.promises.realpath(src); - await fs.promises.symlink(realSrc, dest); - } catch (err) { - log.warn("Failed to symlink skill", { - skillName, - error: err instanceof Error ? err.message : String(err), - }); - } - }), - ); - - return [{ type: "local", path: pluginDir }]; - } catch (err) { - log.warn("Failed to discover skills", { - source: sourceSkillsDir, - error: err instanceof Error ? err.message : String(err), - }); - 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/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts deleted file mode 100644 index 410d77ea59..0000000000 --- a/apps/code/src/main/services/agent/schemas.ts +++ /dev/null @@ -1,303 +0,0 @@ -import type { - RequestPermissionRequest, - PermissionOption as SdkPermissionOption, -} from "@agentclientprotocol/sdk"; -import { effortLevelSchema } from "@shared/types"; -import { z } from "zod"; - -export { effortLevelSchema }; -export type { EffortLevel } from "@shared/types"; - -// Session credentials schema -export const credentialsSchema = z.object({ - apiHost: z.string(), - projectId: z.number(), -}); - -export type Credentials = z.infer; - -// Session config schema -export const sessionConfigSchema = z.object({ - taskId: z.string(), - taskRunId: z.string(), - repoPath: z.string(), - credentials: credentialsSchema, - logUrl: z.string().optional(), - /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ - sessionId: z.string().optional(), - adapter: z.enum(["claude", "codex"]).optional(), - /** Additional directories Claude can access beyond cwd (for worktree support) */ - additionalDirectories: z.array(z.string()).optional(), - /** Permission mode to use for the session (e.g. "default", "acceptEdits", "plan", "bypassPermissions") */ - permissionMode: z.string().optional(), -}); - -export type SessionConfig = z.infer; - -// Start session input/output - -export const startSessionInput = z.object({ - taskId: z.string(), - taskRunId: z.string(), - repoPath: z.string(), - apiHost: z.string(), - projectId: z.number(), - permissionMode: z.string().optional(), - autoProgress: z.boolean().optional(), - runMode: z.enum(["local", "cloud"]).optional(), - adapter: z.enum(["claude", "codex"]).optional(), - additionalDirectories: z.array(z.string()).optional(), - customInstructions: z.string().max(2000).optional(), - effort: effortLevelSchema.optional(), - model: z.string().optional(), - jsonSchema: z.record(z.string(), z.unknown()).nullish(), -}); - -export type StartSessionInput = z.infer; - -export const modelOptionSchema = z.object({ - modelId: z.string(), - name: z.string(), - description: z.string().nullish(), - provider: z.string().optional(), -}); - -export type ModelOption = z.infer; - -const sessionConfigSelectOptionSchema = z.looseObject({ - value: z.string(), - name: z.string(), - description: z.string().nullish(), - _meta: z.record(z.string(), z.unknown()).nullish(), -}); - -const sessionConfigSelectGroupSchema = z.looseObject({ - group: z.string(), - name: z.string(), - options: z.array(sessionConfigSelectOptionSchema), - _meta: z.record(z.string(), z.unknown()).nullish(), -}); - -const sessionConfigSelectSchema = z.looseObject({ - id: z.string(), - name: z.string(), - type: z.literal("select"), - currentValue: z.string(), - options: z - .array(sessionConfigSelectOptionSchema) - .or(z.array(sessionConfigSelectGroupSchema)), - category: z.string().nullish(), - description: z.string().nullish(), - _meta: z.record(z.string(), z.unknown()).nullish(), -}); - -const sessionConfigBooleanSchema = z.looseObject({ - id: z.string(), - name: z.string(), - type: z.literal("boolean"), - currentValue: z.boolean(), - category: z.string().nullish(), - description: z.string().nullish(), - _meta: z.record(z.string(), z.unknown()).nullish(), -}); - -export const sessionConfigOptionSchema = z.union([ - sessionConfigSelectSchema, - sessionConfigBooleanSchema, -]); - -export type SessionConfigOption = z.infer; - -export const sessionResponseSchema = z.object({ - sessionId: z.string(), - channel: z.string(), - configOptions: z.array(sessionConfigOptionSchema).optional(), -}); - -export type SessionResponse = z.infer; - -// Prompt input/output -export const contentBlockSchema = z.looseObject({ - type: z.string(), - text: z.string().optional(), - _meta: z.record(z.string(), z.unknown()).nullish(), -}); - -export const promptInput = z.object({ - sessionId: z.string(), - prompt: z.array(contentBlockSchema), -}); - -export type PromptInput = z.infer; - -export const promptOutput = z.object({ - stopReason: z.string(), - _meta: z - .object({ - interruptReason: z.string().optional(), - }) - .optional(), -}); - -export type PromptOutput = z.infer; - -// Cancel session input -export const cancelSessionInput = z.object({ - sessionId: z.string(), -}); - -// Interrupt reason schema -export const interruptReasonSchema = z.enum([ - "user_request", - "moving_to_worktree", -]); -export type InterruptReason = z.infer; - -// Cancel prompt input -export const cancelPromptInput = z.object({ - sessionId: z.string(), - reason: interruptReasonSchema.optional(), -}); - -// Reconnect session input -export const reconnectSessionInput = z.object({ - taskId: z.string(), - taskRunId: z.string(), - repoPath: z.string(), - apiHost: z.string(), - projectId: z.number(), - logUrl: z.string().optional(), - sessionId: z.string().optional(), - adapter: z.enum(["claude", "codex"]).optional(), - /** Additional directories Claude can access beyond cwd (for worktree support) */ - additionalDirectories: z.array(z.string()).optional(), - permissionMode: z.string().optional(), - customInstructions: z.string().max(2000).optional(), - effort: effortLevelSchema.optional(), - jsonSchema: z.record(z.string(), z.unknown()).nullish(), -}); - -export type ReconnectSessionInput = z.infer; - -// Set config option input (for Codex reasoning level, etc.) -export const setConfigOptionInput = z.object({ - sessionId: z.string(), - configId: z.string(), - value: z.string(), -}); - -// Subscribe to session events input -export const subscribeSessionInput = z.object({ - taskRunId: z.string(), -}); - -// Record activity input — resets the idle timeout for the given session -export const recordActivityInput = z.object({ - taskRunId: z.string(), -}); - -// Agent events -export const AgentServiceEvent = { - SessionEvent: "session-event", - PermissionRequest: "permission-request", - SessionsIdle: "sessions-idle", - SessionIdleKilled: "session-idle-killed", - AgentFileActivity: "agent-file-activity", - LlmActivity: "llm-activity", -} as const; - -export interface AgentSessionEventPayload { - taskRunId: string; - payload: unknown; -} - -export type PermissionOption = SdkPermissionOption; -export type PermissionRequestPayload = Omit< - RequestPermissionRequest, - "sessionId" -> & { - taskRunId: string; -}; - -export interface SessionIdleKilledPayload { - taskRunId: string; - taskId: string; -} - -export interface AgentFileActivityPayload { - taskId: string; - branchName: string | null; -} - -export interface AgentServiceEvents { - [AgentServiceEvent.SessionEvent]: AgentSessionEventPayload; - [AgentServiceEvent.PermissionRequest]: PermissionRequestPayload; - [AgentServiceEvent.SessionsIdle]: undefined; - [AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload; - [AgentServiceEvent.AgentFileActivity]: AgentFileActivityPayload; - [AgentServiceEvent.LlmActivity]: undefined; -} - -// Permission response input for tRPC -export const respondToPermissionInput = z.object({ - taskRunId: z.string(), - toolCallId: z.string(), - optionId: z.string(), - // For "Other" option: custom text input from user (ACP extension via _meta) - customInput: z.string().optional(), - // For multi-question flows: all answers keyed by question text - answers: z.record(z.string(), z.string()).optional(), -}); - -export type RespondToPermissionInput = z.infer; - -// Permission cancellation input for tRPC -export const cancelPermissionInput = z.object({ - taskRunId: z.string(), - toolCallId: z.string(), -}); - -export type CancelPermissionInput = z.infer; - -export const listSessionsInput = z.object({ - taskId: z.string(), -}); - -export const detachedHeadContext = z.object({ - type: z.literal("detached_head"), - branchName: z.string(), - isDetached: z.boolean(), -}); - -export const sessionContextChangeSchema = detachedHeadContext; - -export type SessionContextChange = z.infer; - -export const notifySessionContextInput = z.object({ - sessionId: z.string(), - context: sessionContextChangeSchema, -}); - -export type NotifySessionContextInput = z.infer< - typeof notifySessionContextInput ->; - -export const sessionInfoSchema = z.object({ - taskRunId: z.string(), - repoPath: z.string(), -}); - -export const listSessionsOutput = z.array(sessionInfoSchema); - -export const getGatewayModelsInput = z.object({ - apiHost: z.string(), -}); - -export const getGatewayModelsOutput = z.array(modelOptionSchema); - -export const getPreviewConfigOptionsInput = z.object({ - apiHost: z.string(), - adapter: z.enum(["claude", "codex"]), -}); - -export const getPreviewConfigOptionsOutput = z.array(sessionConfigOptionSchema); diff --git a/apps/code/src/main/services/agent/service.test.ts b/apps/code/src/main/services/agent/service.test.ts deleted file mode 100644 index 5e277e6ad7..0000000000 --- a/apps/code/src/main/services/agent/service.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -// --- Hoisted mocks --- - -const mockApp = vi.hoisted(() => ({ - getAppPath: vi.fn(() => "/mock/appPath"), - isPackaged: false, - getVersion: vi.fn(() => "0.0.0-test"), - getPath: vi.fn(() => "/mock/home"), -})); - -const mockNewSession = vi.hoisted(() => - vi.fn().mockResolvedValue({ - sessionId: "test-session-id", - configOptions: [], - }), -); - -const mockClientSideConnection = vi.hoisted(() => - vi.fn().mockImplementation(function (this: Record) { - this.initialize = vi.fn().mockResolvedValue({}); - this.newSession = mockNewSession; - this.loadSession = vi.fn().mockResolvedValue({ configOptions: [] }); - this.resumeSession = vi.fn().mockResolvedValue({ configOptions: [] }); - }), -); - -const mockAgentRun = vi.hoisted(() => - vi.fn().mockImplementation(() => - Promise.resolve({ - clientStreams: { - readable: new ReadableStream(), - writable: new WritableStream(), - }, - }), - ), -); - -const mockAgentConstructor = vi.hoisted(() => - vi.fn().mockImplementation(function (this: Record) { - this.run = mockAgentRun; - this.cleanup = vi.fn().mockResolvedValue(undefined); - this.getPosthogAPI = vi.fn(); - this.flushAllLogs = vi.fn().mockResolvedValue(undefined); - }), -); - -// --- Module mocks --- - -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, -})); - -vi.mock("@agentclientprotocol/sdk", () => ({ - ClientSideConnection: mockClientSideConnection, - ndJsonStream: vi.fn(), - PROTOCOL_VERSION: 1, -})); - -vi.mock("@posthog/agent", () => ({ - isMcpToolReadOnly: vi.fn(() => false), -})); - -vi.mock("@posthog/agent/posthog-api", () => ({ - getLlmGatewayUrl: vi.fn(() => "https://gateway.example.com"), -})); - -vi.mock("@posthog/agent/gateway-models", () => ({ - DEFAULT_GATEWAY_MODEL: "claude-opus-4-8", - DEFAULT_CODEX_MODEL: "gpt-5.5", - fetchGatewayModels: vi.fn().mockResolvedValue([]), - formatGatewayModelName: vi.fn(), - getProviderName: vi.fn(), - isBlockedModelId: vi.fn().mockReturnValue(false), -})); - -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(); - return { - ...original, - default: { - ...original, - existsSync: vi.fn(() => false), - realpathSync: vi.fn((p: string) => p), - }, - existsSync: vi.fn(() => false), - mkdirSync: vi.fn(), - symlinkSync: vi.fn(), - realpathSync: vi.fn((p: string) => p), - }; -}); - -// --- Import after mocks --- -import { AgentService, buildAutoApproveOutcome } from "./service"; - -// --- Test helpers --- - -function createMockDependencies() { - return { - processTracking: { - register: vi.fn(), - unregister: vi.fn(), - killByTaskId: vi.fn(), - getByTaskId: vi.fn(() => []), - kill: vi.fn(), - }, - sleepService: { - acquire: vi.fn(), - release: vi.fn(), - }, - fsService: { - readRepoFile: vi.fn(), - writeRepoFile: vi.fn(), - }, - posthogPluginService: { - getPluginPath: vi.fn(() => "/mock/plugin"), - }, - agentAuthAdapter: { - ensureGatewayProxy: vi.fn().mockResolvedValue("http://127.0.0.1:9999"), - configureProcessEnv: vi.fn().mockResolvedValue(undefined), - createPosthogConfig: vi.fn((credentials) => ({ - apiUrl: credentials.apiHost, - getApiKey: vi.fn().mockResolvedValue("test-access-token"), - refreshApiKey: vi.fn().mockResolvedValue("fresh-access-token"), - projectId: credentials.projectId, - })), - buildMcpServers: vi.fn().mockResolvedValue({ - servers: [ - { - name: "posthog", - type: "http", - url: "https://mcp.posthog.com/mcp", - headers: [], - }, - ], - toolApprovals: {}, - toolInstallations: {}, - }), - }, - mcpAppsService: { - setServerConfigs: vi.fn(), - handleDiscovery: vi.fn().mockResolvedValue(undefined), - cleanup: vi.fn().mockResolvedValue(undefined), - notifyToolInput: vi.fn(), - notifyToolResult: vi.fn(), - notifyToolCancelled: vi.fn(), - }, - powerManager: { - onResume: vi.fn(() => () => {}), - preventSleep: vi.fn(() => () => {}), - }, - bundledResources: { - resolve: vi.fn((rel: string) => `/mock/appPath/${rel}`), - }, - appMeta: { - version: "0.0.0-test", - isProduction: false, - }, - storagePaths: { - appDataPath: "/mock/userData", - logsPath: "/mock/logs", - }, - defaultAdditionalDirectoryRepository: { - list: vi.fn(() => [] as string[]), - add: vi.fn(), - remove: vi.fn(), - }, - workspaceRepository: { - getAdditionalDirectories: vi.fn(() => [] as string[]), - addAdditionalDirectory: vi.fn(), - removeAdditionalDirectory: vi.fn(), - }, - }; -} - -const baseSessionParams = { - taskId: "task-1", - taskRunId: "run-1", - repoPath: "/mock/repo", - apiHost: "https://app.posthog.com", - projectId: 1, -}; - -describe("AgentService", () => { - let service: AgentService; - let deps: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - - deps = createMockDependencies(); - service = new AgentService( - deps.processTracking as never, - deps.sleepService as never, - deps.fsService as never, - deps.posthogPluginService as never, - deps.agentAuthAdapter as never, - deps.mcpAppsService as never, - deps.powerManager as never, - deps.bundledResources as never, - deps.appMeta as never, - deps.storagePaths as never, - deps.defaultAdditionalDirectoryRepository as never, - deps.workspaceRepository as never, - ); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("MCP servers", () => { - it("marks desktop sessions as local even though they have a taskRunId", async () => { - await service.startSession({ - ...baseSessionParams, - adapter: "codex", - }); - - expect(mockNewSession).toHaveBeenCalledTimes(1); - expect(mockNewSession.mock.calls[0][0]._meta).toMatchObject({ - taskRunId: "run-1", - environment: "local", - }); - }); - - it("passes MCP servers to newSession for codex adapter", async () => { - await service.startSession({ - ...baseSessionParams, - adapter: "codex", - }); - - expect(mockNewSession).toHaveBeenCalledTimes(1); - const mcpServers = mockNewSession.mock.calls[0][0].mcpServers; - expect(mcpServers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "posthog", - type: "http", - url: "https://mcp.posthog.com/mcp", - }), - ]), - ); - }); - - it("passes MCP servers to newSession for claude adapter", async () => { - await service.startSession({ - ...baseSessionParams, - adapter: "claude", - }); - - expect(mockNewSession).toHaveBeenCalledTimes(1); - const mcpServers = mockNewSession.mock.calls[0][0].mcpServers; - expect(mcpServers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "posthog", - type: "http", - url: "https://mcp.posthog.com/mcp", - }), - ]), - ); - }); - - it("passes identical MCP servers regardless of adapter", async () => { - await service.startSession({ - ...baseSessionParams, - taskRunId: "run-claude", - adapter: "claude", - }); - - await service.startSession({ - ...baseSessionParams, - taskRunId: "run-codex", - adapter: "codex", - }); - - const claudeMcp = mockNewSession.mock.calls[0][0].mcpServers; - const codexMcp = mockNewSession.mock.calls[1][0].mcpServers; - expect(codexMcp).toEqual(claudeMcp); - }); - }); - - describe("idle timeout", () => { - function injectSession( - svc: AgentService, - taskRunId: string, - overrides: Record = {}, - ) { - const sessions = (svc as unknown as { sessions: Map }) - .sessions; - sessions.set(taskRunId, { - taskRunId, - taskId: `task-for-${taskRunId}`, - repoPath: "/mock/repo", - agent: { cleanup: vi.fn().mockResolvedValue(undefined) }, - clientSideConnection: {}, - channel: `ch-${taskRunId}`, - createdAt: Date.now(), - lastActivityAt: Date.now(), - config: {}, - promptPending: false, - inFlightMcpToolCalls: new Map(), - mcpToolApprovals: {}, - toolInstallations: {}, - ...overrides, - }); - } - - function getIdleTimeouts(svc: AgentService) { - return ( - svc as unknown as { - idleTimeouts: Map< - string, - { handle: ReturnType; deadline: number } - >; - } - ).idleTimeouts; - } - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("recordActivity is a no-op for unknown sessions", () => { - service.recordActivity("unknown-run"); - expect(getIdleTimeouts(service).size).toBe(0); - }); - - it("recordActivity sets a timeout for a known session", () => { - injectSession(service, "run-1"); - service.recordActivity("run-1"); - expect(getIdleTimeouts(service).has("run-1")).toBe(true); - }); - - it("recordActivity resets the timeout on subsequent calls", () => { - injectSession(service, "run-1"); - service.recordActivity("run-1"); - const firstDeadline = getIdleTimeouts(service).get("run-1")?.deadline; - if (firstDeadline === undefined) - throw new Error("Expected firstDeadline to be defined"); - - vi.advanceTimersByTime(5 * 60 * 1000); - service.recordActivity("run-1"); - const secondDeadline = getIdleTimeouts(service).get("run-1") - ?.deadline as number; - if (secondDeadline === undefined) - throw new Error("Expected secondDeadline to be defined"); - - expect(secondDeadline).toBeGreaterThan(firstDeadline); - }); - - it("kills idle session after timeout expires", () => { - injectSession(service, "run-1"); - service.recordActivity("run-1"); - - vi.advanceTimersByTime(15 * 60 * 1000); - - expect(service.emit).toHaveBeenCalledWith( - "session-idle-killed", - expect.objectContaining({ taskRunId: "run-1" }), - ); - }); - - it("does not kill session if activity is recorded before timeout", () => { - injectSession(service, "run-1"); - service.recordActivity("run-1"); - - vi.advanceTimersByTime(14 * 60 * 1000); - service.recordActivity("run-1"); - vi.advanceTimersByTime(14 * 60 * 1000); - - expect(service.emit).not.toHaveBeenCalledWith( - "session-idle-killed", - expect.anything(), - ); - }); - - it("reschedules when promptPending is true at timeout", () => { - injectSession(service, "run-1", { promptPending: true }); - service.recordActivity("run-1"); - - vi.advanceTimersByTime(15 * 60 * 1000); - - expect(service.emit).not.toHaveBeenCalledWith( - "session-idle-killed", - expect.anything(), - ); - expect(getIdleTimeouts(service).has("run-1")).toBe(true); - }); - - it("reschedules when inFlightMcpToolCalls is non-empty at timeout", () => { - const toolCalls = new Map([["tool-1", "some-mcp-tool"]]); - injectSession(service, "run-1", { inFlightMcpToolCalls: toolCalls }); - service.recordActivity("run-1"); - - vi.advanceTimersByTime(15 * 60 * 1000); - - expect(service.emit).not.toHaveBeenCalledWith( - "session-idle-killed", - expect.anything(), - ); - expect(getIdleTimeouts(service).has("run-1")).toBe(true); - }); - - it("kills session when inFlightMcpToolCalls is empty", () => { - injectSession(service, "run-1", { - inFlightMcpToolCalls: new Map(), - }); - service.recordActivity("run-1"); - - vi.advanceTimersByTime(15 * 60 * 1000); - - expect(service.emit).toHaveBeenCalledWith( - "session-idle-killed", - expect.objectContaining({ taskRunId: "run-1" }), - ); - }); - - it("checkIdleDeadlines kills expired sessions on resume", () => { - injectSession(service, "run-1"); - service.recordActivity("run-1"); - - const resumeHandler = ( - deps.powerManager.onResume.mock.calls[0] as unknown as [() => void] - )[0]; - expect(resumeHandler).toBeDefined(); - - vi.advanceTimersByTime(20 * 60 * 1000); - resumeHandler(); - - expect(service.emit).toHaveBeenCalledWith( - "session-idle-killed", - expect.objectContaining({ taskRunId: "run-1" }), - ); - }); - - it("checkIdleDeadlines does not kill non-expired sessions", () => { - injectSession(service, "run-1"); - service.recordActivity("run-1"); - - const resumeHandler = ( - deps.powerManager.onResume.mock.calls[0] as unknown as [() => void] - )[0]; - - vi.advanceTimersByTime(5 * 60 * 1000); - resumeHandler(); - - expect(service.emit).not.toHaveBeenCalledWith( - "session-idle-killed", - expect.anything(), - ); - }); - }); -}); - -describe("buildAutoApproveOutcome", () => { - it("prefers an allow_once option", () => { - expect( - buildAutoApproveOutcome([ - { optionId: "reject", kind: "reject_once", name: "Reject" }, - { optionId: "allow", kind: "allow_once", name: "Allow" }, - ]), - ).toEqual({ outcome: "selected", optionId: "allow" }); - }); - - it("prefers an allow_always option", () => { - expect( - buildAutoApproveOutcome([ - { optionId: "reject", kind: "reject_once", name: "Reject" }, - { optionId: "allow_always", kind: "allow_always", name: "Always" }, - ]), - ).toEqual({ outcome: "selected", optionId: "allow_always" }); - }); - - it("falls back to the first option when no allow option exists", () => { - expect( - buildAutoApproveOutcome([ - { optionId: "first", kind: "reject_once", name: "First" }, - { optionId: "second", kind: "reject_always", name: "Second" }, - ]), - ).toEqual({ outcome: "selected", optionId: "first" }); - }); - - it("returns a cancelled outcome when options is empty", () => { - expect(buildAutoApproveOutcome([])).toEqual({ outcome: "cancelled" }); - }); -}); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts deleted file mode 100644 index d60b09b6cc..0000000000 --- a/apps/code/src/main/services/agent/service.ts +++ /dev/null @@ -1,1849 +0,0 @@ -import fs, { mkdirSync, symlinkSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; -import { - type Client, - ClientSideConnection, - type ContentBlock, - ndJsonStream, - PROTOCOL_VERSION, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionConfigOption, - type SessionNotification, -} from "@agentclientprotocol/sdk"; -import { - isMcpToolReadOnly, - isNotification, - POSTHOG_NOTIFICATIONS, -} from "@posthog/agent"; -import type { McpToolApprovals } from "@posthog/agent/adapters/claude/mcp/tool-metadata"; -import { hydrateSessionJsonl } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; -import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; -import { Agent } from "@posthog/agent/agent"; -import { - getAvailableCodexModes, - getAvailableModes, -} from "@posthog/agent/execution-mode"; -import { - DEFAULT_CODEX_MODEL, - DEFAULT_GATEWAY_MODEL, - fetchGatewayModels, - formatGatewayModelName, - getProviderName, - isAnthropicModel, - isOpenAIModel, -} from "@posthog/agent/gateway-models"; -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 { inject, injectable, preDestroy } from "inversify"; -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 { loadSessionEnvOverrides } from "../session-env/loader"; -import type { SleepService } from "../sleep/service"; -import type { AgentAuthAdapter, McpToolInstallations } from "./auth-adapter"; -import { discoverExternalPlugins } from "./discover-plugins"; -import { - AgentServiceEvent, - type AgentServiceEvents, - type Credentials, - type EffortLevel, - type InterruptReason, - type PromptOutput, - type ReconnectSessionInput, - type SessionResponse, - type StartSessionInput, -} from "./schemas"; - -export type { InterruptReason }; - -const log = logger.scope("agent-service"); - -const MOCK_NODE_DIR_PREFIX = "agent-node"; - -function getMockNodeDir(): string { - const suffix = isDevBuild() ? "dev" : "prod"; - return join(tmpdir(), `${MOCK_NODE_DIR_PREFIX}-${suffix}`); -} - -/** Mark all content blocks as hidden so the renderer doesn't show a duplicate user message on retry */ -type MessageCallback = (message: unknown) => void; - -/** Shape of the `_meta.claudeCode` extension field on tool call updates. */ -interface ClaudeCodeToolMeta { - claudeCode?: { toolName?: string }; -} - -class NdJsonTap { - private decoder = new TextDecoder(); - private buffer = ""; - - constructor(private onMessage: MessageCallback) {} - - process(chunk: Uint8Array): void { - this.buffer += this.decoder.decode(chunk, { stream: true }); - const lines = this.buffer.split("\n"); - this.buffer = lines.pop() ?? ""; - - for (const line of lines) { - if (!line.trim()) continue; - try { - this.onMessage(JSON.parse(line)); - } catch { - // Not valid JSON, skip - } - } - } -} - -function createTappedReadableStream( - underlying: ReadableStream, - onMessage: MessageCallback, -): ReadableStream { - const reader = underlying.getReader(); - const tap = new NdJsonTap(onMessage); - - return new ReadableStream({ - async pull(controller) { - try { - const { value, done } = await reader.read(); - if (done) { - controller.close(); - return; - } - tap.process(value); - controller.enqueue(value); - } catch (err) { - // Stream may be closed if subprocess crashed - close gracefully - log.warn("Stream read failed (subprocess may have crashed)", { - error: err, - }); - controller.close(); - } - }, - cancel() { - // Release the reader when stream is cancelled - reader.releaseLock(); - }, - }); -} - -function createTappedWritableStream( - underlying: WritableStream, - onMessage: MessageCallback, -): WritableStream { - const tap = new NdJsonTap(onMessage); - - return new WritableStream({ - async write(chunk) { - tap.process(chunk); - try { - const writer = underlying.getWriter(); - await writer.write(chunk); - writer.releaseLock(); - } catch (err) { - // Stream may be closed if subprocess crashed - log but don't throw - log.warn("Stream write failed (subprocess may have crashed)", { - error: err, - }); - } - }, - async close() { - try { - const writer = underlying.getWriter(); - await writer.close(); - writer.releaseLock(); - } catch { - // Stream may already be closed - } - }, - async abort(reason) { - try { - const writer = underlying.getWriter(); - await writer.abort(reason); - writer.releaseLock(); - } catch { - // Stream may already be closed - } - }, - }); -} - -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 buildClaudeCodeOptions(args: { - additionalDirectories?: string[]; - effort?: EffortLevel; - plugins: { type: "local"; path: string }[]; -}) { - return { - ...(args.additionalDirectories?.length && { - additionalDirectories: args.additionalDirectories, - }), - ...(args.effort && { effort: args.effort }), - plugins: args.plugins, - }; -} - -interface SessionConfig { - taskId: string; - taskRunId: string; - repoPath: string; - credentials: Credentials; - logUrl?: string; - /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ - sessionId?: string; - adapter?: "claude" | "codex"; - /** Permission mode to use for the session */ - permissionMode?: string; - /** Custom instructions injected into the system prompt */ - customInstructions?: string; - /** Effort level for Claude sessions */ - effort?: EffortLevel; - /** Model to use for the session (e.g. "claude-sonnet-4-6") */ - model?: string; - /** JSON Schema for structured task output — when set, the agent gets a create_output tool */ - jsonSchema?: Record | null; -} - -interface ManagedSession { - taskRunId: string; - taskId: string; - repoPath: string; - agent: Agent; - clientSideConnection: ClientSideConnection; - channel: string; - createdAt: number; - lastActivityAt: number; - config: SessionConfig; - interruptReason?: InterruptReason; - promptPending: boolean; - pendingContext?: string; - configOptions?: SessionConfigOption[]; - /** Tracks in-flight MCP tool calls (toolCallId → toolKey) for cancellation */ - inFlightMcpToolCalls: Map; - /** MCP tool approval states fetched at session start */ - mcpToolApprovals: McpToolApprovals; - /** Maps tool keys to their installation for backend approval updates */ - toolInstallations: McpToolInstallations; -} - -/** Get the agent session ID from a managed session, throwing if not set. */ -function getAgentSessionId(session: ManagedSession): string { - const { sessionId } = session.config; - if (!sessionId) { - throw new Error(`Session ${session.taskRunId} has no agent session ID`); - } - return sessionId; -} - -export function buildAutoApproveOutcome( - options: RequestPermissionRequest["options"], -): RequestPermissionResponse["outcome"] { - const allowOption = options.find( - (o) => o.kind === "allow_once" || o.kind === "allow_always", - ); - const optionId = allowOption?.optionId ?? options[0]?.optionId; - if (!optionId) { - return { outcome: "cancelled" }; - } - return { outcome: "selected", optionId }; -} - -interface PendingPermission { - resolve: (response: RequestPermissionResponse) => void; - reject: (error: Error) => void; - taskRunId: string; - toolCallId: string; -} - -@injectable() -export class AgentService extends TypedEventEmitter { - private static readonly IDLE_TIMEOUT_MS = 15 * 60 * 1000; - - private sessions = new Map(); - private pendingPermissions = new Map(); - private mockNodeReady = false; - private idleTimeouts = new Map< - string, - { handle: ReturnType; deadline: number } - >(); - private processTracking: ProcessTrackingService; - private sleepService: SleepService; - private fsService: FsService; - private posthogPluginService: PosthogPluginService; - private agentAuthAdapter: AgentAuthAdapter; - private mcpAppsService: McpAppsService; - - constructor( - @inject(MAIN_TOKENS.ProcessTrackingService) - processTracking: ProcessTrackingService, - @inject(MAIN_TOKENS.SleepService) - sleepService: SleepService, - @inject(MAIN_TOKENS.FsService) - fsService: FsService, - @inject(MAIN_TOKENS.PosthogPluginService) - posthogPluginService: PosthogPluginService, - @inject(MAIN_TOKENS.AgentAuthAdapter) - agentAuthAdapter: AgentAuthAdapter, - @inject(MAIN_TOKENS.McpAppsService) - mcpAppsService: McpAppsService, - @inject(MAIN_TOKENS.PowerManager) - powerManager: IPowerManager, - @inject(MAIN_TOKENS.BundledResources) - private readonly bundledResources: IBundledResources, - @inject(MAIN_TOKENS.AppMeta) - private readonly appMeta: IAppMeta, - @inject(MAIN_TOKENS.StoragePaths) - private readonly storagePaths: IStoragePaths, - @inject(MAIN_TOKENS.DefaultAdditionalDirectoryRepository) - private readonly defaultAdditionalDirectoryRepository: IDefaultAdditionalDirectoryRepository, - @inject(MAIN_TOKENS.WorkspaceRepository) - private readonly workspaceRepository: IWorkspaceRepository, - ) { - super(); - this.processTracking = processTracking; - this.sleepService = sleepService; - this.fsService = fsService; - this.posthogPluginService = posthogPluginService; - this.agentAuthAdapter = agentAuthAdapter; - this.mcpAppsService = mcpAppsService; - - powerManager.onResume(() => this.checkIdleDeadlines()); - } - - private getClaudeCliPath(): string { - // Keep in sync with the destDir in apps/code/vite.main.config.mts - // (copyClaudeExecutable plugin). - const binary = process.platform === "win32" ? "claude.exe" : "claude"; - return this.bundledResources.resolve(`.vite/build/claude-cli/${binary}`); - } - - private getCodexBinaryPath(): string { - return this.bundledResources.resolve(".vite/build/codex-acp/codex-acp"); - } - - /** - * Respond to a pending permission request from the UI. - * This resolves the promise that the agent is waiting on. - */ - public respondToPermission( - taskRunId: string, - toolCallId: string, - optionId: string, - customInput?: string, - answers?: Record, - ): void { - const key = `${taskRunId}:${toolCallId}`; - const pending = this.pendingPermissions.get(key); - - if (!pending) { - log.warn("No pending permission found", { taskRunId, toolCallId }); - return; - } - - log.info("Permission response received", { - taskRunId, - toolCallId, - optionId, - hasCustomInput: !!customInput, - hasAnswers: !!answers, - }); - - const meta: Record = {}; - if (customInput) meta.customInput = customInput; - if (answers) meta.answers = answers; - - pending.resolve({ - outcome: { - outcome: "selected", - optionId, - }, - ...(Object.keys(meta).length > 0 && { _meta: meta }), - }); - - this.pendingPermissions.delete(key); - this.recordActivity(taskRunId); - } - - /** - * Cancel a pending permission request. - * This resolves the promise with a "cancelled" outcome per ACP spec. - */ - public cancelPermission(taskRunId: string, toolCallId: string): void { - const key = `${taskRunId}:${toolCallId}`; - const pending = this.pendingPermissions.get(key); - - if (!pending) { - log.warn("No pending permission found to cancel", { - taskRunId, - toolCallId, - }); - return; - } - - log.info("Permission cancelled", { taskRunId, toolCallId }); - - pending.resolve({ - outcome: { - outcome: "cancelled", - }, - }); - - this.pendingPermissions.delete(key); - this.recordActivity(taskRunId); - } - - /** - * Check if any sessions are currently active (i.e. have a prompt pending). - */ - public hasActiveSessions(): boolean { - for (const session of this.sessions.values()) { - if (session.promptPending || session.inFlightMcpToolCalls.size > 0) { - return true; - } - } - return false; - } - - public recordActivity(taskRunId: string): void { - if (!this.sessions.has(taskRunId)) return; - - const existing = this.idleTimeouts.get(taskRunId); - if (existing) clearTimeout(existing.handle); - - const deadline = Date.now() + AgentService.IDLE_TIMEOUT_MS; - const handle = setTimeout(() => { - this.killIdleSession(taskRunId); - }, AgentService.IDLE_TIMEOUT_MS); - - this.idleTimeouts.set(taskRunId, { handle, deadline }); - } - - private killIdleSession(taskRunId: string): void { - const session = this.sessions.get(taskRunId); - if (!session) return; - if (session.promptPending || session.inFlightMcpToolCalls.size > 0) { - this.recordActivity(taskRunId); - return; - } - 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 }); - }); - } - - private checkIdleDeadlines(): void { - const now = Date.now(); - const expired = [...this.idleTimeouts.entries()].filter( - ([, { deadline }]) => now >= deadline, - ); - for (const [taskRunId, { handle }] of expired) { - clearTimeout(handle); - this.killIdleSession(taskRunId); - } - } - - private buildSystemPrompt( - credentials: Credentials, - taskId: string, - customInstructions?: string, - additionalDirectories?: string[], - ): { - append: string; - } { - let prompt = `PostHog context: use project ${credentials.projectId} on ${credentials.apiHost}. When using PostHog MCP tools, operate only on this project.`; - - prompt += ` - -## Attribution -Do NOT use Claude Code's default attribution (no "Co-Authored-By" trailers, no "Generated with [Claude Code]" lines). - -Instead, add the following trailers to EVERY commit message (after a blank line at the end): - Generated-By: PostHog Code - Task-Id: ${taskId} - -Example: -\`\`\` -git commit -m "$(cat <<'EOF' -fix: resolve login redirect loop - -Generated-By: PostHog Code -Task-Id: ${taskId} -EOF -)" -\`\`\` - -When creating new branches, prefix them with \`posthog-code/\` (e.g. \`posthog-code/fix-login-redirect\`). - -When creating pull requests, add the following footer at the end of the PR description: -\`\`\` ---- -*Created with [PostHog Code](https://posthog.com/code?ref=pr)* -\`\`\``; - - if (customInstructions) { - prompt += `\n\nUser custom instructions:\n${customInstructions}`; - } - - if (additionalDirectories?.length) { - const escapeXml = (s: string) => - s.replace(/&/g, "&").replace(//g, ">"); - const dirs = additionalDirectories - .map((d) => ` ${escapeXml(d)}`) - .join("\n"); - prompt += `\n\nThe user has granted you access to additional directories outside the working directory. You may read and edit files in these paths just like the working directory:\n\n${dirs}\n`; - } - - return { append: prompt }; - } - - private resolveAdditionalDirectories(taskId: string): string[] { - const defaults = this.defaultAdditionalDirectoryRepository.list(); - const taskScoped = - this.workspaceRepository.getAdditionalDirectories(taskId); - const seen = new Set(); - const merged: string[] = []; - for (const path of [...defaults, ...taskScoped]) { - if (!path || seen.has(path)) continue; - seen.add(path); - merged.push(path); - } - return merged; - } - - async startSession(params: StartSessionInput): Promise { - this.validateSessionParams(params); - const config = this.toSessionConfig(params); - const session = await this.getOrCreateSession(config, false); - if (!session) { - throw new Error("Failed to create session"); - } - return this.toSessionResponse(session); - } - - async reconnectSession( - params: ReconnectSessionInput, - ): Promise { - try { - this.validateSessionParams(params); - } catch (err) { - log.error("Invalid reconnect params", err); - return null; - } - - const config = this.toSessionConfig(params); - const session = await this.getOrCreateSession(config, true); - return session ? this.toSessionResponse(session) : null; - } - - private async getOrCreateSession( - config: SessionConfig, - isReconnect: boolean, - isRetry = false, - ): Promise { - const { - taskId, - taskRunId, - repoPath: rawRepoPath, - credentials, - logUrl, - adapter, - permissionMode, - customInstructions, - effort, - model, - jsonSchema, - } = config; - - // Preview config doesn't need a real repo — use a temp directory - const repoPath = taskId === "__preview__" ? tmpdir() : rawRepoPath; - - const additionalDirectories = - taskId === "__preview__" ? [] : this.resolveAdditionalDirectories(taskId); - - if (!isRetry) { - const existing = this.sessions.get(taskRunId); - if (existing) { - return existing; - } - - for (const proc of this.processTracking.getByTaskId(taskId)) { - if ( - (proc.category === "agent" || proc.category === "child") && - proc.metadata?.taskRunId === taskRunId - ) { - this.processTracking.kill(proc.pid); - } - } - - // Clean up any prior session for this taskRunId before creating a new one - await this.cleanupSession(taskRunId); - } - - const channel = `agent-event:${taskRunId}`; - const mockNodeDir = this.setupMockNodeEnvironment(); - const proxyUrl = await this.agentAuthAdapter.ensureGatewayProxy( - credentials.apiHost, - ); - await this.agentAuthAdapter.configureProcessEnv({ - credentials, - mockNodeDir, - proxyUrl, - claudeCliPath: this.getClaudeCliPath(), - }); - - const isPreview = taskId === "__preview__"; - - const agent = new Agent({ - posthog: { - ...this.agentAuthAdapter.createPosthogConfig(credentials), - userAgent: `posthog/desktop.hog.dev; version: ${this.appMeta.version}`, - }, - skipLogPersistence: isPreview, - localCachePath: join(homedir(), ".posthog-code"), - debug: isDevBuild(), - onLog: onAgentLog, - }); - - try { - const systemPrompt = this.buildSystemPrompt( - credentials, - taskId, - customInstructions, - additionalDirectories, - ); - - const acpConnection = await agent.run(taskId, taskRunId, { - adapter, - gatewayUrl: proxyUrl, - codexBinaryPath: - adapter === "codex" ? this.getCodexBinaryPath() : undefined, - model, - instructions: adapter === "codex" ? systemPrompt.append : undefined, - additionalDirectories: - adapter === "codex" ? additionalDirectories : undefined, - onStructuredOutput: jsonSchema - ? async (output) => { - const posthogAPI = agent.getPosthogAPI(); - if (posthogAPI) { - await posthogAPI.updateTaskRun(taskId, taskRunId, { output }); - } - } - : undefined, - processCallbacks: { - onProcessSpawned: (info) => { - this.processTracking.register( - info.pid, - "agent", - `agent:${taskRunId}`, - { - taskRunId, - taskId, - command: info.command, - }, - taskId, - ); - }, - onProcessExited: (pid) => { - this.processTracking.unregister(pid, "agent-exited"); - }, - onMcpServersReady: (serverNames) => { - this.mcpAppsService.handleDiscovery(serverNames).catch((err) => { - log.warn("MCP Apps discovery failed", { - error: err instanceof Error ? err.message : String(err), - }); - }); - }, - }, - }); - const { clientStreams } = acpConnection; - - const connection = this.createClientConnection( - taskRunId, - channel, - clientStreams, - ); - - await connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: { - fs: { - readTextFile: true, - writeTextFile: true, - }, - terminal: true, - }, - }); - - const { - servers: mcpServers, - toolApprovals, - toolInstallations, - } = await this.agentAuthAdapter.buildMcpServers(credentials); - - // Store server configs for lazy MCP connections — actual connections - // are created on-demand when UI resources are first requested. - this.mcpAppsService.setServerConfigs( - mcpServers.map((s) => ({ - name: s.name, - url: s.url, - headers: Object.fromEntries(s.headers.map((h) => [h.name, h.value])), - })), - ); - - let externalPlugins: Awaited> = - []; - try { - externalPlugins = await discoverExternalPlugins({ - userDataDir: this.storagePaths.appDataPath, - repoPath, - }); - } catch (err) { - log.warn("Failed to discover external plugins", { - error: err instanceof Error ? err.message : String(err), - }); - } - const plugins = [ - { - type: "local" as const, - path: this.posthogPluginService.getPluginPath(), - }, - ...externalPlugins, - ]; - const claudeCodeOptions = buildClaudeCodeOptions({ - additionalDirectories, - effort, - plugins, - }); - - let configOptions: SessionConfigOption[] | undefined; - let agentSessionId: string; - - // Claude-specific: hydrate session JSONL from PostHog before resuming. - // If hydration finds no conversation to restore, skip the resume and - // fall through to creating a new session. This avoids a doomed - // resumeSession that would fail with "Resource not found" - if (isReconnect && config.sessionId) { - const existingSessionId = config.sessionId; - - if (adapter !== "codex") { - const posthogAPI = agent.getPosthogAPI(); - if (posthogAPI) { - const hasSession = await hydrateSessionJsonl({ - sessionId: existingSessionId, - cwd: repoPath, - taskId, - runId: taskRunId, - permissionMode: config.permissionMode, - posthogAPI, - log, - }); - if (!hasSession) { - log.info( - "No session JSONL to resume, creating new session instead", - { taskId, taskRunId }, - ); - config.sessionId = undefined; - } - } - } - } - - if (isReconnect && config.sessionId) { - const existingSessionId = config.sessionId; - - // Both adapters implement resumeSession: - // - Claude: delegates to SDK's resumeSession with JSONL hydration - // - Codex: delegates to codex-acp's loadSession internally - const resumeResponse = await connection.resumeSession({ - sessionId: existingSessionId, - cwd: repoPath, - mcpServers, - _meta: { - ...(logUrl && { - persistence: { taskId, runId: taskRunId, logUrl }, - }), - taskRunId, - environment: "local", - sessionId: existingSessionId, - systemPrompt, - mcpToolApprovals: toolApprovals, - ...(permissionMode && { permissionMode }), - ...(model != null && { model }), - ...(jsonSchema && { jsonSchema }), - claudeCode: { - options: claudeCodeOptions, - }, - }, - }); - configOptions = resumeResponse?.configOptions ?? undefined; - agentSessionId = existingSessionId; - } else { - if (isReconnect) { - log.info("No sessionId for reconnect, creating new session", { - taskId, - taskRunId, - }); - } - const newSessionResponse = await connection.newSession({ - cwd: repoPath, - mcpServers, - _meta: { - taskRunId, - environment: "local", - systemPrompt, - mcpToolApprovals: toolApprovals, - ...(permissionMode && { permissionMode }), - ...(model != null && { model }), - ...(jsonSchema && { jsonSchema }), - claudeCode: { - options: claudeCodeOptions, - }, - }, - }); - configOptions = newSessionResponse.configOptions ?? undefined; - agentSessionId = newSessionResponse.sessionId; - } - - config.sessionId = agentSessionId; - - const session: ManagedSession = { - taskRunId, - taskId, - repoPath, - agent, - clientSideConnection: connection, - channel, - createdAt: Date.now(), - lastActivityAt: Date.now(), - config, - promptPending: false, - configOptions, - inFlightMcpToolCalls: new Map(), - mcpToolApprovals: toolApprovals, - toolInstallations, - }; - - this.sessions.set(taskRunId, session); - this.recordActivity(taskRunId); - - if (isRetry) { - 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 }); - } - - if (!isRetry && isAuthError(err)) { - log.warn( - `Auth error during ${isReconnect ? "reconnect" : "create"}, retrying`, - { taskRunId }, - ); - return this.getOrCreateSession(config, isReconnect, true); - } - log.error( - `Failed to ${isReconnect ? "reconnect" : "create"} session${ - isRetry ? " after retry" : "" - }`, - err, - ); - // Non-auth reconnect failure on first attempt: fall back to a fresh session. - // 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", { - taskRunId, - }); - config.sessionId = undefined; - return this.getOrCreateSession(config, false, false); - } - if (isReconnect) return null; - throw err; - } - } - - async prompt( - sessionId: string, - prompt: ContentBlock[], - ): Promise { - const session = this.sessions.get(sessionId); - if (!session) { - throw new Error(`Session not found: ${sessionId}`); - } - - // Prepend pending context if present - let finalPrompt = prompt; - if (session.pendingContext) { - log.info("Prepending context to prompt", { sessionId }); - finalPrompt = [ - { - type: "text", - text: `_${session.pendingContext}_\n\n`, - _meta: { ui: { hidden: true } }, - }, - ...prompt, - ]; - session.pendingContext = undefined; - } - - session.lastActivityAt = Date.now(); - session.promptPending = true; - this.recordActivity(sessionId); - this.sleepService.acquire(sessionId); - - try { - const result = await session.clientSideConnection.prompt({ - sessionId: getAgentSessionId(session), - prompt: finalPrompt, - }); - return { - stopReason: result.stopReason, - _meta: result._meta as PromptOutput["_meta"], - }; - } finally { - session.promptPending = false; - session.lastActivityAt = Date.now(); - this.recordActivity(sessionId); - this.sleepService.release(sessionId); - - if (!this.hasActiveSessions()) { - this.emit(AgentServiceEvent.SessionsIdle, undefined); - } - } - } - - async cancelSession(sessionId: string): Promise { - const session = this.sessions.get(sessionId); - if (!session) return false; - - try { - await this.cleanupSession(sessionId); - return true; - } catch (_err) { - return false; - } - } - - async cancelSessionsByTaskId(taskId: string): Promise { - for (const [taskRunId, session] of this.sessions) { - if (session.taskId === taskId) { - await this.cleanupSession(taskRunId); - } - } - } - - async cancelPrompt( - sessionId: string, - reason?: InterruptReason, - ): Promise { - const session = this.sessions.get(sessionId); - if (!session) return false; - - try { - this.cancelInFlightMcpToolCalls(session); - await session.clientSideConnection.cancel({ - sessionId: getAgentSessionId(session), - _meta: reason ? { interruptReason: reason } : undefined, - }); - if (reason) { - session.interruptReason = reason; - log.info("Session interrupted", { sessionId, reason }); - } - return true; - } catch (err) { - log.error("Failed to cancel prompt", { sessionId, err }); - return false; - } - } - - getSession(taskRunId: string): ManagedSession | undefined { - return this.sessions.get(taskRunId); - } - - async setSessionConfigOption( - sessionId: string, - configId: string, - value: string, - ): Promise { - const session = this.sessions.get(sessionId); - if (!session) { - throw new Error(`Session not found: ${sessionId}`); - } - - try { - const result = await session.clientSideConnection.setSessionConfigOption({ - sessionId: getAgentSessionId(session), - configId, - value, - }); - session.configOptions = result.configOptions ?? session.configOptions; - - const updatedModeOption = session.configOptions?.find( - (opt) => opt.category === "mode", - ); - if ( - updatedModeOption && - typeof updatedModeOption.currentValue === "string" - ) { - session.config.permissionMode = updatedModeOption.currentValue; - } - } catch (err) { - log.error("Failed to set session config option", { - sessionId, - configId, - value, - err, - }); - throw err; - } - } - - listSessions(taskId?: string): ManagedSession[] { - const all = Array.from(this.sessions.values()); - return taskId ? all.filter((s) => s.taskId === taskId) : all; - } - - /** - * Resolve env-var overrides set by the SessionStart-style hooks of the most - * recently active agent session for `taskId`. - * - * Used by git/gh operations triggered from the UI (Commit, Create PR) so - * they pick up the same hook env the agent itself sees — most importantly - * the SSH_AUTH_SOCK that Secretive's hook re-points at the Secretive agent - * for commit signing. Returns an empty object when there is no session for - * the task or when no hook output is available. - */ - public async getSessionEnvForTask( - taskId: string, - ): Promise> { - const candidates = this.listSessions(taskId) - .filter((s) => !!s.config.sessionId) - .sort((a, b) => b.lastActivityAt - a.lastActivityAt); - const session = candidates[0]; - if (!session?.config.sessionId) return {}; - return loadSessionEnvOverrides(session.config.sessionId); - } - - /** - * Get sessions that were interrupted for a specific reason. - * Optionally filter by repoPath to get only sessions for a specific repo. - */ - getInterruptedSessions( - reason: InterruptReason, - repoPath?: string, - ): ManagedSession[] { - return Array.from(this.sessions.values()).filter( - (s) => - s.interruptReason === reason && - (repoPath === undefined || s.repoPath === repoPath), - ); - } - - /** - * Resume an interrupted session by clearing the interrupt reason - * and sending a continue prompt. - */ - async resumeInterruptedSession(sessionId: string): Promise { - const session = this.sessions.get(sessionId); - if (!session) { - throw new Error(`Session not found: ${sessionId}`); - } - if (!session.interruptReason) { - throw new Error(`Session ${sessionId} was not interrupted`); - } - log.info("Resuming interrupted session", { - sessionId, - reason: session.interruptReason, - }); - // Clear the interrupt reason - session.interruptReason = undefined; - // Send a continue prompt - return this.prompt(sessionId, [ - { type: "text", text: "Continue where you left off." }, - ]); - } - - setPendingContext(taskRunId: string, context: string): void { - const session = this.sessions.get(taskRunId); - if (!session) { - log.warn("Session not found for setPendingContext", { taskRunId }); - return; - } - session.pendingContext = context; - log.info("Set pending context on session", { - taskRunId, - contextLength: context.length, - }); - } - - /** - * Notify a session of a context change (CWD moved, detached HEAD, etc). - * Used when focusing/unfocusing worktrees - the agent doesn't need to respawn - * because it has additionalDirectories configured, but it should know about the change. - */ - async notifySessionContext( - sessionId: string, - context: import("./schemas.js").SessionContextChange, - ): Promise { - const session = this.sessions.get(sessionId); - if (!session) { - log.warn("Session not found for context notification", { sessionId }); - return; - } - - const contextMessage = this.buildContextMessage(context); - - // Check if session is currently busy - if (session.promptPending) { - // Active session: send immediately with continue instruction - this.prompt(sessionId, [ - { - type: "text", - text: `${contextMessage} Continue where you left off.`, - _meta: { ui: { hidden: true } }, - }, - ]); - } else { - // Idle session: store for prepending to next user message - session.pendingContext = contextMessage; - } - - log.info("Notified session of context change", { - sessionId, - context, - wasPromptPending: session.promptPending, - }); - } - - private buildContextMessage( - context: import("./schemas.js").SessionContextChange, - ): string { - if (context.isDetached) { - return `Your worktree is now on detached HEAD while the user edits in their main repo. The branch is \`${context.branchName}\`. - -For git operations while detached: -- Commit: works normally -- Push: \`git push origin HEAD:refs/heads/${context.branchName}\` -- Pull: \`git fetch origin ${context.branchName} && git merge FETCH_HEAD\``; - } - return `Your worktree is back on branch \`${context.branchName}\`. Normal git commands work again.`; - } - - @preDestroy() - async cleanupAll(): Promise { - 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", { - sessionCount: sessionIds.length, - }); - - for (const session of this.sessions.values()) { - try { - await session.agent.flushAllLogs(); - } catch { - log.debug("Failed to flush session logs during shutdown"); - } - } - - for (const taskRunId of sessionIds) { - await this.cleanupSession(taskRunId); - } - - log.info("All agent sessions cleaned up"); - } - - private setupMockNodeEnvironment(): string { - const mockNodeDir = getMockNodeDir(); - if (!this.mockNodeReady) { - try { - mkdirSync(mockNodeDir, { recursive: true }); - const nodeSymlinkPath = join(mockNodeDir, "node"); - try { - symlinkSync(process.execPath, nodeSymlinkPath); - } catch (err) { - if ( - !(err instanceof Error) || - !("code" in err) || - err.code !== "EEXIST" - ) { - throw err; - } - } - this.mockNodeReady = true; - } catch (err) { - log.warn("Failed to setup mock node environment", err); - } - } - return mockNodeDir; - } - - private cancelInFlightMcpToolCalls(session: ManagedSession): void { - for (const [toolCallId, toolKey] of session.inFlightMcpToolCalls) { - this.mcpAppsService.notifyToolCancelled(toolKey, toolCallId); - } - - session.inFlightMcpToolCalls.clear(); - } - - private async cleanupSession(taskRunId: string): Promise { - const session = this.sessions.get(taskRunId); - if (session) { - this.cancelInFlightMcpToolCalls(session); - this.sleepService.release(taskRunId); - try { - await session.agent.cleanup(); - } catch { - log.debug("Agent cleanup failed", { taskRunId }); - } - - this.sessions.delete(taskRunId); - - const timeout = this.idleTimeouts.get(taskRunId); - if (timeout) { - clearTimeout(timeout.handle); - this.idleTimeouts.delete(taskRunId); - } - - // 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"); - }); - } - } - } - - private createClientConnection( - taskRunId: string, - _channel: string, - clientStreams: { readable: ReadableStream; writable: WritableStream }, - ): ClientSideConnection { - // Capture service reference for use in client callbacks - const service = this; - - const emitToRenderer = (payload: unknown) => { - // Emit event via TypedEventEmitter for tRPC subscription - this.emit(AgentServiceEvent.SessionEvent, { - taskRunId, - payload, - }); - }; - - const onAcpMessage = (message: unknown) => { - const acpMessage: AcpMessage = { - type: "acp_message", - ts: Date.now(), - message: message as AcpMessage["message"], - }; - emitToRenderer(acpMessage); - - // Inspect tool call updates for PR URLs and file activity - this.handleToolCallUpdate(taskRunId, message as AcpMessage["message"]); - }; - - const tappedReadable = createTappedReadableStream( - clientStreams.readable as ReadableStream, - onAcpMessage, - ); - - const tappedWritable = createTappedWritableStream( - clientStreams.writable as WritableStream, - onAcpMessage, - ); - - const client: Client = { - async requestPermission( - params: RequestPermissionRequest, - ): Promise { - const toolName = - (params.toolCall?.rawInput as { toolName?: string } | undefined) - ?.toolName || ""; - const toolCallId = params.toolCall?.toolCallId || ""; - - log.info("requestPermission called", { - taskRunId, - toolCallId, - toolName, - title: params.toolCall?.title, - optionCount: params.options.length, - }); - - if (toolName && isMcpToolReadOnly(toolName)) { - const session = service.sessions.get(taskRunId); - const approvalState = session?.mcpToolApprovals?.[toolName]; - if (approvalState === "approved") { - log.info("Auto-approving read-only MCP tool", { - taskRunId, - toolName, - }); - return { outcome: buildAutoApproveOutcome(params.options) }; - } - } - - // If we have a toolCallId, always prompt the user for permission. - // The claude.ts adapter only calls requestPermission when user input is needed. - // (It handles auto-approve internally for acceptEdits/bypassPermissions modes) - if (toolCallId) { - service.sleepService.release(taskRunId); - try { - const response = await new Promise( - (resolve, reject) => { - const key = `${taskRunId}:${toolCallId}`; - service.pendingPermissions.set(key, { - resolve, - reject, - taskRunId, - toolCallId, - }); - - log.info("Emitting permission request to renderer", { - taskRunId, - toolCallId, - }); - const { sessionId: _agentSessionId, ...rest } = params; - service.emit(AgentServiceEvent.PermissionRequest, { - ...rest, - taskRunId, - }); - }, - ); - - const approved = - response.outcome?.outcome === "selected" && - (response.outcome.optionId === "allow" || - response.outcome.optionId === "allow_always"); - if (approved && toolName) { - const session = service.sessions.get(taskRunId); - if ( - session?.mcpToolApprovals?.[toolName] === "needs_approval" && - session.toolInstallations[toolName] - ) { - const { installationId, toolName: rawToolName } = - session.toolInstallations[toolName]; - try { - await service.agentAuthAdapter.updateMcpToolApproval( - session.config.credentials, - installationId, - rawToolName, - "approved", - ); - session.mcpToolApprovals[toolName] = "approved"; - } catch (err) { - log.warn("Failed to update tool approval on backend", { - toolName, - error: err instanceof Error ? err.message : String(err), - }); - } - } - } - - return response; - } finally { - // Only re-acquire if session wasn't cleaned up while waiting - if (service.sessions.has(taskRunId)) { - service.sleepService.acquire(taskRunId); - } - } - } - - // Fallback: no toolCallId means we can't track the response, auto-approve - log.warn("No toolCallId in permission request, auto-approving", { - taskRunId, - toolName, - }); - return { outcome: buildAutoApproveOutcome(params.options) }; - }, - - async readTextFile(params) { - const session = service.sessions.get(taskRunId); - if (!session) { - throw new Error(`No active session for taskRunId=${taskRunId}`); - } - const repoPath = session.config.repoPath; - const relativePath = service.toRepoRelativePath(repoPath, params.path); - const content = await service.fsService.readRepoFile( - repoPath, - relativePath, - ); - if (content === null) { - throw new Error(`File not found: ${params.path}`); - } - return { content }; - }, - - async writeTextFile(params) { - const session = service.sessions.get(taskRunId); - if (!session) { - throw new Error(`No active session for taskRunId=${taskRunId}`); - } - const repoPath = session.config.repoPath; - const relativePath = service.toRepoRelativePath(repoPath, params.path); - await service.fsService.writeRepoFile( - repoPath, - relativePath, - params.content, - ); - return {}; - }, - - async sessionUpdate(params: SessionNotification) { - // Forward MCP tool events to McpAppsService using the SDK's - // typed discriminated union instead of parsing raw JSON. - const { update } = params; - if ( - update.sessionUpdate !== "tool_call" && - update.sessionUpdate !== "tool_call_update" - ) { - return; - } - - const toolName = (update._meta as ClaudeCodeToolMeta | undefined) - ?.claudeCode?.toolName; - if (!toolName?.startsWith("mcp__")) return; - - const session = service.sessions.get(taskRunId); - if (update.sessionUpdate === "tool_call") { - session?.inFlightMcpToolCalls.set(update.toolCallId, toolName); - service.mcpAppsService.notifyToolInput( - toolName, - update.toolCallId, - update.rawInput, - ); - } else if ( - update.status === "completed" || - update.status === "failed" - ) { - session?.inFlightMcpToolCalls.delete(update.toolCallId); - service.mcpAppsService.notifyToolResult( - toolName, - update.toolCallId, - update.rawOutput, - update.status === "failed", - ); - } - }, - - extNotification: async ( - method: string, - params: Record, - ): Promise => { - if (isNotification(method, POSTHOG_NOTIFICATIONS.SDK_SESSION)) { - const { - taskRunId: notifTaskRunId, - sessionId, - adapter: notifAdapter, - } = params as { - taskRunId: string; - sessionId: string; - adapter: "claude" | "codex"; - }; - const session = this.sessions.get(notifTaskRunId); - if (session) { - session.config.sessionId = sessionId; - if (notifAdapter) { - session.config.adapter = notifAdapter; - } - log.info("Session ID captured", { - taskRunId: notifTaskRunId, - sessionId, - adapter: notifAdapter, - }); - } - } - - if (isNotification(method, POSTHOG_NOTIFICATIONS.USAGE_UPDATE)) { - this.emit(AgentServiceEvent.LlmActivity, undefined); - } - - // Extension notifications already flow through the tapped stream - // (same pattern as sessionUpdate). No need to re-emit here. - }, - }; - - const clientStream = ndJsonStream(tappedWritable, tappedReadable); - - return new ClientSideConnection((_agent) => client, clientStream); - } - - private validateSessionParams( - params: StartSessionInput | ReconnectSessionInput, - ): void { - if (!params.taskId || !params.repoPath) { - throw new Error("taskId and repoPath are required"); - } - if (!params.apiHost) { - throw new Error("PostHog API host is required"); - } - } - - private toRepoRelativePath(repoPath: string, filePath: string): string { - const normalize = (inputPath: string): string => { - try { - return fs.realpathSync(inputPath); - } catch { - return resolve(inputPath); - } - }; - - const resolvedRepo = normalize(repoPath); - const resolvedFile = isAbsolute(filePath) - ? resolve(filePath) - : resolve(repoPath, filePath); - const resolvedFileForCheck = fs.existsSync(resolvedFile) - ? normalize(resolvedFile) - : resolve(resolvedFile); - const repoPrefix = resolvedRepo.endsWith(sep) - ? resolvedRepo - : `${resolvedRepo}${sep}`; - - if ( - resolvedFileForCheck === resolvedRepo || - !resolvedFileForCheck.startsWith(repoPrefix) - ) { - throw new Error(`Access denied: path outside repository (${filePath})`); - } - - return relative(resolvedRepo, resolvedFileForCheck); - } - - private toSessionConfig( - params: StartSessionInput | ReconnectSessionInput, - ): SessionConfig { - return { - taskId: params.taskId, - taskRunId: params.taskRunId, - repoPath: params.repoPath, - credentials: { - apiHost: params.apiHost, - projectId: params.projectId, - }, - logUrl: "logUrl" in params ? params.logUrl : undefined, - sessionId: "sessionId" in params ? params.sessionId : undefined, - adapter: "adapter" in params ? params.adapter : undefined, - permissionMode: - "permissionMode" in params ? params.permissionMode : undefined, - customInstructions: - "customInstructions" in params ? params.customInstructions : undefined, - effort: "effort" in params ? params.effort : undefined, - model: "model" in params ? params.model : undefined, - jsonSchema: "jsonSchema" in params ? params.jsonSchema : undefined, - }; - } - - private toSessionResponse(session: ManagedSession): SessionResponse { - return { - sessionId: session.taskRunId, - channel: session.channel, - configOptions: session.configOptions, - }; - } - - private handleToolCallUpdate(taskRunId: string, message: unknown): void { - try { - const msg = message as { - method?: string; - params?: { - update?: { - sessionUpdate?: string; - _meta?: { - claudeCode?: { - toolName?: string; - toolResponse?: unknown; - bashCommand?: string; - }; - }; - content?: Array<{ type?: string; text?: string }>; - }; - }; - }; - - // Only process session/update notifications for tool_call_update - if (msg.method !== "session/update") return; - if (msg.params?.update?.sessionUpdate !== "tool_call_update") return; - - const update = msg.params.update; - const toolMeta = update._meta?.claudeCode; - const toolName = toolMeta?.toolName; - if (!toolName) return; - - const session = this.sessions.get(taskRunId); - - this.detectAndAttachPrUrl(taskRunId, session, toolMeta, update.content); - - this.trackAgentFileActivity(taskRunId, session, toolName); - } catch (err) { - log.debug("Error in tool call update handling", { - taskRunId, - error: err, - }); - } - } - - /** - * Detect GitHub PR URLs in `gh pr create` output and attach to task. - * Gated on the originating bash command so that unrelated PR URLs (e.g. - * `gh pr view`, `gh search prs`) don't get latched onto the run. - */ - private detectAndAttachPrUrl( - taskRunId: string, - session: ManagedSession | undefined, - toolMeta: - | { - toolName?: string; - toolResponse?: unknown; - bashCommand?: string; - } - | undefined, - content?: Array<{ type?: string; text?: string }>, - ): void { - const prUrl = extractCreatedPrUrl({ - toolName: toolMeta?.toolName, - bashCommand: toolMeta?.bashCommand, - toolResponse: toolMeta?.toolResponse, - content, - }); - if (!prUrl) return; - - log.info("Detected PR URL from gh pr create", { taskRunId, prUrl }); - - if (!session) { - log.warn("Session not found for PR attachment", { taskRunId }); - return; - } - - session.agent - .attachPullRequestToTask(session.taskId, prUrl) - .then(() => { - log.info("PR URL attached to task", { - taskRunId, - taskId: session.taskId, - prUrl, - }); - }) - .catch((err) => { - log.error("Failed to attach PR URL to task", { - taskRunId, - taskId: session.taskId, - prUrl, - error: err, - }); - }); - - // The user-initiated PR-creation flow links the current branch to the - // workspace atomically (see GitService.createPr). PRs created via bash — - // e.g. an agent running a `/commit-and-pr` skill — never go through that - // flow, so `workspace.linkedBranch` would otherwise stay unset and - // PR-aware UI (the unified PR badge, branch mismatch warning, diff - // source) would have no anchor. Emit AgentFileActivity here too so - // WorkspaceService.handleAgentFileActivity links the current feature - // branch the moment we observe a PR for it. - this.emitAgentFileActivityForCurrentBranch(taskRunId, session, { - reason: "pr-detected", - }); - } - - /** - * Track agent file activity for branch association observability. - */ - private static readonly FILE_MODIFYING_TOOLS = new Set([ - "Edit", - "Write", - "FileEditTool", - "FileWriteTool", - "MultiEdit", - "NotebookEdit", - ]); - - private trackAgentFileActivity( - taskRunId: string, - session: ManagedSession | undefined, - toolName: string, - ): void { - if (!session) return; - if (!AgentService.FILE_MODIFYING_TOOLS.has(toolName)) return; - - this.emitAgentFileActivityForCurrentBranch(taskRunId, session, { - reason: "file-edit", - toolName, - }); - } - - /** - * Resolve the current branch in the session's repo and emit AgentFileActivity - * so WorkspaceService can link the branch to the task. Best-effort — branch - * resolution failures are logged but never thrown. - */ - private emitAgentFileActivityForCurrentBranch( - taskRunId: string, - session: ManagedSession, - context: { reason: "file-edit" | "pr-detected"; toolName?: string }, - ): void { - getCurrentBranch(session.repoPath) - .then((branchName) => { - this.emit(AgentServiceEvent.AgentFileActivity, { - taskId: session.taskId, - branchName, - }); - }) - .catch((err) => { - log.warn("Failed to emit agent file activity event", { - taskRunId, - taskId: session.taskId, - ...context, - error: err, - }); - }); - } - - async getGatewayModels(apiHost: string) { - const gatewayUrl = getLlmGatewayUrl(apiHost); - const models = await fetchGatewayModels({ gatewayUrl }); - - const mapped = models.map((model) => ({ - modelId: model.id, - name: formatGatewayModelName(model), - description: `Context: ${model.context_window.toLocaleString()} tokens`, - provider: getProviderName(model.owned_by), - })); - - const CLAUDE_TIER_ORDER = ["opus", "sonnet", "haiku"]; - const getModelTier = (modelId: string): number => { - const lowerId = modelId.toLowerCase(); - for (let i = 0; i < CLAUDE_TIER_ORDER.length; i++) { - if (lowerId.includes(CLAUDE_TIER_ORDER[i])) return i; - } - return CLAUDE_TIER_ORDER.length; - }; - - return mapped.sort((a, b) => { - const providerOrder = ["Anthropic", "OpenAI", "Gemini"]; - const aProviderIdx = providerOrder.indexOf(a.provider ?? ""); - const bProviderIdx = providerOrder.indexOf(b.provider ?? ""); - if (aProviderIdx !== bProviderIdx) { - const aIdx = aProviderIdx === -1 ? 999 : aProviderIdx; - const bIdx = bProviderIdx === -1 ? 999 : bProviderIdx; - return aIdx - bIdx; - } - return getModelTier(a.modelId) - getModelTier(b.modelId); - }); - } - - async getPreviewConfigOptions( - apiHost: string, - adapter: "claude" | "codex" = "claude", - ): Promise { - const gatewayUrl = getLlmGatewayUrl(apiHost); - const gatewayModels = await fetchGatewayModels({ gatewayUrl }); - - const modelFilter = adapter === "codex" ? isOpenAIModel : isAnthropicModel; - - const modelOptions = gatewayModels - .filter((model) => modelFilter(model)) - .map((model) => ({ - value: model.id, - name: formatGatewayModelName(model), - description: `Context: ${model.context_window.toLocaleString()} tokens`, - })); - - const defaultModel = - adapter === "codex" - ? (modelOptions.find((o) => o.value === DEFAULT_CODEX_MODEL)?.value ?? - modelOptions[0]?.value ?? - "") - : DEFAULT_GATEWAY_MODEL; - - const resolvedModelId = modelOptions.some((o) => o.value === defaultModel) - ? defaultModel - : (modelOptions[0]?.value ?? defaultModel); - - if (!modelOptions.some((o) => o.value === resolvedModelId)) { - modelOptions.unshift({ - value: resolvedModelId, - name: resolvedModelId, - description: "Custom model", - }); - } - - const modes = - adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const modeOptions = modes.map((mode) => ({ - value: mode.id, - name: mode.name, - description: mode.description ?? undefined, - })); - const defaultMode = adapter === "codex" ? "auto" : "plan"; - - const configOptions: SessionConfigOption[] = [ - { - id: "mode", - name: "Approval Preset", - type: "select", - currentValue: defaultMode, - options: modeOptions, - category: "mode", - description: - "Choose an approval and sandboxing preset for your session", - }, - { - id: "model", - name: "Model", - type: "select", - currentValue: resolvedModelId, - options: modelOptions, - category: "model", - description: "Choose which model Claude should use", - }, - ]; - - const effortOpts = getReasoningEffortOptions(adapter, resolvedModelId); - if (effortOpts) { - configOptions.push({ - id: adapter === "codex" ? "reasoning_effort" : "effort", - name: adapter === "codex" ? "Reasoning Level" : "Effort", - type: "select", - currentValue: "high", - 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", - }); - } - - return configOptions; - } -} diff --git a/apps/code/src/main/services/agent/skill-schemas.ts b/apps/code/src/main/services/agent/skill-schemas.ts deleted file mode 100644 index 01713be84c..0000000000 --- a/apps/code/src/main/services/agent/skill-schemas.ts +++ /dev/null @@ -1,15 +0,0 @@ -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({ - name: z.string(), - description: z.string(), - source: skillSource, - path: z.string(), - repoName: z.string().optional(), -}); - -export const listSkillsOutput = z.array(skillInfo); 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..8acbf8b41a 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(), @@ -74,6 +92,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..d6923f1771 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 { inject, injectable } from "inversify"; -import type { DatabaseService } from "../../db/service"; +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 { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; 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"; +import type { WatcherRegistryService } from "@posthog/workspace-server/services/watcher-registry/watcher-registry"; 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); } @@ -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/archive/schemas.ts b/apps/code/src/main/services/archive/schemas.ts deleted file mode 100644 index 263129783a..0000000000 --- a/apps/code/src/main/services/archive/schemas.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { z } from "zod"; -import { - type ArchivedTask, - archivedTaskSchema, -} from "../../../shared/types/archive"; - -export { archivedTaskSchema, type ArchivedTask }; - -export const archiveTaskInput = z.object({ - taskId: z.string(), -}); - -export type ArchiveTaskInput = z.infer; - -export const unarchiveTaskInput = z.object({ - taskId: z.string(), - recreateBranch: z.boolean().optional(), -}); - -export type UnarchiveTaskInput = z.infer; - -export const archiveTaskOutput = archivedTaskSchema; - -export const unarchiveTaskOutput = z.object({ - taskId: z.string(), - worktreeName: z.string().nullable(), -}); - -export const listArchivedTasksOutput = z.array(archivedTaskSchema); - -export const archivedTaskIdsOutput = z.array(z.string()); - -export const deleteArchivedTaskInput = z.object({ - taskId: z.string(), -}); - -export const deleteArchivedTaskOutput = z.void(); diff --git a/apps/code/src/main/services/archive/service.integration.test.ts b/apps/code/src/main/services/archive/service.integration.test.ts deleted file mode 100644 index 50f7e05b7d..0000000000 --- a/apps/code/src/main/services/archive/service.integration.test.ts +++ /dev/null @@ -1,584 +0,0 @@ -import { execSync } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { WorktreeManager } from "@posthog/git/worktree"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("electron", () => ({ - app: { - isPackaged: false, - getPath: (name: string) => { - if (name === "home") return os.homedir(); - if (name === "userData") return os.tmpdir(); - return os.tmpdir(); - }, - }, -})); - -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"; -import { - createMockWorkspaceRepository, - type MockWorkspaceRepository, -} from "../../db/repositories/workspace-repository.mock"; -import { - createMockWorktreeRepository, - type MockWorktreeRepository, -} from "../../db/repositories/worktree-repository.mock"; -import { ArchiveService } from "./service"; - -async function createTempGitRepo(): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "archive-test-")); - execSync("git init", { cwd: dir, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { - cwd: dir, - stdio: "pipe", - }); - execSync("git config user.name 'Test'", { cwd: dir, stdio: "pipe" }); - execSync("git config commit.gpgsign false", { cwd: dir, stdio: "pipe" }); - await fs.writeFile(path.join(dir, "README.md"), "# Test Repo"); - execSync("git add . && git commit -m 'Initial commit'", { - cwd: dir, - stdio: "pipe", - }); - return dir; -} - -async function pathExists(p: string): Promise { - try { - await fs.access(p); - return true; - } catch { - return false; - } -} - -const TASK_ID = "task-1"; - -interface TestContext { - service: ArchiveService; - repositoryRepo: IRepositoryRepository; - workspaceRepo: MockWorkspaceRepository; - worktreeRepo: MockWorktreeRepository; - archiveRepo: MockArchiveRepository; - repoPath: string; - repoId: string; - worktreeBasePath: string; - archiveInput: () => { taskId: string }; - setupWorktree: ( - method: "detached" | "branch", - branchName?: string, - ) => Promise<{ worktreePath: string; worktreeName: string }>; - git: (cmd: string) => string; -} - -interface CreateTestContextOpts { - mode?: "local" | "cloud" | "worktree"; - hasWorkspace?: boolean; - isArchived?: boolean; - failOnArchiveCreate?: boolean; - failOnArchiveDelete?: boolean; - failOnWorktreeCreate?: boolean; - failOnWorktreeDelete?: boolean; -} - -async function withTestContext( - opts: CreateTestContextOpts, - fn: (ctx: TestContext) => Promise, -): Promise { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "archive-int-")); - const repoPath = await createTempGitRepo(); - const worktreeBasePath = path.join(tempDir, "worktrees"); - await fs.mkdir(worktreeBasePath, { recursive: true }); - - testWorktreeBasePath = worktreeBasePath; - - const repositoryRepo = createMockRepositoryRepository(); - const workspaceRepo = createMockWorkspaceRepository(); - const worktreeRepo = createMockWorktreeRepository({ - failOnCreate: opts.failOnWorktreeCreate, - failOnDelete: opts.failOnWorktreeDelete, - }); - const archiveRepo = createMockArchiveRepository({ - failOnCreate: opts.failOnArchiveCreate, - failOnDelete: opts.failOnArchiveDelete, - }); - - const repo = repositoryRepo.create({ path: repoPath }); - const repoId = repo.id; - - const mocks = { - agentService: { cancelSessionsByTaskId: vi.fn() }, - processTracking: { killByTaskId: vi.fn() }, - fileWatcher: { stopWatching: vi.fn() }, - }; - - const suspensionRepo = createMockSuspensionRepository(); - - const service = new ArchiveService( - mocks.agentService as never, - mocks.processTracking as never, - mocks.fileWatcher as never, - repositoryRepo as never, - workspaceRepo as never, - worktreeRepo as never, - archiveRepo as never, - suspensionRepo as never, - ); - - const git = (cmd: string) => - execSync(`git ${cmd}`, { - cwd: repoPath, - encoding: "utf8", - stdio: "pipe", - }).trim(); - - const archiveInput = () => ({ taskId: TASK_ID }); - - const setupWorktree = async ( - method: "detached" | "branch", - branchName?: string, - ) => { - const manager = new WorktreeManager({ - mainRepoPath: repoPath, - worktreeBasePath, - }); - const result = - method === "detached" - ? await manager.createDetachedWorktreeAtCommit("HEAD", "test-wt") - : await manager.createWorktreeForExistingBranch( - branchName ?? "", - "test-wt", - ); - - const workspace = workspaceRepo.create({ - taskId: TASK_ID, - repositoryId: repoId, - mode: "worktree", - }); - - worktreeRepo.create({ - workspaceId: workspace.id, - name: result.worktreeName, - path: result.worktreePath, - }); - - return result; - }; - - if (opts.hasWorkspace !== false && opts.mode && opts.mode !== "worktree") { - const workspace = workspaceRepo.create({ - taskId: TASK_ID, - repositoryId: repoId, - mode: opts.mode, - }); - - if (opts.isArchived) { - archiveRepo.create({ - workspaceId: workspace.id, - branchName: null, - checkpointId: null, - }); - } - } - - const ctx: TestContext = { - service, - repositoryRepo, - workspaceRepo, - worktreeRepo, - archiveRepo, - repoPath, - repoId, - worktreeBasePath, - archiveInput, - setupWorktree, - git, - }; - - try { - await fn(ctx); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - await fs.rm(repoPath, { recursive: true, force: true }); - } -} - -describe("ArchiveService integration", () => { - describe("worktree mode", () => { - it("archive and unarchive preserves uncommitted changes", () => - withTestContext({}, async (ctx) => { - const { worktreePath, worktreeName } = - await ctx.setupWorktree("detached"); - await fs.writeFile( - path.join(worktreePath, "work.txt"), - "my precious work", - ); - - const archived = await ctx.service.archiveTask(ctx.archiveInput()); - - expect(await pathExists(worktreePath)).toBe(false); - expect(ctx.archiveRepo.findAll()).toHaveLength(1); - expect(archived.checkpointId).toBeTruthy(); - - const result = await ctx.service.unarchiveTask(TASK_ID); - - expect(result.worktreeName).toBe(worktreeName); - const repoName = path.basename(ctx.repoPath); - const newWorktreePath = path.join( - ctx.worktreeBasePath, - result.worktreeName ?? "", - repoName, - ); - expect(await pathExists(newWorktreePath)).toBe(true); - - const content = await fs.readFile( - path.join(newWorktreePath, "work.txt"), - "utf8", - ); - expect(content).toBe("my precious work"); - - expect(ctx.archiveRepo.findAll()).toHaveLength(0); - })); - - it("archive and unarchive preserves branch name", () => - withTestContext({}, async (ctx) => { - const branchName = "feature/my-branch"; - ctx.git(`checkout -b ${branchName}`); - ctx.git("checkout -"); - - const { worktreePath } = await ctx.setupWorktree("branch", branchName); - - const archived = await ctx.service.archiveTask(ctx.archiveInput()); - - expect(archived.branchName).toBe(branchName); - expect(await pathExists(worktreePath)).toBe(false); - - await ctx.service.unarchiveTask(TASK_ID); - - expect(ctx.archiveRepo.findAll()).toHaveLength(0); - })); - - it("unarchive with recreateBranch creates new branch", () => - withTestContext({}, async (ctx) => { - const branchName = "feature/old-branch"; - ctx.git(`checkout -b ${branchName}`); - ctx.git("checkout -"); - - const { worktreePath } = await ctx.setupWorktree("branch", branchName); - await fs.writeFile(path.join(worktreePath, "work.txt"), "my work"); - - await ctx.service.archiveTask(ctx.archiveInput()); - ctx.git(`branch -D ${branchName}`); - - const result = await ctx.service.unarchiveTask(TASK_ID, true); - - const repoName = path.basename(ctx.repoPath); - const newWorktreePath = path.join( - ctx.worktreeBasePath, - result.worktreeName ?? "", - repoName, - ); - - const currentBranch = execSync("git branch --show-current", { - cwd: newWorktreePath, - encoding: "utf8", - stdio: "pipe", - }).trim(); - expect(currentBranch).toBe(branchName); - - const content = await fs.readFile( - path.join(newWorktreePath, "work.txt"), - "utf8", - ); - expect(content).toBe("my work"); - })); - - it("archive does not save branch name for detached HEAD", () => - withTestContext({}, async (ctx) => { - const { worktreePath } = await ctx.setupWorktree("detached"); - - const archived = await ctx.service.archiveTask(ctx.archiveInput()); - - expect(archived.branchName).toBeNull(); - expect(await pathExists(worktreePath)).toBe(false); - })); - - it("throws when trying to archive already archived task", () => - withTestContext({}, async (ctx) => { - await ctx.setupWorktree("detached"); - - await ctx.service.archiveTask(ctx.archiveInput()); - - await expect( - ctx.service.archiveTask(ctx.archiveInput()), - ).rejects.toThrow("already archived"); - })); - - it("archive finds worktree at legacy path format", () => - withTestContext({}, async (ctx) => { - const repoName = path.basename(ctx.repoPath); - const worktreeName = "legacy-wt"; - const legacyPath = path.join( - ctx.worktreeBasePath, - repoName, - worktreeName, - ); - - await fs.mkdir(legacyPath, { recursive: true }); - ctx.git(`worktree add "${legacyPath}" HEAD --detach`); - await fs.writeFile( - path.join(legacyPath, "legacy.txt"), - "legacy content", - ); - - const workspace = ctx.workspaceRepo.create({ - taskId: TASK_ID, - repositoryId: ctx.repoId, - mode: "worktree", - }); - - ctx.worktreeRepo.create({ - workspaceId: workspace.id, - name: worktreeName, - path: legacyPath, - }); - - const archived = await ctx.service.archiveTask(ctx.archiveInput()); - - expect(archived.checkpointId).toBeTruthy(); - expect(await pathExists(legacyPath)).toBe(false); - })); - - it("archive succeeds when worktree was deleted externally", () => - withTestContext({}, async (ctx) => { - const { worktreePath } = await ctx.setupWorktree("detached"); - - await fs.rm(worktreePath, { recursive: true, force: true }); - expect(await pathExists(worktreePath)).toBe(false); - - const archived = await ctx.service.archiveTask(ctx.archiveInput()); - - expect(archived.checkpointId).toBeNull(); - expect(archived.branchName).toBeNull(); - expect(ctx.archiveRepo.findAll()).toHaveLength(1); - })); - }); - - describe("local/cloud mode", () => { - it.each(["local", "cloud"] as const)( - "archive and unarchive %s mode restores correct workspace", - (mode) => - withTestContext({ mode }, async (ctx) => { - await ctx.service.archiveTask(ctx.archiveInput()); - - expect(ctx.archiveRepo.findAll()).toHaveLength(1); - - const result = await ctx.service.unarchiveTask(TASK_ID); - - expect(result.worktreeName).toBeNull(); - expect(ctx.archiveRepo.findAll()).toHaveLength(0); - }), - ); - }); - - describe("error handling", () => { - it("archives task without workspace association", () => - withTestContext({ hasWorkspace: false }, async (ctx) => { - const result = await ctx.service.archiveTask({ - taskId: "nonexistent", - }); - expect(result).toMatchObject({ - taskId: "nonexistent", - folderId: "", - mode: "cloud", - worktreeName: null, - branchName: null, - checkpointId: null, - }); - })); - - it("unarchives task without repository association", () => - withTestContext({}, async (ctx) => { - const workspace = ctx.workspaceRepo.create({ - taskId: TASK_ID, - repositoryId: null, - mode: "cloud", - }); - ctx.archiveRepo.create({ - workspaceId: workspace.id, - branchName: null, - checkpointId: null, - }); - - const result = await ctx.service.unarchiveTask(TASK_ID); - - expect(result).toEqual({ taskId: TASK_ID, worktreeName: null }); - expect(ctx.archiveRepo.findAll()).toHaveLength(0); - })); - - it("throws when workspace not found for unarchive", () => - withTestContext({}, async (ctx) => { - await expect(ctx.service.unarchiveTask("nonexistent")).rejects.toThrow( - "Workspace not found", - ); - })); - - it("throws when archived task not found for unarchive", () => - withTestContext({ mode: "local", isArchived: false }, async (ctx) => { - await expect(ctx.service.unarchiveTask(TASK_ID)).rejects.toThrow( - "Archived task not found", - ); - })); - - it("throws when repository not found for archive", () => - withTestContext({}, async (ctx) => { - ctx.workspaceRepo.create({ - taskId: TASK_ID, - repositoryId: "missing-repo-id", - mode: "local", - }); - - await expect( - ctx.service.archiveTask(ctx.archiveInput()), - ).rejects.toThrow("Repository not found"); - })); - - it("throws when repository not found for unarchive", () => - withTestContext({}, async (ctx) => { - const workspace = ctx.workspaceRepo.create({ - taskId: TASK_ID, - repositoryId: "missing-repo-id", - mode: "worktree", - }); - ctx.worktreeRepo.create({ - workspaceId: workspace.id, - name: "test-wt", - path: "/some/path", - }); - ctx.archiveRepo.create({ - workspaceId: workspace.id, - branchName: null, - checkpointId: "worktree-test-wt", - }); - - await expect(ctx.service.unarchiveTask(TASK_ID)).rejects.toThrow( - "Repository not found", - ); - })); - }); - - describe("getters", () => { - it("getArchivedTasks returns tasks from repository", () => - withTestContext({ mode: "local", isArchived: true }, async (ctx) => { - const tasks = ctx.service.getArchivedTasks(); - expect(tasks).toHaveLength(1); - expect(tasks[0].taskId).toBe(TASK_ID); - - expect(ctx.service.getArchivedTaskIds()).toEqual([TASK_ID]); - expect(ctx.service.isArchived(TASK_ID)).toBe(true); - expect(ctx.service.isArchived("task-2")).toBe(false); - })); - }); - - describe("deleteArchivedTask", () => { - it("deletes archived task without checkpoint", () => - withTestContext({ mode: "local", isArchived: true }, async (ctx) => { - await ctx.service.deleteArchivedTask(TASK_ID); - expect(ctx.archiveRepo.findAll()).toHaveLength(0); - expect(ctx.workspaceRepo.findByTaskId(TASK_ID)).toBeNull(); - })); - - it("deletes archived task with checkpoint", () => - withTestContext({}, async (ctx) => { - const { worktreePath } = await ctx.setupWorktree("detached"); - await fs.writeFile(path.join(worktreePath, "file.txt"), "content"); - - const archived = await ctx.service.archiveTask(ctx.archiveInput()); - expect(archived.checkpointId).toBeTruthy(); - expect(ctx.archiveRepo.findAll()).toHaveLength(1); - - const refs = ctx.git("for-each-ref --format='%(refname)'"); - expect(refs).toContain(archived.checkpointId); - - await ctx.service.deleteArchivedTask(TASK_ID); - - expect(ctx.archiveRepo.findAll()).toHaveLength(0); - const refsAfter = ctx.git("for-each-ref --format='%(refname)'"); - expect(refsAfter).not.toContain(archived.checkpointId); - })); - - it("throws when workspace not found for delete", () => - withTestContext({}, async (ctx) => { - await expect( - ctx.service.deleteArchivedTask("nonexistent"), - ).rejects.toThrow("Workspace not found"); - })); - - it("throws when archived task not found for delete", () => - withTestContext({ mode: "local", isArchived: false }, async (ctx) => { - await expect(ctx.service.deleteArchivedTask(TASK_ID)).rejects.toThrow( - "Archived task", - ); - })); - - it("still removes from repository if checkpoint deletion fails", () => - withTestContext({}, async (ctx) => { - const workspace = ctx.workspaceRepo.create({ - taskId: TASK_ID, - repositoryId: ctx.repoId, - mode: "worktree", - }); - ctx.worktreeRepo.create({ - workspaceId: workspace.id, - name: "nonexistent", - path: "/some/path", - }); - ctx.archiveRepo.create({ - workspaceId: workspace.id, - branchName: null, - checkpointId: "worktree-nonexistent", - }); - - await ctx.service.deleteArchivedTask(TASK_ID); - expect(ctx.archiveRepo.findAll()).toHaveLength(0); - })); - }); - - describe("rollback behavior", () => { - it("archive rolls back if archive create fails", () => - withTestContext( - { mode: "local", failOnArchiveCreate: true }, - async (ctx) => { - await expect( - ctx.service.archiveTask(ctx.archiveInput()), - ).rejects.toThrow("Injected failure"); - - expect(ctx.archiveRepo.findAll()).toHaveLength(0); - }, - )); - - it("unarchive rolls back if archive delete fails", () => - withTestContext( - { mode: "local", isArchived: true, failOnArchiveDelete: true }, - async (ctx) => { - await expect(ctx.service.unarchiveTask(TASK_ID)).rejects.toThrow( - "Injected failure", - ); - - expect(ctx.archiveRepo.findAll()).toHaveLength(1); - }, - )); - }); -}); diff --git a/apps/code/src/main/services/archive/service.ts b/apps/code/src/main/services/archive/service.ts deleted file mode 100644 index 98830cfa3c..0000000000 --- a/apps/code/src/main/services/archive/service.ts +++ /dev/null @@ -1,581 +0,0 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { createGitClient } from "@posthog/git/client"; -import { isGitRepository } from "@posthog/git/queries"; -import { - CaptureCheckpointSaga, - deleteCheckpoint, - RevertCheckpointSaga, -} from "@posthog/git/sagas/checkpoint"; -import { forceRemove } from "@posthog/git/utils"; -import { type WorktreeInfo, WorktreeManager } from "@posthog/git/worktree"; -import { inject, injectable } from "inversify"; -import type { - Archive, - ArchiveRepository, -} from "../../db/repositories/archive-repository"; -import type { RepositoryRepository } from "../../db/repositories/repository-repository"; -import type { - SuspensionReason, - SuspensionRepository, -} from "../../db/repositories/suspension-repository.js"; -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 type { ArchivedTask, ArchiveTaskInput } from "./schemas"; - -const log = logger.scope("archive"); - -type RollbackFn = () => Promise; - -@injectable() -export class ArchiveService { - constructor( - @inject(MAIN_TOKENS.AgentService) - private readonly agentService: AgentService, - @inject(MAIN_TOKENS.ProcessTrackingService) - private readonly processTracking: ProcessTrackingService, - @inject(MAIN_TOKENS.FileWatcherService) - private readonly fileWatcher: FileWatcherBridge, - @inject(MAIN_TOKENS.RepositoryRepository) - private readonly repositoryRepo: RepositoryRepository, - @inject(MAIN_TOKENS.WorkspaceRepository) - private readonly workspaceRepo: WorkspaceRepository, - @inject(MAIN_TOKENS.WorktreeRepository) - private readonly worktreeRepo: WorktreeRepository, - @inject(MAIN_TOKENS.ArchiveRepository) - private readonly archiveRepo: ArchiveRepository, - @inject(MAIN_TOKENS.SuspensionRepository) - private readonly suspensionRepo: SuspensionRepository, - ) {} - - async archiveTask(input: ArchiveTaskInput): Promise { - log.info(`Archiving task ${input.taskId}`); - - const rollbacks: RollbackFn[] = []; - const runWithRollback = async ( - execute: () => Promise, - rollback: RollbackFn, - ) => { - await execute(); - rollbacks.push(rollback); - }; - - try { - const result = await this.executeArchive(input, runWithRollback); - 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); - } - } - throw error; - } - } - - private async executeArchive( - input: ArchiveTaskInput, - step: (execute: () => Promise, rollback: RollbackFn) => Promise, - ): Promise { - const { taskId } = input; - - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (!workspace) { - return { - taskId, - archivedAt: new Date().toISOString(), - folderId: "", - mode: "cloud", - worktreeName: null, - branchName: null, - checkpointId: null, - }; - } - - const existingArchive = this.archiveRepo.findByWorkspaceId(workspace.id); - if (existingArchive) { - throw new Error(`Task ${taskId} is already archived`); - } - - const suspension = this.suspensionRepo.findByWorkspaceId(workspace.id); - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - - if (suspension) { - const archivedTask: ArchivedTask = { - taskId, - archivedAt: new Date().toISOString(), - folderId: workspace.repositoryId ?? "", - mode: workspace.mode, - worktreeName: worktree?.name ?? null, - branchName: suspension.branchName, - checkpointId: suspension.checkpointId, - }; - - await step( - async () => { - this.archiveRepo.create({ - workspaceId: workspace.id, - branchName: archivedTask.branchName, - checkpointId: archivedTask.checkpointId, - }); - }, - async () => { - this.archiveRepo.deleteByWorkspaceId(workspace.id); - }, - ); - - await step( - async () => { - this.suspensionRepo.deleteByWorkspaceId(workspace.id); - }, - async () => { - this.suspensionRepo.create({ - workspaceId: workspace.id, - branchName: suspension.branchName, - checkpointId: suspension.checkpointId, - reason: suspension.reason as SuspensionReason, - }); - }, - ); - - return archivedTask; - } - - const archivedTask: ArchivedTask = { - taskId, - archivedAt: new Date().toISOString(), - folderId: workspace.repositoryId ?? "", - mode: workspace.mode, - worktreeName: worktree?.name ?? null, - branchName: null, - checkpointId: - workspace.mode === "worktree" && worktree - ? `worktree-${worktree.name}` - : null, - }; - - if (workspace.repositoryId) { - const repo = this.repositoryRepo.findById(workspace.repositoryId); - if (!repo) { - throw new Error(`Repository not found for task ${taskId}`); - } - const folderPath = repo.path; - - if (workspace.mode === "worktree" && worktree) { - const worktreePath = worktree.path; - const worktreeIsValid = await isGitRepository(worktreePath).catch( - (error) => { - log.warn( - `Failed to check worktree at ${worktreePath}; treating as invalid`, - { error }, - ); - return false; - }, - ); - - if (!worktreeIsValid) { - log.warn( - `Worktree at ${worktreePath} is missing or not a git repository; skipping checkpoint capture`, - ); - archivedTask.checkpointId = null; - } else { - const actualBranch = await this.getCurrentBranchName(worktreePath); - if (actualBranch && actualBranch !== "HEAD") { - archivedTask.branchName = actualBranch; - } - - await step( - async () => { - if (!archivedTask.checkpointId) { - throw new Error("checkpointId must be set for worktree mode"); - } - await this.captureWorktreeCheckpoint( - folderPath, - worktreePath, - archivedTask.checkpointId, - ); - }, - async () => { - if (archivedTask.checkpointId) { - const git = createGitClient(folderPath); - await deleteCheckpoint(git, archivedTask.checkpointId); - } - }, - ); - } - - await step( - async () => { - await this.agentService.cancelSessionsByTaskId(taskId); - this.processTracking.killByTaskId(taskId); - await this.fileWatcher.stopWatching(worktreePath); - }, - async () => {}, - ); - - await step( - async () => { - const manager = new WorktreeManager({ - mainRepoPath: folderPath, - worktreeBasePath: getWorktreeLocation(), - }); - await manager.deleteWorktree(worktreePath); - const parentDir = path.dirname(worktreePath); - await forceRemove(parentDir); - }, - async () => {}, - ); - } - } - - if (workspace.mode !== "worktree") { - await step( - async () => { - await this.agentService.cancelSessionsByTaskId(taskId); - this.processTracking.killByTaskId(taskId); - }, - async () => {}, - ); - } - - await step( - async () => { - this.archiveRepo.create({ - workspaceId: workspace.id, - branchName: archivedTask.branchName, - checkpointId: archivedTask.checkpointId, - }); - }, - async () => { - this.archiveRepo.deleteByWorkspaceId(workspace.id); - }, - ); - - return archivedTask; - } - - async unarchiveTask( - taskId: string, - recreateBranch?: boolean, - ): Promise<{ taskId: string; worktreeName: string | null }> { - log.info( - `Unarchiving task ${taskId}${recreateBranch ? " (recreate branch)" : ""}`, - ); - - const rollbacks: RollbackFn[] = []; - const runWithRollback = async ( - execute: () => Promise, - rollback: RollbackFn, - ) => { - await execute(); - rollbacks.push(rollback); - }; - - try { - const result = await this.executeUnarchive( - taskId, - recreateBranch, - runWithRollback, - ); - 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); - } - } - throw error; - } - } - - private async executeUnarchive( - taskId: string, - recreateBranch: boolean | undefined, - step: (execute: () => Promise, rollback: RollbackFn) => Promise, - ): Promise<{ taskId: string; worktreeName: string | null }> { - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (!workspace) { - throw new Error(`Workspace not found: ${taskId}`); - } - - const archive = this.archiveRepo.findByWorkspaceId(workspace.id); - if (!archive) { - throw new Error(`Archived task not found: ${taskId}`); - } - - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - let restoredWorktreeName: string | null = worktree?.name ?? null; - - if (workspace.repositoryId) { - const repo = this.repositoryRepo.findById(workspace.repositoryId); - if (!repo) { - throw new Error(`Repository not found for task ${taskId}`); - } - const folderPath = repo.path; - - const shouldRestoreWorktree = - workspace.mode === "worktree" && archive.checkpointId; - - if (shouldRestoreWorktree) { - await step( - async () => { - restoredWorktreeName = await this.restoreWorktreeFromCheckpoint( - folderPath, - workspace, - archive, - recreateBranch, - ); - }, - async () => { - if (restoredWorktreeName) { - const manager = new WorktreeManager({ - mainRepoPath: folderPath, - worktreeBasePath: getWorktreeLocation(), - }); - const worktreePath = await this.deriveWorktreePath( - folderPath, - restoredWorktreeName, - ); - await manager.deleteWorktree(worktreePath); - const parentDir = path.dirname(worktreePath); - await forceRemove(parentDir); - } - }, - ); - - await step( - async () => { - if (!restoredWorktreeName) { - throw new Error("Failed to restore worktree"); - } - const worktreePath = await this.deriveWorktreePath( - folderPath, - restoredWorktreeName, - ); - this.worktreeRepo.create({ - workspaceId: workspace.id, - name: restoredWorktreeName, - path: worktreePath, - }); - }, - async () => { - this.worktreeRepo.deleteByWorkspaceId(workspace.id); - }, - ); - } - } - - await step( - async () => { - this.archiveRepo.deleteByWorkspaceId(workspace.id); - }, - async () => { - this.archiveRepo.create({ - workspaceId: workspace.id, - branchName: archive.branchName, - checkpointId: archive.checkpointId, - }); - }, - ); - - return { taskId, worktreeName: restoredWorktreeName }; - } - - getArchivedTasks(): ArchivedTask[] { - const archives = this.archiveRepo.findAll(); - return archives.map((archive) => { - const workspace = this.workspaceRepo.findById( - archive.workspaceId, - ) as Workspace; - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - return this.toArchivedTask(workspace, archive, worktree?.name ?? null); - }); - } - - getArchivedTaskIds(): string[] { - const archives = this.archiveRepo.findAll(); - return archives - .map((archive) => { - const workspace = this.workspaceRepo.findById(archive.workspaceId); - return workspace?.taskId; - }) - .filter((id): id is string => id !== undefined); - } - - isArchived(taskId: string): boolean { - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (!workspace) return false; - return this.archiveRepo.findByWorkspaceId(workspace.id) !== null; - } - - async deleteArchivedTask(taskId: string): Promise { - log.info(`Deleting archived task ${taskId}`); - - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (!workspace) { - throw new Error(`Workspace not found: ${taskId}`); - } - - const archive = this.archiveRepo.findByWorkspaceId(workspace.id); - if (!archive) { - throw new Error(`Archived task ${taskId} not found`); - } - - if (archive.checkpointId && workspace.repositoryId) { - const repo = this.repositoryRepo.findById(workspace.repositoryId); - if (repo) { - try { - const git = createGitClient(repo.path); - await deleteCheckpoint(git, archive.checkpointId); - } catch (error) { - log.warn(`Failed to delete checkpoint ${archive.checkpointId}`, { - error, - }); - } - } - } - - this.archiveRepo.deleteByWorkspaceId(workspace.id); - this.workspaceRepo.deleteByTaskId(taskId); - log.info(`Deleted archived task ${taskId}`); - } - - private toArchivedTask( - workspace: Workspace, - archive: Archive, - worktreeName: string | null, - ): ArchivedTask { - return { - taskId: workspace.taskId, - archivedAt: archive.archivedAt, - folderId: workspace.repositoryId ?? "", - mode: workspace.mode, - worktreeName, - branchName: archive.branchName, - checkpointId: archive.checkpointId, - }; - } - - private async 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, - 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 { - const git = createGitClient(worktreePath); - try { - const branch = await git.revparse(["--abbrev-ref", "HEAD"]); - return branch.trim(); - } catch { - return ""; - } - } - - private async 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}`); - } - } - - private async restoreWorktreeFromCheckpoint( - folderPath: string, - workspace: Workspace, - archive: Archive, - recreateBranch?: boolean, - ): Promise { - 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 revertSaga = new RevertCheckpointSaga(); - const result = await revertSaga.run({ - baseDir: newWorktree.worktreePath, - checkpointId: archive.checkpointId, - }); - - 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); - } - - return newWorktree.worktreeName; - } -} diff --git a/apps/code/src/main/services/auth-proxy/service.ts b/apps/code/src/main/services/auth-proxy/service.ts deleted file mode 100644 index 3896996cb2..0000000000 --- a/apps/code/src/main/services/auth-proxy/service.ts +++ /dev/null @@ -1,210 +0,0 @@ -import http from "node:http"; -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"); - -@injectable() -export class AuthProxyService { - private server: http.Server | null = null; - private gatewayUrl: string | null = null; - private port: number | null = null; - - constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, - ) {} - - async start(gatewayUrl: string): Promise { - if (this.server) { - this.gatewayUrl = gatewayUrl; - return this.getProxyUrl(); - } - - this.gatewayUrl = gatewayUrl; - - this.server = http.createServer((req, res) => { - this.handleRequest(req, res); - }); - - return new Promise((resolve, reject) => { - this.server?.listen(0, "127.0.0.1", () => { - const addr = this.server?.address(); - if (typeof addr === "object" && addr) { - this.port = addr.port; - resolve(this.getProxyUrl()); - } else { - reject(new Error("Failed to get proxy address")); - } - }); - - this.server?.on("error", (err) => { - log.error("Auth proxy server error", err); - reject(err); - }); - }); - } - - getProxyUrl(): string { - if (!this.port) { - throw new Error("Auth proxy not started"); - } - return `http://127.0.0.1:${this.port}`; - } - - isRunning(): boolean { - return this.server !== null && this.port !== null; - } - - async stop(): Promise { - if (!this.server) return; - - return new Promise((resolve) => { - this.server?.close(() => { - log.info("Auth proxy stopped"); - this.server = null; - this.port = null; - resolve(); - }); - }); - } - - private handleRequest( - req: http.IncomingMessage, - res: http.ServerResponse, - ): void { - if (!this.gatewayUrl) { - res.writeHead(503); - res.end("Proxy not configured"); - return; - } - - const base = this.gatewayUrl.endsWith("/") - ? this.gatewayUrl - : `${this.gatewayUrl}/`; - const incoming = (req.url ?? "/").replace(/^\//, ""); - const targetUrl = new URL(incoming, base); - - // Validate that the resolved URL stays within the configured gateway origin - const gatewayBase = new URL(base); - const normalizePort = (u: URL): string => { - if (u.port) return u.port; - if (u.protocol === "https:") return "443"; - if (u.protocol === "http:") return "80"; - return ""; - }; - - const targetPort = normalizePort(targetUrl); - const gatewayPort = normalizePort(gatewayBase); - - const sameOrigin = - targetUrl.protocol === gatewayBase.protocol && - targetUrl.hostname === gatewayBase.hostname && - targetPort === gatewayPort; - - const hasPathTraversal = targetUrl.pathname.includes(".."); - - if (!sameOrigin || hasPathTraversal) { - log.warn("Rejected proxy request with invalid target URL", { - method: req.method, - incoming: req.url, - target: targetUrl.toString(), - }); - res.writeHead(403); - res.end("Forbidden"); - return; - } - - const strippedAuthHeaders = new Set([ - "authorization", - "x-api-key", - "api-key", - "anthropic-auth-token", - "proxy-authorization", - ]); - const headers: Record = {}; - for (const [key, value] of Object.entries(req.headers)) { - if ( - key === "host" || - key === "connection" || - strippedAuthHeaders.has(key) - ) { - continue; - } - if (typeof value === "string") { - headers[key] = value; - } - } - const fetchOptions: RequestInit = { - method: req.method ?? "GET", - headers, - }; - - if (req.method !== "GET" && req.method !== "HEAD") { - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => chunks.push(chunk)); - req.on("end", () => { - fetchOptions.body = Buffer.concat(chunks); - this.forwardRequest(targetUrl.toString(), fetchOptions, res); - }); - } else { - this.forwardRequest(targetUrl.toString(), fetchOptions, res); - } - } - - private async forwardRequest( - url: string, - options: RequestInit, - res: http.ServerResponse, - ): Promise { - try { - const response = await this.authService.authenticatedFetch( - fetch, - url, - options, - ); - - const responseHeaders: Record = {}; - const stripHeaders = new Set([ - "transfer-encoding", - "content-encoding", - "content-length", - ]); - response.headers.forEach((value: string, key: string) => { - if (stripHeaders.has(key)) return; - responseHeaders[key] = value; - }); - - res.writeHead(response.status, responseHeaders); - - if (!response.body) { - res.end(); - return; - } - - const reader = response.body.getReader(); - const pump = async (): Promise => { - const { done, value } = await reader.read(); - if (done) { - res.end(); - return; - } - const canContinue = res.write(value); - if (canContinue) { - return pump(); - } - res.once("drain", () => pump()); - }; - - await pump(); - } catch (err) { - log.error("Proxy forward error", { url, err }); - if (!res.headersSent) { - res.writeHead(502); - } - res.end("Proxy 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..3d1e7d3e64 --- /dev/null +++ b/apps/code/src/main/services/auth/port-adapters.ts @@ -0,0 +1,142 @@ +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 { IAuthPreferenceRepository } from "@posthog/workspace-server/db/repositories/auth-preference-repository"; +import type { IAuthSessionRepository } from "@posthog/workspace-server/db/repositories/auth-session-repository"; +import { ConnectivityEvent } from "@posthog/workspace-server/services/connectivity/schemas"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { decrypt, encrypt } from "../../utils/encryption"; +import type { ConnectivityService } from "../connectivity/service"; + +@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 { + constructor( + @inject(MAIN_TOKENS.ConnectivityService) + private readonly connectivity: ConnectivityService, + ) {} + + getStatus(): ConnectivityStatus { + return { isOnline: this.connectivity.getStatus().isOnline }; + } + + onStatusChange(handler: (status: ConnectivityStatus) => void): () => void { + const listener = (status: { isOnline: boolean }) => { + handler({ isOnline: status.isOnline }); + }; + this.connectivity.on(ConnectivityEvent.StatusChange, listener); + return () => { + this.connectivity.off(ConnectivityEvent.StatusChange, listener); + }; + } +} diff --git a/apps/code/src/main/services/auth/schemas.ts b/apps/code/src/main/services/auth/schemas.ts deleted file mode 100644 index f165e6a22a..0000000000 --- a/apps/code/src/main/services/auth/schemas.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from "zod"; -import { cloudRegion, type oAuthTokenResponse } from "../oauth/schemas"; - -export const authStatusSchema = z.enum(["anonymous", "authenticated"]); -export type AuthStatus = z.infer; - -export const authStateSchema = z.object({ - status: authStatusSchema, - bootstrapComplete: z.boolean(), - cloudRegion: cloudRegion.nullable(), - projectId: z.number().nullable(), - availableProjectIds: z.array(z.number()), - availableOrgIds: z.array(z.string()), - hasCodeAccess: z.boolean().nullable(), - needsScopeReauth: z.boolean(), -}); -export type AuthState = z.infer; - -export const loginInput = z.object({ - region: cloudRegion, -}); -export type LoginInput = z.infer; - -export const loginOutput = z.object({ - state: authStateSchema, -}); -export type LoginOutput = z.infer; - -export const redeemInviteCodeInput = z.object({ - code: z.string().min(1), -}); - -export const selectProjectInput = z.object({ - projectId: z.number(), -}); - -export const validAccessTokenOutput = z.object({ - accessToken: z.string(), - apiHost: z.string(), -}); -export type ValidAccessTokenOutput = z.infer; - -export const AuthServiceEvent = { - StateChanged: "state-changed", -} as const; - -export interface AuthServiceEvents { - [AuthServiceEvent.StateChanged]: AuthState; -} - -export type AuthTokenResponse = z.infer; diff --git a/apps/code/src/main/services/auth/service.test.ts b/apps/code/src/main/services/auth/service.test.ts deleted file mode 100644 index 8733ebd258..0000000000 --- a/apps/code/src/main/services/auth/service.test.ts +++ /dev/null @@ -1,560 +0,0 @@ -import { EventEmitter } from "node:events"; -import type { IPowerManager } from "@posthog/platform/power-manager"; -import { OAUTH_SCOPE_VERSION } from "@shared/constants/oauth"; -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"; - -const mockPowerManager = vi.hoisted(() => ({ - onResume: vi.fn(() => () => {}), - preventSleep: vi.fn(() => () => {}), -})); - -vi.mock("@shared/utils/backoff", () => ({ - sleepWithBackoff: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../../utils/logger.js", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -function mockTokenResponse( - overrides: { - accessToken?: string; - refreshToken?: string; - scopedTeams?: number[]; - scopedOrgs?: string[]; - } = {}, -) { - return { - success: true as const, - data: { - access_token: overrides.accessToken ?? "access-token", - refresh_token: overrides.refreshToken ?? "refresh-token", - expires_in: 3600, - token_type: "Bearer", - scope: "", - scoped_teams: overrides.scopedTeams ?? [42], - scoped_organizations: overrides.scopedOrgs ?? ["org-1"], - }, - }; -} - -describe("AuthService", () => { - const preferenceRepository = createMockAuthPreferenceRepository(); - const repository = createMockAuthSessionRepository(); - - const oauthService = { - refreshToken: vi.fn(), - startFlow: vi.fn(), - startSignupFlow: vi.fn(), - } as unknown as OAuthService; - - const connectivityEmitter = new EventEmitter(); - const connectivityService = Object.assign(connectivityEmitter, { - getStatus: vi.fn(() => ({ isOnline: true })), - checkNow: vi.fn(), - }) as unknown as ConnectivityService; - - let service: AuthService; - - function seedStoredSession( - overrides: { - refreshToken?: string; - selectedProjectId?: number | null; - scopeVersion?: number; - } = {}, - ) { - repository.saveCurrent({ - refreshTokenEncrypted: encrypt( - 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 getResumeHandler(): () => void { - const call = mockPowerManager.onResume.mock.calls[0]; - return (call as unknown as [() => void])[0]; - } - - const stubAuthFetch = (accountKey = "user-1") => { - vi.stubGlobal( - "fetch", - vi.fn(async (input: string | Request) => { - const url = typeof input === "string" ? input : input.url; - - if (url.includes("/api/users/@me/")) { - return { - ok: true, - json: vi.fn().mockResolvedValue({ uuid: accountKey }), - } as unknown as Response; - } - - return { - ok: true, - json: vi.fn().mockResolvedValue({ has_access: true }), - } as unknown as Response; - }) as typeof fetch, - ); - }; - - beforeEach(() => { - preferenceRepository._preferences = []; - repository.clearCurrent(); - vi.clearAllMocks(); - connectivityEmitter.removeAllListeners(); - service = new AuthService( - preferenceRepository, - repository, - oauthService, - connectivityService, - mockPowerManager as unknown as IPowerManager, - ); - service.init(); - }); - - afterEach(async () => { - vi.unstubAllGlobals(); - service.shutdown(); - await service.logout(); - }); - - it("bootstraps to anonymous when there is no stored session", async () => { - await service.initialize(); - - expect(service.getState()).toEqual({ - status: "anonymous", - bootstrapComplete: true, - cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], - hasCodeAccess: null, - needsScopeReauth: false, - }); - }); - - it("requires scope reauthentication when the stored scope version is stale", async () => { - seedStoredSession({ - refreshToken: "refresh-token", - selectedProjectId: 123, - scopeVersion: OAUTH_SCOPE_VERSION - 1, - }); - - await service.initialize(); - - expect(service.getState()).toEqual({ - status: "anonymous", - bootstrapComplete: true, - cloudRegion: "us", - projectId: 123, - availableProjectIds: [], - availableOrgIds: [], - hasCodeAccess: null, - needsScopeReauth: true, - }); - }); - - it("restores an authenticated session by refreshing the stored refresh token", async () => { - seedStoredSession({ selectedProjectId: 42 }); - vi.mocked(oauthService.refreshToken).mockResolvedValue( - mockTokenResponse({ - accessToken: "new-access-token", - refreshToken: "rotated-refresh-token", - scopedTeams: [42, 84], - }), - ); - stubAuthFetch(); - - await service.initialize(); - - expect(service.getState()).toMatchObject({ - status: "authenticated", - bootstrapComplete: true, - cloudRegion: "us", - projectId: 42, - availableProjectIds: [42, 84], - availableOrgIds: ["org-1"], - hasCodeAccess: true, - needsScopeReauth: false, - }); - - expect(decrypt(repository.getCurrent()?.refreshTokenEncrypted ?? "")).toBe( - "rotated-refresh-token", - ); - }); - - it("forces a token refresh when explicitly requested", async () => { - vi.mocked(oauthService.startFlow).mockResolvedValue( - mockTokenResponse({ - accessToken: "initial-access-token", - refreshToken: "initial-refresh-token", - }), - ); - vi.mocked(oauthService.refreshToken).mockResolvedValue( - mockTokenResponse({ - accessToken: "refreshed-access-token", - refreshToken: "rotated-refresh-token", - }), - ); - stubAuthFetch(); - - await service.login("us"); - const token = await service.refreshAccessToken(); - - expect(token.accessToken).toBe("refreshed-access-token"); - expect(oauthService.refreshToken).toHaveBeenCalledWith( - "initial-refresh-token", - "us", - ); - expect(decrypt(repository.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) - .mockResolvedValueOnce( - mockTokenResponse({ - accessToken: "initial-access-token", - refreshToken: "initial-refresh-token", - scopedTeams: [42, 84], - }), - ) - .mockResolvedValueOnce( - mockTokenResponse({ - accessToken: "second-access-token", - refreshToken: "second-refresh-token", - scopedTeams: [42, 84], - }), - ); - vi.mocked(oauthService.refreshToken).mockResolvedValue( - mockTokenResponse({ - accessToken: "refreshed-access-token", - refreshToken: "refreshed-refresh-token", - scopedTeams: [42, 84], - }), - ); - stubAuthFetch(); - - await service.login("us"); - await service.selectProject(84); - await service.logout(); - - expect(service.getState()).toMatchObject({ - status: "anonymous", - cloudRegion: "us", - projectId: 84, - }); - - await service.login("us"); - - expect(service.getState()).toMatchObject({ - status: "authenticated", - cloudRegion: "us", - projectId: 84, - availableProjectIds: [42, 84], - }); - }); - - it("restores the selected project after app restart while logged out", async () => { - vi.mocked(oauthService.startFlow) - .mockResolvedValueOnce( - mockTokenResponse({ - accessToken: "initial-access-token", - refreshToken: "initial-refresh-token", - scopedTeams: [42, 84], - }), - ) - .mockResolvedValueOnce( - mockTokenResponse({ - accessToken: "second-access-token", - refreshToken: "second-refresh-token", - scopedTeams: [42, 84], - }), - ); - vi.mocked(oauthService.refreshToken).mockResolvedValue( - mockTokenResponse({ - accessToken: "refreshed-access-token", - refreshToken: "refreshed-refresh-token", - scopedTeams: [42, 84], - }), - ); - stubAuthFetch(); - - await service.login("us"); - await service.selectProject(84); - await service.logout(); - - service = new AuthService( - preferenceRepository, - repository, - oauthService, - connectivityService, - mockPowerManager as unknown as IPowerManager, - ); - - await service.login("us"); - - expect(service.getState()).toMatchObject({ - status: "authenticated", - cloudRegion: "us", - projectId: 84, - availableProjectIds: [42, 84], - }); - }); - - describe("lifecycle: connectivity recovery", () => { - it("recovers session when connectivity changes to online", async () => { - seedStoredSession({ selectedProjectId: 42 }); - vi.mocked(connectivityService.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(), - ); - stubAuthFetch(); - - emitOnline(); - - await vi.waitFor(() => { - expect(service.getState().status).toBe("authenticated"); - }); - }); - - it("does nothing when session already exists", async () => { - vi.mocked(oauthService.startFlow).mockResolvedValue(mockTokenResponse()); - stubAuthFetch(); - await service.login("us"); - vi.mocked(oauthService.refreshToken).mockClear(); - - emitOnline(); - - await new Promise((r) => setTimeout(r, 10)); - expect(oauthService.refreshToken).not.toHaveBeenCalled(); - }); - - it("ignores offline events", async () => { - seedStoredSession(); - - connectivityEmitter.emit(ConnectivityEvent.StatusChange, { - isOnline: false, - }); - - await new Promise((r) => setTimeout(r, 10)); - expect(oauthService.refreshToken).not.toHaveBeenCalled(); - }); - - it("deduplicates concurrent recovery attempts", async () => { - seedStoredSession(); - - let resolveRefresh!: () => void; - vi.mocked(oauthService.refreshToken).mockReturnValue( - new Promise((resolve) => { - resolveRefresh = () => resolve(mockTokenResponse()); - }), - ); - stubAuthFetch(); - - emitOnline(); - emitOnline(); - - await new Promise((r) => setTimeout(r, 10)); - expect(oauthService.refreshToken).toHaveBeenCalledTimes(1); - - resolveRefresh(); - - await vi.waitFor(() => { - expect(service.getState().status).toBe("authenticated"); - }); - }); - }); - - describe("lifecycle: power monitor resume", () => { - it("registers and unregisters the resume handler", () => { - expect(mockPowerManager.onResume).toHaveBeenCalledWith( - expect.any(Function), - ); - const unsubscribe = mockPowerManager.onResume.mock.results[0]?.value as - | (() => void) - | undefined; - const unsubscribeSpy = vi.fn(); - mockPowerManager.onResume.mockReturnValueOnce(unsubscribeSpy); - - service.shutdown(); - expect(unsubscribe).toBeDefined(); - }); - - it("attempts session recovery on resume", async () => { - seedStoredSession(); - vi.mocked(oauthService.refreshToken).mockResolvedValue( - mockTokenResponse(), - ); - stubAuthFetch(); - - getResumeHandler()(); - - await vi.waitFor(() => { - expect(service.getState().status).toBe("authenticated"); - }); - }); - }); - - describe("refresh retry with error codes", () => { - it.each([ - { errorCode: "network_error" as const, label: "network_error" }, - { errorCode: "server_error" as const, label: "server_error" }, - ])( - "retries on $label and succeeds on second attempt", - async ({ errorCode }) => { - seedStoredSession(); - vi.mocked(oauthService.refreshToken) - .mockResolvedValueOnce({ - success: false, - error: "Transient failure", - errorCode, - }) - .mockResolvedValueOnce(mockTokenResponse()); - stubAuthFetch(); - - await service.initialize(); - - expect(service.getState().status).toBe("authenticated"); - expect(oauthService.refreshToken).toHaveBeenCalledTimes(2); - }, - ); - - it("does not retry on auth_error and forces logout", async () => { - seedStoredSession({ selectedProjectId: 42 }); - vi.mocked(oauthService.refreshToken).mockResolvedValue({ - success: false, - error: "Token revoked", - errorCode: "auth_error", - }); - - await service.initialize(); - - expect(service.getState()).toMatchObject({ - status: "anonymous", - cloudRegion: "us", - projectId: 42, - }); - expect(oauthService.refreshToken).toHaveBeenCalledTimes(1); - expect(repository.getCurrent()).toBeNull(); - }); - - it("does not retry on unknown_error", async () => { - seedStoredSession(); - vi.mocked(oauthService.refreshToken).mockResolvedValue({ - success: false, - error: "Something weird", - errorCode: "unknown_error", - }); - - await service.initialize(); - - expect(service.getState().status).toBe("anonymous"); - expect(oauthService.refreshToken).toHaveBeenCalledTimes(1); - }); - - it("gives up after all retry attempts are exhausted", async () => { - seedStoredSession(); - vi.mocked(oauthService.refreshToken).mockResolvedValue({ - success: false, - error: "Network error", - errorCode: "network_error", - }); - - await service.initialize(); - - expect(service.getState().status).toBe("anonymous"); - expect(oauthService.refreshToken).toHaveBeenCalledTimes(3); - }); - }); - - describe("redeemInviteCode uses authenticatedFetch", () => { - it("retries on 401 via authenticatedFetch", async () => { - vi.mocked(oauthService.startFlow).mockResolvedValue( - mockTokenResponse({ - accessToken: "initial-token", - refreshToken: "refresh-token", - }), - ); - vi.mocked(oauthService.refreshToken).mockResolvedValue( - mockTokenResponse({ - accessToken: "refreshed-token", - refreshToken: "new-refresh-token", - }), - ); - - let redeemCallCount = 0; - vi.stubGlobal( - "fetch", - vi.fn(async (input: string | Request) => { - const url = typeof input === "string" ? input : input.url; - - if (url.includes("/api/users/@me/")) { - return { - ok: true, - json: vi.fn().mockResolvedValue({ uuid: "user-1" }), - } as unknown as Response; - } - - if (url.includes("/invites/redeem/")) { - redeemCallCount++; - if (redeemCallCount === 1) { - return { - ok: false, - status: 401, - json: () => Promise.resolve({}), - } as unknown as Response; - } - return { - ok: true, - status: 200, - json: () => Promise.resolve({ success: true }), - } as unknown as Response; - } - - return { - ok: true, - json: vi.fn().mockResolvedValue({ has_access: true }), - } as unknown as Response; - }) as typeof fetch, - ); - - await service.login("us"); - const state = await service.redeemInviteCode("test-code"); - - expect(state.hasCodeAccess).toBe(true); - expect(redeemCallCount).toBe(2); - }); - }); -}); diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts deleted file mode 100644 index e59051aa16..0000000000 --- a/apps/code/src/main/services/auth/service.ts +++ /dev/null @@ -1,671 +0,0 @@ -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 { 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"; -import { - AuthServiceEvent, - type AuthServiceEvents, - type AuthState, - type AuthTokenResponse, - type ValidAccessTokenOutput, -} from "./schemas"; - -const log = logger.scope("auth-service"); -const TOKEN_EXPIRY_SKEW_MS = 60_000; -type FetchLike = ( - input: string | Request, - init?: RequestInit, -) => Promise; - -interface InMemorySession { - accountKey: string | null; - accessToken: string; - accessTokenExpiresAt: number; - refreshToken: string; - cloudRegion: CloudRegion; - projectId: number | null; - availableProjectIds: number[]; - availableOrgIds: string[]; -} - -interface StoredSessionInput { - refreshToken: string; - cloudRegion: CloudRegion; - selectedProjectId: number | null; -} - -interface TokenResponseOptions { - cloudRegion: CloudRegion; - selectedProjectId: number | null; -} - -@injectable() -export class AuthService extends TypedEventEmitter { - private state: AuthState = { - status: "anonymous", - bootstrapComplete: false, - cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], - hasCodeAccess: null, - needsScopeReauth: false, - }; - private session: InMemorySession | null = null; - 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) - private readonly powerManager: IPowerManager, - ) { - super(); - } - async initialize(): Promise { - if (this.initializePromise) { - return this.initializePromise; - } - - this.initializePromise = this.doInitialize(); - return this.initializePromise; - } - getState(): AuthState { - return { ...this.state }; - } - async login(region: CloudRegion): Promise { - await this.authenticateWithFlow( - () => this.oauthService.startFlow(region), - region, - "OAuth flow failed", - ); - return this.getState(); - } - async signup(region: CloudRegion): Promise { - await this.authenticateWithFlow( - () => this.oauthService.startSignupFlow(region), - region, - "Signup failed", - ); - return this.getState(); - } - async getValidAccessToken(): Promise { - const override = process.env.VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE; - if (override) { - await this.initialize(); - const region = this.session?.cloudRegion ?? "us"; - return { - accessToken: override, - apiHost: getCloudUrlFromRegion(region), - }; - } - - await this.initialize(); - - const session = await this.ensureValidSession(); - return { - accessToken: session.accessToken, - apiHost: getCloudUrlFromRegion(session.cloudRegion), - }; - } - async refreshAccessToken(): Promise { - const override = process.env.VITE_POSTHOG_ACCESS_TOKEN_OVERRIDE; - if (override) { - await this.initialize(); - const region = this.session?.cloudRegion ?? "us"; - return { - accessToken: override, - apiHost: getCloudUrlFromRegion(region), - }; - } - - await this.initialize(); - - const session = await this.ensureValidSession(true); - return { - accessToken: session.accessToken, - apiHost: getCloudUrlFromRegion(session.cloudRegion), - }; - } - async invalidateAccessTokenForTest(): Promise { - await this.initialize(); - - if (!this.session) { - return; - } - - 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, - }; - } - async authenticatedFetch( - fetchImpl: FetchLike, - input: string | Request, - init: RequestInit = {}, - ): Promise { - const initialAuth = await this.getValidAccessToken(); - let response = await this.executeAuthenticatedFetch( - fetchImpl, - input, - init, - initialAuth.accessToken, - ); - - if (response.status === 401 || response.status === 403) { - const refreshedAuth = await this.refreshAccessToken(); - response = await this.executeAuthenticatedFetch( - fetchImpl, - input, - init, - refreshedAuth.accessToken, - ); - } - - return response; - } - async redeemInviteCode(code: string): Promise { - const { apiHost } = await this.getValidAccessToken(); - const response = await this.authenticatedFetch( - fetch, - `${apiHost}/api/code/invites/redeem/`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code }), - }, - ); - - const data = (await response.json().catch(() => ({}))) as { - success?: boolean; - error?: string; - }; - - if (!response.ok || !data.success) { - throw new Error(data.error || "Failed to redeem invite code"); - } - - this.updateState({ hasCodeAccess: true }); - return this.getState(); - } - async selectProject(projectId: number): Promise { - await this.initialize(); - - const session = this.requireSession(); - - if (!session.availableProjectIds.includes(projectId)) { - throw new Error("Invalid project selection"); - } - - this.session = { - ...session, - projectId, - }; - - this.persistProjectPreference(this.session); - this.persistSession({ - refreshToken: this.session.refreshToken, - cloudRegion: this.session.cloudRegion, - selectedProjectId: projectId, - }); - - this.updateState({ projectId }); - return this.getState(); - } - async logout(): Promise { - const { cloudRegion, projectId } = this.state; - - this.authSessionRepository.clearCurrent(); - this.session = null; - this.setAnonymousState({ cloudRegion, projectId }); - return this.getState(); - } - private executeAuthenticatedFetch( - fetchImpl: FetchLike, - input: string | Request, - init: RequestInit, - accessToken: string, - ): Promise { - const headers = new Headers(init.headers); - headers.set("authorization", `Bearer ${accessToken}`); - - return fetchImpl(input, { - ...init, - headers, - }); - } - private async doInitialize(): Promise { - const stored = this.authSessionRepository.getCurrent(); - - if (!stored) { - this.setAnonymousState({ bootstrapComplete: true }); - return; - } - - if (stored.scopeVersion < OAUTH_SCOPE_VERSION) { - this.session = null; - this.setAnonymousState({ - bootstrapComplete: true, - cloudRegion: stored.cloudRegion, - projectId: stored.selectedProjectId, - needsScopeReauth: true, - }); - return; - } - - const storedSession = this.resolveStoredSession(); - if (!storedSession) { - log.warn("Stored auth session could not be decrypted"); - this.authSessionRepository.clearCurrent(); - this.setAnonymousState({ bootstrapComplete: true }); - return; - } - - try { - await this.refreshAndSyncSession(storedSession); - } catch (error) { - log.warn("Failed to restore stored auth session", { error }); - this.session = null; - this.setAnonymousState({ - bootstrapComplete: true, - cloudRegion: storedSession.cloudRegion, - projectId: storedSession.selectedProjectId, - }); - } - } - private async ensureValidSession( - forceRefresh = false, - ): Promise { - if ( - this.session && - !forceRefresh && - !this.isSessionExpiring(this.session) - ) { - return this.session; - } - - if (this.refreshPromise) { - return this.refreshPromise; - } - - const sessionInput = this.getSessionInputForRefresh(); - - this.refreshPromise = this.refreshSession(sessionInput).finally(() => { - this.refreshPromise = null; - }); - - const session = await this.refreshPromise; - await this.syncAuthenticatedSession(session); - return session; - } - - private getSessionInputForRefresh(): StoredSessionInput { - if (this.session) { - return { - refreshToken: this.session.refreshToken, - cloudRegion: this.session.cloudRegion, - selectedProjectId: this.session.projectId, - }; - } - - const storedSession = this.resolveStoredSession(); - if (!storedSession) { - throw new NotAuthenticatedError(); - } - - return storedSession; - } - private async refreshSession( - input: StoredSessionInput, - ): Promise { - if (!this.connectivityService.getStatus().isOnline) { - throw new Error("Offline"); - } - - let lastError = "Token refresh failed"; - - for ( - let attempt = 0; - attempt < AuthService.REFRESH_MAX_ATTEMPTS; - attempt++ - ) { - const result = await this.oauthService.refreshToken( - input.refreshToken, - input.cloudRegion, - ); - - if (result.success && result.data) { - return await this.createSessionFromTokenResponse(result.data, input); - } - - lastError = result.error || "Token refresh failed"; - - if (result.errorCode === "auth_error") { - log.warn("Refresh token rejected by server, forcing logout"); - this.authSessionRepository.clearCurrent(); - this.session = null; - this.setAnonymousState({ - cloudRegion: input.cloudRegion, - projectId: input.selectedProjectId, - }); - throw new Error(lastError); - } - - const isRetryable = - result.errorCode === "network_error" || - result.errorCode === "server_error"; - - if (!isRetryable) { - throw new Error(lastError); - } - - const isLastAttempt = attempt === AuthService.REFRESH_MAX_ATTEMPTS - 1; - if (isLastAttempt) break; - - log.warn("Transient refresh failure, retrying", { - attempt, - errorCode: result.errorCode, - }); - await sleepWithBackoff(attempt, AuthService.REFRESH_BACKOFF); - } - - throw new Error(lastError); - } - private async createSessionFromTokenResponse( - tokenResponse: AuthTokenResponse, - options: TokenResponseOptions, - ): Promise { - const availableProjectIds = tokenResponse.scoped_teams ?? []; - const availableOrgIds = tokenResponse.scoped_organizations ?? []; - const accountKey = await this.fetchAccountKey( - tokenResponse.access_token, - options.cloudRegion, - ); - const preferredProjectId = - options.selectedProjectId ?? - (accountKey - ? (this.authPreferenceRepository.get(accountKey, options.cloudRegion) - ?.lastSelectedProjectId ?? null) - : null); - const projectId = - preferredProjectId && availableProjectIds.includes(preferredProjectId) - ? preferredProjectId - : (availableProjectIds[0] ?? null); - - const session: InMemorySession = { - accountKey, - accessToken: tokenResponse.access_token, - accessTokenExpiresAt: Date.now() + tokenResponse.expires_in * 1000, - refreshToken: tokenResponse.refresh_token, - cloudRegion: options.cloudRegion, - projectId, - availableProjectIds, - availableOrgIds, - }; - - return session; - } - private async authenticateWithFlow( - runFlow: () => Promise<{ - success: boolean; - data?: AuthTokenResponse; - error?: string; - }>, - region: CloudRegion, - fallbackError: string, - ): Promise { - const result = await runFlow(); - if (!result.success || !result.data) { - throw new Error(result.error || fallbackError); - } - - const session = await this.createSessionFromTokenResponse(result.data, { - cloudRegion: region, - selectedProjectId: this.state.projectId, - }); - await this.syncAuthenticatedSession(session); - } - private async refreshAndSyncSession( - input: StoredSessionInput, - ): Promise { - const session = await this.refreshSession(input); - await this.syncAuthenticatedSession(session); - } - private async syncAuthenticatedSession( - session: InMemorySession, - ): Promise { - this.persistProjectPreference(session); - this.persistSession({ - refreshToken: session.refreshToken, - cloudRegion: session.cloudRegion, - selectedProjectId: session.projectId, - }); - - this.session = session; - this.updateState({ - status: "authenticated", - bootstrapComplete: true, - cloudRegion: session.cloudRegion, - projectId: session.projectId, - availableProjectIds: session.availableProjectIds, - availableOrgIds: session.availableOrgIds, - needsScopeReauth: false, - }); - await this.updateCodeAccessFromSession(); - } - private persistSession(input: { - refreshToken: string; - cloudRegion: CloudRegion; - selectedProjectId: number | null; - }): void { - const row: PersistAuthSessionInput = { - refreshTokenEncrypted: 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({ - accountKey: session.accountKey, - cloudRegion: session.cloudRegion, - lastSelectedProjectId: session.projectId, - }); - } - private isSessionExpiring(session: InMemorySession): boolean { - return session.accessTokenExpiresAt - Date.now() <= TOKEN_EXPIRY_SKEW_MS; - } - private async fetchAccountKey( - accessToken: string, - cloudRegion: "us" | "eu" | "dev", - ): Promise { - try { - const response = await fetch( - `${getCloudUrlFromRegion(cloudRegion)}/api/users/@me/`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - return null; - } - - const data = (await response.json().catch(() => ({}))) as { - uuid?: unknown; - distinct_id?: unknown; - email?: unknown; - }; - - if (typeof data.uuid === "string" && data.uuid.length > 0) { - return data.uuid; - } - if (typeof data.distinct_id === "string" && data.distinct_id.length > 0) { - return data.distinct_id; - } - if (typeof data.email === "string" && data.email.length > 0) { - return data.email; - } - - return null; - } catch (error) { - log.warn("Failed to resolve auth account key", { error }); - return null; - } - } - private requireSession(): InMemorySession { - if (!this.session) { - throw new NotAuthenticatedError(); - } - return this.session; - } - private setAnonymousState( - partial: Pick< - Partial, - "bootstrapComplete" | "cloudRegion" | "projectId" | "needsScopeReauth" - > = {}, - ): void { - this.updateState({ - status: "anonymous", - bootstrapComplete: partial.bootstrapComplete ?? true, - cloudRegion: partial.cloudRegion ?? null, - projectId: partial.projectId ?? null, - availableProjectIds: [], - availableOrgIds: [], - hasCodeAccess: null, - needsScopeReauth: partial.needsScopeReauth ?? false, - }); - } - private async updateCodeAccessFromSession(): Promise { - if (!this.session) { - this.updateState({ hasCodeAccess: null }); - return; - } - - try { - const apiHost = getCloudUrlFromRegion(this.session.cloudRegion); - const response = await this.executeAuthenticatedFetch( - fetch, - `${apiHost}/api/code/invites/check-access/`, - {}, - this.session.accessToken, - ); - const data = (await response.json().catch(() => ({}))) as { - has_access?: boolean; - }; - - this.updateState({ hasCodeAccess: data.has_access === true }); - } catch (error) { - log.warn("Failed to update code access state", { error }); - this.updateState({ hasCodeAccess: false }); - } - } - private static readonly REFRESH_MAX_ATTEMPTS = 3; - private static readonly REFRESH_BACKOFF: BackoffOptions = { - initialDelayMs: 1_000, - maxDelayMs: 5_000, - multiplier: 2, - }; - private recoveryPromise: Promise | null = null; - private connectivityUnsubscribe: (() => void) | null = null; - 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.resumeUnsubscribe = this.powerManager.onResume(this.handleResume); - } - @preDestroy() - shutdown(): void { - this.connectivityUnsubscribe?.(); - this.connectivityUnsubscribe = null; - this.resumeUnsubscribe?.(); - this.resumeUnsubscribe = null; - } - private handleResume = (): void => { - this.attemptSessionRecovery(); - }; - private resolveStoredSession(): StoredSessionInput | null { - const stored = this.authSessionRepository.getCurrent(); - if (!stored) return null; - - const refreshToken = decrypt(stored.refreshTokenEncrypted); - if (!refreshToken) return null; - - return { - refreshToken, - cloudRegion: stored.cloudRegion, - selectedProjectId: stored.selectedProjectId, - }; - } - private attemptSessionRecovery(): void { - if (this.session) return; - if (this.recoveryPromise) return; - - const stored = this.authSessionRepository.getCurrent(); - if (!stored) return; - if (stored.scopeVersion < OAUTH_SCOPE_VERSION) return; - - const storedSession = this.resolveStoredSession(); - if (!storedSession) return; - - this.recoveryPromise = this.refreshAndSyncSession(storedSession) - .catch((error) => { - log.warn("Session recovery failed", { error }); - }) - .finally(() => { - this.recoveryPromise = null; - }); - } - - private updateState(partial: Partial): void { - this.state = { - ...this.state, - ...partial, - }; - this.emit(AuthServiceEvent.StateChanged, this.getState()); - } -} diff --git a/apps/code/src/main/services/cloud-task/schemas.ts b/apps/code/src/main/services/cloud-task/schemas.ts deleted file mode 100644 index 69512afb7c..0000000000 --- a/apps/code/src/main/services/cloud-task/schemas.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - type CloudTaskUpdatePayload, - isTerminalStatus, - type TaskRunStatus, - TERMINAL_STATUSES, -} from "@shared/types"; -import { z } from "zod"; - -export type { CloudTaskUpdatePayload, TaskRunStatus }; -export { TERMINAL_STATUSES, isTerminalStatus }; - -// --- Events --- - -export const CloudTaskEvent = { - Update: "cloud-task-update", -} as const; - -export interface CloudTaskEvents { - [CloudTaskEvent.Update]: CloudTaskUpdatePayload; -} - -// --- tRPC Schemas --- - -export const watchInput = z.object({ - taskId: z.string(), - runId: z.string(), - apiHost: z.string(), - teamId: z.number(), -}); - -export type WatchInput = z.infer; - -export const unwatchInput = z.object({ - taskId: z.string(), - runId: z.string(), -}); - -export const retryInput = z.object({ - taskId: z.string(), - runId: z.string(), -}); - -export const onUpdateInput = z.object({ - taskId: z.string(), - runId: z.string(), -}); - -export const sendCommandInput = z.object({ - taskId: z.string(), - runId: z.string(), - apiHost: z.string(), - teamId: z.number(), - method: z.enum([ - "user_message", - "cancel", - "close", - "permission_response", - "set_config_option", - ]), - params: z.record(z.string(), z.unknown()).optional(), -}); - -export type SendCommandInput = z.infer; - -export const sendCommandOutput = z.object({ - success: z.boolean(), - result: z.unknown().optional(), - error: z.string().optional(), -}); - -export type SendCommandOutput = z.infer; diff --git a/apps/code/src/main/services/cloud-task/service.test.ts b/apps/code/src/main/services/cloud-task/service.test.ts deleted file mode 100644 index ab9000a167..0000000000 --- a/apps/code/src/main/services/cloud-task/service.test.ts +++ /dev/null @@ -1,1624 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CloudTaskEvent } from "./schemas"; - -const mockNetFetch = vi.hoisted(() => vi.fn()); -const mockStreamFetch = vi.hoisted(() => vi.fn()); - -// The service now uses global fetch for BOTH authenticated API calls (JSON) -// and SSE streaming. The two used to be distinct (net.fetch vs global fetch). -// To preserve the existing test fixtures, route by URL: /stream/ → stream mock, -// everything else → API mock. -const fetchRouter = vi.hoisted(() => - vi.fn((input: string | Request, init?: RequestInit) => { - const url = typeof input === "string" ? input : input.url; - const impl = url.includes("/stream/") ? mockStreamFetch : mockNetFetch; - return impl(input, init); - }), -); - -vi.mock("../../utils/logger", () => ({ - logger: { - scope: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }), - }, -})); - -import { CloudTaskService } from "./service"; - -const mockAuthService = { - authenticatedFetch: vi.fn(), -}; - -function createJsonResponse( - data: unknown, - status = 200, - headers?: Record, -): Response { - return new Response(JSON.stringify(data), { - status, - headers: { "Content-Type": "application/json", ...(headers ?? {}) }, - }); -} - -function createSseResponse(payload: string, status = 200): Response { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(payload)); - controller.close(); - }, - }); - - return new Response(stream, { - status, - headers: { "Content-Type": "text/event-stream" }, - }); -} - -function createOpenSseResponse(payload: string, status = 200): Response { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(payload)); - }, - }); - - return new Response(stream, { - status, - headers: { "Content-Type": "text/event-stream" }, - }); -} - -async function waitFor( - predicate: () => boolean, - timeoutMs = 2_000, -): Promise { - const start = Date.now(); - while (!predicate()) { - if (Date.now() - start > timeoutMs) { - throw new Error("Timed out waiting for condition"); - } - if (vi.isFakeTimers()) { - await vi.advanceTimersByTimeAsync(10); - } else { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } -} - -describe("CloudTaskService", () => { - let service: CloudTaskService; - - beforeEach(() => { - service = new CloudTaskService(mockAuthService as never); - 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, { - ...init, - headers: { - ...(init?.headers ?? {}), - Authorization: "Bearer token", - }, - }); - }, - ); - }); - - afterEach(() => { - service.unwatchAll(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it("bootstraps paged backlog for active runs and drains deduped live SSE entries", async () => { - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - mockNetFetch - .mockResolvedValueOnce( - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: "build", - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }), - ) - .mockResolvedValueOnce( - createJsonResponse( - [ - { - type: "notification", - timestamp: "2026-01-01T00:00:00Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "older history", - }, - }, - }, - ], - 200, - { "X-Has-More": "true" }, - ), - ) - .mockResolvedValueOnce( - createJsonResponse( - [ - { - type: "notification", - timestamp: "2026-01-01T00:00:01Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "hello", - }, - }, - }, - ], - 200, - { "X-Has-More": "false" }, - ), - ); - - mockStreamFetch.mockResolvedValueOnce( - createOpenSseResponse( - 'id: 1\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:01Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"hello"}}}\n\nid: 2\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:02Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"live tail"}}}\n\n', - ), - ); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => updates.length >= 2); - - expect(updates).toEqual([ - { - taskId: "task-1", - runId: "run-1", - kind: "snapshot", - newEntries: [ - { - type: "notification", - timestamp: "2026-01-01T00:00:00Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "older history", - }, - }, - }, - { - type: "notification", - timestamp: "2026-01-01T00:00:01Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "hello", - }, - }, - }, - ], - totalEntryCount: 2, - status: "in_progress", - stage: "build", - output: null, - errorMessage: null, - branch: "main", - }, - { - taskId: "task-1", - runId: "run-1", - kind: "logs", - newEntries: [ - { - type: "notification", - timestamp: "2026-01-01T00:00:02Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "live tail", - }, - }, - }, - ], - totalEntryCount: 3, - }, - ]); - - expect(mockStreamFetch).toHaveBeenCalledWith( - "https://app.example.com/api/projects/2/tasks/task-1/runs/run-1/stream/?start=latest", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer token", - Accept: "text/event-stream", - }), - }), - ); - }); - - it("reconnects with Last-Event-ID after a stream error", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - mockNetFetch - .mockResolvedValueOnce( - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }), - ) - .mockResolvedValueOnce( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ); - - mockStreamFetch - .mockResolvedValueOnce( - createSseResponse( - 'id: 1\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:01Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"hello"}}}\n\nevent: error\ndata: {"error":"boom"}\n\n', - ), - ) - .mockResolvedValueOnce( - createOpenSseResponse( - 'id: 2\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:02Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"again"}}}\n\n', - ), - ); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await vi.advanceTimersByTimeAsync(2_000); - await waitFor(() => updates.length >= 2); - - expect(mockStreamFetch).toHaveBeenNthCalledWith( - 2, - "https://app.example.com/api/projects/2/tasks/task-1/runs/run-1/stream/", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer token", - Accept: "text/event-stream", - "Last-Event-ID": "1", - }), - }), - ); - }); - - it("replays a current snapshot when a subscriber attaches to an existing watcher", async () => { - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - const historicalEntry = { - type: "notification", - timestamp: "2026-01-01T00:00:00Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "older history", - }, - }, - }; - const liveEntry = { - type: "notification", - timestamp: "2026-01-01T00:00:01Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "live tail", - }, - }, - }; - - const runResponse = { - id: "run-1", - status: "in_progress", - stage: "build", - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }; - - mockNetFetch - .mockResolvedValueOnce(createJsonResponse(runResponse)) - .mockResolvedValueOnce( - createJsonResponse([historicalEntry], 200, { "X-Has-More": "false" }), - ) - .mockResolvedValueOnce(createJsonResponse(runResponse)) - .mockResolvedValueOnce( - createJsonResponse([historicalEntry], 200, { "X-Has-More": "false" }), - ); - - mockStreamFetch.mockResolvedValueOnce( - createOpenSseResponse(`id: 1\ndata: ${JSON.stringify(liveEntry)}\n\n`), - ); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => updates.length >= 2); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => - updates.some( - (update) => - typeof update === "object" && - update !== null && - (update as { kind?: string; totalEntryCount?: number }).kind === - "snapshot" && - (update as { totalEntryCount?: number }).totalEntryCount === 2, - ), - ); - - const replayedSnapshot = updates.find( - (update) => - typeof update === "object" && - update !== null && - (update as { kind?: string; totalEntryCount?: number }).kind === - "snapshot" && - (update as { totalEntryCount?: number }).totalEntryCount === 2, - ); - - expect(replayedSnapshot).toEqual({ - taskId: "task-1", - runId: "run-1", - kind: "snapshot", - newEntries: [historicalEntry, liveEntry], - totalEntryCount: 2, - status: "in_progress", - stage: "build", - output: null, - errorMessage: null, - branch: "main", - }); - - const getWatcherEmittedEntryCount = (): number => { - const watcher = ( - service as unknown as { - watchers: Map; - } - ).watchers.get("task-1:run-1"); - return watcher?.emittedLogEntries.length ?? 0; - }; - - expect(getWatcherEmittedEntryCount()).toBe(1); - - mockNetFetch.mockResolvedValueOnce( - createJsonResponse([historicalEntry, liveEntry], 200, { - "X-Has-More": "false", - }), - ); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => getWatcherEmittedEntryCount() === 0); - }); - - it("ignores keepalive SSE events while keeping the stream open", async () => { - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - mockNetFetch - .mockResolvedValueOnce( - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: "build", - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }), - ) - .mockResolvedValueOnce( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ); - - mockStreamFetch.mockResolvedValueOnce( - createOpenSseResponse( - 'event: keepalive\ndata: {"type":"keepalive"}\n\nid: 2\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:02Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"live tail"}}}\n\n', - ), - ); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => updates.length >= 2); - - expect(updates).toEqual([ - { - taskId: "task-1", - runId: "run-1", - kind: "snapshot", - newEntries: [], - totalEntryCount: 0, - status: "in_progress", - stage: "build", - output: null, - errorMessage: null, - branch: "main", - }, - { - taskId: "task-1", - runId: "run-1", - kind: "logs", - newEntries: [ - { - type: "notification", - timestamp: "2026-01-01T00:00:02Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "live tail", - }, - }, - }, - ], - totalEntryCount: 1, - }, - ]); - }); - - it("reconnects after clean stream completion when the run remains active", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - const prUrl = "https://github.com/PostHog/code/pull/123"; - let statusFetchCount = 0; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - const createInProgressRun = (output: Record | null) => - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: "build", - output, - error_message: null, - branch: "main", - updated_at: output ? "2026-01-01T00:00:01Z" : "2026-01-01T00:00:00Z", - }); - - mockNetFetch.mockImplementation((input: string | Request) => { - const url = typeof input === "string" ? input : input.url; - if (url.includes("/session_logs/")) { - return Promise.resolve( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ); - } - - statusFetchCount += 1; - return Promise.resolve( - createInProgressRun(statusFetchCount === 1 ? null : { pr_url: prUrl }), - ); - }); - - mockStreamFetch.mockImplementation(() => - Promise.resolve(createSseResponse("")), - ); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => mockStreamFetch.mock.calls.length === 1); - await waitFor(() => mockStreamFetch.mock.calls.length >= 7, 20_000); - - expect(updates).toContainEqual( - expect.objectContaining({ - taskId: "task-1", - runId: "run-1", - status: "in_progress", - output: { pr_url: prUrl }, - }), - ); - expect( - updates.some( - (update) => - typeof update === "object" && - update !== null && - (update as { kind?: string }).kind === "error", - ), - ).toBe(false); - - expect( - ( - service as unknown as { - watchers: Map; - } - ).watchers.has("task-1:run-1"), - ).toBe(true); - }); - - it("fails the watcher after exhausting the cumulative reconnect budget on clean-EOF loops", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - const makeInProgressRun = () => - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }); - - mockNetFetch.mockImplementation((input: string | Request) => { - const url = typeof input === "string" ? input : input.url; - if (url.includes("/session_logs/")) { - return Promise.resolve( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ); - } - return Promise.resolve(makeInProgressRun()); - }); - - mockStreamFetch.mockImplementation(() => - Promise.resolve(createSseResponse("")), - ); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => mockStreamFetch.mock.calls.length === 1); - await vi.advanceTimersByTimeAsync(60 * 60_000); - - await waitFor( - () => - updates.some( - (u) => - typeof u === "object" && - u !== null && - (u as { kind?: string }).kind === "error", - ), - 10_000, - ); - - expect(updates).toContainEqual({ - taskId: "task-1", - runId: "run-1", - kind: "error", - errorTitle: "Cloud run unreachable", - errorMessage: - "Could not maintain a connection to the cloud run after many attempts. Click retry once the issue is resolved.", - retryable: true, - }); - }); - - it("emits a retryable cloud error after repeated stream failures", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - const makeInProgressRun = () => - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }); - - mockNetFetch - .mockResolvedValueOnce(makeInProgressRun()) // bootstrap: fetchTaskRun - .mockResolvedValueOnce( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ) // bootstrap: fetchSessionLogs - // Each stream error triggers handleStreamCompletion → fetchTaskRun - .mockImplementation(() => Promise.resolve(makeInProgressRun())); - - mockStreamFetch.mockImplementation(() => - Promise.resolve( - createSseResponse( - 'event: keepalive\ndata: {"type":"keepalive"}\n\nevent: error\ndata: {"error":"boom"}\n\n', - ), - ), - ); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => mockStreamFetch.mock.calls.length === 1); - await vi.advanceTimersByTimeAsync(70_000); - await waitFor( - () => - updates.some( - (u) => - typeof u === "object" && - u !== null && - (u as { kind?: string }).kind === "error", - ), - 10_000, - ); - - expect(mockStreamFetch.mock.calls.length).toBe(6); - // 2 bootstrap calls + 1 post-bootstrap status verification + 6 - // handleStreamCompletion calls (one per stream error) - expect(mockNetFetch).toHaveBeenCalledTimes(9); - expect(updates).toContainEqual({ - taskId: "task-1", - runId: "run-1", - kind: "error", - errorTitle: "Cloud stream disconnected", - errorMessage: - "Lost connection to the cloud run stream. Retry to reconnect.", - retryable: true, - }); - }); - - it("clears the backend-error budget after a healthy long-lived cut", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - const makeInProgressRun = () => - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }); - - mockNetFetch - .mockResolvedValueOnce(makeInProgressRun()) // bootstrap: fetchTaskRun - .mockResolvedValueOnce( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ) // bootstrap: fetchSessionLogs - .mockImplementation(() => Promise.resolve(makeInProgressRun())); - - // First connection delivers an explicit backend error frame (accruing the - // backend-error budget). Subsequent connections are healthy long-lived cuts - // (>= SSE_HEALTHY_CONNECTION_MS): each proves the stream recovered and must - // clear the backend-error budget, so it never accumulates for the run's life. - let streamCall = 0; - mockStreamFetch.mockImplementation(() => { - streamCall += 1; - if (streamCall === 1) { - return Promise.resolve( - createSseResponse('event: error\ndata: {"error":"boom"}\n\n'), - ); - } - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode('event: keepalive\ndata: {"type":"keepalive"}\n\n'), - ); - setTimeout(() => controller.error(new Error("terminated")), 65_000); - }, - }); - return Promise.resolve( - new Response(stream, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }), - ); - }); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - const getWatcher = () => - ( - service as unknown as { - watchers: Map< - string, - { - reconnectAttempts: number; - streamErrorAttempts: number; - failed: boolean; - } - >; - } - ).watchers.get("task-1:run-1"); - - // The backend error must have accrued the backend-error budget first... - await waitFor(() => (getWatcher()?.streamErrorAttempts ?? 0) >= 1, 20_000); - // ...then the healthy long-lived cut on the next connection clears it. - await vi.advanceTimersByTimeAsync(67_000 * 2); - await waitFor(() => getWatcher()?.streamErrorAttempts === 0, 20_000); - - const watcher = getWatcher(); - expect(watcher?.failed).toBe(false); - expect(watcher?.streamErrorAttempts).toBe(0); - expect(watcher?.reconnectAttempts).toBe(0); - expect( - updates.some( - (u) => - typeof u === "object" && - u !== null && - (u as { kind?: string }).kind === "error", - ), - ).toBe(false); - }); - - it("counts quick stream failures and surfaces a retryable error", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - const makeInProgressRun = () => - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }); - - mockNetFetch - .mockResolvedValueOnce(makeInProgressRun()) - .mockResolvedValueOnce( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ) - .mockImplementation(() => Promise.resolve(makeInProgressRun())); - - // Connections that fail immediately (under SSE_HEALTHY_CONNECTION_MS) are - // genuine churn and must keep counting toward the retry budget. - mockStreamFetch.mockImplementation(() => - Promise.resolve( - createSseResponse('event: error\ndata: {"error":"boom"}\n\n'), - ), - ); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => mockStreamFetch.mock.calls.length === 1); - await vi.advanceTimersByTimeAsync(70_000); - await waitFor( - () => - updates.some( - (u) => - typeof u === "object" && - u !== null && - (u as { kind?: string }).kind === "error", - ), - 10_000, - ); - - expect(updates).toContainEqual({ - taskId: "task-1", - runId: "run-1", - kind: "error", - errorTitle: "Cloud stream disconnected", - errorMessage: - "Lost connection to the cloud run stream. Retry to reconnect.", - retryable: true, - }); - }); - - it("stops the watcher without reconnecting once the run is terminal", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - let statusFetchCount = 0; - mockNetFetch.mockImplementation((input: string | Request) => { - const url = typeof input === "string" ? input : input.url; - if (url.includes("/session_logs/")) { - return Promise.resolve( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ); - } - statusFetchCount += 1; - // Bootstrap sees an active run; the post-stream status check sees terminal. - return Promise.resolve( - createJsonResponse({ - id: "run-1", - status: statusFetchCount === 1 ? "in_progress" : "completed", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: - statusFetchCount === 1 - ? "2026-01-01T00:00:00Z" - : "2026-01-01T00:00:01Z", - }), - ); - }); - - mockStreamFetch.mockImplementation(() => - Promise.resolve(createSseResponse("")), - ); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => mockStreamFetch.mock.calls.length === 1); - await vi.advanceTimersByTimeAsync(10_000); - - expect(updates).toContainEqual( - expect.objectContaining({ - taskId: "task-1", - runId: "run-1", - kind: "status", - status: "completed", - }), - ); - expect(mockStreamFetch.mock.calls.length).toBe(1); - expect( - (service as unknown as { watchers: Map }).watchers.has( - "task-1:run-1", - ), - ).toBe(false); - }); - - it("surfaces a retryable error when the backend errors even on a long-lived stream", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - const makeInProgressRun = () => - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }); - - mockNetFetch - .mockResolvedValueOnce(makeInProgressRun()) - .mockResolvedValueOnce( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ) - .mockImplementation(() => Promise.resolve(makeInProgressRun())); - - // Each connection stays open with a keepalive for 65s (> the healthy - // threshold) and only THEN emits an explicit backend `event: error` frame. - // An explicit backend error must always count toward the budget, so even a - // long-lived stream eventually surfaces the retryable disconnect error. - mockStreamFetch.mockImplementation(() => { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode('event: keepalive\ndata: {"type":"keepalive"}\n\n'), - ); - setTimeout(() => { - controller.enqueue( - encoder.encode('event: error\ndata: {"error":"boom"}\n\n'), - ); - controller.close(); - }, 65_000); - }, - }); - return Promise.resolve( - new Response(stream, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }), - ); - }); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => mockStreamFetch.mock.calls.length === 1); - // Drive >= 6 long-lived-then-backend-error cycles (65s open + backoff each). - await vi.advanceTimersByTimeAsync(65_000 * 7 + 70_000); - await waitFor( - () => - updates.some( - (u) => - typeof u === "object" && - u !== null && - (u as { kind?: string }).kind === "error", - ), - 10_000, - ); - - expect(updates).toContainEqual({ - taskId: "task-1", - runId: "run-1", - kind: "error", - errorTitle: "Cloud stream disconnected", - errorMessage: - "Lost connection to the cloud run stream. Retry to reconnect.", - retryable: true, - }); - }); - - it("treats a long-lived transport cut as healthy even with no frames received", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - const makeInProgressRun = () => - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }); - - mockNetFetch - .mockResolvedValueOnce(makeInProgressRun()) - .mockResolvedValueOnce( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ) - .mockImplementation(() => Promise.resolve(makeInProgressRun())); - - // Each connection opens but delivers NOTHING, then is transport-cut at 65s. - // Healthiness is duration-only on purpose — it must NOT depend on keepalive - // frames surviving the proxy — so even a frame-less long-lived cut is healthy - // and never exhausts the budget. - mockStreamFetch.mockImplementation(() => { - const stream = new ReadableStream({ - start(controller) { - setTimeout(() => controller.error(new Error("terminated")), 65_000); - }, - }); - return Promise.resolve( - new Response(stream, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }), - ); - }); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => mockStreamFetch.mock.calls.length === 1); - await vi.advanceTimersByTimeAsync(67_000 * 8); - await waitFor(() => mockStreamFetch.mock.calls.length >= 6, 20_000); - - expect( - updates.some( - (u) => - typeof u === "object" && - u !== null && - (u as { kind?: string }).kind === "error", - ), - ).toBe(false); - - const watcher = ( - service as unknown as { - watchers: Map; - } - ).watchers.get("task-1:run-1"); - expect(watcher?.failed).toBe(false); - expect(watcher?.reconnectAttempts).toBe(0); - }); - - it("resets the transport reconnect budget once a keepalive proves recovery", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - const makeInProgressRun = () => - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }); - - mockNetFetch - .mockResolvedValueOnce(makeInProgressRun()) - .mockResolvedValueOnce( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ) - .mockImplementation(() => Promise.resolve(makeInProgressRun())); - - // First 3 connections fail fast at the transport level (established, then - // errored immediately, no frame) and accrue reconnect attempts. The 4th - // delivers a keepalive and stays open — proving the transport recovered, so - // the accrued attempts must reset rather than carry forward into the budget. - let streamCall = 0; - const keepaliveControllerRef: { - current: ReadableStreamDefaultController | null; - } = { current: null }; - const encoder = new TextEncoder(); - mockStreamFetch.mockImplementation(() => { - streamCall += 1; - if (streamCall <= 3) { - const stream = new ReadableStream({ - start(controller) { - controller.error(new Error("terminated")); - }, - }); - return Promise.resolve( - new Response(stream, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }), - ); - } - // 4th connection stays open with no frame; the test injects the keepalive - // below so it can observe the accrued budget BEFORE the reset. - const stream = new ReadableStream({ - start(controller) { - keepaliveControllerRef.current = controller; - }, - }); - return Promise.resolve( - new Response(stream, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }), - ); - }); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - const getWatcher = () => - ( - service as unknown as { - watchers: Map; - } - ).watchers.get("task-1:run-1"); - - await waitFor(() => mockStreamFetch.mock.calls.length === 1); - // Drive the 3 fast transport failures and open the held 4th connection. - await vi.advanceTimersByTimeAsync(30_000); - await waitFor( - () => streamCall >= 4 && !!keepaliveControllerRef.current, - 20_000, - ); - - // Non-vacuous precondition: the fast failures actually accrued the budget. - expect(getWatcher()?.reconnectAttempts ?? 0).toBeGreaterThan(0); - - // A keepalive on the recovered connection must reset the transport budget. - keepaliveControllerRef.current?.enqueue( - encoder.encode('event: keepalive\ndata: {"type":"keepalive"}\n\n'), - ); - await waitFor(() => getWatcher()?.reconnectAttempts === 0, 20_000); - - const watcher = getWatcher(); - expect(watcher?.failed).toBe(false); - expect(watcher?.reconnectAttempts).toBe(0); - expect( - updates.some( - (u) => - typeof u === "object" && - u !== null && - (u as { kind?: string }).kind === "error", - ), - ).toBe(false); - }); - - it("does not let a stale backend-error count inflate a transport reconnect delay", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - const makeInProgressRun = () => - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }); - - mockNetFetch - .mockResolvedValueOnce(makeInProgressRun()) // bootstrap: fetchTaskRun - .mockResolvedValueOnce( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ) // bootstrap: fetchSessionLogs - .mockImplementation(() => Promise.resolve(makeInProgressRun())); - - // Connections 1-4 each emit a backend `event: error` frame, building the - // backend-error budget to 4 — those reconnects correctly pace on - // streamErrorAttempts. Connection 5 is held open until the test injects a - // quick TRANSPORT cut, which must pace its reconnect on the just-incremented - // transport budget (1 -> ~2s), NOT on the stale backend-error budget - // (4 -> ~16s). Math.max(both) for the delay would wrongly use the latter. - let streamCall = 0; - const transportControllerRef: { - current: ReadableStreamDefaultController | null; - } = { current: null }; - mockStreamFetch.mockImplementation(() => { - streamCall += 1; - if (streamCall <= 4) { - return Promise.resolve( - createSseResponse('event: error\ndata: {"error":"boom"}\n\n'), - ); - } - const stream = new ReadableStream({ - start(controller) { - if (streamCall === 5) { - transportControllerRef.current = controller; - } - }, - }); - return Promise.resolve( - new Response(stream, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }), - ); - }); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - const getWatcher = () => - ( - service as unknown as { - watchers: Map< - string, - { - reconnectAttempts: number; - streamErrorAttempts: number; - failed: boolean; - } - >; - } - ).watchers.get("task-1:run-1"); - - await waitFor(() => mockStreamFetch.mock.calls.length === 1); - // Drive the four backend-error reconnects (2s + 4s + 8s + 16s of backoff) - // and open the held fifth connection. - await vi.advanceTimersByTimeAsync(35_000); - await waitFor( - () => streamCall >= 5 && !!transportControllerRef.current, - 20_000, - ); - - // Non-vacuous precondition: the backend-error budget is stale-high while the - // transport budget is still zero. - expect(getWatcher()?.streamErrorAttempts).toBe(4); - expect(getWatcher()?.reconnectAttempts).toBe(0); - expect(getWatcher()?.failed).toBe(false); - - // A quick transport cut on the open fifth connection charges ONE transport - // attempt; its reconnect must wait ~2s (transport budget), not ~16s. - transportControllerRef.current?.error(new Error("terminated")); - await waitFor(() => getWatcher()?.reconnectAttempts === 1, 20_000); - expect(getWatcher()?.streamErrorAttempts).toBe(4); - - const callsBeforeProbe = mockStreamFetch.mock.calls.length; - // 5s is past the fixed ~2s transport backoff but well short of the buggy - // ~16s backend-error backoff, so the sixth connection only opens if the - // delay was paced on the transport budget. - await vi.advanceTimersByTimeAsync(5_000); - expect(mockStreamFetch.mock.calls.length).toBe(callsBeforeProbe + 1); - expect(getWatcher()?.failed).toBe(false); - }); - - it("surfaces an error instead of retrying forever when run-state fetch keeps failing after a clean stream end", async () => { - vi.useFakeTimers(); - - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - // Bootstrap succeeds (run + empty backlog); every subsequent run-state - // fetch returns 500 (a non-fatal status -> fetchTaskRun resolves null). - mockNetFetch - .mockResolvedValueOnce( - createJsonResponse({ - id: "run-1", - status: "in_progress", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - }), - ) // bootstrap: fetchTaskRun - .mockResolvedValueOnce( - createJsonResponse([], 200, { "X-Has-More": "false" }), - ) // bootstrap: fetchSessionLogs - .mockImplementation(() => - Promise.resolve(createJsonResponse({ detail: "boom" }, 500)), - ); - - // First connection is held open so bootstrap can finish; the test then - // closes it cleanly. Every later connection ends cleanly on its own, so the - // only thing that can fail is the post-stream run-state fetch (500). - let streamCall = 0; - const firstControllerRef: { - current: ReadableStreamDefaultController | null; - } = { current: null }; - mockStreamFetch.mockImplementation(() => { - streamCall += 1; - const stream = new ReadableStream({ - start(controller) { - if (streamCall === 1) { - firstControllerRef.current = controller; - } else { - controller.close(); - } - }, - }); - return Promise.resolve( - new Response(stream, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }), - ); - }); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - // Wait for bootstrap to emit its snapshot and hold the live connection open. - await waitFor( - () => - !!firstControllerRef.current && - updates.some( - (u) => - typeof u === "object" && - u !== null && - (u as { kind?: string }).kind === "snapshot", - ), - ); - - // Close the live stream cleanly: each clean end now fetches run state, which - // 500s. The reconnect must charge the budget so it eventually gives up. - firstControllerRef.current?.close(); - - // Budget is 5 attempts (2s + 4s + 8s + 16s + 30s + 30s of backoff). - await vi.advanceTimersByTimeAsync(120_000); - await waitFor( - () => - updates.some( - (u) => - typeof u === "object" && - u !== null && - (u as { kind?: string }).kind === "error", - ), - 20_000, - ); - - expect(updates).toContainEqual({ - taskId: "task-1", - runId: "run-1", - kind: "error", - errorTitle: "Cloud run state unavailable", - errorMessage: - "Could not fetch the latest cloud run state after the stream ended. Retry to reconnect.", - retryable: true, - }); - }); - - const guardedFetchStatusExpectations = [ - [ - 401, - { - errorTitle: "Cloud authentication expired", - errorMessage: "Please reauthenticate and retry the cloud run stream.", - retryable: true, - }, - ], - [ - 403, - { - errorTitle: "Cloud access denied", - errorMessage: - "You no longer have access to this cloud run. Reauthenticate and retry.", - retryable: true, - }, - ], - [ - 404, - { - errorTitle: "Cloud run not found", - errorMessage: - "This cloud run could not be found. It may have been deleted or moved.", - retryable: false, - }, - ], - ] as const; - - const guardedFetchStatusCases = ( - ["status fetch", "persisted log fetch"] as const - ).flatMap((fetchPhase) => - guardedFetchStatusExpectations.map(([status, expectedError]) => ({ - fetchPhase, - status, - expectedError, - })), - ); - - it.each(guardedFetchStatusCases)( - "fails the watcher when $fetchPhase returns $status", - async ({ fetchPhase, status, expectedError }) => { - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - if (fetchPhase === "status fetch") { - mockNetFetch.mockResolvedValueOnce( - createJsonResponse({ detail: "Access denied" }, status), - ); - } else { - mockNetFetch - .mockResolvedValueOnce( - createJsonResponse({ - id: "run-1", - status: "completed", - stage: null, - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - completed_at: "2026-01-01T00:00:01Z", - }), - ) - .mockResolvedValueOnce( - createJsonResponse({ detail: "Access denied" }, status), - ); - } - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => updates.length === 1); - - expect(mockStreamFetch).not.toHaveBeenCalled(); - expect(updates).toContainEqual({ - taskId: "task-1", - runId: "run-1", - kind: "error", - ...expectedError, - }); - }, - ); - - it("loads paginated persisted logs once for an already terminal run", async () => { - const updates: unknown[] = []; - service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); - - mockNetFetch - .mockResolvedValueOnce( - createJsonResponse({ - id: "run-1", - status: "completed", - stage: "build", - output: null, - error_message: null, - branch: "main", - updated_at: "2026-01-01T00:00:00Z", - completed_at: "2026-01-01T00:00:00Z", - }), - ) - .mockResolvedValueOnce( - createJsonResponse( - [ - { - type: "notification", - timestamp: "2026-01-01T00:00:01Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "done-1", - }, - }, - }, - ], - 200, - { "X-Has-More": "true" }, - ), - ) - .mockResolvedValueOnce( - createJsonResponse( - [ - { - type: "notification", - timestamp: "2026-01-01T00:00:02Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "done-2", - }, - }, - }, - ], - 200, - { "X-Has-More": "false" }, - ), - ); - - service.watch({ - taskId: "task-1", - runId: "run-1", - apiHost: "https://app.example.com", - teamId: 2, - }); - - await waitFor(() => updates.length >= 1); - - expect(updates).toEqual([ - { - taskId: "task-1", - runId: "run-1", - kind: "snapshot", - newEntries: [ - { - type: "notification", - timestamp: "2026-01-01T00:00:01Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "done-1", - }, - }, - }, - { - type: "notification", - timestamp: "2026-01-01T00:00:02Z", - notification: { - jsonrpc: "2.0", - method: "_posthog/console", - params: { - sessionId: "run-1", - level: "info", - message: "done-2", - }, - }, - }, - ], - totalEntryCount: 2, - status: "completed", - stage: "build", - output: null, - errorMessage: null, - branch: "main", - }, - ]); - expect(mockNetFetch).toHaveBeenCalledTimes(3); - }); -}); diff --git a/apps/code/src/main/services/cloud-task/service.ts b/apps/code/src/main/services/cloud-task/service.ts deleted file mode 100644 index 59716b068c..0000000000 --- a/apps/code/src/main/services/cloud-task/service.ts +++ /dev/null @@ -1,1336 +0,0 @@ -import type { CloudTaskPermissionRequestUpdate } from "@shared/types"; -import type { StoredLogEntry } from "@shared/types/session-events"; -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 { - CloudTaskEvent, - type CloudTaskEvents, - isTerminalStatus, - type SendCommandInput, - type SendCommandOutput, - type TaskRunStatus, - type WatchInput, -} 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; -const SSE_RECONNECT_MAX_DELAY_MS = 30_000; -const SSE_HEALTHY_CONNECTION_MS = 60_000; -const EVENT_BATCH_FLUSH_MS = 16; -const EVENT_BATCH_MAX_SIZE = 50; -const SESSION_LOG_PAGE_LIMIT = 5_000; - -interface SessionLogsPage { - entries: StoredLogEntry[]; - hasMore: boolean; -} - -interface CloudTaskConnectionError { - title: string; - message: string; - retryable: boolean; - autoRetry?: boolean; -} - -class CloudTaskStreamError extends Error { - constructor( - message: string, - public readonly details: CloudTaskConnectionError, - public readonly status?: number, - ) { - super(message); - this.name = "CloudTaskStreamError"; - } -} - -class BackendStreamError extends Error { - constructor(message: string) { - super(message); - this.name = "BackendStreamError"; - } -} - -interface TaskRunResponse { - id: string; - status: TaskRunStatus; - stage?: string | null; - output?: Record | null; - error_message?: string | null; - branch?: string | null; - updated_at?: string; - completed_at?: string | null; -} - -interface TaskRunStateEvent { - type: "task_run_state"; - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - error_message?: string | null; - branch?: string | null; - updated_at?: string | null; - completed_at?: string | null; -} - -interface WatcherState { - taskId: string; - runId: string; - apiHost: string; - teamId: number; - subscriberCount: number; - sseAbortController: AbortController | null; - reconnectTimeoutId: ReturnType | null; - batchFlushTimeoutId: ReturnType | null; - pendingLogEntries: StoredLogEntry[]; - totalEntryCount: number; - reconnectAttempts: number; - streamErrorAttempts: number; - cumulativeReconnectAttempts: number; - lastEventId: string | null; - lastStatus: TaskRunStatus | null; - lastStage: string | null; - lastOutput: Record | null; - lastErrorMessage: string | null; - lastBranch: string | null; - lastStatusUpdatedAt: string | null; - isBootstrapping: boolean; - hasEmittedSnapshot: boolean; - bufferedLogBatches: StoredLogEntry[][]; - emittedLogEntries: StoredLogEntry[]; - failed: boolean; - needsPostBootstrapReconnect: boolean; - needsStopAfterBootstrap: boolean; -} - -function watcherKey(taskId: string, runId: string): string { - return `${taskId}:${runId}`; -} - -function isTaskRunStateEvent(data: unknown): data is TaskRunStateEvent { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "task_run_state" - ); -} - -interface SseErrorEventData { - error: string; -} - -function isSseErrorEvent(data: unknown): data is SseErrorEventData { - return ( - typeof data === "object" && - data !== null && - "error" in data && - typeof (data as SseErrorEventData).error === "string" - ); -} - -interface PermissionRequestEventData { - type: "permission_request"; - requestId: string; - toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; - options: CloudTaskPermissionRequestUpdate["options"]; -} - -function isPermissionRequestEvent( - data: unknown, -): data is PermissionRequestEventData { - return ( - typeof data === "object" && - data !== null && - (data as { type?: string }).type === "permission_request" && - typeof (data as { requestId?: string }).requestId === "string" - ); -} - -function createStreamStatusError(status: number): CloudTaskStreamError { - switch (status) { - case 401: - return new CloudTaskStreamError( - "Cloud authentication expired", - { - title: "Cloud authentication expired", - message: "Please reauthenticate and retry the cloud run stream.", - retryable: true, - autoRetry: false, - }, - status, - ); - case 403: - return new CloudTaskStreamError( - "Cloud access denied", - { - title: "Cloud access denied", - message: - "You no longer have access to this cloud run. Reauthenticate and retry.", - retryable: true, - autoRetry: false, - }, - status, - ); - case 404: - return new CloudTaskStreamError( - "Cloud run not found", - { - title: "Cloud run not found", - message: - "This cloud run could not be found. It may have been deleted or moved.", - retryable: false, - autoRetry: false, - }, - status, - ); - case 406: - return new CloudTaskStreamError( - "Cloud stream unavailable", - { - title: "Cloud stream unavailable", - message: - "The backend rejected the live stream request. Restart the backend and retry.", - retryable: true, - autoRetry: false, - }, - status, - ); - default: - return new CloudTaskStreamError( - `Stream request failed with status ${status}`, - { - title: "Cloud stream failed", - message: `The cloud stream request failed with status ${status}. Retry to reconnect.`, - retryable: true, - autoRetry: true, - }, - status, - ); - } -} - -function shouldFailWatcherForFetchStatus(status: number): boolean { - return status === 401 || status === 403 || status === 404; -} - -@injectable() -export class CloudTaskService extends TypedEventEmitter { - private watchers = new Map(); - - constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, - ) { - super(); - } - - watch(input: WatchInput): void { - const key = watcherKey(input.taskId, input.runId); - - const existing = this.watchers.get(key); - if (existing) { - existing.subscriberCount++; - log.info("Cloud task watcher subscriber added", { - key, - subscribers: existing.subscriberCount, - }); - void this.emitCurrentSnapshot(key); - return; - } - - this.startWatcher(input, 1); - } - - unwatch(taskId: string, runId: string): void { - const key = watcherKey(taskId, runId); - const watcher = this.watchers.get(key); - if (!watcher) { - return; - } - - watcher.subscriberCount--; - if (watcher.subscriberCount <= 0) { - this.stopWatcher(key); - } else { - log.info("Cloud task watcher subscriber removed", { - key, - subscribers: watcher.subscriberCount, - }); - } - } - - retry(taskId: string, runId: string): void { - const key = watcherKey(taskId, runId); - const watcher = this.watchers.get(key); - if (!watcher) return; - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - watcher.reconnectTimeoutId = null; - } - - watcher.sseAbortController?.abort(); - watcher.sseAbortController = null; - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - watcher.reconnectAttempts = 0; - watcher.streamErrorAttempts = 0; - watcher.cumulativeReconnectAttempts = 0; - watcher.failed = false; - watcher.pendingLogEntries = []; - watcher.bufferedLogBatches = []; - watcher.needsPostBootstrapReconnect = false; - watcher.needsStopAfterBootstrap = false; - - log.info("Retrying cloud task watcher", { - key, - hasSnapshot: watcher.hasEmittedSnapshot, - }); - - if (!watcher.hasEmittedSnapshot) { - watcher.lastEventId = null; - watcher.totalEntryCount = 0; - watcher.isBootstrapping = false; - void this.bootstrapWatcher(key); - return; - } - - void this.connectSse(key, { startLatest: !watcher.lastEventId }); - } - - async sendCommand(input: SendCommandInput): Promise { - const url = `${input.apiHost}/api/projects/${input.teamId}/tasks/${input.taskId}/runs/${input.runId}/command/`; - const body = { - jsonrpc: "2.0", - method: input.method, - params: input.params ?? {}, - id: `posthog-code-${Date.now()}`, - }; - - try { - const response = await this.authService.authenticatedFetch(fetch, url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - let errorMessage = `Command failed with status ${response.status}`; - try { - const errorJson = JSON.parse(errorText); - if (errorJson.error?.message) { - errorMessage = errorJson.error.message; - } else if (errorJson.error) { - errorMessage = - typeof errorJson.error === "string" - ? errorJson.error - : JSON.stringify(errorJson.error); - } - } catch { - if (errorText) errorMessage = errorText; - } - - log.warn("Cloud task command failed", { - taskId: input.taskId, - runId: input.runId, - method: input.method, - status: response.status, - error: errorMessage, - }); - return { success: false, error: errorMessage }; - } - - const data = await response.json(); - - if (data.error) { - log.warn("Cloud task command returned error", { - taskId: input.taskId, - method: input.method, - error: data.error, - }); - return { - success: false, - error: data.error.message ?? JSON.stringify(data.error), - }; - } - - log.info("Cloud task command sent", { - taskId: input.taskId, - runId: input.runId, - method: input.method, - }); - - return { success: true, result: data.result }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Unknown error"; - log.error("Cloud task command error", { - taskId: input.taskId, - method: input.method, - error: errorMessage, - }); - return { success: false, error: errorMessage }; - } - } - - @preDestroy() - unwatchAll(): void { - for (const key of [...this.watchers.keys()]) { - this.stopWatcher(key); - } - } - - private startWatcher(input: WatchInput, subscriberCount: number): void { - const key = watcherKey(input.taskId, input.runId); - - const watcher: WatcherState = { - taskId: input.taskId, - runId: input.runId, - apiHost: input.apiHost, - teamId: input.teamId, - subscriberCount, - sseAbortController: null, - reconnectTimeoutId: null, - batchFlushTimeoutId: null, - pendingLogEntries: [], - totalEntryCount: 0, - reconnectAttempts: 0, - streamErrorAttempts: 0, - cumulativeReconnectAttempts: 0, - lastEventId: null, - lastStatus: null, - lastStage: null, - lastOutput: null, - lastErrorMessage: null, - lastBranch: null, - lastStatusUpdatedAt: null, - isBootstrapping: false, - hasEmittedSnapshot: false, - bufferedLogBatches: [], - emittedLogEntries: [], - failed: false, - needsPostBootstrapReconnect: false, - needsStopAfterBootstrap: false, - }; - - this.watchers.set(key, watcher); - log.info("Cloud task watcher started", { key }); - void this.bootstrapWatcher(key); - } - - private stopWatcher(key: string): void { - const watcher = this.watchers.get(key); - if (!watcher) return; - - watcher.sseAbortController?.abort(); - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - watcher.reconnectTimeoutId = null; - } - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - this.flushLogBatch(key); - this.watchers.delete(key); - log.info("Cloud task watcher stopped", { key }); - } - - private async bootstrapWatcher(key: string): Promise { - const watcher = this.watchers.get(key); - if (!watcher) return; - - watcher.failed = false; - watcher.needsPostBootstrapReconnect = false; - watcher.needsStopAfterBootstrap = false; - - const run = await this.fetchTaskRun(watcher); - const currentWatcher = this.watchers.get(key); - if (!currentWatcher || currentWatcher !== watcher) return; - if (watcher.failed) return; - - if (!run) { - this.failWatcher(key, { - title: "Failed to load cloud run", - message: "Could not fetch the cloud run state. Retry to reconnect.", - retryable: true, - }); - return; - } - - this.applyTaskRunState(watcher, run); - - if (isTerminalStatus(run.status)) { - const historicalEntries = await this.fetchAllSessionLogs(watcher); - const terminalWatcher = this.watchers.get(key); - if (!terminalWatcher || terminalWatcher !== watcher) return; - if (watcher.failed) return; - if (!historicalEntries) { - this.failWatcher(key, { - title: "Failed to load task history", - message: - "Could not load the persisted cloud task logs. Retry to reconnect.", - retryable: true, - }); - return; - } - - watcher.totalEntryCount = historicalEntries.length; - watcher.hasEmittedSnapshot = true; - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "snapshot", - newEntries: historicalEntries, - totalEntryCount: watcher.totalEntryCount, - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - }); - this.stopWatcher(key); - return; - } - - watcher.isBootstrapping = true; - watcher.bufferedLogBatches = []; - void this.connectSse(key, { startLatest: true }); - - const historicalEntries = await this.fetchAllSessionLogs(watcher); - const bootstrappingWatcher = this.watchers.get(key); - if (!bootstrappingWatcher || bootstrappingWatcher !== watcher) return; - if (watcher.failed) return; - if (!historicalEntries) { - this.failWatcher(key, { - title: "Failed to load cloud run history", - message: - "Could not load the existing cloud run logs. Retry to reconnect.", - retryable: true, - }); - return; - } - - // Flush any pending live entries into the bootstrap buffer before snapshot. - this.flushLogBatch(key); - - watcher.totalEntryCount = historicalEntries.length; - watcher.hasEmittedSnapshot = true; - - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "snapshot", - newEntries: historicalEntries, - totalEntryCount: watcher.totalEntryCount, - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - }); - - watcher.isBootstrapping = false; - this.drainBufferedLogBatches(key, historicalEntries); - - if (watcher.failed) { - return; - } - - if ( - watcher.needsStopAfterBootstrap || - isTerminalStatus(watcher.lastStatus) - ) { - watcher.needsStopAfterBootstrap = false; - this.stopWatcher(key); - return; - } - - if (watcher.needsPostBootstrapReconnect) { - watcher.needsPostBootstrapReconnect = false; - this.scheduleReconnect(key, undefined, { countAttempt: false }); - } - - void this.verifyPostBootstrapStatus(key); - } - - private async verifyPostBootstrapStatus(key: string): Promise { - const watcher = this.watchers.get(key); - if (!watcher) return; - if (isTerminalStatus(watcher.lastStatus)) return; - - const run = await this.fetchTaskRun(watcher); - const currentWatcher = this.watchers.get(key); - if (!currentWatcher || currentWatcher !== watcher) return; - if (!run) return; - - if (!this.applyTaskRunState(watcher, run)) return; - if (isTerminalStatus(watcher.lastStatus)) return; - - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "status", - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - }); - } - - private async connectSse( - key: string, - options?: { startLatest?: boolean }, - ): Promise { - const watcher = this.watchers.get(key); - if (!watcher) return; - - const controller = new AbortController(); - watcher.sseAbortController = controller; - - const url = new URL( - `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/stream/`, - ); - if (options?.startLatest && !watcher.lastEventId) { - url.searchParams.set("start", "latest"); - } - const headers: Record = { - Accept: "text/event-stream", - }; - if (watcher.lastEventId) { - headers["Last-Event-ID"] = watcher.lastEventId; - } - - const parser = new SseEventParser(); - const decoder = new TextDecoder(); - - // Tracks whether the response body was opened and how long it stayed open, - // so a long-lived connection cut by transport churn isn't penalized as a - // failed reconnect attempt (see SSE_HEALTHY_CONNECTION_MS). - let connectedAt = 0; - let streamWasEstablished = false; - - try { - const response = await this.authService.authenticatedFetch( - fetch, - url.toString(), - { - method: "GET", - headers, - signal: controller.signal, - }, - ); - - if (!response.ok) { - throw createStreamStatusError(response.status); - } - - if (!response.body) { - throw new Error("Stream response did not include a body"); - } - - connectedAt = Date.now(); - streamWasEstablished = true; - - const reader = response.body.getReader(); - - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - - if (!value) { - continue; - } - - const chunk = decoder.decode(value, { stream: true }); - const events = parser.parse(chunk); - for (const event of events) { - this.handleSseEvent(key, event); - } - } - - const trailingEvents = parser.parse(decoder.decode()); - for (const event of trailingEvents) { - this.handleSseEvent(key, event); - } - - this.flushLogBatch(key); - - if (controller.signal.aborted) { - return; - } - - await this.handleStreamCompletion(key, { reconnectIfNonTerminal: true }); - } catch (error) { - this.flushLogBatch(key); - - if (controller.signal.aborted) { - return; - } - - if ( - error instanceof CloudTaskStreamError && - error.details.autoRetry === false - ) { - this.failWatcher(key, error.details); - return; - } - - const errorMessage = - error instanceof Error ? error.message : "Unknown stream error"; - - const isBackendError = error instanceof BackendStreamError; - const wasHealthyStream = - !isBackendError && - streamWasEstablished && - Date.now() - connectedAt >= SSE_HEALTHY_CONNECTION_MS; - - const watcher = this.watchers.get(key); - if (watcher) { - if (isBackendError) { - watcher.streamErrorAttempts += 1; - } else if (wasHealthyStream) { - watcher.streamErrorAttempts = 0; - } - } - - log.warn("Cloud task stream error", { - key, - error: errorMessage, - wasHealthyStream, - isBackendError, - }); - await this.handleStreamCompletion(key, { - reconnectIfNonTerminal: true, - reconnectError: error, - countReconnectAttempt: !isBackendError && !wasHealthyStream, - }); - } finally { - const currentWatcher = this.watchers.get(key); - if (currentWatcher?.sseAbortController === controller) { - currentWatcher.sseAbortController = null; - } - } - } - - private handleSseEvent(key: string, event: SseEvent): void { - const watcher = this.watchers.get(key); - if (!watcher || watcher.failed) return; - - if (event.id) { - watcher.lastEventId = event.id; - } - - if (event.event === "error") { - const message = isSseErrorEvent(event.data) - ? event.data.error - : "Unknown stream error"; - throw new BackendStreamError(message); - } - - // A keepalive or real event proves the transport recovered, so clear the - // transport reconnect budget. A keepalive stops here: it does NOT clear the - // backend-error budget, since it doesn't prove the stream itself produced - // data. - watcher.reconnectAttempts = 0; - - if ( - event.event === "keepalive" || - (typeof event.data === "object" && - event.data !== null && - "type" in event.data && - event.data.type === "keepalive") - ) { - return; - } - - // A real data event proves the stream materialized; clear the backend-error - // and cumulative budgets too. - watcher.streamErrorAttempts = 0; - watcher.cumulativeReconnectAttempts = 0; - - if (isTaskRunStateEvent(event.data)) { - if (this.applyTaskRunState(watcher, event.data)) { - if (!watcher.isBootstrapping && !isTerminalStatus(watcher.lastStatus)) { - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "status", - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - }); - } - } - return; - } - - if (isPermissionRequestEvent(event.data)) { - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "permission_request" as const, - requestId: event.data.requestId, - toolCall: event.data.toolCall, - options: event.data.options, - }); - return; - } - - watcher.pendingLogEntries.push(event.data as StoredLogEntry); - if (watcher.pendingLogEntries.length >= EVENT_BATCH_MAX_SIZE) { - this.flushLogBatch(key); - return; - } - - if (!watcher.batchFlushTimeoutId) { - watcher.batchFlushTimeoutId = setTimeout(() => { - watcher.batchFlushTimeoutId = null; - this.flushLogBatch(key); - }, EVENT_BATCH_FLUSH_MS); - } - } - - private flushLogBatch(key: string): void { - const watcher = this.watchers.get(key); - if (!watcher || watcher.pendingLogEntries.length === 0) return; - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - const entries = watcher.pendingLogEntries; - watcher.pendingLogEntries = []; - - if (watcher.isBootstrapping) { - watcher.bufferedLogBatches.push(entries); - return; - } - - watcher.totalEntryCount += entries.length; - this.rememberEmittedLogEntries(watcher, entries); - - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "logs", - newEntries: entries, - totalEntryCount: watcher.totalEntryCount, - }); - } - - private drainBufferedLogBatches( - key: string, - historicalEntries: StoredLogEntry[], - ): void { - const watcher = this.watchers.get(key); - if (!watcher || watcher.bufferedLogBatches.length === 0) return; - - // Content-based dedup because SSE IDs (Redis stream IDs) don't exist in - // the S3-backed historical entries — the JSON payload is the only shared key - const historicalCounts = new Map(); - for (const entry of historicalEntries) { - const serialized = JSON.stringify(entry); - historicalCounts.set( - serialized, - (historicalCounts.get(serialized) ?? 0) + 1, - ); - } - - for (const entries of watcher.bufferedLogBatches) { - const dedupedEntries = entries.filter((entry) => { - const serialized = JSON.stringify(entry); - const remaining = historicalCounts.get(serialized) ?? 0; - if (remaining <= 0) { - return true; - } - - historicalCounts.set(serialized, remaining - 1); - return false; - }); - - if (dedupedEntries.length === 0) { - continue; - } - - watcher.totalEntryCount += dedupedEntries.length; - this.rememberEmittedLogEntries(watcher, dedupedEntries); - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "logs", - newEntries: dedupedEntries, - totalEntryCount: watcher.totalEntryCount, - }); - } - - watcher.bufferedLogBatches = []; - } - - private rememberEmittedLogEntries( - watcher: WatcherState, - entries: StoredLogEntry[], - ): void { - watcher.emittedLogEntries.push(...entries); - } - - private mergeHistoricalAndEmittedEntries( - historicalEntries: StoredLogEntry[], - emittedEntries: StoredLogEntry[], - ): { - snapshotEntries: StoredLogEntry[]; - missingEmittedEntries: StoredLogEntry[]; - } { - if (emittedEntries.length === 0) { - return { snapshotEntries: historicalEntries, missingEmittedEntries: [] }; - } - - const historicalCounts = new Map(); - for (const entry of historicalEntries) { - const serialized = JSON.stringify(entry); - historicalCounts.set( - serialized, - (historicalCounts.get(serialized) ?? 0) + 1, - ); - } - - const missingEmittedEntries = emittedEntries.filter((entry) => { - const serialized = JSON.stringify(entry); - const remaining = historicalCounts.get(serialized) ?? 0; - if (remaining <= 0) { - return true; - } - - historicalCounts.set(serialized, remaining - 1); - return false; - }); - - return { - snapshotEntries: [...historicalEntries, ...missingEmittedEntries], - missingEmittedEntries, - }; - } - - private async emitCurrentSnapshot(key: string): Promise { - const watcher = this.watchers.get(key); - if (!watcher || watcher.failed) return; - - const historicalEntries = await this.fetchAllSessionLogs(watcher); - const currentWatcher = this.watchers.get(key); - if (!currentWatcher || currentWatcher !== watcher || watcher.failed) { - return; - } - - if (!historicalEntries) { - log.warn("Cloud task snapshot replay failed", { - taskId: watcher.taskId, - runId: watcher.runId, - }); - return; - } - - const { snapshotEntries, missingEmittedEntries } = - this.mergeHistoricalAndEmittedEntries( - historicalEntries, - watcher.emittedLogEntries, - ); - watcher.emittedLogEntries = missingEmittedEntries; - if (snapshotEntries.length > watcher.totalEntryCount) { - watcher.totalEntryCount = snapshotEntries.length; - } - - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "snapshot", - newEntries: snapshotEntries, - totalEntryCount: snapshotEntries.length, - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - }); - } - - private failWatcher(key: string, error: CloudTaskConnectionError): void { - const watcher = this.watchers.get(key); - if (!watcher) return; - - watcher.failed = true; - watcher.isBootstrapping = false; - watcher.pendingLogEntries = []; - watcher.bufferedLogBatches = []; - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - watcher.reconnectTimeoutId = null; - } - - if (watcher.batchFlushTimeoutId) { - clearTimeout(watcher.batchFlushTimeoutId); - watcher.batchFlushTimeoutId = null; - } - - watcher.sseAbortController?.abort(); - watcher.sseAbortController = null; - - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "error", - errorTitle: error.title, - errorMessage: error.message, - retryable: error.retryable, - }); - } - - private scheduleReconnect( - key: string, - error?: unknown, - options: { countAttempt?: boolean } = {}, - ): void { - const watcher = this.watchers.get(key); - if (!watcher || watcher.failed || isTerminalStatus(watcher.lastStatus)) { - return; - } - - if (watcher.reconnectTimeoutId) { - clearTimeout(watcher.reconnectTimeoutId); - } - - // Cumulative counter bounds runaway loops that clean-EOF (countAttempt=false) - // and would otherwise dodge `reconnectAttempts`. - watcher.cumulativeReconnectAttempts += 1; - const countAttempt = options.countAttempt ?? true; - if (countAttempt) { - watcher.reconnectAttempts += 1; - } - - if ( - watcher.cumulativeReconnectAttempts > MAX_CUMULATIVE_RECONNECT_ATTEMPTS - ) { - this.failWatcher(key, { - title: "Cloud run unreachable", - message: - "Could not maintain a connection to the cloud run after many attempts. Click retry once the issue is resolved.", - retryable: true, - }); - return; - } - - // The watcher fails once either budget is exhausted: transport reconnect - // failures or backend stream-error frames. - const attemptCount = Math.max( - watcher.reconnectAttempts, - watcher.streamErrorAttempts, - ); - if (attemptCount > MAX_SSE_RECONNECT_ATTEMPTS) { - const details = - error instanceof CloudTaskStreamError - ? error.details - : { - title: "Cloud stream disconnected", - message: - "Lost connection to the cloud run stream. Retry to reconnect.", - retryable: true, - }; - this.failWatcher(key, details); - return; - } - - const backoffAttempts = - error instanceof BackendStreamError - ? watcher.streamErrorAttempts - : watcher.reconnectAttempts; - const delay = Math.min( - SSE_RECONNECT_BASE_DELAY_MS * 2 ** Math.max(backoffAttempts - 1, 0), - SSE_RECONNECT_MAX_DELAY_MS, - ); - - watcher.reconnectTimeoutId = setTimeout(() => { - const currentWatcher = this.watchers.get(key); - if (!currentWatcher) return; - currentWatcher.reconnectTimeoutId = null; - void this.connectSse(key, { - startLatest: - currentWatcher.isBootstrapping || currentWatcher.hasEmittedSnapshot, - }); - }, delay); - } - - private async handleStreamCompletion( - key: string, - options: { - reconnectIfNonTerminal: boolean; - reconnectError?: unknown; - countReconnectAttempt?: boolean; - }, - ): Promise { - const watcher = this.watchers.get(key); - if (!watcher) return; - - const { reconnectIfNonTerminal } = options; - const run = await this.fetchTaskRun(watcher); - const currentWatcher = this.watchers.get(key); - if (!currentWatcher || currentWatcher !== watcher) return; - if (watcher.failed) return; - - if (watcher.isBootstrapping) { - if (!run) { - watcher.needsPostBootstrapReconnect = true; - return; - } - - this.applyTaskRunState(watcher, run); - if (isTerminalStatus(watcher.lastStatus) || !reconnectIfNonTerminal) { - watcher.needsStopAfterBootstrap = true; - } else { - watcher.needsPostBootstrapReconnect = true; - } - return; - } - - if (!run) { - this.scheduleReconnect( - key, - new CloudTaskStreamError("Failed to fetch terminal cloud run state", { - title: "Cloud run state unavailable", - message: - "Could not fetch the latest cloud run state after the stream ended. Retry to reconnect.", - retryable: true, - }), - { countAttempt: options.countReconnectAttempt ?? true }, - ); - return; - } - - const stateChanged = this.applyTaskRunState(watcher, run); - - if (!isTerminalStatus(watcher.lastStatus) && reconnectIfNonTerminal) { - if (stateChanged) { - // Polled progress proves the run is alive — reset both budgets. - watcher.reconnectAttempts = 0; - watcher.cumulativeReconnectAttempts = 0; - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "status", - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - }); - } - log.warn("Cloud task stream ended before terminal status", { - key, - status: watcher.lastStatus, - }); - this.scheduleReconnect(key, options.reconnectError, { - countAttempt: options.countReconnectAttempt ?? false, - }); - return; - } - - // Always emit the latest status before stopping. Terminal states are - // intentionally deferred until stream completion; clean EOFs can also mean - // the backend has no more stream events even when the run status remains active. - this.emit(CloudTaskEvent.Update, { - taskId: watcher.taskId, - runId: watcher.runId, - kind: "status", - status: watcher.lastStatus ?? undefined, - stage: watcher.lastStage, - output: watcher.lastOutput, - errorMessage: watcher.lastErrorMessage, - branch: watcher.lastBranch, - }); - - this.stopWatcher(key); - } - - private applyTaskRunState( - watcher: WatcherState, - run: - | Pick< - TaskRunResponse, - | "status" - | "stage" - | "output" - | "error_message" - | "branch" - | "updated_at" - > - | TaskRunStateEvent, - ): boolean { - const updatedAt = run.updated_at ?? null; - if ( - updatedAt && - watcher.lastStatusUpdatedAt && - Date.parse(updatedAt) <= Date.parse(watcher.lastStatusUpdatedAt) - ) { - return false; - } - - const nextStatus = run.status ?? watcher.lastStatus; - const nextStage = run.stage ?? null; - const nextOutput = run.output ?? null; - const nextErrorMessage = run.error_message ?? null; - const nextBranch = run.branch ?? null; - - const changed = - nextStatus !== watcher.lastStatus || - nextStage !== watcher.lastStage || - JSON.stringify(nextOutput) !== JSON.stringify(watcher.lastOutput) || - nextErrorMessage !== watcher.lastErrorMessage || - nextBranch !== watcher.lastBranch; - - watcher.lastStatus = nextStatus ?? null; - watcher.lastStage = nextStage; - watcher.lastOutput = nextOutput; - watcher.lastErrorMessage = nextErrorMessage; - watcher.lastBranch = nextBranch; - if (updatedAt) { - watcher.lastStatusUpdatedAt = updatedAt; - } - - return changed; - } - - private async fetchSessionLogsPage( - watcher: WatcherState, - offset: number, - ): Promise { - const url = new URL( - `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/session_logs/`, - ); - url.searchParams.set("limit", SESSION_LOG_PAGE_LIMIT.toString()); - url.searchParams.set("offset", offset.toString()); - - try { - const authedResponse = await this.authService.authenticatedFetch( - fetch, - url.toString(), - { - method: "GET", - }, - ); - - if (!authedResponse.ok) { - log.warn("Cloud task session logs fetch failed", { - status: authedResponse.status, - taskId: watcher.taskId, - runId: watcher.runId, - offset, - }); - if (shouldFailWatcherForFetchStatus(authedResponse.status)) { - this.failWatcher( - watcherKey(watcher.taskId, watcher.runId), - createStreamStatusError(authedResponse.status).details, - ); - } - return null; - } - - const raw = await authedResponse.text(); - return { - entries: JSON.parse(raw) as StoredLogEntry[], - hasMore: authedResponse.headers.get("X-Has-More") === "true", - }; - } catch (error) { - log.warn("Cloud task session logs fetch error", { - taskId: watcher.taskId, - runId: watcher.runId, - offset, - error, - }); - return null; - } - } - - private async fetchAllSessionLogs( - watcher: WatcherState, - ): Promise { - const entries: StoredLogEntry[] = []; - let offset = 0; - - while (true) { - const page = await this.fetchSessionLogsPage(watcher, offset); - if (!page) { - return null; - } - - for (const entry of page.entries) { - entries.push(entry); - } - if (!page.hasMore || page.entries.length === 0) { - return entries; - } - - offset += page.entries.length; - } - } - - private async fetchTaskRun( - watcher: WatcherState, - ): Promise { - 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", - }, - ); - - if (!authedResponse.ok) { - log.warn("Cloud task status fetch failed", { - status: authedResponse.status, - taskId: watcher.taskId, - runId: watcher.runId, - }); - if (shouldFailWatcherForFetchStatus(authedResponse.status)) { - this.failWatcher( - watcherKey(watcher.taskId, watcher.runId), - createStreamStatusError(authedResponse.status).details, - ); - } - return null; - } - - return (await authedResponse.json()) as TaskRunResponse; - } catch (error) { - log.warn("Cloud task status fetch error", { - taskId: watcher.taskId, - runId: watcher.runId, - error, - }); - return null; - } - } -} diff --git a/apps/code/src/main/services/connectivity/service.test.ts b/apps/code/src/main/services/connectivity/service.test.ts deleted file mode 100644 index e80d8d36ee..0000000000 --- a/apps/code/src/main/services/connectivity/service.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ConnectivityEvent } from "./schemas"; - -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; - - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - mockFetch.mockResolvedValue(ok()); - vi.stubGlobal("fetch", mockFetch); - - service = new ConnectivityService(); - }); - - afterEach(() => { - service.stopPolling(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - describe("init", () => { - it("goes online after a successful HEAD check", async () => { - mockFetch.mockResolvedValue(ok(204)); - - service.init(); - await vi.advanceTimersByTimeAsync(0); - - expect(service.getStatus()).toEqual({ isOnline: true }); - expect(mockFetch).toHaveBeenCalledWith( - "https://www.google.com/generate_204", - expect.objectContaining({ method: "HEAD" }), - ); - }); - - it("goes offline when the HEAD check throws", async () => { - mockFetch.mockImplementation(offline); - - service.init(); - await vi.advanceTimersByTimeAsync(0); - - expect(service.getStatus()).toEqual({ isOnline: false }); - }); - }); - - describe("checkNow", () => { - it("returns online when HEAD succeeds", async () => { - mockFetch.mockResolvedValue(ok(204)); - service.init(); - await vi.advanceTimersByTimeAsync(0); - - const result = await service.checkNow(); - expect(result).toEqual({ isOnline: true }); - }); - - it("returns offline when HEAD rejects", async () => { - mockFetch.mockRejectedValue(new Error("Network error")); - service.init(); - await vi.advanceTimersByTimeAsync(0); - - const result = await service.checkNow(); - expect(result).toEqual({ isOnline: false }); - }); - - it("returns offline when HEAD returns a non-ok non-204 response", async () => { - mockFetch.mockResolvedValue(notOk(500)); - service.init(); - await vi.advanceTimersByTimeAsync(0); - - const result = await service.checkNow(); - expect(result).toEqual({ isOnline: false }); - }); - }); - - describe("status change events", () => { - it("emits when going offline", async () => { - mockFetch.mockResolvedValue(ok(204)); - service.init(); - await vi.advanceTimersByTimeAsync(0); - - const handler = vi.fn(); - service.on(ConnectivityEvent.StatusChange, handler); - - mockFetch.mockRejectedValue(new Error("offline")); - await vi.advanceTimersByTimeAsync(3000); - - expect(handler).toHaveBeenCalledWith({ isOnline: false }); - }); - - it("emits when coming back online", async () => { - mockFetch.mockRejectedValue(new Error("offline")); - service.init(); - await vi.advanceTimersByTimeAsync(0); - - const handler = vi.fn(); - service.on(ConnectivityEvent.StatusChange, handler); - - mockFetch.mockResolvedValue(ok(204)); - await vi.advanceTimersByTimeAsync(3000); - - expect(handler).toHaveBeenCalledWith({ isOnline: true }); - }); - - it("does not emit when status is unchanged", async () => { - mockFetch.mockResolvedValue(ok(204)); - service.init(); - await vi.advanceTimersByTimeAsync(0); - - const handler = vi.fn(); - service.on(ConnectivityEvent.StatusChange, handler); - - await vi.advanceTimersByTimeAsync(3000); - - expect(handler).not.toHaveBeenCalled(); - }); - }); - - describe("HTTP verification", () => { - it("accepts 204 status as success", async () => { - mockFetch.mockResolvedValue({ ok: false, status: 204 }); - service.init(); - await vi.advanceTimersByTimeAsync(0); - - const result = await service.checkNow(); - expect(result).toEqual({ isOnline: true }); - }); - - it("accepts 200 status as success", async () => { - mockFetch.mockResolvedValue(ok(200)); - service.init(); - await vi.advanceTimersByTimeAsync(0); - - const result = await service.checkNow(); - expect(result).toEqual({ isOnline: true }); - }); - }); - - describe("polling", () => { - it("polls periodically after init", async () => { - mockFetch.mockResolvedValue(ok(204)); - service.init(); - await vi.advanceTimersByTimeAsync(0); - - const callsAfterInit = mockFetch.mock.calls.length; - - await vi.advanceTimersByTimeAsync(3000); - expect(mockFetch.mock.calls.length).toBeGreaterThan(callsAfterInit); - }); - }); -}); diff --git a/apps/code/src/main/services/connectivity/service.ts b/apps/code/src/main/services/connectivity/service.ts index 255d26eb51..00692197be 100644 --- a/apps/code/src/main/services/connectivity/service.ts +++ b/apps/code/src/main/services/connectivity/service.ts @@ -1,36 +1,34 @@ -import { getBackoffDelay } from "@shared/utils/backoff"; -import { injectable, postConstruct, preDestroy } from "inversify"; -import { logger } from "../../utils/logger"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +// PORT NOTE: bridge to the @posthog/workspace-server connectivity capability. +// Caches the latest status locally so AuthService can read getStatus() +// synchronously and react to StatusChange events. Delete when AuthService and +// the connectivity tRPC router consume workspaceClient.connectivity directly. +import type { WorkspaceClient } from "@posthog/workspace-client/client"; +import { TypedEventEmitter } from "@posthog/shared"; import { ConnectivityEvent, type ConnectivityEvents, type ConnectivityStatusOutput, -} from "./schemas"; +} from "@posthog/workspace-server/services/connectivity/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; - -@injectable() export class ConnectivityService extends TypedEventEmitter { - private isOnline = false; - private pollTimeoutId: ReturnType | 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)"); - - void this.checkConnectivity(); - this.startPolling(); + private isOnline = true; + + constructor(private readonly workspace: WorkspaceClient) { + super(); + this.setMaxListeners(0); + this.workspace.connectivity.onStatusChange.subscribe(undefined, { + onData: (status) => { + this.isOnline = status.isOnline; + this.emit(ConnectivityEvent.StatusChange, status); + }, + onError: () => {}, + }); + void this.workspace.connectivity.getStatus + .query() + .then((status) => { + this.isOnline = status.isOnline; + }) + .catch(() => {}); } getStatus(): ConnectivityStatusOutput { @@ -38,75 +36,8 @@ export class ConnectivityService extends TypedEventEmitter { } async checkNow(): Promise { - await this.checkConnectivity(); - return { isOnline: this.isOnline }; - } - - 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 { - const verified = await this.verifyWithHttp(); - this.setOnline(verified); - } - - private async verifyWithHttp(): Promise { - try { - const response = await fetch(CHECK_URL, { - method: "HEAD", - signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), - }); - return response.ok || response.status === 204; - } catch (error) { - log.debug("HTTP connectivity check failed", { error }); - 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, - }); - - 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; - } + const status = await this.workspace.connectivity.checkNow.mutate(); + this.isOnline = status.isOnline; + return status; } } diff --git a/apps/code/src/main/services/context-menu/schemas.ts b/apps/code/src/main/services/context-menu/schemas.ts deleted file mode 100644 index 9620d3ba87..0000000000 --- a/apps/code/src/main/services/context-menu/schemas.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { z } from "zod"; - -export const taskContextMenuInput = z.object({ - taskTitle: z.string(), - worktreePath: z.string().optional(), - folderPath: z.string().optional(), - isPinned: z.boolean().optional(), - isSuspended: z.boolean().optional(), - isInCommandCenter: z.boolean().optional(), - hasEmptyCommandCenterCell: z.boolean().optional(), -}); - -export const bulkTaskContextMenuInput = z.object({ - taskCount: z.number().int().min(2), -}); - -export const archivedTaskContextMenuInput = z.object({ - taskTitle: z.string(), -}); - -export const folderContextMenuInput = z.object({ - folderName: z.string(), - folderPath: z.string().optional(), -}); - -export const tabContextMenuInput = z.object({ - canClose: z.boolean(), - filePath: z.string().optional(), -}); - -export const fileContextMenuInput = z.object({ - filePath: z.string(), - showCollapseAll: z.boolean().optional(), -}); - -const externalAppAction = z.discriminatedUnion("type", [ - z.object({ type: z.literal("open-in-app"), appId: z.string() }), - z.object({ type: z.literal("copy-path") }), -]); - -const taskAction = z.discriminatedUnion("type", [ - z.object({ type: z.literal("rename") }), - z.object({ type: z.literal("pin") }), - z.object({ type: z.literal("suspend") }), - z.object({ type: z.literal("archive") }), - z.object({ type: z.literal("archive-prior") }), - z.object({ type: z.literal("delete") }), - z.object({ type: z.literal("add-to-command-center") }), - z.object({ type: z.literal("external-app"), action: externalAppAction }), -]); - -const bulkTaskAction = z.discriminatedUnion("type", [ - z.object({ type: z.literal("archive") }), -]); - -const archivedTaskAction = z.discriminatedUnion("type", [ - z.object({ type: z.literal("restore") }), - z.object({ type: z.literal("delete") }), -]); - -const folderAction = z.discriminatedUnion("type", [ - z.object({ type: z.literal("remove") }), - z.object({ type: z.literal("external-app"), action: externalAppAction }), -]); - -const tabAction = z.discriminatedUnion("type", [ - z.object({ type: z.literal("close") }), - z.object({ type: z.literal("close-others") }), - z.object({ type: z.literal("close-right") }), - z.object({ type: z.literal("external-app"), action: externalAppAction }), -]); - -const fileAction = z.discriminatedUnion("type", [ - z.object({ type: z.literal("collapse-all") }), - z.object({ type: z.literal("external-app"), action: externalAppAction }), -]); - -const splitDirection = z.enum(["left", "right", "up", "down"]); - -export const taskContextMenuOutput = z.object({ - action: taskAction.nullable(), -}); -export const bulkTaskContextMenuOutput = z.object({ - action: bulkTaskAction.nullable(), -}); -export const archivedTaskContextMenuOutput = z.object({ - action: archivedTaskAction.nullable(), -}); -export const folderContextMenuOutput = z.object({ - action: folderAction.nullable(), -}); -export const tabContextMenuOutput = z.object({ action: tabAction.nullable() }); -export const fileContextMenuOutput = z.object({ - action: fileAction.nullable(), -}); -export const splitContextMenuOutput = z.object({ - direction: splitDirection.nullable(), -}); - -export type TaskContextMenuInput = z.infer; -export type BulkTaskContextMenuInput = z.infer; -export type ArchivedTaskContextMenuInput = z.infer< - typeof archivedTaskContextMenuInput ->; -export type FolderContextMenuInput = z.infer; -export type TabContextMenuInput = z.infer; -export type FileContextMenuInput = z.infer; - -export type ExternalAppAction = z.infer; -export type TaskAction = z.infer; -export type BulkTaskAction = z.infer; -export type ArchivedTaskAction = z.infer; -export type FolderAction = z.infer; -export type TabAction = z.infer; -export type FileAction = z.infer; -export type SplitDirection = z.infer; - -export const confirmDeleteTaskInput = z.object({ - taskTitle: z.string(), - hasWorktree: z.boolean(), -}); - -export const confirmDeleteTaskOutput = z.object({ - confirmed: z.boolean(), -}); - -export const confirmDeleteArchivedTaskInput = z.object({ - taskTitle: z.string(), -}); - -export const confirmDeleteArchivedTaskOutput = z.object({ - confirmed: z.boolean(), -}); - -export const confirmDeleteWorktreeInput = z.object({ - worktreePath: z.string(), - linkedTaskCount: z.number(), -}); - -export const confirmDeleteWorktreeOutput = z.object({ - confirmed: z.boolean(), -}); - -export type ConfirmDeleteTaskInput = z.infer; -export type ConfirmDeleteTaskResult = z.infer; -export type ConfirmDeleteArchivedTaskInput = z.infer< - typeof confirmDeleteArchivedTaskInput ->; -export type ConfirmDeleteArchivedTaskResult = z.infer< - typeof confirmDeleteArchivedTaskOutput ->; -export type ConfirmDeleteWorktreeInput = z.infer< - typeof confirmDeleteWorktreeInput ->; -export type ConfirmDeleteWorktreeResult = z.infer< - typeof confirmDeleteWorktreeOutput ->; - -export type TaskContextMenuResult = z.infer; -export type ArchivedTaskContextMenuResult = z.infer< - typeof archivedTaskContextMenuOutput ->; -export type FolderContextMenuResult = z.infer; -export type TabContextMenuResult = z.infer; -export type FileContextMenuResult = z.infer; -export type SplitContextMenuResult = z.infer; diff --git a/apps/code/src/main/services/context-menu/service.ts b/apps/code/src/main/services/context-menu/service.ts deleted file mode 100644 index 93376654c7..0000000000 --- a/apps/code/src/main/services/context-menu/service.ts +++ /dev/null @@ -1,388 +0,0 @@ -import type { - ContextMenuItem, - IContextMenu, -} from "@posthog/platform/context-menu"; -import type { IDialog } from "@posthog/platform/dialog"; -import type { DetectedApplication } from "@shared/types"; -import { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { ExternalAppsService } from "../external-apps/service"; -import type { - ArchivedTaskAction, - ArchivedTaskContextMenuInput, - ArchivedTaskContextMenuResult, - BulkTaskAction, - BulkTaskContextMenuInput, - ConfirmDeleteArchivedTaskInput, - ConfirmDeleteArchivedTaskResult, - ConfirmDeleteTaskInput, - ConfirmDeleteTaskResult, - FileAction, - FileContextMenuInput, - FileContextMenuResult, - FolderAction, - FolderContextMenuInput, - FolderContextMenuResult, - SplitContextMenuResult, - SplitDirection, - TabAction, - TabContextMenuInput, - TabContextMenuResult, - TaskAction, - TaskContextMenuInput, - TaskContextMenuResult, -} from "./schemas"; -import type { - ActionItemDef, - ConfirmOptions, - MenuItemDef, - SeparatorDef, -} from "./types"; - -@injectable() -export class ContextMenuService { - constructor( - @inject(MAIN_TOKENS.ExternalAppsService) - private readonly externalAppsService: ExternalAppsService, - @inject(MAIN_TOKENS.Dialog) - private readonly dialog: IDialog, - @inject(MAIN_TOKENS.ContextMenu) - private readonly contextMenu: IContextMenu, - ) {} - - private async getExternalAppsData() { - const [apps, lastUsed] = await Promise.all([ - this.externalAppsService.getDetectedApps(), - this.externalAppsService.getLastUsed(), - ]); - return { apps, lastUsedAppId: lastUsed.lastUsedApp }; - } - - async confirmDeleteTask( - input: ConfirmDeleteTaskInput, - ): Promise { - const confirmed = await this.confirm({ - title: "Delete Task", - message: `Delete "${input.taskTitle}"?`, - detail: input.hasWorktree - ? "This will permanently delete the task and its associated worktree." - : "This will permanently delete the task.", - confirmLabel: "Delete", - }); - return { confirmed }; - } - - async confirmDeleteArchivedTask( - input: ConfirmDeleteArchivedTaskInput, - ): Promise { - const confirmed = await this.confirm({ - title: "Delete Archived Task", - message: `Delete "${input.taskTitle}"?`, - detail: "This will permanently delete the archived task.", - confirmLabel: "Delete", - }); - return { confirmed }; - } - - async confirmDeleteWorktree({ - worktreePath, - linkedTaskCount, - }: { - worktreePath: string; - linkedTaskCount: number; - }): Promise<{ confirmed: boolean }> { - const confirmed = await this.confirm({ - title: "Delete Worktree", - message: `Delete worktree at ${worktreePath}?`, - detail: - linkedTaskCount > 0 - ? `This will remove ${linkedTaskCount} linked task${linkedTaskCount === 1 ? "" : "s"} and delete the worktree.` - : "This will delete the worktree from disk.", - confirmLabel: "Delete", - }); - return { confirmed }; - } - - async showTaskContextMenu( - input: TaskContextMenuInput, - ): Promise { - const { - worktreePath, - folderPath, - isPinned, - isSuspended, - isInCommandCenter, - hasEmptyCommandCenterCell, - } = input; - const { apps, lastUsedAppId } = await this.getExternalAppsData(); - const hasPath = worktreePath || folderPath; - - return this.showMenu([ - this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }), - this.item("Rename", { type: "rename" }), - ...(worktreePath - ? [ - this.separator(), - this.item(isSuspended ? "Unsuspend" : "Suspend", { - type: "suspend" as const, - }), - ] - : []), - ...(hasPath - ? [ - ...(worktreePath ? [] : [this.separator()]), - ...this.externalAppItems(apps, lastUsedAppId), - ] - : []), - ...(!isInCommandCenter - ? [ - this.separator(), - this.item( - "Add to Command Center", - { type: "add-to-command-center" as const }, - { enabled: hasEmptyCommandCenterCell ?? true }, - ), - ] - : []), - this.separator(), - this.item("Archive", { type: "archive" }), - this.item( - "Archive prior tasks", - { type: "archive-prior" }, - { - confirm: { - title: "Archive Prior Tasks", - message: "Archive all tasks older than this one?", - detail: - "This will archive every task created before this one. You can unarchive them later.", - confirmLabel: "Archive", - }, - }, - ), - ]); - } - - async showBulkTaskContextMenu( - input: BulkTaskContextMenuInput, - ): Promise<{ action: BulkTaskAction | null }> { - const { taskCount } = input; - const label = `Archive ${taskCount} tasks`; - return this.showMenu([ - this.item( - label, - { type: "archive" }, - { - confirm: { - title: "Archive Tasks", - message: `Archive ${taskCount} tasks?`, - detail: "You can unarchive them later.", - confirmLabel: "Archive", - }, - }, - ), - ]); - } - - async showArchivedTaskContextMenu( - input: ArchivedTaskContextMenuInput, - ): Promise { - return this.showMenu([ - this.item("Unarchive", { type: "restore" }), - this.item( - "Delete", - { type: "delete" }, - { - confirm: { - title: "Delete Archived Task", - message: `Delete "${input.taskTitle}"?`, - detail: "This will permanently delete the archived task.", - confirmLabel: "Delete", - }, - }, - ), - ]); - } - - async showFolderContextMenu( - input: FolderContextMenuInput, - ): Promise { - const { folderName, folderPath } = input; - const { apps, lastUsedAppId } = await this.getExternalAppsData(); - - return this.showMenu([ - this.item( - "Remove folder", - { type: "remove" }, - { - confirm: { - title: "Remove Folder", - message: `Remove "${folderName}" from Array?`, - detail: - "This will clean up any worktrees but keep your folder and tasks intact.", - confirmLabel: "Remove", - }, - }, - ), - ...(folderPath - ? [ - this.separator(), - ...this.externalAppItems(apps, lastUsedAppId), - ] - : []), - ]); - } - - async showTabContextMenu( - input: TabContextMenuInput, - ): Promise { - const { canClose, filePath } = input; - const { apps, lastUsedAppId } = await this.getExternalAppsData(); - - return this.showMenu([ - this.item( - "Close tab", - { type: "close" }, - { - accelerator: "CmdOrCtrl+W", - enabled: canClose, - }, - ), - this.item("Close other tabs", { type: "close-others" }), - this.item("Close tabs to the right", { type: "close-right" }), - ...(filePath - ? [ - this.separator(), - ...this.externalAppItems(apps, lastUsedAppId), - ] - : []), - ]); - } - - async showSplitContextMenu(): Promise { - const result = await this.showMenu([ - this.item("Split right", "right"), - this.item("Split left", "left"), - this.item("Split down", "down"), - this.item("Split up", "up"), - ]); - return { direction: result.action }; - } - - async showFileContextMenu( - input: FileContextMenuInput, - ): Promise { - const { apps, lastUsedAppId } = await this.getExternalAppsData(); - - return this.showMenu([ - ...(input.showCollapseAll - ? [ - this.item("Collapse All", { type: "collapse-all" }), - this.separator(), - ] - : []), - ...this.externalAppItems(apps, lastUsedAppId), - ]); - } - - private externalAppItems( - apps: DetectedApplication[], - lastUsedAppId?: string, - ): MenuItemDef[] { - if (apps.length === 0) { - return [this.disabled("No external apps detected")]; - } - - const lastUsedApp = apps.find((app) => app.id === lastUsedAppId) || apps[0]; - const openIn = (appId: string): T => - ({ type: "external-app", action: { type: "open-in-app", appId } }) as T; - return [ - this.item(`Open in ${lastUsedApp.name}`, openIn(lastUsedApp.id)), - { - type: "submenu", - label: "Open in", - items: apps.map((app) => ({ - label: app.name, - icon: app.icon, - action: openIn(app.id), - })), - }, - ]; - } - - private item( - label: string, - action: T, - options?: Partial, "type" | "label" | "action">>, - ): ActionItemDef { - return { type: "item", label, action, ...options }; - } - - private separator(): SeparatorDef { - return { type: "separator" }; - } - - private disabled(label: string): MenuItemDef { - return { type: "disabled", label }; - } - - private showMenu(items: MenuItemDef[]): Promise<{ action: T | null }> { - return new Promise((resolve) => { - let pendingConfirm = false; - - const toContextMenuItem = (def: MenuItemDef): ContextMenuItem => { - switch (def.type) { - case "separator": - return { separator: true }; - case "disabled": - return { label: def.label, enabled: false, click: () => {} }; - case "submenu": - return { - label: def.label, - submenu: def.items.map((sub) => ({ - label: sub.label, - icon: sub.icon, - click: () => resolve({ action: sub.action }), - })), - click: () => {}, - }; - case "item": { - const confirmOptions = def.confirm; - const click = confirmOptions - ? async () => { - pendingConfirm = true; - const confirmed = await this.confirm(confirmOptions); - resolve({ action: confirmed ? def.action : null }); - } - : () => resolve({ action: def.action }); - return { - label: def.label, - enabled: def.enabled, - accelerator: def.accelerator, - icon: def.icon, - click, - }; - } - } - }; - - this.contextMenu.show(items.map(toContextMenuItem), { - onDismiss: () => { - if (!pendingConfirm) resolve({ action: null }); - }, - }); - }); - } - - private async confirm(options: ConfirmOptions): Promise { - const response = await this.dialog.confirm({ - severity: "question", - title: options.title, - message: options.message, - detail: options.detail, - options: ["Cancel", options.confirmLabel], - defaultIndex: 1, - cancelIndex: 0, - }); - return response === 1; - } -} 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/enrichment/service.ts b/apps/code/src/main/services/enrichment/service.ts deleted file mode 100644 index e859d2ecc2..0000000000 --- a/apps/code/src/main/services/enrichment/service.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { createHash } from "node:crypto"; -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { - EXT_TO_LANG_ID, - enrichSource, - 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"); - -setEnricherLogger({ - warn: (message, ...args) => log.warn(message, ...args), -}); - -const MAX_CACHE_ENTRIES = 200; -const CACHE_TTL_MS = 10 * 60 * 1000; - -interface CacheEntry { - value: SerializedEnrichment | null; - expiresAt: number; -} - -export interface EnrichFileInput { - taskId: string; - filePath: string; - absolutePath?: string; - content: string; -} - -const MANIFEST_BASENAMES = new Set([ - "package.json", - "requirements.txt", - "pyproject.toml", - "Gemfile", - "Podfile", - "build.gradle", - "build.gradle.kts", - "pubspec.yaml", - "pubspec.yml", - "go.mod", - "composer.json", -]); -const MANIFEST_EXTENSIONS = new Set([".csproj"]); - -const SKIP_PATH_SEGMENTS = new Set([ - "node_modules", - "dist", - "build", - "out", - ".next", - ".nuxt", - ".svelte-kit", - ".turbo", - ".cache", - "vendor", - "target", - "coverage", - ".git", - "__pycache__", - ".venv", - "venv", - "env", - ".tox", -]); - -export interface StaleFlagSuggestion { - flagKey: string; - references: { file: string; line: number; method: string }[]; - referenceCount: number; -} - -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; -} - -interface ParsedRepoCacheEntry { - files: Map; - manifestHit: boolean; -} - -function yieldToEventLoop(): Promise { - return new Promise((resolve) => setImmediate(resolve)); -} - -function shouldSkipPath(relPath: string): boolean { - const parts = relPath.split(/[\\/]/); - return parts.some((segment) => SKIP_PATH_SEGMENTS.has(segment)); -} - -function isManifestPath(relPath: string): boolean { - const base = path.basename(relPath); - if (MANIFEST_BASENAMES.has(base)) return true; - const ext = path.extname(relPath).toLowerCase(); - return MANIFEST_EXTENSIONS.has(ext); -} - -function isUsageProbeCandidate(relPath: string): boolean { - if (shouldSkipPath(relPath)) return false; - const ext = path.extname(relPath).toLowerCase(); - if (!ext) return false; - return ext in EXT_TO_LANG_ID; -} - -@injectable() -export class EnrichmentService { - private enricher: PostHogEnricher | null = null; - private readonly cache = new Map(); - private readonly repoScanCache = new Map(); - private readonly repoScanInflight = new Map< - string, - Promise - >(); - - constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, - ) {} - - async enrichFile( - input: EnrichFileInput, - ): Promise { - const { taskId, filePath, absolutePath, content } = input; - const cacheKey = this.buildCacheKey(taskId, filePath, content); - - const cached = this.cache.get(cacheKey); - const now = Date.now(); - if (cached && cached.expiresAt > now) { - this.cache.delete(cacheKey); - this.cache.set(cacheKey, cached); - return cached.value; - } - if (cached) { - this.cache.delete(cacheKey); - } - - const result = await this.runEnrichment(filePath, absolutePath, content); - this.setCache(cacheKey, result); - return result; - } - - private async runEnrichment( - filePath: string, - absolutePath: string | undefined, - content: string, - ): Promise { - const apiConfig = await this.resolveApiConfig(); - if (!apiConfig) return null; - - const enricher = this.getEnricher(); - const enriched = await enrichSource({ - enricher, - apiConfig, - filePath, - absolutePath, - content, - onDebug: (message: string, data?: Record) => { - log.debug(message, { filePath, ...(data ?? {}) }); - }, - }); - - if (!enriched) return null; - return toSerializable(enriched); - } - - private getEnricher(): PostHogEnricher { - if (!this.enricher) { - this.enricher = new PostHogEnricher(); - } - return this.enricher; - } - - private async resolveApiConfig(): Promise<{ - apiKey: string; - host: string; - projectId: number; - } | null> { - const state = this.authService.getState(); - if ( - state.status !== "authenticated" || - !state.projectId || - !state.cloudRegion - ) { - return null; - } - try { - const auth = await this.authService.getValidAccessToken(); - return { - apiKey: auth.accessToken, - host: auth.apiHost, - projectId: state.projectId, - }; - } catch (err) { - log.debug("Failed to resolve access token", { - message: err instanceof Error ? err.message : String(err), - }); - return null; - } - } - - async detectPosthogInstallState( - repoPath: string, - ): Promise { - if (!repoPath) return "not_installed"; - - const scan = await this.scanRepo(repoPath); - if (!scan) return "not_installed"; - - let usageFound = false; - for (const entry of scan.files.values()) { - if (!entry.result) continue; - if (entry.result.calls.length > 0 || entry.result.initCalls.length > 0) { - usageFound = true; - break; - } - } - - if (usageFound) return "initialized"; - if (scan.manifestHit) return "installed_no_init"; - return "not_installed"; - } - - async findStaleFlagSuggestions( - repoPath: string, - ): Promise { - if (!repoPath) return []; - - const apiConfig = await this.resolveApiConfig(); - if (!apiConfig) return []; - - const scan = await this.scanRepo(repoPath); - if (!scan) return []; - - const referencesByKey = new Map< - string, - { file: string; line: number; method: string }[] - >(); - for (const [relPath, entry] of scan.files) { - if (!entry.result) continue; - for (const check of entry.result.flagChecks) { - const list = referencesByKey.get(check.flagKey) ?? []; - list.push({ file: relPath, line: check.line, method: check.method }); - referencesByKey.set(check.flagKey, list); - } - } - - if (referencesByKey.size === 0) return []; - - const flagKeys = [...referencesByKey.keys()]; - let lastCalled: Map; - try { - const api = new PostHogApi(apiConfig); - lastCalled = await api.getFlagLastCalled(flagKeys, STALE_LOOKBACK_DAYS); - } catch (err) { - log.debug("Failed to fetch flag-call timestamps", { - error: err instanceof Error ? err.message : String(err), - }); - return []; - } - - const staleKeys = flagKeys.filter((key) => !lastCalled.has(key)).sort(); - - const suggestions: StaleFlagSuggestion[] = []; - for (const key of staleKeys) { - const refs = referencesByKey.get(key); - if (!refs || refs.length === 0) continue; - suggestions.push({ - flagKey: key, - references: refs.slice(0, STALE_FLAG_REFERENCES_PER_FLAG), - referenceCount: refs.length, - }); - if (suggestions.length >= STALE_FLAG_SUGGESTION_CAP) break; - } - return suggestions; - } - - // Memoized per repoPath; concurrent callers wait on the same in-flight - // promise. Cleared by `dispose()`. - private async scanRepo( - repoPath: string, - ): Promise { - const cached = this.repoScanCache.get(repoPath); - if (cached) return cached; - - const inflight = this.repoScanInflight.get(repoPath); - if (inflight) return inflight; - - const promise = this.runScan(repoPath).finally(() => { - this.repoScanInflight.delete(repoPath); - }); - this.repoScanInflight.set(repoPath, promise); - return promise; - } - - private async runScan( - repoPath: string, - ): Promise { - let posthogFiles: string[]; - try { - posthogFiles = await listFilesContainingText(repoPath, "posthog"); - } catch (err) { - log.debug("git grep failed during repo scan", { - repoPath, - error: err instanceof Error ? err.message : String(err), - }); - return null; - } - - const enricher = this.getEnricher(); - const langIdMap = EXT_TO_LANG_ID as Record; - - const manifestHit = posthogFiles.some(isManifestPath); - - const toParse: { relPath: string; langId: string }[] = []; - for (const relPath of posthogFiles) { - if (!isUsageProbeCandidate(relPath)) continue; - const ext = path.extname(relPath).toLowerCase(); - const langId = langIdMap[ext]; - 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", { - repoPath, - totalCandidates: posthogFiles.length, - parseLimit: MAX_FILES_TO_PARSE, - }); - break; - } - } - - const files = new Map(); - // 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); - if (stat.size > MAX_FILE_BYTES) { - files.set(candidate.relPath, { - langId: candidate.langId, - result: null, - }); - continue; - } - content = await fs.readFile(absPath, "utf-8"); - } catch { - continue; - } - try { - 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", { - file: candidate.relPath, - error: err instanceof Error ? err.message : String(err), - }); - files.set(candidate.relPath, { - langId: candidate.langId, - result: null, - }); - } - await yieldToEventLoop(); - } - - const entry: ParsedRepoCacheEntry = { files, manifestHit }; - this.repoScanCache.set(repoPath, entry); - return entry; - } - - private buildCacheKey( - taskId: string, - filePath: string, - content: string, - ): string { - const hash = createHash("sha1").update(content).digest("hex"); - return `${taskId}::${filePath}::${hash}`; - } - - private setCache(key: string, value: SerializedEnrichment | null): void { - this.cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); - while (this.cache.size > MAX_CACHE_ENTRIES) { - const oldest = this.cache.keys().next().value; - if (oldest === undefined) break; - this.cache.delete(oldest); - } - } - - dispose(): void { - this.enricher?.dispose(); - this.enricher = null; - this.cache.clear(); - this.repoScanCache.clear(); - this.repoScanInflight.clear(); - } -} diff --git a/apps/code/src/main/services/external-apps/schemas.ts b/apps/code/src/main/services/external-apps/schemas.ts deleted file mode 100644 index 0e180df886..0000000000 --- a/apps/code/src/main/services/external-apps/schemas.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { z } from "zod"; - -export const openInAppInput = z.object({ - appId: z.string(), - targetPath: z.string(), -}); - -export const setLastUsedInput = z.object({ - appId: z.string(), -}); - -export const copyPathInput = z.object({ - targetPath: z.string(), -}); - -const externalAppType = z.enum([ - "editor", - "terminal", - "file-manager", - "git-client", -]); - -const detectedApplication = z.object({ - id: z.string(), - name: z.string(), - type: externalAppType, - path: z.string(), - command: z.string(), - icon: z.string().optional(), -}); - -export const getDetectedAppsOutput = z.array(detectedApplication); -export const openInAppOutput = z.object({ - success: z.boolean(), - error: z.string().optional(), -}); -export const getLastUsedOutput = z.object({ - lastUsedApp: z.string().optional(), -}); - -export type OpenInAppInput = z.infer; -export type SetLastUsedInput = z.infer; -export type CopyPathInput = z.infer; -export type DetectedApplication = z.infer; -export type OpenInAppOutput = z.infer; -export type GetLastUsedOutput = z.infer; diff --git a/apps/code/src/main/services/external-apps/service.ts b/apps/code/src/main/services/external-apps/service.ts deleted file mode 100644 index 5ca6d89d3a..0000000000 --- a/apps/code/src/main/services/external-apps/service.ts +++ /dev/null @@ -1,672 +0,0 @@ -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 { inject, injectable } from "inversify"; -import { MAIN_TOKENS } from "../../di/tokens"; -import type { AppDefinition, ExternalAppsSchema } from "./types"; - -const execAsync = promisify(exec); - -const LOCALAPPDATA = process.env.LOCALAPPDATA ?? ""; -const PROGRAMFILES = process.env.PROGRAMFILES ?? "C:\\Program Files"; - -@injectable() -export class ExternalAppsService { - private readonly APP_DEFINITIONS: Record = { - // Cross-platform editors - vscode: { - type: "editor", - darwin: { path: "/Applications/Visual Studio Code.app" }, - win32: { - paths: [ - path.join(LOCALAPPDATA, "Programs", "Microsoft VS Code", "Code.exe"), - ], - exeName: "code", - }, - }, - cursor: { - type: "editor", - darwin: { path: "/Applications/Cursor.app" }, - win32: { - paths: [path.join(LOCALAPPDATA, "Programs", "cursor", "Cursor.exe")], - exeName: "cursor", - }, - }, - windsurf: { - type: "editor", - darwin: { path: "/Applications/Windsurf.app" }, - win32: { - paths: [ - path.join(LOCALAPPDATA, "Programs", "Windsurf", "Windsurf.exe"), - ], - exeName: "windsurf", - }, - }, - zed: { - type: "editor", - darwin: { path: "/Applications/Zed.app" }, - win32: { - paths: [path.join(LOCALAPPDATA, "Programs", "Zed", "Zed.exe")], - exeName: "zed", - }, - }, - sublime: { - type: "editor", - darwin: { path: "/Applications/Sublime Text.app" }, - win32: { - paths: [path.join(PROGRAMFILES, "Sublime Text", "sublime_text.exe")], - exeName: "subl", - }, - }, - lapce: { - type: "editor", - darwin: { path: "/Applications/Lapce.app" }, - win32: { - paths: [path.join(PROGRAMFILES, "Lapce", "lapce.exe")], - exeName: "lapce", - }, - }, - emacs: { - type: "editor", - darwin: { path: "/Applications/Emacs.app" }, - win32: { - paths: [path.join(PROGRAMFILES, "Emacs", "bin", "emacs.exe")], - exeName: "emacs", - }, - }, - androidstudio: { - type: "editor", - darwin: { path: "/Applications/Android Studio.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "Android", - "Android Studio", - "bin", - "studio64.exe", - ), - ], - }, - }, - fleet: { - type: "editor", - darwin: { path: "/Applications/Fleet.app" }, - win32: { - paths: [path.join(LOCALAPPDATA, "JetBrains", "Fleet", "fleet.exe")], - }, - }, - intellij: { - type: "editor", - darwin: { path: "/Applications/IntelliJ IDEA.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "IntelliJ IDEA", - "bin", - "idea64.exe", - ), - ], - }, - }, - intellijce: { - type: "editor", - darwin: { path: "/Applications/IntelliJ IDEA CE.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "IntelliJ IDEA Community Edition", - "bin", - "idea64.exe", - ), - ], - }, - }, - intellijultimate: { - type: "editor", - darwin: { path: "/Applications/IntelliJ IDEA Ultimate.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "IntelliJ IDEA", - "bin", - "idea64.exe", - ), - ], - }, - }, - webstorm: { - type: "editor", - darwin: { path: "/Applications/WebStorm.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "WebStorm", - "bin", - "webstorm64.exe", - ), - ], - }, - }, - pycharm: { - type: "editor", - darwin: { path: "/Applications/PyCharm.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "PyCharm", - "bin", - "pycharm64.exe", - ), - ], - }, - }, - pycharmce: { - type: "editor", - darwin: { path: "/Applications/PyCharm CE.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "PyCharm Community Edition", - "bin", - "pycharm64.exe", - ), - ], - }, - }, - pycharmpro: { - type: "editor", - darwin: { path: "/Applications/PyCharm Professional Edition.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "PyCharm Professional", - "bin", - "pycharm64.exe", - ), - ], - }, - }, - phpstorm: { - type: "editor", - darwin: { path: "/Applications/PhpStorm.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "PhpStorm", - "bin", - "phpstorm64.exe", - ), - ], - }, - }, - rubymine: { - type: "editor", - darwin: { path: "/Applications/RubyMine.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "RubyMine", - "bin", - "rubymine64.exe", - ), - ], - }, - }, - goland: { - type: "editor", - darwin: { path: "/Applications/GoLand.app" }, - win32: { - paths: [ - path.join(PROGRAMFILES, "JetBrains", "GoLand", "bin", "goland64.exe"), - ], - }, - }, - clion: { - type: "editor", - darwin: { path: "/Applications/CLion.app" }, - win32: { - paths: [ - path.join(PROGRAMFILES, "JetBrains", "CLion", "bin", "clion64.exe"), - ], - }, - }, - rider: { - type: "editor", - darwin: { path: "/Applications/Rider.app" }, - win32: { - paths: [ - path.join(PROGRAMFILES, "JetBrains", "Rider", "bin", "rider64.exe"), - ], - }, - }, - datagrip: { - type: "editor", - darwin: { path: "/Applications/DataGrip.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "DataGrip", - "bin", - "datagrip64.exe", - ), - ], - }, - }, - dataspell: { - type: "editor", - darwin: { path: "/Applications/DataSpell.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "DataSpell", - "bin", - "dataspell64.exe", - ), - ], - }, - }, - rustrover: { - type: "editor", - darwin: { path: "/Applications/RustRover.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "RustRover", - "bin", - "rustrover64.exe", - ), - ], - }, - }, - aqua: { - type: "editor", - darwin: { path: "/Applications/Aqua.app" }, - win32: { - paths: [ - path.join(PROGRAMFILES, "JetBrains", "Aqua", "bin", "aqua64.exe"), - ], - }, - }, - writerside: { - type: "editor", - darwin: { path: "/Applications/Writerside.app" }, - win32: { - paths: [ - path.join( - PROGRAMFILES, - "JetBrains", - "Writerside", - "bin", - "writerside64.exe", - ), - ], - }, - }, - eclipse: { - type: "editor", - darwin: { path: "/Applications/Eclipse.app" }, - win32: { - paths: [path.join(PROGRAMFILES, "Eclipse", "eclipse.exe")], - exeName: "eclipse", - }, - }, - netbeans: { - type: "editor", - darwin: { path: "/Applications/NetBeans.app" }, - win32: { - paths: [path.join(PROGRAMFILES, "NetBeans", "bin", "netbeans64.exe")], - }, - }, - netbeansapache: { - type: "editor", - darwin: { path: "/Applications/Apache NetBeans.app" }, - win32: { - paths: [path.join(PROGRAMFILES, "NetBeans", "bin", "netbeans64.exe")], - }, - }, - // macOS-only editors - nova: { type: "editor", darwin: { path: "/Applications/Nova.app" } }, - bbedit: { type: "editor", darwin: { path: "/Applications/BBEdit.app" } }, - textmate: { - type: "editor", - darwin: { path: "/Applications/TextMate.app" }, - }, - xcode: { type: "editor", darwin: { path: "/Applications/Xcode.app" } }, - appcode: { - type: "editor", - darwin: { path: "/Applications/AppCode.app" }, - }, - // macOS-only terminals - iterm: { type: "terminal", darwin: { path: "/Applications/iTerm.app" } }, - warp: { type: "terminal", darwin: { path: "/Applications/Warp.app" } }, - terminal: { - type: "terminal", - darwin: { path: "/System/Applications/Utilities/Terminal.app" }, - }, - ghostty: { - type: "terminal", - darwin: { path: "/Applications/Ghostty.app" }, - }, - kitty: { - type: "terminal", - darwin: { path: "/Applications/kitty.app" }, - }, - rio: { type: "terminal", darwin: { path: "/Applications/Rio.app" } }, - // Cross-platform terminals - alacritty: { - type: "terminal", - darwin: { path: "/Applications/Alacritty.app" }, - win32: { - paths: [path.join(PROGRAMFILES, "Alacritty", "alacritty.exe")], - exeName: "alacritty", - }, - }, - hyper: { - type: "terminal", - darwin: { path: "/Applications/Hyper.app" }, - win32: { - paths: [path.join(LOCALAPPDATA, "Programs", "Hyper", "Hyper.exe")], - }, - }, - tabby: { - type: "terminal", - darwin: { path: "/Applications/Tabby.app" }, - win32: { - paths: [path.join(LOCALAPPDATA, "Programs", "Tabby", "Tabby.exe")], - }, - }, - // Windows-only terminals - windowsterminal: { - type: "terminal", - win32: { - paths: [path.join(LOCALAPPDATA, "Microsoft", "WindowsApps", "wt.exe")], - exeName: "wt", - }, - }, - // Git clients - gitkraken: { - type: "git-client", - darwin: { path: "/Applications/GitKraken.app" }, - }, - // File managers - finder: { - type: "file-manager", - darwin: { path: "/System/Library/CoreServices/Finder.app" }, - }, - explorer: { - type: "file-manager", - win32: { - paths: [ - path.join(process.env.SYSTEMROOT ?? "C:\\Windows", "explorer.exe"), - ], - }, - }, - }; - - private readonly DISPLAY_NAMES: Record = { - vscode: "VS Code", - cursor: "Cursor", - windsurf: "Windsurf", - zed: "Zed", - sublime: "Sublime Text", - nova: "Nova", - bbedit: "BBEdit", - textmate: "TextMate", - lapce: "Lapce", - emacs: "Emacs", - xcode: "Xcode", - androidstudio: "Android Studio", - fleet: "Fleet", - intellij: "IntelliJ IDEA", - intellijce: "IntelliJ IDEA CE", - intellijultimate: "IntelliJ IDEA Ultimate", - webstorm: "WebStorm", - pycharm: "PyCharm", - pycharmce: "PyCharm CE", - pycharmpro: "PyCharm Professional", - phpstorm: "PhpStorm", - rubymine: "RubyMine", - goland: "GoLand", - clion: "CLion", - rider: "Rider", - datagrip: "DataGrip", - dataspell: "DataSpell", - rustrover: "RustRover", - aqua: "Aqua", - writerside: "Writerside", - appcode: "AppCode", - eclipse: "Eclipse", - netbeans: "NetBeans", - netbeansapache: "Apache NetBeans", - iterm: "iTerm", - warp: "Warp", - terminal: "Terminal", - alacritty: "Alacritty", - kitty: "Kitty", - ghostty: "Ghostty", - hyper: "Hyper", - tabby: "Tabby", - rio: "Rio", - finder: "Finder", - windowsterminal: "Windows Terminal", - explorer: "Explorer", - gitkraken: "GitKraken", - }; - - private cachedApps: DetectedApplication[] | null = null; - private detectionPromise: Promise | null = null; - private prefsStore: Store; - - constructor( - @inject(MAIN_TOKENS.StoragePaths) - private readonly storagePaths: IStoragePaths, - @inject(MAIN_TOKENS.Clipboard) - private readonly clipboard: IClipboard, - @inject(MAIN_TOKENS.FileIcon) - private readonly fileIcon: IFileIcon, - ) { - this.prefsStore = new Store({ - name: "external-apps", - cwd: this.storagePaths.appDataPath, - defaults: { - externalAppsPrefs: {}, - }, - }); - } - - private async extractIcon(appPath: string): Promise { - const dataUrl = await this.fileIcon.getAsDataUrl(appPath); - return dataUrl ?? undefined; - } - - private async findWin32Executable( - definition: NonNullable, - ): Promise { - for (const p of definition.paths) { - try { - await fs.access(p); - return p; - } catch { - // path not found, try next - } - } - - if (definition.exeName) { - try { - const { stdout } = await execAsync(`where.exe ${definition.exeName}`); - const firstLine = stdout.trim().split("\n")[0]?.trim(); - if (firstLine) { - return firstLine; - } - } catch { - // not found in PATH - } - } - - return null; - } - - private async checkApplication( - id: string, - definition: AppDefinition, - ): Promise { - try { - let appPath: string; - let command: string; - - if (process.platform === "darwin") { - const darwinDef = definition.darwin; - if (!darwinDef) return null; - - await fs.access(darwinDef.path); - appPath = darwinDef.path; - command = `open -a "${appPath}"`; - } else if (process.platform === "win32") { - const win32Def = definition.win32; - if (!win32Def) return null; - - const exePath = await this.findWin32Executable(win32Def); - if (!exePath) return null; - - appPath = exePath; - command = `"${appPath}"`; - } else { - return null; - } - - const icon = await this.extractIcon(appPath); - const name = this.DISPLAY_NAMES[id] || id; - return { id, name, type: definition.type, path: appPath, command, icon }; - } catch { - return null; - } - } - - private async detectExternalApps(): Promise { - const apps: DetectedApplication[] = []; - for (const [id, definition] of Object.entries(this.APP_DEFINITIONS)) { - const detected = await this.checkApplication(id, definition); - if (detected) { - apps.push(detected); - } - } - return apps; - } - - async getDetectedApps(): Promise { - if (this.cachedApps) { - return this.cachedApps; - } - - if (this.detectionPromise) { - return this.detectionPromise; - } - - this.detectionPromise = this.detectExternalApps().then((apps) => { - this.cachedApps = apps; - this.detectionPromise = null; - return apps; - }); - - return this.detectionPromise; - } - - async openInApp( - appId: string, - targetPath: string, - ): Promise<{ success: boolean; error?: string }> { - try { - const apps = await this.getDetectedApps(); - const appToOpen = apps.find((a) => a.id === appId); - - if (!appToOpen) { - return { success: false, error: "Application not found" }; - } - - let isFile = false; - try { - const stat = await fs.stat(targetPath); - isFile = stat.isFile(); - } catch { - isFile = false; - } - - let command: string; - - if (process.platform === "darwin") { - if (appToOpen.id === "finder" && isFile) { - command = `open -R "${targetPath}"`; - } else if (appToOpen.id === "gitkraken") { - // GitKraken ignores positional args; it needs `--args -p `. - command = `open -na "${appToOpen.path}" --args -p "${targetPath}"`; - } else { - command = `open -a "${appToOpen.path}" "${targetPath}"`; - } - } else if (process.platform === "win32") { - command = - appToOpen.id === "explorer" && isFile - ? `explorer.exe /select,"${targetPath}"` - : `"${appToOpen.path}" "${targetPath}"`; - } else { - return { success: false, error: "Unsupported platform" }; - } - - await execAsync(command); - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } - } - - async setLastUsed(appId: string): Promise { - const prefs = this.prefsStore.get("externalAppsPrefs"); - this.prefsStore.set("externalAppsPrefs", { ...prefs, lastUsedApp: appId }); - } - - async getLastUsed(): Promise<{ lastUsedApp?: string }> { - const prefs = this.prefsStore.get("externalAppsPrefs"); - return { lastUsedApp: prefs.lastUsedApp }; - } - - async copyPath(targetPath: string): Promise { - await this.clipboard.writeText(targetPath); - } - - getPrefsStore() { - return this.prefsStore; - } -} diff --git a/apps/code/src/main/services/external-apps/types.ts b/apps/code/src/main/services/external-apps/types.ts deleted file mode 100644 index 59284f8054..0000000000 --- a/apps/code/src/main/services/external-apps/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { ExternalAppType } from "@shared/types"; - -export interface AppDefinition { - type: ExternalAppType; - darwin?: { path: string }; - win32?: { paths: string[]; exeName?: string }; -} - -export interface ExternalAppsPreferences { - lastUsedApp?: string; -} - -export interface ExternalAppsSchema { - externalAppsPrefs: ExternalAppsPreferences; -} diff --git a/apps/code/src/main/services/file-watcher/bridge.ts b/apps/code/src/main/services/file-watcher/bridge.ts index a70146818a..b44f644b87 100644 --- a/apps/code/src/main/services/file-watcher/bridge.ts +++ b/apps/code/src/main/services/file-watcher/bridge.ts @@ -1,6 +1,6 @@ import type { WorkspaceClient } from "@posthog/workspace-client/client"; import type { FileWatcherEvent } from "@posthog/workspace-client/types"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import { TypedEventEmitter } from "@posthog/shared"; type FileWatcherEventsByKind = { [K in FileWatcherEvent["kind"]]: Extract; 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 index 5947491aa8..f8b645d87c 100644 --- a/apps/code/src/main/services/focus/service.ts +++ b/apps/code/src/main/services/focus/service.ts @@ -6,9 +6,9 @@ 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 { TypedEventEmitter } from "@posthog/shared"; import { getWorktreeLocation } from "../settingsStore"; -import type { FocusResult, StashResult } from "./schemas"; +import type { FocusResult, StashResult } from "@posthog/core/focus/identifiers"; export const FocusServiceEvent = { BranchRenamed: "branchRenamed", diff --git a/apps/code/src/main/services/folders/service.test.ts b/apps/code/src/main/services/folders/service.test.ts deleted file mode 100644 index 83e7be4b0e..0000000000 --- a/apps/code/src/main/services/folders/service.test.ts +++ /dev/null @@ -1,624 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const mockExistsSync = vi.hoisted(() => vi.fn(() => true)); -const mockDialog = vi.hoisted(() => ({ - confirm: vi.fn(), - pickFile: vi.fn(), -})); -const mockRepositoryRepo = vi.hoisted(() => ({ - findAll: vi.fn(), - findById: vi.fn(), - findByPath: vi.fn(), - findByRemoteUrl: vi.fn(), - findMostRecentlyAccessed: vi.fn(), - create: vi.fn(), - upsertByPath: vi.fn(), - updateLastAccessed: vi.fn(), - updateRemoteUrl: vi.fn(), - delete: vi.fn(), -})); -const mockWorkspaceRepo = vi.hoisted(() => ({ - findAllByRepositoryId: vi.fn(), - findAll: vi.fn(), -})); -const mockWorktreeRepo = vi.hoisted(() => ({ - findByWorkspaceId: vi.fn(), - findAll: vi.fn(), -})); -const mockWorktreeManager = vi.hoisted(() => ({ - deleteWorktree: vi.fn(), - cleanupOrphanedWorktrees: vi.fn(), -})); -const mockInitRepositorySaga = vi.hoisted(() => ({ - run: vi.fn(), -})); - -vi.mock("node:fs", () => ({ - existsSync: mockExistsSync, - promises: { - readdir: vi.fn(), - readFile: vi.fn(), - }, - default: { - existsSync: mockExistsSync, - promises: { - readdir: vi.fn(), - readFile: vi.fn(), - }, - }, -})); - -vi.mock("@posthog/git/worktree", () => ({ - WorktreeManager: class MockWorktreeManager { - deleteWorktree = mockWorktreeManager.deleteWorktree; - cleanupOrphanedWorktrees = mockWorktreeManager.cleanupOrphanedWorktrees; - }, -})); - -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)), -})); - -vi.mock("@posthog/git/sagas/init", () => ({ - InitRepositorySaga: class { - run = mockInitRepositorySaga.run; - }, -})); - -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 { getRemoteUrl, isGitRepository } from "@posthog/git/queries"; -import type { IDialog } from "@posthog/platform/dialog"; -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"; - -describe("FoldersService", () => { - let service: FoldersService; - - beforeEach(() => { - vi.clearAllMocks(); - - mockRepositoryRepo.findAll.mockReturnValue([]); - mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([]); - 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, - ); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - 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([ - { - id: "folder-1", - path: "/gone/project", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ]); - mockExistsSync.mockReturnValue(false); - mockRepositoryRepo.findById.mockReturnValue({ - id: "folder-1", - path: "/gone/project", - }); - mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([]); - - createService(); - await vi.waitFor(() => { - expect(mockRepositoryRepo.delete).toHaveBeenCalledWith("folder-1"); - }); - }); - - it("cleans up orphaned worktrees for each existing folder", async () => { - mockRepositoryRepo.findAll.mockReturnValue([ - { - id: "folder-1", - path: "/home/user/project-a", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - { - id: "folder-2", - path: "/home/user/project-b", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ]); - mockExistsSync.mockReturnValue(true); - mockWorktreeRepo.findAll.mockReturnValue([]); - mockWorktreeManager.cleanupOrphanedWorktrees.mockResolvedValue({ - deleted: [], - errors: [], - }); - - createService(); - await vi.waitFor(() => { - expect( - mockWorktreeManager.cleanupOrphanedWorktrees, - ).toHaveBeenCalledTimes(2); - }); - }); - - it("continues if one folder removal fails", async () => { - mockRepositoryRepo.findAll.mockReturnValue([ - { - id: "folder-1", - path: "/gone/a", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - { - id: "folder-2", - path: "/gone/b", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ]); - mockExistsSync.mockReturnValue(false); - mockRepositoryRepo.findById - .mockReturnValueOnce({ id: "folder-1", path: "/gone/a" }) - .mockReturnValueOnce({ id: "folder-2", path: "/gone/b" }); - mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([]); - mockRepositoryRepo.delete - .mockImplementationOnce(() => { - throw new Error("db error"); - }) - .mockImplementationOnce(() => undefined); - - createService(); - await vi.waitFor(() => { - expect(mockRepositoryRepo.delete).toHaveBeenCalledTimes(2); - expect(mockRepositoryRepo.delete).toHaveBeenCalledWith("folder-2"); - }); - }); - - it("continues if one worktree cleanup fails", async () => { - mockRepositoryRepo.findAll.mockReturnValue([ - { - id: "folder-1", - path: "/home/user/project-a", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - { - id: "folder-2", - path: "/home/user/project-b", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ]); - mockExistsSync.mockReturnValue(true); - mockWorktreeRepo.findAll.mockReturnValue([]); - mockWorktreeManager.cleanupOrphanedWorktrees - .mockRejectedValueOnce(new Error("cleanup error")) - .mockResolvedValueOnce({ deleted: [], errors: [] }); - - createService(); - await vi.waitFor(() => { - expect( - mockWorktreeManager.cleanupOrphanedWorktrees, - ).toHaveBeenCalledTimes(2); - }); - }); - }); - - describe("getFolders", () => { - it("returns empty array when no folders registered", async () => { - mockRepositoryRepo.findAll.mockReturnValue([]); - - const result = await service.getFolders(); - - expect(result).toEqual([]); - }); - - it("returns folders with exists property", async () => { - const repos = [ - { - id: "folder-1", - path: "/home/user/project", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ]; - mockRepositoryRepo.findAll.mockReturnValue(repos); - mockExistsSync.mockReturnValue(true); - - const result = await service.getFolders(); - - expect(result).toEqual([ - { - id: "folder-1", - path: "/home/user/project", - name: "project", - remoteUrl: null, - lastAccessed: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - exists: true, - }, - ]); - }); - - it("strips .git suffix from remote repo name in display name (defensive against legacy data)", async () => { - const repos = [ - { - id: "folder-1", - path: "/home/user/my-billing-fork", - remoteUrl: "PostHog/billing.git", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ]; - mockRepositoryRepo.findAll.mockReturnValue(repos); - mockExistsSync.mockReturnValue(true); - - const result = await service.getFolders(); - - expect(result[0].name).toBe("my-billing-fork (billing)"); - }); - - it("uses remote repo name in display name when it differs from local dir", async () => { - const repos = [ - { - id: "folder-1", - path: "/home/user/ph-tour-demo", - remoteUrl: "PostHog/hogotchi", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ]; - mockRepositoryRepo.findAll.mockReturnValue(repos); - mockExistsSync.mockReturnValue(true); - - const result = await service.getFolders(); - - expect(result[0].name).toBe("ph-tour-demo (hogotchi)"); - }); - - it("uses local dir name when it matches remote repo name", async () => { - const repos = [ - { - id: "folder-1", - path: "/home/user/hogotchi", - remoteUrl: "PostHog/hogotchi", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ]; - mockRepositoryRepo.findAll.mockReturnValue(repos); - mockExistsSync.mockReturnValue(true); - - const result = await service.getFolders(); - - expect(result[0].name).toBe("hogotchi"); - }); - - it("uses local dir name when it matches remote repo name case-insensitively", async () => { - const repos = [ - { - id: "folder-1", - path: "/home/user/Hogotchi", - remoteUrl: "PostHog/hogotchi", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ]; - mockRepositoryRepo.findAll.mockReturnValue(repos); - mockExistsSync.mockReturnValue(true); - - const result = await service.getFolders(); - - expect(result[0].name).toBe("Hogotchi"); - }); - - it("marks non-existent folders", async () => { - const repos = [ - { - id: "folder-1", - path: "/nonexistent/path", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }, - ]; - mockRepositoryRepo.findAll.mockReturnValue(repos); - mockExistsSync.mockReturnValue(false); - - const result = await service.getFolders(); - - expect(result[0].exists).toBe(false); - }); - }); - - describe("addFolder", () => { - it("adds a new folder when it is a git repository", async () => { - vi.mocked(isGitRepository).mockResolvedValue(true); - mockRepositoryRepo.findByPath.mockReturnValue(null); - mockRepositoryRepo.create.mockReturnValue({ - id: "folder-new", - path: "/home/user/my-project", - remoteUrl: null, - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }); - - const result = await service.addFolder("/home/user/my-project"); - - expect(result.name).toBe("my-project"); - expect(result.path).toBe("/home/user/my-project"); - expect(result.exists).toBe(true); - expect(mockRepositoryRepo.create).toHaveBeenCalledWith({ - path: "/home/user/my-project", - remoteUrl: undefined, - }); - }); - - it("throws error for invalid folder path", async () => { - await expect(service.addFolder("")).rejects.toThrow( - "Invalid folder path", - ); - }); - - it("prompts to initialize git for non-git folder", async () => { - vi.mocked(isGitRepository).mockResolvedValue(false); - mockDialog.confirm.mockResolvedValue(0); - mockInitRepositorySaga.run.mockResolvedValue({ - success: true, - data: { initialized: true }, - }); - mockRepositoryRepo.findByPath.mockReturnValue(null); - mockRepositoryRepo.create.mockReturnValue({ - id: "folder-new", - path: "/home/user/project", - remoteUrl: null, - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }); - - const result = await service.addFolder("/home/user/project"); - - expect(mockDialog.confirm).toHaveBeenCalled(); - expect(mockInitRepositorySaga.run).toHaveBeenCalledWith({ - baseDir: "/home/user/project", - initialCommit: true, - commitMessage: "Initial commit", - }); - expect(result.name).toBe("project"); - }); - - it("tags a new folder with the supplied remoteUrl override", async () => { - vi.mocked(isGitRepository).mockResolvedValue(true); - mockRepositoryRepo.findByPath.mockReturnValue(null); - mockRepositoryRepo.create.mockReturnValue({ - id: "folder-new", - path: "/home/user/fork", - remoteUrl: "PostHog/posthog", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }); - - await service.addFolder("/home/user/fork", { - remoteUrl: "https://github.com/PostHog/posthog", - }); - - expect(mockRepositoryRepo.create).toHaveBeenCalledWith({ - path: "/home/user/fork", - remoteUrl: "PostHog/posthog", - }); - }); - - it("normalizes a non-GitHub override and skips the local remote lookup", async () => { - vi.mocked(isGitRepository).mockResolvedValue(true); - vi.mocked(getRemoteUrl).mockResolvedValue( - "https://github.com/SomeoneElse/wrong", - ); - mockRepositoryRepo.findByPath.mockReturnValue(null); - mockRepositoryRepo.create.mockReturnValue({ - id: "folder-new", - path: "/home/user/fork", - remoteUrl: "https://gitlab.com/PostHog/posthog", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }); - - await service.addFolder("/home/user/fork", { - remoteUrl: "https://gitlab.com/PostHog/posthog.git", - }); - - expect(mockRepositoryRepo.create).toHaveBeenCalledWith({ - path: "/home/user/fork", - remoteUrl: "https://gitlab.com/PostHog/posthog", - }); - expect(getRemoteUrl).not.toHaveBeenCalled(); - }); - - it("backfills remoteUrl on an existing folder when override is supplied", async () => { - vi.mocked(isGitRepository).mockResolvedValue(true); - const existing = { - id: "folder-existing", - path: "/home/user/project", - remoteUrl: null, - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }; - mockRepositoryRepo.findByPath.mockReturnValue(existing); - mockRepositoryRepo.findById.mockReturnValue(existing); - - await service.addFolder("/home/user/project", { - remoteUrl: "https://github.com/PostHog/posthog", - }); - - expect(mockRepositoryRepo.updateRemoteUrl).toHaveBeenCalledWith( - "folder-existing", - "PostHog/posthog", - ); - }); - - it("throws error when user cancels git init", async () => { - vi.mocked(isGitRepository).mockResolvedValue(false); - mockDialog.confirm.mockResolvedValue(1); - - await expect(service.addFolder("/home/user/project")).rejects.toThrow( - "Folder must be a git repository", - ); - }); - }); - - describe("removeFolder", () => { - it("removes folder from database", async () => { - mockRepositoryRepo.findById.mockReturnValue({ - id: "folder-1", - path: "/home/user/project", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }); - mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([]); - - await service.removeFolder("folder-1"); - - expect(mockRepositoryRepo.delete).toHaveBeenCalledWith("folder-1"); - }); - - it("removes associated worktrees", async () => { - mockRepositoryRepo.findById.mockReturnValue({ - id: "folder-1", - path: "/home/user/project", - lastAccessedAt: "2024-01-01T00:00:00.000Z", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-01T00:00:00.000Z", - }); - mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([ - { - id: "workspace-1", - taskId: "task-1", - repositoryId: "folder-1", - mode: "worktree", - state: "active", - }, - ]); - mockWorktreeRepo.findByWorkspaceId.mockReturnValue({ - id: "worktree-1", - workspaceId: "workspace-1", - name: "code-task-1", - path: "/tmp/worktrees/project/code-task-1", - branch: "main", - }); - mockWorktreeManager.deleteWorktree.mockResolvedValue(undefined); - - await service.removeFolder("folder-1"); - - expect(mockWorktreeManager.deleteWorktree).toHaveBeenCalled(); - }); - }); - - describe("updateFolderAccessed", () => { - it("updates lastAccessed timestamp", async () => { - await service.updateFolderAccessed("folder-1"); - - expect(mockRepositoryRepo.updateLastAccessed).toHaveBeenCalledWith( - "folder-1", - ); - }); - }); - - describe("cleanupOrphanedWorktrees", () => { - it("delegates to WorktreeManager", async () => { - mockWorktreeRepo.findAll.mockReturnValue([]); - mockWorktreeManager.cleanupOrphanedWorktrees.mockResolvedValue({ - deleted: ["/tmp/worktrees/project/orphan-1"], - errors: [], - }); - - await service.cleanupOrphanedWorktrees("/home/user/project"); - - expect(mockWorktreeManager.cleanupOrphanedWorktrees).toHaveBeenCalledWith( - [], - ); - }); - - it("excludes associated worktrees from cleanup", async () => { - mockWorktreeRepo.findAll.mockReturnValue([ - { - id: "worktree-1", - workspaceId: "workspace-1", - name: "code-task-1", - path: "/tmp/worktrees/project/code-task-1", - branch: "main", - }, - ]); - mockWorktreeManager.cleanupOrphanedWorktrees.mockResolvedValue({ - deleted: [], - errors: [], - }); - - await service.cleanupOrphanedWorktrees("/home/user/project"); - - expect(mockWorktreeManager.cleanupOrphanedWorktrees).toHaveBeenCalledWith( - ["/tmp/worktrees/project/code-task-1"], - ); - }); - }); -}); diff --git a/apps/code/src/main/services/folders/service.ts b/apps/code/src/main/services/folders/service.ts deleted file mode 100644 index ccf338e982..0000000000 --- a/apps/code/src/main/services/folders/service.ts +++ /dev/null @@ -1,293 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -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 { inject, injectable } from "inversify"; -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"); - -@injectable() -export class FoldersService { - constructor( - @inject(MAIN_TOKENS.RepositoryRepository) - private readonly repositoryRepo: IRepositoryRepository, - @inject(MAIN_TOKENS.WorkspaceRepository) - private readonly workspaceRepo: IWorkspaceRepository, - @inject(MAIN_TOKENS.WorktreeRepository) - private readonly worktreeRepo: IWorktreeRepository, - @inject(MAIN_TOKENS.Dialog) - private readonly dialog: IDialog, - ) { - this.initialize().catch((err) => { - log.error("Folders initialization failed", err); - }); - } - - private async initialize(): Promise { - const folders = await this.getFolders(); - - const deletedFolders = folders.filter((f) => !f.exists); - if (deletedFolders.length > 0) { - let removed = 0; - for (const folder of deletedFolders) { - try { - await this.removeFolder(folder.id); - removed++; - } catch (err) { - log.error(`Failed to remove deleted folder ${folder.path}:`, err); - } - } - if (removed > 0) { - log.info(`Removed ${removed} deleted folder(s)`); - } - } - - const existingFolders = folders.filter((f) => f.exists); - const results = await Promise.allSettled( - existingFolders.map((folder) => - this.cleanupOrphanedWorktrees(folder.path), - ), - ); - for (const [i, result] of results.entries()) { - if (result.status === "rejected") { - log.error( - `Failed to cleanup orphaned worktrees for ${existingFolders[i].path}:`, - result.reason, - ); - } - } - } - - private getDisplayName( - repoPath: string, - remoteUrl: string | null | undefined, - ): string { - const localName = path.basename(repoPath); - if (remoteUrl) { - const repoName = normalizeRepoKey(remoteUrl).split("/").pop(); - if (repoName && repoName.toLowerCase() !== localName.toLowerCase()) { - return `${localName} (${repoName})`; - } - } - return localName; - } - - async getFolders(): Promise<(RegisteredFolder & { exists: boolean })[]> { - const repos = this.repositoryRepo.findAll(); - return repos - .filter((r) => r.path) - .map((r) => ({ - id: r.id, - path: r.path, - name: this.getDisplayName(r.path, r.remoteUrl), - remoteUrl: r.remoteUrl ?? null, - lastAccessed: r.lastAccessedAt ?? r.createdAt, - createdAt: r.createdAt, - exists: fs.existsSync(r.path), - })); - } - - async addFolder( - folderPath: string, - options: { remoteUrl?: string } = {}, - ): Promise { - const folderName = path.basename(folderPath); - if (!folderPath || !folderName) { - throw new Error( - `Invalid folder path: "${folderPath}" - path must have a valid directory name`, - ); - } - - const isRepo = await isGitRepository(folderPath); - - if (!isRepo) { - const response = await this.dialog.confirm({ - severity: "question", - title: "Initialize Git Repository", - message: "This folder is not a git repository", - detail: `Would you like to initialize git in "${path.basename(folderPath)}"?`, - options: ["Initialize Git", "Cancel"], - defaultIndex: 0, - cancelIndex: 1, - }); - - if (response === 1) { - throw new Error("Folder must be a git repository"); - } - - const saga = new InitRepositorySaga(); - const initResult = await saga.run({ - baseDir: folderPath, - initialCommit: true, - commitMessage: "Initial commit", - }); - if (!initResult.success) { - throw new Error( - `Failed to initialize git repository: ${initResult.error}`, - ); - } - } - - const repoKey = await this.resolveRepoKey(folderPath, options.remoteUrl); - const existingRepo = this.repositoryRepo.findByPath(folderPath); - let repo: Repository; - - if (existingRepo) { - this.repositoryRepo.updateLastAccessed(existingRepo.id); - const updated = this.repositoryRepo.findById(existingRepo.id); - if (!updated) { - throw new Error(`Repository ${existingRepo.id} not found after update`); - } - repo = updated; - - if (repoKey && repo.remoteUrl !== repoKey) { - this.repositoryRepo.updateRemoteUrl(repo.id, repoKey); - const refreshed = this.repositoryRepo.findById(repo.id); - if (!refreshed) { - throw new Error( - `Repository ${repo.id} not found after remote URL update`, - ); - } - repo = refreshed; - } - } else { - repo = this.repositoryRepo.create({ - path: folderPath, - remoteUrl: repoKey ?? undefined, - }); - } - - return { - id: repo.id, - path: repo.path, - name: this.getDisplayName(repo.path, repo.remoteUrl), - remoteUrl: repo.remoteUrl ?? null, - lastAccessed: repo.lastAccessedAt ?? repo.createdAt, - createdAt: repo.createdAt, - exists: true, - }; - } - - async removeFolder(folderId: string): Promise { - const repo = this.repositoryRepo.findById(folderId); - if (!repo) { - log.debug(`Folder not found: ${folderId}`); - return; - } - - const workspaces = this.workspaceRepo.findAllByRepositoryId(folderId); - const worktreeBasePath = getWorktreeLocation(); - const repoName = path.basename(repo.path); - - for (const workspace of workspaces) { - if (workspace.mode === "worktree") { - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - if (worktree) { - const worktreePath = path.join( - worktreeBasePath, - repoName, - worktree.name, - ); - try { - const manager = new WorktreeManager({ - mainRepoPath: repo.path, - worktreeBasePath, - }); - await manager.deleteWorktree(worktreePath); - } catch (error) { - log.error(`Failed to delete worktree ${worktreePath}:`, error); - } - } - } - } - - this.repositoryRepo.delete(folderId); - log.debug(`Removed folder with ID: ${folderId}`); - } - - async updateFolderAccessed(folderId: string): Promise { - this.repositoryRepo.updateLastAccessed(folderId); - } - - async cleanupOrphanedWorktrees(mainRepoPath: string): Promise { - const worktreeBasePath = getWorktreeLocation(); - const manager = new WorktreeManager({ mainRepoPath, worktreeBasePath }); - - const allWorktrees = this.worktreeRepo.findAll(); - const associatedWorktreePaths = allWorktrees.map((wt) => wt.path); - - await manager.cleanupOrphanedWorktrees(associatedWorktreePaths); - } - - private async resolveRepoKey( - folderPath: string, - overrideRemoteUrl: string | undefined, - ): Promise { - const slug = (url: string | null | undefined) => { - const parsed = parseGithubUrl(url); - return parsed ? `${parsed.owner}/${parsed.repo}` : null; - }; - if (overrideRemoteUrl) { - return slug(overrideRemoteUrl) ?? normalizeRepoKey(overrideRemoteUrl); - } - const localRemoteUrl = await getRemoteUrl(folderPath); - return slug(localRemoteUrl); - } - - getRepositoryByRemoteUrl( - remoteUrl: string, - ): { id: string; path: string } | null { - const repo = this.repositoryRepo.findByRemoteUrl(remoteUrl); - if (!repo) return null; - return { id: repo.id, path: repo.path }; - } - - getMostRecentlyAccessedRepository(): { id: string; path: string } | null { - const repo = this.repositoryRepo.findMostRecentlyAccessed(); - if (!repo) return null; - return { id: repo.id, path: repo.path }; - } - - async clearAllData(): Promise { - const workspaces = this.workspaceRepo.findAll(); - const worktreeBasePath = getWorktreeLocation(); - - for (const workspace of workspaces) { - if (workspace.mode === "worktree" && workspace.repositoryId) { - const repo = this.repositoryRepo.findById(workspace.repositoryId); - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - if (repo && worktree) { - try { - const manager = new WorktreeManager({ - mainRepoPath: repo.path, - worktreeBasePath, - }); - await manager.deleteWorktree(worktree.path); - } catch (error) { - log.error(`Failed to delete worktree ${worktree.path}:`, error); - } - } - } - } - - this.worktreeRepo.deleteAll(); - this.workspaceRepo.deleteAll(); - this.repositoryRepo.deleteAll(); - - log.info("Cleared all application data"); - } -} 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 index d6b220abfb..e044dfb799 100644 --- a/apps/code/src/main/services/fs/service.ts +++ b/apps/code/src/main/services/fs/service.ts @@ -1,213 +1,65 @@ -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"; +// PORT NOTE: bridge to @posthog/workspace-server fs capability. Forwards every +// call to the workspace-server FsService via WorkspaceClient. Delete when the +// remaining in-process consumer (AgentService) reads/writes repo files through +// workspace-client directly instead of injecting MAIN_TOKENS.FsService. +import type { WorkspaceClient } from "@posthog/workspace-client/client"; +import type { + BoundedReadResult, + FileEntry, +} from "@posthog/workspace-server/services/fs/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); - }); - } + constructor(private readonly workspace: WorkspaceClient) {} - async listRepoFiles( + 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 []; - } + return this.workspace.fs.listRepoFiles.query({ repoPath, query, limit }); } - invalidateCache(repoPath?: string): void { - if (repoPath) { - this.cache.delete(repoPath); - } else { - this.cache.clear(); - } + readRepoFile(repoPath: string, filePath: string): Promise { + return this.workspace.fs.readRepoFile.query({ repoPath, filePath }); } - 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( + 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); + return this.workspace.fs.readRepoFiles.query({ repoPath, filePaths }); } - async readRepoFileBounded( + 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" }; - } + return this.workspace.fs.readRepoFileBounded.query({ + repoPath, + filePath, + maxLines, + }); } - async readRepoFilesBounded( + 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); + return this.workspace.fs.readRepoFilesBounded.query({ + repoPath, + filePaths, + maxLines, + }); } - 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; - } + readAbsoluteFile(filePath: string): Promise { + return this.workspace.fs.readAbsoluteFile.query({ filePath }); } - 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; - } + readFileAsBase64(filePath: string): Promise { + return this.workspace.fs.readFileAsBase64.query({ filePath }); } async writeRepoFile( @@ -215,92 +67,10 @@ export class FsService { filePath: string, content: string, ): Promise { - await fs.promises.writeFile( - this.resolvePath(repoPath, filePath), + await this.workspace.fs.writeRepoFile.mutate({ + 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/git-pr-host.ts b/apps/code/src/main/services/git/git-pr-host.ts new file mode 100644 index 0000000000..40fa94cd06 --- /dev/null +++ b/apps/code/src/main/services/git/git-pr-host.ts @@ -0,0 +1,271 @@ +import { + GitServiceEvent, + type GitServiceEvents, + type HostGitWorkspaceClient, +} from "@posthog/core/git/host-git"; +import { GIT_WORKSPACE_CLIENT } from "@posthog/core/git/identifiers"; +import type { + CreatePrInput, + CreatePrOutput, + GitStateSnapshot, +} from "@posthog/core/git/router-schemas"; +import type { GitPrService } from "@posthog/core/git-pr/git-pr"; +import type { CreatePrHost } from "@posthog/core/git-pr/identifiers"; +import { GIT_PR_SERVICE } from "@posthog/core/git-pr/identifiers"; +import { TypedEventEmitter } from "@posthog/shared"; +import type { AgentService } from "@posthog/workspace-server/services/agent/agent"; +import { AGENT_SERVICE } from "@posthog/workspace-server/services/agent/identifiers"; +import type { SidebarPrState } from "@posthog/workspace-server/services/workspace/schemas"; +import type { WorkspaceService } from "@posthog/workspace-server/services/workspace/workspace"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; + +const log = logger.scope("git-pr-host"); + +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; +} + +/** + * Host for the LLM-driven git-pr business (`@posthog/core` GitPrService): owns + * transport (progress events) and supplies the git/gh operations the core saga + * drives. Every git/gh CLI op routes through workspace-client (ws-server); only + * the auth-pinned LLM orchestration stays in the host process. + */ +@injectable() +export class GitPrHostService extends TypedEventEmitter { + constructor( + @inject(MAIN_TOKENS.WorkspaceService) + private readonly workspaceService: WorkspaceService, + @inject(AGENT_SERVICE) + private readonly agentService: AgentService, + @inject(GIT_PR_SERVICE) + private readonly gitPrService: GitPrService, + @inject(GIT_WORKSPACE_CLIENT) + private readonly workspaceClient: HostGitWorkspaceClient, + ) { + super(); + } + + 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) => 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<{ 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); + + 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) { + 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.workspaceService.linkBranch(taskId, branch, source), + getPrState: (dir) => this.getPrStateSnapshot(dir), + }; + } +} diff --git a/apps/code/src/main/services/git/schemas.ts b/apps/code/src/main/services/git/schemas.ts deleted file mode 100644 index f25a73f69c..0000000000 --- a/apps/code/src/main/services/git/schemas.ts +++ /dev/null @@ -1,625 +0,0 @@ -import { z } from "zod"; - -// Common schemas -export const directoryPathInput = z.object({ - directoryPath: z.string(), -}); - -export const gitFileStatusSchema = z.enum([ - "modified", - "added", - "deleted", - "renamed", - "untracked", -]); - -export type GitFileStatus = z.infer; - -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; - -export const diffStatsSchema = z.object({ - filesChanged: z.number(), - linesAdded: z.number(), - linesRemoved: z.number(), -}); - -export type DiffStats = z.infer; - -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; - -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; - -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; - -// detectRepo schemas -export const detectRepoInput = z.object({ - directoryPath: z.string(), -}); - -export const detectRepoOutput = z - .object({ - organization: z.string(), - repository: z.string(), - remote: z.string().optional(), - branch: z.string().optional(), - }) - .nullable(); - -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(), - cloneId: z.string(), -}); - -export const cloneRepositoryOutput = z.object({ - cloneId: z.string(), -}); - -export const cloneProgressStatus = z.enum(["cloning", "complete", "error"]); - -export const cloneProgressPayload = z.object({ - cloneId: z.string(), - status: cloneProgressStatus, - message: z.string(), -}); - -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", - "cherry-pick", - "revert", -]); - -export const gitBusyStateSchema = z.union([ - z.object({ busy: z.literal(false) }), - z.object({ - busy: z.literal(true), - operation: gitBusyOperationSchema, - }), -]); - -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(), -}); - -export const checkoutBranchInput = z.object({ - directoryPath: z.string(), - branchName: z.string(), -}); -export const checkoutBranchOutput = z.object({ - previousBranch: z.string(), - 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"), - branch: z.string().optional(), - setUpstream: z.boolean().default(false), -}); - -export type PushInput = z.infer; - -// Pull operation -export const pullInput = z.object({ - directoryPath: z.string(), - remote: z.string().default("origin"), - branch: z.string().optional(), -}); - -export type PullInput = z.infer; - -// Commit operation -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(), - taskId: z.string().optional(), -}); - -export type CommitInput = z.infer; - -// Git CLI status -export const gitStatusOutput = z.object({ - installed: z.boolean(), - version: z.string().nullable(), -}); - -export type GitStatusOutput = z.infer; - -// GitHub CLI status -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; - -export const ghAuthTokenOutput = z.object({ - success: z.boolean(), - token: z.string().nullable(), - error: z.string().nullable(), -}); - -export type GhAuthTokenOutput = z.infer; - -// Pull request status -export const prStatusInput = directoryPathInput; -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 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(), -}); -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(), - branchName: z.string().optional(), - commitMessage: z.string().optional(), - prTitle: z.string().optional(), - prBody: z.string().optional(), - draft: z.boolean().optional(), - stagedOnly: z.boolean().optional(), - taskId: z.string().optional(), - conversationContext: z.string().optional(), -}); - -export type CreatePrInput = z.infer; - -// Open PR operation -export const openPrInput = directoryPathInput; -export const openPrOutput = z.object({ - success: z.boolean(), - message: z.string(), - prUrl: z.string().nullable(), -}); - -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"), -}); - -export type PublishInput = z.infer; - -// Sync (pull then push) operation -export const syncInput = z.object({ - directoryPath: z.string(), - remote: z.string().default("origin"), -}); - -export type SyncInput = z.infer; - -// PR Template lookup -export const getPrTemplateInput = directoryPathInput; - -export const getPrTemplateOutput = z.object({ - template: z.string().nullable(), - templatePath: z.string().nullable(), -}); - -export type GetPrTemplateOutput = z.infer; - -// Commit conventions analysis -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 ->; - -// 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(), -}); -export const getPrDetailsByUrlOutput = z.object({ - state: z.string(), - merged: z.boolean(), - draft: z.boolean(), -}); -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 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(), - resolved: z.boolean(), -}); -export const resolveReviewThreadOutput = z.object({ - success: z.boolean(), - isResolved: z.boolean(), -}); -export type ResolveReviewThreadOutput = z.infer< - typeof resolveReviewThreadOutput ->; - -// replyToPrComment schemas -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; - -// 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, -}); -export const updatePrByUrlOutput = z.object({ - success: z.boolean(), - message: z.string(), -}); -export type UpdatePrByUrlOutput = z.infer; - -export const getBranchChangedFilesInput = z.object({ - repo: z.string(), - branch: z.string(), -}); -export const getBranchChangedFilesOutput = z.array(changedFileSchema); - -export const getLocalBranchChangedFilesInput = z.object({ - directoryPath: z.string(), - branch: z.string(), -}); -export const getLocalBranchChangedFilesOutput = z.array(changedFileSchema); - -export const generateCommitMessageInput = z.object({ - directoryPath: z.string(), - conversationContext: z.string().optional(), -}); - -export const generateCommitMessageOutput = z.object({ - message: z.string(), -}); - -export const generatePrTitleAndBodyInput = z.object({ - directoryPath: z.string(), - conversationContext: z.string().optional(), -}); - -export const generatePrTitleAndBodyOutput = z.object({ - title: z.string(), - body: z.string(), -}); - -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; - -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; - -export const pushOutput = z.object({ - success: z.boolean(), - message: z.string(), - state: gitStateSnapshotSchema.optional(), -}); - -export type PushOutput = z.infer; - -export const pullOutput = z.object({ - success: z.boolean(), - message: z.string(), - updatedFiles: z.number().optional(), - state: gitStateSnapshotSchema.optional(), -}); - -export type PullOutput = z.infer; - -export const publishOutput = z.object({ - success: z.boolean(), - message: z.string(), - branch: z.string(), - state: gitStateSnapshotSchema.optional(), -}); - -export type PublishOutput = z.infer; - -export const syncOutput = z.object({ - success: z.boolean(), - pullMessage: z.string(), - pushMessage: z.string(), - state: gitStateSnapshotSchema.optional(), -}); - -export type SyncOutput = z.infer; - -export const createPrStep = z.enum([ - "creating-branch", - "committing", - "pushing", - "creating-pr", - "complete", - "error", -]); - -export type CreatePrStep = z.infer; - -export const createPrOutput = z.object({ - success: z.boolean(), - message: z.string(), - prUrl: z.string().nullable(), - failedStep: createPrStep.nullable(), - state: gitStateSnapshotSchema.optional(), -}); - -export type CreatePrOutput = z.infer; - -export const discardFileChangesOutput = z.object({ - success: z.boolean(), - state: gitStateSnapshotSchema.optional(), -}); - -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 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 createPrProgressPayload = z.object({ - flowId: z.string(), - step: createPrStep, - message: z.string(), - prUrl: z.string().optional(), -}); - -export type CreatePrProgressPayload = z.infer; 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/github-integration/service.ts b/apps/code/src/main/services/github-integration/service.ts deleted file mode 100644 index 87524cd277..0000000000 --- a/apps/code/src/main/services/github-integration/service.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; -import type { IUrlLauncher } from "@posthog/platform/url-launcher"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -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"); - -const FLOW_TIMEOUT_MS = 5 * 60 * 1000; - -export const GitHubIntegrationEvent = { - Callback: "callback", - FlowTimedOut: "flowTimedOut", -} as const; - -export interface IntegrationCallback { - provider: string; - projectId: number | null; - installationId: string | null; - status: "success" | "error"; - errorCode: string | null; - errorMessage: string | null; -} - -export interface FlowTimedOut { - projectId: number; -} - -export interface GitHubIntegrationEvents { - [GitHubIntegrationEvent.Callback]: IntegrationCallback; - [GitHubIntegrationEvent.FlowTimedOut]: FlowTimedOut; -} - -@injectable() -export class GitHubIntegrationService extends TypedEventEmitter { - private pendingCallback: IntegrationCallback | null = null; - private flowTimeout: ReturnType | null = null; - - constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.UrlLauncher) - private readonly urlLauncher: IUrlLauncher, - @inject(MAIN_TOKENS.MainWindow) - private readonly mainWindow: IMainWindow, - ) { - super(); - - this.deepLinkService.registerHandler("integration", (_path, params) => - this.handleCallback(params), - ); - } - - public async startFlow( - region: CloudRegion, - projectId: number, - ): Promise { - try { - const cloudUrl = getCloudUrlFromRegion(region); - const nextPath = `/account-connected/github-integration?provider=github&project_id=${projectId}&connect_from=posthog_code`; - const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=github&next=${encodeURIComponent(nextPath)}`; - - this.clearFlowTimeout(); - this.flowTimeout = setTimeout(() => { - log.warn("GitHub integration flow timed out", { projectId }); - this.flowTimeout = null; - this.emit(GitHubIntegrationEvent.FlowTimedOut, { projectId }); - }, FLOW_TIMEOUT_MS); - - await this.urlLauncher.launch(authorizeUrl); - - return { success: true }; - } catch (error) { - this.clearFlowTimeout(); - log.error("Failed to start GitHub integration flow", { - projectId, - error: error instanceof Error ? error.message : String(error), - }); - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } - } - - public consumePendingCallback(): IntegrationCallback | null { - const pending = this.pendingCallback; - this.pendingCallback = null; - return pending; - } - - private handleCallback(params: URLSearchParams): boolean { - const projectIdRaw = params.get("project_id"); - const parsedProjectId = projectIdRaw ? Number(projectIdRaw) : null; - const status = params.get("status") === "error" ? "error" : "success"; - - const callback: IntegrationCallback = { - provider: params.get("provider") ?? "", - projectId: - parsedProjectId !== null && Number.isFinite(parsedProjectId) - ? parsedProjectId - : null, - installationId: params.get("installation_id") || null, - status, - errorCode: params.get("error_code") || null, - errorMessage: params.get("error_message") || null, - }; - - this.clearFlowTimeout(); - - if (status === "error") { - log.error("Received integration callback with error", { - provider: callback.provider, - projectId: callback.projectId, - errorCode: callback.errorCode, - errorMessage: callback.errorMessage, - }); - } - - const hasListeners = - this.listenerCount(GitHubIntegrationEvent.Callback) > 0; - if (hasListeners) { - this.emit(GitHubIntegrationEvent.Callback, callback); - } else { - this.pendingCallback = callback; - } - - if (this.mainWindow.isMinimized()) { - this.mainWindow.restore(); - } - this.mainWindow.focus(); - - return true; - } - - private clearFlowTimeout(): void { - if (this.flowTimeout) { - clearTimeout(this.flowTimeout); - this.flowTimeout = null; - } - } -} diff --git a/apps/code/src/main/services/handoff/git-gateway.ts b/apps/code/src/main/services/handoff/git-gateway.ts new file mode 100644 index 0000000000..b7631edda6 --- /dev/null +++ b/apps/code/src/main/services/handoff/git-gateway.ts @@ -0,0 +1,45 @@ +import type { HandoffChangedFile, HandoffLocalGitState } from "@posthog/shared"; +import type { WorkspaceClient } from "@posthog/workspace-client/client"; +import type { HandoffGitGateway } from "@posthog/workspace-server/services/handoff/ports"; + +/** + * Desktop transport adapter: the handoff host's git operations run in the + * workspace-server child process, reached over the workspace client. + */ +export class TrpcHandoffGitGateway implements HandoffGitGateway { + constructor(private readonly workspace: WorkspaceClient) {} + + async getChangedFiles( + repoPath: string, + ): Promise { + const files = await this.workspace.git.getChangedFilesHead.query({ + directoryPath: repoPath, + }); + return files.map((f) => ({ + path: f.path, + status: f.status, + linesAdded: f.linesAdded, + linesRemoved: f.linesRemoved, + })); + } + + getLocalGitState(repoPath: string): Promise { + return this.workspace.git.readHandoffLocalGitState.query({ + directoryPath: repoPath, + }); + } + + cleanupAfterCloudHandoff( + repoPath: string, + branchName: string | null, + ): Promise<{ + stashed: boolean; + switched: boolean; + defaultBranch: string | null; + }> { + return this.workspace.git.cleanupAfterCloudHandoff.mutate({ + directoryPath: repoPath, + branchName, + }); + } +} diff --git a/apps/code/src/main/services/handoff/schemas.ts b/apps/code/src/main/services/handoff/schemas.ts deleted file mode 100644 index 290a818b8c..0000000000 --- a/apps/code/src/main/services/handoff/schemas.ts +++ /dev/null @@ -1,131 +0,0 @@ -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"; - -const handoffBaseInput = z.object({ - taskId: z.string(), - runId: z.string(), - repoPath: z.string(), -}); - -const handoffApiInput = handoffBaseInput.extend({ - apiHost: z.string(), - teamId: z.number(), -}); - -export const handoffErrorCodeSchema = z.enum(["github_authorization_required"]); - -export type HandoffErrorCode = z.infer; - -const handoffBaseResult = z.object({ - success: z.boolean(), - error: z.string().optional(), - code: handoffErrorCodeSchema.optional(), -}); - -export const handoffPreflightInput = handoffApiInput; - -export type HandoffPreflightInput = z.infer; - -export const handoffPreflightResult = z.object({ - canHandoff: z.boolean(), - reason: z.string().optional(), - localTreeDirty: z.boolean(), - localGitState: handoffLocalGitStateSchema.optional(), - changedFiles: z - .array( - z.object({ - path: z.string(), - status: z.enum([ - "modified", - "added", - "deleted", - "renamed", - "untracked", - ]), - linesAdded: z.number().optional(), - linesRemoved: z.number().optional(), - }), - ) - .optional(), -}); - -export type HandoffPreflightResult = z.infer; - -export const handoffExecuteInput = handoffApiInput.extend({ - sessionId: z.string().optional(), - adapter: z.enum(["claude", "codex"]).optional(), - localGitState: handoffLocalGitStateSchema.optional(), -}); - -export type HandoffExecuteInput = z.infer; - -export const handoffExecuteResult = handoffBaseResult.extend({ - sessionId: z.string().optional(), -}); - -export type HandoffExecuteResult = z.infer; - -export const handoffToCloudPreflightInput = handoffBaseInput; - -export type HandoffToCloudPreflightInput = z.infer< - typeof handoffToCloudPreflightInput ->; - -export const handoffToCloudPreflightResult = z.object({ - canHandoff: z.boolean(), - reason: z.string().optional(), - localGitState: handoffLocalGitStateSchema.optional(), -}); - -export type HandoffToCloudPreflightResult = z.infer< - typeof handoffToCloudPreflightResult ->; - -export const handoffToCloudExecuteInput = handoffApiInput.extend({ - localGitState: handoffLocalGitStateSchema.optional(), -}); - -export type HandoffToCloudExecuteInput = z.infer< - typeof handoffToCloudExecuteInput ->; - -export const handoffToCloudExecuteResult = handoffBaseResult.extend({ - logEntryCount: z.number().optional(), -}); - -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; - message: string; -} - -export const HandoffEvent = { - Progress: "handoff-progress", -} as const; - -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/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/inbox-link/service.test.ts b/apps/code/src/main/services/inbox-link/service.test.ts deleted file mode 100644 index 747b23e5ff..0000000000 --- a/apps/code/src/main/services/inbox-link/service.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; -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 { DeepLinkHandler, DeepLinkService } from "../deep-link/service"; -import { InboxLinkEvent, InboxLinkService } from "./service"; - -function makeDeepLinkService() { - const handlers = new Map(); - const service = { - registerHandler: vi.fn((key: string, handler: DeepLinkHandler) => { - handlers.set(key, handler); - }), - trigger: (key: string, path: string) => { - const handler = handlers.get(key); - if (!handler) throw new Error(`No handler for ${key}`); - return handler(path, new URLSearchParams()); - }, - }; - return service as unknown as DeepLinkService & { - trigger: (key: string, path: string) => boolean; - }; -} - -function makeMainWindow() { - return { - focus: vi.fn(), - restore: vi.fn(), - isMinimized: vi.fn().mockReturnValue(false), - } as unknown as IMainWindow & { - focus: ReturnType; - restore: ReturnType; - isMinimized: ReturnType; - }; -} - -describe("InboxLinkService", () => { - let deepLinkService: ReturnType; - let mainWindow: ReturnType; - let service: InboxLinkService; - - beforeEach(() => { - deepLinkService = makeDeepLinkService(); - mainWindow = makeMainWindow(); - service = new InboxLinkService(deepLinkService, mainWindow); - }); - - it("registers an 'inbox' handler on the DeepLinkService", () => { - expect(deepLinkService.registerHandler).toHaveBeenCalledWith( - "inbox", - expect.any(Function), - ); - }); - - it("emits OpenReport when a listener is attached", () => { - const listener = vi.fn(); - service.on(InboxLinkEvent.OpenReport, listener); - - const result = deepLinkService.trigger("inbox", "abc-123"); - - expect(result).toBe(true); - expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" }); - }); - - it("queues a pending deep link when no listener is attached", () => { - deepLinkService.trigger("inbox", "pending-id"); - - const pending = service.consumePendingDeepLink(); - expect(pending).toEqual({ reportId: "pending-id" }); - - // Draining clears it - expect(service.consumePendingDeepLink()).toBeNull(); - }); - - it("takes only the first path segment as the report id", () => { - const listener = vi.fn(); - service.on(InboxLinkEvent.OpenReport, listener); - - deepLinkService.trigger("inbox", "abc-123/extra/segments"); - - expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" }); - }); - - it("ignores a trailing slug segment after the report id", () => { - const listener = vi.fn(); - service.on(InboxLinkEvent.OpenReport, listener); - - deepLinkService.trigger("inbox", "abc-123/fix-inbox--Add-foo"); - - expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" }); - }); - - it("returns false and does not emit when the path is empty", () => { - const listener = vi.fn(); - service.on(InboxLinkEvent.OpenReport, listener); - - const result = deepLinkService.trigger("inbox", ""); - - expect(result).toBe(false); - expect(listener).not.toHaveBeenCalled(); - }); - - it("focuses the main window on link arrival", () => { - deepLinkService.trigger("inbox", "abc-123"); - - expect(mainWindow.focus).toHaveBeenCalledTimes(1); - expect(mainWindow.restore).not.toHaveBeenCalled(); - }); - - it("restores the main window when it is minimized", () => { - mainWindow.isMinimized.mockReturnValue(true); - - deepLinkService.trigger("inbox", "abc-123"); - - expect(mainWindow.restore).toHaveBeenCalledTimes(1); - expect(mainWindow.focus).toHaveBeenCalledTimes(1); - }); -}); diff --git a/apps/code/src/main/services/inbox-link/service.ts b/apps/code/src/main/services/inbox-link/service.ts deleted file mode 100644 index 8d78e8b409..0000000000 --- a/apps/code/src/main/services/inbox-link/service.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; -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"); - -export const InboxLinkEvent = { - OpenReport: "openReport", -} as const; - -export interface InboxLinkEvents { - [InboxLinkEvent.OpenReport]: { reportId: string }; -} - -export interface PendingInboxDeepLink { - reportId: string; -} - -@injectable() -export class InboxLinkService extends TypedEventEmitter { - private pendingDeepLink: PendingInboxDeepLink | null = null; - - constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.MainWindow) - private readonly mainWindow: IMainWindow, - ) { - super(); - - this.deepLinkService.registerHandler("inbox", (path) => - this.handleInboxLink(path), - ); - } - - 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"); - return false; - } - - const hasListeners = this.listenerCount(InboxLinkEvent.OpenReport) > 0; - - if (hasListeners) { - log.info(`Emitting inbox link event: reportId=${reportId}`); - this.emit(InboxLinkEvent.OpenReport, { reportId }); - } else { - log.info( - `Queueing inbox link (renderer not ready): reportId=${reportId}`, - ); - this.pendingDeepLink = { reportId }; - } - - log.info("Deep link focusing window", { reportId }); - if (this.mainWindow.isMinimized()) { - this.mainWindow.restore(); - } - this.mainWindow.focus(); - - return true; - } - - public consumePendingDeepLink(): PendingInboxDeepLink | null { - const pending = this.pendingDeepLink; - this.pendingDeepLink = null; - if (pending) { - log.info(`Consumed pending inbox link: reportId=${pending.reportId}`); - } - return pending; - } -} diff --git a/apps/code/src/main/services/integration-flow-schemas.ts b/apps/code/src/main/services/integration-flow-schemas.ts index f2d3220591..ed9a56eecb 100644 --- a/apps/code/src/main/services/integration-flow-schemas.ts +++ b/apps/code/src/main/services/integration-flow-schemas.ts @@ -1,20 +1,11 @@ -import { z } from "zod"; - -export const cloudRegion = z.enum(["us", "eu", "dev"]); -export type CloudRegion = z.infer; - -export const startIntegrationFlowInput = z.object({ - region: cloudRegion, - projectId: z.number(), -}); -export type StartIntegrationFlowInput = z.infer< - typeof startIntegrationFlowInput ->; - -export const startIntegrationFlowOutput = z.object({ - success: z.boolean(), - error: z.string().optional(), -}); -export type StartIntegrationFlowOutput = z.infer< - typeof startIntegrationFlowOutput ->; +// PORT NOTE: bridge to @posthog/core/integrations/schemas. Delete once +// github-integration + slack-integration services move to packages/core and +// import the integration flow schemas from there directly. +export { + type CloudRegion, + cloudRegion, + type StartIntegrationFlowInput, + startIntegrationFlowInput, + type StartIntegrationFlowOutput, + startIntegrationFlowOutput, +} from "@posthog/core/integrations/schemas"; 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/linear-integration/service.ts b/apps/code/src/main/services/linear-integration/service.ts deleted file mode 100644 index 1cf3ff2a40..0000000000 --- a/apps/code/src/main/services/linear-integration/service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { IUrlLauncher } from "@posthog/platform/url-launcher"; -import { getCloudUrlFromRegion } from "@shared/utils/urls.js"; -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"); - -@injectable() -export class LinearIntegrationService { - constructor( - @inject(MAIN_TOKENS.UrlLauncher) - private readonly urlLauncher: IUrlLauncher, - ) {} - - public async startFlow( - region: CloudRegion, - projectId: number, - ): 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 }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } - } -} diff --git a/apps/code/src/main/services/llm-gateway/schemas.ts b/apps/code/src/main/services/llm-gateway/schemas.ts deleted file mode 100644 index 7c569c8953..0000000000 --- a/apps/code/src/main/services/llm-gateway/schemas.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; -import { z } from "zod"; - -export const llmMessageSchema = z.object({ - role: z.enum(["user", "assistant"]), - content: z.string(), -}); - -export type LlmMessage = z.infer; - -export const promptInput = z.object({ - system: z.string().optional(), - messages: z.array(llmMessageSchema), - maxTokens: z.number().optional(), - model: z.string().default(DEFAULT_GATEWAY_MODEL), -}); - -export type PromptInput = z.infer; - -export const promptOutput = z.object({ - content: z.string(), - model: z.string(), - stopReason: z.string().nullable(), - usage: z.object({ - inputTokens: z.number(), - outputTokens: z.number(), - }), -}); - -export type PromptOutput = z.infer; - -export interface AnthropicMessagesRequest { - model: string; - messages: Array<{ role: "user" | "assistant"; content: string }>; - max_tokens?: number; - system?: string; - stream?: boolean; -} - -export interface AnthropicMessagesResponse { - id: string; - type: "message"; - role: "assistant"; - content: Array<{ type: "text"; text: string }>; - model: string; - stop_reason: string | null; - usage: { - input_tokens: number; - output_tokens: number; - }; -} - -export interface AnthropicErrorResponse { - error: { - message: string; - type: string; - code?: string; - }; -} - -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; diff --git a/apps/code/src/main/services/llm-gateway/service.ts b/apps/code/src/main/services/llm-gateway/service.ts deleted file mode 100644 index 11813e474f..0000000000 --- a/apps/code/src/main/services/llm-gateway/service.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; -import { - getGatewayInvalidatePlanCacheUrl, - getGatewayUsageUrl, - 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 AnthropicErrorResponse, - type AnthropicMessagesRequest, - type AnthropicMessagesResponse, - type LlmMessage, - type PromptOutput, - type UsageOutput, - usageOutput, -} from "./schemas"; - -const log = logger.scope("llm-gateway"); - -export class LlmGatewayError extends Error { - constructor( - message: string, - public readonly type: string, - public readonly code?: string, - public readonly statusCode?: number, - ) { - super(message); - this.name = "LlmGatewayError"; - } -} - -@injectable() -export class LlmGatewayService { - constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, - ) {} - - async prompt( - messages: LlmMessage[], - options: { - system?: string; - maxTokens?: number; - model?: string; - signal?: AbortSignal; - timeoutMs?: number; - } = {}, - ): Promise { - const { - system, - maxTokens, - model = DEFAULT_GATEWAY_MODEL, - signal, - timeoutMs = 60_000, - } = options; - - const auth = await this.authService.getValidAccessToken(); - const gatewayUrl = getLlmGatewayUrl(auth.apiHost); - const messagesUrl = `${gatewayUrl}/v1/messages`; - - const requestBody: AnthropicMessagesRequest = { - model, - messages: messages.map((m) => ({ role: m.role, content: m.content })), - stream: false, - }; - - if (maxTokens !== undefined) { - requestBody.max_tokens = maxTokens; - } - - if (system) { - requestBody.system = system; - } - - log.debug("Sending request to LLM gateway", { - url: messagesUrl, - model, - messageCount: messages.length, - }); - - const timeoutController = new AbortController(); - const timeoutId = setTimeout(() => { - timeoutController.abort(); - }, timeoutMs); - const onCallerAbort = () => timeoutController.abort(); - if (signal) { - if (signal.aborted) timeoutController.abort(); - else signal.addEventListener("abort", onCallerAbort, { once: true }); - } - - let response: Response; - try { - response = await this.authService.authenticatedFetch(fetch, messagesUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - signal: timeoutController.signal, - }); - } catch (err) { - if (timeoutController.signal.aborted && !signal?.aborted) { - throw new LlmGatewayError( - `LLM gateway request timed out after ${timeoutMs}ms`, - "timeout", - ); - } - throw err; - } finally { - clearTimeout(timeoutId); - signal?.removeEventListener("abort", onCallerAbort); - } - - if (!response.ok) { - const errorBody = await response.text(); - let errorData: AnthropicErrorResponse | null = null; - - try { - errorData = JSON.parse(errorBody) as AnthropicErrorResponse; - } catch { - log.error("Failed to parse error response", { - errorBody, - status: response.status, - }); - } - - const errorMessage = - errorData?.error?.message || - `HTTP ${response.status}: ${response.statusText}`; - const errorType = errorData?.error?.type || "unknown_error"; - const errorCode = errorData?.error?.code; - - log.error("LLM gateway request failed", { - status: response.status, - errorType, - errorMessage, - }); - - throw new LlmGatewayError( - errorMessage, - errorType, - errorCode, - response.status, - ); - } - - const data = (await response.json()) as AnthropicMessagesResponse; - - const textContent = data.content.find((c) => c.type === "text"); - const content = textContent?.text || ""; - - log.debug("LLM gateway response received", { - model: data.model, - stopReason: data.stop_reason, - inputTokens: data.usage.input_tokens, - outputTokens: data.usage.output_tokens, - }); - - return { - content, - model: data.model, - stopReason: data.stop_reason, - usage: { - inputTokens: data.usage.input_tokens, - outputTokens: data.usage.output_tokens, - }, - }; - } - - async fetchUsage(): Promise { - const auth = await this.authService.getValidAccessToken(); - const usageUrl = getGatewayUsageUrl(auth.apiHost); - - log.debug("Fetching usage from gateway", { url: usageUrl }); - - let response: Response; - try { - response = await this.authService.authenticatedFetch(fetch, usageUrl); - } catch (err) { - 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 }); - throw new LlmGatewayError( - `Failed to fetch usage: HTTP ${response.status}`, - "usage_error", - undefined, - response.status, - ); - } - - return usageOutput.parse(await response.json()); - } - - async invalidatePlanCache(): Promise { - const auth = await this.authService.getValidAccessToken(); - const url = getGatewayInvalidatePlanCacheUrl(auth.apiHost); - - log.debug("Invalidating plan cache", { url }); - - const response = await this.authService.authenticatedFetch(fetch, url, { - method: "POST", - }); - - if (!response.ok) { - throw new LlmGatewayError( - `Failed to invalidate plan cache: HTTP ${response.status}`, - "plan_cache_error", - undefined, - response.status, - ); - } - } -} diff --git a/apps/code/src/main/services/local-logs/service.test.ts b/apps/code/src/main/services/local-logs/service.test.ts deleted file mode 100644 index 80b735e739..0000000000 --- a/apps/code/src/main/services/local-logs/service.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { mockMkdir, mockWriteFile, mockReadFile } = vi.hoisted(() => ({ - mockMkdir: vi.fn(), - mockWriteFile: vi.fn(), - mockReadFile: vi.fn(), -})); - -vi.mock("node:fs", () => ({ - default: { - promises: { - mkdir: mockMkdir, - writeFile: mockWriteFile, - readFile: mockReadFile, - }, - }, -})); - -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"; -const expectedPath = path.join( - os.homedir(), - ".posthog-code", - "sessions", - RUN_ID, - "logs.ndjson", -); - -function deferred(): { - promise: Promise; - resolve: (value: T) => void; - reject: (err: unknown) => void; -} { - let resolve!: (value: T) => void; - let reject!: (err: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -async function flushMicrotasks(): Promise { - for (let i = 0; i < 5; i++) await Promise.resolve(); -} - -describe("LocalLogsService", () => { - beforeEach(() => { - mockMkdir.mockReset().mockResolvedValue(undefined); - mockWriteFile.mockReset().mockResolvedValue(undefined); - mockReadFile.mockReset(); - }); - - describe("readLocalLogs", () => { - it("returns file contents", async () => { - mockReadFile.mockResolvedValue("hello"); - const service = new LocalLogsService(); - await expect(service.readLocalLogs(RUN_ID)).resolves.toBe("hello"); - expect(mockReadFile).toHaveBeenCalledWith(expectedPath, "utf-8"); - }); - - it.each([ - ["file is missing", Object.assign(new Error("nope"), { code: "ENOENT" })], - ["other read errors", new Error("boom")], - ])("returns null when %s", async (_label, err) => { - mockReadFile.mockRejectedValue(err); - const service = new LocalLogsService(); - await expect(service.readLocalLogs(RUN_ID)).resolves.toBeNull(); - }); - }); - - describe("writeLocalLogs", () => { - it("writes content to the run's NDJSON path", async () => { - const service = new LocalLogsService(); - await service.writeLocalLogs(RUN_ID, "line1\n"); - expect(mockMkdir).toHaveBeenCalledWith(path.dirname(expectedPath), { - recursive: true, - }); - expect(mockWriteFile).toHaveBeenCalledWith( - expectedPath, - "line1\n", - "utf-8", - ); - }); - - it("collapses many concurrent writes to one in-flight + one queued", async () => { - const firstWrite = deferred(); - mockWriteFile.mockImplementationOnce(() => firstWrite.promise); - - const service = new LocalLogsService(); - - const a = service.writeLocalLogs(RUN_ID, "A"); - const b = service.writeLocalLogs(RUN_ID, "B"); - const c = service.writeLocalLogs(RUN_ID, "C"); - const d = service.writeLocalLogs(RUN_ID, "D"); - - await flushMicrotasks(); - expect(mockWriteFile).toHaveBeenCalledTimes(1); - expect(mockWriteFile).toHaveBeenCalledWith(expectedPath, "A", "utf-8"); - - firstWrite.resolve(); - await Promise.all([a, b, c, d]); - - expect(mockWriteFile).toHaveBeenCalledTimes(2); - expect(mockWriteFile).toHaveBeenNthCalledWith( - 2, - expectedPath, - "D", - "utf-8", - ); - }); - - it("all coalesced callers see resolution when drain completes", async () => { - const firstWrite = deferred(); - mockWriteFile.mockImplementationOnce(() => firstWrite.promise); - - const service = new LocalLogsService(); - const a = service.writeLocalLogs(RUN_ID, "A"); - const b = service.writeLocalLogs(RUN_ID, "B"); - - let aResolved = false; - let bResolved = false; - void a.then(() => { - aResolved = true; - }); - void b.then(() => { - bResolved = true; - }); - - await Promise.resolve(); - expect(aResolved).toBe(false); - expect(bResolved).toBe(false); - - firstWrite.resolve(); - await Promise.all([a, b]); - expect(aResolved).toBe(true); - expect(bResolved).toBe(true); - }); - - it("keeps writes for different taskRunIds independent", async () => { - const writeA = deferred(); - const writeB = deferred(); - mockWriteFile - .mockImplementationOnce(() => writeA.promise) - .mockImplementationOnce(() => writeB.promise); - - const service = new LocalLogsService(); - const a = service.writeLocalLogs("run-a", "AAA"); - const b = service.writeLocalLogs("run-b", "BBB"); - - await flushMicrotasks(); - expect(mockWriteFile).toHaveBeenCalledTimes(2); - writeA.resolve(); - writeB.resolve(); - await Promise.all([a, b]); - }); - - it("starts fresh after the queue drains", async () => { - const service = new LocalLogsService(); - await service.writeLocalLogs(RUN_ID, "first"); - await service.writeLocalLogs(RUN_ID, "second"); - expect(mockWriteFile).toHaveBeenCalledTimes(2); - expect(mockWriteFile).toHaveBeenNthCalledWith( - 2, - expectedPath, - "second", - "utf-8", - ); - }); - - it("continues draining queued content even if a write rejects", async () => { - const firstWrite = deferred(); - mockWriteFile.mockImplementationOnce(() => firstWrite.promise); - - const service = new LocalLogsService(); - const a = service.writeLocalLogs(RUN_ID, "A"); - const b = service.writeLocalLogs(RUN_ID, "B"); - - firstWrite.reject(new Error("disk full")); - await Promise.all([a, b]); - - expect(mockWriteFile).toHaveBeenCalledTimes(2); - expect(mockWriteFile).toHaveBeenNthCalledWith( - 2, - expectedPath, - "B", - "utf-8", - ); - }); - - it("skips writeFile when coalesced content matches the last write", async () => { - const firstWrite = deferred(); - mockWriteFile.mockImplementationOnce(() => firstWrite.promise); - - const service = new LocalLogsService(); - const a = service.writeLocalLogs(RUN_ID, "SAME"); - const b = service.writeLocalLogs(RUN_ID, "SAME"); - - firstWrite.resolve(); - await Promise.all([a, b]); - - expect(mockWriteFile).toHaveBeenCalledTimes(1); - }); - - it("only mkdirs once per drain", async () => { - const firstWrite = deferred(); - mockWriteFile.mockImplementationOnce(() => firstWrite.promise); - - const service = new LocalLogsService(); - const a = service.writeLocalLogs(RUN_ID, "A"); - const b = service.writeLocalLogs(RUN_ID, "B"); - - firstWrite.resolve(); - await Promise.all([a, b]); - - expect(mockWriteFile).toHaveBeenCalledTimes(2); - expect(mockMkdir).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/apps/code/src/main/services/local-logs/service.ts b/apps/code/src/main/services/local-logs/service.ts index 4c4281bf2f..edb419264a 100644 --- a/apps/code/src/main/services/local-logs/service.ts +++ b/apps/code/src/main/services/local-logs/service.ts @@ -1,108 +1,29 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +// PORT NOTE: bridge to the @posthog/workspace-server local-logs capability. +// Delete when the logs tRPC router and the renderer sessions service consume +// workspaceClient.localLogs directly. (Handoff now delegates its log +// seed/count/delete to this client instead of touching the NDJSON via raw fs.) +import type { WorkspaceClient } from "@posthog/workspace-client/client"; -import { injectable } from "inversify"; -import { DATA_DIR } from "../../../shared/constants"; -import { logger } from "../../utils/logger"; - -const log = logger.scope("local-logs"); - -interface WriteState { - pending: string | undefined; - lastWritten: string | undefined; - dirReady: boolean; -} - -/** - * Single-flight per `taskRunId` with latest-wins coalescing. Prevents the - * gap-reconcile loop from spawning parallel writeFile of the same NDJSON. - */ -@injectable() export class LocalLogsService { - private writes = new Map< - string, - { state: WriteState; inFlight: Promise } - >(); + constructor(private readonly workspace: WorkspaceClient) {} - async readLocalLogs(taskRunId: string): Promise { - const logPath = this.getLocalLogPath(taskRunId); - try { - return await fs.promises.readFile(logPath, "utf-8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - log.warn("Failed to read local logs:", error); - return null; - } + readLocalLogs(taskRunId: string): Promise { + return this.workspace.localLogs.read.query({ taskRunId }); } writeLocalLogs(taskRunId: string, content: string): Promise { - const existing = this.writes.get(taskRunId); - if (existing) { - existing.state.pending = content; - return existing.inFlight; - } - - const state: WriteState = { - pending: undefined, - lastWritten: undefined, - dirReady: false, - }; - const inFlight = this.drain(taskRunId, content, state); - this.writes.set(taskRunId, { state, inFlight }); - return inFlight; + return this.workspace.localLogs.write.mutate({ taskRunId, content }); } - private async drain( - taskRunId: string, - initialContent: string, - state: WriteState, - ): Promise { - try { - let next: string | undefined = initialContent; - while (next !== undefined) { - const current = next; - next = undefined; - if (current !== state.lastWritten) { - await this.doWrite(taskRunId, current, state); - state.lastWritten = current; - } - if (state.pending !== undefined) { - next = state.pending; - state.pending = undefined; - } - } - } finally { - this.writes.delete(taskRunId); - } + seedLocalLogs(taskRunId: string, content: string): Promise { + return this.workspace.localLogs.seed.mutate({ taskRunId, content }); } - private async doWrite( - taskRunId: string, - content: string, - state: WriteState, - ): Promise { - const logPath = this.getLocalLogPath(taskRunId); - try { - if (!state.dirReady) { - await fs.promises.mkdir(path.dirname(logPath), { recursive: true }); - state.dirReady = true; - } - await fs.promises.writeFile(logPath, content, "utf-8"); - } catch (error) { - log.warn("Failed to write local logs:", error); - } + countLocalLogEntries(taskRunId: string): Promise { + return this.workspace.localLogs.count.query({ taskRunId }); } - private getLocalLogPath(taskRunId: string): string { - return path.join( - os.homedir(), - DATA_DIR, - "sessions", - taskRunId, - "logs.ndjson", - ); + deleteLocalLogCache(taskRunId: string): Promise { + return this.workspace.localLogs.delete.mutate({ taskRunId }); } } diff --git a/apps/code/src/main/services/mcp-apps/service.ts b/apps/code/src/main/services/mcp-apps/service.ts deleted file mode 100644 index 46c89bb266..0000000000 --- a/apps/code/src/main/services/mcp-apps/service.ts +++ /dev/null @@ -1,480 +0,0 @@ -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 McpAppsDiscoveryCompleteEvent, - McpAppsServiceEvent, - type McpAppsServiceEvents, - type McpAppsToolCancelledEvent, - type McpAppsToolInputEvent, - type McpAppsToolResultEvent, - type McpResourceUiMeta, - type McpServerConnectionConfig, - 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"); - -const UI_MIME_TYPE = "text/html;profile=mcp-app"; -const MAX_HTML_SIZE = 5 * 1024 * 1024; // 5MB - -interface ServerConnection { - name: string; - client: Client; - transport: StreamableHTTPClientTransport; -} - -@injectable() -export class McpAppsService extends TypedEventEmitter { - private connections = new Map(); - private resourceCache = new Map(); - private toolAssociations = new Map(); - private toolDefinitions = new Map(); - private serverConfigs = new Map(); - private pendingConnections = new Map>(); - private pendingFetches = new Map>(); - private resourceMetaCache = new Map(); - - constructor( - @inject(MAIN_TOKENS.UrlLauncher) - private readonly urlLauncher: IUrlLauncher, - ) { - super(); - } - - /** - * Store server configs for lazy connections later. - * No connections are created at this point. - */ - setServerConfigs(configs: McpServerConnectionConfig[]): void { - this.serverConfigs.clear(); - for (const config of configs) { - this.serverConfigs.set(config.name, config); - } - } - - /** - * Called when the agent confirms MCP servers are connected. - * Connects to each server, calls listTools() to discover _meta.ui fields - * (which the agent SDK strips), then populates tool associations and - * emits DiscoveryComplete. - */ - async handleDiscovery(serverNames: string[]): Promise { - await Promise.allSettled( - serverNames - .filter((name) => this.serverConfigs.has(name)) - .map((name) => this.discoverServerUiTools(name)), - ); - - const toolKeys = [...this.toolAssociations.keys()]; - log.info("Discovery complete", { - serverNames, - toolKeys, - associationCount: this.toolAssociations.size, - }); - - this.emit(McpAppsServiceEvent.DiscoveryComplete, { - toolKeys, - } satisfies McpAppsDiscoveryCompleteEvent); - } - - /** - * Connect to a single server and call listTools() to discover which - * tools have _meta.ui fields. The connection is kept for later reuse - * (proxy calls, resource reads, lazy HTML fetches). - */ - private async discoverServerUiTools(serverName: string): Promise { - try { - const conn = await this.getOrCreateConnection(serverName); - - const [toolsList, resourcesList] = await Promise.all([ - conn.client.listTools(), - conn.client.listResources().catch((err) => { - log.warn("listResources failed during discovery", { - serverName, - error: err instanceof Error ? err.message : String(err), - }); - return null; - }), - ]); - - for (const tool of toolsList.tools) { - const uiMeta = (tool as McpToolUiMeta)._meta?.ui; - if (!uiMeta?.resourceUri) continue; - - const toolKey = `mcp__${serverName}__${tool.name}`; - this.toolAssociations.set(toolKey, { - toolKey, - serverName, - toolName: tool.name, - resourceUri: uiMeta.resourceUri, - visibility: uiMeta.visibility, - }); - this.toolDefinitions.set(toolKey, tool); - } - - // Cache resource metadata (CSP, permissions) for use in fetchUiResource - if (resourcesList) { - for (const resource of resourcesList.resources) { - const meta = resource as McpResourceUiMeta; - if (meta._meta?.ui) { - this.resourceMetaCache.set(resource.uri, meta); - } - } - } - } catch (err) { - log.warn("Failed to discover UI tools for server", { - serverName, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - /** - * Get or create a lazy MCP connection for a server. - * Deduplicates concurrent connection attempts for the same server. - */ - private async getOrCreateConnection( - serverName: string, - ): Promise { - const existing = this.connections.get(serverName); - if (existing) { - 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 }); - return pending; - } - - const config = this.serverConfigs.get(serverName); - if (!config) { - throw new Error(`No server config for: ${serverName}`); - } - - const connectionPromise = this.createConnection(config); - this.pendingConnections.set(serverName, connectionPromise); - - try { - const conn = await connectionPromise; - this.connections.set(serverName, conn); - return conn; - } finally { - this.pendingConnections.delete(serverName); - } - } - - private async createConnection( - config: McpServerConnectionConfig, - ): Promise { - const transport = new StreamableHTTPClientTransport(new URL(config.url), { - requestInit: { - headers: config.headers, - }, - }); - - const client = new Client( - { name: "Twig", version: "1.0.0" }, - { - capabilities: { - extensions: { - "io.modelcontextprotocol/ui": { - mimeTypes: [UI_MIME_TYPE], - }, - }, - } as Record, - }, - ); - - await client.connect(transport); - - log.info("Lazy MCP connection established", { - serverName: config.name, - serverVersion: client.getServerVersion(), - }); - - return { name: config.name, client, transport }; - } - - /** - * Get the UI resource for a tool. Fetches lazily on first access: - * creates an MCP connection if needed, then reads the resource HTML. - * Deduplicates concurrent fetches for the same resource URI. - */ - async getUiResourceForTool(toolKey: string): Promise { - const association = this.toolAssociations.get(toolKey); - if (!association) { - 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 }); - 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", { - toolKey, - uri: association.resourceUri, - }); - return pendingFetch; - } - - // Start the fetch for this resource URI - log.debug("getUiResourceForTool: starting lazy fetch", { - toolKey, - serverName: association.serverName, - uri: association.resourceUri, - }); - const fetchPromise = this.fetchUiResource(association); - this.pendingFetches.set(association.resourceUri, fetchPromise); - - try { - return await fetchPromise; - } finally { - this.pendingFetches.delete(association.resourceUri); - } - } - - private async fetchUiResource( - association: McpToolUiAssociation, - ): Promise { - try { - const conn = await this.getOrCreateConnection(association.serverName); - const resourceResult = await conn.client.readResource({ - uri: association.resourceUri, - }); - - const textContent = resourceResult.contents.find( - (c) => "text" in c && c.mimeType === UI_MIME_TYPE, - ); - if (!textContent || !("text" in textContent)) { - log.warn("UI resource had no matching text content", { - serverName: association.serverName, - uri: association.resourceUri, - contentsCount: resourceResult.contents.length, - }); - return null; - } - - if (textContent.text.length > MAX_HTML_SIZE) { - log.warn("UI resource HTML exceeds size limit", { - uri: association.resourceUri, - size: textContent.text.length, - limit: MAX_HTML_SIZE, - }); - return null; - } - - // Use metadata cached during discovery - const resourceMeta = this.resourceMetaCache.get(association.resourceUri); - - const resource: McpUiResource = { - uri: association.resourceUri, - name: resourceMeta?.name, - mimeType: UI_MIME_TYPE, - csp: resourceMeta?._meta?.ui?.csp, - permissions: resourceMeta?._meta?.ui?.permissions, - html: textContent.text, - serverName: association.serverName, - }; - - this.resourceCache.set(association.resourceUri, resource); - log.info("Lazily fetched and cached UI resource", { - serverName: association.serverName, - uri: association.resourceUri, - htmlLength: textContent.text.length, - hasCsp: !!resource.csp, - }); - - return resource; - } catch (err) { - log.warn("Failed to lazily fetch UI resource", { - serverName: association.serverName, - uri: association.resourceUri, - error: err instanceof Error ? err.message : String(err), - }); - return null; - } - } - - hasUiForTool(toolKey: string): boolean { - const has = this.toolAssociations.has(toolKey); - log.debug("hasUiForTool", { toolKey, result: has }); - return has; - } - - getToolDefinition(toolKey: string): Tool | null { - return this.toolDefinitions.get(toolKey) ?? null; - } - - async proxyToolCall( - serverName: string, - toolName: string, - args?: Record, - ): Promise { - // Validate visibility: reject if tool is model-only - const toolKey = `mcp__${serverName}__${toolName}`; - const association = this.toolAssociations.get(toolKey); - if (association?.visibility && !association.visibility.includes("app")) { - throw new Error( - `Tool "${toolName}" is not accessible to apps (visibility: ${association.visibility.join(", ")})`, - ); - } - - const conn = await this.getOrCreateConnection(serverName); - const result = await conn.client.callTool({ - name: toolName, - arguments: args, - }); - - return result; - } - - async proxyResourceRead(serverName: string, uri: string): Promise { - // Only allow ui:// scheme reads - if (!uri.startsWith("ui://")) { - throw new Error(`Only ui:// URIs are allowed, got: ${uri}`); - } - - const conn = await this.getOrCreateConnection(serverName); - const result = await conn.client.readResource({ uri }); - return result; - } - - async openLink(url: string): Promise { - const parsed = new URL(url); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw new Error( - `Only http/https URLs are allowed, got: ${parsed.protocol}`, - ); - } - await this.urlLauncher.launch(url); - } - - notifyToolInput(toolKey: string, toolCallId: string, args: unknown): void { - log.info("notifyToolInput", { toolKey, toolCallId }); - this.emit(McpAppsServiceEvent.ToolInput, { - toolKey, - toolCallId, - args, - } satisfies McpAppsToolInputEvent); - } - - notifyToolResult( - toolKey: string, - toolCallId: string, - result: unknown, - isError?: boolean, - ): void { - log.info("notifyToolResult", { toolKey, toolCallId, isError }); - this.emit(McpAppsServiceEvent.ToolResult, { - toolKey, - toolCallId, - result, - isError, - } satisfies McpAppsToolResultEvent); - } - - notifyToolCancelled(toolKey: string, toolCallId: string): void { - log.info("notifyToolCancelled", { toolKey, toolCallId }); - this.emit(McpAppsServiceEvent.ToolCancelled, { - toolKey, - toolCallId, - } satisfies McpAppsToolCancelledEvent); - } - - /** - * Clear all cached resources and connections, re-run discovery, and - * emit DiscoveryComplete so the renderer refetches everything. - * Intended for developer debugging via the File > Developer menu. - */ - async refreshDiscovery(): Promise { - log.info("refreshDiscovery: clearing caches and re-running discovery"); - - // Close existing connections - for (const [, conn] of this.connections) { - await conn.client.close().catch(() => {}); - } - this.connections.clear(); - this.resourceCache.clear(); - this.resourceMetaCache.clear(); - this.toolAssociations.clear(); - this.toolDefinitions.clear(); - this.pendingConnections.clear(); - this.pendingFetches.clear(); - - // Re-discover using stored server configs - const serverNames = [...this.serverConfigs.keys()]; - if (serverNames.length > 0) { - await this.handleDiscovery(serverNames); - } else { - log.warn( - "refreshDiscovery: no server configs stored, nothing to discover", - ); - } - } - - async disconnectServer(serverName: string): Promise { - const conn = this.connections.get(serverName); - if (!conn) return; - - try { - await conn.client.close(); - } catch (err) { - log.warn("Error closing MCP connection", { - serverName, - error: err instanceof Error ? err.message : String(err), - }); - } - this.connections.delete(serverName); - - // Clean up associations and cached resources for this server - const urisToEvict = new Set(); - for (const [key, assoc] of this.toolAssociations) { - if (assoc.serverName === serverName) { - urisToEvict.add(assoc.resourceUri); - this.toolAssociations.delete(key); - } - } - - // Only evict cached resources not referenced by remaining associations - const stillReferenced = new Set( - [...this.toolAssociations.values()].map((a) => a.resourceUri), - ); - for (const uri of urisToEvict) { - if (!stillReferenced.has(uri)) { - this.resourceCache.delete(uri); - } - } - } - - async cleanup(): Promise { - const serverNames = [...this.connections.keys()]; - for (const name of serverNames) { - await this.disconnectServer(name); - } - this.resourceCache.clear(); - this.resourceMetaCache.clear(); - this.toolAssociations.clear(); - this.toolDefinitions.clear(); - this.serverConfigs.clear(); - this.pendingConnections.clear(); - this.pendingFetches.clear(); - } -} 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/mcp-proxy/service.test.ts b/apps/code/src/main/services/mcp-proxy/service.test.ts deleted file mode 100644 index 290aaf202d..0000000000 --- a/apps/code/src/main/services/mcp-proxy/service.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -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(), - }), - }, -})); - -type AuthServiceMock = { - authenticatedFetch: ReturnType; - refreshAccessToken: ReturnType; - getValidAccessToken: ReturnType; -}; - -function createAuthServiceMock(): AuthServiceMock { - return { - authenticatedFetch: vi.fn(), - refreshAccessToken: vi.fn().mockResolvedValue({ - accessToken: "refreshed-token", - apiHost: "https://app.posthog.com", - }), - getValidAccessToken: vi.fn().mockResolvedValue({ - accessToken: "access-token", - apiHost: "https://app.posthog.com", - }), - }; -} - -describe("McpProxyService", () => { - let authServiceMock: AuthServiceMock; - let service: McpProxyService; - - beforeEach(() => { - authServiceMock = createAuthServiceMock(); - service = new McpProxyService(authServiceMock as unknown as AuthService); - }); - - afterEach(async () => { - await service.stop(); - vi.restoreAllMocks(); - }); - - describe("lifecycle", () => { - it("starts on a loopback port and returns a URL for register()", async () => { - await service.start(); - const url = service.register("alpha", "https://upstream.example/path"); - expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/alpha$/); - }); - - it("throws from register() before start()", () => { - expect(() => - service.register("alpha", "https://upstream.example"), - ).toThrowError(/not started/); - }); - - it("handles concurrent start() calls without races", async () => { - await Promise.all([service.start(), service.start(), service.start()]); - const url = service.register("alpha", "https://upstream.example"); - expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/alpha$/); - }); - - it("stop() closes the server and clears registered targets", async () => { - await service.start(); - service.register("alpha", "https://upstream.example"); - await service.stop(); - expect(() => - service.register("alpha", "https://upstream.example"), - ).toThrowError(/not started/); - }); - }); - - describe("request forwarding", () => { - it("returns 404 for unknown targets", async () => { - await service.start(); - const proxyUrl = service.register("alpha", "https://upstream.example"); - const unknownUrl = proxyUrl.replace("/alpha", "/bravo"); - - const res = await fetch(unknownUrl); - - expect(res.status).toBe(404); - expect(await res.text()).toBe("Unknown target"); - expect(authServiceMock.authenticatedFetch).not.toHaveBeenCalled(); - }); - - it("forwards GET requests and returns the upstream body and status", async () => { - authServiceMock.authenticatedFetch.mockResolvedValue( - new Response('{"ok":true}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - - await service.start(); - const proxyUrl = service.register("alpha", "https://upstream.example"); - - const res = await fetch(proxyUrl); - - expect(res.status).toBe(200); - expect(await res.text()).toBe('{"ok":true}'); - expect(authServiceMock.authenticatedFetch).toHaveBeenCalledTimes(1); - const [, url] = authServiceMock.authenticatedFetch.mock.calls[0]; - expect(url).toBe("https://upstream.example"); - }); - - it("forwards POST body bytes to the upstream URL", async () => { - authServiceMock.authenticatedFetch.mockResolvedValue( - new Response('{"ok":true}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - - await service.start(); - const proxyUrl = service.register("alpha", "https://upstream.example"); - - await fetch(proxyUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: '{"hello":"world"}', - }); - - expect(authServiceMock.authenticatedFetch).toHaveBeenCalledTimes(1); - const [, , options] = authServiceMock.authenticatedFetch.mock.calls[0]; - expect(options.method).toBe("POST"); - expect(Buffer.from(options.body).toString("utf8")).toBe( - '{"hello":"world"}', - ); - }); - - it("strips Authorization and Host headers before forwarding", async () => { - authServiceMock.authenticatedFetch.mockResolvedValue( - new Response('{"ok":true}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - - await service.start(); - const proxyUrl = service.register("alpha", "https://upstream.example"); - - await fetch(proxyUrl, { - headers: { - Authorization: "Bearer leaked", - "X-Custom": "keep-me", - }, - }); - - const [, , options] = authServiceMock.authenticatedFetch.mock.calls[0]; - const forwardedHeaderKeys = Object.keys(options.headers).map((k) => - k.toLowerCase(), - ); - expect(forwardedHeaderKeys).not.toContain("authorization"); - expect(forwardedHeaderKeys).not.toContain("host"); - expect(forwardedHeaderKeys).not.toContain("connection"); - expect(options.headers["x-custom"]).toBe("keep-me"); - }); - - it("joins path suffix without producing a double slash for trailing-slash targets", async () => { - authServiceMock.authenticatedFetch.mockResolvedValue( - new Response("{}", { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - - await service.start(); - service.register("alpha", "https://upstream.example/inst-2/"); - const port = new URL( - service.register("alpha", "https://upstream.example/inst-2/"), - ).port; - - await fetch(`http://127.0.0.1:${port}/alpha/tools/list`); - - const [, url] = - authServiceMock.authenticatedFetch.mock.calls.at(-1) ?? []; - expect(url).toBe("https://upstream.example/inst-2/tools/list"); - }); - - it("preserves the incoming query string on the upstream URL", async () => { - authServiceMock.authenticatedFetch.mockResolvedValue( - new Response("{}", { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - - await service.start(); - const proxyUrl = service.register("alpha", "https://upstream.example"); - - await fetch(`${proxyUrl}?token=abc&foo=bar`); - - const [, url] = authServiceMock.authenticatedFetch.mock.calls[0]; - expect(url).toBe("https://upstream.example?token=abc&foo=bar"); - }); - }); - - describe("auth error retry", () => { - it("refreshes the token and retries once when the body contains authentication_failed", async () => { - authServiceMock.authenticatedFetch - .mockResolvedValueOnce( - new Response( - JSON.stringify({ error: { code: "authentication_failed" } }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ) - .mockResolvedValueOnce( - new Response('{"ok":true}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - - await service.start(); - const proxyUrl = service.register("alpha", "https://upstream.example"); - - const res = await fetch(proxyUrl, { method: "POST", body: "payload" }); - - expect(res.status).toBe(200); - expect(await res.text()).toBe('{"ok":true}'); - expect(authServiceMock.refreshAccessToken).toHaveBeenCalledTimes(1); - expect(authServiceMock.authenticatedFetch).toHaveBeenCalledTimes(2); - }); - - it("does not retry when the body looks healthy", async () => { - authServiceMock.authenticatedFetch.mockResolvedValue( - new Response('{"ok":true}', { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - - await service.start(); - const proxyUrl = service.register("alpha", "https://upstream.example"); - - await fetch(proxyUrl); - - expect(authServiceMock.refreshAccessToken).not.toHaveBeenCalled(); - expect(authServiceMock.authenticatedFetch).toHaveBeenCalledTimes(1); - }); - }); - - describe("SSE streaming", () => { - it("streams event-stream responses through to the client", async () => { - const sseBody = "data: one\n\ndata: two\n\n"; - authServiceMock.authenticatedFetch.mockResolvedValue( - new Response(sseBody, { - status: 200, - headers: { "content-type": "text/event-stream" }, - }), - ); - - await service.start(); - const proxyUrl = service.register("alpha", "https://upstream.example"); - - const res = await fetch(proxyUrl); - - expect(res.headers.get("content-type")).toContain("text/event-stream"); - expect(await res.text()).toBe(sseBody); - expect(authServiceMock.refreshAccessToken).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/code/src/main/services/mcp-proxy/service.ts b/apps/code/src/main/services/mcp-proxy/service.ts deleted file mode 100644 index 1cf267355e..0000000000 --- a/apps/code/src/main/services/mcp-proxy/service.ts +++ /dev/null @@ -1,303 +0,0 @@ -import http from "node:http"; -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"); - -function truncateRequestBody(body: RequestInit["body"]): string | undefined { - if (body == null) return undefined; - if (typeof body === "string") return body.slice(0, 2000); - if (body instanceof Buffer) return body.toString("utf8").slice(0, 2000); - if (body instanceof Uint8Array) { - return Buffer.from(body).toString("utf8").slice(0, 2000); - } - return `[${body.constructor.name}]`; -} - -/** - * Local HTTP proxy for MCP servers. Allows routing MCP requests through a - * stable loopback URL while injecting a fresh access token on every forwarded - * request. MCP transports bake their headers at construction time, so without - * this proxy we would either need to tear the transport down on every token - * rotation (expensive, racy) or leave it serving stale tokens. - * - * The proxy only listens on 127.0.0.1 and strips inbound Authorization headers - * before forwarding, but any local process can still use it to issue requests - * on the user's behalf — acceptable for a single-user desktop app. - */ -@injectable() -export class McpProxyService { - private server: http.Server | null = null; - private port: number | null = null; - private startPromise: Promise | null = null; - private targets = new Map(); - - constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, - ) {} - - async start(): Promise { - if (this.server && this.port) return; - if (this.startPromise) return this.startPromise; - this.startPromise = this.doStart().catch((err) => { - this.startPromise = null; - throw err; - }); - return this.startPromise; - } - - private async doStart(): Promise { - const server = http.createServer((req, res) => { - this.handleRequest(req, res); - }); - this.server = server; - - await new Promise((resolve, reject) => { - server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - if (typeof addr === "object" && addr) { - this.port = addr.port; - log.info("MCP proxy started", { port: this.port }); - resolve(); - } else { - reject(new Error("Failed to get proxy address")); - } - }); - - server.on("error", (err) => { - log.error("MCP proxy server error", err); - reject(err); - }); - }); - } - - /** - * Register a target URL under a stable ID. Returns the loopback URL that - * should be passed to the MCP transport. Subsequent registrations with the - * same ID overwrite the target. - */ - register(id: string, targetUrl: string): string { - if (!this.port) { - throw new Error("MCP proxy not started"); - } - this.targets.set(id, targetUrl); - return `http://127.0.0.1:${this.port}/${encodeURIComponent(id)}`; - } - - @preDestroy() - async stop(): Promise { - if (!this.server) return; - const server = this.server; - await new Promise((resolve) => { - server.close(() => { - log.info("MCP proxy stopped"); - resolve(); - }); - }); - this.server = null; - this.port = null; - this.startPromise = null; - this.targets.clear(); - } - - private handleRequest( - req: http.IncomingMessage, - res: http.ServerResponse, - ): void { - const incoming = new URL(req.url ?? "/", "http://placeholder"); - const segments = incoming.pathname.split("/").filter(Boolean); - const [rawId, ...rest] = segments; - const id = rawId ? decodeURIComponent(rawId) : ""; - const target = this.targets.get(id); - - if (!target) { - log.warn("Unknown MCP proxy target", { id, url: req.url }); - res.writeHead(404); - res.end("Unknown target"); - return; - } - - const suffix = rest.join("/"); - const targetBase = target.replace(/\/+$/, ""); - const targetUrl = - (suffix ? `${targetBase}/${suffix}` : targetBase) + incoming.search; - - const strippedAuthHeaders = new Set([ - "authorization", - "proxy-authorization", - ]); - const headers: Record = {}; - for (const [key, value] of Object.entries(req.headers)) { - if ( - key === "host" || - key === "connection" || - strippedAuthHeaders.has(key) - ) { - continue; - } - if (typeof value === "string") { - headers[key] = value; - } - } - - const fetchOptions: RequestInit = { - method: req.method ?? "GET", - headers, - }; - - if (req.method !== "GET" && req.method !== "HEAD") { - const chunks: Buffer[] = []; - req.on("data", (chunk: Buffer) => chunks.push(chunk)); - req.on("end", () => { - fetchOptions.body = Buffer.concat(chunks); - this.forwardRequest(id, targetUrl, fetchOptions, res); - }); - } else { - this.forwardRequest(id, targetUrl, fetchOptions, res); - } - } - - private async forwardRequest( - id: string, - url: string, - options: RequestInit, - res: http.ServerResponse, - ): Promise { - try { - let response = await this.authService.authenticatedFetch( - fetch, - 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 - // known error shape and retry once with a force-refreshed token. - const contentType = response.headers.get("content-type") ?? ""; - const isSse = contentType.includes("text/event-stream"); - - if (!isSse) { - const buf = Buffer.from(await response.arrayBuffer()); - const bodyText = buf.toString("utf8"); - - if (this.isAuthErrorBody(bodyText, response.status)) { - 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, - ); - const retryContentType = response.headers.get("content-type") ?? ""; - if (!retryContentType.includes("text/event-stream")) { - const retryBuf = Buffer.from(await response.arrayBuffer()); - this.writeBufferedResponse(response, retryBuf, res); - return; - } - this.writeStreamingResponse(response, res); - return; - } - - if (/"isError"\s*:\s*true/.test(bodyText) || response.status >= 400) { - const details = { - id, - url, - method: options.method, - status: response.status, - requestBody: truncateRequestBody(options.body), - responseHeaders: Object.fromEntries(response.headers.entries()), - body: bodyText.slice(0, 2000), - }; - if (response.status >= 500) { - log.error("MCP proxy server error", details); - } else { - log.warn("MCP proxy non-OK body", details); - } - } - - this.writeBufferedResponse(response, buf, res); - return; - } - - this.writeStreamingResponse(response, res); - } catch (err) { - log.error("MCP proxy forward error", { id, url, err }); - if (!res.headersSent) { - res.writeHead(502); - } - res.end("Proxy error"); - } - } - - private isAuthErrorBody(bodyText: string, status: number): boolean { - if ( - bodyText.includes('"authentication_failed"') || - bodyText.includes('"authentication_error"') - ) { - return true; - } - if (status < 400) return false; - return ( - bodyText.includes("Invalid API key") || - bodyText.includes("Authentication failed") - ); - } - - private buildResponseHeaders(response: Response): Record { - const stripHeaders = new Set([ - "transfer-encoding", - "content-encoding", - "content-length", - ]); - const headers: Record = {}; - response.headers.forEach((value: string, key: string) => { - if (stripHeaders.has(key)) return; - headers[key] = value; - }); - return headers; - } - - private writeBufferedResponse( - response: Response, - buf: Buffer, - res: http.ServerResponse, - ): void { - res.writeHead(response.status, this.buildResponseHeaders(response)); - res.end(buf); - } - - private async writeStreamingResponse( - response: Response, - res: http.ServerResponse, - ): Promise { - res.writeHead(response.status, this.buildResponseHeaders(response)); - if (!response.body) { - res.end(); - return; - } - const reader = response.body.getReader(); - res.on("close", () => { - void reader.cancel().catch(() => {}); - }); - const pump = async (): Promise => { - const { done, value } = await reader.read(); - if (done) { - res.end(); - return; - } - const canContinue = res.write(value); - if (canContinue) { - return pump(); - } - res.once("drain", () => pump()); - }; - await pump(); - } -} diff --git a/apps/code/src/main/services/new-task-link/service.test.ts b/apps/code/src/main/services/new-task-link/service.test.ts deleted file mode 100644 index bfb6c84b0a..0000000000 --- a/apps/code/src/main/services/new-task-link/service.test.ts +++ /dev/null @@ -1,449 +0,0 @@ -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 { IMainWindow } from "@posthog/platform/main-window"; -import type { DeepLinkService } from "../deep-link/service"; -import { NewTaskLinkEvent, NewTaskLinkService } from "./service"; - -function createMockDeepLinkService() { - const handlers = new Map< - string, - (path: string, params: URLSearchParams) => boolean - >(); - return { - registerHandler: vi.fn((key, handler) => handlers.set(key, handler)), - _handlers: handlers, - _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; -} - -describe("NewTaskLinkService", () => { - let service: NewTaskLinkService; - let mockDeepLink: ReturnType; - let mockWindow: IMainWindow; - - beforeEach(() => { - vi.clearAllMocks(); - mockDeepLink = createMockDeepLinkService(); - mockWindow = createMockMainWindow(); - service = new NewTaskLinkService( - mockDeepLink as unknown as DeepLinkService, - mockWindow, - ); - }); - - describe("constructor", () => { - it("registers handlers for new, plan and issue", () => { - expect(mockDeepLink.registerHandler).toHaveBeenCalledWith( - "new", - expect.any(Function), - ); - expect(mockDeepLink.registerHandler).toHaveBeenCalledWith( - "plan", - expect.any(Function), - ); - expect(mockDeepLink.registerHandler).toHaveBeenCalledWith( - "issue", - expect.any(Function), - ); - expect(mockDeepLink.registerHandler).toHaveBeenCalledTimes(3); - }); - }); - - describe("handleNew", () => { - it("rejects empty params", () => { - const result = mockDeepLink._invoke("new", new URLSearchParams()); - expect(result).toBe(false); - }); - - it("rejects when only mode is provided", () => { - const result = mockDeepLink._invoke( - "new", - new URLSearchParams("mode=plan"), - ); - expect(result).toBe(false); - }); - - it("rejects when only model is provided", () => { - const result = mockDeepLink._invoke( - "new", - new URLSearchParams("model=opus"), - ); - expect(result).toBe(false); - }); - - it("rejects when only mode and model are provided", () => { - const result = mockDeepLink._invoke( - "new", - new URLSearchParams("mode=plan&model=opus"), - ); - expect(result).toBe(false); - }); - - it("accepts prompt only", () => { - const listener = vi.fn(); - service.on(NewTaskLinkEvent.Action, listener); - - const result = mockDeepLink._invoke( - "new", - new URLSearchParams("prompt=hello+world"), - ); - - expect(result).toBe(true); - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - action: "new", - prompt: "hello world", - }), - ); - }); - - it("accepts repo only", () => { - const listener = vi.fn(); - service.on(NewTaskLinkEvent.Action, listener); - - const result = mockDeepLink._invoke( - "new", - new URLSearchParams("repo=posthog/posthog"), - ); - - expect(result).toBe(true); - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - action: "new", - repo: "posthog/posthog", - prompt: undefined, - }), - ); - }); - - it("passes shared params (repo, mode, model)", () => { - const listener = vi.fn(); - service.on(NewTaskLinkEvent.Action, listener); - - mockDeepLink._invoke( - "new", - new URLSearchParams("prompt=test&repo=org/repo&mode=cloud&model=opus"), - ); - - expect(listener).toHaveBeenCalledWith({ - action: "new", - prompt: "test", - repo: "org/repo", - mode: "cloud", - model: "opus", - }); - }); - }); - - describe("handlePlan", () => { - it("rejects missing plan param", () => { - const result = mockDeepLink._invoke( - "plan", - new URLSearchParams("repo=org/repo"), - ); - expect(result).toBe(false); - }); - - it("rejects invalid base64", () => { - const result = mockDeepLink._invoke( - "plan", - new URLSearchParams("plan=!!!invalid-base64!!!"), - ); - expect(result).toBe(false); - }); - - it("accepts valid base64 plan", () => { - const listener = vi.fn(); - service.on(NewTaskLinkEvent.Action, listener); - - const planText = "# My Plan\n\n1. Do thing\n2. Do other thing"; - const encoded = btoa(planText); - - const result = mockDeepLink._invoke( - "plan", - new URLSearchParams(`plan=${encoded}&repo=org/repo`), - ); - - expect(result).toBe(true); - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - action: "plan", - plan: planText, - repo: "org/repo", - }), - ); - }); - - it("accepts URL-safe base64 with - and _ instead of + and /", () => { - const listener = vi.fn(); - service.on(NewTaskLinkEvent.Action, listener); - - // "??>" base64 is "Pz8+" — contains `+` so URL-safe substitutes to `-`. - const planText = "??>"; - const standard = Buffer.from(planText, "utf-8").toString("base64"); - const urlSafe = standard - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - - const result = mockDeepLink._invoke( - "plan", - new URLSearchParams(`plan=${urlSafe}`), - ); - - expect(result).toBe(true); - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ action: "plan", plan: planText }), - ); - }); - - it("recovers when + was decoded to space by URLSearchParams", () => { - const listener = vi.fn(); - service.on(NewTaskLinkEvent.Action, listener); - - // "Pz8+" arrives as "Pz8 " because URLSearchParams turns + into space. - const result = mockDeepLink._invoke( - "plan", - new URLSearchParams("plan=Pz8+"), - ); - - expect(result).toBe(true); - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ action: "plan", plan: "??>" }), - ); - }); - - it("round-trips UTF-8 (emoji, non-ASCII)", () => { - const listener = vi.fn(); - service.on(NewTaskLinkEvent.Action, listener); - - const planText = "Plan 🚀: café — naïve résumé"; - const encoded = Buffer.from(planText, "utf-8").toString("base64"); - - const result = mockDeepLink._invoke( - "plan", - new URLSearchParams(`plan=${encoded}`), - ); - - expect(result).toBe(true); - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ action: "plan", plan: planText }), - ); - }); - - it("passes shared params", () => { - const listener = vi.fn(); - service.on(NewTaskLinkEvent.Action, listener); - - const encoded = btoa("plan content"); - mockDeepLink._invoke( - "plan", - new URLSearchParams(`plan=${encoded}&mode=worktree&model=sonnet`), - ); - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - mode: "worktree", - model: "sonnet", - }), - ); - }); - }); - - describe("handleIssue", () => { - it("rejects missing url param", () => { - const result = mockDeepLink._invoke("issue", new URLSearchParams()); - expect(result).toBe(false); - }); - - it("rejects non-GitHub URLs", () => { - const result = mockDeepLink._invoke( - "issue", - new URLSearchParams("url=https://gitlab.com/org/repo/issues/1"), - ); - expect(result).toBe(false); - }); - - it("rejects GitHub URLs that are not issues", () => { - const result = mockDeepLink._invoke( - "issue", - new URLSearchParams("url=https://github.com/org/repo/pull/1"), - ); - expect(result).toBe(false); - }); - - it("rejects issue URLs with non-numeric issue number", () => { - const result = mockDeepLink._invoke( - "issue", - new URLSearchParams("url=https://github.com/org/repo/issues/abc"), - ); - expect(result).toBe(false); - }); - - it("rejects issue URLs with extra trailing path segments", () => { - const result = mockDeepLink._invoke( - "issue", - new URLSearchParams("url=https://github.com/org/repo/issues/42/edit"), - ); - expect(result).toBe(false); - }); - - it("rejects issue URLs with zero or negative issue number", () => { - expect( - mockDeepLink._invoke( - "issue", - new URLSearchParams("url=https://github.com/org/repo/issues/0"), - ), - ).toBe(false); - - expect( - mockDeepLink._invoke( - "issue", - new URLSearchParams("url=https://github.com/org/repo/issues/-1"), - ), - ).toBe(false); - }); - - it("accepts valid GitHub issue URL", () => { - const listener = vi.fn(); - service.on(NewTaskLinkEvent.Action, listener); - - const result = mockDeepLink._invoke( - "issue", - new URLSearchParams("url=https://github.com/posthog/posthog/issues/42"), - ); - - expect(result).toBe(true); - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - action: "issue", - url: "https://github.com/posthog/posthog/issues/42", - owner: "posthog", - issueRepo: "posthog", - issueNumber: 42, - }), - ); - }); - - it("passes shared params", () => { - const listener = vi.fn(); - service.on(NewTaskLinkEvent.Action, listener); - - mockDeepLink._invoke( - "issue", - new URLSearchParams( - "url=https://github.com/org/repo/issues/1&repo=other/repo&model=opus", - ), - ); - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - repo: "other/repo", - model: "opus", - }), - ); - }); - }); - - describe("emitOrQueue", () => { - it("emits when listeners exist", () => { - const listener = vi.fn(); - service.on(NewTaskLinkEvent.Action, listener); - - mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); - - expect(listener).toHaveBeenCalledTimes(1); - expect(service.consumePendingLink()).toBeNull(); - }); - - it("queues when no listeners exist", () => { - mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); - - const pending = service.consumePendingLink(); - expect(pending).toEqual( - expect.objectContaining({ action: "new", prompt: "test" }), - ); - }); - - it("focuses the window", () => { - mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); - - expect(mockWindow.focus).toHaveBeenCalled(); - }); - - it("restores the window if minimized", () => { - vi.mocked(mockWindow.isMinimized).mockReturnValue(true); - - mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); - - expect(mockWindow.restore).toHaveBeenCalled(); - expect(mockWindow.focus).toHaveBeenCalled(); - }); - - it("does not restore the window if not minimized", () => { - vi.mocked(mockWindow.isMinimized).mockReturnValue(false); - - mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); - - expect(mockWindow.restore).not.toHaveBeenCalled(); - }); - }); - - describe("consumePendingLink", () => { - it("returns null when no pending link", () => { - expect(service.consumePendingLink()).toBeNull(); - }); - - it("clears after consuming", () => { - mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); - - expect(service.consumePendingLink()).not.toBeNull(); - expect(service.consumePendingLink()).toBeNull(); - }); - - it("latest link overwrites previous pending", () => { - mockDeepLink._invoke("new", new URLSearchParams("prompt=first")); - mockDeepLink._invoke("new", new URLSearchParams("prompt=second")); - - const pending = service.consumePendingLink(); - expect(pending).toEqual(expect.objectContaining({ prompt: "second" })); - }); - }); -}); diff --git a/apps/code/src/main/services/new-task-link/service.ts b/apps/code/src/main/services/new-task-link/service.ts deleted file mode 100644 index fbbe19c428..0000000000 --- a/apps/code/src/main/services/new-task-link/service.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; -import type { NewTaskLinkPayload, NewTaskSharedParams } from "@shared/types"; -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; - } -} - -export const NewTaskLinkEvent = { - Action: "action", -} as const; - -export type { NewTaskLinkPayload }; - -export interface NewTaskLinkEvents { - [NewTaskLinkEvent.Action]: NewTaskLinkPayload; -} - -@injectable() -export class NewTaskLinkService extends TypedEventEmitter { - private pendingLink: NewTaskLinkPayload | null = null; - - constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.MainWindow) - private readonly mainWindow: IMainWindow, - ) { - super(); - - this.deepLinkService.registerHandler("new", (_path, params) => - this.handleNew(params), - ); - this.deepLinkService.registerHandler("plan", (_path, params) => - this.handlePlan(params), - ); - this.deepLinkService.registerHandler("issue", (_path, params) => - this.handleIssue(params), - ); - } - - private extractSharedParams(params: URLSearchParams): NewTaskSharedParams { - return { - repo: params.get("repo") ?? undefined, - mode: params.get("mode") ?? undefined, - model: params.get("model") ?? undefined, - }; - } - - private handleNew(params: URLSearchParams): boolean { - const shared = this.extractSharedParams(params); - const prompt = params.get("prompt") ?? undefined; - - if (!prompt && !shared.repo) { - log.warn("New task link requires at least prompt or repo"); - return false; - } - - const payload: NewTaskLinkPayload = { - action: "new", - prompt, - ...shared, - }; - - log.info("Handling new task link", { - hasPrompt: !!prompt, - repo: shared.repo, - }); - return this.emitOrQueue(payload); - } - - private handlePlan(params: URLSearchParams): boolean { - const planEncoded = params.get("plan"); - - if (!planEncoded) { - log.warn("Plan link missing plan parameter"); - return false; - } - - const plan = decodePlanBase64(planEncoded); - if (plan === null) { - log.error("Plan link has invalid base64 encoding"); - return false; - } - - const shared = this.extractSharedParams(params); - const payload: NewTaskLinkPayload = { - action: "plan", - plan, - ...shared, - }; - - log.info("Handling plan link", { - planLength: plan.length, - repo: shared.repo, - }); - return this.emitOrQueue(payload); - } - - private handleIssue(params: URLSearchParams): boolean { - const url = params.get("url"); - - if (!url) { - log.warn("Issue link missing url parameter"); - return false; - } - - const parsed = this.parseGitHubIssueUrl(url); - if (!parsed) { - log.warn("Issue link has invalid GitHub issue URL", { url }); - return false; - } - - const shared = this.extractSharedParams(params); - const payload: NewTaskLinkPayload = { - action: "issue", - url, - owner: parsed.owner, - issueRepo: parsed.repo, - issueNumber: parsed.number, - ...shared, - }; - - log.info("Handling issue link", { - owner: parsed.owner, - repo: parsed.repo, - number: parsed.number, - }); - 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.emit(NewTaskLinkEvent.Action, payload); - } else { - log.info( - `Queueing new task link (renderer not ready): action=${payload.action}`, - ); - this.pendingLink = payload; - } - - if (this.mainWindow.isMinimized()) { - this.mainWindow.restore(); - } - this.mainWindow.focus(); - - return true; - } - - public consumePendingLink(): NewTaskLinkPayload | null { - const pending = this.pendingLink; - this.pendingLink = null; - if (pending) { - log.info(`Consumed pending new task link: action=${pending.action}`); - } - return pending; - } -} diff --git a/apps/code/src/main/services/notification/service.ts b/apps/code/src/main/services/notification/service.ts deleted file mode 100644 index 4d27d27d58..0000000000 --- a/apps/code/src/main/services/notification/service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; -import type { INotifier } 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"); - -@injectable() -export class NotificationService { - private hasBadge = false; - - constructor( - @inject(MAIN_TOKENS.TaskLinkService) - private readonly taskLinkService: TaskLinkService, - @inject(MAIN_TOKENS.Notifier) - private readonly notifier: INotifier, - @inject(MAIN_TOKENS.MainWindow) - private readonly mainWindow: IMainWindow, - ) {} - - @postConstruct() - init(): void { - this.mainWindow.onFocus(() => this.clearDockBadge()); - } - - send(title: string, body: string, silent: boolean, taskId?: string): void { - if (!this.notifier.isSupported()) { - log.warn("Notifications not supported on this platform"); - return; - } - - this.notifier.notify({ - title, - body, - silent, - onClick: () => { - log.info("Notification clicked, focusing window", { title, taskId }); - if (this.mainWindow.isMinimized()) { - this.mainWindow.restore(); - } - this.mainWindow.focus(); - - if (taskId) { - this.taskLinkService.emit(TaskLinkEvent.OpenTask, { taskId }); - log.info("Notification clicked, navigating to task", { taskId }); - } - }, - }); - 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"); - } - - bounceDock(): void { - this.notifier.requestAttention(); - log.info("Dock bounce triggered"); - } - - private clearDockBadge(): void { - if (!this.hasBadge) return; - this.hasBadge = false; - this.notifier.setUnreadIndicator(false); - log.info("Dock badge cleared"); - } -} diff --git a/apps/code/src/main/services/oauth/schemas.ts b/apps/code/src/main/services/oauth/schemas.ts deleted file mode 100644 index aef4a0280a..0000000000 --- a/apps/code/src/main/services/oauth/schemas.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { z } from "zod"; - -export const cloudRegion = z.enum(["us", "eu", "dev"]); -export type CloudRegion = z.infer; - -/** - * Error codes for OAuth operations. - * - network_error: Transient network issue, should retry - * - server_error: Server error (5xx), should retry - * - auth_error: Authentication failed (invalid token, 401/403), should logout - * - unknown_error: Other errors - */ -export const oAuthErrorCode = z.enum([ - "network_error", - "server_error", - "auth_error", - "unknown_error", -]); -export type OAuthErrorCode = z.infer; - -export const oAuthTokenResponse = z.object({ - access_token: z.string(), - expires_in: z.number(), - token_type: z.string(), - scope: z.string().optional().default(""), - refresh_token: z.string(), - scoped_teams: z.array(z.number()).optional(), - scoped_organizations: z.array(z.string()).optional(), -}); -export type OAuthTokenResponse = z.infer; - -export const startFlowInput = z.object({ - region: cloudRegion, -}); -export type StartFlowInput = z.infer; - -export const startFlowOutput = z.object({ - success: z.boolean(), - data: oAuthTokenResponse.optional(), - error: z.string().optional(), - errorCode: oAuthErrorCode.optional(), -}); -export type StartFlowOutput = z.infer; - -export const startSignupFlowInput = startFlowInput; -export type StartSignupFlowInput = z.infer; - -export const refreshTokenInput = z.object({ - refreshToken: z.string(), - region: cloudRegion, -}); -export type RefreshTokenInput = z.infer; - -export const refreshTokenOutput = z.object({ - success: z.boolean(), - data: oAuthTokenResponse.optional(), - error: z.string().optional(), - errorCode: oAuthErrorCode.optional(), -}); -export type RefreshTokenOutput = z.infer; - -export const cancelFlowOutput = z.object({ - success: z.boolean(), - error: z.string().optional(), -}); -export type CancelFlowOutput = z.infer; - -export const openExternalUrlInput = z.object({ - url: z.url(), -}); -export type OpenExternalUrlInput = z.infer; diff --git a/apps/code/src/main/services/oauth/service.ts b/apps/code/src/main/services/oauth/service.ts deleted file mode 100644 index 3ee31add2f..0000000000 --- a/apps/code/src/main/services/oauth/service.ts +++ /dev/null @@ -1,553 +0,0 @@ -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 { - getOauthClientIdFromRegion, - OAUTH_SCOPES, -} from "@shared/constants/oauth"; -import { type BackoffOptions, sleepWithBackoff } from "@shared/utils/backoff"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -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 type { - CancelFlowOutput, - CloudRegion, - OAuthTokenResponse, - RefreshTokenOutput, - StartFlowOutput, -} from "./schemas"; - -const log = logger.scope("oauth-service"); - -const OAUTH_TIMEOUT_MS = 180_000; // 3 minutes -const DEV_CALLBACK_PORT = 8237; - -const NETWORK_ERROR_MESSAGE = - "Could not connect to PostHog. Please check your internet connection and try again."; - -const TOKEN_FETCH_MAX_ATTEMPTS = 3; -const TOKEN_FETCH_BACKOFF: BackoffOptions = { - initialDelayMs: 1_000, - maxDelayMs: 5_000, - multiplier: 2, -}; - -interface OAuthConfig { - scopes: string[]; - cloudRegion: CloudRegion; -} - -interface PendingOAuthFlow { - codeVerifier: string; - config: OAuthConfig; - resolve: (code: string) => void; - reject: (error: Error) => void; - timeoutId: NodeJS.Timeout; - server?: http.Server; - connections?: Set; -} - -@injectable() -export class OAuthService { - private pendingFlow: PendingOAuthFlow | null = null; - - constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.UrlLauncher) - private readonly urlLauncher: IUrlLauncher, - @inject(MAIN_TOKENS.MainWindow) - private readonly mainWindow: IMainWindow, - ) { - // Register OAuth callback handler for deep links - this.deepLinkService.registerHandler("callback", (_path, searchParams) => - this.handleOAuthCallback(searchParams), - ); - } - - private handleOAuthCallback(searchParams: URLSearchParams): boolean { - const code = searchParams.get("code"); - const error = searchParams.get("error"); - - 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( - "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"); - if (this.mainWindow.isMinimized()) this.mainWindow.restore(); - this.mainWindow.focus(); - return true; - } - - const { resolve, reject, timeoutId } = this.pendingFlow; - clearTimeout(timeoutId); - this.pendingFlow = null; - - if (error) { - reject(new Error(`OAuth error: ${error}`)); - return true; - } - - if (code) { - resolve(code); - return true; - } - - reject(new Error("OAuth callback missing code")); - return true; - } - - /** - * Get the redirect URI based on environment. - */ - private getRedirectUri(): string { - return isDevBuild() - ? `http://localhost:${DEV_CALLBACK_PORT}/callback` - : `${this.deepLinkService.getProtocol()}://callback`; - } - - /** - * Start the OAuth flow. - * Uses HTTP callback in development, deep links in production. - */ - public async startFlow(region: CloudRegion): Promise { - try { - // Cancel any existing flow - this.cancelFlow(); - - const config: OAuthConfig = { - scopes: OAUTH_SCOPES, - cloudRegion: region, - }; - - const codeVerifier = this.generateCodeVerifier(); - const authUrl = this.buildAuthorizeUrl(region, codeVerifier); - - return await this.startFlowWithUrl( - config, - codeVerifier, - authUrl.toString(), - ); - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } - } - - /** - * Start the OAuth flow from the signup page. - */ - public async startSignupFlow(region: CloudRegion): Promise { - try { - // Cancel any existing flow - this.cancelFlow(); - - const config: OAuthConfig = { - scopes: OAUTH_SCOPES, - cloudRegion: region, - }; - - const codeVerifier = this.generateCodeVerifier(); - const authUrl = this.buildAuthorizeUrl(region, codeVerifier); - const signupUrl = this.buildSignupUrl(region, authUrl); - - return await this.startFlowWithUrl( - config, - codeVerifier, - signupUrl.toString(), - ); - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } - } - - /** - * Refresh an access token using a refresh token. - */ - public async refreshToken( - refreshToken: string, - region: CloudRegion, - ): Promise { - try { - const cloudUrl = getCloudUrlFromRegion(region); - - const response = await fetch(`${cloudUrl}/oauth/token`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: getOauthClientIdFromRegion(region), - }), - }); - - if (!response.ok) { - // 401/403 are auth errors - the token is invalid - const isAuthError = response.status === 401 || response.status === 403; - // 5xx are server errors - should be retried - const isServerError = response.status >= 500; - log.warn( - `Token refresh failed: ${response.status} ${response.statusText}`, - ); - return { - success: false, - error: `Token refresh failed: ${response.status} ${response.statusText}`, - errorCode: isAuthError - ? "auth_error" - : isServerError - ? "server_error" - : "unknown_error", - }; - } - - const tokenResponse: OAuthTokenResponse = await response.json(); - - return { - success: true, - data: tokenResponse, - }; - } catch { - return { - success: false, - error: NETWORK_ERROR_MESSAGE, - errorCode: "network_error", - }; - } - } - - /** - * Cancel any pending OAuth flow. - */ - public cancelFlow(): CancelFlowOutput { - try { - if (this.pendingFlow) { - // Clean up HTTP server if in dev mode - if (this.pendingFlow.server) { - this.cleanupHttpServer(); - } else { - clearTimeout(this.pendingFlow.timeoutId); - this.pendingFlow.reject(new Error("OAuth flow cancelled")); - this.pendingFlow = null; - } - } - return { success: true }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } - } - - /** - * Wait for OAuth callback via deep link (production). - */ - private async waitForDeepLinkCallback( - codeVerifier: string, - config: OAuthConfig, - authUrl: string, - ): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - this.pendingFlow = null; - reject(new Error("Authorization timed out")); - }, OAUTH_TIMEOUT_MS); - - this.pendingFlow = { - codeVerifier, - config, - resolve, - reject, - timeoutId, - }; - - // Open the browser for authentication - this.urlLauncher.launch(authUrl).catch((error) => { - clearTimeout(timeoutId); - this.pendingFlow = null; - reject(new Error(`Failed to open browser: ${error.message}`)); - }); - }); - } - - /** - * Wait for OAuth callback via 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.", - }; - - 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); - } - this.pendingFlow = null; - } - - private async exchangeCodeForToken( - code: string, - codeVerifier: string, - config: OAuthConfig, - ): Promise { - const cloudUrl = getCloudUrlFromRegion(config.cloudRegion); - const redirectUri = this.getRedirectUri(); - const body = JSON.stringify({ - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - client_id: getOauthClientIdFromRegion(config.cloudRegion), - code_verifier: codeVerifier, - }); - - let lastError = "Token exchange failed"; - - for (let attempt = 0; attempt < TOKEN_FETCH_MAX_ATTEMPTS; attempt++) { - let response: Response; - try { - response = await fetch(`${cloudUrl}/oauth/token`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body, - }); - } catch (error) { - // fetch threw — DNS/TLS/socket failure. The raw message ("Failed to fetch", - // "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", { - attempt, - error: error instanceof Error ? error.message : String(error), - }); - if (attempt === TOKEN_FETCH_MAX_ATTEMPTS - 1) break; - await sleepWithBackoff(attempt, TOKEN_FETCH_BACKOFF); - continue; - } - - if (response.ok) { - return response.json(); - } - - lastError = `Token exchange failed: ${response.status} ${response.statusText}`; - const isServerError = response.status >= 500; - if (!isServerError) { - throw new Error(lastError); - } - - log.warn("Token exchange server error", { - attempt, - status: response.status, - }); - if (attempt === TOKEN_FETCH_MAX_ATTEMPTS - 1) break; - await sleepWithBackoff(attempt, TOKEN_FETCH_BACKOFF); - } - - throw new Error(lastError); - } - - private buildAuthorizeUrl(region: CloudRegion, codeVerifier: string): URL { - const codeChallenge = this.generateCodeChallenge(codeVerifier); - const redirectUri = this.getRedirectUri(); - const cloudUrl = getCloudUrlFromRegion(region); - const authUrl = new URL(`${cloudUrl}/oauth/authorize`); - authUrl.searchParams.set("client_id", getOauthClientIdFromRegion(region)); - authUrl.searchParams.set("redirect_uri", redirectUri); - authUrl.searchParams.set("response_type", "code"); - authUrl.searchParams.set("code_challenge", codeChallenge); - authUrl.searchParams.set("code_challenge_method", "S256"); - authUrl.searchParams.set("scope", OAUTH_SCOPES.join(" ")); - authUrl.searchParams.set("required_access_level", "project"); - return authUrl; - } - - private buildSignupUrl(region: CloudRegion, authUrl: URL): URL { - const cloudUrl = getCloudUrlFromRegion(region); - const signupUrl = new URL(`${cloudUrl}/signup`); - const nextPath = `${authUrl.pathname}${authUrl.search}`; - signupUrl.searchParams.set("next", nextPath); - return signupUrl; - } - - private async startFlowWithUrl( - config: OAuthConfig, - codeVerifier: string, - authUrl: string, - ): Promise { - const code = isDevBuild() - ? await this.waitForHttpCallback(codeVerifier, config, authUrl) - : await this.waitForDeepLinkCallback(codeVerifier, config, authUrl); - - const tokenResponse = await this.exchangeCodeForToken( - code, - codeVerifier, - config, - ); - - return { - success: true, - data: tokenResponse, - }; - } - - private generateCodeVerifier(): string { - return crypto.randomBytes(32).toString("base64url"); - } - - private generateCodeChallenge(verifier: string): string { - return crypto.createHash("sha256").update(verifier).digest("base64url"); - } - - /** - * Open an external URL in the default browser. - */ - public async openExternalUrl(url: string): Promise { - await this.urlLauncher.launch(url); - } -} diff --git a/apps/code/src/main/services/posthog-analytics.ts b/apps/code/src/main/services/posthog-analytics.ts index 6eb43841e3..c30c6c568c 100644 --- a/apps/code/src/main/services/posthog-analytics.ts +++ b/apps/code/src/main/services/posthog-analytics.ts @@ -1,102 +1,50 @@ -import { PostHog } from "posthog-node"; -import { getAppVersion } from "../utils/env"; - -let posthogClient: PostHog | null = null; -let currentUserId: string | null = null; +// PORT NOTE: bridge to @posthog/platform ANALYTICS_SERVICE. The implementation +// lives in apps/code/src/main/platform-adapters/posthog-analytics.ts and is +// bound to ANALYTICS_SERVICE in the main container. These free functions +// delegate to the shared adapter instance so existing call sites keep working. +// Retire when index.ts + analytics router + posthog-plugin/workspace/ +// app-lifecycle services inject ANALYTICS_SERVICE directly. +import type { AnalyticsProperties } from "@posthog/platform/analytics"; +import { posthogNodeAnalytics } from "../platform-adapters/posthog-analytics"; 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; + posthogNodeAnalytics.initialize(); } export function setCurrentUserId(userId: string | null) { - currentUserId = userId; + posthogNodeAnalytics.setCurrentUserId(userId); } export function getCurrentUserId() { - return currentUserId; + return posthogNodeAnalytics.getCurrentUserId(); } export function trackAppEvent( eventName: string, - properties?: Record, + properties?: AnalyticsProperties, ) { - 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, - }, - }); + posthogNodeAnalytics.track(eventName, properties); } -export function identifyUser( - userId: string, - properties?: Record, -) { - if (!posthogClient) { - return; - } - - currentUserId = userId; - - posthogClient.identify({ - distinctId: userId, - properties, - }); +export function identifyUser(userId: string, properties?: AnalyticsProperties) { + posthogNodeAnalytics.identify(userId, properties); } export async function shutdownPostHog() { - if (posthogClient) { - await posthogClient.shutdown(); - posthogClient = null; - } -} - -export function getPostHogClient() { - return posthogClient; + await posthogNodeAnalytics.shutdown(); } export function resetUser() { - currentUserId = null; + posthogNodeAnalytics.resetUser(); } export function captureException( error: unknown, additionalProperties?: Record, ) { - if (!posthogClient) { - return; - } + posthogNodeAnalytics.captureException(error, additionalProperties); +} - const distinctId = currentUserId || "anonymous-app-event"; - posthogClient.captureException(error, distinctId, { - team: "posthog-code", - ...additionalProperties, - app_version: getAppVersion(), - }); +export async function flushAnalytics() { + await posthogNodeAnalytics.flush(); } diff --git a/apps/code/src/main/services/posthog-plugin/service.test.ts b/apps/code/src/main/services/posthog-plugin/service.test.ts deleted file mode 100644 index 731deade7e..0000000000 --- a/apps/code/src/main/services/posthog-plugin/service.test.ts +++ /dev/null @@ -1,514 +0,0 @@ -import { vol } from "memfs"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -// Set env before module loads (SKILLS_ZIP_URL / CONTEXT_MILL_ZIP_URL are captured at module level) -vi.hoisted(() => { - process.env.SKILLS_ZIP_URL = "https://example.com/skills.zip"; - process.env.CONTEXT_MILL_ZIP_URL = "https://example.com/context-mill.zip"; -}); - -const mockStoragePaths = vi.hoisted(() => ({ - appDataPath: "/mock/userData", - logsPath: "/mock/logs", -})); - -const mockBundledResources = vi.hoisted(() => ({ - resolve: vi.fn((rel: string) => `/mock/appPath/${rel}`), - _setPackaged: (packaged: boolean) => { - mockBundledResources.resolve.mockImplementation((rel: string) => - packaged ? `/mock/appPath.unpacked/${rel}` : `/mock/appPath/${rel}`, - ); - }, -})); - -const mockFetch = vi.hoisted(() => vi.fn()); - -const mockExtractZip = vi.hoisted(() => - vi.fn<(zipPath: string, extractDir: string) => Promise>(async () => {}), -); - -vi.mock("node:fs", async () => { - const { fs } = await import("memfs"); - return { ...fs, default: fs }; -}); - -vi.mock("node:fs/promises", async () => { - const { fs } = await import("memfs"); - return { ...fs.promises, default: fs.promises }; -}); - -const mockFflateUnzip = vi.hoisted(() => vi.fn()); -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"); - return { - ...actual, - extractZip: mockExtractZip, - }; -}); - -vi.mock("node:os", () => ({ - homedir: () => "/mock/home", - tmpdir: () => "/mock/tmp", - 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 { IBundledResources } from "@posthog/platform/bundled-resources"; -import type { IStoragePaths } from "@posthog/platform/storage-paths"; -import { PosthogPluginService } from "./service"; -import { syncCodexSkills } from "./update-skills-saga"; - -/** Expose private members for testing without `as any`. */ -interface TestablePluginService { - initialize(): Promise; - copyBundledPlugin(): Promise; - intervalId: ReturnType | null; -} - -// Paths based on mock values -const RUNTIME_PLUGIN_DIR = "/mock/userData/plugins/posthog"; -const RUNTIME_SKILLS_DIR = "/mock/userData/skills"; -const BUNDLED_PLUGIN_DIR = "/mock/appPath/.vite/build/plugins/posthog"; -const BUNDLED_PLUGIN_DIR_PACKAGED = - "/mock/appPath.unpacked/.vite/build/plugins/posthog"; -const CODEX_SKILLS_DIR = "/mock/home/.agents/skills"; - -function mockFetchResponse(ok: boolean, status = 200) { - return { - ok, - status, - statusText: ok ? "OK" : "Not Found", - arrayBuffer: vi.fn(async () => new ArrayBuffer(8)), - }; -} - -/** Simulate zip extraction by creating skill files in the extracted dir */ -function simulateExtractZip() { - mockExtractZip.mockImplementation( - async (zipPath: string, extractDir: string) => { - if (zipPath.includes("context-mill")) { - // Inner zip bytes are dummy — fflate.unzip is mocked below. - vol.mkdirSync(extractDir, { recursive: true }); - vol.writeFileSync(`${extractDir}/omnibus-test-skill.zip`, "dummy"); - vol.writeFileSync(`${extractDir}/manifest.json`, "{}"); - // Non-omnibus zip should be ignored - vol.writeFileSync(`${extractDir}/other-skill.zip`, "dummy"); - } else { - // Primary skills zip - vol.mkdirSync(`${extractDir}/skills/remote-skill`, { - recursive: true, - }); - vol.writeFileSync( - `${extractDir}/skills/remote-skill/SKILL.md`, - "# Remote", - ); - } - }, - ); - - mockFflateUnzip.mockImplementation( - ( - _data: Uint8Array, - cb: (err: Error | null, data: Record) => void, - ) => { - cb(null, { - "SKILL.md": new TextEncoder().encode( - "---\nname: omnibus-test-skill\n---\n# Test Skill", - ), - }); - }, - ); -} - -/** Create the bundled plugin directory in memfs */ -function setupBundledPlugin(dir = BUNDLED_PLUGIN_DIR) { - vol.mkdirSync(`${dir}/skills/shipped-skill`, { recursive: true }); - vol.writeFileSync(`${dir}/plugin.json`, '{"name":"posthog"}'); - vol.writeFileSync(`${dir}/skills/shipped-skill/SKILL.md`, "# Shipped"); -} - -describe("PosthogPluginService", () => { - let service: PosthogPluginService; - - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - vol.reset(); - - mockBundledResources._setPackaged(false); - mockFetch.mockResolvedValue(mockFetchResponse(true)); - vi.stubGlobal("fetch", mockFetch); - mockExtractZip.mockResolvedValue(undefined); - - service = new PosthogPluginService( - mockStoragePaths as unknown as IStoragePaths, - mockBundledResources as unknown as IBundledResources, - ); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - afterEach(() => { - service.cleanup(); - vi.useRealTimers(); - }); - - describe("getPluginPath", () => { - it("returns bundled path in dev mode", () => { - process.env.POSTHOG_CODE_IS_DEV = "true"; - 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"; - mockBundledResources._setPackaged(true); - vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); - vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); - - expect(service.getPluginPath()).toBe(RUNTIME_PLUGIN_DIR); - }); - - it("returns bundled path as fallback in prod", () => { - process.env.POSTHOG_CODE_IS_DEV = "false"; - mockBundledResources._setPackaged(true); - expect(service.getPluginPath()).toBe(BUNDLED_PLUGIN_DIR_PACKAGED); - }); - }); - - describe("initialize", () => { - it("copies bundled plugin on first run when plugin.json is missing", async () => { - setupBundledPlugin(); - - await (service as unknown as TestablePluginService).initialize(); - - // Entire bundled dir should be copied to runtime - expect(vol.existsSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`)).toBe(true); - expect( - vol.existsSync(`${RUNTIME_PLUGIN_DIR}/skills/shipped-skill/SKILL.md`), - ).toBe(true); - }); - - it("skips bundled copy when plugin.json already exists in runtime", async () => { - setupBundledPlugin(); - // Pre-populate runtime dir (simulating previous run) - vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); - vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, '{"old":true}'); - - await (service as unknown as TestablePluginService).initialize(); - - // Should keep the existing runtime plugin.json, not overwrite - expect( - vol.readFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "utf-8"), - ).toBe('{"old":true}'); - }); - - it("overlays downloaded skills from cache on top of runtime dir", async () => { - setupBundledPlugin(); - // Pre-populate runtime dir - vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); - vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); - // Pre-populate skills cache (as if downloaded previously) - vol.mkdirSync(`${RUNTIME_SKILLS_DIR}/cached-skill`, { recursive: true }); - vol.writeFileSync( - `${RUNTIME_SKILLS_DIR}/cached-skill/SKILL.md`, - "# Cached", - ); - - await (service as unknown as TestablePluginService).initialize(); - - expect( - vol.readFileSync( - `${RUNTIME_PLUGIN_DIR}/skills/cached-skill/SKILL.md`, - "utf-8", - ), - ).toBe("# Cached"); - }); - - it("starts periodic update interval", async () => { - await (service as unknown as TestablePluginService).initialize(); - expect( - (service as unknown as TestablePluginService).intervalId, - ).not.toBeNull(); - }); - }); - - describe("updateSkills", () => { - it("downloads, extracts, and installs skills", async () => { - setupBundledPlugin(); - simulateExtractZip(); - - await service.updateSkills(); - - // Skills should be in the runtime cache - expect( - vol.existsSync(`${RUNTIME_SKILLS_DIR}/remote-skill/SKILL.md`), - ).toBe(true); - expect(mockFetch).toHaveBeenCalledWith("https://example.com/skills.zip"); - expect(mockExtractZip).toHaveBeenCalled(); - }); - - it("performs atomic swap of skills directory", async () => { - setupBundledPlugin(); - // Pre-populate existing cache with old skill - vol.mkdirSync(`${RUNTIME_SKILLS_DIR}/old-skill`, { recursive: true }); - vol.writeFileSync(`${RUNTIME_SKILLS_DIR}/old-skill/SKILL.md`, "# Old"); - - simulateExtractZip(); - await service.updateSkills(); - - // New skill should be present, old skill should be gone - expect( - vol.existsSync(`${RUNTIME_SKILLS_DIR}/remote-skill/SKILL.md`), - ).toBe(true); - expect(vol.existsSync(`${RUNTIME_SKILLS_DIR}/old-skill`)).toBe(false); - // Temp dirs should be cleaned up - expect(vol.existsSync(`${RUNTIME_SKILLS_DIR}.new`)).toBe(false); - expect(vol.existsSync(`${RUNTIME_SKILLS_DIR}.old`)).toBe(false); - }); - - it("overlays new skills into runtime plugin dir", async () => { - setupBundledPlugin(); - vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); - vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); - - simulateExtractZip(); - await service.updateSkills(); - - expect( - vol.existsSync(`${RUNTIME_PLUGIN_DIR}/skills/remote-skill/SKILL.md`), - ).toBe(true); - }); - - it("emits 'updated' event on success", async () => { - simulateExtractZip(); - const handler = vi.fn(); - service.on("skillsUpdated", handler); - - await service.updateSkills(); - - expect(handler).toHaveBeenCalledWith(true); - }); - - it("throttles: skips if called within 30 minutes", async () => { - simulateExtractZip(); - await service.updateSkills(); - mockFetch.mockClear(); - - await service.updateSkills(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("allows update after throttle period expires", async () => { - simulateExtractZip(); - await service.updateSkills(); - mockFetch.mockClear(); - - vi.advanceTimersByTime(31 * 60 * 1000); - await service.updateSkills(); - - expect(mockFetch).toHaveBeenCalled(); - }); - - it("skips if already updating (reentrance guard)", async () => { - let resolveDownload!: (value: unknown) => void; - mockFetch.mockReturnValue( - new Promise((resolve) => { - resolveDownload = resolve; - }), - ); - - // Start first update (hangs on fetch) - const first = service.updateSkills(); - - // Advance past throttle so second call reaches the `updating` check - vi.advanceTimersByTime(31 * 60 * 1000); - mockFetch.mockClear(); - await service.updateSkills(); - - // Second call should not have triggered another fetch - expect(mockFetch).not.toHaveBeenCalled(); - - // Clean up hanging promise - resolveDownload(mockFetchResponse(true)); - await first.catch(() => {}); - }); - - it("downloads and merges context-mill omnibus skills with prefix stripped", async () => { - setupBundledPlugin(); - simulateExtractZip(); - - await service.updateSkills(); - - // Omnibus skill should exist with prefix stripped - expect(vol.existsSync(`${RUNTIME_SKILLS_DIR}/test-skill/SKILL.md`)).toBe( - true, - ); - - // SKILL.md should have "omnibus-" stripped from name field - const content = vol.readFileSync( - `${RUNTIME_SKILLS_DIR}/test-skill/SKILL.md`, - "utf-8", - ); - expect(content).toContain("name: test-skill"); - expect(content).not.toContain("omnibus-"); - }); - - it("context-mill failure is non-fatal", async () => { - setupBundledPlugin(); - // Primary skills succeed - mockExtractZip.mockImplementation( - async (zipPath: string, extractDir: string) => { - if (zipPath.includes("context-mill")) { - throw new Error("context-mill download failed"); - } - vol.mkdirSync(`${extractDir}/skills/remote-skill`, { - recursive: true, - }); - vol.writeFileSync( - `${extractDir}/skills/remote-skill/SKILL.md`, - "# Remote", - ); - }, - ); - - const handler = vi.fn(); - service.on("skillsUpdated", handler); - await service.updateSkills(); - - // Primary skills should still be installed - expect( - vol.existsSync(`${RUNTIME_SKILLS_DIR}/remote-skill/SKILL.md`), - ).toBe(true); - // Update should still succeed - expect(handler).toHaveBeenCalledWith(true); - }); - - it("handles download failure gracefully", async () => { - mockFetch.mockRejectedValue(new Error("Network error")); - await expect(service.updateSkills()).resolves.toBeUndefined(); - }); - - it("handles non-ok response gracefully", async () => { - mockFetch.mockResolvedValue(mockFetchResponse(false, 404)); - await expect(service.updateSkills()).resolves.toBeUndefined(); - }); - - it("handles missing skills dir in archive", async () => { - // Extraction creates no skills directory - mockExtractZip.mockImplementation( - async (_zipPath: string, extractDir: string) => { - vol.mkdirSync(`${extractDir}/random-dir`, { recursive: true }); - vol.writeFileSync(`${extractDir}/random-dir/README.md`, "nope"); - }, - ); - - const handler = vi.fn(); - service.on("skillsUpdated", handler); - await service.updateSkills(); - - expect(handler).not.toHaveBeenCalled(); - }); - - it("cleans up temp dir even on error", async () => { - mockExtractZip.mockRejectedValue(new Error("extraction failed")); - - await service.updateSkills(); - - // Temp dir under /mock/tmp should be cleaned up - const tmpEntries = vol.existsSync("/mock/tmp") - ? vol.readdirSync("/mock/tmp") - : []; - expect(tmpEntries).toHaveLength(0); - }); - }); - - describe("syncCodexSkills", () => { - it("copies skill directories to Codex dir", async () => { - setupBundledPlugin(); - - await syncCodexSkills(BUNDLED_PLUGIN_DIR, CODEX_SKILLS_DIR); - - expect( - vol.readFileSync(`${CODEX_SKILLS_DIR}/shipped-skill/SKILL.md`, "utf-8"), - ).toBe("# Shipped"); - }); - - it("skips if effective skills dir does not exist", async () => { - // No skills dir anywhere - await syncCodexSkills("/nonexistent", CODEX_SKILLS_DIR); - - expect(vol.existsSync(CODEX_SKILLS_DIR)).toBe(false); - }); - }); - - describe("copyBundledPlugin", () => { - it("copies entire bundled dir to runtime dir", async () => { - setupBundledPlugin(); - - await (service as unknown as TestablePluginService).copyBundledPlugin(); - - expect( - vol.readFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "utf-8"), - ).toBe('{"name":"posthog"}'); - expect( - vol.readFileSync( - `${RUNTIME_PLUGIN_DIR}/skills/shipped-skill/SKILL.md`, - "utf-8", - ), - ).toBe("# Shipped"); - }); - - it("skips if bundled dir does not exist", async () => { - await (service as unknown as TestablePluginService).copyBundledPlugin(); - expect(vol.existsSync(RUNTIME_PLUGIN_DIR)).toBe(false); - }); - - it("handles copy failure gracefully", async () => { - // Bundled dir exists but is not a directory (will cause cp to fail or behave oddly) - // Just verify no exception propagates - setupBundledPlugin(); - await expect( - (service as unknown as TestablePluginService).copyBundledPlugin(), - ).resolves.toBeUndefined(); - }); - }); - - describe("cleanup", () => { - it("clears interval timer", async () => { - await (service as unknown as TestablePluginService).initialize(); - expect( - (service as unknown as TestablePluginService).intervalId, - ).not.toBeNull(); - - service.cleanup(); - expect( - (service as unknown as TestablePluginService).intervalId, - ).toBeNull(); - }); - - it("is safe to call multiple times", () => { - service.cleanup(); - service.cleanup(); - }); - }); -}); diff --git a/apps/code/src/main/services/posthog-plugin/service.ts b/apps/code/src/main/services/posthog-plugin/service.ts deleted file mode 100644 index eb5c925ef6..0000000000 --- a/apps/code/src/main/services/posthog-plugin/service.ts +++ /dev/null @@ -1,215 +0,0 @@ -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 { 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"); - -interface PosthogPluginEvents { - skillsUpdated: true; -} - -@injectable() -export class PosthogPluginService extends TypedEventEmitter { - private intervalId: ReturnType | null = null; - private lastCheckAt = 0; - private updating = false; - - constructor( - @inject(MAIN_TOKENS.StoragePaths) - private readonly storagePaths: IStoragePaths, - @inject(MAIN_TOKENS.BundledResources) - private readonly bundledResources: IBundledResources, - ) { - super(); - } - - /** Runtime plugin dir under userData */ - private get runtimePluginDir(): string { - return join(this.storagePaths.appDataPath, "plugins", "posthog"); - } - - /** Runtime skills cache (downloaded zips extracted here) */ - private get runtimeSkillsDir(): string { - return join(this.storagePaths.appDataPath, "skills"); - } - - /** Bundled plugin path inside the .vite build output */ - private get bundledPluginDir(): string { - return this.bundledResources.resolve(".vite/build/plugins/posthog"); - } - - @postConstruct() - init(): void { - this.initialize().catch((err) => { - log.error("Skills initialization failed", err); - captureException(err, { - source: "posthog-plugin", - operation: "initialize", - }); - }); - } - - private async initialize(): Promise { - // On first run (or after app update), copy the entire bundled plugin to the runtime dir. - // On subsequent starts the runtime dir already exists — just overlay any cached downloaded skills. - if (!existsSync(join(this.runtimePluginDir, "plugin.json"))) { - await this.copyBundledPlugin(); - } - - // Overlay any previously-downloaded skills on top of the runtime plugin - await overlayDownloadedSkills(this.runtimeSkillsDir, this.runtimePluginDir); - - await syncCodexSkills(this.getPluginPath(), CODEX_SKILLS_DIR); - - // Start periodic updates - this.intervalId = setInterval(() => { - this.updateSkills().catch((err) => { - log.warn("Periodic skills update failed", err); - }); - }, UPDATE_INTERVAL_MS); - - // Kick off first download - await this.updateSkills(); - } - - /** - * Returns the path to the plugin directory that should be used for agent sessions. - * - * - In dev mode: Vite already merged shipped + remote + local-dev skills, so use bundled path. - * - In prod: use the runtime plugin dir (with downloaded updates). - * - Fallback: bundled plugin path. - */ - getPluginPath(): string { - if (isDevBuild()) { - return this.bundledPluginDir; - } - - if (existsSync(join(this.runtimePluginDir, "plugin.json"))) { - return this.runtimePluginDir; - } - - return this.bundledPluginDir; - } - - async updateSkills(): Promise { - const now = Date.now(); - if (now - this.lastCheckAt < UPDATE_INTERVAL_MS) { - return; - } - - if (this.updating) { - return; - } - - this.updating = true; - this.lastCheckAt = now; - - const tempDir = join(tmpdir(), `posthog-code-skills-${Date.now()}`); - - try { - await mkdir(tempDir, { recursive: true }); - - const saga = new UpdateSkillsSaga(log); - const result = await saga.run({ - runtimeSkillsDir: this.runtimeSkillsDir, - runtimePluginDir: this.runtimePluginDir, - pluginPath: this.getPluginPath(), - codexSkillsDir: CODEX_SKILLS_DIR, - tempDir, - skillsZipUrl: SKILLS_ZIP_URL, - contextMillZipUrl: CONTEXT_MILL_ZIP_URL, - downloadFile: (url, destPath) => this.downloadFile(url, destPath), - }); - - if (result.success) { - this.emit("skillsUpdated", true); - } else { - log.warn("Skills update failed", { - error: result.error, - failedStep: result.failedStep, - }); - 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, { - source: "posthog-plugin", - operation: "updateSkills", - }); - } finally { - await rm(tempDir, { recursive: true, force: true }); - this.updating = false; - } - } - - /** - * Copies the entire bundled plugin directory to the runtime location. - * Called once on first run or after an app update. - */ - private async copyBundledPlugin(): Promise { - try { - if (!existsSync(this.bundledPluginDir)) { - log.warn("Bundled plugin dir not found", { - path: this.bundledPluginDir, - }); - return; - } - await rm(this.runtimePluginDir, { recursive: true, force: true }); - await cp(this.bundledPluginDir, this.runtimePluginDir, { - recursive: true, - }); - } catch (err) { - log.warn("Failed to copy bundled plugin", err); - captureException(err, { - source: "posthog-plugin", - operation: "copyBundledPlugin", - }); - } - } - - private async downloadFile(url: string, destPath: string): Promise { - const response = await fetch(url); - if (!response.ok) { - throw new Error( - `Download failed: ${response.status} ${response.statusText}`, - ); - } - - const buffer = await response.arrayBuffer(); - await writeFile(destPath, Buffer.from(buffer)); - } - - @preDestroy() - cleanup(): void { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } - } -} diff --git a/apps/code/src/main/services/process-tracking/service.test.ts b/apps/code/src/main/services/process-tracking/service.test.ts deleted file mode 100644 index 264679dd8f..0000000000 --- a/apps/code/src/main/services/process-tracking/service.test.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const mockPlatform = vi.hoisted(() => vi.fn(() => "darwin")); -const mockIsProcessAlive = vi.hoisted(() => vi.fn((_pid: number) => true)); -const mockKillProcessTree = vi.hoisted(() => vi.fn()); -const mockExecAsync = vi.hoisted(() => vi.fn()); - -vi.mock("node:child_process", () => ({ - exec: vi.fn(), - default: { exec: vi.fn() }, -})); - -vi.mock("node:util", () => ({ - promisify: () => mockExecAsync, - default: { promisify: () => mockExecAsync }, -})); - -vi.mock("node:os", () => ({ - platform: mockPlatform, - 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", () => ({ - isProcessAlive: mockIsProcessAlive, - killProcessTree: mockKillProcessTree, -})); - -import { ProcessTrackingService } from "./service"; - -function mockExecResolves(stdout: string): void { - mockExecAsync.mockResolvedValueOnce({ stdout, stderr: "" }); -} - -function mockExecRejects(error: Error): void { - mockExecAsync.mockRejectedValueOnce(error); -} - -describe("ProcessTrackingService", () => { - let service: ProcessTrackingService; - - beforeEach(() => { - vi.clearAllMocks(); - mockPlatform.mockReturnValue("darwin"); - mockIsProcessAlive.mockReturnValue(true); - service = new ProcessTrackingService(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe("register", () => { - it("tracks a process", () => { - service.register(1234, "shell", "shell:session-1"); - - const all = service.getAll(); - expect(all).toHaveLength(1); - expect(all[0]).toMatchObject({ - pid: 1234, - category: "shell", - label: "shell:session-1", - }); - }); - - it("stores metadata when provided", () => { - service.register(1234, "agent", "agent:run-1", { - taskId: "task-abc", - }); - - const all = service.getAll(); - expect(all[0].metadata).toEqual({ taskId: "task-abc" }); - }); - - it("sets registeredAt timestamp", () => { - const before = Date.now(); - service.register(1234, "shell", "test"); - const after = Date.now(); - - const proc = service.getAll()[0]; - expect(proc.registeredAt).toBeGreaterThanOrEqual(before); - expect(proc.registeredAt).toBeLessThanOrEqual(after); - }); - - it("overwrites an existing entry for the same PID", () => { - service.register(1234, "shell", "first"); - service.register(1234, "agent", "second"); - - const all = service.getAll(); - expect(all).toHaveLength(1); - expect(all[0].category).toBe("agent"); - expect(all[0].label).toBe("second"); - }); - }); - - describe("unregister", () => { - it("removes a tracked process", () => { - service.register(1234, "shell", "test"); - service.unregister(1234, "exited"); - - expect(service.getAll()).toHaveLength(0); - }); - - it("does nothing for an unknown PID", () => { - service.register(1234, "shell", "test"); - service.unregister(9999, "unknown"); - - expect(service.getAll()).toHaveLength(1); - }); - }); - - describe("getAll", () => { - it("returns empty array when nothing is tracked", () => { - expect(service.getAll()).toEqual([]); - }); - - it("returns all tracked processes", () => { - service.register(1, "shell", "s1"); - service.register(2, "agent", "a1"); - service.register(3, "child", "c1"); - - expect(service.getAll()).toHaveLength(3); - }); - }); - - describe("getByCategory", () => { - beforeEach(() => { - service.register(1, "shell", "s1"); - service.register(2, "shell", "s2"); - service.register(3, "agent", "a1"); - service.register(4, "child", "c1"); - }); - - it("filters by shell", () => { - const shells = service.getByCategory("shell"); - expect(shells).toHaveLength(2); - expect(shells.map((p) => p.pid)).toEqual([1, 2]); - }); - - it("filters by agent", () => { - const agents = service.getByCategory("agent"); - expect(agents).toHaveLength(1); - expect(agents[0].pid).toBe(3); - }); - - it("returns empty for category with no entries", () => { - service.unregister(4, "gone"); - expect(service.getByCategory("child")).toEqual([]); - }); - }); - - describe("getSnapshot", () => { - it("groups tracked processes by category", async () => { - service.register(1, "shell", "s1"); - service.register(2, "agent", "a1"); - service.register(3, "child", "c1"); - - const snapshot = await service.getSnapshot(); - - expect(snapshot.tracked.shell).toHaveLength(1); - expect(snapshot.tracked.agent).toHaveLength(1); - expect(snapshot.tracked.child).toHaveLength(1); - expect(snapshot.timestamp).toBeGreaterThan(0); - expect(snapshot.discovered).toBeUndefined(); - }); - - it("prunes dead PIDs before returning", async () => { - service.register(1, "shell", "alive"); - service.register(2, "shell", "dead"); - - mockIsProcessAlive.mockImplementation((pid: number) => pid === 1); - - const snapshot = await service.getSnapshot(); - - expect(snapshot.tracked.shell).toHaveLength(1); - expect(snapshot.tracked.shell[0].pid).toBe(1); - expect(service.getAll()).toHaveLength(1); - }); - - it("includes discovered processes when requested", async () => { - mockExecResolves( - ` 100 ${process.pid} /bin/bash\n 200 100 node server.js\n`, - ); - - service.register(100, "shell", "tracked-shell"); - - const snapshot = await service.getSnapshot(true); - - expect(snapshot.discovered).toBeDefined(); - expect(snapshot.discovered?.length).toBeGreaterThanOrEqual(1); - }); - }); - - describe("discoverChildren", () => { - it("returns empty on Windows", async () => { - mockPlatform.mockReturnValue("win32"); - - const result = await service.discoverChildren(); - - expect(result).toEqual([]); - expect(mockExecAsync).not.toHaveBeenCalled(); - }); - - it("finds direct children of the app", async () => { - const appPid = process.pid; - mockExecResolves( - [ - ` ${appPid + 1} ${appPid} /bin/bash`, - ` ${appPid + 2} ${appPid} node agent.js`, - ` 9999 1 /sbin/launchd`, - ].join("\n"), - ); - - const result = await service.discoverChildren(); - - expect(result).toHaveLength(2); - expect(result.map((p) => p.pid)).toContain(appPid + 1); - expect(result.map((p) => p.pid)).toContain(appPid + 2); - }); - - it("finds nested descendants recursively", async () => { - const appPid = process.pid; - const child = appPid + 1; - const grandchild = appPid + 2; - - mockExecResolves( - [ - ` ${child} ${appPid} /bin/bash`, - ` ${grandchild} ${child} node server.js`, - ].join("\n"), - ); - - const result = await service.discoverChildren(); - - expect(result).toHaveLength(2); - expect(result.find((p) => p.pid === grandchild)).toBeDefined(); - }); - - it("marks tracked PIDs as tracked", async () => { - const appPid = process.pid; - const childPid = appPid + 1; - - mockExecResolves(` ${childPid} ${appPid} /bin/bash\n`); - - service.register(childPid, "shell", "known"); - - const result = await service.discoverChildren(); - - expect(result).toHaveLength(1); - expect(result[0].tracked).toBe(true); - }); - - it("marks untracked PIDs as not tracked", async () => { - const appPid = process.pid; - - mockExecResolves(` ${appPid + 1} ${appPid} mystery-process\n`); - - const result = await service.discoverChildren(); - - expect(result).toHaveLength(1); - expect(result[0].tracked).toBe(false); - }); - - it("returns empty when exec fails", async () => { - mockExecRejects(new Error("ps failed")); - - const result = await service.discoverChildren(); - - expect(result).toEqual([]); - }); - - it("does not include processes that are not descendants", async () => { - mockExecResolves(` 9999 1 /sbin/launchd\n 8888 9999 some-other\n`); - - const result = await service.discoverChildren(); - - expect(result).toEqual([]); - }); - }); - - describe("isAlive", () => { - it("delegates to isProcessAlive", () => { - mockIsProcessAlive.mockReturnValue(true); - expect(service.isAlive(1234)).toBe(true); - - mockIsProcessAlive.mockReturnValue(false); - expect(service.isAlive(1234)).toBe(false); - - expect(mockIsProcessAlive).toHaveBeenCalledWith(1234); - }); - }); - - describe("kill", () => { - it("kills the process tree and unregisters", () => { - service.register(1234, "shell", "test"); - - service.kill(1234); - - expect(mockKillProcessTree).toHaveBeenCalledWith(1234); - expect(service.getAll()).toHaveLength(0); - }); - - it("still calls killProcessTree for untracked PIDs", () => { - service.kill(9999); - - expect(mockKillProcessTree).toHaveBeenCalledWith(9999); - }); - }); - - describe("killByCategory", () => { - it("kills all processes in the given category", () => { - service.register(1, "shell", "s1"); - service.register(2, "shell", "s2"); - service.register(3, "agent", "a1"); - - service.killByCategory("shell"); - - expect(mockKillProcessTree).toHaveBeenCalledWith(1); - expect(mockKillProcessTree).toHaveBeenCalledWith(2); - expect(mockKillProcessTree).not.toHaveBeenCalledWith(3); - expect(service.getByCategory("shell")).toHaveLength(0); - expect(service.getByCategory("agent")).toHaveLength(1); - }); - - it("does nothing when no processes in category", () => { - service.register(1, "agent", "a1"); - - service.killByCategory("shell"); - - expect(mockKillProcessTree).not.toHaveBeenCalled(); - expect(service.getAll()).toHaveLength(1); - }); - }); - - describe("getByTaskId", () => { - it("returns processes for a given taskId", () => { - service.register(1, "agent", "a1", undefined, "task-1"); - service.register(2, "agent", "a2", undefined, "task-1"); - service.register(3, "agent", "a3", undefined, "task-2"); - - const result = service.getByTaskId("task-1"); - expect(result).toHaveLength(2); - expect(result.map((p) => p.pid)).toEqual([1, 2]); - }); - - it("returns empty for unknown taskId", () => { - service.register(1, "agent", "a1", undefined, "task-1"); - - expect(service.getByTaskId("task-999")).toEqual([]); - }); - - it("returns empty for processes without taskId", () => { - service.register(1, "shell", "s1"); - - expect(service.getByTaskId("task-1")).toEqual([]); - }); - }); - - describe("killByTaskId", () => { - it("kills all processes for a given taskId", () => { - service.register(1, "agent", "a1", undefined, "task-1"); - service.register(2, "agent", "a2", undefined, "task-1"); - service.register(3, "agent", "a3", undefined, "task-2"); - - service.killByTaskId("task-1"); - - expect(mockKillProcessTree).toHaveBeenCalledWith(1); - expect(mockKillProcessTree).toHaveBeenCalledWith(2); - expect(mockKillProcessTree).not.toHaveBeenCalledWith(3); - expect(service.getByTaskId("task-1")).toEqual([]); - expect(service.getByTaskId("task-2")).toHaveLength(1); - }); - - it("does nothing for unknown taskId", () => { - service.register(1, "agent", "a1", undefined, "task-1"); - - service.killByTaskId("task-999"); - - expect(mockKillProcessTree).not.toHaveBeenCalled(); - expect(service.getAll()).toHaveLength(1); - }); - }); - - describe("taskId index cleanup", () => { - it("cleans up task index on unregister", () => { - service.register(1, "agent", "a1", undefined, "task-1"); - service.register(2, "agent", "a2", undefined, "task-1"); - - service.unregister(1, "exited"); - - expect(service.getByTaskId("task-1")).toHaveLength(1); - expect(service.getByTaskId("task-1")[0].pid).toBe(2); - }); - - it("cleans up task index on kill", () => { - service.register(1, "agent", "a1", undefined, "task-1"); - - service.kill(1); - - expect(service.getByTaskId("task-1")).toEqual([]); - }); - - it("updates task index when PID is re-registered under different task", () => { - service.register(1, "agent", "a1", undefined, "task-1"); - service.register(1, "agent", "a1-new", undefined, "task-2"); - - expect(service.getByTaskId("task-1")).toEqual([]); - expect(service.getByTaskId("task-2")).toHaveLength(1); - }); - - it("clears task index on killAll", () => { - service.register(1, "agent", "a1", undefined, "task-1"); - service.register(2, "agent", "a2", undefined, "task-2"); - - service.killAll(); - - expect(service.getByTaskId("task-1")).toEqual([]); - expect(service.getByTaskId("task-2")).toEqual([]); - }); - }); - - describe("killAll", () => { - it("kills all tracked processes and clears the map", () => { - service.register(1, "shell", "s1"); - service.register(2, "agent", "a1"); - service.register(3, "child", "c1"); - - service.killAll(); - - expect(mockKillProcessTree).toHaveBeenCalledWith(1); - expect(mockKillProcessTree).toHaveBeenCalledWith(2); - expect(mockKillProcessTree).toHaveBeenCalledWith(3); - expect(service.getAll()).toHaveLength(0); - }); - - it("does nothing when no processes are tracked", () => { - service.killAll(); - - expect(mockKillProcessTree).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/code/src/main/services/process-tracking/service.ts b/apps/code/src/main/services/process-tracking/service.ts deleted file mode 100644 index 23b653e52b..0000000000 --- a/apps/code/src/main/services/process-tracking/service.ts +++ /dev/null @@ -1,249 +0,0 @@ -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"; - -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; -} - -@injectable() -export class ProcessTrackingService { - private _isShuttingDown = false; - - get isShuttingDown(): boolean { - return this._isShuttingDown; - } - - private processes = new Map(); - private taskProcesses = new Map>(); - - register( - pid: number, - category: ProcessCategory, - label: string, - 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, { - pid, - category, - label, - registeredAt: Date.now(), - taskId, - metadata, - }); - - if (taskId) { - let pids = this.taskProcesses.get(taskId); - if (!pids) { - pids = new Set(); - this.taskProcesses.set(taskId, pids); - } - pids.add(pid); - } - } - - unregister(pid: number, _reason: string): void { - const proc = this.processes.get(pid); - if (proc) { - this.removeFromTaskIndex(pid); - this.processes.delete(pid); - } - } - - private removeFromTaskIndex(pid: number): void { - const proc = this.processes.get(pid); - if (proc?.taskId) { - const pids = this.taskProcesses.get(proc.taskId); - if (pids) { - pids.delete(pid); - if (pids.size === 0) { - this.taskProcesses.delete(proc.taskId); - } - } - } - } - - getAll(): TrackedProcess[] { - return Array.from(this.processes.values()); - } - - getByCategory(category: ProcessCategory): TrackedProcess[] { - return this.getAll().filter((p) => p.category === category); - } - - async getSnapshot(includeDiscovered = false): Promise { - // Prune dead PIDs - for (const [pid] of this.processes) { - if (!isProcessAlive(pid)) { - this.unregister(pid, "pruned-dead"); - } - } - - const tracked: Record = { - shell: [], - agent: [], - child: [], - }; - - for (const proc of this.processes.values()) { - tracked[proc.category].push(proc); - } - - const snapshot: ProcessSnapshot = { - tracked, - timestamp: Date.now(), - }; - - if (includeDiscovered) { - snapshot.discovered = await this.discoverChildren(); - } - - 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 []; - } - - const appPid = process.pid; - - let stdout: string; - try { - const result = await execAsync( - `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); - return []; - } - - const allProcesses: { pid: number; ppid: number; command: string }[] = []; - - for (const line of stdout.trim().split("\n")) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 3) { - const pid = Number.parseInt(parts[0], 10); - const ppid = Number.parseInt(parts[1], 10); - const command = parts.slice(2).join(" "); - if (!Number.isNaN(pid) && !Number.isNaN(ppid)) { - allProcesses.push({ pid, ppid, command }); - } - } - } - - // Build a set of all descendant PIDs - const descendants = new Set(); - const findDescendants = (parentPid: number): void => { - for (const p of allProcesses) { - if (p.ppid === parentPid && !descendants.has(p.pid)) { - descendants.add(p.pid); - findDescendants(p.pid); - } - } - }; - - findDescendants(appPid); - - const trackedPids = new Set(this.processes.keys()); - const discovered: DiscoveredProcess[] = []; - - for (const p of allProcesses) { - if (descendants.has(p.pid)) { - discovered.push({ - pid: p.pid, - ppid: p.ppid, - command: p.command, - tracked: trackedPids.has(p.pid), - }); - } - } - - return discovered; - } - - isAlive(pid: number): boolean { - return isProcessAlive(pid); - } - - kill(pid: number): void { - killProcessTree(pid); - this.unregister(pid, "killed"); - } - - getByTaskId(taskId: string): TrackedProcess[] { - const pids = this.taskProcesses.get(taskId); - if (!pids) return []; - return Array.from(pids) - .map((pid) => this.processes.get(pid)) - .filter((p): p is TrackedProcess => p !== undefined); - } - - killByCategory(category: ProcessCategory): void { - const procs = this.getByCategory(category); - for (const proc of procs) { - this.kill(proc.pid); - } - } - - 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); - } - } - - @preDestroy() - 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); - } - this.processes.clear(); - this.taskProcesses.clear(); - } -} diff --git a/apps/code/src/main/services/provisioning/service.ts b/apps/code/src/main/services/provisioning/service.ts deleted file mode 100644 index 67d2e0c804..0000000000 --- a/apps/code/src/main/services/provisioning/service.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { injectable } from "inversify"; -import { TypedEventEmitter } from "../../utils/typed-event-emitter"; - -export const ProvisioningEvent = { - Output: "output", -} as const; - -export interface ProvisioningOutputPayload { - taskId: string; - data: string; -} - -export interface ProvisioningServiceEvents { - [ProvisioningEvent.Output]: ProvisioningOutputPayload; -} - -@injectable() -export class ProvisioningService extends TypedEventEmitter { - emitOutput(taskId: string, data: string): void { - this.emit(ProvisioningEvent.Output, { taskId, data }); - } -} 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/shell/service.ts b/apps/code/src/main/services/shell/service.ts deleted file mode 100644 index f82fec5da1..0000000000 --- a/apps/code/src/main/services/shell/service.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { exec } from "node:child_process"; -import { existsSync } from "node:fs"; -import { homedir, platform } from "node:os"; -import { inject, injectable, preDestroy } from "inversify"; -import * as pty from "node-pty"; -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 { type ExecuteOutput, ShellEvent, type ShellEvents } from "./schemas"; - -// node-pty exposes destroy() at runtime but it's missing from type definitions -declare module "node-pty" { - interface IPty { - destroy(): void; - } -} - -const log = logger.scope("shell"); -const PTY_ENCODING = "utf8"; - -export interface ShellSession { - pty: pty.IPty; - exitPromise: Promise<{ exitCode: number }>; - command?: string; - disposables: pty.IDisposable[]; -} - -function getDefaultShell(): string { - if (platform() === "win32") { - return process.env.COMSPEC || "cmd.exe"; - } - return process.env.SHELL || "/bin/bash"; -} - -function getShellArgs(shell: string): string[] { - if (platform() === "win32") { - const lower = shell.toLowerCase(); - if (lower.includes("powershell") || lower.includes("pwsh")) { - return ["-NoLogo"]; - } - return []; - } - return ["-l"]; -} - -function buildShellEnv( - additionalEnv?: Record, -): Record { - const env = { ...process.env } as Record; - - if (platform() === "darwin" && !process.env.LC_ALL) { - const locale = process.env.LC_CTYPE || "en_US.UTF-8"; - Object.assign(env, { - LANG: locale, - LC_ALL: locale, - LC_MESSAGES: locale, - LC_NUMERIC: locale, - LC_COLLATE: locale, - LC_MONETARY: locale, - }); - } - - Object.assign(env, { - TERM_PROGRAM: "PostHog Code", - COLORTERM: "truecolor", - FORCE_COLOR: "3", - ...additionalEnv, - }); - - return env; -} - -export interface CreateSessionOptions { - sessionId: string; - cwd?: string; - taskId?: string; - initialCommand?: string; - additionalEnv?: Record; -} - -@injectable() -export class ShellService extends TypedEventEmitter { - private sessions = new Map(); - private processTracking: ProcessTrackingService; - private repositoryRepo: RepositoryRepository; - private workspaceRepo: WorkspaceRepository; - private worktreeRepo: WorktreeRepository; - - constructor( - @inject(MAIN_TOKENS.ProcessTrackingService) - processTracking: ProcessTrackingService, - @inject(MAIN_TOKENS.RepositoryRepository) - repositoryRepo: RepositoryRepository, - @inject(MAIN_TOKENS.WorkspaceRepository) - workspaceRepo: WorkspaceRepository, - @inject(MAIN_TOKENS.WorktreeRepository) - worktreeRepo: WorktreeRepository, - ) { - super(); - this.processTracking = processTracking; - this.repositoryRepo = repositoryRepo; - this.workspaceRepo = workspaceRepo; - this.worktreeRepo = worktreeRepo; - } - - async create( - sessionId: string, - cwd?: string, - taskId?: string, - ): Promise { - await this.createSession({ sessionId, cwd, taskId }); - } - - async createSession(options: CreateSessionOptions): Promise { - const { sessionId, cwd, taskId, initialCommand, additionalEnv } = options; - - const existing = this.sessions.get(sessionId); - if (existing) { - return existing; - } - - const taskEnv = await this.getTaskEnv(taskId); - const mergedEnv = { ...taskEnv, ...additionalEnv }; - const workingDir = this.resolveWorkingDir(sessionId, cwd); - const shell = getDefaultShell(); - - const ptyProcess = pty.spawn(shell, getShellArgs(shell), { - name: "xterm-256color", - cols: 80, - rows: 24, - cwd: workingDir, - env: buildShellEnv(mergedEnv), - encoding: PTY_ENCODING, - }); - - this.processTracking.register( - ptyProcess.pid, - "shell", - `shell:${sessionId}`, - { sessionId, cwd: workingDir }, - taskId, - ); - - let resolveExit: (result: { exitCode: number }) => void; - const exitPromise = new Promise<{ exitCode: number }>((resolve) => { - resolveExit = resolve; - }); - - const disposables: pty.IDisposable[] = []; - - disposables.push( - ptyProcess.onData((data: string) => { - this.emit(ShellEvent.Data, { sessionId, data }); - }), - ); - - disposables.push( - ptyProcess.onExit(({ exitCode }) => { - this.processTracking.unregister(ptyProcess.pid, "exited"); - const session = this.sessions.get(sessionId); - if (session) { - for (const d of session.disposables) { - d.dispose(); - } - session.pty.destroy(); - this.sessions.delete(sessionId); - } - this.emit(ShellEvent.Exit, { sessionId, exitCode }); - resolveExit({ exitCode }); - }), - ); - - if (initialCommand) { - setTimeout(() => { - ptyProcess.write(`${initialCommand}\n`); - }, 100); - } - - const session: ShellSession = { - pty: ptyProcess, - exitPromise, - command: initialCommand, - disposables, - }; - - this.sessions.set(sessionId, session); - return session; - } - - async createCommandSession(options: { - sessionId: string; - command: string; - cwd: string; - taskId?: string; - }): Promise { - const { sessionId, command, cwd, taskId } = options; - - const existing = this.sessions.get(sessionId); - if (existing) { - return; - } - - const taskEnv = await this.getTaskEnv(taskId); - const workingDir = this.resolveWorkingDir(sessionId, cwd); - const shell = getDefaultShell(); - - const ptyProcess = pty.spawn(shell, ["-c", command], { - name: "xterm-256color", - cols: 80, - rows: 24, - cwd: workingDir, - env: buildShellEnv(taskEnv), - encoding: PTY_ENCODING, - }); - - this.processTracking.register( - ptyProcess.pid, - "shell", - `shell:${sessionId}`, - { sessionId, cwd: workingDir, command }, - taskId, - ); - - let resolveExit: (result: { exitCode: number }) => void; - const exitPromise = new Promise<{ exitCode: number }>((resolve) => { - resolveExit = resolve; - }); - - const disposables: pty.IDisposable[] = []; - - disposables.push( - ptyProcess.onData((data: string) => { - this.emit(ShellEvent.Data, { sessionId, data }); - }), - ); - - disposables.push( - ptyProcess.onExit(({ exitCode }) => { - this.processTracking.unregister(ptyProcess.pid, "exited"); - const session = this.sessions.get(sessionId); - if (session) { - for (const d of session.disposables) { - d.dispose(); - } - session.pty.destroy(); - this.sessions.delete(sessionId); - } - this.emit(ShellEvent.Exit, { sessionId, exitCode }); - resolveExit({ exitCode }); - }), - ); - - const session: ShellSession = { - pty: ptyProcess, - exitPromise, - command, - disposables, - }; - - this.sessions.set(sessionId, session); - } - - write(sessionId: string, data: string): void { - this.getSessionOrThrow(sessionId).pty.write(data); - } - - resize(sessionId: string, cols: number, rows: number): void { - this.getSessionOrThrow(sessionId).pty.resize(cols, rows); - } - - check(sessionId: string): boolean { - return this.sessions.has(sessionId); - } - - hasSession(sessionId: string): boolean { - return this.sessions.has(sessionId); - } - - getSession(sessionId: string): ShellSession | undefined { - return this.sessions.get(sessionId); - } - - getSessionsByPrefix(prefix: string): string[] { - const result: string[] = []; - for (const sessionId of this.sessions.keys()) { - if (sessionId.startsWith(prefix)) { - result.push(sessionId); - } - } - return result; - } - - destroyByPrefix(prefix: string): void { - for (const sessionId of this.sessions.keys()) { - if (sessionId.startsWith(prefix)) { - this.destroy(sessionId); - } - } - } - - destroy(sessionId: string): void { - const session = this.sessions.get(sessionId); - if (session) { - const pid = session.pty.pid; - this.processTracking.kill(pid); - for (const disposable of session.disposables) { - disposable.dispose(); - } - session.pty.destroy(); - this.sessions.delete(sessionId); - } - } - - /** - * Destroy all active shell sessions. - * Used during application shutdown to ensure all child processes are cleaned up. - */ - @preDestroy() - destroyAll(): void { - for (const sessionId of this.sessions.keys()) { - this.destroy(sessionId); - } - } - - /** - * Get the count of active sessions. - */ - getSessionCount(): number { - return this.sessions.size; - } - - getProcess(sessionId: string): string | null { - return this.sessions.get(sessionId)?.pty.process ?? null; - } - - execute(cwd: string, command: string): Promise { - return new Promise((resolve) => { - exec(command, { cwd, timeout: 60000 }, (error, stdout, stderr) => { - resolve({ - stdout: stdout || "", - stderr: stderr || "", - exitCode: error?.code ?? 0, - }); - }); - }); - } - - private getSessionOrThrow(sessionId: string): ShellSession { - const session = this.sessions.get(sessionId); - if (!session) { - throw new Error(`Shell session ${sessionId} not found`); - } - return session; - } - - private resolveWorkingDir(sessionId: string, cwd?: string): string { - const home = homedir(); - const workingDir = cwd || home; - - if (!existsSync(workingDir)) { - log.warn( - `Shell session ${sessionId}: cwd "${workingDir}" does not exist, falling back to home`, - ); - return home; - } - - return workingDir; - } - - private async getTaskEnv( - taskId?: string, - ): Promise | undefined> { - if (!taskId) return undefined; - - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (!workspace || workspace.mode === "cloud" || !workspace.repositoryId) { - return undefined; - } - - const repo = this.repositoryRepo.findById(workspace.repositoryId); - if (!repo) return undefined; - - let worktreePath: string | null = null; - let worktreeName: string | null = null; - - if (workspace.mode === "worktree") { - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - if (worktree) { - worktreeName = worktree.name; - worktreePath = deriveWorktreePath(repo.path, worktreeName); - } - } - - return buildWorkspaceEnv({ - taskId, - folderPath: repo.path, - worktreePath, - worktreeName, - mode: workspace.mode, - }); - } -} 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/slack-integration/service.ts b/apps/code/src/main/services/slack-integration/service.ts deleted file mode 100644 index 126677a8e7..0000000000 --- a/apps/code/src/main/services/slack-integration/service.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; -import type { IUrlLauncher } from "@posthog/platform/url-launcher"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; -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"); - -const FLOW_TIMEOUT_MS = 5 * 60 * 1000; - -export const SlackIntegrationEvent = { - Callback: "callback", - FlowTimedOut: "flowTimedOut", -} as const; - -export interface SlackIntegrationCallback { - projectId: number | null; - integrationId: number | null; - status: "success" | "error"; - errorCode: string | null; - errorMessage: string | null; -} - -export interface SlackFlowTimedOut { - projectId: number; -} - -export interface SlackIntegrationEvents { - [SlackIntegrationEvent.Callback]: SlackIntegrationCallback; - [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; - - constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.UrlLauncher) - private readonly urlLauncher: IUrlLauncher, - @inject(MAIN_TOKENS.MainWindow) - private readonly mainWindow: IMainWindow, - ) { - super(); - - this.deepLinkService.registerHandler("slack-integration", (_path, params) => - this.handleCallback(params), - ); - } - - public async startFlow( - region: CloudRegion, - projectId: number, - ): 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.flowTimeout = null; - this.emit(SlackIntegrationEvent.FlowTimedOut, { projectId }); - }, FLOW_TIMEOUT_MS); - - await this.urlLauncher.launch(authorizeUrl); - - return { success: true }; - } catch (error) { - this.clearFlowTimeout(); - log.error("Failed to start Slack integration flow", { - projectId, - error: error instanceof Error ? error.message : String(error), - }); - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } - } - - public consumePendingCallback(): SlackIntegrationCallback | null { - const pending = this.pendingCallback; - this.pendingCallback = null; - return pending; - } - - private handleCallback(params: URLSearchParams): boolean { - const projectIdRaw = params.get("project_id"); - const parsedProjectId = projectIdRaw ? Number(projectIdRaw) : null; - const integrationIdRaw = params.get("integration_id"); - const parsedIntegrationId = integrationIdRaw - ? Number(integrationIdRaw) - : null; - const status = params.get("status") === "error" ? "error" : "success"; - - const callback: SlackIntegrationCallback = { - projectId: - parsedProjectId !== null && Number.isFinite(parsedProjectId) - ? parsedProjectId - : null, - integrationId: - parsedIntegrationId !== null && Number.isFinite(parsedIntegrationId) - ? parsedIntegrationId - : null, - status, - errorCode: params.get("error_code") || null, - errorMessage: params.get("error_message") || null, - }; - - this.clearFlowTimeout(); - - if (status === "error") { - log.error("Received Slack integration callback with error", { - projectId: callback.projectId, - errorCode: callback.errorCode, - errorMessage: callback.errorMessage, - }); - } - - const hasListeners = this.listenerCount(SlackIntegrationEvent.Callback) > 0; - if (hasListeners) { - this.emit(SlackIntegrationEvent.Callback, callback); - } else { - this.pendingCallback = callback; - } - - if (this.mainWindow.isMinimized()) { - this.mainWindow.restore(); - } - this.mainWindow.focus(); - - return true; - } - - private clearFlowTimeout(): void { - if (this.flowTimeout) { - clearTimeout(this.flowTimeout); - this.flowTimeout = null; - } - } -} diff --git a/apps/code/src/main/services/sleep/service.ts b/apps/code/src/main/services/sleep/service.ts deleted file mode 100644 index 9fa26b014c..0000000000 --- a/apps/code/src/main/services/sleep/service.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { IPowerManager } from "@posthog/platform/power-manager"; -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(); - - constructor( - @inject(MAIN_TOKENS.PowerManager) - private readonly powerManager: IPowerManager, - ) { - this.enabled = settingsStore.get("preventSleepWhileRunning", false); - } - - setEnabled(enabled: boolean): void { - log.info("setEnabled", { enabled }); - this.enabled = enabled; - settingsStore.set("preventSleepWhileRunning", enabled); - this.updateBlocker(); - } - - getEnabled(): boolean { - return this.enabled; - } - - acquire(activityId: string): void { - this.activeActivities.add(activityId); - this.updateBlocker(); - } - - release(activityId: string): void { - this.activeActivities.delete(activityId); - this.updateBlocker(); - } - - @preDestroy() - cleanup(): void { - this.stopBlocker(); - } - - private updateBlocker(): void { - if (this.enabled && this.activeActivities.size > 0) { - this.startBlocker(); - } else { - this.stopBlocker(); - } - } - - private startBlocker(): void { - if (this.releaseBlocker) return; - this.releaseBlocker = this.powerManager.preventSleep( - "prevent-app-suspension", - ); - log.info("Started power save blocker"); - } - - private stopBlocker(): void { - if (!this.releaseBlocker) return; - log.info("Stopping power save blocker"); - this.releaseBlocker(); - this.releaseBlocker = null; - } -} diff --git a/apps/code/src/main/services/suspension/schemas.ts b/apps/code/src/main/services/suspension/schemas.ts deleted file mode 100644 index 1cc43bf534..0000000000 --- a/apps/code/src/main/services/suspension/schemas.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from "zod"; -import { - type SuspendedTask, - suspendedTaskSchema, - suspensionReasonSchema, - suspensionSettingsSchema, -} from "../../../shared/types/suspension.js"; - -export { suspendedTaskSchema, type SuspendedTask }; - -export const suspendTaskInput = z.object({ - taskId: z.string(), - reason: suspensionReasonSchema.optional().default("manual"), -}); - -export type SuspendTaskInput = z.infer; - -export const restoreTaskInput = z.object({ - taskId: z.string(), - recreateBranch: z.boolean().optional(), -}); - -export type RestoreTaskInput = z.infer; - -export const suspendTaskOutput = suspendedTaskSchema; - -export const restoreTaskOutput = z.object({ - taskId: z.string(), - worktreeName: z.string().nullable(), -}); - -export const listSuspendedTasksOutput = z.array(suspendedTaskSchema); - -export const suspendedTaskIdsOutput = z.array(z.string()); - -export const suspensionSettingsOutput = suspensionSettingsSchema; - -export const updateSuspensionSettingsInput = suspensionSettingsSchema.partial(); diff --git a/apps/code/src/main/services/suspension/service.test.ts b/apps/code/src/main/services/suspension/service.test.ts deleted file mode 100644 index d3a8135eae..0000000000 --- a/apps/code/src/main/services/suspension/service.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const mockGetAutoSuspendEnabled = vi.hoisted(() => vi.fn(() => true)); -const mockGetMaxActiveWorktrees = vi.hoisted(() => vi.fn(() => 5)); -const mockGetAutoSuspendAfterDays = vi.hoisted(() => vi.fn(() => 7)); -const mockCaptureRun = vi.hoisted(() => vi.fn(() => ({ success: true }))); -const mockDeleteCheckpoint = vi.hoisted(() => vi.fn()); -const mockCreateGitClient = vi.hoisted(() => - vi.fn(() => ({ revparse: vi.fn(() => "feat/test\n") })), -); -const mockWorktreeManagerProto = vi.hoisted(() => ({ - deleteWorktree: vi.fn(), - createWorktreeForExistingBranch: vi.fn(), - 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, -})); -vi.mock("@posthog/git/sagas/checkpoint", () => ({ - CaptureCheckpointSaga: class { - run = mockCaptureRun; - }, - RevertCheckpointSaga: class { - run = vi.fn(() => ({ success: true })); - }, - deleteCheckpoint: mockDeleteCheckpoint, -})); -vi.mock("@posthog/git/worktree", () => ({ - WorktreeManager: class { - deleteWorktree = mockWorktreeManagerProto.deleteWorktree; - createWorktreeForExistingBranch = - mockWorktreeManagerProto.createWorktreeForExistingBranch; - createDetachedWorktreeAtCommit = - mockWorktreeManagerProto.createDetachedWorktreeAtCommit; - }, -})); -vi.mock("node:fs/promises", () => { - const fns = { - rm: vi.fn(), - access: vi.fn(), - lstat: vi - .fn() - .mockRejectedValue( - Object.assign(new Error("ENOENT"), { code: "ENOENT" }), - ), - chmod: vi.fn(), - readdir: vi.fn().mockResolvedValue([]), - }; - 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"; - -function createMocks() { - const agentService = { - cancelSessionsByTaskId: vi.fn(), - } as unknown as AgentService; - const processTracking = { - killByTaskId: vi.fn(), - } as unknown as ProcessTrackingService; - const fileWatcher = { - stopWatching: vi.fn(), - } as unknown as FileWatcherBridge; - const repositoryRepo = createMockRepositoryRepository(); - const workspaceRepo = createMockWorkspaceRepository(); - const worktreeRepo = createMockWorktreeRepository(); - const suspensionRepo = createMockSuspensionRepository(); - const archiveRepo = createMockArchiveRepository(); - - repositoryRepo.create({ path: "/repo", id: "repo-1" }); - - return { - agentService, - processTracking, - fileWatcher, - repositoryRepo, - workspaceRepo, - worktreeRepo, - suspensionRepo, - archiveRepo, - }; -} - -function makeService(mocks: ReturnType) { - return new SuspensionService( - mocks.agentService, - mocks.processTracking, - mocks.fileWatcher, - mocks.repositoryRepo, - mocks.workspaceRepo, - mocks.worktreeRepo, - mocks.suspensionRepo, - mocks.archiveRepo, - ); -} - -function seedWorktreeWorkspace( - mocks: ReturnType, - overrides: Partial = {}, -) { - const ws = mocks.workspaceRepo.create({ - taskId: overrides.taskId ?? "task-1", - repositoryId: overrides.repositoryId ?? "repo-1", - mode: overrides.mode ?? "worktree", - }); - const stored = mocks.workspaceRepo._workspaces.get(ws.id); - if (!stored) throw new Error(`Workspace not found: ${ws.id}`); - if (overrides.lastActivityAt !== undefined) - stored.lastActivityAt = overrides.lastActivityAt; - if (overrides.createdAt !== undefined) stored.createdAt = overrides.createdAt; - const resolved = mocks.workspaceRepo.findById(ws.id); - if (!resolved) throw new Error(`Workspace not found: ${ws.id}`); - mocks.worktreeRepo.create({ - workspaceId: resolved.id, - name: `wt-${resolved.taskId}`, - path: `/tmp/worktrees/wt-${resolved.taskId}/repo`, - }); - return resolved; -} - -describe("SuspensionService", () => { - let mocks: ReturnType; - let service: SuspensionService; - - beforeEach(() => { - vi.clearAllMocks(); - mockGetAutoSuspendEnabled.mockImplementation(() => true); - mockGetMaxActiveWorktrees.mockImplementation(() => 5); - mockGetAutoSuspendAfterDays.mockImplementation(() => 7); - mocks = createMocks(); - service = makeService(mocks); - }); - - afterEach(() => { - service.stopInactivityChecker(); - }); - - describe("getActiveWorktreeWorkspaces filtering", () => { - beforeEach(() => mockGetMaxActiveWorktrees.mockReturnValue(1)); - - it.each([ - [ - "non-worktree mode", - (m: ReturnType) => - seedWorktreeWorkspace(m, { mode: "local" }), - ], - [ - "already-suspended", - (m: ReturnType) => { - const ws = seedWorktreeWorkspace(m); - m.suspensionRepo.create({ - workspaceId: ws.id, - branchName: null, - checkpointId: null, - reason: "manual", - }); - }, - ], - [ - "archived", - (m: ReturnType) => { - const ws = seedWorktreeWorkspace(m); - m.archiveRepo.create({ - workspaceId: ws.id, - branchName: null, - checkpointId: null, - }); - }, - ], - ])("excludes %s workspaces", async (_label, setup) => { - setup(mocks); - await service.suspendLeastRecentIfOverLimit(); - expect( - mocks.suspensionRepo - .findAll() - .filter((s) => s.reason === "max_worktrees"), - ).toHaveLength(0); - }); - }); - - describe("suspendLeastRecentIfOverLimit", () => { - it("does nothing when autoSuspendEnabled is false", async () => { - mockGetAutoSuspendEnabled.mockReturnValue(false); - seedWorktreeWorkspace(mocks); - await service.suspendLeastRecentIfOverLimit(); - expect(mocks.suspensionRepo.findAll()).toHaveLength(0); - }); - - it("does nothing when active count is below the limit", async () => { - seedWorktreeWorkspace(mocks); - await service.suspendLeastRecentIfOverLimit(); - expect(mocks.suspensionRepo.findAll()).toHaveLength(0); - }); - - it.each([ - [ - "lastActivityAt", - "2024-01-01T00:00:00.000Z", - "2024-06-01T00:00:00.000Z", - ], - ["createdAt fallback", null, null], - ])( - "suspends the oldest workspace by %s", - async (_label, oldActivity, newActivity) => { - const older = seedWorktreeWorkspace(mocks, { - taskId: "task-old", - lastActivityAt: oldActivity, - createdAt: "2024-01-01T00:00:00.000Z", - }); - seedWorktreeWorkspace(mocks, { - taskId: "task-new", - lastActivityAt: newActivity, - createdAt: "2024-06-01T00:00:00.000Z", - }); - mockGetMaxActiveWorktrees.mockReturnValue(1); - - await service.suspendLeastRecentIfOverLimit(); - - const suspended = mocks.suspensionRepo.findAll(); - expect(suspended).toHaveLength(1); - expect(suspended[0].workspaceId).toBe(older.id); - }, - ); - }); - - describe("suspendInactiveWorktrees", () => { - it("does not suspend recently active worktrees", async () => { - seedWorktreeWorkspace(mocks, { - lastActivityAt: new Date().toISOString(), - }); - await service.suspendInactiveWorktrees(); - expect(mocks.suspensionRepo.findAll()).toHaveLength(0); - }); - - it.each([ - ["lastActivityAt", "2020-01-01T00:00:00.000Z", undefined], - ["createdAt fallback", null, "2020-01-01T00:00:00.000Z"], - ])( - "suspends stale worktrees using %s", - async (_label, lastActivityAt, createdAt) => { - seedWorktreeWorkspace(mocks, { - lastActivityAt, - ...(createdAt ? { createdAt } : {}), - }); - - await service.suspendInactiveWorktrees(); - - const suspended = mocks.suspensionRepo.findAll(); - expect(suspended).toHaveLength(1); - expect(suspended[0].reason).toBe("inactivity"); - }, - ); - }); - - describe("withRollback", () => { - it("propagates the error and does not persist suspension", async () => { - seedWorktreeWorkspace(mocks); - mocks.suspensionRepo = createMockSuspensionRepository({ - failOnCreate: true, - }); - service = makeService(mocks); - - await expect(service.suspendTask("task-1", "manual")).rejects.toThrow( - "Injected failure on suspension create", - ); - expect(mocks.suspensionRepo.findAll()).toHaveLength(0); - }); - }); -}); diff --git a/apps/code/src/main/services/suspension/service.ts b/apps/code/src/main/services/suspension/service.ts deleted file mode 100644 index ba765fc3c3..0000000000 --- a/apps/code/src/main/services/suspension/service.ts +++ /dev/null @@ -1,533 +0,0 @@ -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"; -import { forceRemove } from "@posthog/git/utils"; -import { type WorktreeInfo, WorktreeManager } from "@posthog/git/worktree"; -import { inject, injectable } from "inversify"; -import type { IArchiveRepository } from "../../db/repositories/archive-repository.js"; -import type { IRepositoryRepository } from "../../db/repositories/repository-repository.js"; -import type { - SuspensionReason, - SuspensionRepository, -} from "../../db/repositories/suspension-repository.js"; -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"; -import { - getAutoSuspendAfterDays, - getAutoSuspendEnabled, - getMaxActiveWorktrees, - getWorktreeLocation, - setAutoSuspendAfterDays, - setAutoSuspendEnabled, - setMaxActiveWorktrees, -} from "../settingsStore.js"; -import type { SuspendedTask } from "./schemas.js"; - -const log = logger.scope("suspension"); - -type RollbackFn = () => Promise; -type StepFn = ( - execute: () => Promise, - rollback?: RollbackFn, -) => Promise; - -export const SuspensionServiceEvent = { - Suspended: "suspended", - Restored: "restored", -} as const; - -export interface SuspensionServiceEvents { - [SuspensionServiceEvent.Suspended]: { taskId: string; reason: string }; - [SuspensionServiceEvent.Restored]: { taskId: string }; -} - -@injectable() -export class SuspensionService extends TypedEventEmitter { - private inactivityTimerId: ReturnType | null = null; - - constructor( - @inject(MAIN_TOKENS.AgentService) - private readonly agentService: AgentService, - @inject(MAIN_TOKENS.ProcessTrackingService) - private readonly processTracking: ProcessTrackingService, - @inject(MAIN_TOKENS.FileWatcherService) - private readonly fileWatcher: FileWatcherBridge, - @inject(MAIN_TOKENS.RepositoryRepository) - private readonly repositoryRepo: IRepositoryRepository, - @inject(MAIN_TOKENS.WorkspaceRepository) - private readonly workspaceRepo: IWorkspaceRepository, - @inject(MAIN_TOKENS.WorktreeRepository) - private readonly worktreeRepo: IWorktreeRepository, - @inject(MAIN_TOKENS.SuspensionRepository) - private readonly suspensionRepo: SuspensionRepository, - @inject(MAIN_TOKENS.ArchiveRepository) - private readonly archiveRepo: IArchiveRepository, - ) { - super(); - } - - async suspendTask( - taskId: string, - reason: SuspensionReason, - ): Promise { - log.info(`Suspending task ${taskId} (reason: ${reason})`); - const result = await this.withRollback((step) => - this.executeSuspend(taskId, reason, step), - ); - this.emit(SuspensionServiceEvent.Suspended, { taskId, reason }); - return result; - } - - async restoreTask( - taskId: string, - recreateBranch?: boolean, - ): Promise<{ taskId: string; worktreeName: string | null }> { - log.info( - `Restoring suspended task ${taskId}${recreateBranch ? " (recreate branch)" : ""}`, - ); - const result = await this.withRollback((step) => - this.executeRestore(taskId, recreateBranch, step), - ); - this.emit(SuspensionServiceEvent.Restored, { taskId }); - return result; - } - - getSuspendedTasks(): SuspendedTask[] { - return this.suspensionRepo.findAll().map((suspension) => { - const workspace = this.workspaceRepo.findById( - suspension.workspaceId, - ) as Workspace; - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - return { - taskId: workspace.taskId, - suspendedAt: suspension.suspendedAt, - reason: suspension.reason as SuspendedTask["reason"], - folderId: workspace.repositoryId ?? "", - mode: workspace.mode as SuspendedTask["mode"], - worktreeName: worktree?.name ?? null, - branchName: suspension.branchName, - checkpointId: suspension.checkpointId, - }; - }); - } - - getSuspendedTaskIds(): string[] { - return this.getSuspendedTasks().map((t) => t.taskId); - } - - isSuspended(taskId: string): boolean { - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (!workspace) return false; - return this.suspensionRepo.findByWorkspaceId(workspace.id) !== null; - } - - async suspendLeastRecentIfOverLimit(): Promise { - if (!getAutoSuspendEnabled()) return; - const maxActive = getMaxActiveWorktrees(); - const active = this.getActiveWorktreeWorkspaces(); - if (active.length < maxActive) return; - - const oldest = active.sort((a, b) => { - const aTime = a.lastActivityAt ?? a.createdAt ?? ""; - const bTime = b.lastActivityAt ?? b.createdAt ?? ""; - return aTime.localeCompare(bTime); - })[0]; - - if (!oldest) return; - log.info( - `Auto-suspending task ${oldest.taskId} (max: ${maxActive}, active: ${active.length})`, - ); - await this.autoSuspend(oldest.taskId, "max_worktrees"); - } - - async suspendInactiveWorktrees(): Promise { - if (!getAutoSuspendEnabled()) return; - const thresholdDays = getAutoSuspendAfterDays(); - - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - thresholdDays); - const cutoffStr = cutoff.toISOString(); - - const candidates = this.getActiveWorktreeWorkspaces().filter((ws) => { - return (ws.lastActivityAt ?? ws.createdAt ?? "") < cutoffStr; - }); - - for (const ws of candidates) { - log.info( - `Auto-suspending inactive task ${ws.taskId} (last activity: ${ws.lastActivityAt ?? ws.createdAt})`, - ); - await this.autoSuspend(ws.taskId, "inactivity"); - } - } - - startInactivityChecker(): void { - if (this.inactivityTimerId) return; - const ONE_HOUR_MS = 60 * 60 * 1000; - this.inactivityTimerId = setInterval(() => { - this.suspendInactiveWorktrees().catch((error) => { - log.error("Inactivity checker failed:", error); - }); - }, ONE_HOUR_MS); - } - - stopInactivityChecker(): void { - if (!this.inactivityTimerId) return; - clearInterval(this.inactivityTimerId); - this.inactivityTimerId = null; - } - - getSettings() { - return { - autoSuspendEnabled: getAutoSuspendEnabled(), - maxActiveWorktrees: getMaxActiveWorktrees(), - autoSuspendAfterDays: getAutoSuspendAfterDays(), - }; - } - - updateSettings(settings: { - autoSuspendEnabled?: boolean; - maxActiveWorktrees?: number; - autoSuspendAfterDays?: number; - }) { - if (settings.autoSuspendEnabled !== undefined) - setAutoSuspendEnabled(settings.autoSuspendEnabled); - if (settings.maxActiveWorktrees !== undefined) - setMaxActiveWorktrees(settings.maxActiveWorktrees); - if (settings.autoSuspendAfterDays !== undefined) - setAutoSuspendAfterDays(settings.autoSuspendAfterDays); - } - - private async withRollback(fn: (step: StepFn) => Promise): Promise { - const rollbacks: RollbackFn[] = []; - const step: StepFn = async (execute, rollback) => { - await execute(); - if (rollback) rollbacks.push(rollback); - }; - - try { - return await fn(step); - } catch (error) { - for (const rollback of rollbacks.reverse()) { - try { - await rollback(); - } catch (e) { - log.error("Rollback failed:", e); - } - } - throw error; - } - } - - private getActiveWorktreeWorkspaces(): Workspace[] { - return this.workspaceRepo.findAll().filter((ws) => { - if (ws.mode !== "worktree") return false; - if (this.suspensionRepo.findByWorkspaceId(ws.id)) return false; - if (this.archiveRepo.findByWorkspaceId(ws.id)) return false; - return true; - }); - } - - private async autoSuspend( - taskId: string, - reason: SuspensionReason, - ): Promise { - try { - await this.suspendTask(taskId, reason); - } catch (error) { - log.error(`Failed to auto-suspend task ${taskId}:`, error); - } - } - - private getWorkspaceWithRepo(taskId: string) { - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (!workspace) throw new Error(`Workspace not found for task ${taskId}`); - - let folderPath: string | null = null; - if (workspace.repositoryId) { - const repo = this.repositoryRepo.findById(workspace.repositoryId); - if (!repo) throw new Error(`Repository not found for task ${taskId}`); - folderPath = repo.path; - } - - return { workspace, folderPath }; - } - - private createWorktreeManager(folderPath: string) { - return new WorktreeManager({ - mainRepoPath: folderPath, - worktreeBasePath: getWorktreeLocation(), - }); - } - - private async deleteWorktreeOnDisk( - folderPath: string, - worktreePath: string, - ): Promise { - const manager = this.createWorktreeManager(folderPath); - await manager.deleteWorktree(worktreePath); - await forceRemove(path.dirname(worktreePath)); - } - - private async killTaskProcesses( - taskId: string, - worktreePath?: string, - ): Promise { - await this.agentService.cancelSessionsByTaskId(taskId); - this.processTracking.killByTaskId(taskId); - if (worktreePath) await this.fileWatcher.stopWatching(worktreePath); - } - - private async executeSuspend( - taskId: string, - reason: SuspensionReason, - step: StepFn, - ): Promise { - const { workspace, folderPath } = this.getWorkspaceWithRepo(taskId); - - if (this.suspensionRepo.findByWorkspaceId(workspace.id)) - throw new Error(`Task ${taskId} is already suspended`); - if (this.archiveRepo.findByWorkspaceId(workspace.id)) - throw new Error(`Task ${taskId} is already archived`); - - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - const isWorktreeMode = - workspace.mode === "worktree" && worktree && folderPath; - - const suspendedTask: SuspendedTask = { - taskId, - suspendedAt: new Date().toISOString(), - reason, - folderId: workspace.repositoryId ?? "", - mode: workspace.mode, - worktreeName: worktree?.name ?? null, - branchName: null, - checkpointId: isWorktreeMode ? `suspension-${worktree.name}` : null, - }; - - if (isWorktreeMode) { - const worktreePath = worktree.path; - - const branch = await this.getCurrentBranchName(worktreePath); - if (branch && branch !== "HEAD") suspendedTask.branchName = branch; - - const checkpointId = suspendedTask.checkpointId; - if (!checkpointId) - throw new Error("checkpointId must be set in worktree mode"); - - await step( - async () => { - await this.captureWorktreeCheckpoint( - folderPath, - worktreePath, - checkpointId, - ); - }, - async () => { - const git = createGitClient(folderPath); - await deleteCheckpoint(git, checkpointId); - }, - ); - - await step(async () => this.killTaskProcesses(taskId, worktreePath)); - await step(async () => - this.deleteWorktreeOnDisk(folderPath, worktreePath), - ); - } else { - await step(async () => this.killTaskProcesses(taskId)); - } - - await step( - async () => { - this.suspensionRepo.create({ - workspaceId: workspace.id, - branchName: suspendedTask.branchName, - checkpointId: suspendedTask.checkpointId, - reason, - }); - }, - async () => this.suspensionRepo.deleteByWorkspaceId(workspace.id), - ); - - return suspendedTask; - } - - private async executeRestore( - taskId: string, - recreateBranch: boolean | undefined, - step: StepFn, - ): Promise<{ taskId: string; worktreeName: string | null }> { - const { workspace, folderPath } = this.getWorkspaceWithRepo(taskId); - - const suspension = this.suspensionRepo.findByWorkspaceId(workspace.id); - if (!suspension) throw new Error(`Suspended task not found: ${taskId}`); - - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - let restoredWorktreeName: string | null = worktree?.name ?? null; - - if ( - folderPath && - workspace.mode === "worktree" && - suspension.checkpointId - ) { - const checkpointId = suspension.checkpointId; - await step( - async () => { - restoredWorktreeName = await this.restoreWorktreeFromCheckpoint( - folderPath, - workspace, - suspension.branchName, - checkpointId, - recreateBranch, - ); - }, - async () => { - if (restoredWorktreeName) { - const worktreePath = await this.deriveWorktreePath( - folderPath, - restoredWorktreeName, - ); - await this.deleteWorktreeOnDisk(folderPath, worktreePath); - } - }, - ); - - await step( - async () => { - if (!restoredWorktreeName) - throw new Error("Failed to restore worktree"); - const worktreePath = await this.deriveWorktreePath( - folderPath, - restoredWorktreeName, - ); - this.worktreeRepo.create({ - workspaceId: workspace.id, - name: restoredWorktreeName, - path: worktreePath, - }); - }, - async () => this.worktreeRepo.deleteByWorkspaceId(workspace.id), - ); - } - - await step( - async () => this.suspensionRepo.deleteByWorkspaceId(workspace.id), - async () => { - this.suspensionRepo.create({ - workspaceId: workspace.id, - branchName: suspension.branchName, - checkpointId: suspension.checkpointId, - reason: suspension.reason as SuspensionReason, - }); - }, - ); - - return { taskId, worktreeName: restoredWorktreeName }; - } - - private async getCurrentBranchName(worktreePath: string): Promise { - try { - const git = createGitClient(worktreePath); - return (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); - } catch { - return ""; - } - } - - private async 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}`); - } - - private async restoreWorktreeFromCheckpoint( - folderPath: string, - workspace: Workspace, - branchName: string | null, - checkpointId: string, - recreateBranch?: boolean, - ): Promise { - 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, - checkpointId, - }); - 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( - 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, - worktreeName, - ); - - try { - await fs.access(newFormatPath); - return newFormatPath; - } catch {} - try { - await fs.access(legacyFormatPath); - return legacyFormatPath; - } catch {} - return newFormatPath; - } -} diff --git a/apps/code/src/main/services/task-link/service.ts b/apps/code/src/main/services/task-link/service.ts deleted file mode 100644 index 463cf71c0e..0000000000 --- a/apps/code/src/main/services/task-link/service.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { IMainWindow } from "@posthog/platform/main-window"; -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"); - -export const TaskLinkEvent = { - OpenTask: "openTask", -} as const; - -export interface TaskLinkEvents { - [TaskLinkEvent.OpenTask]: { taskId: string; taskRunId?: string }; -} - -export interface PendingDeepLink { - taskId: string; - taskRunId?: string; -} - -@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; - - constructor( - @inject(MAIN_TOKENS.DeepLinkService) - private readonly deepLinkService: DeepLinkService, - @inject(MAIN_TOKENS.MainWindow) - private readonly mainWindow: IMainWindow, - ) { - super(); - - this.deepLinkService.registerHandler("task", (path) => - this.handleTaskLink(path), - ); - } - - 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"); - return false; - } - - // Check if renderer is ready (has any listeners) - const hasListeners = this.listenerCount(TaskLinkEvent.OpenTask) > 0; - - if (hasListeners) { - 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( - `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 }); - if (this.mainWindow.isMinimized()) { - this.mainWindow.restore(); - } - this.mainWindow.focus(); - - 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( - `Consumed pending task link: taskId=${pending.taskId}, taskRunId=${pending.taskRunId ?? "none"}`, - ); - } - return pending; - } -} diff --git a/apps/code/src/main/services/ui/service.ts b/apps/code/src/main/services/ui/service.ts deleted file mode 100644 index f991d4ea88..0000000000 --- a/apps/code/src/main/services/ui/service.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 { UIServiceEvent, type UIServiceEvents } from "./schemas"; - -@injectable() -export class UIService extends TypedEventEmitter { - constructor( - @inject(MAIN_TOKENS.AuthService) - private readonly authService: AuthService, - ) { - super(); - } - - openSettings(): void { - this.emit(UIServiceEvent.OpenSettings, true); - } - - newTask(): void { - this.emit(UIServiceEvent.NewTask, true); - } - - resetLayout(): void { - this.emit(UIServiceEvent.ResetLayout, true); - } - - clearStorage(): void { - this.emit(UIServiceEvent.ClearStorage, true); - } - - async invalidateToken(): Promise { - await this.authService.invalidateAccessTokenForTest(); - this.emit(UIServiceEvent.InvalidateToken, true); - } -} diff --git a/apps/code/src/main/services/updates/service.test.ts b/apps/code/src/main/services/updates/service.test.ts deleted file mode 100644 index f21cbd874f..0000000000 --- a/apps/code/src/main/services/updates/service.test.ts +++ /dev/null @@ -1,1073 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { UpdatesEvent } from "./schemas"; - -// Use vi.hoisted to ensure mocks are available when vi.mock is hoisted -const { - mockUpdater, - mockAppLifecycle, - mockAppMeta, - mockMainWindow, - mockLifecycleService, - mockLog, - updaterHandlers, -} = vi.hoisted(() => { - const updaterHandlers: { - checkStart: (() => void) | null; - updateAvailable: (() => void) | null; - noUpdate: (() => void) | null; - updateDownloaded: ((version: string) => void) | null; - error: ((error: Error) => void) | null; - focus: (() => void) | null; - } = { - checkStart: null, - updateAvailable: null, - noUpdate: null, - updateDownloaded: null, - error: null, - focus: null, - }; - - return { - updaterHandlers, - mockUpdater: { - isSupported: vi.fn(() => true), - setFeedUrl: vi.fn(), - check: vi.fn(), - quitAndInstall: vi.fn(), - onCheckStart: vi.fn((h: () => void) => { - updaterHandlers.checkStart = h; - return () => {}; - }), - onUpdateAvailable: vi.fn((h: () => void) => { - updaterHandlers.updateAvailable = h; - return () => {}; - }), - onNoUpdate: vi.fn((h: () => void) => { - updaterHandlers.noUpdate = h; - return () => {}; - }), - onUpdateDownloaded: vi.fn((h: (version: string) => void) => { - updaterHandlers.updateDownloaded = h; - return () => {}; - }), - onError: vi.fn((h: (error: Error) => void) => { - updaterHandlers.error = h; - return () => {}; - }), - }, - mockAppLifecycle: { - whenReady: vi.fn(() => Promise.resolve()), - quit: vi.fn(), - exit: vi.fn(), - onQuit: vi.fn(() => () => {}), - registerDeepLinkScheme: vi.fn(), - }, - mockAppMeta: { - version: "1.0.0", - isProduction: true, - }, - mockMainWindow: { - focus: vi.fn(), - isFocused: vi.fn(() => false), - isMinimized: vi.fn(() => false), - restore: vi.fn(), - onFocus: vi.fn((h: () => void) => { - updaterHandlers.focus = h; - return () => {}; - }), - }, - mockLifecycleService: { - shutdown: vi.fn(() => Promise.resolve()), - shutdownWithoutContainer: vi.fn(() => Promise.resolve()), - setQuittingForUpdate: vi.fn(), - clearQuittingForUpdate: vi.fn(), - }, - mockLog: { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - }, - }; -}); - -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"; - -function injectPorts(service: UpdatesService): void { - const s = service as unknown as Record; - s.lifecycleService = mockLifecycleService; - s.updater = mockUpdater; - s.appLifecycle = mockAppLifecycle; - s.appMeta = mockAppMeta; - s.mainWindow = mockMainWindow; -} - -// Helper to initialize service and wait for setup without running the periodic interval infinitely -async function initializeService(service: UpdatesService): Promise { - service.init(); - // Allow the whenReady promise microtask to resolve - await vi.advanceTimersByTimeAsync(0); -} - -describe("UpdatesService", () => { - let service: UpdatesService; - let originalPlatform: PropertyDescriptor | undefined; - let originalEnv: NodeJS.ProcessEnv; - - beforeEach(() => { - vi.clearAllMocks(); - vi.useFakeTimers(); - - // Store original values - originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - originalEnv = { ...process.env }; - - // Reset mocks to default state - mockAppMeta.isProduction = true; - mockAppMeta.version = "1.0.0"; - mockUpdater.isSupported.mockReturnValue(true); - mockUpdater.quitAndInstall.mockImplementation(() => undefined); - mockLifecycleService.shutdownWithoutContainer.mockImplementation(() => - Promise.resolve(), - ); - mockAppLifecycle.whenReady.mockResolvedValue(undefined); - - // Set default platform to darwin (macOS) - Object.defineProperty(process, "platform", { - value: "darwin", - configurable: true, - }); - - // Clear env flag - delete process.env.ELECTRON_DISABLE_AUTO_UPDATE; - - service = new UpdatesService(); - injectPorts(service); - }); - - afterEach(() => { - vi.useRealTimers(); - - // Restore original values - if (originalPlatform) { - Object.defineProperty(process, "platform", originalPlatform); - } - process.env = originalEnv; - }); - - 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", () => { - 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", () => { - 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", () => { - it("sets up auto updater when enabled", async () => { - await initializeService(service); - - expect(mockMainWindow.onFocus).toHaveBeenCalledWith(expect.any(Function)); - 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, - }); - - const newService = new UpdatesService(); - injectPorts(newService); - newService.init(); - - expect(mockAppLifecycle.whenReady).not.toHaveBeenCalled(); - }); - - it("prevents multiple initializations", async () => { - await initializeService(service); - - const firstCallCount = mockUpdater.setFeedUrl.mock.calls.length; - - // Simulate whenReady resolving again (shouldn't happen, but testing guard) - await initializeService(service); - - // setFeedURL should not be called again - expect(mockUpdater.setFeedUrl.mock.calls.length).toBe(firstCallCount); - }); - }); - - describe("feedUrl", () => { - it("constructs correct feed URL with platform, arch, and version", async () => { - Object.defineProperty(process, "arch", { - value: "arm64", - configurable: true, - }); - mockAppMeta.version = "2.0.0"; - - await initializeService(service); - - expect(mockUpdater.setFeedUrl).toHaveBeenCalledWith( - "https://update.electronjs.org/PostHog/code/darwin-arm64/2.0.0", - ); - }); - }); - - describe("checkForUpdates", () => { - it("returns success when updates are enabled", () => { - const result = service.checkForUpdates(); - expect(result).toEqual({ success: true }); - }); - - it("returns error when updates are disabled (not packaged)", () => { - mockUpdater.isSupported.mockReturnValue(false); - mockAppMeta.isProduction = false; - - const newService = new UpdatesService(); - injectPorts(newService); - const result = newService.checkForUpdates(); - - expect(result).toEqual({ - success: false, - errorMessage: "Updates only available in packaged builds", - errorCode: "disabled", - }); - }); - - it("returns error when updates are disabled (unsupported platform)", () => { - Object.defineProperty(process, "platform", { - value: "linux", - configurable: true, - }); - - const newService = new UpdatesService(); - injectPorts(newService); - const result = newService.checkForUpdates(); - - expect(result).toEqual({ - success: false, - errorMessage: "Auto updates only supported on macOS and Windows", - errorCode: "disabled", - }); - }); - - it("returns error when already checking for updates", () => { - // First call starts the check - service.checkForUpdates(); - - // Second call should fail - const result = service.checkForUpdates(); - expect(result).toEqual({ - success: false, - errorMessage: "Already checking for updates", - errorCode: "already_checking", - }); - }); - - it("emits status event when checking starts", () => { - const statusHandler = vi.fn(); - service.on(UpdatesEvent.Status, statusHandler); - - service.checkForUpdates(); - - expect(statusHandler).toHaveBeenCalledWith({ checking: true }); - }); - - it("calls autoUpdater.checkForUpdates", async () => { - await initializeService(service); - - // Complete the initial check triggered by setupAutoUpdater - const notAvailableHandler = updaterHandlers.noUpdate; - if (notAvailableHandler) { - notAvailableHandler(); - } - - mockUpdater.check.mockClear(); - service.checkForUpdates(); - - expect(mockUpdater.check).toHaveBeenCalled(); - }); - - it("allows retry after previous check completes", async () => { - await initializeService(service); - - // Complete the initial check triggered by setupAutoUpdater - const notAvailableHandler = updaterHandlers.noUpdate; - - if (notAvailableHandler) { - notAvailableHandler(); - } - - // First explicit check - const result1 = service.checkForUpdates(); - expect(result1.success).toBe(true); - - // Simulate completion - if (notAvailableHandler) { - notAvailableHandler(); - } - - // Second check should succeed - const result2 = service.checkForUpdates(); - expect(result2.success).toBe(true); - }); - }); - - describe("hasUpdateReady", () => { - it("returns false initially", () => { - expect(service.hasUpdateReady).toBe(false); - }); - - it("returns true after an update is downloaded", async () => { - await initializeService(service); - - const downloadedHandler = updaterHandlers.updateDownloaded; - - if (downloadedHandler) { - downloadedHandler("v2.0.0"); - } - - expect(service.hasUpdateReady).toBe(true); - }); - }); - - describe("installUpdate", () => { - it("returns false when no update is ready", async () => { - const result = await service.installUpdate(); - expect(result).toEqual({ installed: false }); - }); - - it("calls quitAndInstall when update is ready", async () => { - await initializeService(service); - - // Simulate update downloaded - const updateDownloadedHandler = updaterHandlers.updateDownloaded; - - if (updateDownloadedHandler) { - updateDownloadedHandler("v2.0.0"); - } - - const resultPromise = service.installUpdate(); - await vi.runOnlyPendingTimersAsync(); - const result = await resultPromise; - expect(result).toEqual({ installed: true }); - - // Verify setQuittingForUpdate is called first - expect(mockLifecycleService.setQuittingForUpdate).toHaveBeenCalled(); - - expect(mockLifecycleService.shutdownWithoutContainer).toHaveBeenCalled(); - expect(mockLifecycleService.shutdown).not.toHaveBeenCalled(); - - expect(mockUpdater.quitAndInstall).toHaveBeenCalled(); - - // Verify order: setQuittingForUpdate -> shutdownWithoutContainer -> quitAndInstall - const setQuittingOrder = - mockLifecycleService.setQuittingForUpdate.mock.invocationCallOrder[0]; - const cleanupOrder = - mockLifecycleService.shutdownWithoutContainer.mock - .invocationCallOrder[0]; - const quitAndInstallOrder = - mockUpdater.quitAndInstall.mock.invocationCallOrder[0]; - - expect(setQuittingOrder).toBeLessThan(cleanupOrder); - expect(cleanupOrder).toBeLessThan(quitAndInstallOrder); - }); - - it("continues to quitAndInstall if partial shutdown times out", async () => { - await initializeService(service); - - updaterHandlers.updateDownloaded?.("v2.0.0"); - - mockLifecycleService.shutdownWithoutContainer.mockReturnValue( - new Promise(() => {}), - ); - - const resultPromise = service.installUpdate(); - await vi.advanceTimersByTimeAsync(3000); - - await expect(resultPromise).resolves.toEqual({ installed: true }); - expect(mockUpdater.quitAndInstall).toHaveBeenCalled(); - expect(mockLog.warn).toHaveBeenCalledWith( - "Partial shutdown timed out before update install", - expect.objectContaining({ - timeoutMs: 3000, - downloadedVersion: "v2.0.0", - }), - ); - }); - - it("returns false if quitAndInstall throws", async () => { - await initializeService(service); - - // Simulate update downloaded - const updateDownloadedHandler = updaterHandlers.updateDownloaded; - - if (updateDownloadedHandler) { - updateDownloadedHandler("v2.0.0"); - } - - mockUpdater.quitAndInstall.mockImplementation(() => { - throw new Error("Failed to install"); - }); - - const resultPromise = service.installUpdate(); - await vi.runOnlyPendingTimersAsync(); - const result = await resultPromise; - expect(result).toEqual({ installed: false }); - }); - - it("clears the quitting-for-update lifecycle flag when install handoff fails", async () => { - await initializeService(service); - updaterHandlers.updateDownloaded?.("v2.0.0"); - - mockUpdater.quitAndInstall.mockImplementation(() => { - throw new Error("Failed to install"); - }); - - await service.installUpdate(); - - expect(mockLifecycleService.clearQuittingForUpdate).toHaveBeenCalled(); - const setOrder = - mockLifecycleService.setQuittingForUpdate.mock.invocationCallOrder[0]; - const clearOrder = - mockLifecycleService.clearQuittingForUpdate.mock.invocationCallOrder[0]; - expect(setOrder).toBeLessThan(clearOrder); - }); - - it("rolls back to a re-installable ready state when install handoff fails", async () => { - await initializeService(service); - updaterHandlers.updateDownloaded?.("v2.0.0"); - - mockUpdater.quitAndInstall.mockImplementation(() => { - throw new Error("Failed to install"); - }); - - const statusHandler = vi.fn(); - service.on(UpdatesEvent.Status, statusHandler); - - const first = await service.installUpdate(); - expect(first).toEqual({ installed: false }); - expect(service.hasUpdateReady).toBe(true); - expect(statusHandler).toHaveBeenLastCalledWith({ - checking: false, - updateReady: true, - installing: false, - version: "v2.0.0", - }); - - mockUpdater.quitAndInstall.mockImplementationOnce(() => undefined); - const second = await service.installUpdate(); - expect(second).toEqual({ installed: true }); - }); - - it("is idempotent when install is already in progress", async () => { - await initializeService(service); - - updaterHandlers.updateDownloaded?.("v2.0.0"); - - await expect(service.installUpdate()).resolves.toEqual({ - installed: true, - }); - expect(mockUpdater.quitAndInstall).toHaveBeenCalledTimes(1); - - await expect(service.installUpdate()).resolves.toEqual({ - installed: true, - }); - expect(mockUpdater.quitAndInstall).toHaveBeenCalledTimes(1); - expect(mockLog.warn).not.toHaveBeenCalledWith( - "installUpdate called but no update is ready", - expect.anything(), - ); - }); - }); - - describe("triggerMenuCheck", () => { - it("emits CheckFromMenu event", () => { - const handler = vi.fn(); - service.on(UpdatesEvent.CheckFromMenu, handler); - - service.triggerMenuCheck(); - - expect(handler).toHaveBeenCalledWith(true); - }); - }); - - describe("autoUpdater event handling", () => { - beforeEach(async () => { - await initializeService(service); - }); - - it("registers all required event handlers", () => { - expect(mockUpdater.onError).toHaveBeenCalled(); - expect(mockUpdater.onCheckStart).toHaveBeenCalled(); - expect(mockUpdater.onUpdateAvailable).toHaveBeenCalled(); - expect(mockUpdater.onNoUpdate).toHaveBeenCalled(); - expect(mockUpdater.onUpdateDownloaded).toHaveBeenCalled(); - }); - - it("handles update-not-available event", () => { - const statusHandler = vi.fn(); - service.on(UpdatesEvent.Status, statusHandler); - - // Start a check - service.checkForUpdates(); - statusHandler.mockClear(); - - // Simulate no update available - const notAvailableHandler = updaterHandlers.noUpdate; - - if (notAvailableHandler) { - notAvailableHandler(); - } - - expect(statusHandler).toHaveBeenCalledWith({ - checking: false, - upToDate: true, - version: "1.0.0", - }); - }); - - it("ignores later update events once an update is already downloaded", () => { - // Simulate update already downloaded - const downloadedHandler = updaterHandlers.updateDownloaded; - if (downloadedHandler) { - downloadedHandler("v2.0.0"); - } - - const statusHandler = vi.fn(); - const readyHandler = vi.fn(); - service.on(UpdatesEvent.Status, statusHandler); - service.on(UpdatesEvent.Ready, readyHandler); - - mockUpdater.check.mockClear(); - - // Periodic checks should be suppressed once an update is staged. - service.checkForUpdates("periodic"); - expect(mockUpdater.check).not.toHaveBeenCalled(); - - const notAvailableHandler = updaterHandlers.noUpdate; - if (notAvailableHandler) { - notAvailableHandler(); - } - - expect(statusHandler).not.toHaveBeenCalledWith({ checking: false }); - expect(statusHandler).not.toHaveBeenCalledWith( - expect.objectContaining({ upToDate: true }), - ); - expect(readyHandler).not.toHaveBeenCalled(); - }); - - it("handles update-downloaded event with version info", () => { - const readyHandler = vi.fn(); - service.on(UpdatesEvent.Ready, readyHandler); - - // Simulate update downloaded with version - const downloadedHandler = updaterHandlers.updateDownloaded; - - if (downloadedHandler) { - downloadedHandler("v2.0.0"); - } - - expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); - }); - - it("emits a complete staged payload when an update is downloaded", () => { - const statusHandler = vi.fn(); - service.on(UpdatesEvent.Status, statusHandler); - - updaterHandlers.updateDownloaded?.("v2.0.0"); - - expect(statusHandler).toHaveBeenCalledWith({ - checking: false, - updateReady: true, - installing: false, - version: "v2.0.0", - }); - }); - - it("handles error event and emits status with error", () => { - const statusHandler = vi.fn(); - service.on(UpdatesEvent.Status, statusHandler); - - // Start a check - service.checkForUpdates(); - statusHandler.mockClear(); - - // Simulate error - const errorHandler = updaterHandlers.error; - - if (errorHandler) { - errorHandler(new Error("Network error")); - } - - expect(statusHandler).toHaveBeenCalledWith({ - checking: false, - error: "Network error", - }); - }); - - it("handles error event gracefully when not checking", () => { - // Complete the initial check triggered by setupAutoUpdater so we're not in checking state - const notAvailableHandler = updaterHandlers.noUpdate; - if (notAvailableHandler) { - notAvailableHandler(); - } - - const statusHandler = vi.fn(); - service.on(UpdatesEvent.Status, statusHandler); - - // Simulate error without starting a check - const errorHandler = updaterHandlers.error; - - expect(() => { - if (errorHandler) { - errorHandler(new Error("Test error")); - } - }).not.toThrow(); - - // Should not emit status since we weren't checking - expect(statusHandler).not.toHaveBeenCalled(); - }); - }); - - describe("status snapshots", () => { - it("returns update-ready status for a staged update", async () => { - await initializeService(service); - - updaterHandlers.updateDownloaded?.("v2.0.0"); - - expect(service.getStatus()).toEqual({ - checking: false, - updateReady: true, - installing: false, - version: "v2.0.0", - }); - }); - - it("flags installing in the staged status payload while install is in flight", async () => { - await initializeService(service); - - updaterHandlers.updateDownloaded?.("v2.0.0"); - mockLifecycleService.shutdownWithoutContainer.mockReturnValue( - new Promise(() => {}), - ); - - void service.installUpdate(); - // Allow the synchronous part of installUpdate to run. - await Promise.resolve(); - - expect(service.getStatus()).toEqual({ - checking: false, - updateReady: true, - installing: true, - version: "v2.0.0", - }); - }); - - it("returns downloading status while an update is downloading", async () => { - await initializeService(service); - - updaterHandlers.updateAvailable?.(); - - expect(service.getStatus()).toEqual({ - checking: true, - downloading: true, - }); - }); - }); - - describe("check timeout", () => { - beforeEach(async () => { - await initializeService(service); - }); - - it("times out after 60 seconds if no response", async () => { - const statusHandler = vi.fn(); - service.on(UpdatesEvent.Status, statusHandler); - - service.checkForUpdates(); - statusHandler.mockClear(); - - // Advance 60 seconds - await vi.advanceTimersByTimeAsync(60 * 1000); - - expect(statusHandler).toHaveBeenCalledWith({ - checking: false, - error: "Update check timed out. Please try again.", - }); - }); - - it("clears timeout when update-not-available fires", async () => { - const statusHandler = vi.fn(); - service.on(UpdatesEvent.Status, statusHandler); - - service.checkForUpdates(); - statusHandler.mockClear(); - - // Simulate response before timeout - const notAvailableHandler = updaterHandlers.noUpdate; - - if (notAvailableHandler) { - notAvailableHandler(); - } - - // Advance past the timeout - await vi.advanceTimersByTimeAsync(60 * 1000); - - // Should only have received the upToDate status, not a timeout - expect(statusHandler).toHaveBeenCalledTimes(1); - expect(statusHandler).toHaveBeenCalledWith({ - checking: false, - upToDate: true, - version: "1.0.0", - }); - }); - - it("clears timeout when error fires", async () => { - const statusHandler = vi.fn(); - service.on(UpdatesEvent.Status, statusHandler); - - service.checkForUpdates(); - statusHandler.mockClear(); - - // Simulate error before timeout - const errorHandler = updaterHandlers.error; - - if (errorHandler) { - errorHandler(new Error("Network error")); - } - - // Advance past the timeout - await vi.advanceTimersByTimeAsync(60 * 1000); - - // Should only have received the error status, not a timeout - expect(statusHandler).toHaveBeenCalledTimes(1); - expect(statusHandler).toHaveBeenCalledWith({ - checking: false, - error: "Network error", - }); - }); - }); - - describe("flushPendingNotification", () => { - it("emits Ready event on window focus when update is pending", async () => { - await initializeService(service); - - const readyHandler = vi.fn(); - service.on(UpdatesEvent.Ready, readyHandler); - - // Simulate update downloaded - const downloadedHandler = updaterHandlers.updateDownloaded; - - if (downloadedHandler) { - downloadedHandler("v2.0.0"); - } - - // First Ready event from handleUpdateDownloaded - expect(readyHandler).toHaveBeenCalledTimes(1); - - // Reset the handler count - readyHandler.mockClear(); - - // Pending notification should be false now, so no second emit - updaterHandlers.focus?.(); - - expect(readyHandler).not.toHaveBeenCalled(); - }); - }); - - describe("periodic update checks", () => { - it("performs initial check on setup", async () => { - await initializeService(service); - - expect(mockUpdater.check).toHaveBeenCalled(); - }); - - it("performs check every hour", async () => { - await initializeService(service); - - const initialCallCount = mockUpdater.check.mock.calls.length; - - // Advance 1 hour - await vi.advanceTimersByTimeAsync(60 * 60 * 1000); - - expect(mockUpdater.check.mock.calls.length).toBe(initialCallCount + 1); - - // Advance another hour - await vi.advanceTimersByTimeAsync(60 * 60 * 1000); - - expect(mockUpdater.check.mock.calls.length).toBe(initialCallCount + 2); - }); - - it("stops the periodic interval once an update is staged", async () => { - await initializeService(service); - - updaterHandlers.updateDownloaded?.("v2.0.0"); - - const baselineCallCount = mockUpdater.check.mock.calls.length; - - // The interval would normally fire every hour; with the update staged it - // should be cleared so no further wake-ups occur. - await vi.advanceTimersByTimeAsync(60 * 60 * 1000 * 3); - - expect(mockUpdater.check.mock.calls.length).toBe(baselineCallCount); - }); - }); - - describe("staged update guards", () => { - it("does not re-check on periodic checks when update is ready", async () => { - await initializeService(service); - - // Simulate update downloaded - const downloadedHandler = updaterHandlers.updateDownloaded; - if (downloadedHandler) { - downloadedHandler("v2.0.0"); - } - - // Clear the checkForUpdates calls from initialization - mockUpdater.check.mockClear(); - - // Periodic check should not overwrite or refresh the staged update. - const result = service.checkForUpdates("periodic"); - expect(result).toEqual({ success: true }); - expect(mockUpdater.check).not.toHaveBeenCalled(); - // Update should still be ready (state not reset) - expect(service.hasUpdateReady).toBe(true); - }); - - it("user check still shows existing notification when update is ready", async () => { - await initializeService(service); - - // Simulate update downloaded - const downloadedHandler = updaterHandlers.updateDownloaded; - if (downloadedHandler) { - downloadedHandler("v2.0.0"); - } - - const readyHandler = vi.fn(); - service.on(UpdatesEvent.Ready, readyHandler); - - // User check should show existing notification, not re-check - mockUpdater.check.mockClear(); - const result = service.checkForUpdates("user"); - expect(result).toEqual({ success: true }); - expect(mockUpdater.check).not.toHaveBeenCalled(); - expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); - }); - - it("preserves downloaded update when later updater errors fire", async () => { - await initializeService(service); - - // Simulate update downloaded - const downloadedHandler = updaterHandlers.updateDownloaded; - if (downloadedHandler) { - downloadedHandler("v2.0.0"); - } - - mockUpdater.check.mockClear(); - service.checkForUpdates("periodic"); - expect(mockUpdater.check).not.toHaveBeenCalled(); - - // Simulate a stale updater error after staging. - const errorHandler = updaterHandlers.error; - if (errorHandler) { - errorHandler(new Error("Network error")); - } - - // Update should still be ready - expect(service.hasUpdateReady).toBe(true); - }); - - it("does not re-notify when same version is re-downloaded after staging", async () => { - await initializeService(service); - - const readyHandler = vi.fn(); - service.on(UpdatesEvent.Ready, readyHandler); - - // First download of v2.0.0 - const downloadedHandler = updaterHandlers.updateDownloaded; - if (downloadedHandler) { - downloadedHandler("v2.0.0"); - } - expect(readyHandler).toHaveBeenCalledTimes(1); - - readyHandler.mockClear(); - - if (downloadedHandler) { - downloadedHandler("v2.0.0"); - } - - // Should NOT re-notify since same version - expect(readyHandler).not.toHaveBeenCalled(); - }); - - it("does not overwrite staged version when a later download event arrives", async () => { - await initializeService(service); - - const readyHandler = vi.fn(); - service.on(UpdatesEvent.Ready, readyHandler); - - // Simulate update downloaded - const downloadedHandler = updaterHandlers.updateDownloaded; - if (downloadedHandler) { - downloadedHandler("v2.0.0"); - } - expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); - - readyHandler.mockClear(); - - if (downloadedHandler) { - downloadedHandler("v3.0.0"); - } - - // User checks should still surface the originally staged update. - service.checkForUpdates("user"); - expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); - - // Update should still be ready (state not corrupted) - expect(service.hasUpdateReady).toBe(true); - }); - }); - - describe("transition logging", () => { - it("logs state transitions with source and state metadata", () => { - service.checkForUpdates("user"); - - expect(mockLog.info).toHaveBeenCalledWith( - "Update state transition", - expect.objectContaining({ - source: "user", - fromState: "idle", - toState: "checking", - downloadedVersion: null, - skippedBecauseUpdateStaged: false, - }), - ); - }); - - it("logs skipped checks after an update is staged", async () => { - await initializeService(service); - updaterHandlers.updateDownloaded?.("v2.0.0"); - - mockLog.info.mockClear(); - service.checkForUpdates("periodic"); - - expect(mockLog.info).toHaveBeenCalledWith( - "Update state transition", - expect.objectContaining({ - source: "periodic", - fromState: "ready", - toState: "ready", - downloadedVersion: "v2.0.0", - skippedBecauseUpdateStaged: true, - }), - ); - }); - }); - - describe("error handling", () => { - it("catches errors during checkForUpdates", async () => { - await initializeService(service); - - mockUpdater.check.mockImplementation(() => { - throw new Error("Network error"); - }); - - // Should not throw - expect(() => service.checkForUpdates()).not.toThrow(); - }); - - it("handles setFeedURL failure gracefully", async () => { - mockUpdater.setFeedUrl.mockImplementation(() => { - throw new Error("Invalid URL"); - }); - - // Should not throw - expect(() => { - const newService = new UpdatesService(); - injectPorts(newService); - newService.init(); - }).not.toThrow(); - }); - }); -}); diff --git a/apps/code/src/main/services/updates/service.ts b/apps/code/src/main/services/updates/service.ts deleted file mode 100644 index 76d1c4c504..0000000000 --- a/apps/code/src/main/services/updates/service.ts +++ /dev/null @@ -1,470 +0,0 @@ -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 { 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 CheckForUpdatesOutput, - type InstallUpdateOutput, - UpdatesEvent, - type UpdatesEvents, - type UpdatesStatusPayload, -} from "./schemas"; - -type CheckSource = "user" | "periodic"; -type UpdateState = - | "idle" - | "checking" - | "downloading" - | "ready" - | "installing" - | "error"; -type TransitionContext = { - source?: CheckSource; - skippedBecauseUpdateStaged?: boolean; - reason?: string; - incomingVersion?: string | null; - error?: string; -}; - -const log = logger.scope("updates"); - -@injectable() -export class UpdatesService extends TypedEventEmitter { - private static readonly SERVER_HOST = "https://update.electronjs.org"; - private static readonly REPO_OWNER = "PostHog"; - private static readonly REPO_NAME = "code"; - 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(MAIN_TOKENS.Updater) - private updater!: IUpdater; - - @inject(MAIN_TOKENS.AppLifecycle) - private appLifecycle!: IAppLifecycle; - - @inject(MAIN_TOKENS.AppMeta) - private appMeta!: IAppMeta; - - @inject(MAIN_TOKENS.MainWindow) - private mainWindow!: IMainWindow; - - private state: UpdateState = "idle"; - private pendingNotification = false; - private checkTimeoutId: ReturnType | null = null; - private checkIntervalId: ReturnType | null = null; - private downloadedVersion: string | null = null; - private notifiedVersion: string | null = null; - private lastError: string | null = null; - private initialized = false; - private unsubscribes: Array<() => void> = []; - - get hasUpdateReady(): boolean { - return this.isUpdateStaged(); - } - - private isUpdateStaged(): boolean { - return this.state === "ready" || this.state === "installing"; - } - - get isEnabled(): boolean { - return ( - this.updater.isSupported() && - !process.env[UpdatesService.DISABLE_ENV_FLAG] && - UpdatesService.SUPPORTED_PLATFORMS.includes(process.platform) - ); - } - - 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}`; - } - - @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"); - } - return; - } - - this.unsubscribes.push( - this.mainWindow.onFocus(() => this.flushPendingNotification()), - ); - this.appLifecycle.whenReady().then(() => this.setupAutoUpdater()); - } - - triggerMenuCheck(): void { - this.emit(UpdatesEvent.CheckFromMenu, true); - } - - getStatus(): UpdatesStatusPayload { - if (this.state === "checking") { - return { checking: true }; - } - - if (this.state === "downloading") { - return { checking: true, downloading: true }; - } - - if (this.isUpdateStaged()) { - return this.stagedStatusPayload(); - } - - if (this.state === "error") { - return { - checking: false, - error: this.lastError ?? "Update check failed. Please try again.", - }; - } - - return { checking: false }; - } - - checkForUpdates(source: CheckSource = "user"): CheckForUpdatesOutput { - if (!this.isEnabled) { - const reason = isDevBuild() - ? "Updates only available in packaged builds" - : "Auto updates only supported on macOS and Windows"; - return { success: false, errorMessage: reason, errorCode: "disabled" }; - } - - if (this.isUpdateStaged()) { - this.logStateTransition(this.state, { - source, - skippedBecauseUpdateStaged: true, - reason: "check skipped because update is already staged", - }); - - if (source === "user") { - this.pendingNotification = true; - this.flushPendingNotification(); - this.emitStatus(this.stagedStatusPayload()); - } - - return { success: true }; - } - - if (this.state === "checking" || this.state === "downloading") { - return { - success: false, - errorMessage: "Already checking for updates", - errorCode: "already_checking", - }; - } - - this.transitionTo("checking", { source }); - this.emitStatus({ checking: true }); - this.performCheck(); - - return { success: true }; - } - - async installUpdate(): Promise { - if (this.state === "installing") { - this.logStateTransition("installing", { - skippedBecauseUpdateStaged: true, - reason: "install already in progress", - }); - return { installed: true }; - } - - if (this.state !== "ready") { - log.warn("installUpdate called but no update is ready", { - state: this.state, - }); - return { installed: false }; - } - - log.info("Installing update and restarting...", { - downloadedVersion: this.downloadedVersion, - }); - - try { - this.transitionTo("installing", { reason: "install requested" }); - this.emitStatus(this.stagedStatusPayload()); - this.lifecycleService.setQuittingForUpdate(); - const cleanupResult = await withTimeout( - this.lifecycleService.shutdownWithoutContainer(), - UpdatesService.INSTALL_SHUTDOWN_TIMEOUT_MS, - ); - if (cleanupResult.result === "timeout") { - log.warn("Partial shutdown timed out before update install", { - timeoutMs: UpdatesService.INSTALL_SHUTDOWN_TIMEOUT_MS, - downloadedVersion: this.downloadedVersion, - }); - } - this.updater.quitAndInstall(); - return { installed: true }; - } catch (error) { - log.error("Failed to quit and install update", error); - this.lifecycleService.clearQuittingForUpdate(); - this.transitionTo("ready", { - reason: "install handoff failed", - error: error instanceof Error ? error.message : String(error), - }); - this.emitStatus(this.stagedStatusPayload()); - return { installed: false }; - } - } - - private setupAutoUpdater(): void { - if (this.initialized) { - log.warn("setupAutoUpdater called multiple times, ignoring"); - return; - } - - this.initialized = true; - const feedUrl = this.feedUrl; - log.info("Setting up auto updater", { - feedUrl, - currentVersion: this.appMeta.version, - platform: process.platform, - arch: process.arch, - }); - - try { - this.updater.setFeedUrl(feedUrl); - } catch (error) { - 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.onUpdateAvailable(() => this.handleUpdateAvailable()), - this.updater.onNoUpdate(() => this.handleNoUpdate()), - this.updater.onUpdateDownloaded((releaseName) => - this.handleUpdateDownloaded(releaseName), - ), - ); - - this.checkForUpdates("periodic"); - - this.checkIntervalId = setInterval( - () => this.checkForUpdates("periodic"), - UpdatesService.CHECK_INTERVAL_MS, - ); - } - - private stagedStatusPayload(): UpdatesStatusPayload { - return { - checking: false, - updateReady: true, - installing: this.state === "installing", - version: this.downloadedVersion ?? undefined, - }; - } - - private handleError(error: Error): void { - this.clearCheckTimeout(); - log.error("Auto update error", { - message: error.message, - stack: error.stack, - feedUrl: this.feedUrl, - state: this.state, - }); - - if (this.isUpdateStaged()) { - this.logStateTransition(this.state, { - skippedBecauseUpdateStaged: true, - reason: "updater error ignored because update is staged", - error: error.message, - }); - return; - } - - if (this.state === "checking" || this.state === "downloading") { - this.lastError = error.message; - this.transitionTo("error", { error: error.message }); - this.emitStatus({ - checking: false, - error: error.message, - }); - } - } - - private handleUpdateAvailable(): void { - if (this.isUpdateStaged()) { - log.info( - "Ignoring update-available because an update is already staged", - { - downloadedVersion: this.downloadedVersion, - }, - ); - return; - } - - this.clearCheckTimeout(); - this.transitionTo("downloading", { reason: "update available" }); - log.info("Update available, downloading..."); - this.emitStatus({ checking: true, downloading: true }); - } - - private handleNoUpdate(): void { - this.clearCheckTimeout(); - - if (this.isUpdateStaged()) { - log.info("Ignoring update-not-available because update is staged", { - downloadedVersion: this.downloadedVersion, - }); - return; - } - - 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({ - checking: false, - upToDate: true, - version: this.appMeta.version, - }); - } - } - - private handleUpdateDownloaded(releaseName?: string): void { - this.clearCheckTimeout(); - - if (this.isUpdateStaged()) { - log.info("Ignoring duplicate update-downloaded event", { - existingVersion: this.downloadedVersion, - incomingVersion: releaseName, - }); - return; - } - - this.downloadedVersion = releaseName ?? null; - this.transitionTo("ready", { - reason: "update downloaded", - incomingVersion: releaseName ?? null, - }); - this.clearCheckInterval(); - this.emitStatus(this.stagedStatusPayload()); - - log.info("Update downloaded, awaiting user confirmation", { - currentVersion: this.appMeta.version, - downloadedVersion: this.downloadedVersion, - }); - - if (this.notifiedVersion !== this.downloadedVersion) { - this.pendingNotification = true; - this.flushPendingNotification(); - } else { - log.info("Skipping notification - same version already notified", { - version: this.downloadedVersion, - }); - } - } - - private flushPendingNotification(): void { - if (this.state === "ready" && this.pendingNotification) { - log.info("Notifying user that update is ready", { - downloadedVersion: this.downloadedVersion, - }); - this.emit(UpdatesEvent.Ready, { version: this.downloadedVersion }); - this.pendingNotification = false; - this.notifiedVersion = this.downloadedVersion; - } - } - - private emitStatus(status: UpdatesStatusPayload): void { - this.emit(UpdatesEvent.Status, status); - } - - private performCheck(): void { - this.clearCheckTimeout(); - - this.checkTimeoutId = setTimeout(() => { - 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.lastError = message; - this.transitionTo("error", { error: message }); - this.emitStatus({ checking: false, error: message }); - } - }, UpdatesService.CHECK_TIMEOUT_MS); - - try { - this.updater.check(); - } catch (error) { - this.clearCheckTimeout(); - 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), - }); - this.emitStatus({ - checking: false, - error: "Failed to check for updates. Please try again.", - }); - } - } - - private transitionTo( - state: UpdateState, - context: TransitionContext = {}, - ): void { - this.logStateTransition(state, context); - this.state = state; - if (state !== "error") { - this.lastError = null; - } - } - - private logStateTransition( - toState: UpdateState, - context: TransitionContext = {}, - ): void { - log.info("Update state transition", { - source: context.source, - fromState: this.state, - toState, - downloadedVersion: this.downloadedVersion, - skippedBecauseUpdateStaged: context.skippedBecauseUpdateStaged ?? false, - reason: context.reason, - incomingVersion: context.incomingVersion, - error: context.error, - }); - } - - private clearCheckTimeout(): void { - if (this.checkTimeoutId) { - clearTimeout(this.checkTimeoutId); - this.checkTimeoutId = null; - } - } - - private clearCheckInterval(): void { - if (this.checkIntervalId) { - clearInterval(this.checkIntervalId); - this.checkIntervalId = null; - } - } - - @preDestroy() - shutdown(): void { - this.clearCheckTimeout(); - this.clearCheckInterval(); - for (const unsub of this.unsubscribes) unsub(); - this.unsubscribes = []; - } -} diff --git a/apps/code/src/main/services/usage-monitor/schemas.ts b/apps/code/src/main/services/usage-monitor/schemas.ts deleted file mode 100644 index dbfbde1631..0000000000 --- a/apps/code/src/main/services/usage-monitor/schemas.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { UsageOutput } from "@main/services/llm-gateway/schemas"; -import { usageOutput } from "@main/services/llm-gateway/schemas"; -import { z } from "zod"; - -export const USAGE_THRESHOLDS = [50, 75, 90, 100] as const; -export type UsageThreshold = (typeof USAGE_THRESHOLDS)[number]; - -export const thresholdCrossedEvent = z.object({ - bucket: z.enum(["burst", "sustained"]), - threshold: z.union([ - z.literal(50), - z.literal(75), - z.literal(90), - z.literal(100), - ]), - usedPercent: z.number(), - resetAt: z.string().datetime(), - isPro: z.boolean(), - userIsActive: z.boolean(), -}); - -export type ThresholdCrossedEvent = z.infer; - -export const usageSnapshotOutput = usageOutput.nullable(); -export type UsageSnapshot = UsageOutput | null; - -export const UsageMonitorEvent = { - ThresholdCrossed: "threshold-crossed", - UsageUpdated: "usage-updated", -} as const; - -export interface UsageMonitorEvents { - [UsageMonitorEvent.ThresholdCrossed]: ThresholdCrossedEvent; - [UsageMonitorEvent.UsageUpdated]: UsageOutput; -} diff --git a/apps/code/src/main/services/usage-monitor/service.test.ts b/apps/code/src/main/services/usage-monitor/service.test.ts deleted file mode 100644 index 6132a8851a..0000000000 --- a/apps/code/src/main/services/usage-monitor/service.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -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; -} - -function makeUsage(overrides?: { - burstPercent?: number; - sustainedPercent?: number; - billingPeriodEnd?: string | null; - burstResetAt?: string; - sustainedResetAt?: string; - isPro?: boolean; -}): UsageOutput { - return { - product: "posthog_code", - user_id: 42, - is_rate_limited: false, - is_pro: overrides?.isPro ?? false, - billing_period_end: - overrides?.billingPeriodEnd === undefined - ? null - : overrides.billingPeriodEnd, - burst: { - used_percent: overrides?.burstPercent ?? 0, - reset_at: overrides?.burstResetAt ?? "2026-05-25T16:00:00.000Z", - exceeded: false, - }, - sustained: { - used_percent: overrides?.sustainedPercent ?? 0, - reset_at: overrides?.sustainedResetAt ?? "2026-06-01T00:00:00.000Z", - exceeded: false, - }, - }; -} - -function mockGateway(usage: UsageOutput | null): LlmGatewayService { - return { - fetchUsage: vi.fn().mockResolvedValue(usage), - } as unknown as LlmGatewayService; -} - -describe("UsageMonitorService", () => { - let service: UsageMonitorService; - let persisted: Record; - - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-25T12:00:00.000Z")); - persisted = {}; - mockStoreGet.mockImplementation((_key: string, fallback: unknown) => ({ - ...persisted, - ...(fallback as Record), - })); - mockStoreSet.mockImplementation( - (_key: string, value: Record) => { - persisted = { ...value }; - }, - ); - }); - - afterEach(() => { - service?.stop(); - vi.useRealTimers(); - }); - - 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.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); - - await service.fetchOnce(); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ - bucket: "burst", - threshold: 75, - usedPercent: 78, - }); - - await service.fetchOnce(); - expect(events).toHaveLength(1); - }); - - 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.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); - - await service.fetchOnce(); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ threshold: 90 }); - }); - - 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.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); - await service.fetchOnce(); - expect(events).toHaveLength(1); - service.stop(); - - service = new UsageMonitorService(gateway, makeAgentService()); - service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); - await service.fetchOnce(); - expect(events).toHaveLength(1); - }); - - it("tracks burst and sustained as independent buckets", async () => { - const events: unknown[] = []; - const gateway = mockGateway( - makeUsage({ - burstPercent: 55, - sustainedPercent: 80, - billingPeriodEnd: "2026-06-01T00:00:00.000Z", - }), - ); - service = new UsageMonitorService(gateway, makeAgentService()); - service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); - - await service.fetchOnce(); - expect(events).toHaveLength(2); - expect(events.map((e) => (e as { bucket: string }).bucket).sort()).toEqual([ - "burst", - "sustained", - ]); - }); - - it("marks events with isPro from the gateway", async () => { - const events: { isPro: boolean }[] = []; - const gateway = mockGateway( - makeUsage({ - sustainedPercent: 60, - isPro: true, - billingPeriodEnd: "2026-06-01T00:00:00.000Z", - }), - ); - service = new UsageMonitorService(gateway, makeAgentService()); - service.on(UsageMonitorEvent.ThresholdCrossed, (e) => - events.push(e as { isPro: boolean }), - ); - - await service.fetchOnce(); - expect(events[0]?.isPro).toBe(true); - }); - - it("marks events with userIsActive from the agent service", async () => { - const events: { userIsActive: boolean }[] = []; - const gateway = mockGateway(makeUsage({ burstPercent: 78 })); - service = new UsageMonitorService( - gateway, - makeAgentService({ hasActiveSessions: true }), - ); - service.on(UsageMonitorEvent.ThresholdCrossed, (e) => - events.push(e as { userIsActive: boolean }), - ); - - await service.fetchOnce(); - expect(events[0]?.userIsActive).toBe(true); - }); - - it("silently skips polls when the gateway throws", async () => { - const events: unknown[] = []; - const gateway = { - fetchUsage: vi.fn().mockRejectedValue(new Error("not authenticated")), - } as unknown as LlmGatewayService; - service = new UsageMonitorService(gateway, makeAgentService()); - service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); - - await expect(service.fetchOnce()).resolves.toBeNull(); - expect(events).toHaveLength(0); - }); - - it("emits UsageUpdated only when the snapshot actually changes", async () => { - const updates: UsageOutput[] = []; - const gateway = { - fetchUsage: vi - .fn() - .mockResolvedValueOnce(makeUsage({ burstPercent: 20 })) - .mockResolvedValueOnce(makeUsage({ burstPercent: 20 })) - .mockResolvedValueOnce(makeUsage({ burstPercent: 35 })), - } as unknown as LlmGatewayService; - service = new UsageMonitorService(gateway, makeAgentService()); - service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); - - expect(service.getLatest()).toBeNull(); - await service.fetchOnce(); - expect(updates).toHaveLength(1); - expect(service.getLatest()?.burst.used_percent).toBe(20); - - await service.fetchOnce(); - expect(updates).toHaveLength(1); - - await service.fetchOnce(); - expect(updates).toHaveLength(2); - expect(updates[1].burst.used_percent).toBe(35); - }); - - it("does not emit UsageUpdated when the gateway throws", async () => { - const updates: UsageOutput[] = []; - const gateway = { - fetchUsage: vi.fn().mockRejectedValue(new Error("offline")), - } as unknown as LlmGatewayService; - service = new UsageMonitorService(gateway, makeAgentService()); - service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); - - await service.fetchOnce(); - expect(updates).toHaveLength(0); - expect(service.getLatest()).toBeNull(); - }); - - it("refreshNow triggers a fresh fetch and returns the snapshot", async () => { - const gateway = mockGateway(makeUsage({ burstPercent: 42 })); - service = new UsageMonitorService(gateway, makeAgentService()); - - const result = await service.refreshNow(); - expect(result?.burst.used_percent).toBe(42); - expect(service.getLatest()?.burst.used_percent).toBe(42); - }); - - 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); - 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); - await vi.advanceTimersByTimeAsync(0); - expect(gateway.fetchUsage).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(5_000); - expect(gateway.fetchUsage).toHaveBeenCalledTimes(2); - - await vi.advanceTimersByTimeAsync(60_000); - agent.emit(AgentServiceEvent.LlmActivity, undefined); - 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); - service.init(); - await vi.advanceTimersByTimeAsync(0); - const baseline = (gateway.fetchUsage as ReturnType).mock.calls - .length; - - service.stop(); - agent.emit(AgentServiceEvent.LlmActivity, undefined); - await vi.advanceTimersByTimeAsync(10_000); - expect(gateway.fetchUsage).toHaveBeenCalledTimes(baseline); - }); -}); diff --git a/apps/code/src/main/services/usage-monitor/service.ts b/apps/code/src/main/services/usage-monitor/service.ts deleted file mode 100644 index 41f02bc121..0000000000 --- a/apps/code/src/main/services/usage-monitor/service.ts +++ /dev/null @@ -1,262 +0,0 @@ -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_THRESHOLDS, - UsageMonitorEvent, - type UsageMonitorEvents, - type UsageThreshold, -} from "./schemas"; -import { usageMonitorStore } from "./store"; - -const log = logger.scope("usage-monitor"); - -const COALESCE_INTERVAL_MS = 5_000; -// Catches reset-window rollovers and out-of-band plan changes while the app -// sits idle and no LlmActivity events fire. -const BACKSTOP_INTERVAL_MS = 30 * 60_000; - -type BucketName = "burst" | "sustained"; - -@injectable() -export class UsageMonitorService extends TypedEventEmitter { - private backstopTimeoutId: ReturnType | null = null; - private coalesceTimeoutId: ReturnType | null = null; - private lastFetchStartedAt = 0; - private isFetching = false; - private thresholdsSeen: Record; - private latestUsage: UsageOutput | null = null; - - private readonly onLlmActivity = (): void => this.requestRefresh(); - - constructor( - @inject(MAIN_TOKENS.LlmGatewayService) - private readonly llmGateway: LlmGatewayService, - @inject(MAIN_TOKENS.AgentService) - private readonly agentService: AgentService, - ) { - super(); - this.thresholdsSeen = { ...usageMonitorStore.get("thresholdsSeen", {}) }; - } - - getLatest(): UsageOutput | null { - return this.latestUsage; - } - - async refreshNow(): Promise { - return this.fetchOnce(); - } - - // Coalesces N parallel agents finishing turns into at most two fetches - // (leading + trailing) per `COALESCE_INTERVAL_MS` window. - requestRefresh(): void { - if (this.coalesceTimeoutId) return; - const now = Date.now(); - const delay = Math.max( - 0, - this.lastFetchStartedAt + COALESCE_INTERVAL_MS - now, - ); - this.coalesceTimeoutId = setTimeout(() => { - this.coalesceTimeoutId = null; - void this.fetchOnce(); - }, delay); - } - - @postConstruct() - init(): void { - this.pruneStaleEntries(); - this.agentService.on(AgentServiceEvent.LlmActivity, this.onLlmActivity); - void this.fetchOnce(); - this.scheduleBackstop(); - } - - @preDestroy() - stop(): void { - this.agentService.off(AgentServiceEvent.LlmActivity, this.onLlmActivity); - if (this.backstopTimeoutId) { - clearTimeout(this.backstopTimeoutId); - this.backstopTimeoutId = null; - } - if (this.coalesceTimeoutId) { - clearTimeout(this.coalesceTimeoutId); - this.coalesceTimeoutId = null; - } - } - - async fetchOnce(): Promise { - if (this.isFetching) return null; - this.isFetching = true; - this.lastFetchStartedAt = Date.now(); - if (this.coalesceTimeoutId) { - clearTimeout(this.coalesceTimeoutId); - this.coalesceTimeoutId = null; - } - try { - let usage: UsageOutput | null = null; - try { - usage = await this.llmGateway.fetchUsage(); - } catch (err) { - log.debug("Usage fetch skipped", { - error: err instanceof Error ? err.message : String(err), - }); - } - if (usage) { - const changed = !isSameUsage(this.latestUsage, usage); - this.latestUsage = usage; - if (changed) { - this.emit(UsageMonitorEvent.UsageUpdated, usage); - } - this.processUsage(usage); - } - return usage; - } finally { - this.isFetching = false; - } - } - - private scheduleBackstop(): void { - this.backstopTimeoutId = setTimeout(async () => { - this.backstopTimeoutId = null; - await this.fetchOnce(); - this.scheduleBackstop(); - }, BACKSTOP_INTERVAL_MS); - } - - private processUsage(usage: UsageOutput): void { - const userId = usage.user_id.toString(); - const product = usage.product; - this.maybeEmit(usage, "burst", usage.burst, userId, product, usage.is_pro); - this.maybeEmit( - usage, - "sustained", - usage.sustained, - userId, - product, - usage.is_pro, - ); - } - - private maybeEmit( - usage: UsageOutput, - bucket: BucketName, - status: UsageBucket, - userId: string, - product: string, - isPro: boolean, - ): void { - const anchor = this.anchorFor(bucket, status, usage); - if (!anchor) return; - - const threshold = highestThresholdCrossed(status.used_percent); - if (threshold === null) return; - - const key = makeKey(userId, product, bucket, anchor, threshold); - if (this.thresholdsSeen[key]) return; - - this.thresholdsSeen[key] = anchor; - usageMonitorStore.set("thresholdsSeen", this.thresholdsSeen); - - log.info("Usage threshold crossed", { - bucket, - threshold, - usedPercent: status.used_percent, - }); - - this.emit(UsageMonitorEvent.ThresholdCrossed, { - bucket, - threshold, - usedPercent: status.used_percent, - resetAt: status.reset_at, - isPro, - userIsActive: this.agentService.hasActiveSessions(), - }); - } - - // Rounded anchor so transient TTL jitter doesn't make every poll look like - // a fresh window. - private anchorFor( - bucket: BucketName, - status: UsageBucket, - usage: UsageOutput, - ): string | null { - if (bucket === "sustained") { - return usage.billing_period_end ?? sustainedFreeAnchor(status) ?? null; - } - return burstAnchor(status); - } - - private pruneStaleEntries(): void { - const now = Date.now(); - let dirty = false; - for (const [key, anchor] of Object.entries(this.thresholdsSeen)) { - const parsed = Date.parse(anchor); - if (Number.isNaN(parsed) || parsed < now) { - delete this.thresholdsSeen[key]; - dirty = true; - } - } - if (dirty) { - usageMonitorStore.set("thresholdsSeen", this.thresholdsSeen); - } - } -} - -function highestThresholdCrossed(usedPercent: number): UsageThreshold | null { - for (let i = USAGE_THRESHOLDS.length - 1; i >= 0; i--) { - const t = USAGE_THRESHOLDS[i]; - if (usedPercent >= t) return t; - } - return null; -} - -function burstAnchor(status: UsageBucket): string | null { - const resetMs = resetMillis(status); - if (resetMs === null) return null; - // Round to the nearest hour so 30s polling doesn't churn the anchor. - const rounded = Math.round(resetMs / 3_600_000) * 3_600_000; - return new Date(rounded).toISOString(); -} - -function sustainedFreeAnchor(status: UsageBucket): string | null { - const resetMs = resetMillis(status); - if (resetMs === null) return null; - return new Date(resetMs).toISOString().slice(0, 10); -} - -function resetMillis(status: UsageBucket): number | null { - const parsed = Date.parse(status.reset_at); - return Number.isNaN(parsed) ? null : parsed; -} - -function makeKey( - userId: string, - product: string, - bucket: BucketName, - anchor: string, - threshold: UsageThreshold, -): string { - return `${userId}:${product}:${bucket}:${anchor}:${threshold}`; -} - -function isSameUsage(a: UsageOutput | null, b: UsageOutput): boolean { - if (!a) return false; - return ( - a.is_rate_limited === b.is_rate_limited && - a.billing_period_end === b.billing_period_end && - isSameBucket(a.burst, b.burst) && - isSameBucket(a.sustained, b.sustained) - ); -} - -function isSameBucket(a: UsageBucket, b: UsageBucket): boolean { - return ( - a.used_percent === b.used_percent && - a.reset_at === b.reset_at && - a.exceeded === b.exceeded - ); -} diff --git a/apps/code/src/main/services/watcher-registry/service.ts b/apps/code/src/main/services/watcher-registry/service.ts deleted file mode 100644 index 9ac03ae930..0000000000 --- a/apps/code/src/main/services/watcher-registry/service.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type * as watcher from "@parcel/watcher"; -import { injectable } from "inversify"; -import { logger } from "../../utils/logger"; - -const log = logger.scope("watcher-registry"); - -const UNSUBSCRIBE_TIMEOUT_MS = 2000; - -@injectable() -export class WatcherRegistryService { - private subscriptions = new Map(); - private _isShutdown = false; - - get isShutdown(): boolean { - return this._isShutdown; - } - - register(id: string, subscription: watcher.AsyncSubscription): void { - if (this._isShutdown) { - log.warn(`Attempted to register watcher after shutdown: ${id}`); - subscription.unsubscribe().catch((err) => { - log.warn(`Failed to unsubscribe rejected watcher ${id}:`, err); - }); - return; - } - - 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.subscriptions.set(id, subscription); - } - - async unregister(id: string): Promise { - const subscription = this.subscriptions.get(id); - if (!subscription) return; - - this.subscriptions.delete(id); - try { - await subscription.unsubscribe(); - log.debug(`Unregistered watcher: ${id}`); - } catch (err) { - log.warn(`Failed to unsubscribe watcher ${id}:`, err); - } - } - - async shutdownAll(): Promise { - if (this._isShutdown) { - log.warn("shutdownAll called but already shutdown"); - return; - } - - this._isShutdown = true; - const count = this.subscriptions.size; - - if (count === 0) { - log.info("No watchers to shutdown"); - return; - } - - log.info(`Shutting down ${count} watchers`); - - const entries = Array.from(this.subscriptions.entries()); - this.subscriptions.clear(); - - const results = await Promise.allSettled( - entries.map(([id, sub]) => this.unsubscribeWithTimeout(id, sub)), - ); - - const failures = results.filter((r) => r.status === "rejected").length; - const timeouts = results.filter( - (r) => r.status === "fulfilled" && r.value === "timeout", - ).length; - - if (failures > 0 || timeouts > 0) { - log.warn( - `Watcher shutdown: ${count - failures - timeouts} clean, ${timeouts} timed out, ${failures} failed`, - ); - } else { - log.info(`All ${count} watchers shutdown successfully`); - } - } - - private async unsubscribeWithTimeout( - id: string, - sub: watcher.AsyncSubscription, - ): Promise<"ok" | "timeout"> { - const timeoutPromise = new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), UNSUBSCRIBE_TIMEOUT_MS), - ); - - const unsubPromise = sub - .unsubscribe() - .then(() => "ok" as const) - .catch((err) => { - log.warn(`Failed to unsubscribe watcher ${id}:`, err); - return "ok" as const; - }); - - const result = await Promise.race([unsubPromise, timeoutPromise]); - - if (result === "timeout") { - log.warn( - `Watcher ${id} unsubscribe timed out after ${UNSUBSCRIBE_TIMEOUT_MS}ms`, - ); - } else { - log.debug(`Shutdown watcher: ${id}`); - } - - return result; - } -} diff --git a/apps/code/src/main/services/workspace-server/service.ts b/apps/code/src/main/services/workspace-server/service.ts index 118feb6def..f2e1c627fd 100644 --- a/apps/code/src/main/services/workspace-server/service.ts +++ b/apps/code/src/main/services/workspace-server/service.ts @@ -5,7 +5,7 @@ import path from "node:path"; 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"; +import { TypedEventEmitter } from "@posthog/shared"; const HEALTH_POLL_INTERVAL_MS = 100; const HEALTH_POLL_TIMEOUT_MS = 5_000; diff --git a/apps/code/src/main/services/workspace/schemas.ts b/apps/code/src/main/services/workspace/schemas.ts deleted file mode 100644 index 2569bab385..0000000000 --- a/apps/code/src/main/services/workspace/schemas.ts +++ /dev/null @@ -1,301 +0,0 @@ -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(), -}); - -// Input schemas -export const createWorkspaceInput = z - .object({ - taskId: z.string(), - mainRepoPath: z.string(), - folderId: z.string(), - folderPath: z.string(), - mode: workspaceModeSchema, - branch: z.string().optional(), - useExistingBranch: z.boolean().optional(), - }) - .refine( - (data) => - data.mode === "cloud" || - (data.mainRepoPath.length >= 2 && data.folderPath.length >= 2), - { - message: "Repository and folder paths must be valid for non-cloud mode", - }, - ); - -export const reconcileCloudWorkspacesInput = z.object({ - taskIds: z.array(z.string()), -}); - -export const reconcileCloudWorkspacesOutput = z.object({ - created: z.array(z.string()), -}); - -export const deleteWorkspaceInput = z.object({ - taskId: z.string(), - mainRepoPath: z.string(), -}); - -export const verifyWorkspaceInput = z.object({ - taskId: z.string(), -}); - -export const getWorkspaceInfoInput = z.object({ - taskId: z.string(), -}); - -// Output schemas -export const createWorkspaceOutput = workspaceInfoSchema; -export const verifyWorkspaceOutput = z.object({ - exists: z.boolean(), - missingPath: z.string().optional(), -}); -export const getWorkspaceInfoOutput = workspaceInfoSchema.nullable(); -export const getAllWorkspacesOutput = z.record(z.string(), workspaceSchema); - -export const workspaceErrorPayload = z.object({ - taskId: z.string(), - message: z.string(), -}); - -export const workspaceWarningPayload = z.object({ - taskId: z.string(), - title: z.string(), - message: z.string(), -}); - -export const workspacePromotedPayload = z.object({ - taskId: z.string(), - worktree: worktreeInfoSchema, - fromBranch: z.string(), -}); - -export const branchChangedPayload = z.object({ - taskId: z.string(), - branchName: z.string().nullable(), -}); - -export const linkedBranchChangedPayload = z.object({ - taskId: z.string(), - branchName: z.string().nullable(), -}); - -export const linkBranchInput = z.object({ - taskId: z.string(), - branchName: z.string(), -}); - -export const unlinkBranchInput = z.object({ - taskId: z.string(), -}); - -export const localBackgroundedPayload = z.object({ - mainRepoPath: z.string(), - localWorktreePath: z.string(), - branch: z.string(), -}); - -export const localForegroundedPayload = z.object({ - mainRepoPath: z.string(), -}); - -// Input/output schemas for local workspace backgrounding -export const isLocalBackgroundedInput = z.object({ - mainRepoPath: z.string(), -}); - -export const isLocalBackgroundedOutput = z.boolean(); - -export const getLocalWorktreePathInput = z.object({ - mainRepoPath: z.string(), -}); - -export const getLocalWorktreePathOutput = z.string(); - -export const backgroundLocalWorkspaceInput = z.object({ - mainRepoPath: z.string(), - branch: z.string(), -}); - -export const backgroundLocalWorkspaceOutput = z.string().nullable(); - -export const foregroundLocalWorkspaceInput = z.object({ - mainRepoPath: z.string(), -}); - -export const foregroundLocalWorkspaceOutput = z.boolean(); - -export const getLocalTasksInput = z.object({ - mainRepoPath: z.string(), -}); - -export const localTaskSchema = z.object({ - taskId: z.string(), -}); - -export const getLocalTasksOutput = z.array(localTaskSchema); - -export const getWorktreeTasksInput = z.object({ - worktreePath: z.string(), -}); - -export const getWorktreeTasksOutput = z.array(localTaskSchema); - -export const listGitWorktreesInput = z.object({ - mainRepoPath: z.string(), -}); - -export const getWorktreeFileUsageInput = z.object({ - mainRepoPath: z.string(), -}); - -export const getWorktreeFileUsageOutput = z.object({ - usesWorktreeLink: z.boolean(), - usesWorktreeInclude: z.boolean(), -}); - -export const gitWorktreeEntrySchema = z.object({ - worktreePath: z.string(), - head: z.string(), - branch: z.string().nullable(), - taskIds: z.array(z.string()), -}); - -export const listGitWorktreesOutput = z.array(gitWorktreeEntrySchema); - -export const getWorktreeSizeInput = z.object({ - worktreePath: z.string(), -}); - -export const getWorktreeSizeOutput = z.object({ - sizeBytes: z.number(), -}); - -export const deleteWorktreeInput = z.object({ - worktreePath: z.string(), - mainRepoPath: z.string(), -}); - -export const togglePinInput = z.object({ - taskId: z.string(), -}); - -export const togglePinOutput = z.object({ - isPinned: z.boolean(), - pinnedAt: z.string().nullable(), -}); - -export const markViewedInput = z.object({ - taskId: z.string(), -}); - -export const markActivityInput = z.object({ - taskId: z.string(), -}); - -export const getPinnedTaskIdsOutput = z.array(z.string()); - -export const getTaskTimestampsInput = z.object({ - taskId: z.string(), -}); - -export const getTaskTimestampsOutput = z.object({ - pinnedAt: z.string().nullable(), - lastViewedAt: z.string().nullable(), - lastActivityAt: z.string().nullable(), -}); - -export const getAllTaskTimestampsOutput = z.record( - z.string(), - z.object({ - pinnedAt: z.string().nullable(), - lastViewedAt: z.string().nullable(), - lastActivityAt: z.string().nullable(), - }), -); - -// Task PR status -export const taskPrStatusInput = z.object({ - taskId: z.string(), - cloudPrUrl: z.string().nullable(), -}); - -export const sidebarPrStateSchema = z - .enum(["merged", "open", "draft", "closed"]) - .nullable(); - -export const taskPrStatusOutput = z.object({ - prState: sidebarPrStateSchema, - hasDiff: z.boolean(), -}); - -export type TaskPrStatusInput = z.infer; -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 CreateWorkspaceInput = z.infer; -export type ReconcileCloudWorkspacesInput = z.infer< - typeof reconcileCloudWorkspacesInput ->; -export type ReconcileCloudWorkspacesOutput = z.infer< - typeof reconcileCloudWorkspacesOutput ->; -export type DeleteWorkspaceInput = z.infer; -export type VerifyWorkspaceInput = z.infer; -export type GetWorkspaceInfoInput = z.infer; -export type ListGitWorktreesInput = z.infer; -export type GetWorktreeSizeInput = z.infer; -export type DeleteWorktreeInput = z.infer; -export type WorkspaceErrorPayload = z.infer; -export type WorkspaceWarningPayload = z.infer; -export type WorkspacePromotedPayload = z.infer; -export type BranchChangedPayload = z.infer; -export type LinkedBranchChangedPayload = z.infer< - typeof linkedBranchChangedPayload ->; -export type LinkBranchInput = z.infer; -export type UnlinkBranchInput = z.infer; -export type LocalBackgroundedPayload = z.infer; -export type LocalForegroundedPayload = z.infer; -export type IsLocalBackgroundedInput = z.infer; -export type GetLocalWorktreePathInput = z.infer< - typeof getLocalWorktreePathInput ->; diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts deleted file mode 100644 index eada6cf4bf..0000000000 --- a/apps/code/src/main/services/workspace/service.ts +++ /dev/null @@ -1,1235 +0,0 @@ -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 { 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 { 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 type { - BranchChangedPayload, - CreateWorkspaceInput, - LinkedBranchChangedPayload, - ReconcileCloudWorkspacesOutput, - Workspace, - WorkspaceErrorPayload, - WorkspaceInfo, - WorkspacePromotedPayload, - WorkspaceWarningPayload, - WorktreeInfo, -} from "./schemas"; - -const execFileAsync = promisify(execFile); - -type TaskAssociation = - | { taskId: string; folderId: string; mode: "local" } - | { taskId: string; folderId: string | null; mode: "cloud" } - | { - taskId: string; - folderId: string; - mode: "worktree"; - worktree: string; - 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", - Promoted: "promoted", - BranchChanged: "branchChanged", - LinkedBranchChanged: "linkedBranchChanged", -} as const; - -export interface WorkspaceServiceEvents { - [WorkspaceServiceEvent.Error]: WorkspaceErrorPayload; - [WorkspaceServiceEvent.Warning]: WorkspaceWarningPayload; - [WorkspaceServiceEvent.Promoted]: WorkspacePromotedPayload; - [WorkspaceServiceEvent.BranchChanged]: BranchChangedPayload; - [WorkspaceServiceEvent.LinkedBranchChanged]: LinkedBranchChangedPayload; -} - -@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 creatingWorkspaces = new Map>(); - private branchWatcherInitialized = false; - - private findTaskAssociation(taskId: string): TaskAssociation | null { - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (!workspace) return null; - - if (workspace.mode === "cloud") { - return { - taskId, - folderId: workspace.repositoryId, - mode: "cloud", - }; - } - - if (!workspace.repositoryId) return null; - - if (workspace.mode === "worktree") { - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - if (!worktree) return null; - return { - taskId, - folderId: workspace.repositoryId, - mode: "worktree", - worktree: worktree.name, - branchName: null, - }; - } - - return { - taskId, - folderId: workspace.repositoryId, - mode: "local", - }; - } - - private getFolderPath(folderId: string): string | null { - const repo = this.repositoryRepo.findById(folderId); - return repo?.path ?? null; - } - - private getAllTaskAssociations(): TaskAssociation[] { - const workspaces = this.workspaceRepo.findAll(); - const result: TaskAssociation[] = []; - - for (const workspace of workspaces) { - if (workspace.mode === "cloud") { - result.push({ - taskId: workspace.taskId, - folderId: workspace.repositoryId, - mode: "cloud", - }); - continue; - } - - if (!workspace.repositoryId) continue; - - if (workspace.mode === "worktree") { - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - if (!worktree) continue; - result.push({ - taskId: workspace.taskId, - folderId: workspace.repositoryId, - mode: "worktree", - worktree: worktree.name, - branchName: null, - }); - } else { - result.push({ - taskId: workspace.taskId, - folderId: workspace.repositoryId, - mode: "local", - }); - } - } - - return result; - } - - /** - * Initialize branch change watching. Should be called after app is ready. - * Subscribes to GitStateChanged events and checks for branch renames. - */ - initBranchWatcher(): void { - 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), - ); - - focusService.on( - FocusServiceEvent.BranchRenamed, - this.handleFocusBranchRenamed.bind(this), - ); - - this.agentService.on( - AgentServiceEvent.AgentFileActivity, - this.handleAgentFileActivity.bind(this), - ); - } - - private handleFocusBranchRenamed({ - worktreePath, - newBranch, - }: { - mainRepoPath: string; - worktreePath: string; - oldBranch: string; - newBranch: string; - }): void { - const associations = this.getAllTaskAssociations(); - for (const assoc of associations) { - if (assoc.mode !== "worktree") continue; - const folderPath = this.getFolderPath(assoc.folderId); - if (!folderPath) continue; - const derivedPath = deriveWorktreePath(folderPath, assoc.worktree); - if (derivedPath === worktreePath && assoc.branchName !== newBranch) { - this.updateAssociationBranchName(assoc.taskId, newBranch); - this.emit(WorkspaceServiceEvent.BranchChanged, { - taskId: assoc.taskId, - branchName: newBranch, - }); - } - } - } - - private async handleGitStateChanged({ - repoPath, - }: { - repoPath: string; - }): Promise { - const associations = this.getAllTaskAssociations(); - - for (const assoc of associations) { - if (assoc.mode === "cloud" || !assoc.folderId) continue; - - const folderPath = this.getFolderPath(assoc.folderId); - if (!folderPath) continue; - - if (assoc.mode === "worktree") { - const worktreePath = deriveWorktreePath(folderPath, assoc.worktree); - if (worktreePath !== repoPath) continue; - - const currentBranch = await getBranchFromPath(repoPath); - if (currentBranch !== null && currentBranch !== assoc.branchName) { - this.updateAssociationBranchName(assoc.taskId, currentBranch); - this.emit(WorkspaceServiceEvent.BranchChanged, { - taskId: assoc.taskId, - branchName: currentBranch, - }); - } - } else if (assoc.mode === "local") { - if (folderPath !== repoPath) continue; - - const localWorktreePath = - await this.getLocalWorktreePathIfExists(folderPath); - const branchPath = localWorktreePath ?? folderPath; - const currentBranch = await getBranchFromPath(branchPath); - - if (currentBranch === null && localWorktreePath) { - continue; - } - - this.emit(WorkspaceServiceEvent.BranchChanged, { - taskId: assoc.taskId, - branchName: currentBranch, - }); - } - } - } - - private async handleAgentFileActivity({ - taskId, - branchName, - }: { - taskId: string; - branchName: string | null; - }): Promise { - if (!branchName) return; - - const dbRow = this.workspaceRepo.findByTaskId(taskId); - if (!dbRow || dbRow.mode !== "local") return; - if (!dbRow.repositoryId) return; - - const folderPath = this.getFolderPath(dbRow.repositoryId); - if (!folderPath) return; - - try { - 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, - }); - return; - } - - const currentLinked = dbRow.linkedBranch ?? null; - if (currentLinked === branchName) return; - - this.linkBranch(taskId, branchName, "agent"); - } - - private updateAssociationBranchName( - _taskId: string, - _branchName: string, - ): void {} - - public linkBranch( - taskId: string, - branchName: string, - source?: "agent" | "user", - ): void { - this.workspaceRepo.updateLinkedBranch(taskId, branchName); - this.emit(WorkspaceServiceEvent.LinkedBranchChanged, { - taskId, - branchName, - }); - trackAppEvent(ANALYTICS_EVENTS.BRANCH_LINKED, { - task_id: taskId, - branch_name: branchName, - source: source ?? "unknown", - }); - log.info("Linked branch to task", { taskId, branchName, source }); - } - - public unlinkBranch(taskId: string, source?: "agent" | "user"): void { - this.workspaceRepo.updateLinkedBranch(taskId, null); - this.emit(WorkspaceServiceEvent.LinkedBranchChanged, { - taskId, - branchName: null, - }); - trackAppEvent(ANALYTICS_EVENTS.BRANCH_UNLINKED, { - task_id: taskId, - source: source ?? "unknown", - }); - log.info("Unlinked branch from task", { taskId, source }); - } - - private async 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; - } - } - - // Batched cloud-workspace reconcile. The renderer calls this once on boot - // with every cloud taskId it sees that has no local workspace row, instead - // of firing one createWorkspace mutation per task. With 100+ cloud tasks - // the N-call pattern saturates the main thread on the tRPC IPC path; this - // collapses it to one IPC + one batched insert. - async reconcileCloudWorkspaces( - taskIds: string[], - ): Promise { - if (taskIds.length === 0) return { created: [] }; - - const existingTaskIds = new Set( - this.workspaceRepo.findAll().map((w) => w.taskId), - ); - const uniqueRequested = Array.from(new Set(taskIds)); - const toCreate = uniqueRequested.filter((id) => !existingTaskIds.has(id)); - if (toCreate.length === 0) return { created: [] }; - - log.info( - `Reconciling ${toCreate.length} cloud workspaces (requested ${taskIds.length})`, - ); - this.workspaceRepo.createCloudMany(toCreate); - return { created: toCreate }; - } - - async createWorkspace(options: CreateWorkspaceInput): Promise { - // Prevent concurrent workspace creation for the same task - const existingPromise = this.creatingWorkspaces.get(options.taskId); - if (existingPromise) { - log.warn( - `Workspace creation already in progress for task ${options.taskId}, waiting for existing operation`, - ); - return existingPromise; - } - - const promise = this.doCreateWorkspace(options); - this.creatingWorkspaces.set(options.taskId, promise); - - try { - return await promise; - } finally { - this.creatingWorkspaces.delete(options.taskId); - } - } - - private async doCreateWorkspace( - options: CreateWorkspaceInput, - ): Promise { - const { - taskId, - mainRepoPath, - folderPath, - mode, - branch, - useExistingBranch, - } = options; - - const existingWorkspace = await this.getWorkspaceInfo(taskId); - if (existingWorkspace) { - log.info( - `Workspace already exists for task ${taskId}, returning existing workspace`, - ); - return existingWorkspace; - } - - log.info( - `Creating workspace for task ${taskId} in ${mainRepoPath} (mode: ${mode}, useExistingBranch: ${useExistingBranch})`, - ); - - const repository = this.repositoryRepo.findByPath(mainRepoPath); - const repositoryId = repository?.id ?? null; - - if (mode === "cloud") { - this.workspaceRepo.create({ - taskId, - repositoryId, - mode: "cloud", - }); - - return { - taskId, - mode, - worktree: null, - branchName: null, - linkedBranch: null, - }; - } - - if (mode === "local") { - if (branch) { - const currentBranch = await getCurrentBranch(folderPath); - if (currentBranch === branch) { - log.info(`Already on branch ${branch}, skipping checkout`); - } else { - log.info(`Creating/switching to branch ${branch} for task ${taskId}`); - const saga = new CreateOrSwitchBranchSaga(); - const result = await saga.run({ - baseDir: folderPath, - branchName: branch, - }); - if (!result.success) { - const message = `Could not switch to branch "${branch}". Please commit or stash your changes first.`; - 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}`); - } else { - log.info(`Switched to existing branch ${branch}`); - } - } - } - - this.workspaceRepo.create({ - taskId, - repositoryId, - mode: "local", - }); - - const localBranch = await getBranchFromPath(folderPath); - return { - taskId, - mode, - worktree: null, - branchName: localBranch, - linkedBranch: null, - }; - } - - await this.suspensionService.suspendLeastRecentIfOverLimit(); - - const worktreeBasePath = getWorktreeLocation(); - const worktreeManager = new WorktreeManager({ - mainRepoPath, - worktreeBasePath, - }); - let worktree: WorktreeInfo; - - try { - const defaultBranch = await getDefaultBranch(mainRepoPath).catch(() => - getCurrentBranch(mainRepoPath).then((b) => b ?? "main"), - ); - const selectedBranch = branch ?? defaultBranch; - const isTrunkSelected = selectedBranch === defaultBranch; - - const onOutput = (data: string) => { - this.provisioningService.emitOutput(taskId, data); - }; - - if (isTrunkSelected) { - log.info( - `Trunk branch selected (${defaultBranch}), creating detached worktree`, - ); - worktree = await worktreeManager.createWorktree({ - baseBranch: defaultBranch, - onOutput, - fetchBeforeCreate: true, - }); - log.info( - `Created detached worktree from trunk: ${worktree.worktreeName} at ${worktree.worktreePath}`, - ); - } else { - log.info( - `Non-trunk branch selected (${selectedBranch}), attempting checkout`, - ); - try { - worktree = await worktreeManager.createWorktreeForExistingBranch( - selectedBranch, - undefined, - { onOutput }, - ); - log.info( - `Created worktree with branch checkout: ${worktree.worktreeName} at ${worktree.worktreePath} (branch: ${selectedBranch})`, - ); - } catch (checkoutError) { - const errorMessage = - checkoutError instanceof Error - ? checkoutError.message - : String(checkoutError); - if (errorMessage.includes("is already used by worktree")) { - log.info( - `Branch ${selectedBranch} is occupied, falling back to detached worktree`, - ); - worktree = await worktreeManager.createWorktree({ - baseBranch: selectedBranch, - onOutput, - }); - log.info( - `Created detached worktree from occupied branch: ${worktree.worktreeName} at ${worktree.worktreePath}`, - ); - } else { - throw checkoutError; - } - } - } - - // Warn if worktree is empty but main repo has files - const worktreeHasFiles = await hasTrackedFiles(worktree.worktreePath); - if (!worktreeHasFiles) { - const mainHasFiles = await hasAnyFiles(mainRepoPath); - if (mainHasFiles) { - log.warn( - `Worktree ${worktree.worktreeName} is empty but main repo has files`, - ); - this.emitWorkspaceWarning( - taskId, - "Workspace is empty", - "No files are committed yet. Commit your files to see them in workspaces.", - ); - } - } - } catch (error) { - log.error(`Failed to create worktree for task ${taskId}:`, error); - throw new Error(`Failed to create worktree: ${String(error)}`); - } - - const createdWorkspace = this.workspaceRepo.create({ - taskId, - repositoryId, - mode: "worktree", - }); - - this.worktreeRepo.create({ - workspaceId: createdWorkspace.id, - name: worktree.worktreeName, - path: worktree.worktreePath, - }); - - return { - taskId, - mode, - worktree, - branchName: worktree.branchName, - linkedBranch: null, - }; - } - - async deleteWorkspace(taskId: string, mainRepoPath: string): Promise { - log.info(`Deleting workspace for task ${taskId}`); - - const association = this.findTaskAssociation(taskId); - if (!association) { - log.warn(`No workspace found for task ${taskId}`); - return; - } - - if (association.mode === "cloud") { - this.removeTaskAssociation(taskId); - 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.removeTaskAssociation(taskId); - return; - } - - let worktreePath: string | null = null; - - if (association.mode === "worktree") { - worktreePath = deriveWorktreePath(folderPath, association.worktree); - } - - await this.agentService.cancelSessionsByTaskId(taskId); - this.processTracking.killByTaskId(taskId); - - if (association.mode === "worktree" && worktreePath) { - await this.cleanupWorktree( - taskId, - mainRepoPath, - worktreePath, - association.branchName, - ); - - const otherWorkspacesForFolder = this.getAllTaskAssociations().filter( - (a) => - a.folderId === folderId && - a.taskId !== taskId && - a.mode === "worktree", - ); - - if (otherWorkspacesForFolder.length === 0) { - await this.cleanupRepoWorktreeFolder(folderPath); - } - } - - this.removeTaskAssociation(taskId); - - log.info(`Workspace deleted for task ${taskId}`); - } - - private removeTaskAssociation(taskId: string): void { - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (workspace) { - this.worktreeRepo.deleteByWorkspaceId(workspace.id); - } - this.workspaceRepo.deleteByTaskId(taskId); - } - - private async cleanupRepoWorktreeFolder(folderPath: string): Promise { - const worktreeBasePath = 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( - `Skipping cleanup of worktree folder: path matches project folder (${folderPath})`, - ); - return; - } - - if (!fs.existsSync(repoWorktreeFolderPath)) { - return; - } - - const allFolders = this.repositoryRepo.findAll(); - const otherFoldersWithSameName = allFolders.filter( - (f) => f.path !== folderPath && path.basename(f.path) === repoName, - ); - - if (otherFoldersWithSameName.length > 0) { - log.info( - `Skipping cleanup of worktree folder ${repoWorktreeFolderPath}: used by other folders: ${otherFoldersWithSameName.map((f) => f.path).join(", ")}`, - ); - return; - } - - try { - // Safety check 3: Only delete if empty (ignoring .DS_Store) - const files = fs.readdirSync(repoWorktreeFolderPath); - const validFiles = files.filter((f) => f !== ".DS_Store"); - - if (validFiles.length > 0) { - 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}`); - } catch (error) { - log.warn( - `Failed to cleanup worktree folder at ${repoWorktreeFolderPath}:`, - error, - ); - } - } - - async verifyWorkspaceExists( - taskId: string, - ): Promise<{ exists: boolean; missingPath?: string }> { - const association = this.findTaskAssociation(taskId); - if (!association) { - return { exists: false }; - } - - if (association.mode === "cloud") { - return { exists: true }; - } - - const folderPath = this.getFolderPath(association.folderId); - if (!folderPath) { - this.removeTaskAssociation(taskId); - return { exists: false, missingPath: "(folder not found)" }; - } - - if (association.mode === "local") { - const exists = fs.existsSync(folderPath); - if (!exists) { - log.info( - `Folder for task ${taskId} no longer exists, removing association`, - ); - this.removeTaskAssociation(taskId); - return { exists: false, missingPath: folderPath }; - } - return { exists: true }; - } - - if (association.mode === "worktree") { - const worktreePath = deriveWorktreePath(folderPath, association.worktree); - const exists = fs.existsSync(worktreePath); - if (!exists) { - log.info( - `Worktree for task ${taskId} no longer exists, removing association`, - ); - this.removeTaskAssociation(taskId); - return { exists: false, missingPath: worktreePath }; - } - return { exists: true }; - } - - return { exists: false }; - } - - async getWorkspace(taskId: string): Promise { - const assoc = this.findTaskAssociation(taskId); - if (!assoc) return null; - - const dbRow = this.workspaceRepo.findByTaskId(taskId); - const linkedBranch = dbRow?.linkedBranch ?? null; - - if (assoc.mode === "cloud") { - return { - taskId, - folderId: assoc.folderId ?? "", - folderPath: "", - mode: "cloud", - worktreePath: null, - worktreeName: null, - branchName: null, - baseBranch: null, - linkedBranch, - createdAt: new Date().toISOString(), - }; - } - - const folderPath = this.getFolderPath(assoc.folderId); - if (!folderPath) return null; - - let worktreePath: string | null = null; - let worktreeName: string | null = null; - let branchName: string | null = null; - - if (assoc.mode === "worktree") { - worktreeName = assoc.worktree; - worktreePath = deriveWorktreePath(folderPath, worktreeName); - const gitBranch = await getBranchFromPath(worktreePath); - branchName = gitBranch ?? assoc.branchName; - } else if (assoc.mode === "local") { - const localWorktreePath = - await this.getLocalWorktreePathIfExists(folderPath); - const branchPath = localWorktreePath ?? folderPath; - branchName = await getBranchFromPath(branchPath); - } - - return { - taskId, - folderId: assoc.folderId, - folderPath, - mode: assoc.mode, - worktreePath, - worktreeName, - branchName, - baseBranch: null, - linkedBranch, - createdAt: new Date().toISOString(), - }; - } - - async getWorkspaceInfo(taskId: string): Promise { - const association = this.findTaskAssociation(taskId); - if (!association) { - return null; - } - - const dbRow = this.workspaceRepo.findByTaskId(taskId); - - if (association.mode === "cloud") { - return { - taskId, - mode: "cloud", - worktree: null, - branchName: null, - linkedBranch: dbRow?.linkedBranch ?? null, - }; - } - - const folderPath = association.folderId - ? this.getFolderPath(association.folderId) - : null; - let worktreeInfo: WorktreeInfo | null = null; - let branchName: string | null = null; - - if (association.mode === "worktree") { - if (folderPath) { - const worktreePath = deriveWorktreePath( - folderPath, - association.worktree, - ); - const gitBranch = await getBranchFromPath(worktreePath); - branchName = gitBranch ?? association.branchName; - worktreeInfo = { - worktreePath, - worktreeName: association.worktree, - branchName, - baseBranch: "main", - createdAt: new Date().toISOString(), - }; - } - } else if (association.mode === "local" && folderPath) { - branchName = await getBranchFromPath(folderPath); - } - - return { - taskId, - mode: association.mode, - worktree: worktreeInfo, - branchName, - linkedBranch: dbRow?.linkedBranch ?? null, - }; - } - - async getAllWorkspaces(): Promise> { - const associations = this.getAllTaskAssociations(); - const dbRows = this.workspaceRepo.findAll(); - const linkedBranchByTaskId = new Map( - dbRows.map((row) => [row.taskId, row.linkedBranch ?? null]), - ); - const workspaces: Record = {}; - - for (const assoc of associations) { - if (assoc.mode === "cloud") { - workspaces[assoc.taskId] = { - taskId: assoc.taskId, - folderId: assoc.folderId ?? "", - folderPath: "", - mode: "cloud", - worktreePath: null, - worktreeName: null, - branchName: null, - baseBranch: null, - linkedBranch: linkedBranchByTaskId.get(assoc.taskId) ?? null, - createdAt: new Date().toISOString(), - }; - continue; - } - - const folderPath = this.getFolderPath(assoc.folderId); - if (!folderPath) continue; - - let worktreePath: string | null = null; - let worktreeName: string | null = null; - - if (assoc.mode === "worktree") { - worktreeName = assoc.worktree; - worktreePath = deriveWorktreePath(folderPath, worktreeName); - } - - let branchName: string | null = null; - if (assoc.mode === "worktree" && worktreePath) { - const gitBranch = await getBranchFromPath(worktreePath); - branchName = gitBranch ?? assoc.branchName; - } else if (assoc.mode === "local") { - const localWorktreePath = - await this.getLocalWorktreePathIfExists(folderPath); - const branchPath = localWorktreePath ?? folderPath; - branchName = await getBranchFromPath(branchPath); - } - - workspaces[assoc.taskId] = { - taskId: assoc.taskId, - folderId: assoc.folderId, - folderPath, - mode: assoc.mode, - worktreePath, - worktreeName, - branchName, - baseBranch: null, - linkedBranch: linkedBranchByTaskId.get(assoc.taskId) ?? null, - createdAt: new Date().toISOString(), - }; - } - - return workspaces; - } - - /** - * Promote a local-mode task to worktree mode on an existing branch. - * This is used when focusing on another workspace would disrupt a local-mode task. - * The task gets its own worktree so it can continue working undisturbed. - */ - async promoteToWorktree( - taskId: string, - mainRepoPath: string, - branch: string, - ): Promise { - 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}`); - return null; - } - - if (association.mode !== "local") { - log.warn(`Task ${taskId} is not in local mode, cannot promote`); - return null; - } - - const worktreeBasePath = getWorktreeLocation(); - const worktreeManager = new WorktreeManager({ - mainRepoPath, - worktreeBasePath, - }); - - let worktree: WorktreeInfo; - try { - const currentBranch = await getCurrentBranch(mainRepoPath); - if (currentBranch === branch) { - log.info( - `Main repo is on target branch ${branch}, detaching before creating worktree`, - ); - const detachSaga = new DetachHeadSaga(); - const detachResult = await detachSaga.run({ baseDir: mainRepoPath }); - if (!detachResult.success) { - throw new Error(`Failed to detach HEAD: ${detachResult.error}`); - } - } - - worktree = await worktreeManager.createWorktreeForExistingBranch(branch); - log.info( - `Created worktree for promoted task: ${worktree.worktreeName} at ${worktree.worktreePath}`, - ); - } catch (error) { - log.error( - `Failed to create worktree for promoted task ${taskId}:`, - error, - ); - throw new Error(`Failed to promote task to worktree: ${String(error)}`); - } - - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (workspace) { - this.workspaceRepo.updateMode(taskId, "worktree"); - this.worktreeRepo.create({ - workspaceId: workspace.id, - name: worktree.worktreeName, - path: worktree.worktreePath, - }); - log.info(`Updated task ${taskId} association to worktree mode`); - } - - this.emit(WorkspaceServiceEvent.Promoted, { - taskId, - worktree, - fromBranch: branch, - }); - - return worktree; - } - - getLocalTasksForFolder(folderPath: string): Array<{ taskId: string }> { - const associations = this.getAllTaskAssociations(); - const folder = this.repositoryRepo.findByPath(folderPath); - if (!folder) return []; - - return associations - .filter((a) => a.mode === "local" && a.folderId === folder.id) - .map((a) => ({ taskId: a.taskId })); - } - - getWorktreeTasks(worktreePath: string): Array<{ taskId: string }> { - const associations = this.getAllTaskAssociations(); - const result: Array<{ taskId: string }> = []; - - for (const assoc of associations) { - if (assoc.mode !== "worktree") continue; - const folderPath = this.getFolderPath(assoc.folderId); - if (!folderPath) continue; - const derivedPath = deriveWorktreePath(folderPath, assoc.worktree); - if (derivedPath === worktreePath) { - result.push({ taskId: assoc.taskId }); - } - } - - return result; - } - - async listGitWorktrees(mainRepoPath: string): Promise< - Array<{ - worktreePath: string; - head: string; - branch: string | null; - 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 }; - } - - 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 }; - } - } - - async deleteWorktree( - mainRepoPath: string, - worktreePath: string, - ): Promise { - const worktree = this.worktreeRepo.findByPath(worktreePath); - if (worktree) { - const workspace = this.workspaceRepo.findById(worktree.workspaceId); - if (workspace) { - await this.deleteWorkspace(workspace.taskId, mainRepoPath); - return; - } - } - - const worktreeBasePath = getWorktreeLocation(); - const manager = new WorktreeManager({ mainRepoPath, worktreeBasePath }); - await manager.deleteWorktree(worktreePath); - - if (worktree) { - this.worktreeRepo.deleteByWorkspaceId(worktree.workspaceId); - } - } - - private async cleanupWorktree( - taskId: string, - mainRepoPath: string, - worktreePath: string, - branchName: string | null, - ): Promise { - try { - const fileWatcher = container.get( - MAIN_TOKENS.FileWatcherService, - ); - await fileWatcher.stopWatching(worktreePath); - } catch (error) { - 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); - } catch (error) { - 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}`); - } catch (error) { - log.warn( - `Failed to delete branch ${branchName} for task ${taskId}:`, - error, - ); - } - } - } - - private emitWorkspaceError(taskId: string, message: string): void { - this.emit(WorkspaceServiceEvent.Error, { taskId, message }); - } - - private emitWorkspaceWarning( - taskId: string, - title: string, - message: string, - ): void { - this.emit(WorkspaceServiceEvent.Warning, { taskId, title, message }); - } -} diff --git a/apps/code/src/main/services/workspace/workspaceEnv.ts b/apps/code/src/main/services/workspace/workspaceEnv.ts deleted file mode 100644 index ce925d2cc9..0000000000 --- a/apps/code/src/main/services/workspace/workspaceEnv.ts +++ /dev/null @@ -1,74 +0,0 @@ -import path from "node:path"; -import { getCurrentBranch, getDefaultBranch } from "@posthog/git/queries"; -import type { WorkspaceMode } from "./schemas"; - -export interface WorkspaceEnvContext { - taskId: string; - folderPath: string; - worktreePath: string | null; - worktreeName: string | null; - mode: WorkspaceMode; -} - -const PORT_BASE = 50000; -const PORTS_PER_WORKSPACE = 20; -const MAX_WORKSPACES = 1000; - -function hashTaskId(taskId: string): number { - let hash = 0; - for (let i = 0; i < taskId.length; i++) { - const char = taskId.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; - } - return Math.abs(hash); -} - -function allocateWorkspacePorts(taskId: string): { - start: number; - end: number; - ports: number[]; -} { - const workspaceIndex = hashTaskId(taskId) % MAX_WORKSPACES; - const start = PORT_BASE + workspaceIndex * PORTS_PER_WORKSPACE; - const end = start + PORTS_PER_WORKSPACE - 1; - - const ports: number[] = []; - for (let port = start; port <= end; port++) { - ports.push(port); - } - - return { start, end, ports }; -} - -export async function buildWorkspaceEnv( - context: WorkspaceEnvContext, -): Promise> { - if (context.mode === "cloud") { - return {}; - } - - const workspaceName = - context.worktreeName ?? path.basename(context.folderPath); - const workspacePath = context.worktreePath ?? context.folderPath; - const rootPath = context.folderPath; - - const defaultBranch = await getDefaultBranch(rootPath); - - const workspaceBranch = (await getCurrentBranch(workspacePath)) ?? ""; - - const portAllocation = allocateWorkspacePorts(context.taskId); - - return { - POSTHOG_CODE: "1", - POSTHOG_CODE_WORKSPACE_NAME: workspaceName, - POSTHOG_CODE_WORKSPACE_PATH: workspacePath, - POSTHOG_CODE_ROOT_PATH: rootPath, - POSTHOG_CODE_DEFAULT_BRANCH: defaultBranch, - POSTHOG_CODE_WORKSPACE_BRANCH: workspaceBranch, - POSTHOG_CODE_WORKSPACE_PORTS: portAllocation.ports.join(","), - POSTHOG_CODE_WORKSPACE_PORTS_RANGE: String(PORTS_PER_WORKSPACE), - POSTHOG_CODE_WORKSPACE_PORTS_START: String(portAllocation.start), - POSTHOG_CODE_WORKSPACE_PORTS_END: String(portAllocation.end), - }; -} diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 06bca0027d..14cf7700b0 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 { 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 "./routers/connectivity"; -import { contextMenuRouter } from "./routers/context-menu"; -import { deepLinkRouter } from "./routers/deep-link"; +import { contextMenuRouter } from "@posthog/host-router/routers/context-menu.router"; +import { deepLinkRouter } from "@posthog/host-router/routers/deep-link.router"; import { encryptionRouter } from "./routers/encryption"; -import { enrichmentRouter } from "./routers/enrichment"; +import { enrichmentRouter } from "@posthog/host-router/routers/enrichment.router"; import { environmentRouter } from "./routers/environment"; -import { externalAppsRouter } from "./routers/external-apps"; +import { externalAppsRouter } from "@posthog/host-router/routers/external-apps.router"; 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 { 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 "./routers/handoff"; -import { linearIntegrationRouter } from "./routers/linear-integration.js"; -import { llmGatewayRouter } from "./routers/llm-gateway"; +import { linearIntegrationRouter } from "@posthog/host-router/routers/linear-integration.router"; +import { llmGatewayRouter } from "@posthog/host-router/routers/llm-gateway.router"; 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 { 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 { workspaceServerRouter } from "./routers/workspace-server"; import { router } from "./trpc"; 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 index c2e7063b32..b76b8e7460 100644 --- a/apps/code/src/main/trpc/routers/connectivity.ts +++ b/apps/code/src/main/trpc/routers/connectivity.ts @@ -4,7 +4,7 @@ import { ConnectivityEvent, type ConnectivityEvents, connectivityStatusOutput, -} from "../../services/connectivity/schemas"; +} from "@posthog/workspace-server/services/connectivity/schemas"; import type { ConnectivityService } from "../../services/connectivity/service"; import { publicProcedure, router } from "../trpc"; 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/enrichment.ts b/apps/code/src/main/trpc/routers/enrichment.ts deleted file mode 100644 index a01d0d67f5..0000000000 --- a/apps/code/src/main/trpc/routers/enrichment.ts +++ /dev/null @@ -1,61 +0,0 @@ -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(MAIN_TOKENS.EnrichmentService); - -const enrichFileInput = z.object({ - taskId: z.string(), - filePath: z.string(), - absolutePath: z.string().optional(), - content: z.string(), -}); - -const detectPosthogInstallStateInput = z.object({ - repoPath: z.string(), -}); - -const detectPosthogInstallStateOutput = z.enum([ - "not_installed", - "installed_no_init", - "initialized", -]); - -const findStaleFlagSuggestionsInput = z.object({ - repoPath: z.string(), -}); - -const staleFlagReference = z.object({ - file: z.string(), - line: z.number(), - method: z.string(), -}); - -const findStaleFlagSuggestionsOutput = z.array( - z.object({ - flagKey: z.string(), - references: z.array(staleFlagReference), - referenceCount: z.number(), - }), -); - -export const enrichmentRouter = router({ - enrichFile: publicProcedure - .input(enrichFileInput) - .query(({ input }) => getService().enrichFile(input)), - detectPosthogInstallState: publicProcedure - .input(detectPosthogInstallStateInput) - .output(detectPosthogInstallStateOutput) - .query(({ input }) => - getService().detectPosthogInstallState(input.repoPath), - ), - findStaleFlagSuggestions: publicProcedure - .input(findStaleFlagSuggestionsInput) - .output(findStaleFlagSuggestionsOutput) - .query(({ input }) => - getService().findStaleFlagSuggestions(input.repoPath), - ), -}); diff --git a/apps/code/src/main/trpc/routers/environment.ts b/apps/code/src/main/trpc/routers/environment.ts index 22c6770648..54e0c26270 100644 --- a/apps/code/src/main/trpc/routers/environment.ts +++ b/apps/code/src/main/trpc/routers/environment.ts @@ -1,3 +1,4 @@ +import type { WorkspaceClient } from "@posthog/workspace-client/client"; import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; import { @@ -7,45 +8,33 @@ import { getEnvironmentInput, listEnvironmentsInput, updateEnvironmentInput, -} from "../../services/environment/schemas"; -import type { EnvironmentService } from "../../services/environment/service"; +} from "@posthog/workspace-server/services/environment/schemas"; import { publicProcedure, router } from "../trpc"; -const getService = () => - container.get(MAIN_TOKENS.EnvironmentService); +const ws = () => container.get(MAIN_TOKENS.WorkspaceClient); export const environmentRouter = router({ list: publicProcedure .input(listEnvironmentsInput) .output(environmentSchema.array()) - .query(({ input }) => getService().listEnvironments(input.repoPath)), + .query(({ input }) => ws().environment.list.query(input)), get: publicProcedure .input(getEnvironmentInput) .output(environmentSchema.nullable()) - .query(({ input }) => - getService().getEnvironment(input.repoPath, input.id), - ), + .query(({ input }) => ws().environment.get.query(input)), create: publicProcedure .input(createEnvironmentInput) .output(environmentSchema) - .mutation(({ input }) => { - const { repoPath, ...rest } = input; - return getService().createEnvironment(rest, repoPath); - }), + .mutation(({ input }) => ws().environment.create.mutate(input)), update: publicProcedure .input(updateEnvironmentInput) .output(environmentSchema) - .mutation(({ input }) => { - const { repoPath, ...rest } = input; - return getService().updateEnvironment(rest, repoPath); - }), + .mutation(({ input }) => ws().environment.update.mutate(input)), delete: publicProcedure .input(deleteEnvironmentInput) - .mutation(({ input }) => - getService().deleteEnvironment(input.repoPath, input.id), - ), + .mutation(({ input }) => ws().environment.delete.mutate(input)), }); 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/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/handoff.ts b/apps/code/src/main/trpc/routers/handoff.ts index d231a70df0..13edd329d6 100644 --- a/apps/code/src/main/trpc/routers/handoff.ts +++ b/apps/code/src/main/trpc/routers/handoff.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,12 +10,12 @@ import { handoffToCloudExecuteResult, handoffToCloudPreflightInput, handoffToCloudPreflightResult, -} from "../../services/handoff/schemas"; -import type { HandoffService } from "../../services/handoff/service"; +} from "@posthog/core/handoff/schemas"; +import { z } from "zod"; +import { container } from "../../di/container"; import { publicProcedure, router } from "../trpc"; -const getService = () => - container.get(MAIN_TOKENS.HandoffService); +const getService = () => container.get(HANDOFF_SERVICE); export const handoffRouter = router({ preflight: publicProcedure 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/mcp-apps.ts b/apps/code/src/main/trpc/routers/mcp-apps.ts deleted file mode 100644 index 60d423d435..0000000000 --- a/apps/code/src/main/trpc/routers/mcp-apps.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - getToolDefinitionInput, - getUiResourceInput, - hasUiForToolInput, - McpAppsServiceEvent, - mcpAppsSubscriptionInput, - mcpUiResourceSchema, - 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(MAIN_TOKENS.McpAppsService); - -export const mcpAppsRouter = router({ - getUiResource: publicProcedure - .input(getUiResourceInput) - .output(mcpUiResourceSchema.nullable()) - .query(({ input }) => getService().getUiResourceForTool(input.toolKey)), - - hasUiForTool: publicProcedure - .input(hasUiForToolInput) - .query(({ input }) => getService().hasUiForTool(input.toolKey)), - - getToolDefinition: publicProcedure - .input(getToolDefinitionInput) - .query(({ input }) => getService().getToolDefinition(input.toolKey)), - - proxyToolCall: publicProcedure - .input(proxyToolCallInput) - .mutation(({ input }) => - getService().proxyToolCall(input.serverName, input.toolName, input.args), - ), - - proxyResourceRead: publicProcedure - .input(proxyResourceReadInput) - .mutation(({ input }) => - getService().proxyResourceRead(input.serverName, input.uri), - ), - - openLink: publicProcedure - .input(openLinkInput) - .mutation(({ input }) => getService().openLink(input.url)), - - onToolInput: publicProcedure - .input(mcpAppsSubscriptionInput) - .subscription(async function* (opts) { - const service = getService(); - const targetToolKey = opts.input.toolKey; - for await (const event of service.toIterable( - McpAppsServiceEvent.ToolInput, - { signal: opts.signal }, - )) { - if (event.toolKey === targetToolKey) { - yield event; - } - } - }), - - onToolResult: publicProcedure - .input(mcpAppsSubscriptionInput) - .subscription(async function* (opts) { - const service = getService(); - const targetToolKey = opts.input.toolKey; - for await (const event of service.toIterable( - McpAppsServiceEvent.ToolResult, - { signal: opts.signal }, - )) { - if (event.toolKey === targetToolKey) { - yield event; - } - } - }), - - onToolCancelled: publicProcedure - .input(mcpAppsSubscriptionInput) - .subscription(async function* (opts) { - const service = getService(); - const targetToolKey = opts.input.toolKey; - for await (const event of service.toIterable( - McpAppsServiceEvent.ToolCancelled, - { signal: opts.signal }, - )) { - if (event.toolKey === targetToolKey) { - yield event; - } - } - }), - - onDiscoveryComplete: publicProcedure.subscription(async function* (opts) { - const service = getService(); - for await (const event of service.toIterable( - McpAppsServiceEvent.DiscoveryComplete, - { signal: opts.signal }, - )) { - yield event; - } - }), -}); diff --git a/apps/code/src/main/trpc/routers/mcp-callback.ts b/apps/code/src/main/trpc/routers/mcp-callback.ts deleted file mode 100644 index 8bf60be438..0000000000 --- a/apps/code/src/main/trpc/routers/mcp-callback.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -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(MAIN_TOKENS.McpCallbackService); - -export const mcpCallbackRouter = router({ - /** - * Get the callback URL for MCP OAuth (dev: http://localhost:8238/..., prod: deep link via the app-registered URL scheme). - * Call this before making the install_custom API call to PostHog. - */ - getCallbackUrl: publicProcedure - .output(getCallbackUrlOutput) - .query(() => getService().getCallbackUrl()), - - /** - * Open the OAuth authorization URL in the browser and wait for the callback. - * Returns when the OAuth flow completes (success or error). - */ - openAndWaitForCallback: publicProcedure - .input(openAndWaitInput) - .output(openAndWaitOutput) - .mutation(({ input }) => - getService().openAndWaitForCallback(input.redirectUrl), - ), - - /** - * Subscribe to MCP OAuth completion events. - * Useful for refreshing the installations list when a flow completes. - */ - onOAuthComplete: publicProcedure.subscription(async function* (opts) { - const service = getService(); - for await (const data of service.toIterable( - McpCallbackEvent.OAuthComplete, - { signal: opts.signal }, - )) { - yield data; - } - }), -}); 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/ui.ts b/apps/code/src/main/trpc/routers/ui.ts deleted file mode 100644 index 45830580b2..0000000000 --- a/apps/code/src/main/trpc/routers/ui.ts +++ /dev/null @@ -1,28 +0,0 @@ -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(MAIN_TOKENS.UIService); - -function subscribeToUIEvent(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 uiRouter = router({ - onOpenSettings: subscribeToUIEvent(UIServiceEvent.OpenSettings), - onNewTask: subscribeToUIEvent(UIServiceEvent.NewTask), - onResetLayout: subscribeToUIEvent(UIServiceEvent.ResetLayout), - onClearStorage: subscribeToUIEvent(UIServiceEvent.ClearStorage), - onInvalidateToken: subscribeToUIEvent(UIServiceEvent.InvalidateToken), -}); diff --git a/apps/code/src/main/trpc/routers/updates.test.ts b/apps/code/src/main/trpc/routers/updates.test.ts deleted file mode 100644 index b36e223d1a..0000000000 --- a/apps/code/src/main/trpc/routers/updates.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -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"; - -describe("updatesRouter", () => { - it("returns the current update status snapshot", async () => { - const caller = updatesRouter.createCaller({}); - - await expect(caller.getStatus()).resolves.toEqual({ - checking: false, - updateReady: true, - version: "v2.0.0", - }); - expect(mockUpdatesService.getStatus).toHaveBeenCalled(); - }); - - it("delegates menu/user checks to the updates service", async () => { - const caller = updatesRouter.createCaller({}); - - await expect(caller.check()).resolves.toEqual({ success: true }); - expect(mockUpdatesService.checkForUpdates).toHaveBeenCalled(); - }); - - it("reports whether updates are enabled", async () => { - const caller = updatesRouter.createCaller({}); - - await expect(caller.isEnabled()).resolves.toEqual({ enabled: true }); - }); - - it("delegates install to the updates service", async () => { - const caller = updatesRouter.createCaller({}); - - await expect(caller.install()).resolves.toEqual({ installed: true }); - expect(mockUpdatesService.installUpdate).toHaveBeenCalled(); - }); -}); 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/usage-monitor.ts b/apps/code/src/main/trpc/routers/usage-monitor.ts deleted file mode 100644 index 6775e57d2f..0000000000 --- a/apps/code/src/main/trpc/routers/usage-monitor.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { container } from "../../di/container"; -import { MAIN_TOKENS } from "../../di/tokens"; -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(MAIN_TOKENS.UsageMonitorService); - -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 usageMonitorRouter = router({ - onThresholdCrossed: subscribe(UsageMonitorEvent.ThresholdCrossed), - onUsageUpdated: subscribe(UsageMonitorEvent.UsageUpdated), - getLatest: publicProcedure - .output(usageSnapshotOutput) - .query(() => getService().getLatest()), - refresh: publicProcedure - .output(usageSnapshotOutput) - .mutation(() => getService().refreshNow()), -}); 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..2012bf28b0 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 { + middleware, + publicProcedure as baseProcedure, + router as baseRouter, +} 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/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..0a597bbe90 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -9,8 +9,8 @@ import { screen, shell, } from "electron"; +import { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window"; 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"; @@ -240,12 +240,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 index a5748db25b..e53627a421 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -1,8 +1,8 @@ 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 { GlobalEventHandlers } from "@components/GlobalEventHandlers"; +import { LoginTransition } from "@posthog/ui/primitives/LoginTransition"; +import { MainLayout } from "@posthog/ui/workbench/MainLayout"; +import { ScopeReauthPrompt } from "@posthog/ui/features/auth/components/ScopeReauthPrompt"; import { AuthScreen } from "@features/auth/components/AuthScreen"; import { InviteCodeScreen } from "@features/auth/components/InviteCodeScreen"; import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; @@ -11,33 +11,27 @@ import { 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 { useIsOrgAdmin } from "@posthog/ui/features/auth/useOrgRole"; +import { AddDirectoryDialog } from "@posthog/ui/features/folder-picker/AddDirectoryDialog"; +import { SettingsDialog } from "@posthog/ui/features/settings/SettingsDialog"; +import { AiApprovalScreen } from "@posthog/ui/features/ai-approval/AiApprovalScreen"; +import { OnboardingFlow } from "@posthog/ui/features/onboarding/components/OnboardingFlow"; +import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; +import { UpdateBanner } from "@posthog/ui/features/sidebar/components/UpdateBanner"; +import { useCommandMenuStore } from "@posthog/ui/workbench/commandMenuStore"; +import { useShortcutsSheetStore } from "@posthog/ui/workbench/shortcutsSheetStore"; +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; 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 { trpcClient } from "@renderer/trpc/client"; +import { isNotAuthenticatedError } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { track } from "@utils/analytics"; +import { EXTERNAL_LINKS } from "@posthog/shared"; 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( @@ -46,137 +40,18 @@ function App() { 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); - // 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; - } + // Analytics init + dev inbox console moved to host WORKBENCH_CONTRIBUTIONs + // (AnalyticsBootContribution / InboxDemoDevContribution), started by + // startWorkbench at boot. - 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, - }); - } - }, - }), - ); + // 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; @@ -280,6 +155,11 @@ function App() { } + onOpenSupport={() => + trpcClient.os.openExternal.mutate({ url: EXTERNAL_LINKS.discord }) + } + settingsDialog={} /> ); @@ -293,6 +173,10 @@ function App() { transition={{ duration: 0.5, delay: showTransition ? 0.5 : 0 }} > + ); }; diff --git a/apps/code/src/renderer/api/posthogClient.test.ts b/apps/code/src/renderer/api/posthogClient.test.ts deleted file mode 100644 index 2e0f299643..0000000000 --- a/apps/code/src/renderer/api/posthogClient.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { PostHogAPIClient } from "./posthogClient"; - -describe("PostHogAPIClient", () => { - it("sends supported reasoning effort for cloud Codex runs", async () => { - const client = new PostHogAPIClient( - "http://localhost:8000", - async () => "token", - async () => "token", - 123, - ); - - const post = vi.fn().mockResolvedValue({ - id: "task-123", - title: "Task", - description: "Task", - created_at: "2026-04-14T00:00:00Z", - updated_at: "2026-04-14T00:00:00Z", - origin_product: "user_created", - }); - - (client as unknown as { api: { post: typeof post } }).api = { post }; - - await client.runTaskInCloud("task-123", "feature/max-effort", { - adapter: "codex", - model: "gpt-5.4", - reasoningLevel: "high", - }); - - expect(post).toHaveBeenCalledWith( - "/api/projects/{project_id}/tasks/{id}/run/", - expect.objectContaining({ - path: { project_id: "123", id: "task-123" }, - body: expect.objectContaining({ - mode: "interactive", - branch: "feature/max-effort", - runtime_adapter: "codex", - model: "gpt-5.4", - reasoning_effort: "high", - }), - }), - ); - }); - - it("preserves Codex-native permission modes for cloud runs", async () => { - const client = new PostHogAPIClient( - "http://localhost:8000", - async () => "token", - async () => "token", - 123, - ); - - const post = vi.fn().mockResolvedValue({ - id: "task-123", - title: "Task", - description: "Task", - created_at: "2026-04-14T00:00:00Z", - updated_at: "2026-04-14T00:00:00Z", - origin_product: "user_created", - }); - - (client as unknown as { api: { post: typeof post } }).api = { post }; - - await client.runTaskInCloud("task-123", "feature/codex-mode", { - adapter: "codex", - model: "gpt-5.4", - initialPermissionMode: "auto", - }); - - expect(post).toHaveBeenCalledWith( - "/api/projects/{project_id}/tasks/{id}/run/", - expect.objectContaining({ - body: expect.objectContaining({ - initial_permission_mode: "auto", - }), - }), - ); - }); - - it("rejects unsupported reasoning effort for cloud Codex runs", async () => { - const client = new PostHogAPIClient( - "http://localhost:8000", - async () => "token", - async () => "token", - 123, - ); - - const post = vi.fn(); - (client as unknown as { api: { post: typeof post } }).api = { post }; - - await expect( - client.runTaskInCloud("task-123", "feature/max-effort", { - adapter: "codex", - model: "gpt-5.4", - reasoningLevel: "max", - }), - ).rejects.toThrow( - "Reasoning effort 'max' is not supported for codex model 'gpt-5.4'.", - ); - - expect(post).not.toHaveBeenCalled(); - }); - - it("rejects unsupported minimal reasoning effort for cloud runs", async () => { - const client = new PostHogAPIClient( - "http://localhost:8000", - async () => "token", - async () => "token", - 123, - ); - - const post = vi.fn(); - (client as unknown as { api: { post: typeof post } }).api = { post }; - - await expect( - client.runTaskInCloud("task-123", "feature/legacy-effort", { - adapter: "claude", - model: "claude-opus-4-8", - reasoningLevel: "minimal", - }), - ).rejects.toThrow( - "Reasoning effort 'minimal' is not supported for claude model 'claude-opus-4-8'.", - ); - - expect(post).not.toHaveBeenCalled(); - }); - - it("creates cloud task runs without relying on generated request typing", async () => { - const fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ id: "run-123", environment: "cloud" }), - }); - const client = new PostHogAPIClient( - "http://localhost:8000", - async () => "token", - async () => "token", - 123, - ); - - ( - client as unknown as { - api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; - } - ).api = { - baseUrl: "http://localhost:8000", - fetcher: { fetch }, - }; - - await expect( - client.createTaskRun("task-123", { - environment: "cloud", - mode: "interactive", - branch: "feature/direct-upload", - adapter: "codex", - model: "gpt-5.4", - reasoningLevel: "high", - initialPermissionMode: "auto", - }), - ).resolves.toEqual({ id: "run-123", environment: "cloud" }); - - expect(fetch).toHaveBeenCalledWith( - expect.objectContaining({ - method: "post", - path: "/api/projects/123/tasks/task-123/runs/", - overrides: { - body: JSON.stringify({ - mode: "interactive", - branch: "feature/direct-upload", - runtime_adapter: "codex", - model: "gpt-5.4", - reasoning_effort: "high", - initial_permission_mode: "auto", - environment: "cloud", - }), - }, - }), - ); - }); - - it("starts an existing cloud task run with run-scoped artifact ids", async () => { - const fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ id: "task-123", latest_run: { id: "run-123" } }), - }); - const client = new PostHogAPIClient( - "http://localhost:8000", - async () => "token", - async () => "token", - 123, - ); - - ( - client as unknown as { - api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; - } - ).api = { - baseUrl: "http://localhost:8000", - fetcher: { fetch }, - }; - - await expect( - client.startTaskRun("task-123", "run-123", { - pendingUserMessage: "Read the attached file first", - pendingUserArtifactIds: ["artifact-1"], - }), - ).resolves.toEqual({ id: "task-123", latest_run: { id: "run-123" } }); - - expect(fetch).toHaveBeenCalledWith( - expect.objectContaining({ - method: "post", - path: "/api/projects/123/tasks/task-123/runs/run-123/start/", - overrides: { - body: JSON.stringify({ - pending_user_message: "Read the attached file first", - pending_user_artifact_ids: ["artifact-1"], - }), - }, - }), - ); - }); - - describe("getSignalReport", () => { - function makeClient(fetch: ReturnType) { - const client = new PostHogAPIClient( - "http://localhost:8000", - async () => "token", - async () => "token", - 123, - ); - ( - client as unknown as { - api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; - } - ).api = { - baseUrl: "http://localhost:8000", - fetcher: { fetch }, - }; - return client; - } - - it("returns the parsed report on success", async () => { - const fetch = vi.fn().mockResolvedValue({ - json: async () => ({ id: "abc", title: "hi" }), - }); - const client = makeClient(fetch); - - await expect(client.getSignalReport("abc")).resolves.toEqual({ - id: "abc", - title: "hi", - }); - }); - - it("returns null when the shared fetcher throws a 404", async () => { - const fetch = vi - .fn() - .mockRejectedValue( - new Error('Failed request: [404] {"detail":"Not found."}'), - ); - const client = makeClient(fetch); - - await expect(client.getSignalReport("abc")).resolves.toBeNull(); - }); - - it("returns null when the shared fetcher throws a 403", async () => { - const fetch = vi - .fn() - .mockRejectedValue( - new Error('Failed request: [403] {"detail":"Forbidden."}'), - ); - const client = makeClient(fetch); - - await expect(client.getSignalReport("abc")).resolves.toBeNull(); - }); - - it("rethrows non-404/403 errors", async () => { - const fetch = vi - .fn() - .mockRejectedValue(new Error("Failed request: [500] boom")); - const client = makeClient(fetch); - - await expect(client.getSignalReport("abc")).rejects.toThrow("[500]"); - }); - }); - - describe("getTaskSummaries", () => { - const SUMMARIES_PATH = "/api/projects/123/tasks/summaries/"; - - function buildClient(fetch: ReturnType) { - const client = new PostHogAPIClient( - "http://localhost:8000", - async () => "token", - async () => "token", - 123, - ); - ( - client as unknown as { - api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; - } - ).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } }; - return client; - } - - function page(results: object[], next: string | null = null) { - return { - ok: true, - json: async () => ({ count: 0, previous: null, next, results }), - }; - } - - function buildFetchForPages(...pages: ReturnType[]) { - const fetch = vi.fn(); - for (const p of pages) fetch.mockResolvedValueOnce(p); - return fetch; - } - - it("returns immediately for empty input without hitting the network", async () => { - const fetch = vi.fn(); - await expect(buildClient(fetch).getTaskSummaries([])).resolves.toEqual( - [], - ); - expect(fetch).not.toHaveBeenCalled(); - }); - - it("returns single-page results without further requests", async () => { - const fetch = buildFetchForPages(page([{ id: "a" }])); - await expect(buildClient(fetch).getTaskSummaries(["a"])).resolves.toEqual( - [{ id: "a" }], - ); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it.each([ - { - name: "same-host next URL", - nextUrl: `http://localhost:8000${SUMMARIES_PATH}?limit=2&offset=2`, - expectedSecondPath: `${SUMMARIES_PATH}?limit=2&offset=2`, - }, - { - name: "cross-host next URL (proxy variance)", - nextUrl: `https://internal.posthog.example${SUMMARIES_PATH}?limit=1&offset=1`, - expectedSecondPath: `${SUMMARIES_PATH}?limit=1&offset=1`, - }, - ])( - "follows the next cursor across pages and merges results: $name", - async ({ nextUrl, expectedSecondPath }) => { - const fetch = buildFetchForPages( - page([{ id: "a" }, { id: "b" }], nextUrl), - page([{ id: "c" }]), - ); - await expect( - buildClient(fetch).getTaskSummaries(["a", "b", "c"]), - ).resolves.toEqual([{ id: "a" }, { id: "b" }, { id: "c" }]); - expect(fetch).toHaveBeenCalledTimes(2); - expect(fetch.mock.calls[0][0]).toMatchObject({ - method: "post", - path: SUMMARIES_PATH, - }); - expect(fetch.mock.calls[1][0]).toMatchObject({ - method: "post", - path: expectedSecondPath, - }); - }, - ); - - it("throws when the server responds non-OK", async () => { - const fetch = vi - .fn() - .mockResolvedValue({ ok: false, statusText: "Bad Request" }); - await expect(buildClient(fetch).getTaskSummaries(["a"])).rejects.toThrow( - "Bad Request", - ); - }); - - it("returns partial results when MAX_PAGES is exceeded", async () => { - const fetch = vi - .fn() - .mockResolvedValue( - page( - [{ id: "x" }], - `http://localhost:8000${SUMMARIES_PATH}?offset=1`, - ), - ); - const result = await buildClient(fetch).getTaskSummaries(["a"]); - expect(fetch).toHaveBeenCalledTimes(50); - expect(result.length).toBe(50); - }); - }); -}); diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts deleted file mode 100644 index 505f04b600..0000000000 --- a/apps/code/src/renderer/api/posthogClient.ts +++ /dev/null @@ -1,2934 +0,0 @@ -import type { SpendAnalysisResponse } from "@features/billing/types/spend-analysis"; -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 { - DISMISSAL_REASON_OPTIONS, - type DismissalReasonOptionValue, -} from "@shared/dismissalReasons"; -import type { - ActionabilityJudgmentArtefact, - AvailableSuggestedReviewer, - AvailableSuggestedReviewersResponse, - DismissalArtefact, - PriorityJudgmentArtefact, - SandboxEnvironment, - SandboxEnvironmentInput, - SignalFindingArtefact, - SignalProcessingStateResponse, - SignalReport, - SignalReportArtefact, - SignalReportArtefactsResponse, - SignalReportSignalsResponse, - SignalReportsQueryParams, - SignalReportsResponse, - SignalReportTask, - SignalReportTaskRelationship, - SignalTeamConfig, - SignalUserAutonomyConfig, - SlackChannelsQueryParams, - SlackChannelsResponse, - 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"; - -export class SeatSubscriptionRequiredError extends Error { - redirectUrl: string; - constructor(redirectUrl: string) { - super("Billing subscription required"); - this.name = "SeatSubscriptionRequiredError"; - this.redirectUrl = redirectUrl; - } -} - -export class SeatPaymentFailedError extends Error { - constructor(message?: string) { - super(message ?? "Payment failed"); - this.name = "SeatPaymentFailedError"; - } -} - -const log = logger.scope("posthog-client"); - -export const MCP_CATEGORIES = [ - { id: "all", label: "All" }, - { id: "business", label: "Business Operations" }, - { id: "data", label: "Data & Analytics" }, - { id: "design", label: "Design & Content" }, - { id: "dev", label: "Developer Tools & APIs" }, - { id: "infra", label: "Infrastructure" }, - { 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; - -export type Evaluation = Schemas.Evaluation; - -export interface UserGitHubIntegration { - id: string; - kind: "github"; - installation_id: string; - repository_selection?: string | null; - account?: { - type?: string | null; - name?: string | null; - } | null; - uses_shared_installation?: boolean; - created_at?: string; -} - -export interface SignalSourceConfig { - id: string; - source_product: - | "session_replay" - | "llm_analytics" - | "github" - | "linear" - | "zendesk" - | "conversations" - | "error_tracking" - | "pganalyze"; - source_type: - | "session_analysis_cluster" - | "evaluation" - | "issue" - | "ticket" - | "issue_created" - | "issue_reopened" - | "issue_spiking"; - enabled: boolean; - config: Record; - created_at: string; - updated_at: string; - status: "running" | "completed" | "failed" | null; -} - -export interface ExternalDataSourceSchema { - id: string; - name: string; - should_sync: boolean; - /** e.g. `full_refresh` (full table replication), `incremental`, `append` */ - sync_type?: string | null; -} - -export interface ExternalDataSource { - id: string; - source_type: string; - status: string; - // The generated `ExternalDataSourceSerializers` types this as `string`, - // but the actual API returns an array of schema objects - schemas?: ExternalDataSourceSchema[] | string; -} - -export interface TaskArtifactUploadRequest { - name: string; - type: "user_attachment"; - size: number; - content_type?: string; - source?: string; -} - -export interface DirectUploadPresignedPost { - url: string; - fields: Record; -} - -export interface PreparedTaskArtifactUpload extends TaskArtifactUploadRequest { - id: string; - storage_path: string; - expires_in: number; - presigned_post: DirectUploadPresignedPost; -} - -export interface FinalizedTaskArtifactUpload { - id: string; - name: string; - type: string; - source?: string; - size?: number; - content_type?: string; - storage_path: string; - uploaded_at?: string; -} - -type CloudRuntimeAdapter = "claude" | "codex"; - -interface CloudRunOptions { - adapter?: CloudRuntimeAdapter; - model?: string; - reasoningLevel?: string; - sandboxEnvironmentId?: string; - prAuthorshipMode?: PrAuthorshipMode; - runSource?: CloudRunSource; - signalReportId?: string; - initialPermissionMode?: PermissionMode; -} - -interface CreateTaskRunOptions extends CloudRunOptions { - environment?: "local" | "cloud"; - mode?: "interactive" | "background"; - branch?: string | null; -} - -interface StartTaskRunOptions { - pendingUserMessage?: string; - pendingUserArtifactIds?: string[]; -} - -function buildCloudRunRequestBody( - options?: CloudRunOptions & { - branch?: string | null; - mode?: "interactive" | "background"; - resumeFromRunId?: string; - pendingUserMessage?: string; - pendingUserArtifactIds?: string[]; - }, -): Record { - const body: Record = { - mode: options?.mode ?? "interactive", - }; - - if (options?.branch) { - body.branch = options.branch; - } - if (options?.adapter) { - body.runtime_adapter = options.adapter; - if (options.model) { - body.model = options.model; - } - if (options.reasoningLevel) { - if (!options.model) { - throw new Error( - "A cloud reasoning level requires a model to be selected.", - ); - } - if ( - !isSupportedReasoningEffort( - options.adapter, - options.model, - options.reasoningLevel, - ) - ) { - throw new Error( - `Reasoning effort '${options.reasoningLevel}' is not supported for ${options.adapter} model '${options.model}'.`, - ); - } - body.reasoning_effort = options.reasoningLevel; - } - } - if (options?.resumeFromRunId) { - body.resume_from_run_id = options.resumeFromRunId; - } - if (options?.pendingUserMessage) { - body.pending_user_message = options.pendingUserMessage; - } - if (options?.pendingUserArtifactIds?.length) { - body.pending_user_artifact_ids = options.pendingUserArtifactIds; - } - if (options?.sandboxEnvironmentId) { - body.sandbox_environment_id = options.sandboxEnvironmentId; - } - if (options?.prAuthorshipMode) { - body.pr_authorship_mode = options.prAuthorshipMode; - } - if (options?.runSource) { - body.run_source = options.runSource; - } - if (options?.signalReportId) { - body.signal_report_id = options.signalReportId; - } - if (options?.initialPermissionMode) { - body.initial_permission_mode = options.initialPermissionMode; - } - - return body; -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function optionalString(value: unknown): string | null { - return typeof value === "string" ? value : null; -} - -type AnyArtefact = - | SignalReportArtefact - | PriorityJudgmentArtefact - | ActionabilityJudgmentArtefact - | SignalFindingArtefact - | SuggestedReviewersArtefact - | DismissalArtefact; - -const DISMISSAL_REASONS = new Set( - DISMISSAL_REASON_OPTIONS.map((o) => o.value), -); - -const PRIORITY_VALUES = new Set(["P0", "P1", "P2", "P3", "P4"]); - -function normalizePriorityJudgmentArtefact( - value: Record, -): PriorityJudgmentArtefact | null { - const id = optionalString(value.id); - if (!id) return null; - - const contentValue = isObjectRecord(value.content) ? value.content : null; - if (!contentValue) return null; - - const priority = optionalString(contentValue.priority); - if (!priority || !PRIORITY_VALUES.has(priority)) return null; - - return { - id, - type: "priority_judgment", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), - content: { - explanation: optionalString(contentValue.explanation) ?? "", - priority: priority as PriorityJudgmentArtefact["content"]["priority"], - }, - }; -} - -const ACTIONABILITY_VALUES = new Set([ - "immediately_actionable", - "requires_human_input", - "not_actionable", -]); - -function normalizeActionabilityJudgmentArtefact( - value: Record, -): ActionabilityJudgmentArtefact | null { - const id = optionalString(value.id); - if (!id) return null; - - const contentValue = isObjectRecord(value.content) ? value.content : null; - if (!contentValue) return null; - - // Support both agentic ("actionability") and legacy ("choice") field names - const actionability = - optionalString(contentValue.actionability) ?? - optionalString(contentValue.choice); - if (!actionability || !ACTIONABILITY_VALUES.has(actionability)) return null; - - return { - id, - type: "actionability_judgment", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), - content: { - explanation: optionalString(contentValue.explanation) ?? "", - actionability: - actionability as ActionabilityJudgmentArtefact["content"]["actionability"], - already_addressed: - typeof contentValue.already_addressed === "boolean" - ? contentValue.already_addressed - : false, - }, - }; -} - -function normalizeSignalFindingArtefact( - value: Record, -): SignalFindingArtefact | null { - const id = optionalString(value.id); - if (!id) return null; - - const contentValue = isObjectRecord(value.content) ? value.content : null; - if (!contentValue) return null; - - const signalId = optionalString(contentValue.signal_id); - if (!signalId) return null; - - return { - id, - type: "signal_finding", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), - content: { - signal_id: signalId, - relevant_code_paths: Array.isArray(contentValue.relevant_code_paths) - ? contentValue.relevant_code_paths.filter( - (p: unknown): p is string => typeof p === "string", - ) - : [], - relevant_commit_hashes: isObjectRecord( - contentValue.relevant_commit_hashes, - ) - ? Object.fromEntries( - Object.entries(contentValue.relevant_commit_hashes).filter( - (e): e is [string, string] => typeof e[1] === "string", - ), - ) - : {}, - data_queried: optionalString(contentValue.data_queried) ?? "", - verified: - typeof contentValue.verified === "boolean" - ? contentValue.verified - : false, - }, - }; -} - -function normalizeDismissalArtefact( - value: Record, -): DismissalArtefact | null { - const id = optionalString(value.id); - if (!id) return null; - - const contentValue = isObjectRecord(value.content) ? value.content : null; - if (!contentValue) return null; - - const rawReason = optionalString(contentValue.reason); - const reason = - rawReason && DISMISSAL_REASONS.has(rawReason as DismissalReasonOptionValue) - ? (rawReason as DismissalReasonOptionValue) - : null; - - if (reason == null) { - return null; - } - - return { - id, - type: "dismissal", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), - content: { - reason, - note: optionalString(contentValue.note) ?? "", - user_id: - typeof contentValue.user_id === "number" ? contentValue.user_id : null, - user_uuid: optionalString(contentValue.user_uuid), - }, - }; -} - -function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { - if (!isObjectRecord(value)) { - return null; - } - - const dispatchType = optionalString(value.type); - if (dispatchType === "signal_finding") { - return normalizeSignalFindingArtefact(value); - } - if (dispatchType === "actionability_judgment") { - return normalizeActionabilityJudgmentArtefact(value); - } - if (dispatchType === "priority_judgment") { - return normalizePriorityJudgmentArtefact(value); - } - if (dispatchType === "dismissal") { - return normalizeDismissalArtefact(value); - } - - const id = optionalString(value.id); - if (!id) { - return null; - } - - const type = dispatchType ?? "unknown"; - const created_at = - optionalString(value.created_at) ?? new Date(0).toISOString(); - - // suggested_reviewers: content is an array of reviewer objects - if (type === "suggested_reviewers" && Array.isArray(value.content)) { - return { - id, - type: "suggested_reviewers" as const, - created_at, - content: value.content as SuggestedReviewersArtefact["content"], - }; - } - - // video_segment and other artefacts with object content - const contentValue = isObjectRecord(value.content) ? value.content : null; - if (!contentValue) { - return null; - } - - const content = optionalString(contentValue.content); - const sessionId = optionalString(contentValue.session_id); - - // The backend may return empty content objects when binary decode fails. - if (!content && !sessionId) { - return null; - } - - return { - id, - type, - created_at, - content: { - session_id: sessionId ?? "", - start_time: optionalString(contentValue.start_time) ?? "", - end_time: optionalString(contentValue.end_time) ?? "", - distinct_id: optionalString(contentValue.distinct_id) ?? "", - content: content ?? "", - distance_to_centroid: - typeof contentValue.distance_to_centroid === "number" - ? contentValue.distance_to_centroid - : null, - }, - }; -} - -function parseSignalReportArtefactsPayload( - value: unknown, -): SignalReportArtefactsResponse { - const payload = isObjectRecord(value) ? value : null; - const rawResults = Array.isArray(payload?.results) - ? payload.results - : Array.isArray(value) - ? value - : []; - - const results = rawResults - .map(normalizeSignalReportArtefact) - .filter((artefact): artefact is AnyArtefact => artefact !== null); - const count = - typeof payload?.count === "number" ? payload.count : results.length; - - if (rawResults.length > 0 && results.length === 0) { - return { - results: [], - count: 0, - unavailableReason: "invalid_payload", - }; - } - - return { - results, - count, - }; -} - -function normalizeAvailableSuggestedReviewer( - uuid: string, - value: unknown, -): AvailableSuggestedReviewer | null { - if (!isObjectRecord(value)) { - return null; - } - - const normalizedUuid = optionalString(uuid); - if (!normalizedUuid) { - return null; - } - - return { - uuid: normalizedUuid, - name: optionalString(value.name) ?? "", - email: optionalString(value.email) ?? "", - github_login: optionalString(value.github_login) ?? "", - }; -} - -function parseAvailableSuggestedReviewersPayload( - value: unknown, -): AvailableSuggestedReviewersResponse { - if (!isObjectRecord(value)) { - return { - results: [], - count: 0, - }; - } - - const results = Object.entries(value) - .map(([uuid, reviewer]) => - normalizeAvailableSuggestedReviewer(uuid, reviewer), - ) - .filter( - (reviewer): reviewer is AvailableSuggestedReviewer => reviewer !== null, - ); - - return { - results, - count: results.length, - }; -} - -export class PostHogAPIClient { - private api: ReturnType; - private _teamId: number | null = null; - - constructor( - apiHost: string, - getAccessToken: () => Promise, - refreshAccessToken: () => Promise, - teamId?: number, - ) { - const baseUrl = apiHost.endsWith("/") ? apiHost.slice(0, -1) : apiHost; - this.api = createApiClient( - buildApiFetcher({ - getAccessToken, - refreshAccessToken, - appVersion: - typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "unknown", - }), - baseUrl, - ); - if (teamId) { - this._teamId = teamId; - } - } - - setTeamId(teamId: number): void { - this._teamId = teamId; - } - - private async getTeamId(): Promise { - if (this._teamId !== null) { - return this._teamId; - } - - const user = await this.api.get("/api/users/{uuid}/", { - path: { uuid: "@me" }, - }); - - if (user?.team?.id) { - this._teamId = user.team.id; - return this._teamId; - } - - throw new Error("No team found for user"); - } - - async getCurrentUser() { - const data = await this.api.get("/api/users/{uuid}/", { - path: { uuid: "@me" }, - }); - return data; - } - - async getGithubLogin(): Promise { - const data = (await this.api.get("/api/users/{uuid}/github_login/", { - path: { uuid: "@me" }, - })) as { github_login: string | null }; - return data.github_login; - } - - /** - * `POST .../integrations/github/start/`. Optional `teamId` matches app project when session `current_team` differs. - */ - async startGithubUserIntegrationConnect(teamId?: number): Promise<{ - install_url: string; - connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install"; - }> { - const id = teamId ?? (await this.getTeamId()); - const urlPath = `/api/users/@me/integrations/github/start/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: urlPath, - overrides: { - body: JSON.stringify({ team_id: id, connect_from: "posthog_code" }), - }, - }); - if (!response.ok) { - const err = (await response.json().catch(() => ({}))) as { - detail?: unknown; - }; - const detail = - typeof err.detail === "string" - ? err.detail - : "Failed to start GitHub connection"; - throw new Error(detail); - } - return (await response.json()) as { - install_url: string; - connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install"; - }; - } - - async getGithubUserIntegrations(): Promise { - const urlPath = `/api/users/@me/integrations/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: urlPath, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch personal GitHub integrations: ${response.statusText}`, - ); - } - - const data = (await response.json()) as { - results?: UserGitHubIntegration[]; - }; - return data.results ?? []; - } - - async disconnectGithubUserIntegration(installationId: string): Promise { - const urlPath = `/api/users/@me/integrations/github/${encodeURIComponent(installationId)}/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ - method: "delete", - url, - path: urlPath, - }); - if (!response.ok && response.status !== 404) { - throw new Error( - `Failed to disconnect GitHub integration: ${response.statusText}`, - ); - } - } - - async switchOrganization(orgId: string): Promise { - await this.api.patch("/api/users/{uuid}/", { - path: { uuid: "@me" }, - body: { set_current_organization: orgId } as Record, - }); - } - - async getProject(projectId: number) { - //@ts-expect-error this is not in the generated client - const data = await this.api.get("/api/projects/{project_id}/", { - path: { project_id: projectId.toString() }, - }); - return data as Schemas.Team; - } - - async listSignalSourceConfigs( - projectId: number, - ): Promise { - const urlPath = `/api/projects/${projectId}/signals/source_configs/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: urlPath, - }); - if (!response.ok) { - throw new Error( - `Failed to fetch signal source configs: ${response.statusText}`, - ); - } - const data = (await response.json()) as - | { results: SignalSourceConfig[] } - | SignalSourceConfig[]; - return Array.isArray(data) ? data : (data.results ?? []); - } - - async createSignalSourceConfig( - projectId: number, - options: { - source_product: SignalSourceConfig["source_product"]; - source_type: SignalSourceConfig["source_type"]; - enabled: boolean; - config?: Record; - }, - ): Promise { - const urlPath = `/api/projects/${projectId}/signals/source_configs/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: urlPath, - overrides: { - body: JSON.stringify(options), - }, - }); - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - detail?: string; - }; - throw new Error( - errorData.detail ?? - `Failed to create signal source config: ${response.statusText}`, - ); - } - return (await response.json()) as SignalSourceConfig; - } - - async updateSignalSourceConfig( - projectId: number, - configId: string, - updates: { enabled: boolean }, - ): Promise { - const urlPath = `/api/projects/${projectId}/signals/source_configs/${configId}/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ - method: "patch", - url, - path: urlPath, - overrides: { - body: JSON.stringify(updates), - }, - }); - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - detail?: string; - }; - throw new Error( - errorData.detail ?? - `Failed to update signal source config: ${response.statusText}`, - ); - } - return (await response.json()) as SignalSourceConfig; - } - - async listEvaluations(projectId: number): Promise { - const data = await this.api.get( - "/api/environments/{project_id}/evaluations/", - { - path: { project_id: projectId.toString() }, - query: { limit: 200 }, - }, - ); - return data.results ?? []; - } - - async updateEvaluation( - projectId: number, - evaluationId: string, - updates: { enabled: boolean }, - ): Promise { - return await this.api.patch( - "/api/environments/{project_id}/evaluations/{id}/", - { - path: { - project_id: projectId.toString(), - id: evaluationId, - }, - body: updates, - }, - ); - } - - async listExternalDataSources( - projectId: number, - ): Promise { - const data = (await this.api.get( - "/api/projects/{project_id}/external_data_sources/", - { - path: { project_id: projectId.toString() }, - query: {}, - }, - )) as unknown as { results?: ExternalDataSource[] } | ExternalDataSource[]; - return Array.isArray(data) ? data : (data.results ?? []); - } - - async createExternalDataSource( - projectId: number, - payload: { - source_type: string; - payload: Record; - }, - ): Promise { - const response = await this.api.post( - "/api/projects/{project_id}/external_data_sources/", - { - path: { project_id: projectId.toString() }, - body: payload as unknown as Schemas.ExternalDataSourceCreate, - withResponse: true, - throwOnStatusError: false, - }, - ); - if (!response.ok) { - const errorData = isObjectRecord(response.data) - ? (response.data as { detail?: string }) - : {}; - throw new Error( - errorData.detail ?? - `Failed to create external data source: ${response.statusText}`, - ); - } - return response.data as unknown as ExternalDataSource; - } - - async updateExternalDataSchema( - projectId: number, - schemaId: string, - updates: { should_sync: boolean; sync_type?: string }, - ): Promise { - const urlPath = `/api/projects/${projectId}/external_data_schemas/${schemaId}/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ - method: "patch", - url, - path: urlPath, - overrides: { - body: JSON.stringify(updates), - }, - }); - if (!response.ok) { - const errorData = (await response.json().catch(() => ({}))) as { - detail?: string; - }; - throw new Error( - errorData.detail ?? - `Failed to update external data schema: ${response.statusText}`, - ); - } - } - - async getTasks(options?: { - repository?: string; - createdBy?: number; - originProduct?: string; - internal?: boolean; - }) { - const teamId = await this.getTeamId(); - const params: Record = { - limit: 500, - }; - - if (options?.repository) { - params.repository = options.repository; - } - - if (options?.createdBy) { - params.created_by = options.createdBy; - } - - if (options?.originProduct) { - params.origin_product = options.originProduct; - } - - if (options?.internal) { - params.internal = true; - } - - const data = await this.api.get(`/api/projects/{project_id}/tasks/`, { - path: { project_id: teamId.toString() }, - query: params, - }); - - return data.results ?? []; - } - - async getTaskSummaries(ids: string[]) { - if (ids.length === 0) return []; - const TASK_SUMMARIES_MAX_PAGES = 50; - const teamId = await this.getTeamId(); - const all: Schemas.TaskSummary[] = []; - let urlPath: string = `/api/projects/${teamId}/tasks/summaries/`; - for (let i = 0; i < TASK_SUMMARIES_MAX_PAGES; i++) { - const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: urlPath, - overrides: { - body: JSON.stringify({ ids } satisfies Schemas.TaskSummariesRequest), - }, - }); - if (!response.ok) { - throw new Error( - `Failed to fetch task summaries: ${response.statusText}`, - ); - } - const page = (await response.json()) as Schemas.PaginatedTaskSummaryList; - all.push(...page.results); - if (!page.next) return all; - const nextUrl = new URL(page.next); - urlPath = `${nextUrl.pathname}${nextUrl.search}`; - } - log.warn( - `getTaskSummaries hit MAX_PAGES (${TASK_SUMMARIES_MAX_PAGES}); returning partial results`, - { ids: ids.length, returned: all.length }, - ); - return all; - } - - async getTask(taskId: string) { - const teamId = await this.getTeamId(); - const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, { - path: { project_id: teamId.toString(), id: taskId }, - }); - return data as unknown as Task; - } - - async createTask( - options: Pick & - Partial< - Pick< - Task, - | "title" - | "repository" - | "json_schema" - | "origin_product" - | "signal_report" - > - > & { - github_integration?: number | null; - github_user_integration?: string | null; - /** POST-only: `SignalReportTask.relationship` to create when linking to `signal_report`. */ - signal_report_task_relationship?: SignalReportTaskRelationship; - }, - ) { - const teamId = await this.getTeamId(); - const { origin_product: originProduct, ...taskOptions } = options; - - const data = await this.api.post(`/api/projects/{project_id}/tasks/`, { - path: { project_id: teamId.toString() }, - body: { - ...taskOptions, - origin_product: originProduct ?? "user_created", - } as unknown as Schemas.Task, - }); - - return data; - } - - async updateTask(taskId: string, updates: Partial) { - const teamId = await this.getTeamId(); - const data = await this.api.patch( - `/api/projects/{project_id}/tasks/{id}/`, - { - path: { project_id: teamId.toString(), id: taskId }, - body: updates, - }, - ); - - return data; - } - - async deleteTask(taskId: string) { - const teamId = await this.getTeamId(); - await this.api.delete(`/api/projects/{project_id}/tasks/{id}/`, { - path: { project_id: teamId.toString(), id: taskId }, - }); - } - - async duplicateTask(taskId: string) { - const task = await this.getTask(taskId); - return this.createTask({ - description: task.description ?? "", - title: task.title, - repository: task.repository, - json_schema: task.json_schema, - origin_product: task.origin_product, - github_integration: task.github_integration, - github_user_integration: task.github_user_integration, - }); - } - - async sendRunCommand( - taskId: string, - runId: string, - method: "user_message" | "cancel" | "close", - params?: Record, - ): Promise<{ success: boolean; result?: unknown; error?: string }> { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/command/`, - ); - const body = { - jsonrpc: "2.0", - method, - params: params ?? {}, - id: `posthog-code-${Date.now()}`, - }; - - try { - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/command/`, - overrides: { - body: JSON.stringify(body), - }, - }); - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - let errorMessage = `Command failed: ${response.statusText}`; - try { - const errorJson = JSON.parse(errorText); - errorMessage = - errorJson.error?.message ?? errorJson.error ?? errorMessage; - } catch { - if (errorText) errorMessage = errorText; - } - return { success: false, error: errorMessage }; - } - - const data = await response.json(); - if (data.error) { - return { - success: false, - error: data.error.message ?? JSON.stringify(data.error), - }; - } - - return { success: true, result: data.result }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; - } - } - - async runTaskInCloud( - taskId: string, - branch?: string | null, - options?: CloudRunOptions & { - resumeFromRunId?: string; - pendingUserMessage?: string; - pendingUserArtifactIds?: string[]; - }, - ): Promise { - const teamId = await this.getTeamId(); - const body = buildCloudRunRequestBody({ - ...options, - branch, - mode: "interactive", - }); - - const data = await this.api.post( - `/api/projects/{project_id}/tasks/{id}/run/`, - { - path: { project_id: teamId.toString(), id: taskId }, - body, - }, - ); - - return data as unknown as Task; - } - - async prepareTaskStagedArtifactUploads( - taskId: string, - artifacts: TaskArtifactUploadRequest[], - ): Promise { - if (!artifacts.length) { - return []; - } - - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/staged_artifacts/prepare_upload/`, - ); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: `/api/projects/${teamId}/tasks/${taskId}/staged_artifacts/prepare_upload/`, - overrides: { - body: JSON.stringify({ artifacts }), - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to prepare staged uploads: ${response.statusText}`, - ); - } - - const data = (await response.json()) as { - artifacts?: PreparedTaskArtifactUpload[]; - }; - return data.artifacts ?? []; - } - - async finalizeTaskStagedArtifactUploads( - taskId: string, - artifacts: PreparedTaskArtifactUpload[], - ): Promise { - if (!artifacts.length) { - return []; - } - - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/staged_artifacts/finalize_upload/`, - ); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: `/api/projects/${teamId}/tasks/${taskId}/staged_artifacts/finalize_upload/`, - overrides: { - body: JSON.stringify({ - artifacts: artifacts.map((artifact) => ({ - id: artifact.id, - name: artifact.name, - type: artifact.type, - source: artifact.source, - content_type: artifact.content_type, - storage_path: artifact.storage_path, - })), - }), - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to finalize staged uploads: ${response.statusText}`, - ); - } - - const data = (await response.json()) as { - artifacts?: FinalizedTaskArtifactUpload[]; - }; - return data.artifacts ?? []; - } - - async prepareTaskRunArtifactUploads( - taskId: string, - runId: string, - artifacts: TaskArtifactUploadRequest[], - ): Promise { - if (!artifacts.length) { - return []; - } - - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/prepare_upload/`, - ); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/prepare_upload/`, - overrides: { - body: JSON.stringify({ artifacts }), - }, - }); - - if (!response.ok) { - throw new Error(`Failed to prepare uploads: ${response.statusText}`); - } - - const data = (await response.json()) as { - artifacts?: PreparedTaskArtifactUpload[]; - }; - return data.artifacts ?? []; - } - - async finalizeTaskRunArtifactUploads( - taskId: string, - runId: string, - artifacts: PreparedTaskArtifactUpload[], - ): Promise { - if (!artifacts.length) { - return []; - } - - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/finalize_upload/`, - ); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/finalize_upload/`, - overrides: { - body: JSON.stringify({ - artifacts: artifacts.map((artifact) => ({ - id: artifact.id, - name: artifact.name, - type: artifact.type, - source: artifact.source, - content_type: artifact.content_type, - storage_path: artifact.storage_path, - })), - }), - }, - }); - - if (!response.ok) { - throw new Error(`Failed to finalize uploads: ${response.statusText}`); - } - - const data = (await response.json()) as { - artifacts?: FinalizedTaskArtifactUpload[]; - }; - return data.artifacts ?? []; - } - - async resumeRunInCloud(taskId: string, runId: string): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/resume_in_cloud/`, - ); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/resume_in_cloud/`, - }); - - if (!response.ok) { - throw new Error(`Failed to resume run in cloud: ${response.statusText}`); - } - - return (await response.json()) as TaskRun; - } - - async listTaskRuns(taskId: string): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/`, - ); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/projects/${teamId}/tasks/${taskId}/runs/`, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch task runs: ${response.statusText}`); - } - - const data = await response.json(); - return data.results ?? data ?? []; - } - - async getTaskRun(taskId: string, runId: string): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/`, - ); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/`, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch task run: ${response.statusText}`); - } - - return await response.json(); - } - - async createTaskRun( - taskId: string, - options?: CreateTaskRunOptions, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/`, - ); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: `/api/projects/${teamId}/tasks/${taskId}/runs/`, - overrides: { - body: JSON.stringify({ - ...buildCloudRunRequestBody({ - ...options, - mode: options?.mode ?? "background", - }), - environment: options?.environment ?? "local", - }), - }, - }); - - if (!response.ok) { - throw new Error(`Failed to create task run: ${response.statusText}`); - } - - return (await response.json()) as TaskRun; - } - - async startTaskRun( - taskId: string, - runId: string, - options?: StartTaskRunOptions, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/start/`, - ); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/start/`, - overrides: { - body: JSON.stringify({ - pending_user_message: options?.pendingUserMessage, - pending_user_artifact_ids: options?.pendingUserArtifactIds, - }), - }, - }); - - if (!response.ok) { - throw new Error(`Failed to start task run: ${response.statusText}`); - } - - return (await response.json()) as Task; - } - - async updateTaskRun( - taskId: string, - runId: string, - updates: Partial< - Pick< - TaskRun, - "status" | "branch" | "stage" | "error_message" | "output" | "state" - > - >, - ): Promise { - const teamId = await this.getTeamId(); - const data = await this.api.patch( - `/api/projects/{project_id}/tasks/{task_id}/runs/{id}/`, - { - path: { - project_id: teamId.toString(), - task_id: taskId, - id: runId, - }, - body: updates as Record, - }, - ); - return data as unknown as TaskRun; - } - - /** - * Append events to a task run's S3 log file - */ - async appendTaskRunLog( - taskId: string, - runId: string, - entries: StoredLogEntry[], - ): Promise { - const teamId = await this.getTeamId(); - const url = `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/append_log/`; - const response = await this.api.fetcher.fetch({ - method: "post", - url: new URL(url), - path: url, - overrides: { - body: JSON.stringify({ entries }), - }, - }); - if (!response.ok) { - throw new Error(`Failed to append log: ${response.statusText}`); - } - } - - async getTaskRunSessionLogs( - taskId: string, - runId: string, - options?: { limit?: number; after?: string }, - ): Promise { - try { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/session_logs/`, - ); - url.searchParams.set("limit", String(options?.limit ?? 5000)); - if (options?.after) { - url.searchParams.set("after", options.after); - } - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/session_logs/`, - }); - - if (!response.ok) { - log.warn( - `Failed to fetch session logs: ${response.status} ${response.statusText}`, - ); - return []; - } - - return (await response.json()) as StoredLogEntry[]; - } catch (err) { - log.warn("Failed to fetch task run session logs", err); - return []; - } - } - - async getTaskLogs(taskId: string): Promise { - try { - const task = (await this.getTask(taskId)) as unknown as Task; - const logUrl = task?.latest_run?.log_url; - - if (!logUrl) { - return []; - } - - const response = await fetch(logUrl); - - if (!response.ok) { - log.warn( - `Failed to fetch logs: ${response.status} ${response.statusText}`, - ); - return []; - } - - const content = await response.text(); - - if (!content.trim()) { - return []; - } - return content - .trim() - .split("\n") - .map((line) => JSON.parse(line) as StoredLogEntry); - } catch (err) { - log.warn("Failed to fetch task logs from latest run", err); - return []; - } - } - - async getIntegrations() { - const teamId = await this.getTeamId(); - return this.getIntegrationsForProject(teamId); - } - - async getIntegrationsForProject(projectId: number) { - const url = new URL( - `${this.api.baseUrl}/api/environments/${projectId}/integrations/`, - ); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/environments/${projectId}/integrations/`, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch integrations: ${response.statusText}`); - } - - const data = await response.json(); - return data.results ?? data ?? []; - } - - async getGithubBranches( - integrationId: string | number, - repo: string, - ): Promise<{ branches: string[]; defaultBranch: string | null }> { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, - ); - url.searchParams.set("repo", repo); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch GitHub branches: ${response.statusText}`, - ); - } - - const data = await response.json(); - return { - branches: data.branches ?? data.results ?? data ?? [], - defaultBranch: data.default_branch ?? null, - }; - } - - async getGithubBranchesPage( - integrationId: string | number, - repo: string, - offset: number, - limit: number, - search?: string, - ): Promise<{ - branches: string[]; - defaultBranch: string | null; - hasMore: boolean; - }> { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, - ); - url.searchParams.set("repo", repo); - url.searchParams.set("offset", String(offset)); - url.searchParams.set("limit", String(limit)); - if (search?.trim()) { - url.searchParams.set("search", search.trim()); - } - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch GitHub branches: ${response.statusText}`, - ); - } - - const data = await response.json(); - return { - branches: data.branches ?? data.results ?? data ?? [], - defaultBranch: data.default_branch ?? null, - hasMore: data.has_more ?? false, - }; - } - - async getGithubUserBranchesPage( - installationId: string | number, - repo: string, - offset: number, - limit: number, - search?: string, - ): Promise<{ - branches: string[]; - defaultBranch: string | null; - hasMore: boolean; - }> { - const urlPath = `/api/users/@me/integrations/github/${installationId}/branches/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - url.searchParams.set("repo", repo); - url.searchParams.set("offset", String(offset)); - url.searchParams.set("limit", String(limit)); - if (search?.trim()) { - url.searchParams.set("search", search.trim()); - } - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: urlPath, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch personal GitHub branches: ${response.statusText}`, - ); - } - - const data = await response.json(); - return { - branches: data.branches ?? data.results ?? data ?? [], - defaultBranch: data.default_branch ?? null, - hasMore: data.has_more ?? false, - }; - } - - async getGithubRepositories( - integrationId: string | number, - ): Promise { - const repositories: string[] = []; - let offset = 0; - - while (true) { - const page = await this.getGithubRepositoriesPage( - integrationId, - offset, - 500, - ); - repositories.push(...page.repositories); - - if (!page.hasMore) { - return repositories; - } - - offset += page.repositories.length; - } - } - - async getGithubRepositoriesPage( - integrationId: string | number, - offset: number, - limit: number, - search?: string, - ): Promise<{ - repositories: string[]; - hasMore: boolean; - }> { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_repos/`, - ); - url.searchParams.set("offset", String(offset)); - url.searchParams.set("limit", String(limit)); - if (search?.trim()) { - url.searchParams.set("search", search.trim()); - } - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/environments/${teamId}/integrations/${integrationId}/github_repos/`, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch GitHub repositories: ${response.statusText}`, - ); - } - - const data = await response.json(); - return { - repositories: this.normalizeGithubRepositories(data), - hasMore: data.has_more ?? false, - }; - } - - async getGithubUserRepositories( - installationId: string | number, - ): Promise { - const repositories: string[] = []; - let offset = 0; - - while (true) { - const page = await this.getGithubUserRepositoriesPage( - installationId, - offset, - 500, - ); - repositories.push(...page.repositories); - - if (!page.hasMore) { - return repositories; - } - - offset += page.repositories.length; - } - } - - async getGithubUserRepositoriesPage( - installationId: string | number, - offset: number, - limit: number, - search?: string, - ): Promise<{ - repositories: string[]; - hasMore: boolean; - }> { - const urlPath = `/api/users/@me/integrations/github/${installationId}/repos/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - url.searchParams.set("offset", String(offset)); - url.searchParams.set("limit", String(limit)); - if (search?.trim()) { - url.searchParams.set("search", search.trim()); - } - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: urlPath, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch personal GitHub repositories: ${response.statusText}`, - ); - } - - const data = await response.json(); - return { - repositories: this.normalizeGithubRepositories(data), - hasMore: data.has_more ?? false, - }; - } - - async refreshGithubRepositories( - integrationId: string | number, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_repos/refresh/`, - ); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: `/api/environments/${teamId}/integrations/${integrationId}/github_repos/refresh/`, - }); - - if (!response.ok) { - throw new Error( - `Failed to refresh GitHub repositories: ${response.statusText}`, - ); - } - - const data = await response.json(); - return this.normalizeGithubRepositories(data); - } - - async refreshGithubUserRepositories( - installationId: string | number, - ): Promise { - const urlPath = `/api/users/@me/integrations/github/${installationId}/repos/refresh/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: urlPath, - }); - - if (!response.ok) { - throw new Error( - `Failed to refresh personal GitHub repositories: ${response.statusText}`, - ); - } - - const data = await response.json(); - return this.normalizeGithubRepositories(data); - } - - private normalizeGithubRepositories(data: unknown): string[] { - const repos = - (data as { repositories?: unknown[] }).repositories ?? - (data as { results?: unknown[] }).results ?? - (Array.isArray(data) ? data : []); - - return (repos as (string | { full_name?: string; name?: string })[]).map( - (repo) => { - if (typeof repo === "string") return repo; - return (repo.full_name ?? repo.name ?? "").toLowerCase(); - }, - ); - } - - async getAgents() { - const teamId = await this.getTeamId(); - const url = new URL(`${this.api.baseUrl}/api/projects/${teamId}/agents/`); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/projects/${teamId}/agents/`, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch agents: ${response.statusText}`); - } - - const data = await response.json(); - return data.results ?? data ?? []; - } - - async getUsers() { - const data = (await this.api.get("/api/users/", { - query: { limit: 1000 }, - })) as unknown as { results: Schemas.User[] } | Schemas.User[]; - return Array.isArray(data) ? data : (data.results ?? []); - } - - async updateTeam(updates: { - session_recording_opt_in?: boolean; - autocapture_exceptions_opt_in?: boolean; - }): Promise { - const teamId = await this.getTeamId(); - const url = new URL(`${this.api.baseUrl}/api/projects/${teamId}/`); - const response = await this.api.fetcher.fetch({ - method: "patch", - url, - path: `/api/projects/${teamId}/`, - overrides: { - body: JSON.stringify(updates), - }, - }); - - if (!response.ok) { - const responseText = await response.text(); - let detail = responseText; - try { - const parsed = JSON.parse(responseText) as - | { detail?: string } - | Record; - if ( - typeof parsed === "object" && - parsed !== null && - "detail" in parsed && - typeof parsed.detail === "string" - ) { - detail = parsed.detail; - } else if (typeof parsed === "object" && parsed !== null) { - detail = Object.entries(parsed) - .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) - .join(", "); - } - } catch { - // keep plain text fallback - } - - throw new Error( - `Failed to update team: ${detail || response.statusText}`, - ); - } - - return await response.json(); - } - - async getSignalReport(reportId: string): Promise { - const teamId = await this.getTeamId(); - const path = `/api/projects/${teamId}/signals/reports/${reportId}/`; - const url = new URL(`${this.api.baseUrl}${path}`); - - try { - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - return (await response.json()) as SignalReport; - } catch (error) { - // The shared fetcher throws "Failed request: [] " for any - // non-2xx. Treat missing / forbidden as "not available in the current - // team" and surface other errors to the caller. - const msg = error instanceof Error ? error.message : String(error); - if (msg.includes("[404]") || msg.includes("[403]")) { - return null; - } - throw error; - } - } - - async getSignalReports( - params?: SignalReportsQueryParams, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/`, - ); - - if (params?.limit != null) { - url.searchParams.set("limit", String(params.limit)); - } - if (params?.offset != null) { - url.searchParams.set("offset", String(params.offset)); - } - if (params?.status) { - url.searchParams.set("status", params.status); - } - if (params?.ordering) { - url.searchParams.set("ordering", params.ordering); - } - if (params?.source_product) { - url.searchParams.set("source_product", params.source_product); - } - if (params?.suggested_reviewers) { - url.searchParams.set("suggested_reviewers", params.suggested_reviewers); - } - - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/projects/${teamId}/signals/reports/`, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch signal reports: ${response.statusText}`); - } - - const data = await response.json(); - return { - results: data.results ?? data ?? [], - count: data.count ?? data.results?.length ?? data?.length ?? 0, - }; - } - - async getSignalProcessingState(): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/processing/`, - ); - const path = `/api/projects/${teamId}/signals/processing/`; - - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch signal processing state: ${response.statusText}`, - ); - } - - const data = await response.json(); - return { - paused_until: - typeof data?.paused_until === "string" ? data.paused_until : null, - }; - } - - async getAvailableSuggestedReviewers( - query?: string, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/available_reviewers/`, - ); - const path = `/api/projects/${teamId}/signals/reports/available_reviewers/`; - - if (query?.trim()) { - url.searchParams.set("query", query.trim()); - } - - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch available suggested reviewers: ${response.statusText}`, - ); - } - - return parseAvailableSuggestedReviewersPayload(await response.json()); - } - - async getSignalReportSignals( - reportId: string, - ): Promise { - try { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/signals/`, - ); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/projects/${teamId}/signals/reports/${reportId}/signals/`, - }); - - if (!response.ok) { - log.warn("Signal report signals unavailable", { - reportId, - status: response.status, - }); - return { report: null, signals: [] }; - } - - const data = await response.json(); - return { - report: data.report ?? null, - signals: data.signals ?? [], - }; - } catch (error) { - log.warn("Failed to fetch signal report signals", { reportId, error }); - return { report: null, signals: [] }; - } - } - - async getSignalReportArtefacts( - reportId: string, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/artefacts/`, - ); - const path = `/api/projects/${teamId}/signals/reports/${reportId}/artefacts/`; - - try { - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - - if (!response.ok) { - const responseText = await response.text(); - const unavailableReason = - response.status === 403 - ? "forbidden" - : response.status === 404 - ? "not_found" - : "request_failed"; - - log.warn("Signal report artefacts unavailable", { - teamId, - reportId, - status: response.status, - statusText: response.statusText, - body: responseText || undefined, - }); - - return { results: [], count: 0, unavailableReason }; - } - - const data = (await response.json()) as unknown; - const parsed = parseSignalReportArtefactsPayload(data); - - if (parsed.unavailableReason) { - log.warn("Signal report artefacts payload did not match schema", { - teamId, - reportId, - }); - } - - return parsed; - } catch (error) { - log.warn("Failed to fetch signal report artefacts", { - teamId, - reportId, - error, - }); - return { - results: [], - count: 0, - unavailableReason: "request_failed", - }; - } - } - - async updateSignalReportState( - reportId: string, - input: - | { - state: "potential"; - snooze_for?: number; - reset_weight?: boolean; - error?: string; - } - | { - state: "suppressed"; - /** When omitted, the server suppresses without creating a dismissal artefact. */ - dismissal_reason?: DismissalReasonOptionValue; - dismissal_note?: string; - reset_weight?: boolean; - error?: string; - }, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/state/`, - ); - const path = `/api/projects/${teamId}/signals/reports/${reportId}/state/`; - - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path, - overrides: { - body: JSON.stringify(input), - }, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText || "Failed to update signal report state"); - } - - return (await response.json()) as SignalReport; - } - - async deleteSignalReport(reportId: string): Promise<{ - status: "deletion_started" | "already_running"; - report_id: string; - }> { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/`, - ); - const path = `/api/projects/${teamId}/signals/reports/${reportId}/`; - - const response = await this.api.fetcher.fetch({ - method: "delete", - url, - path, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText || "Failed to delete signal report"); - } - - return (await response.json()) as { - status: "deletion_started" | "already_running"; - report_id: string; - }; - } - - async reingestSignalReport(reportId: string): Promise<{ - status: "reingestion_started" | "already_running"; - report_id: string; - }> { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/reingest/`, - ); - const path = `/api/projects/${teamId}/signals/reports/${reportId}/reingest/`; - - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path, - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText || "Failed to reingest signal report"); - } - - return (await response.json()) as { - status: "reingestion_started" | "already_running"; - report_id: string; - }; - } - - async getSignalReportTasks( - reportId: string, - options?: { relationship?: SignalReportTask["relationship"] }, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/tasks/`, - ); - if (options?.relationship) { - url.searchParams.set("relationship", options.relationship); - } - const path = `/api/projects/${teamId}/signals/reports/${reportId}/tasks/`; - - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch signal report tasks: ${response.statusText}`, - ); - } - - const data = await response.json(); - return data.results ?? []; - } - - async getSignalTeamConfig(): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/config/`, - ); - const path = `/api/projects/${teamId}/signals/config/`; - - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch signal team config: ${response.statusText}`, - ); - } - - return (await response.json()) as SignalTeamConfig; - } - - async updateSignalTeamConfig(updates: { - default_autostart_priority: string; - }): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/config/`, - ); - const path = `/api/projects/${teamId}/signals/config/`; - - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path, - overrides: { - body: JSON.stringify(updates), - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to update signal team config: ${response.statusText}`, - ); - } - - return (await response.json()) as SignalTeamConfig; - } - - async getSignalUserAutonomyConfig(): Promise { - const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); - const path = "/api/users/@me/signal_autonomy/"; - - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - - return (await response.json()) as SignalUserAutonomyConfig; - } - - async updateSignalUserAutonomyConfig( - updates: Partial<{ - autostart_priority: string | null; - slack_notification_integration_id: number | null; - slack_notification_channel: string | null; - slack_notification_min_priority: string | null; - }>, - ): Promise { - const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); - const path = "/api/users/@me/signal_autonomy/"; - - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path, - overrides: { - body: JSON.stringify(updates), - }, - }); - - if (!response.ok) { - throw new Error( - `Failed to update signal user autonomy config: ${response.statusText}`, - ); - } - return (await response.json()) as SignalUserAutonomyConfig; - } - - async getSlackChannelsForIntegration( - integrationId: number, - params?: SlackChannelsQueryParams, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/channels/`, - ); - const search = params?.search?.trim(); - if (search) { - url.searchParams.set("search", search); - } - if (params?.limit != null) { - url.searchParams.set("limit", String(params.limit)); - } - if (params?.offset != null) { - url.searchParams.set("offset", String(params.offset)); - } - if (params?.channelId) { - url.searchParams.set("channel_id", params.channelId); - } - const path = `/api/environments/${teamId}/integrations/${integrationId}/channels/${url.search}`; - - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch Slack channels: ${response.statusText}`); - } - return (await response.json()) as SlackChannelsResponse; - } - - async deleteSignalUserAutonomyConfig(): Promise { - const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); - const path = "/api/users/@me/signal_autonomy/"; - - const response = await this.api.fetcher.fetch({ - method: "delete", - url, - path, - }); - - if (!response.ok) { - throw new Error( - `Failed to delete signal user autonomy config: ${response.statusText}`, - ); - } - } - - async getMcpServers(): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/mcp_servers/`, - ); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/environments/${teamId}/mcp_servers/`, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch MCP servers: ${response.statusText}`); - } - - const data = await response.json(); - return data.results ?? data ?? []; - } - - async getMcpServerInstallations(): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/`, - ); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/environments/${teamId}/mcp_server_installations/`, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch MCP server installations: ${response.statusText}`, - ); - } - - const data = await response.json(); - return data.results ?? data ?? []; - } - - async installCustomMcpServer(options: { - name: string; - url: string; - auth_type: McpAuthType; - api_key?: string; - description?: string; - client_id?: string; - client_secret?: string; - install_source?: "posthog" | "posthog-code"; - posthog_code_callback_url?: string; - }): Promise { - const teamId = await this.getTeamId(); - const apiUrl = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/install_custom/`, - ); - const response = await this.api.fetcher.fetch({ - method: "post", - url: apiUrl, - path: `/api/environments/${teamId}/mcp_server_installations/install_custom/`, - overrides: { - body: JSON.stringify(options), - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - (errorData as { detail?: string }).detail ?? - `Failed to install MCP server: ${response.statusText}`, - ); - } - - return await response.json(); - } - - async updateMcpServerInstallation( - installationId: string, - updates: { - display_name?: string; - description?: string; - is_enabled?: boolean; - }, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/${installationId}/`, - ); - const response = await this.api.fetcher.fetch({ - method: "patch", - url, - path: `/api/environments/${teamId}/mcp_server_installations/${installationId}/`, - overrides: { - body: JSON.stringify(updates), - }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - (errorData as { detail?: string }).detail ?? - `Failed to update MCP server: ${response.statusText}`, - ); - } - - return await response.json(); - } - - async uninstallMcpServer(installationId: string): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/${installationId}/`, - ); - const response = await this.api.fetcher.fetch({ - method: "delete", - url, - path: `/api/environments/${teamId}/mcp_server_installations/${installationId}/`, - }); - - if (!response.ok && response.status !== 204) { - throw new Error(`Failed to uninstall MCP server: ${response.statusText}`); - } - } - - async installMcpTemplate(options: { - template_id: string; - api_key?: string; - install_source?: "posthog" | "posthog-code"; - posthog_code_callback_url?: string; - }): Promise { - const teamId = await this.getTeamId(); - const path = `/api/environments/${teamId}/mcp_server_installations/install_template/`; - const response = await this.api.fetcher.fetch({ - method: "post", - url: new URL(`${this.api.baseUrl}${path}`), - path, - overrides: { body: JSON.stringify(options) }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - (errorData as { detail?: string }).detail ?? - `Failed to install MCP template: ${response.statusText}`, - ); - } - - return await response.json(); - } - - async authorizeMcpInstallation(options: { - installation_id: string; - install_source?: "posthog" | "posthog-code"; - posthog_code_callback_url?: string; - }): Promise { - const teamId = await this.getTeamId(); - const path = `/api/environments/${teamId}/mcp_server_installations/authorize/`; - const url = new URL(`${this.api.baseUrl}${path}`); - url.searchParams.set("installation_id", options.installation_id); - if (options.install_source) { - url.searchParams.set("install_source", options.install_source); - } - if (options.posthog_code_callback_url) { - url.searchParams.set( - "posthog_code_callback_url", - options.posthog_code_callback_url, - ); - } - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - (errorData as { detail?: string }).detail ?? - `Failed to authorize MCP installation: ${response.statusText}`, - ); - } - - return await response.json(); - } - - async getMcpInstallationTools( - installationId: string, - options: { includeRemoved?: boolean } = {}, - ): Promise { - const teamId = await this.getTeamId(); - const path = `/api/environments/${teamId}/mcp_server_installations/${installationId}/tools/`; - const url = new URL(`${this.api.baseUrl}${path}`); - if (options.includeRemoved) { - url.searchParams.set("include_removed", "1"); - } - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch MCP installation tools: ${response.statusText}`, - ); - } - - const data = await response.json(); - return data.results ?? data ?? []; - } - - async updateMcpToolApproval( - installationId: string, - toolName: string, - approval_state: McpApprovalState, - ): Promise { - const teamId = await this.getTeamId(); - const path = `/api/environments/${teamId}/mcp_server_installations/${installationId}/tools/${encodeURIComponent(toolName)}/`; - const response = await this.api.fetcher.fetch({ - method: "patch", - url: new URL(`${this.api.baseUrl}${path}`), - path, - overrides: { body: JSON.stringify({ approval_state }) }, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - (errorData as { detail?: string }).detail ?? - `Failed to update tool approval: ${response.statusText}`, - ); - } - - return await response.json(); - } - - async refreshMcpInstallationTools( - installationId: string, - ): Promise { - const teamId = await this.getTeamId(); - const path = `/api/environments/${teamId}/mcp_server_installations/${installationId}/tools/refresh/`; - const response = await this.api.fetcher.fetch({ - method: "post", - url: new URL(`${this.api.baseUrl}${path}`), - path, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - (errorData as { detail?: string }).detail ?? - `Failed to refresh MCP tools: ${response.statusText}`, - ); - } - - const data = await response.json(); - return data.results ?? data ?? []; - } - - async getMySeat( - options: { best?: boolean } = { best: true }, - ): Promise { - try { - const url = new URL(`${this.api.baseUrl}/api/seats/me/`); - url.searchParams.set("product_key", SEAT_PRODUCT_KEY); - if (options.best) { - url.searchParams.set("best", "true"); - } - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: "/api/seats/me/", - }); - return (await response.json()) as SeatData; - } catch (error) { - if (this.isFetcherStatusError(error, 404)) { - return null; - } - throw error; - } - } - - async createSeat(planKey: string): Promise { - try { - const user = await this.getCurrentUser(); - const distinctId = user.distinct_id; - if (!distinctId) { - throw new Error("Cannot create seat: user has no distinct_id"); - } - const url = new URL(`${this.api.baseUrl}/api/seats/`); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: "/api/seats/", - overrides: { - body: JSON.stringify({ - product_key: SEAT_PRODUCT_KEY, - plan_key: planKey, - user_distinct_id: distinctId, - }), - }, - }); - return (await response.json()) as SeatData; - } catch (error) { - this.throwSeatError(error); - } - } - - async upgradeSeat(planKey: string): Promise { - try { - const url = new URL(`${this.api.baseUrl}/api/seats/me/`); - const response = await this.api.fetcher.fetch({ - method: "patch", - url, - path: "/api/seats/me/", - overrides: { - body: JSON.stringify({ - product_key: SEAT_PRODUCT_KEY, - plan_key: planKey, - }), - }, - }); - return (await response.json()) as SeatData; - } catch (error) { - this.throwSeatError(error); - } - } - - async cancelSeat(): Promise { - try { - const url = new URL(`${this.api.baseUrl}/api/seats/me/`); - url.searchParams.set("product_key", SEAT_PRODUCT_KEY); - await this.api.fetcher.fetch({ - method: "delete", - url, - path: "/api/seats/me/", - }); - } catch (error) { - if (this.isFetcherStatusError(error, 204)) { - return; - } - this.throwSeatError(error); - } - } - - async reactivateSeat(): Promise { - try { - const url = new URL(`${this.api.baseUrl}/api/seats/me/reactivate/`); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: "/api/seats/me/reactivate/", - overrides: { - body: JSON.stringify({ product_key: SEAT_PRODUCT_KEY }), - }, - }); - return (await response.json()) as SeatData; - } catch (error) { - this.throwSeatError(error); - } - } - - private isFetcherStatusError(error: unknown, status: number): boolean { - return error instanceof Error && error.message.includes(`[${status}]`); - } - - private parseFetcherError(error: unknown): { - status: number; - body: Record; - } | null { - if (!(error instanceof Error)) return null; - const match = error.message.match(/\[(\d+)\]\s*(.*)/); - if (!match) return null; - try { - return { - status: Number.parseInt(match[1], 10), - body: JSON.parse(match[2]) as Record, - }; - } catch { - return { status: Number.parseInt(match[1], 10), body: {} }; - } - } - - private throwSeatError(error: unknown): never { - const parsed = this.parseFetcherError(error); - - if (parsed) { - if ( - parsed.status === 400 && - typeof parsed.body.redirect_url === "string" - ) { - throw new SeatSubscriptionRequiredError(parsed.body.redirect_url); - } - if (parsed.status === 402) { - const message = - typeof parsed.body.error === "string" ? parsed.body.error : undefined; - throw new SeatPaymentFailedError(message); - } - } - - throw error; - } - - /** - * Check if a feature flag is enabled for the current project. - * Returns true if the flag exists and is active, false otherwise. - */ - async isFeatureFlagEnabled(flagKey: string): Promise { - try { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/feature_flags/`, - ); - url.searchParams.set("key", flagKey); - - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/projects/${teamId}/feature_flags/`, - }); - - if (!response.ok) { - log.warn(`Failed to fetch feature flags: ${response.statusText}`); - return false; - } - - const data = await response.json(); - const flags = data.results ?? data ?? []; - const flag = flags.find( - (f: { key: string; active: boolean }) => f.key === flagKey, - ); - - return flag?.active ?? false; - } catch (error) { - log.warn(`Error checking feature flag "${flagKey}":`, error); - return false; - } - } - - // Sandbox Environments - - async listSandboxEnvironments(): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/sandbox_environments/`, - ); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: `/api/projects/${teamId}/sandbox_environments/`, - }); - if (!response.ok) { - throw new Error( - `Failed to fetch sandbox environments: ${response.statusText}`, - ); - } - const data = await response.json(); - return (data.results ?? data) as SandboxEnvironment[]; - } - - async createSandboxEnvironment( - input: SandboxEnvironmentInput, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/sandbox_environments/`, - ); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: `/api/projects/${teamId}/sandbox_environments/`, - overrides: { - body: JSON.stringify(input), - }, - }); - if (!response.ok) { - throw new Error( - `Failed to create sandbox environment: ${response.statusText}`, - ); - } - return (await response.json()) as SandboxEnvironment; - } - - async updateSandboxEnvironment( - id: string, - input: Partial, - ): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/sandbox_environments/${id}/`, - ); - const response = await this.api.fetcher.fetch({ - method: "patch", - url, - path: `/api/projects/${teamId}/sandbox_environments/${id}/`, - overrides: { - body: JSON.stringify(input), - }, - }); - if (!response.ok) { - throw new Error( - `Failed to update sandbox environment: ${response.statusText}`, - ); - } - return (await response.json()) as SandboxEnvironment; - } - - async deleteSandboxEnvironment(id: string): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/sandbox_environments/${id}/`, - ); - const response = await this.api.fetcher.fetch({ - method: "delete", - url, - path: `/api/projects/${teamId}/sandbox_environments/${id}/`, - }); - if (!response.ok) { - throw new Error( - `Failed to delete sandbox environment: ${response.statusText}`, - ); - } - } - - /** Find an exported asset by session recording ID. */ - async findExportBySessionRecordingId( - projectId: number, - sessionRecordingId: string, - ): Promise { - const urlPath = `/api/projects/${projectId}/exports/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - url.searchParams.set("session_recording_id", sessionRecordingId); - url.searchParams.set("export_format", "video/mp4"); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: urlPath, - }); - if (!response.ok) return null; - const data = (await response.json()) as { - results?: Array<{ id: number; has_content: boolean }>; - }; - const match = data.results?.find((e) => e.has_content); - return match?.id ?? null; - } - - /** Get the presigned content URL for an exported asset (e.g. rasterized recording). */ - async getExportContentUrl( - projectId: number, - exportId: number, - ): Promise { - const urlPath = `/api/projects/${projectId}/exports/${exportId}/content/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: urlPath, - }); - if (!response.ok) return null; - const blob = await response.blob(); - return URL.createObjectURL(blob); - } - - /** - * Fetch the requesting user's personal LLM spend analysis. `dateFrom` / `dateTo` - * accept absolute dates (`2026-04-23`) or relative strings (`-7d`, `-1m`), and - * default to the last 30 days. When `product` is set the tool / model / trace - * breakdowns are scoped to that `ai_product` (e.g. `posthog_code`); when omitted - * they aggregate across every product. - */ - async getPersonalSpendAnalysis( - options: { dateFrom?: string; dateTo?: string; product?: string } = {}, - ): Promise { - const { dateFrom = "-30d", dateTo, product } = options; - const urlPath = `/api/llm_analytics/@me/spend/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - url.searchParams.set("date_from", dateFrom); - if (dateTo) { - url.searchParams.set("date_to", dateTo); - } - if (product) { - url.searchParams.set("product", product); - } - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path: urlPath, - }); - if (!response.ok) { - throw new Error(`Failed to fetch spend analysis: ${response.status}`); - } - return (await response.json()) as SpendAnalysisResponse; - } -} 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/CodeBlock.test.tsx b/apps/code/src/renderer/components/CodeBlock.test.tsx index efc1c12950..f695ac409e 100644 --- a/apps/code/src/renderer/components/CodeBlock.test.tsx +++ b/apps/code/src/renderer/components/CodeBlock.test.tsx @@ -3,15 +3,15 @@ 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"; +import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; +import { HighlightedCode } from "@posthog/ui/primitives/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/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/ErrorBoundary.test.tsx b/apps/code/src/renderer/components/ErrorBoundary.test.tsx index 9dfe6b7953..8ebdb8c252 100644 --- a/apps/code/src/renderer/components/ErrorBoundary.test.tsx +++ b/apps/code/src/renderer/components/ErrorBoundary.test.tsx @@ -1,5 +1,5 @@ import { Theme } from "@radix-ui/themes"; -import { isNotAuthenticatedError, NotAuthenticatedError } from "@shared/errors"; +import { isNotAuthenticatedError, NotAuthenticatedError } from "@posthog/shared"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; diff --git a/apps/code/src/renderer/components/ErrorBoundary.tsx b/apps/code/src/renderer/components/ErrorBoundary.tsx index 4dabd1f96f..22eeb0f218 100644 --- a/apps/code/src/renderer/components/ErrorBoundary.tsx +++ b/apps/code/src/renderer/components/ErrorBoundary.tsx @@ -1,101 +1,43 @@ -import { Warning } from "@phosphor-icons/react"; -import { Box, Button, Callout, Flex, Text } from "@radix-ui/themes"; +import { + type ErrorBoundaryProps, + ErrorBoundary as UiErrorBoundary, +} from "@posthog/ui/primitives/ErrorBoundary"; 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 { - children: ReactNode; - fallback?: ReactNode; - /** Optional name to identify which boundary caught the error */ - name?: string; - /** When this value changes, the boundary clears its error state. */ - 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). - */ - shouldSuppress?: (error: Error) => boolean; -} - -interface State { - error: Error | null; - lastResetKey: unknown; -} - -export class ErrorBoundary extends Component { - state: State = { error: null, lastResetKey: this.props.resetKey }; - - static getDerivedStateFromError(error: Error): Partial { - return { error }; - } - - static getDerivedStateFromProps( - props: Props, - state: State, - ): Partial | null { - if (props.resetKey === state.lastResetKey) return null; - return { error: null, lastResetKey: props.resetKey }; - } - - 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, - componentStack: errorInfo.componentStack, - }); - - captureException(error, { - $exception_component_stack: errorInfo.componentStack, - boundary_name: this.props.name, - source: "error-boundary", - }); - } - - handleRetry = () => { - this.setState({ error: null }); - }; - - render() { - const { error } = this.state; - if (!error) return this.props.children; - if (this.props.shouldSuppress?.(error)) return null; - if (this.props.fallback) return this.props.fallback; - - return ( - - - - - - - - Something went wrong - - {error.message || "An unexpected error occurred"} - - - - - - - - - ); - } +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 ( + { + 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/apps/code/src/renderer/components/FullScreenLayout.tsx b/apps/code/src/renderer/components/FullScreenLayout.tsx index 6f6725d678..a049f82a5d 100644 --- a/apps/code/src/renderer/components/FullScreenLayout.tsx +++ b/apps/code/src/renderer/components/FullScreenLayout.tsx @@ -1,12 +1,8 @@ -import { UpdateBanner } from "@features/sidebar/components/UpdateBanner"; -import { Lifebuoy } from "@phosphor-icons/react"; -import { Button, Flex, Theme } from "@radix-ui/themes"; +import { UpdateBanner } from "@posthog/ui/features/sidebar/components/UpdateBanner"; +import { FullScreenLayout as UiFullScreenLayout } from "@posthog/ui/primitives/FullScreenLayout"; import { trpcClient } from "@renderer/trpc/client"; -import { useThemeStore } from "@stores/themeStore"; -import { EXTERNAL_LINKS } from "@utils/links"; +import { EXTERNAL_LINKS } from "@posthog/shared"; import type { ReactNode } from "react"; -import { DotPatternBackground } from "./DotPatternBackground"; -import { DraggableTitleBar } from "./DraggableTitleBar"; interface FullScreenLayoutProps { children: ReactNode; @@ -14,70 +10,16 @@ interface FullScreenLayoutProps { footerRight?: ReactNode; } -export function FullScreenLayout({ - children, - footerLeft, - footerRight, -}: FullScreenLayoutProps) { - const isDarkMode = useThemeStore((state) => state.isDarkMode); - +// PORT NOTE: real layout is @posthog/ui/primitives/FullScreenLayout; this app +// wrapper injects the host update banner + support-link opener. +export function FullScreenLayout(props: FullScreenLayoutProps) { return ( - - - - -
- - - - - {children} - - - - {footerLeft ?? ( - - - - - )} - {footerRight ??
} - - - - + } + onOpenSupport={() => + trpcClient.os.openExternal.mutate({ url: EXTERNAL_LINKS.discord }) + } + /> ); } diff --git a/apps/code/src/renderer/components/GlobalEventHandlers.tsx b/apps/code/src/renderer/components/GlobalEventHandlers.tsx index 2e7fe4c763..5545c1cd08 100644 --- a/apps/code/src/renderer/components/GlobalEventHandlers.tsx +++ b/apps/code/src/renderer/components/GlobalEventHandlers.tsx @@ -1,22 +1,26 @@ -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 { + SESSION_SERVICE, + type SessionService, +} from "@posthog/core/sessions/sessionService"; +import { useService } from "@posthog/di/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 { 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 { shipIt } from "@posthog/ui/primitives/confetti"; +import { clearApplicationStorage } from "@posthog/ui/utils/clearStorage"; +import { useCommandMenuStore } from "@posthog/ui/workbench/commandMenuStore"; import { useTRPC } from "@renderer/trpc"; -import type { Task } from "@shared/types"; -import { useCommandMenuStore } from "@stores/commandMenuStore"; -import { useNavigationStore } from "@stores/navigationStore"; 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"; @@ -31,6 +35,7 @@ export function GlobalEventHandlers({ onToggleShortcutsSheet, }: GlobalEventHandlersProps) { const trpcReact = useTRPC(); + const sessionService = useService(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/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 ( -
- ); -} diff --git a/apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx b/apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx deleted file mode 100644 index c5e973bf09..0000000000 --- a/apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { Box, Dialog, Flex, Text } from "@radix-ui/themes"; -import { - CATEGORY_LABELS, - formatHotkeyParts, - getShortcutsByCategory, - type ShortcutCategory, -} from "@renderer/constants/keyboard-shortcuts"; -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 - 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} - - ); -} - -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 ( - - e.preventDefault()} - className="max-h-[80vh] overflow-hidden" - > - - - - - - - - - - - ); -} - -function ShortcutsHeader() { - const triggerParts = formatHotkeyParts("mod+/"); - - return ( - - - - Keyboard Combos - - - {triggerParts.map((part) => ( - - ))} - - - - Your cheat codes for shipping faster - - - ); -} - -export function KeyboardShortcutsList() { - const shortcutsByCategory = useMemo(() => getShortcutsByCategory(), []); - - const categoryOrder: ShortcutCategory[] = [ - "general", - "navigation", - "panels", - "editor", - ]; - - return ( - - {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 ( - - - {CATEGORY_LABELS[category]} - - - {uniqueShortcuts.map((shortcut) => ( - - {shortcut.description} - - - ))} - - - ); - })} - - ); -} - -function SingleShortcutKeys({ keys }: { keys: string }) { - const parts = formatHotkeyParts(keys); - - return ( - - {parts.map((part) => ( - - ))} - - ); -} - -function ShortcutKeys({ - keys, - alternateKeys, -}: { - keys: string; - alternateKeys?: string; -}) { - if (!alternateKeys) { - return ; - } - - return ( - - - - or - - - - ); -} diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/components/MainLayout.tsx deleted file mode 100644 index ff1d04eecf..0000000000 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ /dev/null @@ -1,205 +0,0 @@ -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 { 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"; - -const log = logger.scope("main-layout"); - -export function MainLayout() { - const { - view, - hydrateTask, - navigateToTaskInput, - navigateToTask, - taskInputReportAssociation, - taskInputCloudRepository, - } = useNavigationStore(); - const { - isOpen: commandMenuOpen, - setOpen: setCommandMenuOpen, - toggle: toggleCommandMenu, - } = useCommandMenuStore(); - const { - isOpen: shortcutsSheetOpen, - toggle: toggleShortcutsSheet, - close: closeShortcutsSheet, - } = useShortcutsSheetStore(); - const { data: tasks } = useTasks(); - const { data: workspaces, isFetched: workspacesFetched } = useWorkspaces(); - const trpcReact = useTRPC(); - const queryClient = useQueryClient(); - const reconcilingTaskIds = useRef>(new Set()); - const billingEnabled = useFeatureFlag(BILLING_FLAG); - const syncCloudTasksEnabled = useFeatureFlag(SYNC_CLOUD_TASKS_FLAG); - - // Space switcher data - const sidebarData = useSidebarData({ activeView: view }); - const visualTaskOrder = useVisualTaskOrder(sidebarData); - const activeTaskId = - view.type === "task-detail" && view.data ? view.data.id : null; - - useIntegrations(); - useTaskDeepLink(); - useInboxDeepLink(); - useSetupDiscovery(); - useNewTaskDeepLink(); - - useEffect(() => { - if (tasks) { - hydrateTask(tasks); - } - }, [tasks, hydrateTask]); - - useEffect(() => { - if (!syncCloudTasksEnabled) return; - if (!tasks || !workspaces || !workspacesFetched) return; - const missing = tasks.filter( - (t) => - t.latest_run?.environment === "cloud" && - !workspaces[t.id] && - !reconcilingTaskIds.current.has(t.id), - ); - if (missing.length === 0) return; - const missingIds = missing.map((t) => t.id); - 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) - .then((result) => { - for (const id of missingIds) reconcilingTaskIds.current.delete(id); - if (result.created.length > 0) { - void queryClient.invalidateQueries( - trpcReact.workspace.getAll.pathFilter(), - ); - } - }) - .catch((err) => { - for (const id of missingIds) reconcilingTaskIds.current.delete(id); - log.warn("Failed to reconcile cloud workspaces", err); - }); - }, [ - syncCloudTasksEnabled, - tasks, - workspaces, - workspacesFetched, - queryClient, - trpcReact, - ]); - - useEffect(() => { - if (view.type === "task-detail" && !view.data && !view.taskId) { - navigateToTaskInput(); - } - }, [view, navigateToTaskInput]); - - const handleToggleCommandMenu = useCallback(() => { - toggleCommandMenu(); - }, [toggleCommandMenu]); - - return ( - - - - - - - {view.type === "task-input" && ( - - )} - - {view.type === "task-detail" && view.data && ( - - )} - - {view.type === "task-pending" && view.pendingTaskKey && ( - - )} - - {view.type === "folder-settings" && } - - {view.type === "inbox" && } - - {view.type === "archived" && } - - {view.type === "command-center" && } - - {view.type === "skills" && } - - {view.type === "mcp-servers" && } - - - - - - (open ? null : closeShortcutsSheet())} - /> - - - - {billingEnabled && } - - - ); -} 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/permissions/types.ts b/apps/code/src/renderer/components/permissions/types.ts deleted file mode 100644 index ba7cd12c84..0000000000 --- a/apps/code/src/renderer/components/permissions/types.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { - PermissionOption, - RequestPermissionRequest, - ToolCallContent, -} from "@agentclientprotocol/sdk"; -import type { SelectorOption } from "@components/ActionSelector"; -import type { CodeToolKind } from "@features/sessions/types"; - -type AcpToolCall = RequestPermissionRequest["toolCall"]; -export type PermissionToolCall = Omit & { - kind?: CodeToolKind | null; -}; - -export interface BasePermissionProps { - toolCall: PermissionToolCall; - options: PermissionOption[]; - onSelect: ( - optionId: string, - customInput?: string, - answers?: Record, - ) => void; - onCancel: () => void; -} - -export function toSelectorOptions( - options: PermissionOption[], -): SelectorOption[] { - return options.map((opt) => { - const meta = opt._meta as - | { description?: string; customInput?: boolean } - | undefined; - return { - id: opt.optionId, - label: opt.name, - description: meta?.description, - customInput: meta?.customInput, - }; - }); -} - -export { - type DiffContent, - findDiffContent, -} from "@features/sessions/components/session-update/toolCallUtils"; -export type TerminalContent = Extract; -export type StandardContent = Extract; - -export function findTerminalContent( - content: ToolCallContent[] | null | undefined, -): TerminalContent | undefined { - return content?.find((c): c is TerminalContent => c.type === "terminal"); -} - -export function findTextContent( - content: ToolCallContent[] | null | undefined, -): string | undefined { - const stdContent = content?.find( - (c): c is StandardContent => c.type === "content", - ); - if (stdContent?.content.type === "text") { - return stdContent.content.text; - } - return undefined; -} diff --git a/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.test.ts b/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.test.ts index 81a3670ea8..abfb56df30 100644 --- a/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.test.ts +++ b/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.test.ts @@ -1,6 +1,6 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useComboboxFilter } from "./useComboboxFilter"; +import { useComboboxFilter } from "@posthog/ui/primitives/combobox/useComboboxFilter"; describe("useComboboxFilter", () => { beforeEach(() => { 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..010b880720 --- /dev/null +++ b/apps/code/src/renderer/contributions/app-boot.contributions.ts @@ -0,0 +1,34 @@ +import type { WorkbenchContribution } from "@posthog/di/contribution"; +import { trpcClient } from "@renderer/trpc/client"; +import { initializePostHog, registerAppVersion } from "@utils/analytics"; +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("@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..ee9b028e18 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -1,6 +1,46 @@ +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 { 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 { 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, + fileWatcherUiModule, + focusUiModule, + inboxCoreModule, + notificationsUiModule, + onboardingModule, + provisioningUiModule, + setupCoreModule, + 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..8e72a18ad8 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -1,3 +1,203 @@ // 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 } from "@posthog/core/archive/identifiers"; +import { + SEAT_CLIENT, + type SeatClient, +} from "@posthog/core/billing/identifiers"; +import { + LINEAR_OAUTH_FLOW, + REPORT_MODEL_RESOLVER, +} from "@posthog/core/inbox/identifiers"; +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 { + type ISetupRunService, + SETUP_RUN_SERVICE, + SETUP_STORE, +} from "@posthog/core/setup/identifiers"; +import { resolveService } from "@posthog/di/container"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { HOST_TRPC_CLIENT } from "@posthog/host-router/client"; +import { NOTIFICATIONS_SERVICE } from "@posthog/platform/notifications"; +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 { MESSAGE_EDITOR_HOST } from "@posthog/ui/features/message-editor/identifiers"; +import { useNavigationStore } from "@posthog/ui/features/navigation/store"; +import { NAVIGATION_TASK_BINDER } from "@posthog/ui/features/navigation/taskBinder"; +import { + ACTIVE_VIEW_PROVIDER, + type IActiveView, + type INotificationSettings, + NOTIFICATION_SETTINGS_PROVIDER, +} from "@posthog/ui/features/notifications/identifiers"; +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 { IMPERATIVE_QUERY_CLIENT } from "@posthog/ui/workbench/queryClient"; +import { container } from "@renderer/di/container"; +import { createArchiveClient } from "@renderer/platform-adapters/archive"; +import { RendererAuthSideEffects } from "@renderer/platform-adapters/auth-side-effects"; +import { RendererSeatClient } from "@renderer/platform-adapters/billing-client"; +import { RendererFeatureFlags } from "@renderer/platform-adapters/feature-flags"; +import { TrpcFileWatcherControl } from "@renderer/platform-adapters/file-watcher-control"; +import { gitCacheKeyProvider } from "@renderer/platform-adapters/git-cache-keys"; +import { RendererGithubConnectClient } from "@renderer/platform-adapters/github-connect-client"; +import { RendererHedgehogModeHost } from "@renderer/platform-adapters/hedgehog-mode-host"; +import { RendererRepositoriesClient } from "@renderer/platform-adapters/integrations-client"; +import { RendererLinearOAuthFlow } from "@renderer/platform-adapters/linear-oauth-flow"; +import { messageEditorHost } from "@renderer/platform-adapters/message-editor-host"; +import { navigationTaskBinder } from "@renderer/platform-adapters/navigation-task-binder"; +import { TrpcNotificationsService } from "@renderer/platform-adapters/notifications"; +import { RendererReportModelResolver } from "@renderer/platform-adapters/report-model-resolver"; +import { setupStore } from "@renderer/platform-adapters/setup"; +import { RendererSetupRunService } from "@renderer/platform-adapters/setup-run-service"; +import { initTours } from "@renderer/platform-adapters/tour"; +import { hostTrpcClient } 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); + +container.bind(HOST_TRPC_CLIENT).toConstantValue(hostTrpcClient); + +// archive +container.load(archiveModule); +container + .bind(ARCHIVE_CLIENT) + .toConstantValue(createArchiveClient(hostTrpcClient)); + +// inbox host capabilities +container + .bind(REPORT_MODEL_RESOLVER) + .toConstantValue(new RendererReportModelResolver()); +container + .bind(LINEAR_OAUTH_FLOW) + .toConstantValue(new RendererLinearOAuthFlow()); + +// onboarding +container + .bind(GITHUB_CONNECT_CLIENT) + .toConstantValue(new RendererGithubConnectClient()); + +// integrations +container + .bind(REPOSITORIES_CLIENT) + .toConstantValue(new RendererRepositoriesClient()); +container.bind(REPOSITORIES_SERVICE).to(RepositoriesService).inSingletonScope(); + +container + .bind(SEAT_CLIENT) + .toConstantValue(new RendererSeatClient()); +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(MESSAGE_EDITOR_HOST).toConstantValue(messageEditorHost); +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); + +container + .bind(NOTIFICATIONS_SERVICE) + .to(TrpcNotificationsService) + .inSingletonScope(); + +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) + .to(TrpcFileWatcherControl) + .inSingletonScope(); + +container + .bind(FEATURE_FLAGS) + .to(RendererFeatureFlags) + .inSingletonScope(); + +container + .bind(AUTH_SIDE_EFFECTS) + .to(RendererAuthSideEffects) + .inSingletonScope(); + +container + .bind(SETUP_RUN_SERVICE) + .to(RendererSetupRunService) + .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..1650018221 100644 --- a/apps/code/src/renderer/di/container.ts +++ b/apps/code/src/renderer/di/container.ts @@ -1,9 +1,122 @@ import "reflect-metadata"; -import { SetupRunService } from "@features/setup/services/setupRunService"; -import { TaskService } from "@features/task-detail/service/service"; +import { McpToolBlock } from "@features/sessions/components/session-update/McpToolBlock"; +import { getSessionService } from "@features/sessions/service/service"; import type { TrpcRouter } from "@main/trpc/router"; +import { + CODE_REVIEW_WORKSPACE_CLIENT, + REVERT_HUNK_SERVICE, +} from "@posthog/core/code-review/identifiers"; +import { RevertHunkService } from "@posthog/core/code-review/revertHunkService"; +import { + GITHUB_ISSUE_CLIENT, + 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, +} from "@posthog/core/external-apps/identifiers"; +import type { FocusControllerDeps } from "@posthog/core/focus/service"; +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 { 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 } 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 { + REVIEW_HOST, + type ReviewHost, +} from "@posthog/ui/features/code-review/reviewHost"; +import { FocusStoreCoordinator } from "@posthog/ui/features/external-apps/focusCoordinator"; +import { FOCUS_CONTROLLER_DEPS } from "@posthog/ui/features/focus/focusClient"; +import { MCP_TOOL_BLOCK_COMPONENT } from "@posthog/ui/features/sessions/components/session-update/identifiers"; +import { + localHandoffDialog, + localHandoffNotifier, +} from "@posthog/ui/features/sessions/localHandoffService"; +import { + SHELL_CLIENT, + type ShellClient, +} from "@posthog/ui/features/terminal/shellClient"; +import { + UPDATES_CLIENT, + type UpdatesClient, +} 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 { + diffWorkerFactory, + reviewHost, +} from "@renderer/features/code-review/reviewHost"; +import { RendererCodeReviewWorkspaceClient } from "@renderer/platform-adapters/code-review-workspace-client"; +import { externalAppsWorkspaceClient } from "@renderer/platform-adapters/external-apps-client"; +import { + gitInteractionEffects, + gitWriteClient, +} from "@renderer/platform-adapters/git-interaction"; +import { githubIssueClient } from "@renderer/platform-adapters/github-issue-client"; +import { rendererLlmGateway } from "@renderer/platform-adapters/llm-gateway-client"; +import { localHandoffHost } from "@renderer/platform-adapters/local-handoff-host"; +import { TrpcTaskCreationHost } from "@renderer/platform-adapters/task-creation-host"; +import { + taskDeletionHost, + taskDeletionWorkspaceClient, +} from "@renderer/platform-adapters/task-deletion"; +import { taskCreationEffects } from "@renderer/platform-adapters/task-detail"; +import { shellProcessReader } from "@renderer/platform-adapters/terminal"; +import { TrpcWorkspaceSetupGitClient } from "@renderer/platform-adapters/workspace-setup"; import { trpcClient } from "@renderer/trpc"; import type { TRPCClient } from "@trpc/client"; +import { setActiveTaskAnalyticsContext, track } from "@utils/analytics"; +import { hostLog, logger } from "@utils/logger"; import { Container } from "inversify"; import { RENDERER_TOKENS } from "./tokens"; @@ -14,16 +127,226 @@ export const container = new Container({ defaultScope: "Singleton", }); +setWorkbenchContainer(container); + +container.bind(HOST_LOGGER).toConstantValue(hostLog); + // Bind infrastructure container .bind>(RENDERER_TOKENS.TRPCClient) .toConstantValue(trpcClient); +// updates client — bound after setWorkbenchContainer/trpcClient so that +// platform-adapters/updates.ts resolves UPDATES_CLIENT at its module-load time. +const updatesClient: UpdatesClient = { + install: () => trpcClient.updates.install.mutate(), + check: () => trpcClient.updates.check.mutate(), + isEnabled: () => trpcClient.updates.isEnabled.query(), + getStatus: () => trpcClient.updates.getStatus.query(), + onStatus: (sub) => trpcClient.updates.onStatus.subscribe(undefined, sub), + onReady: (sub) => + trpcClient.updates.onReady.subscribe(undefined, { + onData: (data) => sub.onData({ version: data.version }), + onError: sub.onError, + }), + onCheckFromMenu: (sub) => + trpcClient.updates.onCheckFromMenu.subscribe(undefined, { + onData: () => sub.onData(), + onError: sub.onError, + }), +}; +container.bind(UPDATES_CLIENT).toConstantValue(updatesClient); + +// 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 +const focusDeps: FocusControllerDeps = { + 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 }), +}; +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); + +// terminal shell process reader + core module +container.bind(SHELL_PROCESS_READER).toConstantValue(shellProcessReader); +container.load(terminalCoreModule); + +// analytics tracker +container.bind(ANALYTICS_TRACKER).toConstantValue({ + track, + setActiveTaskContext: setActiveTaskAnalyticsContext, +}); + // 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(localHandoffHost); +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(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).to(TrpcWorkspaceSetupGitClient); +container + .bind(WORKSPACE_SETUP_SERVICE) + .to(WorkspaceSetupService) + .inSingletonScope(); + +// deep-links +container.bind(GITHUB_ISSUE_CLIENT).toConstantValue(githubIssueClient); +container + .bind(NEW_TASK_LINK_RESOLVER) + .to(NewTaskLinkResolver) + .inSingletonScope(); + +// code-review +container + .bind(CODE_REVIEW_WORKSPACE_CLIENT) + .toConstantValue(new RendererCodeReviewWorkspaceClient()); +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(rendererLlmGateway); +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/archive/components/ArchivedTasksView.tsx b/apps/code/src/renderer/features/archive/components/ArchivedTasksView.tsx deleted file mode 100644 index 1dc7f35316..0000000000 --- a/apps/code/src/renderer/features/archive/components/ArchivedTasksView.tsx +++ /dev/null @@ -1,625 +0,0 @@ -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, - Check, - Cloud as CloudIcon, - GitBranch as GitBranchIcon, - Laptop as LaptopIcon, - MagnifyingGlass, -} from "@phosphor-icons/react"; -import { - AlertDialog, - Box, - Button, - Dialog, - Flex, - Popover, - Table, - 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 { 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() ?? "—"; -} - -const ICON_SIZE = 12; - -function ModeIcon({ mode }: { mode: WorkspaceMode }) { - if (mode === "cloud") { - return ( - - - - - - ); - } - if (mode === "worktree") { - return ( - - - - - - ); - } - return ( - - - - - - ); -} - -type SortColumn = "created" | "archived"; -type SortDirection = "asc" | "desc"; - -interface SortState { - column: SortColumn; - direction: SortDirection; -} - -function SortableColumnHeader({ - column, - label, - sort, - onSort, - width, -}: { - column: SortColumn; - label: string; - sort: SortState; - onSort: (column: SortColumn) => void; - width?: string; -}) { - const isActive = sort.column === column; - return ( - - - - ); -} - -const filterItemClassName = - "flex w-full items-center justify-between rounded-sm px-1.5 py-1 text-left text-[13px] text-gray-12 transition-colors hover:bg-gray-3"; - -function RepositoryFilterHeader({ - repos, - selectedRepo, - onSelect, -}: { - repos: string[]; - selectedRepo: string | null; - onSelect: (repo: string | null) => void; -}) { - return ( - - - - - - - - - {repos.map((repo) => ( - - ))} - - - - - ); -} - -interface BranchNotFoundPrompt { - taskId: string; - branchName: string; -} - -export interface ArchivedTaskWithDetails { - archived: ArchivedTask; - task: Task | null; -} - -export interface ArchivedTasksViewPresentationProps { - items: ArchivedTaskWithDetails[]; - isLoading: boolean; - branchNotFound: BranchNotFoundPrompt | null; - onUnarchive: (taskId: string) => void; - onDelete: (taskId: string) => void; - onContextMenu: (item: ArchivedTaskWithDetails, e: React.MouseEvent) => void; - onBranchNotFoundClose: () => void; - onRecreateBranch: () => void; -} - -export function ArchivedTasksViewPresentation({ - items, - isLoading, - branchNotFound, - onUnarchive, - onDelete, - onContextMenu, - onBranchNotFoundClose, - onRecreateBranch, -}: ArchivedTasksViewPresentationProps) { - const [searchQuery, setSearchQuery] = useState(""); - const [sort, setSort] = useState({ - column: "archived", - direction: "desc", - }); - const [repoFilter, setRepoFilter] = useState(null); - const [deleteTargetId, setDeleteTargetId] = useState(null); - - const handleSort = (column: SortColumn) => { - setSort((prev) => - prev.column === column - ? { column, direction: prev.direction === "asc" ? "desc" : "asc" } - : { column, direction: "desc" }, - ); - }; - - const itemsWithRepo = useMemo( - () => - items.map((item) => ({ - ...item, - repoName: getRepoName(item.task?.repository), - })), - [items], - ); - - const uniqueRepos = useMemo(() => { - const repos = new Set(); - 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), - ); - } - - if (repoFilter) { - result = result.filter((item) => item.repoName === repoFilter); - } - - const dir = sort.direction === "asc" ? 1 : -1; - - 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]); - - return ( - - - - setSearchQuery(e.target.value)} - className="text-[13px]" - > - - - - - - - {isLoading ? ( - - - - Loading archived tasks... - - - ) : filteredItems.length === 0 ? ( - - - {items.length === 0 ? "No archived tasks" : "No matching tasks"} - - - ) : ( - - - - - Title - - - - - - - - - {filteredItems.map((item) => ( - onContextMenu(item, e)} - className="group" - > - - - - - {item.task?.title ?? "Unknown task"} - - - - - - {formatRelativeDate(item.task?.created_at)} - - - - - {formatRelativeDate(item.archived.archivedAt)} - - - - - {item.repoName} - - - - - - - - - - ))} - - - )} - - - { - if (!open) onBranchNotFoundClose(); - }} - > - - - Unarchive to new branch? - - - - This workspace was last on{" "} - - {branchNotFound?.branchName} - - , but that branch has been deleted or renamed. - - - - - - - - - - - - { - if (!open) setDeleteTargetId(null); - }} - > - - - Delete archived task - - - - Permanently delete{" "} - - {items.find((i) => i.archived.taskId === deleteTargetId)?.task - ?.title ?? "Unknown task"} - - ? This cannot be undone. - - - - - - - - - - - - - - ); -} - -export function ArchivedTasksView() { - const trpcReact = useTRPC(); - const { data: archivedTasks = [], isLoading: isLoadingArchived } = useQuery( - trpcReact.archive.list.queryOptions(), - ); - const { data: tasks = [], isLoading: isLoadingTasks } = useTasks(); - const queryClient = useQueryClient(); - - useSetHeaderContent( - Archived tasks, - ); - - const [branchNotFound, setBranchNotFound] = - useState(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 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(); - 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); - const match = message.match(BRANCH_NOT_FOUND_PATTERN); - if (match) { - setBranchNotFound({ taskId, branchName: match[1] }); - } else { - toast.error(`Failed to unarchive task: ${message}`); - } - } - }; - - const executeDelete = async (taskId: string) => { - try { - await trpcClient.archive.delete.mutate({ taskId }); - await invalidateArchiveQueries(); - toast.success("Task deleted"); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`Failed to delete task: ${message}`); - } - }; - - const handleContextMenu = async ( - item: ArchivedTaskWithDetails, - e: React.MouseEvent, - ) => { - 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 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}`); - } - }; - - return ( - setBranchNotFound(null)} - onRecreateBranch={handleRecreateBranch} - /> - ); -} diff --git a/apps/code/src/renderer/features/archive/hooks/useArchivedTaskIds.ts b/apps/code/src/renderer/features/archive/hooks/useArchivedTaskIds.ts deleted file mode 100644 index 1b81d802fb..0000000000 --- a/apps/code/src/renderer/features/archive/hooks/useArchivedTaskIds.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useTRPC } from "@renderer/trpc"; -import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; - -export function useArchivedTaskIds(): Set { - const trpcReact = useTRPC(); - const { data } = useQuery(trpcReact.archive.archivedTaskIds.queryOptions()); - return useMemo(() => new Set(data ?? []), [data]); -} diff --git a/apps/code/src/renderer/features/auth/components/AuthScreen.tsx b/apps/code/src/renderer/features/auth/components/AuthScreen.tsx index 1c3eba4e2e..a55b8f682c 100644 --- a/apps/code/src/renderer/features/auth/components/AuthScreen.tsx +++ b/apps/code/src/renderer/features/auth/components/AuthScreen.tsx @@ -1,6 +1,6 @@ import { FullScreenLayout } from "@components/FullScreenLayout"; +import { happyHog } from "@posthog/ui/assets/hedgehogs"; 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/apps/code/src/renderer/features/auth/components/InviteCodeScreen.tsx index c74fe1ca4d..550fbad72a 100644 --- a/apps/code/src/renderer/features/auth/components/InviteCodeScreen.tsx +++ b/apps/code/src/renderer/features/auth/components/InviteCodeScreen.tsx @@ -1,14 +1,14 @@ import { FullScreenLayout } from "@components/FullScreenLayout"; -import { OnboardingHogTip } from "@features/onboarding/components/OnboardingHogTip"; +import { OnboardingHogTip } from "@posthog/ui/primitives/OnboardingHogTip"; import { SignOut } from "@phosphor-icons/react"; +import { happyHog } from "@posthog/ui/assets/hedgehogs"; +import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore"; 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 { useLogoutMutation, useRedeemInviteCodeMutation, -} from "../hooks/authMutations"; -import { useAuthUiStateStore } from "../stores/authUiStateStore"; +} from "@posthog/ui/features/auth/useAuthMutations"; export function InviteCodeScreen() { const code = useAuthUiStateStore((state) => state.inviteCode); diff --git a/apps/code/src/renderer/features/auth/components/SignInCard.tsx b/apps/code/src/renderer/features/auth/components/SignInCard.tsx index c88147556c..fa8315251c 100644 --- a/apps/code/src/renderer/features/auth/components/SignInCard.tsx +++ b/apps/code/src/renderer/features/auth/components/SignInCard.tsx @@ -1,7 +1,6 @@ -import { OnboardingHogTip } from "@features/onboarding/components/OnboardingHogTip"; -import { Flex, Text } from "@radix-ui/themes"; -import type { CloudRegion } from "@shared/types/regions"; -import { OAuthControls } from "./OAuthControls"; +import { SignInCard as UiSignInCard } from "@posthog/ui/features/auth/SignInCard"; +import { IS_DEV } from "@shared/constants/environment"; +import type { CloudRegion } from "@posthog/shared"; interface SignInCardProps { hogSrc: string; @@ -10,22 +9,6 @@ interface SignInCardProps { onAuthInitiated?: (region: CloudRegion) => void; } -export function SignInCard({ - hogSrc, - hogMessage, - subtitle, - onAuthInitiated, -}: SignInCardProps) { - return ( - - - - Sign in / sign up with PostHog - - {subtitle} - - - - - ); +export function SignInCard(props: SignInCardProps) { + return ; } diff --git a/apps/code/src/renderer/features/auth/hooks/authClient.ts b/apps/code/src/renderer/features/auth/hooks/authClient.ts index 42d23a1990..4fc815ef42 100644 --- a/apps/code/src/renderer/features/auth/hooks/authClient.ts +++ b/apps/code/src/renderer/features/auth/hooks/authClient.ts @@ -1,13 +1,16 @@ -import { PostHogAPIClient } from "@renderer/api/posthogClient"; +// PORT NOTE: hooks + builder live in @posthog/ui/features/auth/authClient. +// This app module keeps the 1-arg createAuthenticatedClient(authState) + +// getAuthenticatedClient() helpers (used by non-React renderer services) by +// supplying trpcClient-backed token accessors to the package builder. +import { createAuthenticatedClient as createClient } from "@posthog/ui/features/auth/authClient"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; 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"; +import { type AuthState, fetchAuthState } from "./authQueries"; + +export { + useAuthenticatedClient, + useOptionalAuthenticatedClient, +} from "@posthog/ui/features/auth/authClient"; async function getValidAccessToken(): Promise { const { accessToken } = await trpcClient.auth.getValidAccessToken.query(); @@ -22,43 +25,9 @@ async function refreshAccessToken(): Promise { 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; + return createClient(authState, getValidAccessToken, refreshAccessToken); } 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 index c7a7198c71..1fefe9c244 100644 --- a/apps/code/src/renderer/features/auth/hooks/authQueries.ts +++ b/apps/code/src/renderer/features/auth/hooks/authQueries.ts @@ -1,16 +1,21 @@ -import type { PostHogAPIClient } from "@renderer/api/posthogClient"; +import { getAuthIdentity, useAuthStore } from "@posthog/ui/features/auth/store"; import { trpc, trpcClient } from "@renderer/trpc/client"; import { useQuery } from "@tanstack/react-query"; import { queryClient } from "@utils/queryClient"; +// PORT NOTE: useCurrentUser/authKeys/AUTH_SCOPED_QUERY_META/getAuthIdentity now +// live in @posthog/ui/features/auth; re-exported here for existing importers. +export { + AUTH_SCOPED_QUERY_META, + authKeys, + useCurrentUser, +} from "@posthog/ui/features/auth/useCurrentUser"; +export { getAuthIdentity }; + 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, @@ -22,12 +27,6 @@ export const ANONYMOUS_AUTH_STATE: AuthState = { 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(); } @@ -53,14 +52,6 @@ export function clearAuthScopedQueries(): void { }); } -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(), @@ -70,36 +61,11 @@ export function useAuthState() { } export function useAuthStateFetched(): boolean { - const { isFetched } = useAuthState(); - return isFetched; + // PORT NOTE: store-backed via AuthContribution; bootstrapComplete is the + // "auth resolved" signal (replaces the old query.isFetched). + return useAuthStore((s) => s.authState.bootstrapComplete); } 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, - }); + return useAuthStore((s) => selector(s.authState as AuthState)); } diff --git a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts index f3b946ce93..22ce850192 100644 --- a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts +++ b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts @@ -7,9 +7,9 @@ import { 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 { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore"; +import { useSeatStore } from "@posthog/ui/features/billing/seatStore"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { trpcClient } from "@renderer/trpc/client"; import { BILLING_FLAG } from "@shared/constants"; import { identifyUser, resetUser, setUserGroups } from "@utils/analytics"; 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/hooks/useOrgRole.ts b/apps/code/src/renderer/features/auth/hooks/useOrgRole.ts deleted file mode 100644 index 09ece7ac44..0000000000 --- a/apps/code/src/renderer/features/auth/hooks/useOrgRole.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useCurrentUser } from "@features/auth/hooks/authQueries"; - -export const ORGANIZATION_ADMIN_LEVEL = 8; - -export function useIsOrgAdmin(): { isAdmin: boolean | null } { - const client = useOptionalAuthenticatedClient(); - const { data, isLoading } = useCurrentUser({ client }); - const level = data?.organization?.membership_level ?? null; - if (isLoading || level === null) return { isAdmin: null }; - return { isAdmin: level >= ORGANIZATION_ADMIN_LEVEL }; -} 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/billing/subscriptions.ts b/apps/code/src/renderer/features/billing/subscriptions.ts deleted file mode 100644 index 94efa1bb59..0000000000 --- a/apps/code/src/renderer/features/billing/subscriptions.ts +++ /dev/null @@ -1,58 +0,0 @@ -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"); - -const openPlanUsage = () => { - useSettingsDialogStore.getState().open("plan-usage"); -}; - -export function registerBillingSubscriptions() { - const subscription = trpcClient.usageMonitor.onThresholdCrossed.subscribe( - undefined, - { - onData: (event) => { - const resetLabel = formatResetTime(event.resetAt); - - if (event.threshold === 100) { - if (event.userIsActive) { - useUsageLimitStore.getState().show({ - bucket: event.bucket, - resetAt: event.resetAt, - isPro: event.isPro, - }); - return; - } - toast.error("Usage limit reached", { - id: `usage-threshold-${event.bucket}-100`, - description: resetLabel, - }); - return; - } - - const limitName = - event.bucket === "burst" ? "daily limit" : "monthly limit"; - toast.warning( - `You've used ${Math.round(event.usedPercent)}% of your ${limitName}`, - { - id: `usage-threshold-${event.bucket}-${event.threshold}`, - description: resetLabel, - action: { label: "View usage", onClick: openPlanUsage }, - duration: 10_000, - }, - ); - }, - onError: (error) => { - log.error("Usage threshold subscription error", { error }); - }, - }, - ); - - return () => { - subscription.unsubscribe(); - }; -} diff --git a/apps/code/src/renderer/features/billing/utils.test.ts b/apps/code/src/renderer/features/billing/utils.test.ts deleted file mode 100644 index 0b9ed02d71..0000000000 --- a/apps/code/src/renderer/features/billing/utils.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { UsageOutput } from "@main/services/llm-gateway/schemas"; -import { describe, expect, it } from "vitest"; -import { formatResetTime, isUsageExceeded } from "./utils"; - -function makeUsage( - overrides: Partial<{ - sustained: boolean; - burst: boolean; - isRateLimited: boolean; - }> = {}, -): UsageOutput { - return { - product: "posthog_code", - user_id: 1, - sustained: { - used_percent: 50, - reset_at: "2026-05-01T13:00:00.000Z", - exceeded: overrides.sustained ?? false, - }, - burst: { - used_percent: 30, - reset_at: "2026-05-01T12:10:00.000Z", - exceeded: overrides.burst ?? false, - }, - is_rate_limited: overrides.isRateLimited ?? false, - is_pro: false, - }; -} - -describe("isUsageExceeded", () => { - it("returns false when nothing is exceeded", () => { - expect(isUsageExceeded(makeUsage())).toBe(false); - }); - - it("returns true when sustained is exceeded", () => { - expect(isUsageExceeded(makeUsage({ sustained: true }))).toBe(true); - }); - - it("returns true when burst is exceeded", () => { - expect(isUsageExceeded(makeUsage({ burst: true }))).toBe(true); - }); - - it("returns true when rate limited", () => { - expect(isUsageExceeded(makeUsage({ isRateLimited: true }))).toBe(true); - }); - - it("returns true when all flags are set", () => { - expect( - isUsageExceeded( - makeUsage({ sustained: true, burst: true, isRateLimited: true }), - ), - ).toBe(true); - }); -}); - -describe("formatResetTime", () => { - const NOW = Date.parse("2026-05-01T12:00:00.000Z"); - const isoAt = (msFromNow: number) => new Date(NOW + msFromNow).toISOString(); - - it.each([ - { - name: "returns minutes-only under 1h", - resetAt: isoAt(30 * 60 * 1000), - expected: "Resets in 30m" as string | RegExp, - }, - { - name: "returns hours + minutes under 24h", - resetAt: isoAt((4 * 3600 + 30 * 60) * 1000), - expected: "Resets in 4h 30m", - }, - { - name: "returns hours only when minutes round to 0", - resetAt: isoAt(4 * 3600 * 1000), - expected: "Resets in 4h", - }, - { - name: "returns localized date when over 24h away", - resetAt: isoAt(30 * 86400 * 1000), - expected: /^Resets [A-Za-z]+ \d+ at /, - }, - { - name: "treats an already-past reset_at as shortly", - resetAt: isoAt(-60_000), - expected: "Resets shortly", - }, - { - name: "treats an unparseable reset_at as shortly", - resetAt: "not-a-date", - expected: "Resets shortly", - }, - ])("$name", ({ resetAt, expected }) => { - const result = formatResetTime(resetAt, NOW); - if (expected instanceof RegExp) { - expect(result).toMatch(expected); - } else { - expect(result).toBe(expected); - } - }); -}); diff --git a/apps/code/src/renderer/features/billing/utils.ts b/apps/code/src/renderer/features/billing/utils.ts deleted file mode 100644 index 7db7af0415..0000000000 --- a/apps/code/src/renderer/features/billing/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { UsageOutput } from "@main/services/llm-gateway/schemas"; - -export function isUsageExceeded(usage: UsageOutput): boolean { - return ( - usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded - ); -} - -export function formatResetTime( - resetAtIso: string, - now: number = Date.now(), -): string { - const parsed = Date.parse(resetAtIso); - const ms = Number.isNaN(parsed) ? 0 : Math.max(0, parsed - now); - - const totalMinutes = Math.ceil(ms / 60_000); - if (totalMinutes <= 0) return "Resets shortly"; - if (totalMinutes < 60) return `Resets in ${totalMinutes}m`; - - const totalHours = ms / 3_600_000; - if (totalHours < 24) { - const hours = Math.floor(totalHours); - const minutes = Math.round((totalHours - hours) * 60); - return minutes === 0 - ? `Resets in ${hours}h` - : `Resets in ${hours}h ${minutes}m`; - } - - const target = new Date(now + ms); - const date = target.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }); - const time = target.toLocaleTimeString(undefined, { - hour: "numeric", - minute: "2-digit", - timeZoneName: "short", - }); - return `Resets ${date} at ${time}`; -} diff --git a/apps/code/src/renderer/features/billing/utils/spendAnalysisFormat.ts b/apps/code/src/renderer/features/billing/utils/spendAnalysisFormat.ts deleted file mode 100644 index 051963b7e9..0000000000 --- a/apps/code/src/renderer/features/billing/utils/spendAnalysisFormat.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** 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"; - if (amount < 100) return `$${amount.toFixed(2)}`; - return `$${Math.round(amount).toLocaleString()}`; -} - -export function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`; - return n.toString(); -} - -export function formatWindow(fromIso: string, toIso: string): string { - 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`; -} diff --git a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx b/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx deleted file mode 100644 index aa7b1f7bca..0000000000 --- a/apps/code/src/renderer/features/code-editor/components/CodeEditorPanel.tsx +++ /dev/null @@ -1,301 +0,0 @@ -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 { - getImageMimeType, - isRasterImageFile, - parseImageDataUrl, -} from "@posthog/shared"; -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"; - -interface CodeEditorPanelProps { - taskId: string; - task: Task; - absolutePath: string; -} - -function FilePanelImagePreview({ - base64, - mimeType, - filePath, - absolutePath, -}: { - base64: string; - mimeType: string; - filePath: string; - absolutePath: string; -}) { - return ( - - - Failed to render image - - } - /> - - ); -} - -export function CodeEditorPanel({ - taskId, - task: _task, - absolutePath, -}: CodeEditorPanelProps) { - const trpcReact = useTRPC(); - const repoPath = useCwd(taskId); - const isInsideRepo = !!repoPath && absolutePath.startsWith(repoPath); - const filePath = getRelativePath(absolutePath, repoPath); - const isImage = isRasterImageFile(absolutePath); - const isMarkdown = isMarkdownFile(absolutePath); - const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit); - const expandToFile = useFileTreeStore((s) => s.expandToFile); - const [copied, setCopied] = useState(false); - - const handleMarkdownLinkClick = useCallback( - (e: React.MouseEvent, href: string) => { - e.preventDefault(); - if (href.startsWith("http://") || href.startsWith("https://")) { - trpcClient.os.openExternal.mutate({ url: 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}`); - } - openFileInSplit(taskId, resolved); - }, - [filePath, taskId, repoPath, openFileInSplit, expandToFile], - ); - - const markdownComponents: Components = useMemo( - () => ({ - a: ({ href, children }) => ( - - handleMarkdownLinkClick(e, href ?? "")} - className="cursor-pointer text-(--accent-11) underline" - > - {children} - - - ), - }), - [handleMarkdownLinkClick], - ); - - const isCloudRun = useIsWorkspaceCloudRun(taskId); - const cloudFile = useCloudFileContent( - taskId, - filePath, - 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 imageQuery = useQuery( - trpcReact.fs.readFileAsBase64.queryOptions( - { filePath: absolutePath }, - { enabled: isImage && !isCloudRun, staleTime: Infinity }, - ), - ); - - 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 enrichment = useFileEnrichment({ - taskId, - filePath, - absolutePath: isInsideRepo ? absolutePath : undefined, - content: isImage ? null : fileContent, - }); - - const dataUrlImage = useMemo( - () => - isImage || fileContent == null ? null : parseImageDataUrl(fileContent), - [isImage, fileContent], - ); - - if (isImage) { - if (isCloudRun) { - return ( - - Images not available for cloud runs - - ); - } - if (imageQuery.isLoading) { - return Loading image...; - } - if (imageQuery.error || !imageQuery.data) { - return ( - Failed to load image - ); - } - return ( - - ); - } - - if (isLoading) { - return Loading file...; - } - - if (isCloudRun && !cloudFile.touched) { - return ( - - File content not available — the agent did not read or write this file - - ); - } - - if (isCloudRun && cloudFile.touched && cloudFile.content == null) { - return ( - - This file was deleted by the agent - - ); - } - - if (error || fileContent == null) { - return ( - Failed to load file - ); - } - - if (fileContent.length === 0) { - return File is empty; - } - - if (dataUrlImage) { - return ( - - ); - } - - if (isMarkdown) { - const handleCopySource = () => { - navigator.clipboard.writeText(fileContent); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( - - - - {filePath} - - - - - {copied ? : } - - - - - - - - {fileContent} - - - - - ); - } - - return ( - - - - - ); -} diff --git a/apps/code/src/renderer/features/code-editor/extensions/postHogEnrichment.ts b/apps/code/src/renderer/features/code-editor/extensions/postHogEnrichment.ts deleted file mode 100644 index 4810af263c..0000000000 --- a/apps/code/src/renderer/features/code-editor/extensions/postHogEnrichment.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { - type Extension, - RangeSetBuilder, - StateEffect, - StateField, -} from "@codemirror/state"; -import { - Decoration, - type DecorationSet, - EditorView, - ViewPlugin, - type ViewUpdate, -} from "@codemirror/view"; -import type { SerializedEnrichment } from "@posthog/enricher"; -import { - type EnrichmentPopoverEntry, - useEnrichmentPopoverStore, -} from "../stores/enrichmentPopoverStore"; - -export const setEnrichmentEffect = - StateEffect.define(); - -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({ - create: () => [], - update(value, tr) { - for (const effect of tr.effects) { - if (effect.is(setEnrichmentEffect)) { - return buildOccurrences(effect.value); - } - } - return value; - }, -}); - -const pillStyles = EditorView.baseTheme({ - ".cm-posthog-pill": { - backgroundColor: - "color-mix(in srgb, var(--accent-9, #6b46c1) 18%, transparent)", - borderRadius: "3px", - padding: "0 3px", - margin: "0 -3px", - boxShadow: - "inset 0 0 0 1px color-mix(in srgb, var(--accent-9, #6b46c1) 40%, transparent)", - cursor: "pointer", - }, - ".cm-posthog-pill:hover": { - backgroundColor: - "color-mix(in srgb, var(--accent-9, #6b46c1) 30%, transparent)", - }, -}); - -function openPopoverFor(view: EditorView, occurrence: Occurrence): 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); - const start = view.coordsAtPos(from); - if (!start) return; - const end = view.coordsAtPos(to) ?? start; - useEnrichmentPopoverStore.getState().show( - { - top: start.top, - bottom: start.bottom, - left: start.left, - right: end.right, - }, - occurrence.entry, - ); -} - -const pillPlugin = ViewPlugin.fromClass( - class { - decorations: DecorationSet; - constructor(view: EditorView) { - this.decorations = this.build(view); - } - update(update: ViewUpdate) { - const prev = update.startState.field(enrichmentField, false); - const next = update.state.field(enrichmentField, false); - if (prev !== next || update.docChanged) { - this.decorations = this.build(update.view); - } - } - build(view: EditorView): DecorationSet { - const occurrences = view.state.field(enrichmentField, false) ?? []; - const builder = new RangeSetBuilder(); - const doc = view.state.doc; - for (const occ of occurrences) { - if (occ.line < 1 || occ.line > doc.lines) continue; - const line = doc.line(occ.line); - const from = line.from + Math.max(0, occ.startCol); - const to = line.from + Math.max(occ.startCol, occ.endCol); - if (to <= from || to > line.to) continue; - builder.add( - from, - to, - Decoration.mark({ - class: "cm-posthog-pill", - attributes: { - "data-posthog-pill": "1", - title: occ.summary, - }, - }), - ); - } - return builder.finish(); - } - }, - { - decorations: (v) => v.decorations, - eventHandlers: { - click(event, view) { - const target = event.target as HTMLElement | null; - if (!target) return false; - const pill = target.closest("[data-posthog-pill]"); - if (!pill) return false; - const pos = view.posAtDOM(pill); - const occurrences = view.state.field(enrichmentField, false) ?? []; - const line = view.state.doc.lineAt(pos).number; - const col = pos - view.state.doc.line(line).from; - const match = occurrences.find( - (o) => o.line === line && col >= o.startCol && col <= o.endCol, - ); - if (!match) return false; - event.preventDefault(); - event.stopPropagation(); - openPopoverFor(view, match); - return true; - }, - }, - }, -); - -export function postHogEnrichmentExtension(): Extension { - return [enrichmentField, pillPlugin, pillStyles]; -} diff --git a/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts b/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts deleted file mode 100644 index 4dd162a5d6..0000000000 --- a/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts +++ /dev/null @@ -1,83 +0,0 @@ -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 { useEffect, useRef } from "react"; - -interface UseCodeMirrorOptions { - doc: string; - extensions: Extension[]; - filePath?: string; -} - -export function useCodeMirror(options: UseCodeMirrorOptions) { - const containerRef = useRef(null); - const instanceRef = useRef(null); - - useEffect(() => { - if (!containerRef.current) return; - - instanceRef.current?.destroy(); - instanceRef.current = null; - - instanceRef.current = new EditorView({ - state: EditorState.create({ - doc: options.doc, - extensions: options.extensions, - }), - parent: containerRef.current, - }); - - return () => { - instanceRef.current?.destroy(); - instanceRef.current = null; - }; - }, [options]); - - useEffect(() => { - if (!instanceRef.current || !options.filePath) return; - - const filePath = options.filePath; - const domElement = instanceRef.current.dom; - - 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, - }, - ); - } - }; - - domElement.addEventListener("contextmenu", handleContextMenu); - - return () => { - domElement.removeEventListener("contextmenu", handleContextMenu); - }; - }, [options.filePath]); - - return { containerRef, instanceRef }; -} 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/CloudReviewPage.tsx b/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx deleted file mode 100644 index 3bbb8d910a..0000000000 --- a/apps/code/src/renderer/features/code-review/components/CloudReviewPage.tsx +++ /dev/null @@ -1,140 +0,0 @@ -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 { 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 { PatchedFileDiff } from "./PatchedFileDiff"; -import { - buildItemIndex, - type ReviewListItem, - ReviewShell, - useReviewState, -} from "./ReviewShell"; - -interface CloudReviewPageProps { - task: Task; -} - -export function CloudReviewPage({ task }: CloudReviewPageProps) { - const taskId = task.id; - const isReviewOpen = useReviewNavigationStore( - (s) => (s.reviewModes[taskId] ?? "closed") !== "closed", - ); - const showReviewComments = useDiffViewerStore((s) => s.showReviewComments); - const { - effectiveBranch, - prUrl, - isRunActive, - remoteFiles, - reviewFiles, - toolCalls, - isLoading, - } = useCloudChangedFiles(taskId, task, isReviewOpen); - const { commentThreads } = usePrDetails(prUrl, { - includeComments: isReviewOpen && showReviewComments, - }); - - const allPaths = useMemo(() => reviewFiles.map((f) => f.path), [reviewFiles]); - - const { - diffOptions, - linesAdded, - linesRemoved, - collapsedFiles, - toggleFile, - expandAll, - collapseAll, - 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 items = useMemo(() => { - return reviewFiles.map((file) => { - const isCollapsed = collapsedFiles.has(file.path); - const githubFileUrl = prUrl - ? `${prUrl}/files#diff-${file.path.replaceAll("/", "-")}` - : undefined; - - return { - key: file.path, - scrollKey: file.path, - node: ( - toggleFile(file.path)} - commentThreads={showReviewComments ? commentThreads : undefined} - fallback={toolCallFallbacks?.get(file.path) ?? null} - externalUrl={githubFileUrl} - /> - ), - }; - }); - }, [ - collapsedFiles, - commentThreads, - diffOptions, - prUrl, - reviewFiles, - showReviewComments, - taskId, - toggleFile, - toolCallFallbacks, - ]); - - const itemIndexByFilePath = useMemo(() => buildItemIndex(items), [items]); - - if (!prUrl && !effectiveBranch && reviewFiles.length === 0) { - if (isRunActive) { - return ( - - - - Waiting for changes... - - - ); - } - return null; - } - - return ( - - ); -} diff --git a/apps/code/src/renderer/features/code-review/components/ReviewShell.test.tsx b/apps/code/src/renderer/features/code-review/components/ReviewShell.test.tsx deleted file mode 100644 index c25b21d30c..0000000000 --- a/apps/code/src/renderer/features/code-review/components/ReviewShell.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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", () => ({ - useDiffViewerStore: vi.fn(), -})); -vi.mock("@features/task-detail/components/ChangesPanel", () => ({ - ChangesPanel: () => null, -})); -vi.mock("@features/git-interaction/utils/diffStats", () => ({ - computeDiffStats: () => ({ linesAdded: 0, linesRemoved: 0 }), -})); -vi.mock("@stores/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", () => ({ - FileIcon: () => , -})); -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: {}, - useTRPC: vi.fn(), -})); -vi.mock("@features/sessions/service/service", () => ({ - getSessionService: vi.fn(), -})); - -import { DeferredDiffPlaceholder, DiffFileHeader } from "./ReviewShell"; - -type FileDiffMetadata = import("@pierre/diffs/react").FileDiffMetadata; - -function makeFileDiff(name: string): FileDiffMetadata { - return { - name, - prevName: null, - hunks: [{ additionLines: 3, deletionLines: 1 }], - } as unknown as FileDiffMetadata; -} - -function findSpan( - container: HTMLElement, - match: (s: HTMLSpanElement) => boolean, -): HTMLSpanElement { - const spans = Array.from(container.querySelectorAll("span")); - const found = spans.find(match); - if (!found) throw new Error("span not found"); - return found; -} - -function renderHeader(path: string) { - const diff = render( - {}} - />, - ); - const deferred = render( - {}} - />, - ); - return { diff, deferred }; -} - -describe.each([ - ["DiffFileHeader", "diff" as const], - ["DeferredDiffPlaceholder", "deferred" as const], -])("%s", (_name, which) => { - it("renders the directory path and filename", () => { - const rendered = renderHeader( - "src/renderer/features/code-review/components/ReviewShell.tsx", - )[which]; - - const text = rendered.container.querySelector("button")?.textContent ?? ""; - expect(text).toContain("src/renderer/features/code-review/components/"); - expect(text).toContain("ReviewShell.tsx"); - }); - - it("truncates the directory path and keeps the filename intact", () => { - const rendered = renderHeader( - "src/a/very/deeply/nested/structure/ReviewShell.tsx", - )[which]; - - // Inline styles were migrated to Tailwind utility classes; check classes - // instead. The dir span gets the muted color + truncation utilities, the - // file span gets bold weight + a non-shrinking flex behavior. - const dirSpan = findSpan(rendered.container, (s) => - s.classList.contains("text-(--gray-9)"), - ); - const fileSpan = findSpan(rendered.container, (s) => - s.classList.contains("font-semibold"), - ); - - expect(dirSpan.classList.contains("overflow-hidden")).toBe(true); - expect(dirSpan.classList.contains("text-ellipsis")).toBe(true); - expect(dirSpan.classList.contains("whitespace-nowrap")).toBe(true); - - expect(fileSpan.classList.contains("whitespace-nowrap")).toBe(true); - expect(fileSpan.classList.contains("shrink-0")).toBe(true); - - expect(dirSpan.parentElement).toBe(fileSpan.parentElement); - expect(dirSpan.parentElement?.classList.contains("flex")).toBe(true); - }); -}); 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 ( -
- - {!collapsed && ( -
- {getDeferredMessage(reason)} - {onShow ? ( - <> - {" "} - - - ) : externalUrl ? ( - <> - {" "} - - View on GitHub - - - ) : null} -
- )} -
- ); -} diff --git a/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts b/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts deleted file mode 100644 index 788466c384..0000000000 --- a/apps/code/src/renderer/features/code-review/hooks/useEffectiveDiffSource.ts +++ /dev/null @@ -1,95 +0,0 @@ -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"; - -export interface EffectiveDiffSource { - effectiveSource: ResolvedDiffSource; - prUrl: string | null; - linkedBranch: string | null; - defaultBranch: string | null; - repoSlug: string | null; - branchSourceAvailable: boolean; - prSourceAvailable: boolean; - diffStats: DiffStats; -} - -export function useEffectiveDiffSource(taskId: string): EffectiveDiffSource { - const trpc = useTRPC(); - const repoPath = useCwd(taskId); - const workspace = useWorkspace(taskId); - const linkedBranch = workspace?.linkedBranch ?? null; - - const configured = useDiffViewerStore((s) => s.diffSource[taskId] ?? null); - - const enabled = !!repoPath; - const emptyDiffStats: DiffStats = { - filesChanged: 0, - linesAdded: 0, - linesRemoved: 0, - }; - - const { data: syncStatus } = useQuery( - trpc.git.getGitSyncStatus.queryOptions( - { directoryPath: repoPath as string }, - { - enabled, - staleTime: 30_000, - }, - ), - ); - - const { data: repoInfo } = useQuery( - trpc.git.getGitRepoInfo.queryOptions( - { directoryPath: repoPath as string }, - { - enabled, - staleTime: 60_000, - }, - ), - ); - - const { data: diffStats = emptyDiffStats } = useDiffStats(repoPath ?? null); - - const aheadOfDefault = syncStatus?.aheadOfDefault ?? 0; - const defaultBranch = repoInfo?.defaultBranch ?? null; - const hasLocalChanges = diffStats.filesChanged > 0; - const branchSourceAvailable = !!linkedBranch && aheadOfDefault > 0; - - const prUrl = useLinkedBranchPrUrl({ - linkedBranch, - folderPath: workspace?.folderPath ?? null, - }); - const prSourceAvailable = !!prUrl; - - const repoSlug = repoInfo - ? `${repoInfo.organization}/${repoInfo.repository}` - : null; - - const effectiveSource = resolveDiffSource({ - configured, - hasLocalChanges, - linkedBranch, - aheadOfDefault, - prSourceAvailable, - }); - - return { - effectiveSource, - prUrl, - linkedBranch, - defaultBranch, - repoSlug, - branchSourceAvailable, - prSourceAvailable, - diffStats, - }; -} diff --git a/apps/code/src/renderer/features/code-review/hooks/usePrCommentActions.ts b/apps/code/src/renderer/features/code-review/hooks/usePrCommentActions.ts deleted file mode 100644 index 365f1a03ca..0000000000 --- a/apps/code/src/renderer/features/code-review/hooks/usePrCommentActions.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useTRPC } from "@renderer/trpc"; -import { trpcClient } from "@renderer/trpc/client"; -import { useQueryClient } from "@tanstack/react-query"; -import { useCallback } from "react"; -import { toast } from "sonner"; - -export function usePrCommentActions(prUrl: string | null) { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const reply = useCallback( - async (commentId: number, body: string): Promise => { - if (!prUrl) return false; - try { - const result = await trpcClient.git.replyToPrComment.mutate({ - prUrl, - commentId, - body, - }); - if (!result.success) { - toast.error("Failed to post reply"); - return false; - } - await queryClient.invalidateQueries( - trpc.git.getPrReviewComments.queryFilter({ prUrl }), - ); - return true; - } catch { - toast.error("Failed to post reply"); - return false; - } - }, - [prUrl, queryClient, trpc], - ); - - const resolve = useCallback( - async (threadNodeId: string, resolved: boolean): Promise => { - if (!prUrl) return false; - const errorMessage = resolved - ? "Failed to resolve thread" - : "Failed to unresolve thread"; - try { - const result = await trpcClient.git.resolveReviewThread.mutate({ - prUrl, - threadNodeId, - resolved, - }); - if (!result.success) { - toast.error(errorMessage); - return false; - } - await queryClient.invalidateQueries( - trpc.git.getPrReviewComments.queryFilter({ prUrl }), - ); - return true; - } catch { - toast.error(errorMessage); - return false; - } - }, - [prUrl, queryClient, trpc], - ); - - return { reply, resolve }; -} 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/code-review/types.ts b/apps/code/src/renderer/features/code-review/types.ts deleted file mode 100644 index ca77ca73d2..0000000000 --- a/apps/code/src/renderer/features/code-review/types.ts +++ /dev/null @@ -1,62 +0,0 @@ -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"; - -export interface HunkRevertMetadata { - kind: "hunk-revert"; - hunkIndex: number; -} - -export interface CommentMetadata { - kind: "comment"; - startLine: number; - endLine: number; - side: AnnotationSide; -} - -export interface DraftCommentMetadata { - kind: "draft-comment"; - draftId: string; - startLine: number; - endLine: number; - side: AnnotationSide; -} - -export interface PrCommentMetadata { - kind: "pr-comment"; - threadId: number; - nodeId: string; - isResolved: boolean; - comments: PrReviewComment[]; - isOutdated: boolean; - isFileLevel: boolean; - startLine: number | null; - endLine: number; - side: AnnotationSide; -} - -export type AnnotationMetadata = - | HunkRevertMetadata - | CommentMetadata - | DraftCommentMetadata - | 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/apps/code/src/renderer/features/code-review/utils/prCommentAnnotations.ts b/apps/code/src/renderer/features/code-review/utils/prCommentAnnotations.ts deleted file mode 100644 index e704f9e567..0000000000 --- a/apps/code/src/renderer/features/code-review/utils/prCommentAnnotations.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { PrReviewComment } from "@main/services/git/schemas"; -import type { DiffLineAnnotation } from "@pierre/diffs"; -import type { AnnotationMetadata } from "../types"; - -export interface PrCommentThread { - rootId: number; - nodeId: string; - isResolved: boolean; - comments: PrReviewComment[]; - filePath: string; -} - -function buildAnnotation( - thread: PrCommentThread, -): DiffLineAnnotation | null { - const root = thread.comments[0]; - if (!root) return null; - - const isFileLevel = root.line == null && root.original_line == null; - const line = root.line ?? root.original_line ?? 1; - - const isOutdated = - !isFileLevel && root.line == null && root.original_line != null; - const side = isFileLevel - ? "additions" - : root.side === "LEFT" - ? "deletions" - : "additions"; - - return { - side, - lineNumber: line, - metadata: { - kind: "pr-comment", - threadId: thread.rootId, - nodeId: thread.nodeId, - isResolved: thread.isResolved, - comments: thread.comments, - isOutdated, - isFileLevel, - startLine: root.start_line, - endLine: line, - side, - }, - }; -} - -export function buildFileAnnotations( - threads: Map, - filePath: string, -): DiffLineAnnotation[] { - const annotations: DiffLineAnnotation[] = []; - for (const thread of threads.values()) { - if (thread.filePath !== filePath) continue; - const annotation = buildAnnotation(thread); - if (annotation) annotations.push(annotation); - } - return annotations; -} diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterPRButton.tsx b/apps/code/src/renderer/features/command-center/components/CommandCenterPRButton.tsx deleted file mode 100644 index 53039e7562..0000000000 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterPRButton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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"; - -interface CommandCenterPRButtonProps { - taskId: string; - workspaceMode: WorkspaceMode | null; -} - -/** - * PR badge for a task cell in the command center. Same resolution rules as - * `TaskActionsMenu` via `useTaskPrUrl`, gated by `usePrDetails` returning a - * real PR state. - */ -export function CommandCenterPRButton({ - taskId, - workspaceMode, -}: CommandCenterPRButtonProps) { - const isCloud = workspaceMode === "cloud"; - const prUrl = useTaskPrUrl(taskId, isCloud); - - const { - meta: { state, merged, draft }, - } = usePrDetails(prUrl); - - if (!prUrl || state === null) return null; - - return ( - - ); -} diff --git a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts b/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts deleted file mode 100644 index 83cb67a3df..0000000000 --- a/apps/code/src/renderer/features/command-center/hooks/useAutofillCommandCenter.ts +++ /dev/null @@ -1,72 +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 { 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); -} - -export function useAutofillCommandCenter(): void { - const { data: tasks = [], isFetched: tasksFetched } = useTasks(); - const { data: workspaces, isFetched: workspacesFetched } = useWorkspaces(); - const archivedTaskIds = useArchivedTaskIds(); - - const cells = useCommandCenterStore((s) => s.cells); - const autofillCells = useCommandCenterStore((s) => s.autofillCells); - - // Fires at most once per mount so clearing cells in-place doesn't - // immediately re-populate them. Navigating away and back remounts the - // view and lets autofill run again with the latest recent tasks. - const hasRunRef = useRef(false); - - useEffect(() => { - if (hasRunRef.current) return; - if (!workspacesFetched || !workspaces) return; - if (!tasksFetched) return; - - const emptySlots = cells.filter((id) => id == null).length; - if (emptySlots === 0) { - hasRunRef.current = true; - return; - } - - 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); - - if (candidates.length > 0) { - autofillCells(candidates); - } - hasRunRef.current = true; - }, [ - cells, - workspaces, - workspacesFetched, - tasks, - tasksFetched, - archivedTaskIds, - autofillCells, - ]); -} 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 index 7a6d56a72b..11da843268 100644 --- a/apps/code/src/renderer/features/folders/hooks/useFolders.ts +++ b/apps/code/src/renderer/features/folders/hooks/useFolders.ts @@ -1,113 +1,9 @@ -import type { RegisteredFolder } from "@main/services/folders/schemas"; -import { trpc, trpcClient, useTRPC } from "@renderer/trpc"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { trpc, trpcClient } from "@renderer/trpc"; import { queryClient } from "@utils/queryClient"; -import { useCallback, useMemo } from "react"; +import type { RegisteredFolder } from "@posthog/ui/features/folders/types"; -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, - }; -} +export { useFolders } from "@posthog/ui/features/folders/useFolders"; +export type { RegisteredFolder } from "@posthog/ui/features/folders/types"; const invalidateFolders = () => { void queryClient.invalidateQueries(trpc.folders.getFolders.pathFilter()); @@ -118,9 +14,7 @@ export const foldersApi = { return trpcClient.folders.getFolders.query(); }, async addFolder(folderPath: string) { - const newFolder = await trpcClient.folders.addFolder.mutate({ - folderPath, - }); + const newFolder = await trpcClient.folders.addFolder.mutate({ folderPath }); invalidateFolders(); return newFolder; }, diff --git a/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx b/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx deleted file mode 100644 index b8b358155b..0000000000 --- a/apps/code/src/renderer/features/git-interaction/components/CloudGitInteractionHeader.tsx +++ /dev/null @@ -1,184 +0,0 @@ -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 { Button as QuillButton } from "@posthog/quill"; -import type { Task } from "@shared/types"; -import { useState } from "react"; - -const CLOUD_HANDOFF_FLAG = "phc-cloud-handoff"; - -interface CloudGitInteractionHeaderProps { - taskId: string; - task: Task; -} - -export function CloudGitInteractionHeader({ - taskId, - task, -}: CloudGitInteractionHeaderProps) { - const session = useSessionForTask(taskId); - const localHandoff = getLocalHandoffService(); - const cloudHandoffEnabled = - useFeatureFlag(CLOUD_HANDOFF_FLAG) || import.meta.env.DEV; - - const confirmOpen = useHandoffDialogStore((s) => s.confirmOpen); - const direction = useHandoffDialogStore((s) => s.direction); - const branchName = useHandoffDialogStore((s) => s.branchName); - const dirtyTreeOpen = useHandoffDialogStore((s) => s.dirtyTreeOpen); - const changedFiles = useHandoffDialogStore((s) => s.changedFiles); - const closeConfirm = useHandoffDialogStore((s) => s.closeConfirm); - const pendingAfterCommit = useHandoffDialogStore((s) => s.pendingAfterCommit); - - const commitRepoPath = pendingAfterCommit?.repoPath; - const git = useGitInteraction(taskId, commitRepoPath); - - const [isPreflighting, setIsPreflighting] = useState(false); - const [preflightError, setPreflightError] = useState(null); - - const handleConfirm = async () => { - setPreflightError(null); - setIsPreflighting(true); - try { - await localHandoff.start(taskId, task); - } catch (err) { - setPreflightError( - err instanceof Error ? err.message : "Preflight failed", - ); - } finally { - setIsPreflighting(false); - } - }; - - const handleCommitAndContinue = async () => { - localHandoff.hideDirtyTree(); - if (git.state.isFeatureBranch) { - useGitInteractionStore.getState().actions.openCommit("commit"); - return; - } - - useGitInteractionStore - .getState() - .actions.openBranch(getSuggestedBranchName(taskId, commitRepoPath)); - }; - - const handleBranchConfirm = async () => { - const branchCreated = await git.actions.runBranch(); - if (!branchCreated) return; - useGitInteractionStore.getState().actions.openCommit("commit"); - }; - - const handleCommitConfirm = async () => { - const committed = await git.actions.runCommit(); - if (!committed) return; - await localHandoff.resumePending(); - }; - - if (!cloudHandoffEnabled) return null; - - const inProgress = session?.handoffInProgress ?? false; - - return ( - <> -
- - localHandoff.openConfirm(taskId, session?.cloudBranch ?? null) - } - > - {inProgress ? ( - - ) : ( - - )} - {inProgress ? "Transferring..." : "Continue locally"} - -
- {confirmOpen && direction === "to-local" && ( - { - if (!open) { - closeConfirm(); - setPreflightError(null); - } - }} - direction="to-local" - branchName={branchName} - onConfirm={handleConfirm} - isSubmitting={isPreflighting} - error={preflightError} - /> - )} - {dirtyTreeOpen && ( - { - if (!open) localHandoff.cancelPendingFlow(); - }} - changedFiles={changedFiles} - onCommitAndContinue={handleCommitAndContinue} - /> - )} - {pendingAfterCommit && ( - { - if (!open) { - git.actions.closeCommit(); - localHandoff.cancelPendingFlow(); - } - }} - branchName={git.state.currentBranch ?? pendingAfterCommit.branchName} - diffStats={git.state.diffStats} - commitMessage={git.modals.commitMessage} - onCommitMessageChange={git.actions.setCommitMessage} - nextStep={git.modals.commitNextStep} - onNextStepChange={git.actions.setCommitNextStep} - pushDisabledReason={git.state.pushDisabledReason} - onContinue={handleCommitConfirm} - isSubmitting={git.modals.isSubmitting} - error={git.modals.commitError} - onGenerateMessage={git.actions.generateCommitMessage} - isGeneratingMessage={git.modals.isGeneratingCommitMessage} - showCommitAllToggle={ - git.state.stagedFiles.length > 0 && - git.state.unstagedFiles.length > 0 - } - commitAll={git.modals.commitAll} - onCommitAllChange={git.actions.setCommitAll} - stagedFileCount={git.state.stagedFiles.length} - /> - )} - {pendingAfterCommit && ( - { - if (!open) { - git.actions.closeBranch(); - localHandoff.cancelPendingFlow(); - } - }} - branchName={git.modals.branchName} - onBranchNameChange={git.actions.setBranchName} - onConfirm={handleBranchConfirm} - isSubmitting={git.modals.isSubmitting} - error={git.modals.branchError} - /> - )} - - ); -} diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.test.ts b/apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.test.ts deleted file mode 100644 index 4499882cfb..0000000000 --- a/apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -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"; - -function makeTask(prUrl?: unknown): Task { - return { - id: "task-1", - latest_run: { output: { pr_url: prUrl } }, - } as unknown as Task; -} - -function makeSession(prUrl?: unknown): AgentSession { - return { cloudOutput: { pr_url: prUrl } } as unknown as AgentSession; -} - -describe("resolveCloudPrUrl", () => { - it("returns null when both task and session are undefined", () => { - expect(resolveCloudPrUrl(undefined, undefined)).toBeNull(); - }); - - it("returns task PR URL when available", () => { - const task = makeTask("https://github.com/org/repo/pull/1"); - expect(resolveCloudPrUrl(task, undefined)).toBe( - "https://github.com/org/repo/pull/1", - ); - }); - - it("returns session PR URL when task has none", () => { - const task = makeTask(undefined); - const session = makeSession("https://github.com/org/repo/pull/2"); - expect(resolveCloudPrUrl(task, session)).toBe( - "https://github.com/org/repo/pull/2", - ); - }); - - it("prefers task PR URL over session", () => { - const task = makeTask("https://github.com/org/repo/pull/1"); - const session = makeSession("https://github.com/org/repo/pull/2"); - expect(resolveCloudPrUrl(task, session)).toBe( - "https://github.com/org/repo/pull/1", - ); - }); - - it("ignores non-string pr_url values", () => { - expect(resolveCloudPrUrl(makeTask(123), makeSession(true))).toBeNull(); - expect(resolveCloudPrUrl(makeTask(null), makeSession(null))).toBeNull(); - }); - - it("ignores empty string pr_url", () => { - expect(resolveCloudPrUrl(makeTask(""), makeSession(""))).toBeNull(); - }); - - it("falls back to session when task pr_url is empty", () => { - const session = makeSession("https://github.com/org/repo/pull/3"); - expect(resolveCloudPrUrl(makeTask(""), session)).toBe( - "https://github.com/org/repo/pull/3", - ); - }); -}); diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.ts b/apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.ts deleted file mode 100644 index 972e338619..0000000000 --- a/apps/code/src/renderer/features/git-interaction/hooks/useCloudPrUrl.ts +++ /dev/null @@ -1,29 +0,0 @@ -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"; - -/** - * Extracts the PR URL from a task and/or session. The URL can arrive via the - * persisted TaskRun output or the live session's cloudOutput (pushed over SSE - * while the run is active), so both sources are consulted. - */ -export function resolveCloudPrUrl( - task: Task | undefined, - session: AgentSession | undefined, -): string | null { - const taskPrUrl = task?.latest_run?.output?.pr_url; - const sessionPrUrl = session?.cloudOutput?.pr_url; - - if (typeof taskPrUrl === "string" && taskPrUrl) return taskPrUrl; - 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/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/useGitQueries.ts b/apps/code/src/renderer/features/git-interaction/hooks/useGitQueries.ts deleted file mode 100644 index 7675687401..0000000000 --- a/apps/code/src/renderer/features/git-interaction/hooks/useGitQueries.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { useTRPC } from "@renderer/trpc"; -import { useQuery } from "@tanstack/react-query"; - -const EMPTY_DIFF_STATS = { filesChanged: 0, linesAdded: 0, linesRemoved: 0 }; -const EMPTY_CHANGED_FILES: never[] = []; - -const GIT_QUERY_DEFAULTS = { - staleTime: 30_000, -} as const; - -interface UseGitQueriesOptions { - enabled?: boolean; -} - -export function useGitQueries( - repoPath?: string, - options?: UseGitQueriesOptions, -) { - const trpc = useTRPC(); - const enabled = !!repoPath && (options?.enabled ?? true); - - const { data: isRepo = false, isLoading: isRepoLoading } = useQuery( - trpc.git.validateRepo.queryOptions( - { directoryPath: repoPath as string }, - { enabled, ...GIT_QUERY_DEFAULTS }, - ), - ); - - const repoEnabled = enabled && isRepo; - - const { - 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, - }, - ), - ); - - 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, - }, - ), - ); - - const { data: currentBranchData, isLoading: branchLoading } = useQuery( - trpc.git.getCurrentBranch.queryOptions( - { directoryPath: repoPath as string }, - { - 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, - }, - ), - ); - - const { data: syncStatus, isLoading: syncLoading } = useQuery( - trpc.git.getGitSyncStatus.queryOptions( - { directoryPath: repoPath as string }, - { - 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, - }, - ), - ); - - const { data: ghStatus } = useQuery( - trpc.git.getGhStatus.queryOptions(undefined, { - enabled, - ...GIT_QUERY_DEFAULTS, - staleTime: 60_000, - }), - ); - - 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, - }, - ), - ); - - const { data: latestCommit } = useQuery( - trpc.git.getLatestCommit.queryOptions( - { directoryPath: repoPath as string }, - { - enabled: repoEnabled, - ...GIT_QUERY_DEFAULTS, - }, - ), - ); - - useQuery( - trpc.git.getAllBranches.queryOptions( - { directoryPath: repoPath as string }, - { - enabled: repoEnabled, - ...GIT_QUERY_DEFAULTS, - staleTime: 60_000, - }, - ), - ); - - const hasChanges = changedFiles.length > 0; - const aheadOfRemote = syncStatus?.aheadOfRemote ?? 0; - const behind = syncStatus?.behind ?? 0; - const aheadOfDefault = syncStatus?.aheadOfDefault ?? 0; - const hasRemote = syncStatus?.hasRemote ?? true; - const isFeatureBranch = syncStatus?.isFeatureBranch ?? false; - const defaultBranch = repoInfo?.defaultBranch ?? null; - - return { - isRepo, - isRepoLoading, - changedFiles, - changesLoading, - diffStats, - syncStatus, - syncLoading, - repoInfo, - ghStatus, - prStatus, - latestCommit, - hasChanges, - aheadOfRemote, - behind, - aheadOfDefault, - hasRemote, - isFeatureBranch, - currentBranch, - branchLoading, - defaultBranch, - busyState, - isLoading: isRepoLoading || changesLoading || syncLoading, - }; -} - -export function usePrChangedFiles(prUrl: string | null, pollFast?: boolean) { - const trpc = useTRPC(); - return useQuery( - trpc.git.getPrChangedFiles.queryOptions( - { prUrl: prUrl as string }, - { - enabled: !!prUrl, - staleTime: pollFast ? 10_000 : 5 * 60_000, - refetchInterval: pollFast ? 10_000 : false, - retry: 1, - }, - ), - ); -} - -export function useBranchChangedFiles( - repo: string | null, - branch: string | null, - pollFast?: boolean, -) { - const trpc = useTRPC(); - return useQuery( - trpc.git.getBranchChangedFiles.queryOptions( - { repo: repo as string, branch: branch as string }, - { - enabled: !!repo && !!branch, - staleTime: pollFast ? 10_000 : 5 * 60_000, - refetchInterval: pollFast ? 10_000 : false, - retry: 1, - }, - ), - ); -} - -export function useLocalBranchChangedFiles( - directoryPath: string | null, - branch: string | null, -) { - const trpc = useTRPC(); - return useQuery( - trpc.git.getLocalBranchChangedFiles.queryOptions( - { - directoryPath: directoryPath as string, - branch: branch as string, - }, - { - enabled: !!directoryPath && !!branch, - staleTime: 30_000, - refetchOnMount: "always", - retry: 1, - }, - ), - ); -} diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useLinkedBranchPrUrl.ts b/apps/code/src/renderer/features/git-interaction/hooks/useLinkedBranchPrUrl.ts deleted file mode 100644 index 7f7540a3e1..0000000000 --- a/apps/code/src/renderer/features/git-interaction/hooks/useLinkedBranchPrUrl.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useTRPC } from "@renderer/trpc/client"; -import { useQuery } from "@tanstack/react-query"; - -interface UseLinkedBranchPrUrlArgs { - linkedBranch: string | null; - folderPath: string | null; -} - -/** - * Resolves the PR URL for a local task's linked branch by looking it up via - * `gh pr list --head`. Returns `null` when the task has no linked branch, no - * folder path, or the branch has no associated PR on GitHub. - */ -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, - }, - ), - ); - - return data ?? null; -} 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/hooks/usePrDetails.ts b/apps/code/src/renderer/features/git-interaction/hooks/usePrDetails.ts deleted file mode 100644 index 8ea85d8db6..0000000000 --- a/apps/code/src/renderer/features/git-interaction/hooks/usePrDetails.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { PrReviewThread } from "@main/services/git/schemas"; -import type { PrCommentThread } from "@renderer/features/code-review/utils/prCommentAnnotations"; -import { useTRPC } from "@renderer/trpc"; -import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; - -interface UsePrDetailsOptions { - includeComments?: boolean; -} - -function threadsToMap(threads: PrReviewThread[]): Map { - const map = new Map(); - for (const thread of threads) { - map.set(thread.rootId, { - rootId: thread.rootId, - nodeId: thread.nodeId, - isResolved: thread.isResolved, - comments: thread.comments, - filePath: thread.filePath, - }); - } - return map; -} - -export function usePrDetails( - prUrl: string | null, - options?: UsePrDetailsOptions, -) { - const { includeComments = false } = options ?? {}; - const trpc = useTRPC(); - - 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 commentThreads = useMemo( - () => threadsToMap(commentsQuery.data ?? []), - [commentsQuery.data], - ); - - return { - meta: { - state: metaQuery.data?.state ?? null, - merged: metaQuery.data?.merged ?? false, - draft: metaQuery.data?.draft ?? false, - isLoading: metaQuery.isLoading, - }, - commentThreads, - }; -} diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts b/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts deleted file mode 100644 index d68b0d57d3..0000000000 --- a/apps/code/src/renderer/features/git-interaction/hooks/useTaskPrUrl.ts +++ /dev/null @@ -1,40 +0,0 @@ -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 { useQuery } from "@tanstack/react-query"; - -/** - * Resolves the PR URL for a task across all task kinds: - * - cloud: the cloud run's `pr_url` - * - local: the linked-branch lookup, falling back to `getPrStatus` on the - * active repo path - * - * Shared by the task header (`TaskActionsMenu`) and the command center cell - * header (`CommandCenterPRButton`) so they always agree on what PR a task - * points at. - */ -export function useTaskPrUrl(taskId: string, isCloud: boolean): string | null { - const cloudPrUrl = useCloudPrUrl(taskId); - const workspace = useWorkspace(taskId); - const linkedPrUrl = useLinkedBranchPrUrl({ - linkedBranch: workspace?.linkedBranch ?? null, - folderPath: workspace?.folderPath ?? null, - }); - const localRepoPath = useLocalRepoPath(taskId); - - const trpc = useTRPC(); - 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/apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts b/apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts deleted file mode 100644 index 90ba900ef1..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/branchCreation.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -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"; - -describe("branchCreation", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("getBranchNameInputState", () => { - it("sanitizes spaces and returns no error for valid names", () => { - expect(getBranchNameInputState("feature my branch")).toEqual({ - sanitized: "feature-my-branch", - error: null, - }); - }); - - it("returns validation errors for invalid names", () => { - expect(getBranchNameInputState("feature..branch")).toEqual({ - sanitized: "feature..branch", - error: 'Branch name cannot contain "..".', - }); - }); - }); - - describe("createBranch", () => { - it("returns missing-repo error when repo path is not provided", async () => { - const result = await createBranch({ - repoPath: undefined, - rawBranchName: "feature/test", - }); - - expect(result).toEqual({ - success: false, - error: "Select a repository folder first.", - reason: "missing-repo", - }); - expect(mockCreateBranchMutate).not.toHaveBeenCalled(); - expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); - }); - - it("returns validation error for empty branch name", async () => { - const result = await createBranch({ - repoPath: "/repo", - rawBranchName: " ", - }); - - expect(result).toEqual({ - success: false, - error: "Branch name is required.", - reason: "validation", - }); - expect(mockCreateBranchMutate).not.toHaveBeenCalled(); - expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); - }); - - it("returns validation error for invalid branch names", async () => { - const result = await createBranch({ - repoPath: "/repo", - rawBranchName: "feature..branch", - }); - - expect(result).toEqual({ - success: false, - error: 'Branch name cannot contain "..".', - reason: "validation", - }); - expect(mockCreateBranchMutate).not.toHaveBeenCalled(); - expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); - }); - - it("creates branch with trimmed name and invalidates branch queries", async () => { - mockCreateBranchMutate.mockResolvedValueOnce(undefined); - - const result = await createBranch({ - repoPath: "/repo", - rawBranchName: " feature/test ", - }); - - expect(mockCreateBranchMutate).toHaveBeenCalledWith({ - directoryPath: "/repo", - branchName: "feature/test", - }); - expect(mockInvalidateGitBranchQueries).toHaveBeenCalledWith("/repo"); - expect(result).toEqual({ - success: true, - branchName: "feature/test", - }); - }); - - it("returns request error with message when mutate throws Error", async () => { - const error = new Error("boom"); - mockCreateBranchMutate.mockRejectedValueOnce(error); - - const result = await createBranch({ - repoPath: "/repo", - rawBranchName: "feature/test", - }); - - expect(result).toEqual({ - success: false, - error: "boom", - reason: "request", - rawError: error, - }); - expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); - }); - - it("returns fallback error when mutate throws non-Error value", async () => { - mockCreateBranchMutate.mockRejectedValueOnce("oops"); - - const result = await createBranch({ - repoPath: "/repo", - rawBranchName: "feature/test", - }); - - expect(result).toEqual({ - success: false, - error: "Failed to create branch.", - reason: "request", - rawError: "oops", - }); - expect(mockInvalidateGitBranchQueries).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts b/apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts deleted file mode 100644 index 60b0955092..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/branchCreation.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - sanitizeBranchName, - validateBranchName, -} from "@features/git-interaction/utils/branchNameValidation"; -import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; -import { trpcClient } from "@renderer/trpc"; - -export interface BranchNameInputState { - sanitized: string; - error: string | null; -} - -export type CreateBranchResult = - | { - success: true; - branchName: string; - } - | { - success: false; - error: string; - reason: "missing-repo" | "validation" | "request"; - rawError?: unknown; - }; - -interface CreateBranchInput { - repoPath?: string; - rawBranchName: string; -} - -function getCreateBranchError(error: unknown): string { - return error instanceof Error ? error.message : "Failed to create branch."; -} - -export function getBranchNameInputState(value: string): BranchNameInputState { - const sanitized = sanitizeBranchName(value); - return { - sanitized, - error: validateBranchName(sanitized), - }; -} - -export async function createBranch({ - repoPath, - rawBranchName, -}: CreateBranchInput): Promise { - if (!repoPath) { - return { - success: false, - error: "Select a repository folder first.", - reason: "missing-repo", - }; - } - - const branchName = rawBranchName.trim(); - if (!branchName) { - return { - success: false, - error: "Branch name is required.", - reason: "validation", - }; - } - - const validationError = validateBranchName(branchName); - if (validationError) { - return { - success: false, - error: validationError, - reason: "validation", - }; - } - - try { - await trpcClient.git.createBranch.mutate({ - directoryPath: repoPath, - branchName, - }); - - invalidateGitBranchQueries(repoPath); - - return { - success: true, - branchName, - }; - } catch (error) { - return { - success: false, - error: getCreateBranchError(error), - reason: "request", - rawError: error, - }; - } -} diff --git a/apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.test.ts b/apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.test.ts deleted file mode 100644 index dd3789f400..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - sanitizeBranchName, - validateBranchName, -} from "@features/git-interaction/utils/branchNameValidation"; -import { describe, expect, it } from "vitest"; - -describe("sanitizeBranchName", () => { - it("replaces spaces with dashes", () => { - expect(sanitizeBranchName("my new branch")).toBe("my-new-branch"); - }); - - it("replaces multiple consecutive spaces", () => { - expect(sanitizeBranchName("a b c")).toBe("a--b---c"); - }); - - it("returns the same string when there are no spaces", () => { - expect(sanitizeBranchName("feature/foo-bar")).toBe("feature/foo-bar"); - }); - - it("handles empty string", () => { - expect(sanitizeBranchName("")).toBe(""); - }); -}); - -describe("validateBranchName", () => { - it("returns null for valid branch names", () => { - expect(validateBranchName("feature/my-branch")).toBeNull(); - expect(validateBranchName("fix-123")).toBeNull(); - expect(validateBranchName("release/v1.0.0")).toBeNull(); - expect(validateBranchName("user/feature")).toBeNull(); - }); - - it("returns null for empty string", () => { - expect(validateBranchName("")).toBeNull(); - }); - - it("rejects control characters", () => { - expect(validateBranchName("branch\x00name")).not.toBeNull(); - expect(validateBranchName("branch\x1fname")).not.toBeNull(); - expect(validateBranchName("branch\x7fname")).not.toBeNull(); - }); - - it('rejects ".."', () => { - expect(validateBranchName("branch..name")).not.toBeNull(); - }); - - it("rejects forbidden characters", () => { - for (const char of ["~", "^", ":", "?", "*", "[", "]", "\\"]) { - expect(validateBranchName(`branch${char}name`)).not.toBeNull(); - } - }); - - it("rejects spaces", () => { - expect(validateBranchName("branch name")).not.toBeNull(); - }); - - it("rejects names starting or ending with a dot", () => { - expect(validateBranchName(".branch")).not.toBeNull(); - expect(validateBranchName("branch.")).not.toBeNull(); - }); - - it('rejects names ending with ".lock"', () => { - expect(validateBranchName("branch.lock")).not.toBeNull(); - }); - - it('rejects "@{"', () => { - expect(validateBranchName("branch@{0}")).not.toBeNull(); - }); - - it('rejects bare "@"', () => { - expect(validateBranchName("@")).not.toBeNull(); - }); - - it('allows "@" as part of a longer name', () => { - expect(validateBranchName("user@feature")).toBeNull(); - }); - - it('rejects "//"', () => { - expect(validateBranchName("branch//name")).not.toBeNull(); - }); - - it("rejects path components starting or ending with a dot", () => { - expect(validateBranchName("feature/.hidden")).not.toBeNull(); - expect(validateBranchName("feature/name.")).not.toBeNull(); - }); - - it("returns a descriptive error message", () => { - expect(validateBranchName("a..b")).toBe('Branch name cannot contain "..".'); - }); -}); diff --git a/apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.ts b/apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.ts deleted file mode 100644 index 51b4f12e0d..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/branchNameValidation.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Replaces spaces with dashes so users can type natural words and get a - * valid branch name without thinking about it. - */ -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; - - // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching ASCII control characters forbidden by git - if (/[\x00-\x1f\x7f]/.test(name)) { - return "Branch name cannot contain control characters."; - } - - if (name.includes("..")) { - return 'Branch name cannot contain "..".'; - } - - if (/[~^:?*[\]\\]/.test(name)) { - return "Branch name cannot contain ~, ^, :, ?, *, [, ], or \\."; - } - - if (name.includes(" ")) { - return "Branch name cannot contain spaces."; - } - - if (name.startsWith(".") || name.endsWith(".")) { - return "Branch name cannot start or end with a dot."; - } - - if (name.endsWith(".lock")) { - return 'Branch name cannot end with ".lock".'; - } - - if (name.includes("@{")) { - return 'Branch name cannot contain "@{".'; - } - - if (name === "@") { - return 'Branch name cannot be "@".'; - } - - if (name.includes("//")) { - return 'Branch name cannot contain "//".'; - } - - const components = name.split("/"); - for (const component of components) { - if (component.startsWith(".") || component.endsWith(".")) { - return "Path components cannot start or end with a dot."; - } - } - - return null; -} 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/diffStats.ts b/apps/code/src/renderer/features/git-interaction/utils/diffStats.ts deleted file mode 100644 index ab0d883265..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/diffStats.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ChangedFile } from "@shared/types"; - -export interface DiffStats { - filesChanged: number; - linesAdded: number; - linesRemoved: number; -} - -export function computeDiffStats(files: ChangedFile[]): DiffStats { - let linesAdded = 0; - let linesRemoved = 0; - const uniquePaths = new Set(); - for (const file of files) { - linesAdded += file.linesAdded ?? 0; - linesRemoved += file.linesRemoved ?? 0; - uniquePaths.add(file.path); - } - return { filesChanged: uniquePaths.size, linesAdded, linesRemoved }; -} - -export function formatFileCountLabel( - stagedOnly: boolean, - stagedFileCount: number, - totalFileCount: number, -): string { - if (stagedOnly) { - return `${stagedFileCount} staged file${stagedFileCount === 1 ? "" : "s"}`; - } - return `${totalFileCount} file${totalFileCount === 1 ? "" : "s"}`; -} 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/gitStatusUtils.ts b/apps/code/src/renderer/features/git-interaction/utils/gitStatusUtils.ts deleted file mode 100644 index b536c00bf1..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/gitStatusUtils.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { GitFileStatus } from "@shared/types"; - -export type StatusColor = "green" | "orange" | "red" | "blue" | "gray"; -export interface StatusIndicator { - label: string; - fullLabel: string; - color: StatusColor; -} -export function getStatusIndicator(status: GitFileStatus): StatusIndicator { - switch (status) { - case "added": - case "untracked": - return { label: "A", fullLabel: "Added", color: "green" }; - case "deleted": - return { label: "D", fullLabel: "Deleted", color: "red" }; - case "modified": - return { label: "M", fullLabel: "Modified", color: "orange" }; - case "renamed": - return { label: "R", fullLabel: "Renamed", color: "blue" }; - default: - return { label: "?", fullLabel: "Unknown", color: "gray" }; - } -} 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/git-interaction/utils/prStatus.tsx b/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx deleted file mode 100644 index 96c4c8860a..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/prStatus.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { PrActionType } from "@main/services/git/schemas"; -import { - Check, - GitMerge, - GitPullRequest, - type Icon, - PencilSimple, - X, -} from "@phosphor-icons/react"; - -export interface PrAction { - id: PrActionType; - label: string; -} - -export interface PrVisualConfig { - color: "gray" | "green" | "red" | "purple"; - Icon: Icon; - label: string; - actions: PrAction[]; -} - -export function getPrVisualConfig( - state: string, - merged: boolean, - draft: boolean, -): PrVisualConfig { - if (merged) { - return { - color: "purple", - Icon: GitMerge, - label: "Merged", - actions: [], - }; - } - if (state === "closed") { - return { - color: "red", - Icon: GitPullRequest, - label: "Closed", - actions: [{ id: "reopen", label: "Reopen PR" }], - }; - } - if (draft) { - return { - color: "gray", - Icon: GitPullRequest, - label: "Draft", - actions: [ - { id: "ready", label: "Ready for review" }, - { id: "close", label: "Close PR" }, - ], - }; - } - return { - color: "green", - Icon: GitPullRequest, - label: "Open", - actions: [ - { id: "draft", label: "Convert to draft" }, - { id: "close", label: "Close PR" }, - ], - }; -} - -export function getOptimisticPrState(action: PrActionType) { - switch (action) { - case "close": - return { state: "closed", merged: false, draft: false }; - case "reopen": - return { state: "open", merged: false, draft: false }; - case "ready": - return { state: "open", merged: false, draft: false }; - case "draft": - return { state: "open", merged: false, draft: true }; - } -} - -export const PR_ACTION_LABELS: Record = { - close: "PR closed", - reopen: "PR reopened", - ready: "PR marked as ready for review", - draft: "PR converted to draft", -}; - -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/apps/code/src/renderer/features/git-interaction/utils/updateGitCache.ts b/apps/code/src/renderer/features/git-interaction/utils/updateGitCache.ts deleted file mode 100644 index 9eca5382ee..0000000000 --- a/apps/code/src/renderer/features/git-interaction/utils/updateGitCache.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { GitStateSnapshot } from "@main/services/git/schemas"; -import { trpc } from "@renderer/trpc"; -import type { QueryClient } from "@tanstack/react-query"; - -export function updateGitCacheFromSnapshot( - queryClient: QueryClient, - repoPath: string, - snapshot: GitStateSnapshot, -): void { - const input = { directoryPath: repoPath }; - - if (snapshot.changedFiles !== undefined) { - queryClient.setQueryData( - trpc.git.getChangedFilesHead.queryKey(input), - snapshot.changedFiles, - ); - } - - if (snapshot.diffStats !== undefined) { - queryClient.setQueryData( - trpc.git.getDiffStats.queryKey(input), - snapshot.diffStats, - ); - } - - if (snapshot.syncStatus !== undefined) { - queryClient.setQueryData( - trpc.git.getGitSyncStatus.queryKey(input), - snapshot.syncStatus, - ); - if (snapshot.syncStatus.currentBranch !== undefined) { - queryClient.setQueryData( - trpc.git.getCurrentBranch.queryKey(input), - snapshot.syncStatus.currentBranch, - ); - } - } - - if (snapshot.latestCommit !== undefined) { - queryClient.setQueryData( - trpc.git.getLatestCommit.queryKey(input), - snapshot.latestCommit, - ); - } - - if (snapshot.prStatus !== undefined) { - queryClient.setQueryData( - trpc.git.getPrStatus.queryKey(input), - snapshot.prStatus, - ); - } -} diff --git a/apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts b/apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts index 2594cbeee8..ddca64b0bb 100644 --- a/apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts +++ b/apps/code/src/renderer/features/inbox/devtools/inboxDemoConsole.ts @@ -3,7 +3,7 @@ import type { SignalReportArtefact, SignalReportArtefactsResponse, SignalReportsResponse, -} from "@shared/types"; +} from "@posthog/shared/domain-types"; import { logger } from "@utils/logger"; import { queryClient } from "@utils/queryClient"; 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/useEvaluations.ts b/apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts deleted file mode 100644 index dcd207e935..0000000000 --- a/apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useAuthStore } from "@features/auth/stores/authStore"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { Evaluation } from "@renderer/api/posthogClient"; - -const POLL_INTERVAL_MS = 5_000; - -export function useEvaluations() { - const projectId = useAuthStore((s) => s.projectId); - return useAuthenticatedQuery( - ["evaluations", projectId], - (client) => - projectId ? client.listEvaluations(projectId) : Promise.resolve([]), - { - enabled: !!projectId, - staleTime: POLL_INTERVAL_MS, - refetchInterval: POLL_INTERVAL_MS, - }, - ); -} diff --git a/apps/code/src/renderer/features/inbox/hooks/useExternalDataSources.ts b/apps/code/src/renderer/features/inbox/hooks/useExternalDataSources.ts deleted file mode 100644 index 55fe227f2d..0000000000 --- a/apps/code/src/renderer/features/inbox/hooks/useExternalDataSources.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { ExternalDataSource } from "@renderer/api/posthogClient"; - -export function useExternalDataSources() { - const projectId = useAuthStateValue((state) => state.projectId); - return useAuthenticatedQuery( - ["external-data-sources", projectId], - (client) => - projectId - ? client.listExternalDataSources(projectId) - : Promise.resolve([]), - { enabled: !!projectId, staleTime: 60_000 }, - ); -} 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/useInboxEngagementTracker.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts deleted file mode 100644 index a4de5c8f50..0000000000 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxEngagementTracker.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { consumePendingInboxOpenMethod } from "@features/inbox/utils/pendingInboxOpenMethod"; -import type { SignalReport } from "@shared/types"; -import { - ANALYTICS_EVENTS, - type InboxReportActionProperties, - type InboxReportCloseMethod, -} from "@shared/types/analytics"; -import { track } from "@utils/analytics"; -import { useCallback, useEffect, useRef } from "react"; - -interface OpenInfo { - reportId: string; - reportTitle: string | null; - reportCreatedAt: string | null; - reportPriority: string | null; - reportActionability: string | null; - openedAt: number; - rank: number; - listSize: number; - 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; - /** - * Fires INBOX_REPORT_ACTION for the current open or an explicit report id. - * - * `rank`, `list_size`, `priority`, and `actionability` default to the live tracker - * state (or a lookup in the visible list for non-current reports). Callers that fire - * after an async mutation (bulk dismiss/delete/snooze/reingest, single-report dismiss - * confirm) should snapshot the pre-mutation values and pass them through — by the - * time the promise resolves the visible list has usually been re-queried without the - * affected report. - */ - signalAction( - action: Omit< - InboxReportActionProperties, - "rank" | "list_size" | "priority" | "actionability" - > & { - rank?: number; - list_size?: number; - priority?: string | null; - actionability?: string | null; - }, - ): void; -} - -export interface UseInboxEngagementTrackerOptions { - currentReportId: string | null; - currentReport: SignalReport | null; - reports: SignalReport[]; - isInboxView: boolean; -} - -export function useInboxEngagementTracker( - options: UseInboxEngagementTrackerOptions, -): InboxEngagementTracker { - const { currentReportId, currentReport, reports, isInboxView } = options; - - const openInfoRef = useRef(null); - const previousReportIdRef = useRef(null); - - // Keep reports/currentReport accessible to callbacks without retriggering effects. - const reportsRef = useRef(reports); - reportsRef.current = reports; - const currentReportRef = useRef(currentReport); - currentReportRef.current = currentReport; - - const fireClose = useCallback((closeMethod: InboxReportCloseMethod) => { - const info = openInfoRef.current; - if (!info) return; - track(ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, { - report_id: info.reportId, - report_title: info.reportTitle, - report_age_hours: reportAgeHours(info.reportCreatedAt), - priority: info.reportPriority, - actionability: info.reportActionability, - time_spent_ms: Date.now() - info.openedAt, - scrolled: info.hasScrolled, - close_method: closeMethod, - }); - openInfoRef.current = null; - }, []); - - // Drive OPENED / CLOSED transitions on selection change. - useEffect(() => { - const prevInfo = openInfoRef.current; - const prevId = prevInfo?.reportId ?? null; - - if (currentReportId === prevId) return; - - if (prevInfo) { - fireClose(currentReportId == null ? "deselected" : "next_report"); - } - - if (currentReportId != null) { - const visibleReports = reportsRef.current; - const rank = visibleReports.findIndex((r) => r.id === currentReportId); - const listSize = visibleReports.length; - const openMethod = consumePendingInboxOpenMethod(); - const report = currentReportRef.current; - - const info: OpenInfo = { - reportId: currentReportId, - reportTitle: report?.title ?? null, - reportCreatedAt: report?.created_at ?? null, - reportPriority: report?.priority ?? null, - reportActionability: report?.actionability ?? null, - openedAt: Date.now(), - rank, - listSize, - hasScrolled: false, - }; - openInfoRef.current = info; - - track(ANALYTICS_EVENTS.INBOX_REPORT_OPENED, { - report_id: currentReportId, - report_title: info.reportTitle, - report_age_hours: reportAgeHours(info.reportCreatedAt), - status: report?.status ?? null, - priority: info.reportPriority, - actionability: info.reportActionability, - source_products: report?.source_products ?? [], - rank, - list_size: listSize, - open_method: openMethod, - previous_report_id: previousReportIdRef.current, - }); - } - - previousReportIdRef.current = currentReportId; - }, [currentReportId, fireClose]); - - // Close on inbox-view exit. - useEffect(() => { - if (isInboxView) return; - if (openInfoRef.current) { - fireClose("navigated_away"); - } - }, [isInboxView, fireClose]); - - // Close on unmount (covers app quit / hard navigation). - useEffect(() => { - return () => { - if (openInfoRef.current) { - fireClose("unmount"); - } - }; - }, [fireClose]); - - const signalScroll = useCallback(() => { - const info = openInfoRef.current; - if (!info || info.hasScrolled) return; - info.hasScrolled = true; - track(ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED, { - report_id: info.reportId, - report_title: info.reportTitle, - report_age_hours: reportAgeHours(info.reportCreatedAt), - priority: info.reportPriority, - actionability: info.reportActionability, - rank: info.rank, - list_size: info.listSize, - time_since_open_ms: Date.now() - info.openedAt, - }); - }, []); - - const signalAction = useCallback( - ( - action: Omit< - InboxReportActionProperties, - "rank" | "list_size" | "priority" | "actionability" - > & { - rank?: number; - list_size?: number; - priority?: string | null; - actionability?: string | null; - }, - ) => { - const info = openInfoRef.current; - const visibleReports = reportsRef.current; - const { - rank: rankOverride, - list_size: listSizeOverride, - priority: priorityOverride, - 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); - track(ANALYTICS_EVENTS.INBOX_REPORT_ACTION, { - ...rest, - rank, - list_size: listSize, - priority, - actionability, - }); - }, - [], - ); - - return { signalScroll, signalAction }; -} 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/useSignalSourceConfigs.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceConfigs.ts deleted file mode 100644 index 6cc50445bc..0000000000 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceConfigs.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { SignalSourceConfig } from "@renderer/api/posthogClient"; - -export function useSignalSourceConfigs() { - const projectId = useAuthStateValue((state) => state.projectId); - return useAuthenticatedQuery( - ["signals", "source-configs", projectId], - (client) => - projectId - ? client.listSignalSourceConfigs(projectId) - : Promise.resolve([]), - { enabled: !!projectId, staleTime: 30_000 }, - ); -} 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/stores/inboxSignalsSidebarStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsSidebarStore.ts deleted file mode 100644 index 1445a4ea84..0000000000 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsSidebarStore.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createSidebarStore } from "@stores/createSidebarStore"; - -export const useInboxSignalsSidebarStore = createSidebarStore({ - name: "inbox-signals-sidebar-storage", - defaultWidth: 380, - defaultOpen: false, -}); diff --git a/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.ts b/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.ts deleted file mode 100644 index 3e67772b07..0000000000 --- a/apps/code/src/renderer/features/inbox/utils/buildCreatePrReportPrompt.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getDeeplinkProtocol } from "@shared/deeplink"; - -interface BuildCreatePrReportPromptOptions { - reportId: string; - isDevBuild: boolean; -} - -export function buildCreatePrReportPrompt({ - reportId, - isDevBuild, -}: BuildCreatePrReportPromptOptions): string { - 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.`; -} 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/filterReports.test.ts b/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts deleted file mode 100644 index 6042daec01..0000000000 --- a/apps/code/src/renderer/features/inbox/utils/filterReports.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { SignalReport } from "@shared/types"; -import { describe, expect, it } from "vitest"; -import { - buildSignalReportListOrdering, - buildSuggestedReviewerFilterParam, - filterReportsBySearch, -} from "./filterReports"; - -function makeReport(overrides: Partial = {}): SignalReport { - return { - id: "1", - title: "Test report", - summary: "A summary of the report", - status: "ready", - total_weight: 50, - signal_count: 10, - created_at: "2025-01-01T00:00:00Z", - updated_at: "2025-01-02T00:00:00Z", - artefact_count: 3, - ...overrides, - }; -} - -describe("filterReportsBySearch", () => { - const reports = [ - makeReport({ - id: "1", - title: "Login errors spike", - summary: "Users cannot log in", - }), - makeReport({ - id: "2", - title: "Checkout flow broken", - summary: "Payment page crashes", - }), - makeReport({ - id: "3", - title: "Slow dashboard load", - summary: "Performance degradation", - }), - ]; - - it("returns all reports when query is empty", () => { - expect(filterReportsBySearch(reports, "")).toEqual(reports); - }); - - it("returns all reports when query is whitespace", () => { - expect(filterReportsBySearch(reports, " ")).toEqual(reports); - }); - - it("filters by title match", () => { - const result = filterReportsBySearch(reports, "login"); - expect(result).toHaveLength(1); - expect(result[0].id).toBe("1"); - }); - - it("filters by summary match", () => { - const result = filterReportsBySearch(reports, "payment"); - expect(result).toHaveLength(1); - expect(result[0].id).toBe("2"); - }); - - it("is case insensitive", () => { - const result = filterReportsBySearch(reports, "DASHBOARD"); - expect(result).toHaveLength(1); - expect(result[0].id).toBe("3"); - }); - - it("handles null title", () => { - const withNull = [ - makeReport({ id: "4", title: null, summary: "Some summary" }), - ]; - const result = filterReportsBySearch(withNull, "some"); - expect(result).toHaveLength(1); - }); - - it("handles null summary", () => { - const withNull = [makeReport({ id: "5", title: "A title", summary: null })]; - const result = filterReportsBySearch(withNull, "title"); - expect(result).toHaveLength(1); - }); - - it("handles both null title and summary", () => { - const withNull = [makeReport({ id: "6", title: null, summary: null })]; - const result = filterReportsBySearch(withNull, "anything"); - expect(result).toHaveLength(0); - }); - - it("returns empty array when no matches", () => { - const result = filterReportsBySearch(reports, "nonexistent"); - expect(result).toHaveLength(0); - }); - - it("returns empty array for empty input", () => { - expect(filterReportsBySearch([], "test")).toEqual([]); - }); -}); - -describe("buildSignalReportListOrdering", () => { - it("puts status then suggested reviewer then descending field", () => { - expect(buildSignalReportListOrdering("total_weight", "desc")).toBe( - "status,-is_suggested_reviewer,-total_weight", - ); - }); - - it("puts status then suggested reviewer then ascending field", () => { - expect(buildSignalReportListOrdering("created_at", "asc")).toBe( - "status,-is_suggested_reviewer,created_at", - ); - }); - - it("works for signal_count", () => { - expect(buildSignalReportListOrdering("signal_count", "desc")).toBe( - "status,-is_suggested_reviewer,-signal_count", - ); - }); -}); - -describe("buildSuggestedReviewerFilterParam", () => { - it("returns undefined for an empty array", () => { - expect(buildSuggestedReviewerFilterParam([])).toBeUndefined(); - }); - - it("trims reviewer ids and joins them with commas", () => { - expect( - buildSuggestedReviewerFilterParam([ - " reviewer-1 ", - "reviewer-2", - " reviewer-3", - ]), - ).toBe("reviewer-1,reviewer-2,reviewer-3"); - }); - - it("deduplicates reviewer ids after trimming", () => { - expect( - buildSuggestedReviewerFilterParam([ - " reviewer-1 ", - "reviewer-2", - "reviewer-1", - " reviewer-2 ", - ]), - ).toBe("reviewer-1,reviewer-2"); - }); - - it("drops blank reviewer ids", () => { - expect( - buildSuggestedReviewerFilterParam([ - "reviewer-1", - " ", - "reviewer-2", - "", - ]), - ).toBe("reviewer-1,reviewer-2"); - }); -}); diff --git a/apps/code/src/renderer/features/inbox/utils/filterReports.ts b/apps/code/src/renderer/features/inbox/utils/filterReports.ts deleted file mode 100644 index 82848f4ae1..0000000000 --- a/apps/code/src/renderer/features/inbox/utils/filterReports.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { - SignalReport, - SignalReportOrderingField, - SignalReportStatus, -} from "@shared/types"; - -function normalizeReviewerId(value: string): string { - return value.trim(); -} - -/** - * Reports that are surfaced to the current user as needing review: ready, - * immediately actionable, and addressed to them. Used for both the sidebar - * red badge count and the inbox toolbar "up for review" byline so the two - * numbers always agree. - */ -export function isReportUpForReview(report: SignalReport): boolean { - return ( - report.status === "ready" && - report.is_suggested_reviewer === true && - report.actionability === "immediately_actionable" - ); -} - -export function filterReportsBySearch( - reports: SignalReport[], - query: string, -): SignalReport[] { - const trimmed = query.trim(); - if (!trimmed) return reports; - - const lower = trimmed.toLowerCase(); - return reports.filter( - (report) => - report.title?.toLowerCase().includes(lower) || - report.summary?.toLowerCase().includes(lower) || - report.id.toLowerCase().includes(lower), - ); -} - -/** - * Build a comma-separated status filter string for the API from an array of statuses. - */ -export function buildStatusFilterParam(statuses: SignalReportStatus[]): string { - return statuses.join(","); -} - -/** - * Comma-separated `ordering` for the signal report list API: - * 1. Status rank (ready first — semantic server-side rank, always applied) - * 2. Suggested reviewer (current user's reports first) - * 3. Toolbar-selected field (priority, total_weight, created_at, etc.) - */ -export function buildSignalReportListOrdering( - field: SignalReportOrderingField, - direction: "asc" | "desc", -): string { - const fieldKey = direction === "desc" ? `-${field}` : field; - return `status,-is_suggested_reviewer,${fieldKey}`; -} - -export function buildSuggestedReviewerFilterParam( - reviewerIds: string[], -): string | undefined { - const normalizedIds = reviewerIds.map(normalizeReviewerId).filter(Boolean); - - if (normalizedIds.length === 0) { - return undefined; - } - - return Array.from(new Set(normalizedIds)).join(","); -} 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/inbox/utils/suggestedReviewerFilters.test.ts b/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.test.ts deleted file mode 100644 index f8ee0c1a88..0000000000 --- a/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { AvailableSuggestedReviewer } from "@shared/types"; -import { describe, expect, it } from "vitest"; -import { - buildSuggestedReviewerFilterOptions, - getSuggestedReviewerDisplayName, -} from "./suggestedReviewerFilters"; - -function makeReviewer( - overrides: Partial = {}, -): AvailableSuggestedReviewer { - return { - uuid: "reviewer-1", - name: "Alice Jones", - email: "alice@example.com", - github_login: "alicejones", - ...overrides, - }; -} - -describe("getSuggestedReviewerDisplayName", () => { - it("returns name when present", () => { - expect( - getSuggestedReviewerDisplayName({ - ...makeReviewer({ name: "Alice Jones" }), - isMe: false, - }), - ).toBe("Alice Jones"); - }); - - it("falls back to email when name is missing", () => { - expect( - getSuggestedReviewerDisplayName({ - ...makeReviewer({ - name: "", - email: "fallback@example.com", - }), - isMe: false, - }), - ).toBe("fallback@example.com"); - }); - - it("falls back to Unknown user when name and email are missing", () => { - expect( - getSuggestedReviewerDisplayName({ - ...makeReviewer({ - name: "", - email: "", - }), - isMe: false, - }), - ).toBe("Unknown user"); - }); - - it("appends Me for the pinned current user", () => { - expect( - getSuggestedReviewerDisplayName({ - ...makeReviewer({ name: "Boss Person" }), - isMe: true, - }), - ).toBe("Boss Person (Me)"); - }); -}); - -describe("buildSuggestedReviewerFilterOptions", () => { - it("pins the current user to the top and marks them as me", () => { - const me = { - uuid: "me-id", - first_name: "Boss", - last_name: "Person", - email: "boss@example.com", - }; - const options = buildSuggestedReviewerFilterOptions( - [ - makeReviewer({ - uuid: "other-id", - name: "Alice Jones", - }), - ], - me, - ); - - expect(options).toHaveLength(2); - expect(options[0]).toMatchObject({ - uuid: "me-id", - name: "Boss Person", - isMe: true, - showSeparatorBelow: true, - }); - expect(getSuggestedReviewerDisplayName(options[0])).toBe( - "Boss Person (Me)", - ); - expect(options[1]).toMatchObject({ - uuid: "other-id", - name: "Alice Jones", - isMe: false, - showSeparatorBelow: false, - }); - }); - - it("deduplicates the current user if already present in backend results", () => { - const me = { - uuid: "me-id", - first_name: "Boss", - last_name: "Person", - email: "boss@example.com", - }; - const options = buildSuggestedReviewerFilterOptions( - [ - makeReviewer({ - uuid: "me-id", - name: "Old Name", - email: "old@example.com", - }), - makeReviewer({ - uuid: "other-id", - name: "Alice Jones", - }), - ], - me, - ); - - expect(options.map((option) => option.uuid)).toEqual(["me-id", "other-id"]); - expect(options[0]).toMatchObject({ - uuid: "me-id", - name: "Boss Person", - email: "boss@example.com", - isMe: true, - }); - }); - - it("sorts backend reviewers alphabetically by name", () => { - const options = buildSuggestedReviewerFilterOptions( - [ - makeReviewer({ uuid: "c", name: "Charlie Zebra" }), - makeReviewer({ uuid: "a", name: "Alice Jones" }), - makeReviewer({ uuid: "b", name: "Bob Smith" }), - ], - null, - ); - - expect(options.map((option) => option.uuid)).toEqual(["a", "b", "c"]); - expect(options.map((option) => option.name)).toEqual([ - "Alice Jones", - "Bob Smith", - "Charlie Zebra", - ]); - }); - - it("uses email and uuid as stable alphabetical tie-breakers", () => { - const options = buildSuggestedReviewerFilterOptions( - [ - makeReviewer({ uuid: "b", name: "", email: "b@example.com" }), - makeReviewer({ uuid: "a", name: "", email: "a@example.com" }), - makeReviewer({ uuid: "c", name: "", email: "a@example.com" }), - ], - null, - ); - - expect(options.map((option) => option.uuid)).toEqual(["a", "c", "b"]); - }); - - it("returns backend reviewers unchanged when there is no current user", () => { - const reviewers = [ - makeReviewer({ uuid: "b", name: "Bob Smith" }), - makeReviewer({ uuid: "a", name: "Alice Jones" }), - ]; - - const options = buildSuggestedReviewerFilterOptions(reviewers, null); - - expect(options).toHaveLength(2); - expect(options[0]).toMatchObject({ - uuid: "a", - name: "Alice Jones", - isMe: false, - }); - expect(options[1]).toMatchObject({ - uuid: "b", - name: "Bob Smith", - isMe: false, - }); - }); -}); diff --git a/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.ts b/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.ts deleted file mode 100644 index d8a772917d..0000000000 --- a/apps/code/src/renderer/features/inbox/utils/suggestedReviewerFilters.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { AvailableSuggestedReviewer } from "@shared/types"; - -export interface CurrentSuggestedReviewerUser { - uuid: string; - email?: string | null; - first_name?: string | null; - last_name?: string | null; -} - -export interface SuggestedReviewerFilterOption { - uuid: string; - name: string; - email: string; - github_login: string; - isMe: boolean; - showSeparatorBelow: boolean; -} - -function normalizeString(value: string | null | undefined): string { - return typeof value === "string" ? value.trim() : ""; -} - -function buildCurrentUserName( - currentUser?: CurrentSuggestedReviewerUser | null, -): string { - const firstName = normalizeString(currentUser?.first_name); - const lastName = normalizeString(currentUser?.last_name); - return [firstName, lastName].filter(Boolean).join(" "); -} - -function sortReviewerOptionsByName( - reviewers: SuggestedReviewerFilterOption[], -): SuggestedReviewerFilterOption[] { - return [...reviewers].sort((a, b) => { - const aName = normalizeString(a.name).toLowerCase(); - const bName = normalizeString(b.name).toLowerCase(); - const aEmail = normalizeString(a.email).toLowerCase(); - const bEmail = normalizeString(b.email).toLowerCase(); - - return ( - aName.localeCompare(bName) || - aEmail.localeCompare(bEmail) || - a.uuid.localeCompare(b.uuid) - ); - }); -} - -export function getSuggestedReviewerDisplayName( - reviewer: Pick, -): string { - const baseLabel = - normalizeString(reviewer.name) || - normalizeString(reviewer.email) || - "Unknown user"; - - return reviewer.isMe ? `${baseLabel} (Me)` : baseLabel; -} - -export function buildSuggestedReviewerFilterOptions( - reviewers: AvailableSuggestedReviewer[], - currentUser?: CurrentSuggestedReviewerUser | null, -): SuggestedReviewerFilterOption[] { - const byUuid = new Map(); - - for (const reviewer of reviewers) { - const uuid = normalizeString(reviewer.uuid); - if (!uuid || byUuid.has(uuid)) { - continue; - } - - byUuid.set(uuid, { - uuid, - name: normalizeString(reviewer.name), - email: normalizeString(reviewer.email), - github_login: normalizeString(reviewer.github_login), - isMe: false, - showSeparatorBelow: false, - }); - } - - const currentUserUuid = normalizeString(currentUser?.uuid); - if (currentUserUuid) { - const existing = byUuid.get(currentUserUuid); - byUuid.set(currentUserUuid, { - uuid: currentUserUuid, - name: buildCurrentUserName(currentUser) || existing?.name || "", - email: normalizeString(currentUser?.email) || existing?.email || "", - github_login: existing?.github_login || "", - isMe: true, - showSeparatorBelow: true, - }); - } - - const options = Array.from(byUuid.values()); - const meOption = options.find((option) => option.isMe) ?? null; - const otherOptions = sortReviewerOptionsByName( - options.filter((option) => !option.isMe), - ); - - return meOption ? [meOption, ...otherOptions] : otherOptions; -} diff --git a/apps/code/src/renderer/features/integrations/hooks/useGitHubIntegrationCallback.ts b/apps/code/src/renderer/features/integrations/hooks/useGitHubIntegrationCallback.ts deleted file mode 100644 index c0cb20a935..0000000000 --- a/apps/code/src/renderer/features/integrations/hooks/useGitHubIntegrationCallback.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { logger } from "@utils/logger"; -import { useEffect, useRef } from "react"; - -const log = logger.scope("github-integration-callback-hook"); - -const DEFAULT_ERROR_MESSAGE = - "GitHub install failed. Please try connecting again."; - -export interface IntegrationCallbackError { - message: string; - code: string | null; -} - -interface Options { - onSuccess: (projectId: number | null) => void; - onError: (error: IntegrationCallbackError) => void; - onTimedOut?: () => void; -} - -/** - * Subscribes to GitHub integration deep link callbacks and drains any pending - * callback that arrived before the subscription was established (cold-start). - */ -export function useGitHubIntegrationCallback({ - onSuccess, - onError, - onTimedOut, -}: Options): void { - const trpcReact = useTRPC(); - 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); - }, - }), - ); - - useSubscription( - trpcReact.githubIntegration.onFlowTimedOut.subscriptionOptions(undefined, { - onData: (data) => { - log.info("GitHub integration flow timed out", data); - optsRef.current.onTimedOut?.(); - }, - }), - ); - - useEffect(() => { - if (hasConsumedPendingRef.current) return; - hasConsumedPendingRef.current = true; - void (async () => { - try { - const pending = - await trpcClient.githubIntegration.consumePendingCallback.query(); - if (!pending) return; - log.info("Consumed pending integration callback on mount", pending); - if (pending.status === "error") { - optsRef.current.onError({ - message: pending.errorMessage ?? DEFAULT_ERROR_MESSAGE, - code: pending.errorCode, - }); - return; - } - optsRef.current.onSuccess(pending.projectId); - } catch (error) { - log.error("Failed to consume pending integration callback", error); - } - })(); - }, []); -} diff --git a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts b/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts deleted file mode 100644 index 12404fe288..0000000000 --- a/apps/code/src/renderer/features/integrations/hooks/useGithubUserConnect.ts +++ /dev/null @@ -1,339 +0,0 @@ -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 { type QueryClient, useQueryClient } from "@tanstack/react-query"; -import { openUrlInBrowser } from "@utils/browser"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -const POLL_INTERVAL_MS = 3_000; -const POLL_TIMEOUT_MS = 300_000; - -export type GithubUserConnectState = - | "idle" - | "connecting" - | "timed-out" - | "error"; - -export interface GithubUserConnectError { - message: string; - code: string | null; -} - -const 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: GithubUserConnectError | null, -): string { - if (!error) return ""; - if (error.code && ERROR_MESSAGES[error.code]) { - return ERROR_MESSAGES[error.code]; - } - return error.message; -} - -interface Options { - projectId: number | null; -} - -interface Result { - state: GithubUserConnectState; - error: GithubUserConnectError | null; - isConnecting: boolean; - isTimedOut: boolean; - hasError: boolean; - connect: () => Promise; - reset: () => void; -} - -export function invalidateGithubQueries( - queryClient: QueryClient, - projectId: number | null = null, -): void { - if (projectId !== null) { - void queryClient.invalidateQueries({ - queryKey: ["integrations", projectId], - }); - } - void queryClient.invalidateQueries({ - queryKey: ["integrations", "list"], - }); - void queryClient.invalidateQueries({ - queryKey: ["user-github-integrations"], - }); - void queryClient.invalidateQueries({ queryKey: ["github_login"] }); -} - -interface StateMachine { - state: GithubUserConnectState; - error: GithubUserConnectError | null; - stateRef: React.MutableRefObject; - beginConnecting: () => void; - finishWithError: (error: GithubUserConnectError) => void; - reset: () => void; - scheduleUserFlowTimeout: () => void; - scheduleDevPolling: () => void; -} - -function useConnectStateMachine( - projectId: number | null, - onConnected?: () => void, -): StateMachine { - const queryClient = useQueryClient(); - const [state, setState] = useState("idle"); - const [error, setError] = useState(null); - const stateRef = useRef(state); - stateRef.current = state; - const onConnectedRef = useRef(onConnected); - onConnectedRef.current = onConnected; - const pollTimerRef = useRef | null>(null); - const pollTimeoutRef = useRef | null>(null); - - const stopPolling = useCallback(() => { - if (pollTimerRef.current) { - clearInterval(pollTimerRef.current); - pollTimerRef.current = null; - } - if (pollTimeoutRef.current) { - clearTimeout(pollTimeoutRef.current); - pollTimeoutRef.current = null; - } - }, []); - - const invalidate = useCallback( - (pid: number | null) => invalidateGithubQueries(queryClient, pid), - [queryClient], - ); - - useEffect(() => stopPolling, [stopPolling]); - - // 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; - const onFocus = () => invalidate(projectId); - window.addEventListener("focus", onFocus); - return () => window.removeEventListener("focus", onFocus); - }, [state, projectId, invalidate]); - - useGitHubIntegrationCallback({ - onSuccess: (callbackProjectId) => { - stopPolling(); - setState("idle"); - setError(null); - invalidate(callbackProjectId ?? projectId); - onConnectedRef.current?.(); - }, - onError: (cbError) => { - stopPolling(); - setState("error"); - setError(cbError); - }, - onTimedOut: () => { - stopPolling(); - setState("timed-out"); - invalidate(projectId); - }, - }); - - const beginConnecting = useCallback(() => { - stopPolling(); - setError(null); - setState("connecting"); - }, [stopPolling]); - - const finishWithError = useCallback( - (e: GithubUserConnectError) => { - stopPolling(); - setError(e); - setState("error"); - }, - [stopPolling], - ); - - const reset = useCallback(() => { - stopPolling(); - setError(null); - setState("idle"); - }, [stopPolling]); - - const scheduleUserFlowTimeout = useCallback(() => { - pollTimeoutRef.current = setTimeout(() => { - stopPolling(); - setState("timed-out"); - }, POLL_TIMEOUT_MS); - }, [stopPolling]); - - const scheduleDevPolling = useCallback(() => { - if (!IS_DEV) return; - pollTimerRef.current = setInterval( - () => invalidate(projectId), - POLL_INTERVAL_MS, - ); - }, [invalidate, projectId]); - - return useMemo( - () => ({ - state, - error, - stateRef, - beginConnecting, - finishWithError, - reset, - scheduleUserFlowTimeout, - scheduleDevPolling, - }), - [ - state, - error, - beginConnecting, - finishWithError, - reset, - scheduleUserFlowTimeout, - scheduleDevPolling, - ], - ); -} - -function machineToResult( - machine: StateMachine, - connect: () => Promise, -): Result { - return { - state: machine.state, - error: machine.error, - isConnecting: machine.state === "connecting", - isTimedOut: machine.state === "timed-out", - hasError: machine.state === "error", - connect, - reset: machine.reset, - }; -} - -async function runUserFlow( - client: PostHogAPIClient, - projectId: number, -): Promise { - 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 machine = useConnectStateMachine(projectId); - - const connect = useCallback(async () => { - if (machine.stateRef.current === "connecting") return; - if (projectId === null || !client) return; - machine.beginConnecting(); - try { - await runUserFlow(client, projectId); - machine.scheduleDevPolling(); - machine.scheduleUserFlowTimeout(); - } catch (e) { - machine.finishWithError({ - message: - e instanceof Error ? e.message : "Failed to start GitHub connection", - code: null, - }); - } - }, [client, projectId, machine]); - - return machineToResult(machine, connect); -} - -interface ConnectOptions extends Options { - /** Whether `projectId` already has a team-level GitHub Integration. Required - * because the relevant project is not always the auth project (e.g. - * onboarding picks a project from a list). Admins on projects where this - * is `false` get the team-level OAuth flow (Cloud also seeds their - * `UserIntegration` in the same round-trip). */ - projectHasTeamIntegration: boolean | null; - onConnected?: () => void; -} - -/** - * Single "Connect GitHub" button for surfaces that should respect the - * team-vs-user distinction. Picks the team-level flow only for admins on - * projects with no team integration yet; everyone else gets the user-level - * flow. For purely user-scoped surfaces ("Add another GitHub org") use - * `useGithubUserConnect` directly. - */ -export function useGithubConnect({ - projectId, - projectHasTeamIntegration, - onConnected, -}: ConnectOptions): Result { - const client = useOptionalAuthenticatedClient(); - 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; - 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); - machine.scheduleDevPolling(); - machine.scheduleUserFlowTimeout(); - } - } catch (e) { - machine.finishWithError({ - message: - e instanceof Error ? e.message : "Failed to start GitHub connection", - code: null, - }); - } - }, [client, projectId, shouldUseTeamFlow, cloudRegion, machine]); - - return machineToResult(machine, connect); -} diff --git a/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts b/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts deleted file mode 100644 index 1f0f920d7c..0000000000 --- a/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useSlackIntegrationCallback } from "@features/integrations/hooks/useSlackIntegrationCallback"; -import { trpcClient } from "@renderer/trpc/client"; -import { type QueryClient, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -const POLL_TIMEOUT_MS = 300_000; - -export type SlackConnectState = "idle" | "connecting" | "timed-out" | "error"; - -export interface SlackConnectError { - message: string; - code: string | null; -} - -interface Result { - state: SlackConnectState; - error: SlackConnectError | null; - isConnecting: boolean; - isTimedOut: boolean; - hasError: boolean; - connect: () => Promise; - reset: () => void; -} - -function invalidateIntegrationQueries(queryClient: QueryClient): void { - void queryClient.invalidateQueries({ queryKey: ["integrations", "list"] }); - void queryClient.invalidateQueries({ queryKey: ["integrations"] }); -} - -/** - * Drives the "Connect Slack workspace" button: - * - kicks off the main-process flow via `slackIntegration.startFlow`, - * - listens for the deep-link callback via `useSlackIntegrationCallback`, - * - refetches integration queries on success so the rest of the UI updates, - * - times out after 5 minutes and refetches as a fallback (a Slack admin who - * finishes the install in another browser still surfaces eventually). - */ -export function useSlackConnect(): Result { - const queryClient = useQueryClient(); - const cloudRegion = useAuthStateValue((s) => s.cloudRegion); - const projectId = useAuthStateValue((s) => s.projectId); - - const [state, setState] = useState("idle"); - const [error, setError] = useState(null); - const stateRef = useRef(state); - stateRef.current = state; - - const timeoutRef = useRef | null>(null); - const clearLocalTimeout = useCallback(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - }, []); - - useEffect(() => clearLocalTimeout, [clearLocalTimeout]); - - // Window-focus fallback — the deep link can occasionally miss (browser - // setting, OS prompt dismissed), so refetch when the user returns to the - // app while a connect is in flight. - useEffect(() => { - if (state !== "connecting") return; - const onFocus = () => invalidateIntegrationQueries(queryClient); - window.addEventListener("focus", onFocus); - return () => window.removeEventListener("focus", onFocus); - }, [state, queryClient]); - - useSlackIntegrationCallback({ - onSuccess: () => { - clearLocalTimeout(); - setState("idle"); - setError(null); - invalidateIntegrationQueries(queryClient); - }, - onError: (cbError) => { - clearLocalTimeout(); - setState("error"); - setError(cbError); - }, - onTimedOut: () => { - clearLocalTimeout(); - setState("timed-out"); - invalidateIntegrationQueries(queryClient); - }, - }); - - const reset = useCallback(() => { - clearLocalTimeout(); - setError(null); - setState("idle"); - }, [clearLocalTimeout]); - - const connect = useCallback(async () => { - if (stateRef.current === "connecting") return; - if (projectId === null || cloudRegion === null) return; - clearLocalTimeout(); - setError(null); - setState("connecting"); - try { - const res = await trpcClient.slackIntegration.startFlow.mutate({ - region: cloudRegion, - projectId, - }); - if (!res.success) { - throw new Error(res.error ?? "Failed to start Slack connection"); - } - timeoutRef.current = setTimeout(() => { - setState("timed-out"); - invalidateIntegrationQueries(queryClient); - }, POLL_TIMEOUT_MS); - } catch (e) { - clearLocalTimeout(); - setError({ - message: - e instanceof Error ? e.message : "Failed to start Slack connection", - code: null, - }); - setState("error"); - } - }, [cloudRegion, projectId, clearLocalTimeout, queryClient]); - - return useMemo( - () => ({ - state, - error, - isConnecting: state === "connecting", - isTimedOut: state === "timed-out", - hasError: state === "error", - connect, - reset, - }), - [state, error, connect, reset], - ); -} diff --git a/apps/code/src/renderer/features/integrations/hooks/useSlackIntegrationCallback.ts b/apps/code/src/renderer/features/integrations/hooks/useSlackIntegrationCallback.ts deleted file mode 100644 index 49676573b4..0000000000 --- a/apps/code/src/renderer/features/integrations/hooks/useSlackIntegrationCallback.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { trpcClient, useTRPC } from "@renderer/trpc/client"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { logger } from "@utils/logger"; -import { useEffect, useRef } from "react"; - -const log = logger.scope("slack-integration-callback-hook"); - -const DEFAULT_ERROR_MESSAGE = - "Slack connection failed. Please try connecting again."; - -export interface SlackCallbackError { - message: string; - code: string | null; -} - -interface Options { - onSuccess: (projectId: number | null, integrationId: number | null) => void; - onError: (error: SlackCallbackError) => void; - onTimedOut?: () => void; -} - -/** - * Subscribes to Slack integration deep link callbacks and drains any pending - * callback that arrived before the subscription was established (cold-start). - */ -export function useSlackIntegrationCallback({ - onSuccess, - onError, - onTimedOut, -}: Options): void { - const trpcReact = useTRPC(); - 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); - }, - }), - ); - - useSubscription( - trpcReact.slackIntegration.onFlowTimedOut.subscriptionOptions(undefined, { - onData: (data) => { - log.info("Slack integration flow timed out", data); - optsRef.current.onTimedOut?.(); - }, - }), - ); - - useEffect(() => { - if (hasConsumedPendingRef.current) return; - hasConsumedPendingRef.current = true; - void (async () => { - try { - const pending = - await trpcClient.slackIntegration.consumePendingCallback.query(); - if (!pending) return; - log.info( - "Consumed pending Slack integration callback on mount", - pending, - ); - if (pending.status === "error") { - optsRef.current.onError({ - message: pending.errorMessage ?? DEFAULT_ERROR_MESSAGE, - code: pending.errorCode, - }); - return; - } - optsRef.current.onSuccess(pending.projectId, pending.integrationId); - } catch (error) { - log.error( - "Failed to consume pending Slack integration callback", - error, - ); - } - })(); - }, []); -} diff --git a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts b/apps/code/src/renderer/features/integrations/stores/integrationStore.ts deleted file mode 100644 index 022f1eea8a..0000000000 --- a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { create } from "zustand"; - -export interface IntegrationAccount { - name?: string; - type?: string; -} - -export interface IntegrationConfig { - account?: IntegrationAccount; - [key: string]: unknown; -} - -export interface Integration { - id: number; - kind: string; - config?: IntegrationConfig; - display_name?: string; - [key: string]: unknown; -} - -interface IntegrationStore { - integrations: Integration[]; - setIntegrations: (integrations: Integration[]) => void; -} - -interface IntegrationSelectors { - 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); - const githubIntegrations = integrations.filter((i) => i.kind === "github"); - const slackIntegrations = integrations.filter((i) => i.kind === "slack"); - - return { - githubIntegrations, - hasGithubIntegration: githubIntegrations.length > 0, - slackIntegrations, - hasSlackIntegration: slackIntegrations.length > 0, - }; -}; diff --git a/apps/code/src/renderer/features/mcp-apps/components/McpAppHost.tsx b/apps/code/src/renderer/features/mcp-apps/components/McpAppHost.tsx index e909423cd3..7212a225e6 100644 --- a/apps/code/src/renderer/features/mcp-apps/components/McpAppHost.tsx +++ b/apps/code/src/renderer/features/mcp-apps/components/McpAppHost.tsx @@ -1,4 +1,4 @@ -import type { ToolViewProps } from "@features/sessions/components/session-update/toolCallUtils"; +import type { ToolViewProps } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolResult, @@ -8,14 +8,14 @@ import type { import { ArrowsIn, ArrowsOut, Plugs, X } from "@phosphor-icons/react"; import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc/client"; -import { useThemeStore } from "@stores/themeStore"; +import { useThemeStore } from "@posthog/ui/workbench/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 { createPortal } from "react-dom"; -import { type Phase, useAppBridge } from "../hooks/useAppBridge"; -import { toCallToolResult } from "../utils/mcp-app-host-utils"; +import { type Phase, useAppBridge } from "@posthog/ui/features/mcp-apps/hooks/useAppBridge"; +import { toCallToolResult } from "@posthog/ui/features/mcp-apps/utils/mcp-app-host-utils"; const log = logger.scope("mcp-app-host"); 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.test.ts b/apps/code/src/renderer/features/mcp-servers/components/parts/statusBadge.test.ts deleted file mode 100644 index 3d7c270e27..0000000000 --- a/apps/code/src/renderer/features/mcp-servers/components/parts/statusBadge.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { McpServerInstallation } from "@renderer/api/posthogClient"; -import { describe, expect, it } from "vitest"; -import { getInstallationStatus } from "./statusBadge"; - -function makeInstallation( - overrides: Partial = {}, -): McpServerInstallation { - return { - id: "inst-1", - template_id: null, - name: "Test", - 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, - }; -} - -describe("getInstallationStatus", () => { - it("returns connected for a live installation", () => { - expect(getInstallationStatus(makeInstallation())).toBe("connected"); - }); - - it("returns pending_oauth when the OAuth flow is incomplete", () => { - expect( - getInstallationStatus(makeInstallation({ pending_oauth: true })), - ).toBe("pending_oauth"); - }); - - it("returns needs_reauth when the server demands re-auth", () => { - expect( - getInstallationStatus(makeInstallation({ needs_reauth: true })), - ).toBe("needs_reauth"); - }); - - it("prefers pending_oauth over needs_reauth if both set", () => { - expect( - getInstallationStatus( - makeInstallation({ pending_oauth: true, needs_reauth: true }), - ), - ).toBe("pending_oauth"); - }); -}); 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/mcp-servers/hooks/mcpFilters.test.ts b/apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.test.ts deleted file mode 100644 index b6f322f7ae..0000000000 --- a/apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { McpRecommendedServer } from "@renderer/api/posthogClient"; -import { describe, expect, it } from "vitest"; -import { filterServersByCategory, filterServersByQuery } from "./mcpFilters"; - -function server( - overrides: Partial, -): McpRecommendedServer { - return { - id: "test-id", - name: "Test", - url: "https://example.com/mcp", - description: "", - auth_type: "oauth", - ...overrides, - } as McpRecommendedServer; -} - -describe("filterServersByCategory", () => { - const all = [ - server({ id: "a", category: "dev", name: "Alpha" }), - server({ id: "b", category: "data", name: "Beta" }), - server({ id: "c", category: "dev", name: "Gamma" }), - server({ id: "d", name: "Delta" }), // no category - ]; - - it("returns everything when category is 'all'", () => { - expect(filterServersByCategory(all, "all")).toHaveLength(4); - }); - - it("filters down to the exact category", () => { - const out = filterServersByCategory(all, "dev"); - expect(out.map((s) => s.id).sort()).toEqual(["a", "c"]); - }); - - it("returns empty when nothing matches", () => { - expect(filterServersByCategory(all, "infra")).toEqual([]); - }); -}); - -describe("filterServersByQuery", () => { - const all = [ - server({ id: "a", name: "Linear", description: "Ticket tracker" }), - server({ id: "b", name: "GitHub", description: "Code hosting" }), - server({ - id: "c", - name: "Notion", - description: "Docs and knowledge base", - }), - ]; - - it("returns all when query is empty or whitespace", () => { - expect(filterServersByQuery(all, "")).toHaveLength(3); - expect(filterServersByQuery(all, " ")).toHaveLength(3); - }); - - it("matches against name", () => { - expect(filterServersByQuery(all, "linear").map((s) => s.id)).toEqual(["a"]); - }); - - it("matches against description", () => { - expect(filterServersByQuery(all, "tracker").map((s) => s.id)).toEqual([ - "a", - ]); - }); - - it("is case insensitive", () => { - expect(filterServersByQuery(all, "NOTION").map((s) => s.id)).toEqual(["c"]); - }); - - it("returns empty when nothing matches", () => { - expect(filterServersByQuery(all, "zzz")).toEqual([]); - }); -}); diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.ts b/apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.ts deleted file mode 100644 index a8cc52cfdf..0000000000 --- a/apps/code/src/renderer/features/mcp-servers/hooks/mcpFilters.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { - McpCategory, - McpRecommendedServer, - McpServerInstallation, -} from "@renderer/api/posthogClient"; - -export function filterServersByCategory( - servers: McpRecommendedServer[], - category: McpCategory | "all", -): McpRecommendedServer[] { - if (category === "all") return servers; - return servers.filter((s) => s.category === category); -} - -export function filterServersByQuery( - servers: McpRecommendedServer[], - query: string, -): McpRecommendedServer[] { - const q = query.trim().toLowerCase(); - if (!q) return servers; - return servers.filter( - (s) => - s.name.toLowerCase().includes(q) || - s.description?.toLowerCase().includes(q), - ); -} - -export function filterInstallationsByQuery( - installations: McpServerInstallation[], - templatesById: Map, - query: string, -): McpServerInstallation[] { - const q = query.trim().toLowerCase(); - if (!q) return installations; - return installations.filter((i) => { - const template = i.template_id ? templatesById.get(i.template_id) : null; - const fields = [ - i.display_name, - i.name, - i.url, - i.description, - template?.name, - template?.description, - ]; - return fields.some((f) => f?.toLowerCase().includes(q)); - }); -} diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.test.ts b/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.test.ts deleted file mode 100644 index f61f6cbbd6..0000000000 --- a/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { McpInstallationTool } from "@renderer/api/posthogClient"; -import { describe, expect, it, vi } from "vitest"; -import { dispatchBulkApproval } from "./mcpToolBulk"; - -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("dispatchBulkApproval", () => { - it("calls updateMcpToolApproval once per non-removed tool with the chosen state", async () => { - const update = vi.fn().mockResolvedValue(undefined); - const tools = [ - tool("a"), - tool("b"), - tool("c", { removed_at: "2026-04-01T00:00:00Z" }), - ]; - - await dispatchBulkApproval( - { updateMcpToolApproval: update }, - "inst-1", - tools, - "approved", - ); - - expect(update).toHaveBeenCalledTimes(2); - expect(update).toHaveBeenCalledWith("inst-1", "a", "approved"); - expect(update).toHaveBeenCalledWith("inst-1", "b", "approved"); - expect(update).not.toHaveBeenCalledWith( - expect.anything(), - "c", - expect.anything(), - ); - }); - - it("fires requests in parallel rather than sequentially", async () => { - let concurrent = 0; - let peak = 0; - const update = vi.fn(async () => { - concurrent += 1; - peak = Math.max(peak, concurrent); - await new Promise((r) => setTimeout(r, 5)); - concurrent -= 1; - }); - - await dispatchBulkApproval( - { updateMcpToolApproval: update }, - "inst-1", - [tool("a"), tool("b"), tool("c")], - "do_not_use", - ); - - expect(peak).toBeGreaterThan(1); - }); - - it("rejects if any update fails", async () => { - const update = vi.fn(async (_id: string, name: string) => { - if (name === "b") throw new Error("boom"); - }); - - await expect( - dispatchBulkApproval( - { updateMcpToolApproval: update }, - "inst-1", - [tool("a"), tool("b"), tool("c")], - "approved", - ), - ).rejects.toThrow("boom"); - }); - - it("is a no-op when the tool list is empty", async () => { - const update = vi.fn().mockResolvedValue(undefined); - await dispatchBulkApproval( - { updateMcpToolApproval: update }, - "inst-1", - [], - "approved", - ); - expect(update).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.ts b/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.ts deleted file mode 100644 index 4a57aeddcd..0000000000 --- a/apps/code/src/renderer/features/mcp-servers/hooks/mcpToolBulk.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { - McpApprovalState, - McpInstallationTool, -} from "@renderer/api/posthogClient"; - -interface ToolApprovalClient { - updateMcpToolApproval: ( - installationId: string, - toolName: string, - approval_state: McpApprovalState, - ) => Promise; -} - -/** - * Fire a PATCH per non-removed tool in parallel. Returns once every request - * resolves (or rejects — callers should surface the error). - */ -export async function dispatchBulkApproval( - client: ToolApprovalClient, - installationId: string, - tools: McpInstallationTool[], - approval_state: McpApprovalState, -): Promise { - await Promise.all( - tools - .filter((t) => !t.removed_at) - .map((t) => - client.updateMcpToolApproval( - installationId, - t.tool_name, - approval_state, - ), - ), - ); -} diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/useMcpInstallationTools.ts b/apps/code/src/renderer/features/mcp-servers/hooks/useMcpInstallationTools.ts deleted file mode 100644 index 86fe802b03..0000000000 --- a/apps/code/src/renderer/features/mcp-servers/hooks/useMcpInstallationTools.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; -import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { - McpApprovalState, - McpInstallationTool, -} from "@renderer/api/posthogClient"; -import { useTRPC } from "@renderer/trpc/client"; -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 { - includeRemoved?: boolean; - autoRefreshIfEmpty?: boolean; -} - -// Module-scoped on purpose: state must survive remounts of this hook so a -// detail-page revisit doesn't re-fire the auto-refresh. Tests that exercise -// auto-refresh need to clear this in beforeEach. -const autoRefreshedInstallations = new Set(); - -export function useMcpInstallationTools( - installationId: string | null, - options: UseMcpInstallationToolsOptions = {}, -) { - const trpcReact = useTRPC(); - const queryClient = useQueryClient(); - - const queryKey = [ - ...mcpKeys.tools(installationId ?? ""), - { includeRemoved: !!options.includeRemoved }, - ] as const; - - const { data: tools, isLoading } = useAuthenticatedQuery( - queryKey, - (client) => - installationId - ? client.getMcpInstallationTools(installationId, { - includeRemoved: options.includeRemoved, - }) - : Promise.resolve([] as McpInstallationTool[]), - { - enabled: !!installationId, - refetchOnMount: "always", - }, - ); - - const invalidate = useCallback(() => { - if (!installationId) return; - queryClient.invalidateQueries({ - queryKey: mcpKeys.tools(installationId), - }); - }, [installationId, queryClient]); - - const setToolApprovalMutation = useAuthenticatedMutation( - (client, vars: { toolName: string; approval_state: McpApprovalState }) => { - if (!installationId) { - return Promise.reject(new Error("No installation selected")); - } - return client.updateMcpToolApproval( - installationId, - vars.toolName, - vars.approval_state, - ); - }, - { - onSuccess: () => { - invalidate(); - }, - onError: (error: Error) => { - toast.error(error.message || "Failed to update tool approval"); - }, - }, - ); - - const setBulkApprovalMutation = useAuthenticatedMutation( - ( - client, - vars: { - approval_state: McpApprovalState; - targetTools?: McpInstallationTool[]; - }, - ) => { - if (!installationId) { - return Promise.reject(new Error("No installation selected")); - } - return dispatchBulkApproval( - client, - installationId, - vars.targetTools ?? tools ?? [], - vars.approval_state, - ); - }, - { - onSuccess: () => { - invalidate(); - }, - onError: (error: Error) => { - toast.error(error.message || "Failed to update tool approvals"); - }, - }, - ); - - const silentRefreshRef = useRef(false); - - const refreshMutation = useAuthenticatedMutation( - (client) => { - if (!installationId) { - return Promise.reject(new Error("No installation selected")); - } - return client.refreshMcpInstallationTools(installationId); - }, - { - onSuccess: () => { - const silent = silentRefreshRef.current; - silentRefreshRef.current = false; - if (!silent) toast.success("Tools refreshed"); - invalidate(); - queryClient.invalidateQueries({ queryKey: mcpKeys.installations }); - }, - onError: (error: Error) => { - const silent = silentRefreshRef.current; - silentRefreshRef.current = false; - if (!silent) toast.error(error.message || "Failed to refresh tools"); - }, - }, - ); - - const toolsLength = (tools ?? []).length; - 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; - autoRefreshedInstallations.add(installationId); - silentRefreshRef.current = true; - refreshMutate(undefined); - }, [ - options.autoRefreshIfEmpty, - installationId, - isLoading, - toolsLength, - refreshIsPending, - refreshMutate, - ]); - - useSubscription( - trpcReact.mcpCallback.onOAuthComplete.subscriptionOptions(undefined, { - onData: (data) => { - if (data.status === "success") { - invalidate(); - } - }, - }), - ); - - return { - tools: tools ?? [], - isLoading, - setToolApproval: setToolApprovalMutation.mutate, - setBulkApproval: ( - approval_state: McpApprovalState, - targetTools?: McpInstallationTool[], - ) => setBulkApprovalMutation.mutate({ approval_state, targetTools }), - bulkPending: setBulkApprovalMutation.isPending, - refresh: () => refreshMutation.mutate(undefined), - refreshPending: refreshMutation.isPending, - }; -} diff --git a/apps/code/src/renderer/features/mcp-servers/hooks/useMcpServers.ts b/apps/code/src/renderer/features/mcp-servers/hooks/useMcpServers.ts deleted file mode 100644 index 50074a794d..0000000000 --- a/apps/code/src/renderer/features/mcp-servers/hooks/useMcpServers.ts +++ /dev/null @@ -1,264 +0,0 @@ -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"; -import { useQueryClient } from "@tanstack/react-query"; -import { useSubscription } from "@trpc/tanstack-react-query"; -import { useCallback, useMemo, useState } from "react"; -import { toast } from "sonner"; - -export const mcpKeys = { - servers: ["mcp", "servers"] as const, - installations: ["mcp", "installations"] as const, - tools: (installationId: string) => - ["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 { - const { callbackUrl } = await trpcClient.mcpCallback.getCallbackUrl.query(); - return callbackUrl; -} - -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 }; -} - -export { filterServersByCategory, filterServersByQuery } from "./mcpFilters"; - -export function useMcpServers() { - const trpcReact = useTRPC(); - const [installingId, setInstallingId] = useState(null); - const queryClient = useQueryClient(); - - const { data: installations, isLoading: installationsLoading } = - useAuthenticatedQuery(mcpKeys.installations, (client) => - client.getMcpServerInstallations(), - ); - - const { data: servers, isLoading: serversLoading } = useAuthenticatedQuery( - mcpKeys.servers, - (client) => client.getMcpServers(), - ); - - const installedTemplateIds = useMemo( - () => - new Set( - (installations ?? []) - .map((i) => i.template_id) - .filter((id): id is string => !!id), - ), - [installations], - ); - - const installedUrls = useMemo( - () => - new Set( - (installations ?? []).map((i) => i.url).filter((u): u is string => !!u), - ), - [installations], - ); - - const invalidateInstallations = useCallback(() => { - queryClient.invalidateQueries({ queryKey: mcpKeys.installations }); - }, [queryClient]); - - const uninstallMutation = useAuthenticatedMutation( - (client, installationId: string) => - client.uninstallMcpServer(installationId), - { - onSuccess: () => { - toast.success("Server uninstalled"); - invalidateInstallations(); - }, - onError: (error: Error) => { - toast.error(error.message || "Failed to uninstall server"); - }, - }, - ); - - const toggleEnabledMutation = useAuthenticatedMutation( - (client, vars: { id: string; is_enabled: boolean }) => - client.updateMcpServerInstallation(vars.id, { - is_enabled: vars.is_enabled, - }), - { - onSuccess: () => { - invalidateInstallations(); - }, - onError: (error: Error) => { - toast.error(error.message || "Failed to update server"); - }, - }, - ); - - const toggleEnabled = useCallback( - (installationId: string, enabled: boolean) => { - toggleEnabledMutation.mutate({ id: installationId, is_enabled: enabled }); - }, - [toggleEnabledMutation], - ); - - const installTemplateMutation = useAuthenticatedMutation( - (client, vars: { template_id: string; api_key?: string }) => - installTemplateWithOAuth(client, vars), - { - onSuccess: (data) => { - if (data && "success" in data && data.success) { - toast.success("Server connected"); - } else if (data && "error" in data && data.error) { - toast.error(data.error); - } - invalidateInstallations(); - setInstallingId(null); - }, - onError: (error: Error) => { - toast.error(error.message || "Failed to connect server"); - setInstallingId(null); - }, - }, - ); - - const installTemplate = useCallback( - (template: McpRecommendedServer, opts?: { api_key?: string }) => { - setInstallingId(template.id); - installTemplateMutation.mutate({ - template_id: template.id, - api_key: opts?.api_key, - }); - }, - [installTemplateMutation], - ); - - const installCustomMutation = useAuthenticatedMutation( - ( - client, - vars: { - name: string; - url: string; - description: string; - auth_type: McpAuthType; - api_key?: string; - client_id?: string; - client_secret?: string; - }, - ) => installCustomWithOAuth(client, vars), - { - onSuccess: (data) => { - if (data && "success" in data && data.success) { - toast.success("Server added"); - } else if (data && "error" in data && data.error) { - toast.error(data.error); - } - invalidateInstallations(); - }, - onError: (error: Error) => { - toast.error(error.message || "Failed to add server"); - }, - }, - ); - - 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); - }, - { - onSuccess: (data) => { - if (data && "success" in data && data.success) { - toast.success("Server reconnected"); - } else if (data && "error" in data && data.error) { - toast.error(data.error); - } - invalidateInstallations(); - }, - onError: (error: Error) => { - toast.error(error.message || "Failed to reconnect server"); - }, - }, - ); - - useSubscription( - trpcReact.mcpCallback.onOAuthComplete.subscriptionOptions(undefined, { - onData: (data) => { - if (data.status === "success") { - invalidateInstallations(); - } - }, - }), - ); - - return { - installations: installations as McpServerInstallation[] | undefined, - installationsLoading, - servers: servers as McpRecommendedServer[] | undefined, - serversLoading, - installedTemplateIds, - installedUrls, - installingId, - uninstallMutation, - toggleEnabled, - installTemplate, - installCustom: installCustomMutation.mutate, - installCustomPending: installCustomMutation.isPending, - reauthorize: reauthorizeMutation.mutate, - reauthorizePending: reauthorizeMutation.isPending, - invalidateInstallations, - }; -} diff --git a/apps/code/src/renderer/features/message-editor/commands.ts b/apps/code/src/renderer/features/message-editor/commands.ts deleted file mode 100644 index 04deb7efb6..0000000000 --- a/apps/code/src/renderer/features/message-editor/commands.ts +++ /dev/null @@ -1,131 +0,0 @@ -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 type { Editor } from "@tiptap/core"; -import { track } from "@utils/analytics"; -import { toast } from "@utils/toast"; -import type { MentionChipAttrs } from "./tiptap/MentionChipNode"; - -interface CommandContext { - taskId: string; - repoPath: string | null | undefined; - session: { - taskRunId?: string; - logUrl?: string; - events: unknown[]; - } | null; - taskRun: { id?: string; log_url?: string } | null; -} - -export interface CodeCommandInsertContext { - editor: Editor; - chipId: string; - sessionId: string; -} - -interface CodeCommand { - name: string; - description: string; - input?: { hint: string }; - /** Optional override for the chip attrs inserted when this command is committed. */ - placeholderChip?: Partial; - /** Fires immediately after the chip is inserted into the editor. */ - onInsert?: (ctx: CodeCommandInsertContext) => void; - /** Runs at submission time when the message is sent. Optional. */ - execute?: ( - args: string | undefined, - context: CommandContext, - ) => Promise | 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, - label: string, -): CodeCommand { - return { - name, - 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, - }); - toast.success(`${label} feedback captured`); - }, - }; -} - -const addDirCommand: CodeCommand = { - name: "add-dir", - description: "Add a folder the agent can access in this task", - async onInsert(ctx) { - const taskId = ctx.sessionId; - try { - const path = await trpcClient.os.selectDirectory.query(); - if (!path) { - ctx.editor.commands.removeMentionChipById(ctx.chipId); - return; - } - ctx.editor.commands.replaceMentionChipById(ctx.chipId, { - id: path, - label: `add-dir - ${basename(path)}`, - }); - useAddDirectoryDialogStore.getState().show({ - taskId, - path, - onCancel: () => ctx.editor.commands.removeMentionChipById(ctx.chipId), - }); - } catch (err) { - ctx.editor.commands.removeMentionChipById(ctx.chipId); - toast.error("Failed to open folder picker", { - description: err instanceof Error ? err.message : String(err), - }); - } - }, -}; - -const commands: CodeCommand[] = [ - addDirCommand, - makeFeedbackCommand("good", "good", "Positive"), - makeFeedbackCommand("bad", "bad", "Negative"), - makeFeedbackCommand("feedback", "general", "General"), -]; - -export const CODE_COMMANDS: AvailableCommand[] = commands.map((cmd) => ({ - name: cmd.name, - description: cmd.description, - input: cmd.input, -})); - -const commandMap = new Map(commands.map((cmd) => [cmd.name, cmd])); - -export function getCodeCommand(name: string): CodeCommand | undefined { - return commandMap.get(name); -} - -export async function tryExecuteCodeCommand( - text: string, - context: CommandContext, -): Promise { - const match = text.match(/^\/(\S+)(?:\s+(.*))?$/); - if (!match) return false; - - const cmd = commandMap.get(match[1]); - if (!cmd?.execute) return false; - - await cmd.execute(match[2], context); - return true; -} diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx index bc67302db8..2be0725eab 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx @@ -1,13 +1,13 @@ 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 { 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 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"; // --- Mock data matching SessionConfigOption shape --- 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/message-editor/types.ts b/apps/code/src/renderer/features/message-editor/types.ts deleted file mode 100644 index 22624fc5d3..0000000000 --- a/apps/code/src/renderer/features/message-editor/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { AvailableCommand } from "@agentclientprotocol/sdk"; -import type { GithubRefKind, GithubRefState } from "@main/services/git/schemas"; -import type { - EditorContent, - FileAttachment, - MentionChip, -} from "./utils/content"; - -export type GithubIssueState = GithubRefState; -export type { GithubRefKind, GithubRefState }; - -export interface EditorHandle { - focus: () => void; - blur: () => void; - clear: () => void; - isEmpty: () => boolean; - getContent: () => EditorContent; - getText: () => string; - setContent: (text: string) => void; - insertChip: (chip: MentionChip) => void; - removeChipById: (chipId: string) => void; - replaceChipAttrs: ( - chipId: string, - attrs: Partial<{ id: string; label: string; type: MentionChip["type"] }>, - ) => void; - addAttachment: (attachment: FileAttachment) => void; - removeAttachment: (id: string) => void; -} - -export interface SuggestionItem { - id: string; - label: string; - description?: string; - filename?: string; - chipType?: MentionChip["type"]; -} - -export interface FileSuggestionItem extends SuggestionItem { - path: string; - kind?: "file" | "directory"; -} - -export interface CommandSuggestionItem extends SuggestionItem { - command: AvailableCommand; -} - -export interface IssueSuggestionItem extends SuggestionItem { - kind: GithubRefKind; - number: number; - title: string; - url: string; - repo: string; - state: GithubRefState; - labels: string[]; - isDraft?: boolean; -} - -export type SuggestionLoadingState = "idle" | "loading" | "error" | "success"; - -export interface SuggestionPosition { - x: number; - y: number; -} diff --git a/apps/code/src/renderer/features/message-editor/utils/content.ts b/apps/code/src/renderer/features/message-editor/utils/content.ts deleted file mode 100644 index f0da426568..0000000000 --- a/apps/code/src/renderer/features/message-editor/utils/content.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { escapeXmlAttr, unescapeXmlAttr } from "@utils/xml"; - -export interface MentionChip { - type: - | "file" - | "folder" - | "command" - | "error" - | "experiment" - | "insight" - | "feature_flag" - | "github_issue" - | "github_pr"; - id: string; - label: string; - pastedText?: boolean; - chipId?: string; -} - -export interface FileAttachment { - id: string; - label: string; -} - -export interface EditorContent { - segments: Array< - { type: "text"; text: string } | { type: "chip"; chip: MentionChip } - >; - attachments?: FileAttachment[]; -} - -export function contentToPlainText(content: EditorContent): string { - return content.segments - .map((seg) => { - if (seg.type === "text") return seg.text; - const chip = seg.chip; - if (chip.type === "file" || chip.type === "folder") - return `@${chip.label}`; - if (chip.type === "command") return `/${chip.label}`; - return `@${chip.label}`; - }) - .join(""); -} - -function isAbsolutePathLike(p: string): boolean { - return p.startsWith("/") || p.startsWith("~") || /^[A-Za-z]:[\\/]/.test(p); -} - -export function contentToXml(content: EditorContent): string { - const inlineFilePaths = new Set(); - const parts = content.segments.map((seg) => { - if (seg.type === "text") return seg.text; - const chip = seg.chip; - const escapedId = escapeXmlAttr(chip.id); - switch (chip.type) { - case "file": - inlineFilePaths.add(chip.id); - return ``; - case "folder": - inlineFilePaths.add(chip.id); - return ``; - case "command": - if (chip.id && chip.id !== chip.label && isAbsolutePathLike(chip.id)) { - return ``; - } - return `/${chip.label}`; - case "error": - return ``; - case "experiment": - return ``; - case "insight": - return ``; - case "feature_flag": - return ``; - case "github_issue": - case "github_pr": { - const labelMatch = chip.label.match(/^#(\d+)(?:\s*-\s*(.*))?$/); - const number = labelMatch?.[1] ?? ""; - const title = labelMatch?.[2] ?? ""; - return `<${chip.type} number="${escapeXmlAttr(number)}" title="${escapeXmlAttr(title)}" url="${escapedId}" />`; - } - default: - return `@${chip.label}`; - } - }); - - // Append file tags for attachments not already referenced inline - if (content.attachments) { - for (const att of content.attachments) { - if (!inlineFilePaths.has(att.id)) { - parts.push(``); - } - } - } - - return parts.join(""); -} - -const CHIP_TAG_REGEX = - /<(file|folder|error|experiment|insight|feature_flag|github_issue|github_pr)\b([^>]*?)\s*\/>/g; -const ATTR_REGEX = /(\w+)="([^"]*)"/g; - -export function deriveFileLabel(filePath: string): string { - const segments = filePath.split("/").filter(Boolean); - const fileName = segments.pop() ?? filePath; - const parentDir = segments.pop(); - return parentDir ? `${parentDir}/${fileName}` : fileName; -} - -function parseAttrs(raw: string): Record { - const attrs: Record = {}; - for (const match of raw.matchAll(ATTR_REGEX)) { - attrs[match[1]] = unescapeXmlAttr(match[2]); - } - return attrs; -} - -function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { - const attrs = parseAttrs(rawAttrs); - switch (tag) { - case "file": { - const path = attrs.path; - if (!path) return null; - return { type: "file", id: path, label: deriveFileLabel(path) }; - } - case "folder": { - const path = attrs.path; - if (!path) return null; - return { type: "folder", id: path, label: deriveFileLabel(path) }; - } - case "error": - case "experiment": - case "insight": - case "feature_flag": { - const id = attrs.id; - if (!id) return null; - return { type: tag, id, label: id }; - } - case "github_issue": - case "github_pr": { - const number = attrs.number ?? ""; - const title = attrs.title ?? ""; - const url = attrs.url ?? ""; - if (!number && !url) return null; - const label = title ? `#${number} - ${title}` : `#${number}`; - return { type: tag, id: url, label }; - } - default: - return null; - } -} - -export function xmlToContent(xml: string): EditorContent { - const segments: EditorContent["segments"] = []; - let lastIndex = 0; - - for (const match of xml.matchAll(CHIP_TAG_REGEX)) { - const matchIndex = match.index ?? 0; - const chip = chipFromTag(match[1], match[2] ?? ""); - if (!chip) continue; - - if (matchIndex > lastIndex) { - segments.push({ type: "text", text: xml.slice(lastIndex, matchIndex) }); - } - segments.push({ type: "chip", chip }); - lastIndex = matchIndex + match[0].length; - } - - if (lastIndex < xml.length) { - segments.push({ type: "text", text: xml.slice(lastIndex) }); - } - - if (segments.length === 0) { - segments.push({ type: "text", text: xml }); - } - - return { segments }; -} - -export function xmlToPlainText(xml: string): string { - return contentToPlainText(xmlToContent(xml)); -} - -export function isContentEmpty( - content: EditorContent | null | string, -): boolean { - if (!content) return true; - if (typeof content === "string") return !content.trim(); - if (content.attachments && content.attachments.length > 0) return false; - if (!content.segments) return true; - return content.segments.every( - (seg) => seg.type === "text" && !seg.text.trim(), - ); -} - -export function extractFilePaths(content: EditorContent): string[] { - const filePaths: string[] = []; - const seen = new Set(); - - for (const seg of content.segments) { - if ( - seg.type === "chip" && - (seg.chip.type === "file" || seg.chip.type === "folder") && - !seen.has(seg.chip.id) - ) { - seen.add(seg.chip.id); - filePaths.push(seg.chip.id); - } - } - - if (content.attachments) { - for (const att of content.attachments) { - if (!seen.has(att.id)) { - seen.add(att.id); - filePaths.push(att.id); - } - } - } - - return filePaths; -} diff --git a/apps/code/src/renderer/features/message-editor/utils/githubIssueChip.ts b/apps/code/src/renderer/features/message-editor/utils/githubIssueChip.ts deleted file mode 100644 index de44aae8fb..0000000000 --- a/apps/code/src/renderer/features/message-editor/utils/githubIssueChip.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { GithubRefState } from "../types"; -import type { MentionChip } from "./content"; - -export interface GithubIssueChipSource { - number: number; - title: string; - url: string; -} - -export function githubIssueToMentionChip( - issue: GithubIssueChipSource, -): MentionChip { - return { - type: "github_issue", - id: issue.url, - label: `#${issue.number} - ${issue.title}`, - }; -} - -export function githubPullRequestToMentionChip( - pr: GithubIssueChipSource, -): MentionChip { - return { - type: "github_pr", - id: pr.url, - label: `#${pr.number} - ${pr.title}`, - }; -} - -export const GITHUB_ISSUE_STATE_COLORS: Record = { - OPEN: "#238636", - CLOSED: "#AB7DF8", - MERGED: "#8957E5", -}; - -export function githubIssueStateColor(state: GithubRefState): string { - return GITHUB_ISSUE_STATE_COLORS[state]; -} diff --git a/apps/code/src/renderer/features/message-editor/utils/githubIssueUrl.ts b/apps/code/src/renderer/features/message-editor/utils/githubIssueUrl.ts deleted file mode 100644 index 96e5ae4a2e..0000000000 --- a/apps/code/src/renderer/features/message-editor/utils/githubIssueUrl.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { GithubRefKind } from "../types"; - -export type { GithubRefKind }; - -export interface ParsedGithubIssueUrl { - kind: GithubRefKind; - owner: string; - repo: string; - number: number; - normalizedUrl: string; -} - -const GITHUB_ISSUE_URL_PATTERN = - /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/(issues|pull)\/(\d+)(?:[/?#].*)?$/; - -export function parseGithubIssueUrl(text: string): ParsedGithubIssueUrl | null { - const trimmed = text.trim(); - const match = trimmed.match(GITHUB_ISSUE_URL_PATTERN); - if (!match) return null; - - const [, owner, repo, segment, rawNumber] = match; - const number = Number(rawNumber); - if (!Number.isInteger(number) || number <= 0) return null; - - const kind: GithubRefKind = segment === "pull" ? "pr" : "issue"; - return { - kind, - owner, - repo, - number, - normalizedUrl: `https://github.com/${owner}/${repo}/${segment}/${number}`, - }; -} diff --git a/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts b/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts deleted file mode 100644 index 7a7e73fd56..0000000000 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -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 = - await vi.importActual("@posthog/shared"); - 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 { - persistBrowserFile, - persistImageFile, - persistImageFilePath, - persistTextContent, - resolveAndAttachDroppedFiles, - resolveDroppedFile, -} from "./persistFile"; - -describe("persistFile", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("passes original text filenames through clipboard persistence", async () => { - mockSaveClipboardText.mockResolvedValue({ - path: "/tmp/posthog-code-clipboard/attachment-123/notes.md", - name: "notes.md", - }); - - const result = await persistTextContent("# hello", "notes.md"); - - expect(mockSaveClipboardText).toHaveBeenCalledWith({ - text: "# hello", - originalName: "notes.md", - }); - expect(result).toEqual({ - path: "/tmp/posthog-code-clipboard/attachment-123/notes.md", - name: "notes.md", - }); - }); - - it("persists image files via saveClipboardImage", async () => { - mockSaveClipboardImage.mockResolvedValue({ - path: "/tmp/posthog-code-clipboard/attachment-789/photo.png", - name: "photo.png", - mimeType: "image/png", - }); - - const file = { - name: "photo.png", - type: "image/png", - arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), - } as unknown as File; - - const result = await persistImageFile(file); - - expect(mockSaveClipboardImage).toHaveBeenCalledWith( - expect.objectContaining({ - mimeType: "image/png", - originalName: "photo.png", - }), - ); - expect(result).toEqual({ - path: "/tmp/posthog-code-clipboard/attachment-789/photo.png", - name: "photo.png", - mimeType: "image/png", - }); - }); - - it("routes image files through persistBrowserFile", async () => { - mockSaveClipboardImage.mockResolvedValue({ - path: "/tmp/posthog-code-clipboard/attachment-abc/img.png", - name: "img.png", - mimeType: "image/png", - }); - - const file = { - name: "img.png", - type: "image/png", - arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), - } as unknown as File; - - const result = await persistBrowserFile(file); - - expect(result).toEqual({ - id: "/tmp/posthog-code-clipboard/attachment-abc/img.png", - label: "img.png", - }); - }); - - it("persists arbitrary non-image files via saveClipboardFile", async () => { - mockSaveClipboardFile.mockResolvedValue({ - path: "/tmp/posthog-code-clipboard/attachment-def/archive.zip", - name: "archive.zip", - }); - - const file = { - name: "archive.zip", - type: "application/zip", - arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), - } as unknown as File; - - await expect(persistBrowserFile(file)).resolves.toEqual({ - id: "/tmp/posthog-code-clipboard/attachment-def/archive.zip", - label: "archive.zip", - }); - - expect(mockSaveClipboardFile).toHaveBeenCalledWith({ - base64Data: expect.any(String), - 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", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("calls downscaleImageFile and returns { id, label }", async () => { - mockDownscaleImageFile.mockResolvedValue({ - path: "/tmp/posthog-code-clipboard/attachment-aaa/photo.jpg", - name: "photo.jpg", - mimeType: "image/jpeg", - }); - - const result = await persistImageFilePath("/Users/me/Desktop/photo.png"); - - expect(mockDownscaleImageFile).toHaveBeenCalledWith({ - filePath: "/Users/me/Desktop/photo.png", - }); - expect(result).toEqual({ - id: "/tmp/posthog-code-clipboard/attachment-aaa/photo.jpg", - label: "photo.jpg", - }); - }); - - it("propagates errors from downscaleImageFile", async () => { - mockDownscaleImageFile.mockRejectedValue(new Error("Image too large")); - - await expect(persistImageFilePath("/big/image.png")).rejects.toThrow( - "Image too large", - ); - }); -}); - -describe("resolveDroppedFile", () => { - 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("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", - mimeType: "image/jpeg", - }); - - const file = { name: "photo.png" } as File; - const result = await resolveDroppedFile(file); - - expect(mockDownscaleImageFile).toHaveBeenCalledWith({ - filePath: "/Users/me/photo.png", - }); - expect(result).toEqual({ - id: "/tmp/posthog-code-clipboard/attachment-bbb/photo.jpg", - label: "photo.jpg", - }); - }); - - it("falls back to original path and 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/apps/code/src/renderer/features/message-editor/utils/persistFile.ts b/apps/code/src/renderer/features/message-editor/utils/persistFile.ts deleted file mode 100644 index 1e366b57b2..0000000000 --- a/apps/code/src/renderer/features/message-editor/utils/persistFile.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 { - const bytes = new Uint8Array(buffer); - const chunks: string[] = []; - for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { - chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE))); - } - return btoa(chunks.join("")); -} - -export interface PersistedFile { - path: string; - name: string; - mimeType?: string; -} - -export async function persistImageFile(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({ - base64Data, - mimeType, - originalName: file.name, - }); - return { path: result.path, name: result.name, mimeType: result.mimeType }; -} - -export async function persistTextContent( - text: string, - originalName?: string, -): Promise { - const result = await trpcClient.os.saveClipboardText.mutate({ - text, - originalName, - }); - return { path: result.path, name: result.name }; -} - -export async function persistGenericFile(file: File): Promise { - const arrayBuffer = await file.arrayBuffer(); - const base64Data = arrayBufferToBase64(arrayBuffer); - - const result = await trpcClient.os.saveClipboardFile.mutate({ - base64Data, - originalName: file.name, - }); - - return { - path: result.path, - name: result.name, - mimeType: file.type || undefined, - }; -} - -export async function persistImageFilePath( - filePath: string, -): Promise<{ id: string; label: string }> { - const result = await trpcClient.os.downscaleImageFile.mutate({ filePath }); - return { id: result.path, label: result.name }; -} - -export async function resolveDroppedFile( - file: File, -): Promise { - const filePath = getFilePath(file); - if (!filePath) return null; - - if (isRasterImageFile(file.name)) { - try { - return await persistImageFilePath(filePath); - } catch { - toast.warning("Image could not be downscaled", { - description: "Attaching original file instead", - }); - return { id: filePath, label: file.name }; - } - } - - 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( - file: File, -): Promise<{ id: string; label: string }> { - if (file.type.startsWith("image/")) { - const result = await persistImageFile(file); - return { id: result.path, label: result.name }; - } - - const result = await persistGenericFile(file); - return { id: result.path, label: result.name }; -} 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/hooks/useProjectsWithIntegrations.ts b/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts deleted file mode 100644 index c6da7b49ec..0000000000 --- a/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 { useQueries } from "@tanstack/react-query"; -import { useMemo } from "react"; - -export interface ProjectWithIntegrations { - id: number; - name: string; - organization: { id: string; name: string }; - integrations: Integration[]; - hasGithubIntegration: boolean; -} - -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], - queryFn: async () => { - if (!client) throw new Error("Not authenticated"); - return client.getIntegrationsForProject(project.id); - }, - enabled: !!client && projects.length > 0, - staleTime: 60 * 1000, // 1 minute - meta: AUTH_SCOPED_QUERY_META, - })), - }); - - const isLoading = - 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], - ); - - return { - projects: projectsWithIntegrations, - projectsWithGithub, - isLoading, - isFetching, - }; -} 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/constants/panelConstants.ts b/apps/code/src/renderer/features/panels/constants/panelConstants.ts deleted file mode 100644 index aa990772c6..0000000000 --- a/apps/code/src/renderer/features/panels/constants/panelConstants.ts +++ /dev/null @@ -1,27 +0,0 @@ -export const PANEL_SIZES = { - MIN_PANEL_SIZE: 15, - DEFAULT_SPLIT: [70, 30] as const, - EVEN_SPLIT: [50, 50] as const, - 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", - RIGHT_GROUP: "right-group", - TOP_RIGHT: "top-right", - BOTTOM_RIGHT: "bottom-right", -} as const; - -export const DEFAULT_TAB_IDS = { - LOGS: "logs", - SHELL: "shell", - FILES: "files", - CHANGES: "changes", -} as const; 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/store/panelStoreHelpers.ts b/apps/code/src/renderer/features/panels/store/panelStoreHelpers.ts deleted file mode 100644 index 3b953ddfb4..0000000000 --- a/apps/code/src/renderer/features/panels/store/panelStoreHelpers.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { DEFAULT_TAB_IDS } from "../constants/panelConstants"; -import type { SplitDirection, TaskLayout } from "./panelLayoutStore"; -import type { GroupPanel, LeafPanel, PanelNode, Tab } from "./panelTypes"; - -// Constants -export const DEFAULT_FALLBACK_TAB = DEFAULT_TAB_IDS.LOGS; - -// Tab ID utilities -export type TabType = "file" | "system"; - -export interface ParsedTabId { - type: TabType; - value: string; -} - -export function createFileTabId(filePath: string): string { - return `file-${filePath}`; -} - -export function parseTabId(tabId: string): ParsedTabId & { status?: string } { - if (tabId.startsWith("file-")) { - return { type: "file", value: tabId.slice(5) }; - } - return { type: "system", value: tabId }; -} - -export function createTabLabel(tabId: string): string { - const parsed = parseTabId(tabId); - if (parsed.type === "file") { - return parsed.value.split("/").pop() || parsed.value; - } - return parsed.value; -} - -// Panel finding utilities -export function findPanelById( - node: PanelNode, - panelId: string, -): PanelNode | null { - if (node.id === panelId) { - return node; - } - - if (node.type === "group") { - for (const child of node.children) { - const found = findPanelById(child, panelId); - if (found) return found; - } - } - - return null; -} - -export function getLeafPanel( - tree: PanelNode, - panelId: string, -): LeafPanel | null { - const panel = findPanelById(tree, panelId); - return panel?.type === "leaf" ? panel : null; -} - -export function getGroupPanel( - tree: PanelNode, - panelId: string, -): GroupPanel | null { - const panel = findPanelById(tree, panelId); - return panel?.type === "group" ? panel : null; -} - -// Panel ID generation -let nextPanelId = 1; - -export function generatePanelId(): string { - return `panel-${nextPanelId++}`; -} - -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, - isPreview = false, -): Tab { - 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 - }; - break; - case "system": - if (tabId === "logs") { - data = { type: "logs" }; - } else if (tabId.startsWith("shell")) { - data = { - type: "terminal", - terminalId: tabId, - cwd: "", - }; - } else { - data = { type: "other" }; - } - break; - default: - data = { type: "other" }; - } - - return { - id: tabId, - label: createTabLabel(tabId), - data, - component: null, - closeable, - draggable: true, - isPreview, - }; -} - -export function addNewTabToPanel( - panel: PanelNode, - tabId: string, - closeable = true, - isPreview = false, -): 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; - - return { - ...panel, - content: { - ...panel.content, - tabs: [...tabs, createNewTab(tabId, closeable, isPreview)], - activeTabId: tabId, - }, - }; -} - -export function selectNextTabAfterClose( - tabs: Tab[], - closedTabIndex: number, - activeTabId: string, - closedTabId: string, -): string { - if (activeTabId !== closedTabId) { - return activeTabId; - } - - if (tabs.length === 0) { - return DEFAULT_FALLBACK_TAB; - } - - const nextIndex = Math.min(closedTabIndex, tabs.length - 1); - return tabs[nextIndex].id; -} - -// Split direction utilities -export interface SplitConfig { - splitDirection: "horizontal" | "vertical"; - isAfter: boolean; -} - -export function getSplitConfig(direction: SplitDirection): SplitConfig { - const horizontalDirections: SplitDirection[] = ["left", "right"]; - const afterDirections: SplitDirection[] = ["right", "bottom"]; - - return { - splitDirection: horizontalDirections.includes(direction) - ? "horizontal" - : "vertical", - isAfter: afterDirections.includes(direction), - }; -} - -// Metadata tracking utilities -export function updateMetadataForTab( - layout: TaskLayout, - tabId: string, - action: "add" | "remove", -): Pick { - const parsed = parseTabId(tabId); - - if (parsed.type === "file") { - const openFiles = - action === "add" - ? [...layout.openFiles, parsed.value] - : layout.openFiles.filter((f) => f !== parsed.value); - return { openFiles }; - } - - return { openFiles: layout.openFiles }; -} - -// Cleanup utilities -export function applyCleanupWithFallback( - cleanedTree: PanelNode | null, - originalTree: PanelNode, -): PanelNode { - return cleanedTree || originalTree; -} - -// Tab active state utilities -export function isTabActiveInTree(tree: PanelNode, tabId: string): boolean { - if (tree.type === "leaf") { - return tree.content.activeTabId === tabId; - } - return tree.children.some((child) => isTabActiveInTree(child, tabId)); -} - -export function isFileTabActiveInTree( - tree: PanelNode, - filePath: string, -): boolean { - const tabId = createFileTabId(filePath); - return isTabActiveInTree(tree, tabId); -} diff --git a/apps/code/src/renderer/features/panels/store/panelTree.ts b/apps/code/src/renderer/features/panels/store/panelTree.ts deleted file mode 100644 index 889a60dc11..0000000000 --- a/apps/code/src/renderer/features/panels/store/panelTree.ts +++ /dev/null @@ -1,249 +0,0 @@ -import type { PanelNode, Tab } from "./panelTypes"; -import { normalizeSizes, redistributeSizes } from "./panelUtils"; - -const isLeafNode = ( - node: PanelNode | null, -): node is Extract => node?.type === "leaf"; - -const isGroupNode = ( - node: PanelNode | null, -): node is Extract => node?.type === "group"; - -export const removeTabFromPanel = ( - node: PanelNode, - tabId: string, -): PanelNode => { - if (!isLeafNode(node)) return node; - - const newTabs = node.content.tabs.filter((t) => t.id !== tabId); - const newActiveTabId = - node.content.activeTabId === tabId - ? newTabs[0]?.id || "" - : node.content.activeTabId; - - return { - ...node, - content: { ...node.content, tabs: newTabs, activeTabId: newActiveTabId }, - }; -}; - -export const addTabToPanel = (node: PanelNode, tab: Tab): PanelNode => { - if (!isLeafNode(node)) return node; - - return { - ...node, - content: { - ...node.content, - tabs: [...node.content.tabs, tab], - activeTabId: tab.id, - }, - }; -}; - -export const setActiveTabInPanel = ( - node: PanelNode, - tabId: string, -): PanelNode => { - if (!isLeafNode(node)) return node; - - return { - ...node, - content: { ...node.content, activeTabId: tabId }, - }; -}; - -export const findTabInPanel = ( - panel: Extract, - tabId: string, -): Tab | undefined => panel.content.tabs.find((t) => t.id === tabId); - -export const findTabInTree = ( - node: PanelNode, - tabId: string, -): { panelId: string; tab: Tab } | null => { - if (node.type === "leaf") { - const tab = node.content.tabs.find((t) => t.id === tabId); - if (tab) { - return { panelId: node.id, tab }; - } - return null; - } - - if (node.type === "group") { - for (const child of node.children) { - const result = findTabInTree(child, tabId); - if (result) return result; - } - } - - return null; -}; - -export const updateTreeNode = ( - node: PanelNode, - targetId: string, - updateFn: (node: PanelNode) => PanelNode, -): PanelNode => { - if (node.id === targetId) return updateFn(node); - - if (isGroupNode(node)) { - return { - ...node, - children: node.children.map((child) => - updateTreeNode(child, targetId, updateFn), - ), - }; - } - - return node; -}; - -export const cleanupNode = (node: PanelNode): PanelNode | null => { - if (isLeafNode(node)) { - return node.content.tabs.length === 0 ? null : node; - } - - const childrenWithIndices = node.children.map((child, index) => ({ - child: cleanupNode(child), - originalIndex: index, - })); - - const cleanedWithIndices = childrenWithIndices.filter( - (item): item is { child: PanelNode; originalIndex: number } => - item.child !== null, - ); - - if (cleanedWithIndices.length === 0) return null; - if (cleanedWithIndices.length === 1) return cleanedWithIndices[0].child; - - let finalSizes = node.sizes; - - if (cleanedWithIndices.length < node.children.length) { - if (node.sizes) { - const removedIndices = new Set( - node.children - .map((_, i) => i) - .filter( - (i) => !cleanedWithIndices.some((item) => item.originalIndex === i), - ), - ); - - let newSizes = node.sizes; - for (const removedIndex of Array.from(removedIndices).sort( - (a, b) => b - a, - )) { - newSizes = redistributeSizes(newSizes, removedIndex); - } - finalSizes = newSizes; - } else { - finalSizes = normalizeSizes([], cleanedWithIndices.length); - } - } else if (!finalSizes || finalSizes.length !== cleanedWithIndices.length) { - finalSizes = normalizeSizes(finalSizes || [], cleanedWithIndices.length); - } - - return { - ...node, - children: cleanedWithIndices.map((item) => item.child), - sizes: finalSizes, - }; -}; - -/** - * 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, - onClose: newTab.onClose, - onSelect: newTab.onSelect, - label: newTab.label, - icon: newTab.icon, - }; - } - return existingTab; - }) - .filter((tab) => newTabsMap.has(tab.id)); // Remove tabs not in new tree - - // 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: { - ...existingTree.content, - tabs: finalTabs, - activeTabId, - }, - }; - } - - 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) { - return mergeTreeContent(existingChild, newChild); - } - return existingChild; - }); - - // Check if any children actually changed - const childrenChanged = mergedChildren.some( - (child, index) => child !== existingTree.children[index], - ); - - if (!childrenChanged) { - return existingTree; - } - - return { - ...existingTree, - children: mergedChildren, - // Preserve existing sizes and direction - }; - } - - return existingTree; -}; - -export const isLeaf = isLeafNode; -export const isGroup = isGroupNode; diff --git a/apps/code/src/renderer/features/panels/store/panelTypes.ts b/apps/code/src/renderer/features/panels/store/panelTypes.ts deleted file mode 100644 index d50c9e9f43..0000000000 --- a/apps/code/src/renderer/features/panels/store/panelTypes.ts +++ /dev/null @@ -1,78 +0,0 @@ -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"; - relativePath: string; - absolutePath: string; - repoPath: string; - } - | { - type: "terminal"; - terminalId: string; - cwd: string; - } - | { - type: "action"; - actionId: string; - command: string; - cwd: string; - label: string; - } - | { - type: "logs"; - } - | { - type: "review"; - } - | { - type: "other"; - }; - -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; - -export type SplitDirection = "top" | "bottom" | "left" | "right"; diff --git a/apps/code/src/renderer/features/panels/store/panelUtils.ts b/apps/code/src/renderer/features/panels/store/panelUtils.ts deleted file mode 100644 index e953cbb729..0000000000 --- a/apps/code/src/renderer/features/panels/store/panelUtils.ts +++ /dev/null @@ -1,54 +0,0 @@ -const MIN_PANEL_SIZE = 15; - -export const normalizeSizes = ( - sizes: number[], - childCount: number, -): number[] => { - if (!sizes?.length) { - return new Array(childCount).fill(100 / childCount); - } - - const normalized = [...sizes]; - while (normalized.length < childCount) normalized.push(100 / childCount); - if (normalized.length > childCount) normalized.length = childCount; - - const validSizes = normalized.map((size) => - size > 0 ? size : MIN_PANEL_SIZE, - ); - const total = validSizes.reduce((sum, size) => sum + size, 0); - - if (total === 0) return new Array(childCount).fill(100 / childCount); - - const scaled = validSizes.map((size) => (size / total) * 100); - const withMinimums = scaled.map((size) => Math.max(size, MIN_PANEL_SIZE)); - const finalTotal = withMinimums.reduce((sum, size) => sum + size, 0); - - return withMinimums.map((size) => (size / finalTotal) * 100); -}; - -export const calculateSplitSizes = (): [number, number] => [50, 50]; - -export const redistributeSizes = ( - sizes: number[], - removedIndex: number, -): number[] => { - if (sizes.length <= 1) return [100]; - - const removedSize = sizes[removedIndex] ?? 0; - const remainingSizes = sizes.filter((_, i) => i !== removedIndex); - - if (!remainingSizes.length) return [100]; - - const remainingTotal = remainingSizes.reduce((sum, size) => sum + size, 0); - - if (remainingTotal === 0) { - return new Array(remainingSizes.length).fill(100 / remainingSizes.length); - } - - const redistributed = remainingSizes.map((size) => { - const proportion = size / remainingTotal; - return size + removedSize * proportion; - }); - - return normalizeSizes(redistributed, redistributed.length); -}; 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/provisioning/stores/provisioningStore.ts b/apps/code/src/renderer/features/provisioning/stores/provisioningStore.ts deleted file mode 100644 index 2997ca8906..0000000000 --- a/apps/code/src/renderer/features/provisioning/stores/provisioningStore.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { create } from "zustand"; - -interface ProvisioningStoreState { - activeTasks: Set; -} - -interface ProvisioningStoreActions { - setActive: (taskId: string) => void; - clear: (taskId: string) => void; - isActive: (taskId: string) => boolean; -} - -type ProvisioningStore = ProvisioningStoreState & ProvisioningStoreActions; - -export const useProvisioningStore = create()((set, get) => ({ - activeTasks: new Set(), - - setActive: (taskId) => - set((state) => { - const next = new Set(state.activeTasks); - next.add(taskId); - return { activeTasks: next }; - }), - - clear: (taskId) => - set((state) => { - const next = new Set(state.activeTasks); - next.delete(taskId); - return { activeTasks: next }; - }), - - isActive: (taskId) => get().activeTasks.has(taskId), -})); diff --git a/apps/code/src/renderer/features/sessions/components/GeneratingIndicator.test.ts b/apps/code/src/renderer/features/sessions/components/GeneratingIndicator.test.ts index 8e76be1431..6c7843cb92 100644 --- a/apps/code/src/renderer/features/sessions/components/GeneratingIndicator.test.ts +++ b/apps/code/src/renderer/features/sessions/components/GeneratingIndicator.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { formatDuration } from "./GeneratingIndicator"; +import { formatDuration } from "@posthog/ui/features/sessions/components/GeneratingIndicator"; describe("formatDuration", () => { it("formats sub-minute durations with configurable precision", () => { diff --git a/apps/code/src/renderer/features/sessions/components/PendingChatView.tsx b/apps/code/src/renderer/features/sessions/components/PendingChatView.tsx deleted file mode 100644 index e657e41f3a..0000000000 --- a/apps/code/src/renderer/features/sessions/components/PendingChatView.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 { Box, Flex, Text } from "@radix-ui/themes"; -import { PendingInputPlaceholder } from "./PendingInputPlaceholder"; -import { UserMessage } from "./session-update/UserMessage"; - -interface PendingChatViewProps { - promptText: string; - attachments?: UserMessageAttachment[]; -} - -export function PendingChatView({ - promptText, - attachments, -}: PendingChatViewProps) { - return ( - - - - - - - Starting task... - - - - - - - - ); -} diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx deleted file mode 100644 index 49b9cdfa95..0000000000 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ /dev/null @@ -1,716 +0,0 @@ -import { isOtherOption } from "@components/action-selector/constants"; -import { PermissionSelector } from "@components/permissions/PermissionSelector"; -import { showOfflineToast } from "@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"; -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"; -import { - type AcpMessage, - isJsonRpcNotification, - isJsonRpcResponse, -} from "@shared/types/session-events"; -import { - pendingTaskPromptStoreApi, - usePendingTaskPrompt, -} from "@stores/pendingTaskPromptStore"; -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[]; - taskId?: string; - task?: Task; - isRunning: boolean; - isPromptPending?: boolean | null; - promptStartedAt?: number | null; - onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean; - onSendPrompt: (text: string) => void; - onBashCommand?: (command: string) => void; - onCancelPrompt: () => void; - repoPath?: string | null; - cloudBranch?: string | null; - isSuspended?: boolean; - onRestoreWorktree?: () => void; - isRestoring?: boolean; - hasError?: boolean; - errorTitle?: string; - errorMessage?: string; - onRetry?: () => void; - onNewSession?: () => void; - isInitializing?: boolean; - isCloud?: boolean; - cloudStatus?: TaskRunStatus | null; - slackThreadUrl?: string; - compact?: boolean; - isActiveSession?: boolean; - /** Hide the message input and permission UI — log-only view. */ - hideInput?: boolean; -} - -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, -): 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, - task, - isRunning, - isPromptPending = false, - promptStartedAt, - onBeforeSubmit, - onSendPrompt, - onBashCommand, - onCancelPrompt, - repoPath, - cloudBranch, - isSuspended = false, - onRestoreWorktree, - isRestoring = false, - hasError = false, - errorTitle, - errorMessage = DEFAULT_ERROR_MESSAGE, - onRetry, - onNewSession, - isInitializing = false, - isCloud = false, - cloudStatus = null, - slackThreadUrl, - compact = false, - isActiveSession = true, - hideInput = false, -}: SessionViewProps) { - const showRawLogs = useShowRawLogs(); - const { setShowRawLogs } = useSessionViewActions(); - const pendingTaskPrompt = usePendingTaskPrompt(taskId); - const pendingPermissions = usePendingPermissionsForTask(taskId); - const modeOption = useModeConfigOptionForTask(taskId); - const thoughtOption = useThoughtLevelConfigOptionForTask(taskId); - const adapter = useAdapterForTask(taskId); - const { allowBypassPermissions } = useSettingsStore(); - const { isOnline } = useConnectivity(); - const currentModeId = modeOption?.currentValue; - const handoffInProgress = - useSessionForTask(taskId)?.handoffInProgress ?? false; - - useEffect(() => { - if (!taskId) return; - if (isInitializing) return; - pendingTaskPromptStoreApi.clear(taskId); - }, [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]); - - const handleModeChange = useCallback( - (nextMode: string) => { - if (!taskId) return; - getSessionService().setSessionConfigOptionByCategory( - taskId, - "mode", - nextMode, - ); - }, - [taskId], - ); - - const handleThoughtChange = useCallback( - (value: string) => { - if (!taskId || !thoughtOption) return; - getSessionService().setSessionConfigOption( - taskId, - thoughtOption.id, - value, - ); - }, - [taskId, thoughtOption], - ); - - const sessionId = taskId ?? "default"; - const setContext = useDraftStore((s) => s.actions.setContext); - const requestFocus = useDraftStore((s) => s.actions.requestFocus); - - useEffect(() => { - setContext(sessionId, { - taskId, - repoPath, - cloudBranch, - disabled: !isRunning, - isLoading: !!isPromptPending, - }); - }, [ - setContext, - sessionId, - taskId, - repoPath, - cloudBranch, - isRunning, - isPromptPending, - ]); - - 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 handleSubmit = useCallback( - (text: string) => { - if (text.trim()) { - onSendPrompt(text); - } - }, - [onSendPrompt], - ); - - const handleBeforeSubmit = useCallback( - (text: string, clearEditor: () => void): boolean => { - if (!isOnline) { - showOfflineToast(); - return false; - } - return onBeforeSubmit ? onBeforeSubmit(text, clearEditor) : true; - }, - [isOnline, onBeforeSubmit], - ); - - const [isDraggingFile, setIsDraggingFile] = useState(false); - const editorRef = useRef(null); - const dragCounterRef = useRef(0); - - const firstPendingPermission = useMemo(() => { - const entries = Array.from(pendingPermissions.entries()); - if (entries.length === 0) return null; - const [toolCallId, permission] = entries[0]; - return { ...permission, toolCallId }; - }, [pendingPermissions]); - - const handlePermissionSelect = useCallback( - async ( - optionId: string, - customInput?: string, - answers?: Record, - ) => { - if (!firstPendingPermission || !taskId) return; - - const selectedOption = firstPendingPermission.options.find( - (o) => o.optionId === optionId, - ); - 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, - ); - } - - requestFocus(sessionId); - }, - [ - firstPendingPermission, - taskId, - onSendPrompt, - requestFocus, - sessionId, - modeOption, - ], - ); - - const handlePermissionCancel = useCallback(async () => { - if (!firstPendingPermission || !taskId) return; - await getSessionService().cancelPermission( - taskId, - firstPendingPermission.toolCallId, - ); - await getSessionService().cancelPrompt(taskId); - requestFocus(sessionId); - }, [firstPendingPermission, taskId, requestFocus, sessionId]); - - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - dragCounterRef.current++; - if (e.dataTransfer.types.includes("Files")) { - setIsDraggingFile(true); - } - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - dragCounterRef.current--; - if (dragCounterRef.current === 0) { - setIsDraggingFile(false); - } - }, []); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - }, []); - - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - dragCounterRef.current = 0; - setIsDraggingFile(false); - - // If dropped on the editor, Tiptap's handleDrop already handled it - if ((e.target as HTMLElement).closest(".ProseMirror")) return; - - const files = e.dataTransfer.files; - if (!files || files.length === 0) return; - - resolveAndAttachDroppedFiles(files, (a) => - editorRef.current?.addAttachment(a), - ) - .then(() => editorRef.current?.focus()) - .catch(() => toast.error("Failed to attach files")); - }, []); - - const handlePaneClick = useCallback((e: React.MouseEvent) => { - const target = e.target as HTMLElement; - - const interactiveSelector = - 'button, a, input, textarea, select, [role="button"], [role="link"], [contenteditable="true"], [data-interactive]'; - if (target.closest(interactiveSelector)) { - return; - } - - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - return; - } - - editorRef.current?.focus(); - }, []); - - useAutoFocusOnTyping(editorRef, !isActiveSession); - - const handleContextMenu = useCallback((e: React.MouseEvent) => { - const target = e.target as HTMLElement; - if ( - target.closest('input, textarea, [contenteditable="true"], .ProseMirror') - ) { - e.stopPropagation(); - } - }, []); - - return ( - - - {showRawLogs ? ( - - setShowRawLogs(false)} - /> - - ) : ( - - {isSuspended ? ( - <> - - - - - - - - Worktree suspended - - - Worktree was removed to save disk space - - - {onRestoreWorktree && ( - - )} - - - - - ) : isInitializing ? ( - isCloud ? ( - - ) : pendingTaskPrompt?.promptText ? ( - - ) : ( - - - - ) - ) : ( - <> - - - - - - {hasError ? ( - - - {errorTitle && ( - - {errorTitle} - - )} - - {errorMessage} - - - {onRetry && ( - - )} - {onNewSession && ( - - )} - - - ) : hideInput ? null : firstPendingPermission ? ( - - - - - - ) : ( - - - - - Connecting to agent... - - - - - - } - reasoningSelector={ - thoughtOption ? ( - - ) : null - } - onBeforeSubmit={handleBeforeSubmit} - onSubmit={handleSubmit} - onBashCommand={onBashCommand} - onCancel={onCancelPrompt} - /> - - - - )} - - )} - - )} - - - { - const text = window.getSelection()?.toString(); - if (text) { - navigator.clipboard.writeText(text); - } - }} - > - Copy - - - setShowRawLogs(!showRawLogs)}> - {showRawLogs ? "Back to conversation" : "Show raw logs"} - - - - ); -} diff --git a/apps/code/src/renderer/features/sessions/components/session-update/FileMentionChip.tsx b/apps/code/src/renderer/features/sessions/components/session-update/FileMentionChip.tsx deleted file mode 100644 index 8a6d02a197..0000000000 --- a/apps/code/src/renderer/features/sessions/components/session-update/FileMentionChip.tsx +++ /dev/null @@ -1,109 +0,0 @@ -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 { 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 { getFilename } from "./toolCallUtils"; - -interface FileMentionChipProps { - filePath: string; -} - -function toRelativePath(absolutePath: string, repoPath: string | null): string { - if (!absolutePath) return absolutePath; - if (!repoPath) return absolutePath; - const normalizedRepo = repoPath.endsWith("/") - ? repoPath.slice(0, -1) - : repoPath; - if (absolutePath.startsWith(`${normalizedRepo}/`)) { - return absolutePath.slice(normalizedRepo.length + 1); - } - if (absolutePath === normalizedRepo) { - return ""; - } - return absolutePath; -} - -export const FileMentionChip = memo(function FileMentionChip({ - filePath, -}: FileMentionChipProps) { - const taskId = useSessionTaskId(); - const repoPath = useCwd(taskId ?? ""); - const workspace = useWorkspace(taskId ?? undefined); - const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit); - - const filename = getFilename(filePath); - const mainRepoPath = workspace?.folderPath; - - const handleClick = useCallback(() => { - if (!taskId) return; - const relativePath = toRelativePath(filePath, repoPath ?? null); - openFileInSplit(taskId, relativePath, true); - }, [taskId, filePath, repoPath, openFileInSplit]); - - const handleContextMenu = useCallback( - async (e: React.MouseEvent) => { - e.preventDefault(); - const absolutePath = isAbsolutePath(filePath) - ? filePath - : repoPath - ? `${repoPath}/${filePath}` - : filePath; - - const result = await trpcClient.contextMenu.showFileContextMenu.mutate({ - filePath: absolutePath, - showCollapseAll: false, - }); - - if (!result.action) return; - - if (result.action.type === "external-app") { - await handleExternalAppAction( - result.action.action, - absolutePath, - filename, - { workspace, mainRepoPath }, - ); - } - }, - [filePath, repoPath, filename, workspace, mainRepoPath], - ); - - const isClickable = !!taskId; - - const relativePath = toRelativePath(filePath, repoPath ?? null); - const directory = - relativePath && relativePath !== filename - ? relativePath.replace(`/${filename}`, "") - : null; - - return ( - - - - - - {filename} - - {directory && ( - - {directory} - - )} - - - - ); -}); diff --git a/apps/code/src/renderer/features/sessions/components/session-update/McpToolBlock.tsx b/apps/code/src/renderer/features/sessions/components/session-update/McpToolBlock.tsx index 25d0e5e3d8..50f4cd06c1 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/McpToolBlock.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/McpToolBlock.tsx @@ -1,11 +1,11 @@ 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 { McpToolView } from "@posthog/ui/features/mcp-apps/components/McpToolView"; +import { parseMcpToolKey } from "@posthog/ui/features/mcp-apps/utils/mcp-app-host-utils"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useTRPC } from "@renderer/trpc/client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; -import type { ToolViewProps } from "./toolCallUtils"; +import type { ToolViewProps } from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; interface McpToolBlockProps extends ToolViewProps { mcpToolName: string; diff --git a/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx b/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx deleted file mode 100644 index 90eebd85bf..0000000000 --- a/apps/code/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import type { Step } from "@components/ui/StepList"; -import type { ConversationItem } from "@features/sessions/components/buildConversationItems"; -import type { SessionUpdate, ToolCall } from "@features/sessions/types"; -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 { ToolCallBlock } from "./ToolCallBlock"; - -export type RenderItem = - | SessionUpdate - | { - sessionUpdate: "console"; - level: string; - message: string; - timestamp?: string; - } - | { - sessionUpdate: "compact_boundary"; - trigger: "manual" | "auto"; - preTokens: number; - contextSize?: number; - } - | { - sessionUpdate: "status"; - status: string; - isComplete?: boolean; - } - | { - sessionUpdate: "error"; - errorType: string; - message: string; - } - | { - sessionUpdate: "task_notification"; - taskId: string; - status: "completed" | "failed" | "stopped"; - summary: string; - outputFile: string; - } - | { - sessionUpdate: "progress_group"; - steps: Step[]; - isActive: boolean; - }; - -interface SessionUpdateViewProps { - item: RenderItem; - toolCalls?: Map; - childItems?: Map; - turnCancelled?: boolean; - turnComplete?: boolean; - thoughtComplete?: boolean; -} - -export const SessionUpdateView = memo(function SessionUpdateView({ - item, - toolCalls, - childItems, - turnCancelled, - turnComplete, - thoughtComplete, -}: SessionUpdateViewProps) { - switch (item.sessionUpdate) { - case "user_message_chunk": - return null; - case "agent_message_chunk": - return item.content.type === "text" ? ( - - ) : null; - case "agent_thought_chunk": - return item.content.type === "text" ? ( - - ) : null; - case "tool_call": - return ( - - ); - case "tool_call_update": - return null; - case "plan": - return null; - case "available_commands_update": - return null; - case "config_option_update": - return null; - case "console": - return ( - - ); - case "compact_boundary": - return ( - - ); - case "status": - return ( - - ); - case "error": - return ( - - ); - case "task_notification": - return ( - - ); - case "progress_group": - return ( - - ); - default: - return null; - } -}); diff --git a/apps/code/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx b/apps/code/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx deleted file mode 100644 index 5ebe91129c..0000000000 --- a/apps/code/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import type { - ConversationItem, - TurnContext, -} from "@features/sessions/components/buildConversationItems"; -import type { ToolCall } from "@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 { SubagentToolView } from "./SubagentToolView"; -import { ThinkToolView } from "./ThinkToolView"; -import { ToolCallView } from "./ToolCallView"; -import type { ToolViewProps } from "./toolCallUtils"; - -interface ToolCallBlockProps extends ToolViewProps { - childItems?: ConversationItem[]; - childItemsMap?: Map; -} - -export function ToolCallBlock({ - toolCall, - turnCancelled, - turnComplete, - childItems, - childItemsMap, -}: ToolCallBlockProps) { - const meta = toolCall._meta as - | { claudeCode?: { toolName?: string } } - | undefined; - const toolName = meta?.claudeCode?.toolName; - - if (toolName === "EnterPlanMode") { - return null; - } - - const props = { toolCall, turnCancelled, turnComplete }; - - if ( - (toolName === "Task" || toolName === "Agent") && - childItems && - childItems.length > 0 - ) { - const turnContext: TurnContext = { - toolCalls: buildChildToolCallsMap(childItems), - childItems: childItemsMap ?? new Map(), - turnCancelled: turnCancelled ?? false, - turnComplete: turnComplete ?? false, - }; - return ( - - - - ); - } - - if (toolName?.startsWith("mcp__")) { - return ( - - - - ); - } - - const content = (() => { - switch (toolCall.kind) { - case "switch_mode": - return ; - case "execute": - return ; - case "read": - return ; - case "edit": - return ; - case "delete": - return ; - case "move": - return ; - case "search": - return ; - case "think": - return ; - case "fetch": - return ; - case "question": - return ; - default: - return ; - } - })(); - - return {content}; -} - -function buildChildToolCallsMap( - childItems: ConversationItem[], -): Map { - const map = new Map(); - for (const item of childItems) { - if ( - item.type === "session_update" && - item.update.sessionUpdate === "tool_call" - ) { - const tc = item.update as unknown as ToolCall; - if (tc.toolCallId) { - map.set(tc.toolCallId, tc); - } - } - } - return map; -} diff --git a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts b/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts deleted file mode 100644 index 9745dd8eba..0000000000 --- a/apps/code/src/renderer/features/sessions/hooks/useChatTitleGenerator.ts +++ /dev/null @@ -1,170 +0,0 @@ -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 { - 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"; -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, -): 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); -} - -export function useChatTitleGenerator(task: Task): void { - const taskId = task.id; - const lastGeneratedAtCount = useRef(0); - const initialDescriptionHandled = useRef(false); - const isGenerating = useRef(false); - const isAuthenticated = useAuthStateValue( - (state) => state.status === "authenticated" && !!state.cloudRegion, - ); - - const promptCount = useSessionStore((state) => { - const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) return 0; - const session = state.sessions[taskRunId]; - if (!session?.events) return 0; - return extractUserPromptsFromEvents(session.events).length; - }); - - useEffect(() => { - 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); - - if (!shouldGenerateFromPrompts && !shouldGenerateFromTaskDescription) { - return; - } - - isGenerating.current = true; - - const state = useSessionStore.getState(); - const taskRunId = state.taskIdIndex[taskId]; - const session = taskRunId ? state.sessions[taskRunId] : undefined; - let rawContent = task.description; - - if (shouldGenerateFromPrompts) { - if (!session?.events) { - isGenerating.current = false; - return; - } - - const allPrompts = extractUserPromptsFromEvents(session.events); - const promptsForTitle = - promptCount === 1 ? allPrompts : allPrompts.slice(-REGENERATE_INTERVAL); - - rawContent = promptsForTitle.map((p, i) => `${i + 1}. ${p}`).join("\n"); - } - - const run = async () => { - try { - const content = await enrichDescriptionWithFileContent(rawContent); - const result = await generateTitleAndSummary(content); - if (result) { - const { title, summary } = result; - const titleLocked = isAutoTitleLocked(getCachedTask(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( - { queryKey: taskKeys.lists() }, - (old) => - old?.map((task) => - task.id === taskId ? { ...task, title } : task, - ), - ); - queryClient.setQueriesData( - { queryKey: taskKeys.allSummaries() }, - (old) => - old?.map((task) => - task.id === taskId ? { ...task, title } : task, - ), - ); - queryClient.setQueryData(taskKeys.detail(taskId), (old) => - old ? { ...old, title } : old, - ); - getSessionService().updateSessionTaskTitle(taskId, title); - log.debug("Updated task title from conversation", { - taskId, - promptCount, - }); - } - } - - if (summary && taskRunId) { - sessionStoreSetters.updateSession(taskRunId, { - conversationSummary: result.summary, - }); - - log.debug("Updated task summary from conversation", { - taskId, - promptCount, - }); - } - } - } catch (error) { - log.error("Failed to update task title", { taskId, error }); - } finally { - if (shouldGenerateFromPrompts) { - lastGeneratedAtCount.current = promptCount; - } - if (shouldGenerateFromTaskDescription) { - initialDescriptionHandled.current = true; - } - isGenerating.current = false; - } - }; - - run(); - }, [isAuthenticated, promptCount, taskId, task]); -} diff --git a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.test.ts b/apps/code/src/renderer/features/sessions/hooks/useContextUsage.test.ts deleted file mode 100644 index 88c37ffa02..0000000000 --- a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { AcpMessage } from "@shared/types/session-events"; -import { describe, expect, it } from "vitest"; -import { extractContextUsage } from "./useContextUsage"; - -function usageUpdateEvent(used: number, size: number): AcpMessage { - return { - type: "acp_message", - ts: 1, - message: { - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId: "s1", - update: { sessionUpdate: "usage_update", used, size }, - }, - }, - }; -} - -function breakdownEvent( - breakdown: Record, - method = "_posthog/usage_update", -): AcpMessage { - return { - type: "acp_message", - ts: 1, - message: { jsonrpc: "2.0", method, params: { sessionId: "s1", breakdown } }, - }; -} - -describe("extractContextUsage", () => { - it("returns null with no usage event", () => { - expect(extractContextUsage([])).toBeNull(); - }); - - it("derives aggregate from the latest session/update", () => { - const result = extractContextUsage([usageUpdateEvent(50_000, 200_000)]); - expect(result?.used).toBe(50_000); - expect(result?.size).toBe(200_000); - expect(result?.percentage).toBe(25); - expect(result?.breakdown).toBeNull(); - }); - - it("merges breakdown from a _posthog/usage_update notification", () => { - const result = extractContextUsage([ - usageUpdateEvent(50_000, 200_000), - breakdownEvent({ - systemPrompt: 4000, - tools: 500, - rules: 0, - skills: 0, - mcp: 0, - subagents: 0, - conversation: 45_500, - }), - ]); - expect(result?.breakdown?.systemPrompt).toBe(4000); - expect(result?.breakdown?.conversation).toBe(45_500); - }); - - it("tolerates the double-underscore method prefix from extNotification", () => { - const result = extractContextUsage([ - usageUpdateEvent(50_000, 200_000), - breakdownEvent( - { - systemPrompt: 4000, - tools: 0, - rules: 0, - skills: 0, - mcp: 0, - subagents: 0, - conversation: 46_000, - }, - "__posthog/usage_update", - ), - ]); - expect(result?.breakdown?.systemPrompt).toBe(4000); - }); -}); diff --git a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts b/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts deleted file mode 100644 index 73a8c68623..0000000000 --- a/apps/code/src/renderer/features/sessions/hooks/useContextUsage.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { AcpMessage } from "@shared/types/session-events"; -import { useMemo } from "react"; - -// 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; - rules: number; - skills: number; - mcp: number; - subagents: number; - conversation: number; -} - -export interface ContextUsage { - used: number; - size: number; - percentage: number; - cost: { amount: number; currency: string } | null; - 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; - - for (let i = events.length - 1; i >= 0; i--) { - const msg = events[i].message; - if (!aggregate) { - aggregate = extractAggregate(msg); - } - if (!breakdown) { - breakdown = extractBreakdown(msg); - } - if (aggregate && breakdown) break; - } - - if (!aggregate) return null; - return { ...aggregate, breakdown }; -} - -function extractAggregate( - msg: AcpMessage["message"], -): Omit | null { - if ( - "method" in msg && - msg.method === "session/update" && - !("id" in msg) && - "params" in msg - ) { - const params = msg.params as - | { - update?: { - sessionUpdate?: string; - used?: number; - size?: number; - cost?: { amount: number; currency: string } | null; - }; - } - | undefined; - const update = params?.update; - if ( - update?.sessionUpdate === "usage_update" && - typeof update.used === "number" && - typeof update.size === "number" - ) { - const percentage = - update.size > 0 - ? Math.min(100, Math.round((update.used / update.size) * 100)) - : 0; - return { - used: update.used, - size: update.size, - percentage, - cost: update.cost ?? null, - }; - } - } - return null; -} - -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" - ) { - return null; - } - const params = msg.params as { breakdown?: ContextBreakdown } | undefined; - return params?.breakdown ?? null; -} diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts deleted file mode 100644 index ae236342fb..0000000000 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts +++ /dev/null @@ -1,190 +0,0 @@ -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"; - -const log = logger.scope("session-callbacks"); - -interface UseSessionCallbacksOptions { - taskId: string; - task: Task; - session: AgentSession | undefined; - repoPath: string | null; -} - -export function useSessionCallbacks({ - taskId, - task, - session, - repoPath, -}: UseSessionCallbacksOptions) { - const { markActivity, markAsViewed } = useTaskViewed(); - const { requestFocus, setPendingContent } = useDraftStore((s) => s.actions); - - const sessionRef = useRef(session); - sessionRef.current = session; - - const handleSendPrompt = useCallback( - async (text: string) => { - const currentSession = sessionRef.current; - const currentEvents = currentSession?.events ?? []; - const handled = await tryExecuteCodeCommand(text, { - taskId, - repoPath, - session: currentSession - ? { - taskRunId: currentSession.taskRunId, - logUrl: currentSession.logUrl, - events: currentEvents, - } - : null, - taskRun: task.latest_run ?? null, - }); - if (handled) return; - - try { - markAsViewed(taskId); - markActivity(taskId); - await getSessionService().sendPrompt(taskId, text); - - const view = useNavigationStore.getState().view; - const isViewingTask = - view?.type === "task-detail" && view?.data?.id === taskId; - if (isViewingTask) { - markAsViewed(taskId); - } - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to send message"; - toast.error(message); - log.error("Failed to send prompt", error); - } - }, - [taskId, repoPath, markActivity, markAsViewed, task.latest_run], - ); - - const handleCancelPrompt = useCallback(async () => { - const queuedMessages = sessionStoreSetters.dequeueMessages(taskId); - const result = await getSessionService().cancelPrompt(taskId); - log.info("Prompt cancelled", { success: result }); - - const queuedPrompt = sessionRef.current?.isCloud - ? combineQueuedCloudPrompts(queuedMessages) - : queuedMessages.map((message) => message.content).join("\n\n"); - - if (queuedPrompt) { - const pendingContent = sessionRef.current?.isCloud - ? promptToQueuedEditorContent(queuedPrompt) - : { - segments: [ - { - type: "text" as const, - text: typeof queuedPrompt === "string" ? queuedPrompt : "", - }, - ], - }; - - setPendingContent(taskId, pendingContent); - } - requestFocus(taskId); - }, [taskId, setPendingContent, requestFocus]); - - const handleRetry = useCallback(async () => { - try { - if (sessionRef.current?.isCloud) { - await getSessionService().retryCloudTaskWatch(taskId); - return; - } - - if (!repoPath) return; - await getSessionService().clearSessionError(taskId, repoPath); - } catch (error) { - log.error("Failed to clear session error", error); - toast.error("Failed to retry. Please try again."); - } - }, [taskId, repoPath]); - - const handleNewSession = useCallback(async () => { - if (!repoPath) return; - try { - await getSessionService().resetSession(taskId, repoPath); - } catch (error) { - log.error("Failed to reset session", error); - toast.error("Failed to start new session. Please try again."); - } - }, [taskId, repoPath]); - - 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( - taskId, - execId, - command, - repoPath, - ); - - try { - const result = await trpcClient.shell.execute.mutate({ - cwd: repoPath, - command, - }); - await getSessionService().completeUserShellExecute( - taskId, - execId, - command, - repoPath, - result, - ); - } catch (error) { - log.error("Failed to execute shell command", error); - await getSessionService().completeUserShellExecute( - taskId, - execId, - command, - repoPath, - { - stdout: "", - stderr: error instanceof Error ? error.message : "Command failed", - exitCode: 1, - }, - ); - } - }, - [taskId, repoPath], - ); - - const initiateHandoffToCloud = useCallback(async () => { - if (!repoPath) return; - try { - await getSessionService().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]); - - return { - handleSendPrompt, - handleCancelPrompt, - handleRetry, - handleNewSession, - handleBashCommand, - initiateHandoffToCloud, - }; -} 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/hooks/useSessionViewState.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts deleted file mode 100644 index 19bbdf26cb..0000000000 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionViewState.ts +++ /dev/null @@ -1,71 +0,0 @@ -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"; - -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 cloudStatus = session?.cloudStatus ?? null; - const isCloudRunNotTerminal = - isCloud && - (!cloudStatus || cloudStatus === "queued" || cloudStatus === "in_progress"); - const isCloudRunTerminal = isCloud && !isCloudRunNotTerminal; - - const hasError = session?.status === "error" && !session?.idleKilled; - const handoffInProgress = session?.handoffInProgress ?? false; - - let isRunning = false; - if (!handoffInProgress) { - if (isCloud) { - isRunning = !hasError; - } else { - isRunning = session?.status === "connected"; - } - } - - const events = session?.events ?? []; - const isPromptPending = session?.isPromptPending ?? false; - const promptStartedAt = session?.promptStartedAt; - - const isNewSessionWithInitialPrompt = - !task.latest_run?.id && !!task.description; - const isResumingExistingSession = !!task.latest_run?.id; - const isInitializing = isCloud - ? !hasError && (!session || (events.length === 0 && isCloudRunNotTerminal)) - : !session || - (session.status === "connecting" && events.length === 0) || - (session.status === "connected" && - events.length === 0 && - (isPromptPending || - isNewSessionWithInitialPrompt || - isResumingExistingSession)); - - const cloudBranch = isCloud - ? (workspace?.baseBranch ?? task.latest_run?.branch ?? null) - : null; - - return { - session, - repoPath, - isCloud, - isCloudRunNotTerminal, - isCloudRunTerminal, - cloudStatus, - isRunning: !!isRunning, - hasError, - events, - isPromptPending, - promptStartedAt, - isInitializing, - cloudBranch, - errorTitle: session?.errorTitle, - errorMessage: - session?.errorMessage ?? - (isCloud ? session?.cloudErrorMessage : undefined), - }; -} 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/service/service.recovery.integration.test.ts b/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts index 617ad07f7a..33fbf7037c 100644 --- a/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts @@ -138,7 +138,7 @@ const mockSessionConfigStore = vi.hoisted(() => ({ })); vi.mock( - "@features/sessions/stores/sessionConfigStore", + "@posthog/ui/features/sessions/sessionConfigStore", () => mockSessionConfigStore, ); @@ -158,27 +158,36 @@ 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(), })); +vi.mock("@posthog/di/container", () => ({ + resolveService: (token: symbol) => { + if (token === Symbol.for("posthog.host.trpcClient")) { + return { fs: mockTrpcFs }; + } + 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(), @@ -203,7 +212,7 @@ 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", () => ({ @@ -213,7 +222,8 @@ vi.mock("@utils/queryClient", () => ({ setQueriesData: vi.fn(), }, })); -vi.mock("@shared/utils/urls", () => ({ +vi.mock("@posthog/shared", async (importOriginal) => ({ + ...(await importOriginal()), getCloudUrlFromRegion: () => "https://api.anthropic.com", })); @@ -221,10 +231,12 @@ const mockConvertStoredEntriesToEvents = vi.hoisted(() => vi.fn<(entries: unknown[]) => unknown[]>(() => []), ); -vi.mock("@utils/session", async () => { - const actual = - await vi.importActual("@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,13 +269,13 @@ 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"; +} from "@posthog/ui/features/sessions/sessionStore"; import { getSessionService, resetSessionService } from "./service"; const TASK_ID = "task-299bc88e"; diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index 1b7f411b4b..559c40ddfd 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.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 --- @@ -104,7 +104,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), @@ -189,7 +189,7 @@ const mockSessionConfigStore = vi.hoisted(() => ({ })); vi.mock( - "@features/sessions/stores/sessionConfigStore", + "@posthog/ui/features/sessions/sessionConfigStore", () => mockSessionConfigStore, ); @@ -209,13 +209,13 @@ 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(), })); @@ -223,13 +223,13 @@ 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(), @@ -254,7 +254,7 @@ 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", () => ({ @@ -264,16 +264,40 @@ vi.mock("@utils/queryClient", () => ({ setQueriesData: vi.fn(), }, })); -vi.mock("@shared/utils/urls", () => ({ +vi.mock("@posthog/di/container", () => ({ + resolveService: (token: symbol) => { + if (token === Symbol.for("posthog.host.trpcClient")) { + return { fs: mockTrpcFs }; + } + throw new Error(`resolveService: unmocked token ${String(token)}`); + }, +})); +vi.mock("@posthog/shared", async (importOriginal) => ({ + ...(await importOriginal()), 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("@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,16 +322,20 @@ 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 { toast } from "@posthog/ui/primitives/toast"; import { getSessionService, resetSessionService } from "./service"; // --- Test Fixtures --- diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index c0903429bd..4f44a849b8 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -1,62 +1,36 @@ -import type { - ContentBlock, - RequestPermissionRequest, - SessionConfigOption, -} 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 { 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 { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore"; +import { useAddDirectoryDialogStore } from "@posthog/ui/features/folder-picker/addDirectoryDialogStore"; +import { useSessionAdapterStore } from "@posthog/ui/features/sessions/sessionAdapterStore"; import { getPersistedConfigOptions, removePersistedConfigOptions, setPersistedConfigOptions, updatePersistedConfigOptionValue, -} from "@features/sessions/stores/sessionConfigStore"; -import type { - Adapter, - AgentSession, - PermissionRequest, -} from "@features/sessions/stores/sessionStore"; -import { - flattenSelectOptions, - getConfigOptionByCategory, - 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"; +} 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 { trpcClient } from "@renderer/trpc/client"; -import { toast } from "@renderer/utils/toast"; -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"; import { @@ -64,195 +38,90 @@ import { notifyPromptComplete, } from "@utils/notifications"; import { queryClient } from "@utils/queryClient"; -import { - convertStoredEntriesToEvents, - createUserPromptEvent, - createUserShellExecuteEvent, - extractPromptText, - getUserShellExecutesSinceLastPrompt, - isFatalSessionError, - isRateLimitError, - normalizePromptToBlocks, - shellExecutesToContextBlocks, -} from "@utils/session"; -import { - cloudPromptToBlocks, - combineQueuedCloudPrompts, - getCloudPromptTransport, - uploadRunAttachments, - uploadTaskStagedAttachments, -} from "../utils/cloudArtifacts"; -import { CloudRunIdleTracker } from "./cloudRunIdleTracker"; - -const log = logger.scope("session-service"); -const LOCAL_SESSION_RECONNECT_ATTEMPTS = 3; -const LOCAL_SESSION_RECONNECT_BACKOFF = { - initialDelayMs: 1_000, - maxDelayMs: 5_000, -}; -const LOCAL_SESSION_RECOVERY_MESSAGE = - "Lost connection to the agent. Reconnecting…"; -const LOCAL_SESSION_RECOVERY_FAILED_MESSAGE = - "Connecting to to the agent has been lost. Retry, or start a new session."; -const GITHUB_AUTHORIZATION_REQUIRED_CODE = "github_authorization_required"; -const AUTO_RETRY_MAX_ATTEMPTS = 2; -const AUTO_RETRY_DELAY_MS = 10_000; - -class GitHubAuthorizationRequiredForCloudHandoffError extends Error { - constructor( - message = "Connect GitHub before continuing this task in cloud.", - ) { - super(message); - this.name = "GitHubAuthorizationRequiredForCloudHandoffError"; - } -} -/** - * 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; -} +export { SessionService }; +export type { ConnectParams } from "@posthog/core/sessions/sessionService"; -function hasSessionPromptEvent(events: AcpMessage[]): boolean { - return events.some( - (event) => - isJsonRpcRequest(event.message) && - event.message.method === "session/prompt", - ); -} +const log = logger.scope("session-service"); -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", +// PORT NOTE: desktop host adapter for the ported @posthog/core SessionService. +// It wires the Electron renderer's tRPC client, @posthog/ui stores, and host +// helpers into the core service's host-agnostic SessionServiceDeps. The +// orchestration itself lives in @posthog/core/sessions/sessionService.ts. +const cloudArtifactService = new CloudArtifactService((filePath) => + trpcClient.fs.readFileAsBase64.query({ filePath }), +); + +function buildSessionServiceDeps(): SessionServiceDeps { + return { + trpc: trpcClient, + store: sessionStoreSetters, + log, + toast: { + error: (msg, opts) => toast.error(msg, opts), + info: (msg, opts) => toast.info(msg, opts), }, - ...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>>; -} - -interface CloudLogGapReconcileRequest { - taskId: string; - taskRunId: string; - expectedCount: number; - currentCount: number; - newEntries: StoredLogEntry[]; - logUrl?: string; -} - -interface ParsedSessionLogs { - rawEntries: StoredLogEntry[]; - totalLineCount: number; - parseFailureCount: number; - sessionId?: string; - adapter?: Adapter; -} - -interface CloudLogGapReconcileState { - pendingRequest?: CloudLogGapReconcileRequest; -} - -interface CloudLogReconcileDeficiency { - expectedCount: number; - observedLineCount: number; -} - -export interface ConnectParams { - task: Task; - repoPath: string; - initialPrompt?: ContentBlock[]; - executionMode?: ExecutionMode; - adapter?: "claude" | "codex"; - model?: string; - reasoningLevel?: string; -} - -const FOLDER_TAG_REGEX = //g; - -function isAbsoluteFolderPath(p: string): boolean { - return p.startsWith("/") || p.startsWith("~") || /^[A-Za-z]:[\\/]/.test(p); -} - -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; + track: (event, props) => { + (track as (event: string, props?: Record) => void)( + event, + props, + ); + }, + buildPermissionToolMetadata, + notifyPermissionRequest, + notifyPromptComplete, + 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 --- @@ -261,7 +130,7 @@ let serviceInstance: SessionService | null = null; export function getSessionService(): SessionService { if (!serviceInstance) { - serviceInstance = new SessionService(); + serviceInstance = new SessionService(buildSessionServiceDeps()); } return serviceInstance; } @@ -278,3701 +147,3 @@ export function resetSessionService(): void { log.error("Failed to reset all sessions on main process", err); }); } - -export class SessionService { - private connectingTasks = new Map>(); - 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 nextCloudTaskWatchToken = 0; - private subscriptions = new Map< - string, - { - event: { unsubscribe: () => void }; - permission?: { unsubscribe: () => void }; - } - >(); - /** Active cloud task watchers, keyed by taskId */ - private cloudTaskWatchers = new Map< - string, - { - runId: string; - apiHost: string; - teamId: number; - startToken: number; - subscription: { unsubscribe: () => void }; - onStatusChange?: () => void; - } - >(); - private cloudLogGapReconciles = new Map(); - /** Last observed reconcile deficit per taskRunId — see reconcileCloudLogGapOnce. */ - private cloudLogReconcileDeficiency = new Map< - string, - CloudLogReconcileDeficiency - >(); - /** Maps toolCallId → cloud requestId for routing permission responses */ - private cloudPermissionRequestIds = new Map(); - private idleKilledSubscription: { unsubscribe: () => void } | null = null; - /** - * Cached preview-config-options responses keyed by `${apiHost}::${adapter}`. - * Shared across cloud sessions so switching model/adapter reuses the list. - */ - private previewConfigOptionsCache = new Map< - string, - Promise - >(); - - constructor() { - this.idleKilledSubscription = - trpcClient.agent.onSessionIdleKilled.subscribe(undefined, { - onData: (event: { taskRunId: string }) => { - const { taskRunId } = event; - log.info("Session idle-killed by main process", { taskRunId }); - this.handleIdleKill(taskRunId); - }, - onError: (err: unknown) => { - log.debug("Idle-killed subscription error", { error: err }); - }, - }); - } - - /** - * Connect to a task session. - * Uses locking to prevent duplicate concurrent connections. - */ - async connectToTask(params: ConnectParams): Promise { - const { task } = params; - const taskId = task.id; - this.localRepoPaths.set(taskId, params.repoPath); - - // Return existing connection promise if already connecting - const existingPromise = this.connectingTasks.get(taskId); - if (existingPromise) { - return existingPromise; - } - - // Check for existing connected session - const existingSession = sessionStoreSetters.getSessionByTaskId(taskId); - if (existingSession?.status === "connected") { - log.info("Already connected to task", { taskId }); - return; - } - if (existingSession?.status === "connecting") { - log.info("Session already in connecting state", { taskId }); - return; - } - - // Create and store the connection promise - const connectPromise = this.doConnect(params).finally(() => { - this.connectingTasks.delete(taskId); - }); - this.connectingTasks.set(taskId, connectPromise); - - return connectPromise; - } - - private async doConnect(params: ConnectParams): Promise { - const { - task, - repoPath, - initialPrompt, - executionMode, - adapter, - model, - reasoningLevel, - } = params; - const { id: taskId, latest_run: latestRun } = task; - const taskTitle = task.title || task.description || "Task"; - - if (latestRun?.environment === "cloud") { - log.info("Skipping local session connect for cloud run", { - taskId, - taskRunId: latestRun.id, - }); - return; - } - - try { - const auth = await this.getAuthCredentials(); - if (!auth) { - log.error("Missing auth credentials"); - const taskRunId = latestRun?.id ?? `error-${taskId}`; - const session = this.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); - return; - } - - if (latestRun?.id && latestRun?.log_url) { - if (!getIsOnline()) { - log.info("Skipping connection attempt - offline", { taskId }); - const { rawEntries } = await this.fetchSessionLogs( - latestRun.log_url, - latestRun.id, - ); - const events = convertStoredEntriesToEvents(rawEntries); - const session = this.createBaseSession( - latestRun.id, - taskId, - taskTitle, - ); - session.events = events; - session.logUrl = latestRun.log_url; - session.status = "disconnected"; - session.errorMessage = - "No internet connection. Connect when you're back online."; - sessionStoreSetters.setSession(session); - return; - } - - const [workspaceResult, logResult] = await Promise.all([ - trpcClient.workspace.verify.query({ taskId }), - this.fetchSessionLogs(latestRun.log_url, latestRun.id), - ]); - - if (!workspaceResult.exists) { - 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, - ); - session.events = events; - session.logUrl = latestRun.log_url; - 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); - return; - } - - await this.reconnectToLocalSession( - taskId, - latestRun.id, - taskTitle, - latestRun.log_url, - repoPath, - auth, - logResult, - ); - } else { - if (!getIsOnline()) { - log.info("Skipping connection attempt - offline", { taskId }); - const taskRunId = latestRun?.id ?? `offline-${taskId}`; - const session = this.createBaseSession(taskRunId, taskId, taskTitle); - session.status = "disconnected"; - session.errorMessage = - "No internet connection. Connect when you're back online."; - sessionStoreSetters.setSession(session); - return; - } - - await this.createNewLocalSession( - taskId, - taskTitle, - repoPath, - auth, - initialPrompt, - executionMode, - adapter, - model, - reasoningLevel, - ); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log.error("Failed to connect to task", { message }); - - const taskRunId = latestRun?.id ?? `error-${taskId}`; - const session = this.createBaseSession(taskRunId, taskId, taskTitle); - if (initialPrompt?.length) { - session.initialPrompt = initialPrompt; - } - if (latestRun?.log_url) { - try { - const { rawEntries } = await this.fetchSessionLogs( - latestRun.log_url, - latestRun.id, - ); - session.events = convertStoredEntriesToEvents(rawEntries); - session.logUrl = latestRun.log_url; - } catch { - // Ignore log fetch errors - } - } - - const shouldAutoRetry = getIsOnline(); - session.status = shouldAutoRetry ? "connecting" : "error"; - if (!shouldAutoRetry) { - session.errorTitle = "Failed to connect"; - session.errorMessage = message; - } - sessionStoreSetters.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", { - taskId, - attempt, - delayMs: AUTO_RETRY_DELAY_MS, - }); - await new Promise((resolve) => - setTimeout(resolve, AUTO_RETRY_DELAY_MS), - ); - if (!getIsOnline()) { - log.warn("Skipping retry — device went offline", { - taskId, - attempt, - }); - wentOffline = true; - break; - } - try { - await this.clearSessionError(taskId, repoPath); - return; - } catch (retryError) { - lastRetryMessage = - retryError instanceof Error - ? retryError.message - : String(retryError); - log.error("Auto-retry via clearSessionError failed", { - taskId, - attempt, - error: lastRetryMessage, - }); - } - } - - const currentSession = sessionStoreSetters.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, - }); - } - } - - private async reconnectToLocalSession( - taskId: string, - taskRunId: string, - taskTitle: string, - logUrl: string | undefined, - repoPath: string, - auth: AuthCredentials, - prefetchedLogs?: { - rawEntries: StoredLogEntry[]; - sessionId?: string; - adapter?: Adapter; - }, - ): Promise { - const { rawEntries, sessionId, adapter } = - prefetchedLogs ?? (await this.fetchSessionLogs(logUrl, taskRunId)); - const events = convertStoredEntriesToEvents(rawEntries); - - const storedAdapter = useSessionAdapterStore - .getState() - .getAdapter(taskRunId); - const resolvedAdapter = adapter ?? storedAdapter; - const persistedConfigOptions = getPersistedConfigOptions(taskRunId); - - const previous = sessionStoreSetters.getSessions()[taskRunId]; - - const session = this.createBaseSession(taskRunId, taskId, taskTitle); - session.events = events; - if (logUrl) { - session.logUrl = logUrl; - } - if (persistedConfigOptions) { - session.configOptions = persistedConfigOptions; - } - if (resolvedAdapter) { - session.adapter = resolvedAdapter; - useSessionAdapterStore.getState().setAdapter(taskRunId, resolvedAdapter); - } - - if (previous) { - session.optimisticItems = previous.optimisticItems; - session.messageQueue = previous.messageQueue; - session.isPromptPending = previous.isPromptPending; - session.promptStartedAt = previous.promptStartedAt; - session.pausedDurationMs = previous.pausedDurationMs; - } - - sessionStoreSetters.setSession(session); - this.subscribeToChannel(taskRunId); - - try { - const modeOpt = getConfigOptionByCategory(persistedConfigOptions, "mode"); - const persistedMode = - modeOpt?.type === "select" ? modeOpt.currentValue : undefined; - - trpcClient.workspace.verify - .query({ taskId }) - .then((workspaceResult) => { - if (!workspaceResult.exists) { - log.warn("Workspace no longer exists", { - taskId, - missingPath: workspaceResult.missingPath, - }); - sessionStoreSetters.updateSession(taskRunId, { - status: "error", - errorMessage: workspaceResult.missingPath - ? `Working directory no longer exists: ${workspaceResult.missingPath}` - : "The working directory for this task no longer exists. Please start a new session.", - }); - } - }) - .catch((err) => { - log.warn("Failed to verify workspace", { taskId, err }); - }); - - const { customInstructions } = useSettingsStore.getState(); - const result = await trpcClient.agent.reconnect.mutate({ - taskId, - taskRunId, - repoPath, - apiHost: auth.apiHost, - projectId: auth.projectId, - logUrl, - sessionId, - adapter: resolvedAdapter, - permissionMode: persistedMode, - customInstructions: customInstructions || undefined, - }); - - if (result) { - // Cast and merge live configOptions with persisted values. - // Fall back to persisted options if the agent doesn't return any - // (e.g. after session compaction). - let configOptions = result.configOptions as - | SessionConfigOption[] - | undefined; - if (configOptions && persistedConfigOptions) { - configOptions = mergeConfigOptions( - configOptions, - persistedConfigOptions, - ); - } else if (!configOptions) { - configOptions = persistedConfigOptions ?? undefined; - } - - sessionStoreSetters.updateSession(taskRunId, { - status: "connected", - configOptions, - }); - - // Persist the merged config options - if (configOptions) { - setPersistedConfigOptions(taskRunId, configOptions); - } - - // Restore persisted config options to server in parallel - if (persistedConfigOptions) { - await Promise.all( - persistedConfigOptions.map((opt) => - trpcClient.agent.setConfigOption - .mutate({ - sessionId: taskRunId, - configId: opt.id, - value: String(opt.currentValue), - }) - .catch((error) => { - log.warn( - "Failed to restore persisted config option after reconnect", - { - taskId, - configId: opt.id, - error, - }, - ); - }), - ), - ); - } - return true; - } else { - log.warn("Reconnect returned null", { taskId, taskRunId }); - this.setErrorSession( - taskId, - taskRunId, - taskTitle, - "Session could not be resumed. Please retry or start a new session.", - ); - return false; - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - log.warn("Reconnect failed", { taskId, error: errorMessage }); - this.setErrorSession( - taskId, - taskRunId, - taskTitle, - errorMessage || - "Failed to reconnect. Please retry or start a new session.", - ); - return false; - } - } - - private async teardownSession(taskRunId: string): Promise { - const session = this.getSessionByRunId(taskRunId); - - try { - await trpcClient.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.unsubscribeFromChannel(taskRunId); - sessionStoreSetters.removeSession(taskRunId); - this.cloudRunIdleTracker.delete(taskRunId); - this.cloudLogReconcileDeficiency.delete(taskRunId); - if (session) { - this.localRepoPaths.delete(session.taskId); - this.localRecoveryAttempts.delete(session.taskId); - } - useSessionAdapterStore.getState().removeAdapter(taskRunId); - removePersistedConfigOptions(taskRunId); - } - - /** - * Handle an idle-kill from the main process without destroying session state. - * The main process already cleaned up the agent, so we only need to - * unsubscribe from the channel and mark the session as errored. - * Preserves events, logUrl, configOptions and adapter so that Retry - * can reconnect with full context via resumeSession. - */ - private handleIdleKill(taskRunId: string): void { - this.unsubscribeFromChannel(taskRunId); - sessionStoreSetters.updateSession(taskRunId, { - status: "error", - errorMessage: "Session disconnected due to inactivity. Reconnecting…", - isPromptPending: false, - isCompacting: false, - promptStartedAt: null, - idleKilled: true, - }); - } - - private setErrorSession( - taskId: string, - taskRunId: string, - taskTitle: string, - errorMessage: string, - errorTitle?: string, - ): void { - // Preserve events and logUrl from the existing session so the - // 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); - session.status = "error"; - session.errorTitle = errorTitle; - session.errorMessage = errorMessage; - if (existing?.events?.length) { - session.events = existing.events; - } - if (existing?.logUrl) { - session.logUrl = existing.logUrl; - } - if (existing?.initialPrompt?.length) { - session.initialPrompt = existing.initialPrompt; - } - sessionStoreSetters.setSession(session); - } - - private async tryAutoRecoverLocalSession( - taskId: string, - taskRunId: string, - reason: string, - ): Promise { - const existingRecovery = this.localRecoveryAttempts.get(taskId); - if (existingRecovery) { - return existingRecovery; - } - - const recoveryPromise = this.runAutoRecoverLocalSession( - taskId, - taskRunId, - reason, - ).finally(() => { - this.localRecoveryAttempts.delete(taskId); - }); - - this.localRecoveryAttempts.set(taskId, recoveryPromise); - return recoveryPromise; - } - - private async runAutoRecoverLocalSession( - taskId: string, - taskRunId: string, - reason: string, - ): Promise { - const repoPath = this.localRepoPaths.get(taskId); - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!repoPath || !session || session.isCloud) { - return false; - } - - log.warn("Attempting automatic local session recovery", { - taskId, - taskRunId, - reason, - }); - - sessionStoreSetters.updateSession(taskRunId, { - status: "disconnected", - errorTitle: undefined, - errorMessage: LOCAL_SESSION_RECOVERY_MESSAGE, - isPromptPending: false, - isCompacting: false, - promptStartedAt: null, - }); - - for ( - let attempt = 0; - attempt < LOCAL_SESSION_RECONNECT_ATTEMPTS; - attempt++ - ) { - const currentSession = sessionStoreSetters.getSessionByTaskId(taskId); - if (!currentSession || currentSession.taskRunId !== taskRunId) { - return false; - } - - if (attempt > 0) { - const delay = getBackoffDelay( - attempt - 1, - LOCAL_SESSION_RECONNECT_BACKOFF, - ); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - - const recovered = await this.reconnectInPlace(taskId, repoPath); - if (recovered) { - log.info("Automatic local session recovery succeeded", { - taskId, - taskRunId, - attempt: attempt + 1, - }); - return true; - } - } - - const latestSession = sessionStoreSetters.getSessionByTaskId(taskId); - if (latestSession?.taskRunId === taskRunId) { - this.setErrorSession( - taskId, - taskRunId, - latestSession.taskTitle, - LOCAL_SESSION_RECOVERY_FAILED_MESSAGE, - "Connection lost", - ); - } - - log.warn("Automatic local session recovery exhausted", { - taskId, - taskRunId, - }); - - return false; - } - - private startAutoRecoverLocalSession( - taskId: string, - taskRunId: string, - taskTitle: string, - reason: string, - fallbackMessage: string, - ): void { - void this.tryAutoRecoverLocalSession(taskId, taskRunId, reason).then( - (recovered) => { - if (recovered) { - return; - } - - const latestSession = sessionStoreSetters.getSessionByTaskId(taskId); - if (!latestSession || latestSession.taskRunId !== taskRunId) { - return; - } - - if (latestSession.status !== "error") { - this.setErrorSession( - taskId, - taskRunId, - taskTitle, - fallbackMessage, - "Connection lost", - ); - } - }, - ); - } - - private async createNewLocalSession( - taskId: string, - taskTitle: string, - repoPath: string, - auth: AuthCredentials, - initialPrompt?: ContentBlock[], - executionMode?: ExecutionMode, - adapter?: "claude" | "codex", - model?: string, - reasoningLevel?: string, - ): Promise { - const { client } = auth; - if (!client) { - throw new Error("Unable to reach server. Please check your connection."); - } - - const taskRun = await client.createTaskRun(taskId); - if (!taskRun?.id) { - 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({ - taskId, - taskRunId: taskRun.id, - repoPath, - apiHost: auth.apiHost, - projectId: auth.projectId, - permissionMode: executionMode, - adapter, - customInstructions: startCustomInstructions || undefined, - effort: effortLevelSchema.safeParse(reasoningLevel).success - ? (reasoningLevel as EffortLevel) - : undefined, - model: preferredModel, - }); - - const session = this.createBaseSession(taskRun.id, taskId, taskTitle); - session.channel = result.channel; - session.status = "connected"; - session.adapter = adapter; - const configOptions = result.configOptions as - | SessionConfigOption[] - | undefined; - session.configOptions = configOptions; - - // Persist the config options - if (configOptions) { - setPersistedConfigOptions(taskRun.id, configOptions); - } - - // Persist the adapter - if (adapter) { - useSessionAdapterStore.getState().setAdapter(taskRun.id, adapter); - } - - // Store the initial prompt on the session so retry/reset flows can - // re-send it if the session errors after this point (e.g. subscription - // error, agent crash, or prompt failure). - if (initialPrompt?.length) { - session.initialPrompt = initialPrompt; - } - - sessionStoreSetters.setSession(session); - this.subscribeToChannel(taskRun.id); - - track(ANALYTICS_EVENTS.TASK_RUN_STARTED, { - task_id: taskId, - execution_type: "local", - initial_mode: executionMode, - adapter, - }); - - if (initialPrompt?.length) { - await this.sendPrompt(taskId, initialPrompt); - } - } - - async loadLogsOnly(params: { - taskId: string; - taskRunId: string; - taskTitle: string; - logUrl: string; - }): Promise { - const { taskId, taskRunId, taskTitle, logUrl } = params; - const existing = sessionStoreSetters.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); - session.events = events; - session.logUrl = logUrl; - session.status = "disconnected"; - sessionStoreSetters.setSession(session); - } - - async disconnectFromTask(taskId: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) return; - - await this.teardownSession(session.taskRunId); - } - - // --- Subscription Management --- - - private subscribeToChannel(taskRunId: string): void { - if (this.subscriptions.has(taskRunId)) { - return; - } - - const eventSubscription = trpcClient.agent.onSessionEvent.subscribe( - { taskRunId }, - { - onData: (payload: unknown) => { - this.handleSessionEvent(taskRunId, payload as AcpMessage); - }, - onError: (err) => { - log.error("Session subscription error", { taskRunId, error: err }); - const session = this.getSessionByRunId(taskRunId); - if (!session || session.isCloud) { - sessionStoreSetters.updateSession(taskRunId, { - status: "error", - errorMessage: - "Lost connection to the agent. Please restart the task.", - }); - return; - } - - this.startAutoRecoverLocalSession( - session.taskId, - taskRunId, - session.taskTitle, - "subscription_error", - "Lost connection to the agent. Please retry or start a new session.", - ); - }, - }, - ); - - const permissionSubscription = - trpcClient.agent.onPermissionRequest.subscribe( - { taskRunId }, - { - onData: async (payload) => { - this.handlePermissionRequest(taskRunId, payload); - }, - onError: (err) => { - log.error("Permission subscription error", { - taskRunId, - error: err, - }); - }, - }, - ); - - this.subscriptions.set(taskRunId, { - event: eventSubscription, - permission: permissionSubscription, - }); - } - - private unsubscribeFromChannel(taskRunId: string): void { - const subscription = this.subscriptions.get(taskRunId); - subscription?.event.unsubscribe(); - subscription?.permission?.unsubscribe(); - this.subscriptions.delete(taskRunId); - } - - /** - * Reset all service state and clean up subscriptions. - * Called on logout or app reset. - */ - reset(): void { - log.info("Resetting session service", { - subscriptionCount: this.subscriptions.size, - connectingCount: this.connectingTasks.size, - cloudWatcherCount: this.cloudTaskWatchers.size, - }); - - // Unsubscribe from all active subscriptions - for (const taskRunId of this.subscriptions.keys()) { - this.unsubscribeFromChannel(taskRunId); - } - - // Clean up all cloud task watchers - for (const taskId of [...this.cloudTaskWatchers.keys()]) { - this.stopCloudTaskWatch(taskId); - } - - this.connectingTasks.clear(); - this.localRepoPaths.clear(); - this.localRecoveryAttempts.clear(); - this.cloudPermissionRequestIds.clear(); - this.cloudLogGapReconciles.clear(); - this.cloudLogReconcileDeficiency.clear(); - this.dispatchingCloudQueues.clear(); - this.scheduledCloudQueueFlushes.clear(); - this.cloudRunIdleTracker.clear(); - this.idleKilledSubscription?.unsubscribe(); - this.idleKilledSubscription = null; - } - - private updatePromptStateFromEvents( - taskRunId: string, - events: AcpMessage[], - { isLive = false }: { isLive?: boolean } = {}, - ): void { - for (const acpMsg of events) { - const msg = acpMsg.message; - if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { - sessionStoreSetters.updateSession(taskRunId, { - isPromptPending: true, - promptStartedAt: acpMsg.ts, - pausedDurationMs: 0, - currentPromptId: msg.id, - }); - const promptSession = sessionStoreSetters.getSessions()[taskRunId]; - if (promptSession?.isCloud) { - this.cloudRunIdleTracker.markBusy(promptSession); - if (promptSession.agentIdleForRunId) { - sessionStoreSetters.updateSession(taskRunId, { - agentIdleForRunId: undefined, - }); - } - } - } - if ( - "id" in msg && - "result" in msg && - typeof msg.result === "object" && - msg.result !== null && - "stopReason" in msg.result - ) { - // 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]; - if (session && session.currentPromptId !== msg.id) { - continue; - } - sessionStoreSetters.updateSession(taskRunId, { - isPromptPending: false, - promptStartedAt: null, - currentPromptId: null, - }); - } - if (isTurnCompleteEvent(acpMsg)) { - // Local sessions use the JSON-RPC response as the canonical turn-done - // signal; clearing currentPromptId here would race the id-match guard - // above. Cloud sessions never see that response. - const session = this.getSessionByRunId(taskRunId); - if (session?.isCloud) { - sessionStoreSetters.updateSession(taskRunId, { - isPromptPending: false, - promptStartedAt: null, - currentPromptId: null, - }); - if (isLive) { - // Queued messages will start a new turn — suppress the "done" notification in that case. - if (session.messageQueue.length === 0) { - notifyPromptComplete( - session.taskTitle, - "end_turn", - session.taskId, - ); - } - taskViewedApi.markActivity(session.taskId); - } - } - } - // Lifecycle handshake from the agent — flip status to "connected" - // so the UI can release the queue-while-not-ready guard. This is - // the explicit "agent is up and accepting user messages" signal, - // emitted by `agent-server.ts` once the ACP session is fully - // wired. We deliberately do NOT drain the queue here: the agent - // is about to start `sendInitialTaskMessage` (or `sendResumeMessage`), - // and dispatching a queued user_message right now would race with - // its `clientConnection.prompt()` and one of the prompts would end - // up cancelled. The `turn_complete` handler below drains once the - // agent's initial / resume turn is actually finished. - if ( - "method" in msg && - isNotification(msg.method, POSTHOG_NOTIFICATIONS.RUN_STARTED) - ) { - const session = sessionStoreSetters.getSessions()[taskRunId]; - const params = (msg as { params?: { agentVersion?: unknown } }).params; - const agentVersion = - typeof params?.agentVersion === "string" - ? params.agentVersion - : undefined; - const updates: Partial = {}; - if (agentVersion && session?.agentVersion !== agentVersion) { - updates.agentVersion = agentVersion; - } - if (session?.isCloud && session.status !== "connected") { - updates.status = "connected"; - } - if (Object.keys(updates).length > 0) { - sessionStoreSetters.updateSession(taskRunId, updates); - } - } - // Canonical "turn boundary" — flush any queued cloud messages now - // that the agent is idle and accepting the next prompt. - if ( - "method" in msg && - isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE) - ) { - const session = sessionStoreSetters.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 - // finished, so the agent is idle for this run, lets a later - // transport drop recover readiness. - const updates: Partial = {}; - if (session.status !== "connected") { - updates.status = "connected"; - } - if (session.agentIdleForRunId !== taskRunId) { - updates.agentIdleForRunId = taskRunId; - } - if (Object.keys(updates).length > 0) { - sessionStoreSetters.updateSession(taskRunId, updates); - } - this.cloudRunIdleTracker.markIdle(session); - if (session.messageQueue.length > 0) { - this.scheduleCloudQueueFlush(session.taskId, "turn_complete"); - } - } - } - } - } - - private handleSessionEvent(taskRunId: string, acpMsg: AcpMessage): void { - const session = sessionStoreSetters.getSessions()[taskRunId]; - if (!session) return; - - const isUserPromptEcho = - isJsonRpcRequest(acpMsg.message) && - acpMsg.message.method === "session/prompt"; - - // 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, { - initialPrompt: undefined, - }); - } - - if (isUserPromptEcho) { - sessionStoreSetters.replaceOptimisticWithEvent(taskRunId, acpMsg); - } else { - sessionStoreSetters.appendEvents(taskRunId, [acpMsg]); - } - this.updatePromptStateFromEvents(taskRunId, [acpMsg], { isLive: true }); - - const msg = acpMsg.message; - - if ( - "id" in msg && - "result" in msg && - typeof msg.result === "object" && - msg.result !== null && - "stopReason" in msg.result - ) { - // Ignore responses that don't match the currently in-flight prompt id. - // A late response from a cancelled prior turn must not drain the queue - // or fire the "prompt complete" notification for the newer turn. - // We check against `session` (captured at the top of this function, pre-update), - // because updatePromptStateFromEvents above already cleared currentPromptId - // for a valid match — re-reading from the store would lose the distinction - // between "valid match just cleared" and "no turn was in flight". - if (session.currentPromptId !== msg.id) { - return; - } - - const stopReason = (msg.result as { stopReason?: string }).stopReason; - const hasQueuedMessages = this.drainQueuedMessages(taskRunId, session); - - // Only notify when queue is empty - queued messages will start a new turn - if (stopReason && !hasQueuedMessages) { - notifyPromptComplete(session.taskTitle, stopReason, session.taskId); - } - - taskViewedApi.markActivity(session.taskId); - } - - if ("method" in msg && msg.method === "session/update" && "params" in msg) { - const params = msg.params as { - update?: { - sessionUpdate?: string; - configOptions?: SessionConfigOption[]; - }; - }; - - // Handle config option updates (replaces current_mode_update) - if ( - params?.update?.sessionUpdate === "config_option_update" && - params.update.configOptions - ) { - const configOptions = params.update.configOptions; - sessionStoreSetters.updateSession(taskRunId, { - configOptions, - }); - // Persist the updated config options - setPersistedConfigOptions(taskRunId, configOptions); - log.info("Session config options updated", { taskRunId }); - } - - // Handle context usage updates - if (params?.update?.sessionUpdate === "usage_update") { - const update = params.update as { - used?: number; - size?: number; - }; - if ( - typeof update.used === "number" && - typeof update.size === "number" - ) { - sessionStoreSetters.updateSession(taskRunId, { - contextUsed: update.used, - contextSize: update.size, - }); - } - } - } - - // Handle SDK_SESSION notifications for adapter info - if ( - "method" in msg && - isNotification(msg.method, POSTHOG_NOTIFICATIONS.SDK_SESSION) && - "params" in msg - ) { - const params = msg.params as { - adapter?: Adapter; - }; - if (params?.adapter) { - sessionStoreSetters.updateSession(taskRunId, { - adapter: params.adapter, - }); - useSessionAdapterStore.getState().setAdapter(taskRunId, params.adapter); - } - } - - if ( - "method" in msg && - "params" in msg && - isNotification(msg.method, POSTHOG_NOTIFICATIONS.STATUS) - ) { - const params = msg.params as { status?: string; isComplete?: boolean }; - if (params?.status === "compacting") { - sessionStoreSetters.updateSession(taskRunId, { - isCompacting: !params.isComplete, - }); - } - } - - if ( - "method" in msg && - isNotification(msg.method, POSTHOG_NOTIFICATIONS.COMPACT_BOUNDARY) - ) { - sessionStoreSetters.updateSession(taskRunId, { - isCompacting: false, - }); - - this.drainQueuedMessages(taskRunId, session); - } - } - - private drainQueuedMessages( - taskRunId: string, - session: AgentSession, - ): boolean { - const freshSession = sessionStoreSetters.getSessions()[taskRunId]; - const hasQueuedMessages = - freshSession && - freshSession.messageQueue.length > 0 && - freshSession.status === "connected"; - - if (hasQueuedMessages) { - setTimeout(() => { - this.sendQueuedMessages(session.taskId).catch((err) => { - log.error("Failed to send queued messages", { - taskId: session.taskId, - error: err, - }); - }); - }, 0); - } - - return hasQueuedMessages; - } - - private handlePermissionRequest( - taskRunId: string, - payload: Omit & { - taskRunId: string; - }, - ): void { - 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]; - if (!session) { - log.warn("Session not found for permission request", { - taskRunId, - }); - return; - } - - const newPermissions = new Map(session.pendingPermissions); - // Add receivedAt to create PermissionRequest - newPermissions.set(payload.toolCall.toolCallId, { - ...payload, - receivedAt: Date.now(), - }); - - sessionStoreSetters.setPendingPermissions(taskRunId, newPermissions); - taskViewedApi.markActivity(session.taskId); - notifyPermissionRequest(session.taskTitle, session.taskId); - } - - private handleCloudPermissionRequest( - taskRunId: string, - update: CloudTaskPermissionRequestUpdate, - ): void { - log.info("Cloud permission request received", { - taskRunId, - requestId: update.requestId, - toolCallId: update.toolCall.toolCallId, - title: update.toolCall.title, - }); - - const session = sessionStoreSetters.getSessions()[taskRunId]; - if (!session) { - log.warn("Session not found for cloud permission request", { taskRunId }); - return; - } - - // Store the cloud requestId so we can route the response back - this.cloudPermissionRequestIds.set( - update.toolCall.toolCallId, - update.requestId, - ); - - const newPermissions = new Map(session.pendingPermissions); - newPermissions.set(update.toolCall.toolCallId, { - toolCall: update.toolCall as PermissionRequest["toolCall"], - options: update.options as PermissionRequest["options"], - taskRunId, - receivedAt: Date.now(), - }); - - sessionStoreSetters.setPendingPermissions(taskRunId, newPermissions); - taskViewedApi.markActivity(session.taskId); - notifyPermissionRequest(session.taskTitle, session.taskId); - } - - // --- Prompt Handling --- - - /** - * Send a prompt to the agent. - * Queues if a prompt is already pending. - */ - async sendPrompt( - taskId: string, - prompt: string | ContentBlock[], - ): Promise<{ stopReason: string }> { - if (!getIsOnline()) { - throw new Error( - "No internet connection. Please check your connection and try again.", - ); - } - - let session = sessionStoreSetters.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) { - throw new Error( - "Confirm the folder access dialog before sending your message.", - ); - } - - if (session.isCloud) { - return this.sendCloudPrompt(session, prompt); - } - - if (session.status !== "connected") { - if (session.status === "error") { - throw new Error( - session.errorMessage || - "Session is in error state. Please retry or start a new session.", - ); - } - if (session.status === "connecting") { - throw new Error( - "Session is still connecting. Please wait and try again.", - ); - } - throw new Error(`Session is not ready (status: ${session.status})`); - } - - if (session.isPromptPending || session.isCompacting) { - const promptText = extractPromptText(prompt); - sessionStoreSetters.enqueueMessage(taskId, promptText); - log.info("Message queued", { - taskId, - queueLength: session.messageQueue.length + 1, - reason: session.isCompacting ? "compacting" : "prompt_pending", - }); - return { stopReason: "queued" }; - } - - let blocks = normalizePromptToBlocks(prompt); - - const shellExecutes = getUserShellExecutesSinceLastPrompt(session.events); - if (shellExecutes.length > 0) { - const contextBlocks = shellExecutesToContextBlocks(shellExecutes); - blocks = [...contextBlocks, ...blocks]; - } - - const promptText = extractPromptText(prompt); - track(ANALYTICS_EVENTS.PROMPT_SENT, { - task_id: taskId, - is_initial: session.events.length === 0, - execution_type: "local", - prompt_length_chars: promptText.length, - }); - - // Show the user's message in the chat immediately, before any respawn - this.applyOptimisticPrompt(session.taskRunId, blocks, promptText); - - if (promptReferencesAbsoluteFolder(prompt)) { - const repoPath = this.localRepoPaths.get(taskId); - if (repoPath) { - 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, { - isPromptPending: false, - promptStartedAt: null, - }); - 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.", - }); - throw err instanceof Error - ? err - : new Error("Failed to apply additional directories"); - } - const refreshed = sessionStoreSetters.getSessionByTaskId(taskId); - if (refreshed) { - session = refreshed; - } - } - } - - return this.sendLocalPrompt(session, blocks, promptText, { - optimisticApplied: true, - }); - } - - /** - * Send all queued messages as a single prompt. - * Called internally when a turn completes and there are queued messages. - * Queue is cleared atomically before sending - if sending fails, messages are lost - * (this is acceptable since the user can re-type; avoiding complex retry logic). - */ - private async sendQueuedMessages( - taskId: string, - ): Promise<{ stopReason: string }> { - const combinedText = sessionStoreSetters.dequeueMessagesAsText(taskId); - if (!combinedText) { - return { stopReason: "skipped" }; - } - - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) { - 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", { - taskId, - promptLength: combinedText.length, - }); - - let blocks = normalizePromptToBlocks(combinedText); - - const shellExecutes = getUserShellExecutesSinceLastPrompt(session.events); - if (shellExecutes.length > 0) { - const contextBlocks = shellExecutesToContextBlocks(shellExecutes); - blocks = [...contextBlocks, ...blocks]; - } - - track(ANALYTICS_EVENTS.PROMPT_SENT, { - task_id: taskId, - is_initial: false, - execution_type: "local", - prompt_length_chars: combinedText.length, - }); - - try { - 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", { - taskId, - lostMessageLength: combinedText.length, - error, - }); - throw error; - } - } - - private applyOptimisticPrompt( - taskRunId: string, - blocks: ContentBlock[], - promptText: string, - ): void { - sessionStoreSetters.updateSession(taskRunId, { - isPromptPending: true, - promptStartedAt: Date.now(), - pausedDurationMs: 0, - }); - - const skillButtonId = extractSkillButtonId(blocks); - if (skillButtonId) { - sessionStoreSetters.appendOptimisticItem(taskRunId, { - type: "skill_button_action", - buttonId: skillButtonId, - }); - } else { - sessionStoreSetters.appendOptimisticItem(taskRunId, { - type: "user_message", - content: promptText, - timestamp: Date.now(), - }); - } - } - - private async sendLocalPrompt( - session: AgentSession, - blocks: ContentBlock[], - promptText: string, - options: { optimisticApplied?: boolean } = {}, - ): Promise<{ stopReason: string }> { - if (!options.optimisticApplied) { - this.applyOptimisticPrompt(session.taskRunId, blocks, promptText); - } - - try { - const result = await trpcClient.agent.prompt.mutate({ - sessionId: session.taskRunId, - prompt: blocks, - }); - sessionStoreSetters.updateSession(session.taskRunId, { - isPromptPending: false, - promptStartedAt: null, - }); - return result; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - const errorDetails = (error as { data?: { details?: string } }).data - ?.details; - - sessionStoreSetters.clearOptimisticItems(session.taskRunId); - - if (isRateLimitError(errorMessage, errorDetails)) { - log.warn("Rate limit exceeded, showing usage limit modal", { - taskRunId: session.taskRunId, - }); - sessionStoreSetters.updateSession(session.taskRunId, { - isPromptPending: false, - promptStartedAt: null, - }); - useUsageLimitStore.getState().show(); - return { stopReason: "rate_limited" }; - } - - if (isFatalSessionError(errorMessage, errorDetails)) { - log.error("Fatal prompt error, attempting recovery", { - taskRunId: session.taskRunId, - errorMessage, - errorDetails, - }); - this.startAutoRecoverLocalSession( - session.taskId, - session.taskRunId, - session.taskTitle, - errorDetails || errorMessage, - errorDetails || - "Session connection lost. Please retry or start a new session.", - ); - } else { - sessionStoreSetters.updateSession(session.taskRunId, { - isPromptPending: false, - isCompacting: false, - promptStartedAt: null, - }); - } - - throw error; - } - } - - /** - * Cancel the current prompt. - */ - async cancelPrompt(taskId: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) return false; - - sessionStoreSetters.updateSession(session.taskRunId, { - isPromptPending: false, - promptStartedAt: null, - }); - - if (session.isCloud) { - return this.cancelCloudPrompt(session); - } - - try { - const result = await trpcClient.agent.cancelPrompt.mutate({ - sessionId: session.taskRunId, - }); - - const durationSeconds = Math.round( - (Date.now() - session.startedAt) / 1000, - ); - const promptCount = session.events.filter( - (e) => "method" in e.message && e.message.method === "session/prompt", - ).length; - track(ANALYTICS_EVENTS.TASK_RUN_CANCELLED, { - task_id: taskId, - execution_type: "local", - duration_seconds: durationSeconds, - prompts_sent: promptCount, - }); - - return result; - } catch (error) { - log.error("Failed to cancel prompt", error); - return false; - } - } - - // --- Cloud Commands --- - - private async sendCloudPrompt( - session: AgentSession, - prompt: string | ContentBlock[], - options?: { skipQueueGuard?: boolean }, - ): Promise<{ stopReason: string }> { - const transport = getCloudPromptTransport(prompt); - if (!transport.messageText && transport.filePaths.length === 0) { - return { stopReason: "empty" }; - } - - if (isTerminalStatus(session.cloudStatus)) { - // If the agent never booted (no `run_started`), resuming spins another - // sandbox that hits the same provisioning failure — surface the error - // instead of looping. - if (session.cloudStatus === "failed" && session.status !== "connected") { - throw new Error( - session.cloudErrorMessage ?? - "Cloud run couldn't start. Check that GitHub is connected for this project, then try again.", - ); - } - return this.resumeCloudRun(session, prompt); - } - - if (session.cloudStatus !== "in_progress") { - sessionStoreSetters.enqueueMessage(session.taskId, transport.promptText); - log.info("Cloud message queued (sandbox not ready)", { - taskId: session.taskId, - cloudStatus: session.cloudStatus, - }); - return { stopReason: "queued" }; - } - - // Agent-readiness guard: until we've received `_posthog/run_started` - // (which flips `session.status` to `"connected"`), the agent may - // still be booting / restoring after a sandbox restart, or mid- - // initial-prompt — sending now would race with its - // `clientConnection.prompt(initialPrompt)` on the same ACP session. - // Funnel through the queue; the run_started or turn_complete - // handlers will drain it once the agent is provably ready. - if ( - !options?.skipQueueGuard && - session.isCloud && - session.status !== "connected" - ) { - sessionStoreSetters.enqueueMessage( - session.taskId, - transport.promptText, - prompt, - ); - log.info("Cloud message queued (agent not ready)", { - taskId: session.taskId, - sessionStatus: session.status, - queueLength: session.messageQueue.length + 1, - }); - // The watcher may have exhausted its reconnect budget and been left in a - // failed state — without an SSE stream, no `turn_complete` will arrive - // to drain the queue. Kick a retry so the stream comes back online; the - // queued message dispatches naturally once `run_started`/`turn_complete` - // 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), - }); - }); - } - return { stopReason: "queued" }; - } - - if (!options?.skipQueueGuard && session.isPromptPending) { - sessionStoreSetters.enqueueMessage( - session.taskId, - transport.promptText, - prompt, - ); - log.info("Cloud message queued", { - taskId: session.taskId, - queueLength: session.messageQueue.length + 1, - }); - return { stopReason: "queued" }; - } - - const [auth, cloudCommandAuth] = await Promise.all([ - this.getAuthCredentials(), - this.getCloudCommandAuth(), - ]); - if (!auth || !cloudCommandAuth) { - throw new Error("Authentication required for cloud commands"); - } - - this.watchCloudTask( - session.taskId, - session.taskRunId, - cloudCommandAuth.apiHost, - cloudCommandAuth.teamId, - undefined, - session.logUrl, - undefined, - session.adapter ?? "claude", - ); - - const artifactIds = await uploadRunAttachments( - auth.client, - session.taskId, - session.taskRunId, - transport.filePaths, - ); - const params: Record = {}; - if (transport.messageText) { - params.content = transport.messageText; - } - if (artifactIds.length > 0) { - params.artifact_ids = artifactIds; - } - - const currentSessionBeforeSend = - this.getSessionByRunId(session.taskRunId) ?? session; - const idleEvidenceBeforeSend = this.cloudRunIdleTracker.capture( - currentSessionBeforeSend, - ); - sessionStoreSetters.updateSession(session.taskRunId, { - isPromptPending: true, - promptStartedAt: Date.now(), - pausedDurationMs: 0, - agentIdleForRunId: undefined, - }); - this.cloudRunIdleTracker.markBusy(currentSessionBeforeSend); - sessionStoreSetters.appendOptimisticItem(session.taskRunId, { - type: "user_message", - content: transport.promptText, - timestamp: Date.now(), - pinToTop: false, - }); - - track(ANALYTICS_EVENTS.PROMPT_SENT, { - task_id: session.taskId, - is_initial: session.events.length === 0, - execution_type: "cloud", - prompt_length_chars: transport.promptText.length, - }); - - try { - const result = await trpcClient.cloudTask.sendCommand.mutate({ - taskId: session.taskId, - runId: session.taskRunId, - apiHost: cloudCommandAuth.apiHost, - teamId: cloudCommandAuth.teamId, - method: "user_message", - params, - }); - - if (!result.success) { - throw new Error(result.error ?? "Failed to send cloud command"); - } - - const commandResult = result.result as - | { queued?: boolean; stopReason?: string } - | undefined; - const stopReason = commandResult?.queued - ? "queued" - : (commandResult?.stopReason ?? "end_turn"); - - return { stopReason }; - } catch (error) { - sessionStoreSetters.updateSession(session.taskRunId, { - isPromptPending: false, - promptStartedAt: null, - }); - sessionStoreSetters.clearTailOptimisticItems(session.taskRunId); - const currentSessionAfterFailure = this.getSessionByRunId( - session.taskRunId, - ); - if (currentSessionAfterFailure) { - const restoreResult = this.cloudRunIdleTracker.restoreAfterFailedSend( - idleEvidenceBeforeSend, - currentSessionAfterFailure, - ); - if (restoreResult) { - log.warn("Restored idle evidence after failed cloud send", { - taskId: session.taskId, - taskRunId: session.taskRunId, - }); - if ( - currentSessionAfterFailure.agentIdleForRunId !== - restoreResult.agentIdleForRunId - ) { - sessionStoreSetters.updateSession(session.taskRunId, { - agentIdleForRunId: restoreResult.agentIdleForRunId, - }); - } - } - } - throw error; - } - } - - /** - * Dispatches all currently queued cloud messages as a single combined - * prompt. Drains the queue up-front and rolls it back on failure so the - * next dispatch trigger (turn_complete, cloudStatus flip) can retry. A - * per-taskId re-entrance guard prevents concurrent triggers from - * double-dispatching. - * - * Pre-flight conditions match what `sendCloudPrompt` would otherwise - * silently re-queue on (sandbox not in_progress, prompt already pending). - * Skipping early lets the next trigger retry instead of re-queueing the - * already-dequeued prompt back into the same queue. - */ - private async sendQueuedCloudMessages(taskId: string): Promise { - if (this.dispatchingCloudQueues.has(taskId)) return; - - this.dispatchingCloudQueues.add(taskId); - try { - const session = sessionStoreSetters.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. - // Otherwise gate on the agent-ready handshake (`run_started` flips - // status to "connected") to avoid racing with `sendInitialTaskMessage`. - const isTerminal = isTerminalStatus(session.cloudStatus); - const canSendNow = - isTerminal || - (session.cloudStatus === "in_progress" && - session.status === "connected"); - if (!canSendNow || session.isPromptPending) return; - - const drained = sessionStoreSetters.dequeueMessages(taskId); - const combined = combineQueuedCloudPrompts(drained); - if (!combined) return; - - log.info("Sending queued cloud messages", { - taskId, - drainedCount: drained.length, - }); - - try { - await this.sendCloudPrompt(session, combined, { - skipQueueGuard: true, - }); - } catch (err) { - log.warn("Cloud queue dispatch failed; re-enqueueing", { - taskId, - error: String(err), - }); - sessionStoreSetters.prependQueuedMessages(taskId, drained); - } - } finally { - this.dispatchingCloudQueues.delete(taskId); - } - } - - private async resumeCloudRun( - session: AgentSession, - prompt: string | ContentBlock[], - ): Promise<{ stopReason: string }> { - const authCredentials = await this.getAuthCredentials(); - if (!authCredentials) { - throw new Error("Authentication required for cloud commands"); - } - const auth = await this.getCloudCommandAuth(); - if (!auth) { - throw new Error("Authentication required for cloud commands"); - } - - const transport = getCloudPromptTransport(prompt); - if (!transport.messageText && transport.filePaths.length === 0) { - return { stopReason: "empty" }; - } - const artifactIds = await uploadTaskStagedAttachments( - authCredentials.client, - session.taskId, - transport.filePaths, - ); - - const previousRun = await authCredentials.client.getTaskRun( - session.taskId, - session.taskRunId, - ); - const previousState = previousRun.state as Record; - const previousOutput = (previousRun.output ?? {}) as Record< - string, - unknown - >; - // Prefer the actual working branch the agent last pushed to (synced by - // agent-server after each turn), then the run-level branch field, then - // the original base branch from state. This preserves unmerged work when - // the snapshot has expired and the sandbox is rebuilt from scratch. - const previousBaseBranch = - (typeof previousOutput.head_branch === "string" - ? previousOutput.head_branch - : null) ?? - previousRun.branch ?? - (typeof previousState.pr_base_branch === "string" - ? previousState.pr_base_branch - : null) ?? - session.cloudBranch; - const prAuthorshipMode = this.getCloudPrAuthorshipMode(previousState); - - log.info("Creating resume run for terminal cloud task", { - taskId: session.taskId, - previousRunId: session.taskRunId, - previousStatus: session.cloudStatus, - }); - - const runtimeOptions = this.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. - // The agent will load conversation history and restore the sandbox snapshot. - const updatedTask = await authCredentials.client.runTaskInCloud( - session.taskId, - previousBaseBranch, - { - adapter: runtimeOptions.adapter, - model: runtimeOptions.model, - reasoningLevel: runtimeOptions.reasoningLevel, - resumeFromRunId: session.taskRunId, - pendingUserMessage: transport.messageText, - pendingUserArtifactIds: - artifactIds.length > 0 ? artifactIds : undefined, - prAuthorshipMode, - runSource: this.getCloudRunSource(previousState), - signalReportId: - typeof previousState.signal_report_id === "string" - ? previousState.signal_report_id - : undefined, - }, - ); - const newRun = updatedTask.latest_run; - if (!newRun?.id) { - throw new Error("Failed to create resume run"); - } - - // Replace session with one for the new run, preserving conversation history. - // setSession handles old session cleanup via taskIdIndex. - const newSession = this.createBaseSession( - newRun.id, - session.taskId, - session.taskTitle, - ); - newSession.status = "disconnected"; - newSession.isCloud = true; - // Carry over existing events and add optimistic user bubble for the follow-up. - // Reset processedLineCount to 0 because the new run's log stream starts fresh. - newSession.events = [ - ...session.events, - createUserPromptEvent( - transport.filePaths.length > 0 - ? cloudPromptToBlocks(prompt) - : [{ type: "text", text: transport.promptText }], - Date.now(), - ), - ]; - newSession.processedLineCount = 0; - // 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); - - // No enqueueMessage / isPromptPending needed — the follow-up is passed - // in run state (pending_user_message), NOT via user_message command. - - // Start the watcher immediately so we don't miss status updates. - const initialMode = - typeof newRun.state?.initial_permission_mode === "string" - ? newRun.state.initial_permission_mode - : undefined; - const priorModel = getConfigOptionByCategory( - session.configOptions, - "model", - )?.currentValue; - const initialModel = - newRun.model ?? (typeof priorModel === "string" ? priorModel : undefined); - this.watchCloudTask( - session.taskId, - newRun.id, - auth.apiHost, - auth.teamId, - undefined, - newRun.log_url, - initialMode, - newRun.runtime_adapter ?? session.adapter ?? "claude", - initialModel, - ); - - // Invalidate task queries so the UI picks up the new run metadata - queryClient.invalidateQueries({ queryKey: ["tasks"] }); - - track(ANALYTICS_EVENTS.PROMPT_SENT, { - task_id: session.taskId, - is_initial: false, - execution_type: "cloud", - prompt_length_chars: transport.promptText.length, - }); - - return { stopReason: "queued" }; - } - - private async cancelCloudPrompt(session: AgentSession): Promise { - if (isTerminalStatus(session.cloudStatus)) { - log.info("Skipping cancel for terminal cloud run", { - taskId: session.taskId, - status: session.cloudStatus, - }); - return false; - } - - const auth = await this.getCloudCommandAuth(); - if (!auth) { - log.error("No auth for cloud cancel"); - return false; - } - - try { - const result = await trpcClient.cloudTask.sendCommand.mutate({ - taskId: session.taskId, - runId: session.taskRunId, - apiHost: auth.apiHost, - teamId: auth.teamId, - method: "cancel", - }); - - const durationSeconds = Math.round( - (Date.now() - session.startedAt) / 1000, - ); - const promptCount = session.events.filter( - (e) => "method" in e.message && e.message.method === "session/prompt", - ).length; - track(ANALYTICS_EVENTS.TASK_RUN_CANCELLED, { - task_id: session.taskId, - execution_type: "cloud", - duration_seconds: durationSeconds, - prompts_sent: promptCount, - }); - - if (!result.success) { - log.warn("Cloud cancel command failed", { error: result.error }); - return false; - } - - return true; - } catch (error) { - log.error("Failed to cancel cloud prompt", error); - return false; - } - } - - private async getCloudCommandAuth(): Promise<{ - apiHost: string; - teamId: number; - } | null> { - const authState = await fetchAuthState(); - if (!authState.cloudRegion || !authState.projectId) return null; - return { - apiHost: getCloudUrlFromRegion(authState.cloudRegion), - teamId: authState.projectId, - }; - } - - /** - * Send a command to the cloud agent server via the backend proxy. - * Handles auth lookup and throws if credentials are unavailable. - */ - private async sendCloudCommand( - session: AgentSession, - method: "permission_response" | "set_config_option", - params: Record, - ): Promise { - const auth = await this.getCloudCommandAuth(); - if (!auth) { - throw new Error("No cloud auth credentials available"); - } - await trpcClient.cloudTask.sendCommand.mutate({ - taskId: session.taskId, - runId: session.taskRunId, - apiHost: auth.apiHost, - teamId: auth.teamId, - method, - params, - }); - } - - // --- Permissions --- - - private resolvePermission(session: AgentSession, toolCallId: string): void { - const permission = session.pendingPermissions.get(toolCallId); - const newPermissions = new Map(session.pendingPermissions); - newPermissions.delete(toolCallId); - sessionStoreSetters.setPendingPermissions( - session.taskRunId, - newPermissions, - ); - - if (permission?.receivedAt) { - sessionStoreSetters.updateSession(session.taskRunId, { - pausedDurationMs: - (session.pausedDurationMs ?? 0) + - (Date.now() - permission.receivedAt), - }); - } - } - - /** - * Respond to a permission request. - */ - async respondToPermission( - taskId: string, - toolCallId: string, - optionId: string, - customInput?: string, - answers?: Record, - ): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) { - log.error("No session found for permission response", { taskId }); - return; - } - - const permission = session.pendingPermissions.get(toolCallId); - track(ANALYTICS_EVENTS.PERMISSION_RESPONDED, { - task_id: taskId, - ...buildPermissionToolMetadata(permission, optionId, customInput), - }); - - const cloudRequestId = this.cloudPermissionRequestIds.get(toolCallId); - this.resolvePermission(session, toolCallId); - - try { - if (session.isCloud && cloudRequestId) { - this.cloudPermissionRequestIds.delete(toolCallId); - await this.sendCloudCommand(session, "permission_response", { - requestId: cloudRequestId, - optionId, - customInput, - answers, - }); - } else { - await trpcClient.agent.respondToPermission.mutate({ - taskRunId: session.taskRunId, - toolCallId, - optionId, - customInput, - answers, - }); - } - - log.info("Permission response sent", { - taskId, - toolCallId, - optionId, - isCloud: !!cloudRequestId, - hasCustomInput: !!customInput, - }); - } catch (error) { - log.error("Failed to respond to permission", { - taskId, - toolCallId, - optionId, - error, - }); - } - } - - /** - * Cancel a permission request. - */ - async cancelPermission(taskId: string, toolCallId: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) { - log.error("No session found for permission cancellation", { taskId }); - return; - } - - const permission = session.pendingPermissions.get(toolCallId); - track(ANALYTICS_EVENTS.PERMISSION_CANCELLED, { - task_id: taskId, - ...buildPermissionToolMetadata(permission), - }); - - const cloudRequestId = this.cloudPermissionRequestIds.get(toolCallId); - this.resolvePermission(session, toolCallId); - - try { - if (session.isCloud && cloudRequestId) { - this.cloudPermissionRequestIds.delete(toolCallId); - await this.sendCloudCommand(session, "permission_response", { - requestId: cloudRequestId, - optionId: "reject_with_feedback", - customInput: "User cancelled the permission request.", - }); - } else { - await trpcClient.agent.cancelPermission.mutate({ - taskRunId: session.taskRunId, - toolCallId, - }); - } - - log.info("Permission cancelled", { - taskId, - toolCallId, - isCloud: !!cloudRequestId, - }); - } catch (error) { - log.error("Failed to cancel permission", { - taskId, - toolCallId, - error, - }); - } - } - - // --- Config Option Changes (Optimistic Updates) --- - - /** - * Set a session configuration option with optimistic update and rollback. - * This is the unified method for model, mode, thought level, etc. - */ - async setSessionConfigOption( - taskId: string, - configId: string, - value: string, - ): Promise { - const session = sessionStoreSetters.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 }); - return; - } - - const previousValue = configOptions[optionIndex].currentValue; - - // Skip if value is already set — avoids expensive IPC round-trip (e.g. setModel ~2s) - if (previousValue === value) { - return; - } - - // Optimistic update - const updatedOptions = configOptions.map((opt) => - opt.id === configId - ? ({ ...opt, currentValue: value } as SessionConfigOption) - : opt, - ); - sessionStoreSetters.updateSession(session.taskRunId, { - configOptions: updatedOptions, - }); - updatePersistedConfigOptionValue(session.taskRunId, configId, value); - - if ( - !session.isCloud && - (session.idleKilled || - session.status === "disconnected" || - session.status === "connecting") - ) { - return; - } - - try { - if (session.isCloud) { - await this.sendCloudCommand(session, "set_config_option", { - configId, - value, - }); - } else { - await trpcClient.agent.setConfigOption.mutate({ - sessionId: session.taskRunId, - configId, - value, - }); - } - } catch (error) { - // Rollback on error - const rolledBackOptions = configOptions.map((opt) => - opt.id === configId - ? ({ ...opt, currentValue: previousValue } as SessionConfigOption) - : opt, - ); - sessionStoreSetters.updateSession(session.taskRunId, { - configOptions: rolledBackOptions, - }); - updatePersistedConfigOptionValue( - session.taskRunId, - configId, - String(previousValue), - ); - log.error("Failed to set session config option", { - taskId, - configId, - value, - error, - }); - toast.error("Failed to change setting. Please try again."); - } - } - - /** - * Set a session configuration option by category (e.g., "mode", "model"). - * This is a convenience method that looks up the config ID by category. - */ - async setSessionConfigOptionByCategory( - taskId: string, - category: string, - value: string, - ): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) return; - - const configOption = getConfigOptionByCategory( - session.configOptions, - category, - ); - if (!configOption) { - log.warn("Config option not found for category", { taskId, category }); - return; - } - - if (configOption.currentValue !== value) { - track(ANALYTICS_EVENTS.SESSION_CONFIG_CHANGED, { - task_id: taskId, - category, - from_value: String(configOption.currentValue), - to_value: value, - }); - } - - await this.setSessionConfigOption(taskId, configOption.id, value); - } - - /** - * Start a user shell execute event (shows command as running). - * Call completeUserShellExecute with the same id when the command finishes. - */ - async startUserShellExecute( - taskId: string, - id: string, - command: string, - cwd: string, - ): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) return; - - const event = createUserShellExecuteEvent(command, cwd, undefined, id); - sessionStoreSetters.appendEvents(session.taskRunId, [event]); - } - - /** - * Complete a user shell execute event with results. - * Must be called after startUserShellExecute with the same id. - */ - async completeUserShellExecute( - taskId: string, - id: string, - command: string, - cwd: string, - result: { stdout: string; stderr: string; exitCode: number }, - ): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) return; - - const storedEntry: StoredLogEntry = { - type: "notification", - timestamp: new Date().toISOString(), - notification: { - method: "_array/user_shell_execute", - params: { id, command, cwd, result }, - }, - }; - - const event = createUserShellExecuteEvent(command, cwd, result, id); - - await this.appendAndPersist(taskId, session, event, storedEntry); - } - - /** - * Retry connecting to the existing session (resume attempt using - * the sessionId from logs). Does NOT tear down — avoids the connect - * effect loop. - * - * If the session failed before any conversation started (has an - * initialPrompt saved from the original creation attempt), creates - * a fresh session and re-sends the prompt instead of reconnecting - * to an empty session. - */ - async clearSessionError(taskId: string, repoPath: string): Promise { - this.localRepoPaths.set(taskId, repoPath); - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (session?.initialPrompt?.length) { - const { taskTitle, initialPrompt } = session; - await this.teardownSession(session.taskRunId); - const auth = await this.getAuthCredentials(); - if (!auth) { - throw new Error( - "Unable to reach server. Please check your connection.", - ); - } - await this.createNewLocalSession( - taskId, - taskTitle, - repoPath, - auth, - initialPrompt, - ); - return; - } - await this.reconnectInPlace(taskId, repoPath); - } - - /** - * Start a fresh session for a task, abandoning the old conversation. - * Clears the backend sessionId so the next reconnect creates a new - * session instead of attempting to resume the stale one. - */ - async resetSession(taskId: string, repoPath: string): Promise { - this.localRepoPaths.set(taskId, repoPath); - await this.reconnectInPlace(taskId, repoPath, null); - } - - /** - * Cancel the current backend agent and reconnect under the same taskRunId. - * Does NOT remove the session from the store (avoids connect effect loop). - * Overwrites the store session in place via reconnectToLocalSession. - * - * @param overrideSessionId - Controls which sessionId is used for reconnect: - * - `undefined` (default): use the sessionId from logs (resume attempt) - * - `null`: strip the sessionId so the backend creates a fresh session - * - `string`: use that specific sessionId - */ - private async reconnectInPlace( - taskId: string, - repoPath: string, - overrideSessionId?: string | null, - ): Promise { - this.localRepoPaths.set(taskId, repoPath); - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) return false; - - const { taskRunId, taskTitle, logUrl } = session; - - // Cancel lingering backend agent (ignore errors — it may not exist - // after a failed reconnect) - try { - await trpcClient.agent.cancel.mutate({ sessionId: taskRunId }); - } catch { - // expected when backend has no session - } - this.unsubscribeFromChannel(taskRunId); - - const auth = await this.getAuthCredentials(); - if (!auth) { - throw new Error("Unable to reach server. Please check your connection."); - } - - const prefetchedLogs = await this.fetchSessionLogs(logUrl, taskRunId); - - // Determine sessionId: undefined = use from logs, null = strip (fresh), string = use as-is - const sessionId = - overrideSessionId === null - ? undefined - : (overrideSessionId ?? prefetchedLogs.sessionId); - - return this.reconnectToLocalSession( - taskId, - taskRunId, - taskTitle, - logUrl, - repoPath, - auth, - { ...prefetchedLogs, sessionId }, - ); - } - - /** - * Fetch model/effort options from the main-process preview-config endpoint - * and merge them into the cloud session's configOptions. Cached per - * (apiHost, adapter) so repeated visits don't refetch. - * - * Runs fire-and-forget: the session stays usable with just the `mode` option - * if the fetch fails or is still in flight. - */ - private async fetchAndApplyCloudPreviewOptions( - taskRunId: string, - apiHost: string, - adapter: Adapter, - initialModel?: string, - ): Promise { - const cacheKey = `${apiHost}::${adapter}`; - let pending = this.previewConfigOptionsCache.get(cacheKey); - if (!pending) { - pending = trpcClient.agent.getPreviewConfigOptions - .query({ apiHost, adapter }) - .catch((err: unknown) => { - log.warn("Failed to fetch preview config options for cloud session", { - apiHost, - adapter, - error: err, - }); - this.previewConfigOptionsCache.delete(cacheKey); - return [] as SessionConfigOption[]; - }); - this.previewConfigOptionsCache.set(cacheKey, pending); - } - - const previewOptions = await pending; - const extras = previewOptions - .filter( - (opt) => opt.category === "model" || opt.category === "thought_level", - ) - .map((opt) => { - if ( - opt.category === "model" && - opt.type === "select" && - typeof initialModel === "string" - ) { - const flat = flattenSelectOptions(opt.options); - if (flat.some((o) => o.value === initialModel)) { - return { ...opt, currentValue: initialModel }; - } - } - return opt; - }); - - if (extras.length === 0) return; - - const session = sessionStoreSetters.getSessions()[taskRunId]; - if (!session) return; - - const existingOptions = session.configOptions ?? []; - const existingIds = new Set(existingOptions.map((o) => o.id)); - const newExtras = extras.filter((o) => !existingIds.has(o.id)); - if (newExtras.length === 0) return; - const merged = [...existingOptions, ...newExtras]; - - sessionStoreSetters.updateSession(taskRunId, { configOptions: merged }); - } - - /** - * Start watching a cloud task via main-process CloudTaskService. - * - * The watcher stays alive across navigation. A fresh watcher is created only - * on first visit or when the runId changes (new run started). Terminal - * status triggers full teardown from within handleCloudTaskUpdate via - * stopCloudTaskWatch(). - */ - watchCloudTask( - taskId: string, - runId: string, - apiHost: string, - teamId: number, - onStatusChange?: () => void, - logUrl?: string, - initialMode?: string, - adapter: Adapter = "claude", - initialModel?: string, - taskDescription?: string, - ): () => void { - const taskRunId = runId; - const existingWatcher = this.cloudTaskWatchers.get(taskId); - - // Resuming same run — reuse the existing watcher. - if ( - existingWatcher && - existingWatcher.runId === runId && - existingWatcher.apiHost === apiHost && - existingWatcher.teamId === teamId - ) { - if (onStatusChange) { - existingWatcher.onStatusChange = onStatusChange; - } - // Ensure configOptions is populated on revisit - const existing = sessionStoreSetters.getSessionByTaskId(taskId); - if (existing) { - const existingMode = getConfigOptionByCategory( - existing.configOptions, - "mode", - )?.currentValue; - const currentMode = - typeof existingMode === "string" ? existingMode : initialMode; - const shouldRefreshConfigOptions = - !existing.configOptions?.length || existing.adapter !== adapter; - if (shouldRefreshConfigOptions) { - sessionStoreSetters.updateSession(existing.taskRunId, { - adapter, - configOptions: buildCloudDefaultConfigOptions(currentMode, adapter), - }); - } - void this.fetchAndApplyCloudPreviewOptions( - existing.taskRunId, - apiHost, - adapter, - initialModel, - ); - } - return () => {}; - } - - // Different run — full cleanup of old watcher first - if (existingWatcher) { - this.stopCloudTaskWatch(taskId); - } - - const startToken = ++this.nextCloudTaskWatchToken; - - // Create session in the store - const existing = sessionStoreSetters.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. - const shouldResetExistingSession = - existing?.taskRunId === taskRunId && - existing.events.length > 0 && - existing.processedLineCount === undefined; - const shouldHydrateSession = - !existing || - existing.taskRunId !== taskRunId || - shouldResetExistingSession || - existing.events.length === 0; - - if ( - !existing || - existing.taskRunId !== taskRunId || - shouldResetExistingSession - ) { - const taskTitle = existing?.taskTitle ?? "Cloud Task"; - const session = this.createBaseSession(taskRunId, taskId, taskTitle); - session.status = "disconnected"; - session.isCloud = true; - session.adapter = adapter; - session.configOptions = buildCloudDefaultConfigOptions( - initialMode, - adapter, - ); - sessionStoreSetters.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 - // the description at top until hydration replaced it. - } else { - // Ensure cloud flag and configOptions are set on existing sessions - const updates: Partial = {}; - if (!existing.isCloud) updates.isCloud = true; - if (existing.adapter !== adapter) updates.adapter = adapter; - if (!existing.configOptions?.length || existing.adapter !== adapter) { - const existingMode = getConfigOptionByCategory( - existing.configOptions, - "mode", - )?.currentValue; - const currentMode = - typeof existingMode === "string" ? existingMode : initialMode; - updates.configOptions = buildCloudDefaultConfigOptions( - currentMode, - adapter, - ); - } - if (Object.keys(updates).length > 0) { - sessionStoreSetters.updateSession(existing.taskRunId, updates); - } - } - - void this.fetchAndApplyCloudPreviewOptions( - taskRunId, - apiHost, - adapter, - initialModel, - ); - - if (shouldHydrateSession) { - this.hydrateCloudTaskSessionFromLogs( - taskId, - taskRunId, - logUrl, - taskDescription, - ); - } - - // 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( - { taskId, runId }, - { - onData: (update: CloudTaskUpdatePayload) => { - this.handleCloudTaskUpdate(taskRunId, update); - const watcher = this.cloudTaskWatchers.get(taskId); - if ( - (update.kind === "status" || - update.kind === "snapshot" || - update.kind === "error") && - watcher?.onStatusChange - ) { - watcher.onStatusChange(); - } - }, - onError: (err: unknown) => - log.error("Cloud task subscription error", { taskId, err }), - }, - ); - - this.cloudTaskWatchers.set(taskId, { - runId, - apiHost, - teamId, - startToken, - subscription, - onStatusChange, - }); - - // Start main-process watcher after the subscription is attached. - void (async () => { - try { - if (!this.isCurrentCloudTaskWatcher(taskId, runId, startToken)) { - return; - } - - await trpcClient.cloudTask.watch.mutate({ - taskId, - runId, - apiHost, - teamId, - }); - - // 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 }); - } - } catch (err: unknown) { - if (!this.isCurrentCloudTaskWatcher(taskId, runId, startToken)) { - return; - } - log.warn("Failed to start cloud task watcher", { taskId, err }); - } - })(); - - return () => {}; - } - - private hydrateCloudTaskSessionFromLogs( - taskId: string, - taskRunId: string, - logUrl?: string, - taskDescription?: string, - ): void { - void (async () => { - const { rawEntries, totalLineCount } = await this.fetchSessionLogs( - logUrl, - taskRunId, - ); - - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session || session.taskRunId !== taskRunId) { - return; - } - - const events = convertStoredEntriesToEvents(rawEntries); - const hasUserPrompt = events.some( - (e) => - isJsonRpcRequest(e.message) && e.message.method === "session/prompt", - ); - - // Seed the optimistic user-message bubble whenever the agent has - // not yet recorded an initial `session/prompt` request — covers the - // 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, { - type: "user_message", - content: taskDescription, - timestamp: Date.now(), - }); - } - - if (rawEntries.length === 0) { - return; - } - - // If live updates already populated a processed count, don't overwrite - // that newer state with the persisted baseline fetched during startup. - if ( - session.processedLineCount !== undefined && - session.processedLineCount > 0 - ) { - return; - } - - sessionStoreSetters.updateSession(taskRunId, { - events, - isCloud: true, - logUrl: logUrl ?? session.logUrl, - processedLineCount: totalLineCount, - }); - // Without this the "Galumphing…" indicator stays hidden when the hydrated - // baseline already contains an in-flight session/prompt — the live delta - // 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", { - taskId, - taskRunId, - err, - }); - }); - } - - private isCurrentCloudTaskWatcher( - taskId: string, - runId: string, - startToken: number, - ): boolean { - const watcher = this.cloudTaskWatchers.get(taskId); - return watcher?.runId === runId && watcher.startToken === startToken; - } - - /** - * Fully stop a cloud task watcher. The tRPC subscription unwatches from the - * main process in its finally handler; the in-flight watch path below sends a - * compensating unwatch if teardown wins before watch.mutate lands. - */ - stopCloudTaskWatch(taskId: string): void { - const watcher = this.cloudTaskWatchers.get(taskId); - if (!watcher) return; - - watcher.subscription.unsubscribe(); - this.cloudTaskWatchers.delete(taskId); - this.cloudLogReconcileDeficiency.delete(watcher.runId); - } - - async preflightToLocal(taskId: string, repoPath: string) { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) - return { - canHandoff: false as const, - localTreeDirty: false as const, - reason: "No session found", - }; - - const auth = await this.getHandoffAuth(); - if (!auth) - return { - canHandoff: false as const, - localTreeDirty: false as const, - reason: "Authentication required", - }; - - const preflight = await trpcClient.handoff.preflight.query({ - taskId, - runId: session.taskRunId, - repoPath, - apiHost: auth.apiHost, - teamId: auth.projectId, - }); - - return { - canHandoff: preflight.canHandoff, - localTreeDirty: preflight.localTreeDirty, - localGitState: preflight.localGitState, - changedFiles: preflight.changedFiles, - reason: preflight.reason, - }; - } - - async handoffToLocal(taskId: string, repoPath: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) { - log.warn("No session found for handoff", { taskId }); - return; - } - - const runId = session.taskRunId; - const auth = await this.getHandoffAuth(); - if (!auth) return; - - sessionStoreSetters.updateSession(runId, { handoffInProgress: true }); - - try { - const preflight = await this.runHandoffPreflight( - taskId, - runId, - repoPath, - auth, - ); - this.stopCloudTaskWatch(taskId); - sessionStoreSetters.updateSession(runId, { status: "connecting" }); - await this.executeHandoff( - taskId, - runId, - repoPath, - auth, - preflight.localGitState, - ); - this.transitionToLocalSession(runId); - this.subscribeToChannel(runId); - await Promise.all([ - queryClient.refetchQueries({ queryKey: ["tasks"] }), - queryClient.refetchQueries(trpc.workspace.getAll.pathFilter()), - ]); - sessionStoreSetters.updateSession(runId, { handoffInProgress: false }); - log.info("Cloud-to-local handoff complete", { taskId, runId }); - } catch (err) { - log.error("Handoff failed", { taskId, err }); - toast.error( - err instanceof Error ? err.message : "Handoff to local failed", - ); - this.watchCloudTask(taskId, runId, auth.apiHost, auth.projectId); - sessionStoreSetters.updateSession(runId, { - handoffInProgress: false, - status: "disconnected", - }); - } - } - - async handoffToCloud(taskId: string, repoPath: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) { - log.warn("No session found for cloud handoff", { taskId }); - return; - } - - const runId = session.taskRunId; - const auth = await this.getHandoffAuth(); - if (!auth) return; - - sessionStoreSetters.updateSession(runId, { handoffInProgress: true }); - - try { - const preflight = await trpcClient.handoff.preflightToCloud.query({ - taskId, - runId, - repoPath, - }); - if (!preflight.canHandoff) { - sessionStoreSetters.updateSession(runId, { - handoffInProgress: false, - }); - throw new Error(preflight.reason ?? "Cannot hand off to cloud"); - } - - this.unsubscribeFromChannel(runId); - sessionStoreSetters.updateSession(runId, { status: "connecting" }); - - const result = await trpcClient.handoff.executeToCloud.mutate({ - taskId, - runId, - repoPath, - apiHost: auth.apiHost, - teamId: auth.projectId, - localGitState: preflight.localGitState, - }); - if (!result.success) { - if (result.code === GITHUB_AUTHORIZATION_REQUIRED_CODE) { - throw new GitHubAuthorizationRequiredForCloudHandoffError( - result.error, - ); - } - throw new Error(result.error ?? "Handoff to cloud failed"); - } - - sessionStoreSetters.updateSession(runId, { - isCloud: true, - cloudStatus: undefined, - cloudStage: undefined, - cloudOutput: undefined, - cloudErrorMessage: undefined, - cloudBranch: undefined, - status: "disconnected", - processedLineCount: result.logEntryCount ?? 0, - }); - - this.watchCloudTask(taskId, runId, auth.apiHost, auth.projectId); - await Promise.all([ - queryClient.refetchQueries({ queryKey: ["tasks"] }), - queryClient.refetchQueries(trpc.workspace.getAll.pathFilter()), - ]); - sessionStoreSetters.updateSession(runId, { handoffInProgress: false }); - log.info("Local-to-cloud handoff complete", { taskId, runId }); - } catch (err) { - log.error("Handoff to cloud failed", { taskId, err }); - if (err instanceof GitHubAuthorizationRequiredForCloudHandoffError) { - await this.startGithubReauthForCloudHandoff(auth.projectId); - } else { - toast.error( - err instanceof Error ? err.message : "Handoff to cloud failed", - ); - } - this.subscribeToChannel(runId); - sessionStoreSetters.updateSession(runId, { - handoffInProgress: false, - status: "disconnected", - }); - } - } - - private async startGithubReauthForCloudHandoff( - projectId: number, - ): Promise { - const client = await getAuthenticatedClient(); - if (!client) { - toast.error("Sign in before connecting GitHub."); - return; - } - - try { - const { install_url: installUrl } = - await client.startGithubUserIntegrationConnect(projectId); - const url = installUrl?.trim(); - if (!url) { - toast.error( - "GitHub connection did not return a URL. Please try again.", - ); - return; - } - - await trpcClient.os.openExternal.mutate({ url }); - toast.info( - "Connect GitHub to continue in cloud", - "Complete the authorization in your browser, then click Continue again.", - ); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Failed to start GitHub connection", - ); - } - } - - private async getHandoffAuth(): Promise<{ - apiHost: string; - projectId: number; - } | null> { - let auth: Awaited>; - try { - auth = await fetchAuthState(); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; - toast.error(`Authentication required for handoff: ${message}`); - return null; - } - if (!auth.projectId || !auth.cloudRegion) { - toast.error("Missing project configuration for handoff"); - return null; - } - return { - apiHost: getCloudUrlFromRegion(auth.cloudRegion), - projectId: auth.projectId, - }; - } - - private async runHandoffPreflight( - taskId: string, - runId: string, - repoPath: string, - auth: { apiHost: string; projectId: number }, - ): Promise>> { - const preflight = await trpcClient.handoff.preflight.query({ - taskId, - runId, - repoPath, - apiHost: auth.apiHost, - teamId: auth.projectId, - }); - if (!preflight.canHandoff) { - sessionStoreSetters.updateSession(runId, { - handoffInProgress: false, - }); - throw new Error(preflight.reason ?? "Cannot hand off to local"); - } - return preflight; - } - - private async executeHandoff( - taskId: string, - runId: string, - repoPath: string, - auth: { apiHost: string; projectId: number }, - localGitState?: Awaited< - ReturnType - >["localGitState"], - ): Promise { - const result = await trpcClient.handoff.execute.mutate({ - taskId, - runId, - repoPath, - apiHost: auth.apiHost, - teamId: auth.projectId, - localGitState, - }); - if (!result.success) { - throw new Error(result.error ?? "Handoff failed"); - } - } - - private transitionToLocalSession(runId: string): void { - sessionStoreSetters.updateSession(runId, { - isCloud: false, - cloudStatus: undefined, - cloudStage: undefined, - cloudOutput: undefined, - cloudErrorMessage: undefined, - cloudBranch: undefined, - status: "connected", - }); - } - - async retryCloudTaskWatch(taskId: string): Promise { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session?.isCloud) { - throw new Error("No active cloud session for task"); - } - - const previousErrorTitle = session.errorTitle; - const previousErrorMessage = session.errorMessage; - - sessionStoreSetters.updateSession(session.taskRunId, { - status: "disconnected", - errorTitle: undefined, - errorMessage: undefined, - isPromptPending: false, - }); - - try { - await trpcClient.cloudTask.retry.mutate({ - taskId, - runId: session.taskRunId, - }); - } catch (error) { - sessionStoreSetters.updateSession(session.taskRunId, { - status: "error", - errorTitle: previousErrorTitle, - errorMessage: previousErrorMessage, - }); - throw error; - } - - // The main-process retry of an already-bootstrapped - // watcher only reconnects SSE (`start=latest`) and emits no fresh - // status/snapshot for an idle run, so the update-driven trigger in - // `handleCloudTaskUpdate` would never fire, the queued message would - // stay stuck. Attempt the same guarded recovery here once the reconnect - // request has been accepted. No-ops unless a queue is stranded on an - // idle, provably-alive run. - this.tryRecoverIdleCloudQueue(session.taskRunId); - } - - /** - * Retries every cloud session whose stream is in the `error` state, i.e. the - * main process exhausted its SSE reconnect budget and surfaced the manual - * Retry button. Invoked on window focus so users coming back to the app - * after a Django deploy, laptop sleep, or network blip don't have to click - * Retry themselves. - */ - public retryUnhealthyCloudSessions(): void { - const sessions = sessionStoreSetters.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", { - taskId: session.taskId, - }); - this.retryCloudTaskWatch(session.taskId).catch((error) => { - log.warn("Auto-retry of errored cloud session failed", { - taskId: session.taskId, - error, - }); - }); - } - } - - public updateSessionTaskTitle(taskId: string, taskTitle: string): void { - const session = sessionStoreSetters.getSessionByTaskId(taskId); - if (!session) return; - - if (session.taskTitle === taskTitle) return; - - sessionStoreSetters.updateSession(session.taskRunId, { taskTitle }); - } - - /** - * Drain the cloud queue, the deferral breaks out of - * the synchronous store-update frame so the dispatcher reads committed - * state; `sendQueuedCloudMessages` is reentrancy-guarded so stacked - * schedules from multiple triggers collapse to one. - */ - private scheduleCloudQueueFlush(taskId: string, reason: string): void { - if ( - this.scheduledCloudQueueFlushes.has(taskId) || - this.dispatchingCloudQueues.has(taskId) - ) { - return; - } - - this.scheduledCloudQueueFlushes.add(taskId); - setTimeout(() => { - this.scheduledCloudQueueFlushes.delete(taskId); - this.sendQueuedCloudMessages(taskId).catch((err) => - log.error("cloud queue flush failed", { taskId, reason, error: err }), - ); - }, 0); - } - - /** - * Guarded recovery for a queued cloud message stranded by a transport - * drop on an idle, already-bootstrapped run. - * - * `run_started` is normally the canonical "agent is ready" trigger and - * would race with `sendInitialTaskMessage` while still booting, so the - * safe default remains "drain only once status is connected". But an - * idle run stays `in_progress` on the server while emitting NO fresh - * `run_started`/`turn_complete` (those only fire on boot or a new turn). - * If an SSE transport drop or the `retryCloudTaskWatch` it triggers - * flipped the session to disconnected/error AFTER the agent already - * booted for this exact run, nothing flips it back to "connected" and - * the queued message is stranded forever. When the run is provably - * alive (`cloudStatus === "in_progress"`) and the agent provably idle - * for THIS run (`isAgentIdleForRun`), recover readiness and drain. - */ - private tryRecoverIdleCloudQueue(taskRunId: string): void { - const session = sessionStoreSetters.getSessions()[taskRunId]; - if (!session?.isCloud || session.messageQueue.length === 0) { - return; - } - if (session.cloudStatus !== "in_progress") { - return; - } - if ( - this.scheduledCloudQueueFlushes.has(session.taskId) || - this.dispatchingCloudQueues.has(session.taskId) - ) { - return; - } - - const recoverableAfterTransportDrop = - (session.status === "disconnected" || session.status === "error") && - !session.isPromptPending; - - if (session.status !== "connected" && !recoverableAfterTransportDrop) { - return; - } - - // A local prompt in flight means a queued follow-up would double-send. - // The idle scan below is still the real safety check after reconnect. - if (session.isPromptPending) { - return; - } - - // The agent must be provably idle for this run, the - // connected path included. `status: "connected"` alone is NOT proof of - // idleness: the `_posthog/run_started` handler flips status to - // "connected" before the initial/resume turn even starts, so a - // connected-but-not-idle session is mid-boot. Draining now would race - // with `sendInitialTaskMessage`/`sendResumeMessage` and one prompt - // would be cancelled. Only `_posthog/turn_complete` makes the agent - // idle for the run. - const idleResult = this.cloudRunIdleTracker.evaluateIdle(session); - if (!idleResult.idle) { - return; - } - if (idleResult.shouldCacheToStore) { - sessionStoreSetters.updateSession(taskRunId, { - agentIdleForRunId: taskRunId, - }); - } - - if (recoverableAfterTransportDrop) { - sessionStoreSetters.updateSession(taskRunId, { - status: "connected", - errorTitle: undefined, - errorMessage: undefined, - }); - log.info("Recovered cloud session readiness after transport drop", { - taskId: session.taskId, - previousStatus: session.status, - }); - } - - this.scheduleCloudQueueFlush(session.taskId, "idle-run-recovery"); - } - - private handleCloudTaskUpdate( - taskRunId: string, - update: CloudTaskUpdatePayload, - ): void { - if (update.kind === "error") { - sessionStoreSetters.updateSession(taskRunId, { - status: "error", - errorTitle: update.errorTitle, - errorMessage: - update.errorMessage ?? - "Lost connection to the cloud run. Retry to reconnect.", - isPromptPending: false, - }); - return; - } - - if (update.kind === "permission_request") { - this.handleCloudPermissionRequest(taskRunId, update); - return; - } - - // Append new log entries with dedup guard - if ( - (update.kind === "logs" || update.kind === "snapshot") && - update.newEntries.length > 0 - ) { - // Cloud streams deliver `session/update` notifications as regular log - // entries rather than live ACP messages. Without this, config changes - // made mid-run (e.g. plan-approval switching to bypassPermissions) never - // reach the session store and the footer mode selector stays stale. - const latestConfigOptions = extractLatestConfigOptionsFromEntries( - update.newEntries, - ); - if (latestConfigOptions) { - sessionStoreSetters.updateSession(taskRunId, { - configOptions: latestConfigOptions, - }); - setPersistedConfigOptions(taskRunId, latestConfigOptions); - } - - const session = sessionStoreSetters.getSessions()[taskRunId]; - const currentCount = session?.processedLineCount ?? 0; - const expectedCount = update.totalEntryCount; - const delta = expectedCount - currentCount; - - if (delta <= 0) { - // 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); - let newEvents = convertStoredEntriesToEvents(entriesToAppend); - newEvents = this.filterSkippedPromptEvents( - taskRunId, - session, - newEvents, - ); - if (hasSessionPromptEvent(newEvents)) { - sessionStoreSetters.clearTailOptimisticItems(taskRunId); - } - sessionStoreSetters.appendEvents(taskRunId, newEvents, expectedCount); - this.updatePromptStateFromEvents(taskRunId, newEvents, { - isLive: true, - }); - } else { - this.reconcileCloudLogGap({ - taskId: update.taskId, - taskRunId, - expectedCount, - currentCount, - newEntries: update.newEntries, - logUrl: session?.logUrl, - }); - } - } - - // NOTE: Don't auto-flush on `!isPromptPending && queue.length > 0` here. - // Setup-phase log batches (`_posthog/progress`, `_posthog/console`) stream - // in BEFORE the agent emits its initial `session/prompt` request, so - // `isPromptPending` is still false during those batches — firing the - // dispatcher then races with the agent's initial `clientConnection.prompt`. - // The canonical "agent is idle" signal is `_posthog/turn_complete`, which - // is handled in `updatePromptStateFromEvents`. - - // Update cloud status fields if present - if (update.kind === "status" || update.kind === "snapshot") { - sessionStoreSetters.updateCloudStatus(taskRunId, { - status: update.status, - stage: update.stage, - output: update.output, - errorMessage: update.errorMessage, - branch: update.branch, - }); - - if (update.status === "in_progress") { - this.tryRecoverIdleCloudQueue(taskRunId); - } - - if (isTerminalStatus(update.status)) { - // Clean up any pending resume messages that couldn't be sent - const session = sessionStoreSetters.getSessions()[taskRunId]; - if ( - session && - (session.messageQueue.length > 0 || session.isPromptPending) - ) { - sessionStoreSetters.clearMessageQueue(session.taskId); - sessionStoreSetters.updateSession(taskRunId, { - isPromptPending: false, - }); - } - this.stopCloudTaskWatch(update.taskId); - } - } - } - - 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 - * logs would duplicate the optimistic user bubble we already added. - */ - // Note: `session` is a snapshot from the start of handleCloudTaskUpdate. - // The updateSession call below makes it stale, but this is safe because - // skipPolledPromptCount is only ever 1, so this method runs at most once. - private filterSkippedPromptEvents( - taskRunId: string, - 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", - ); - if (promptIdx !== -1) { - const filtered = [...events]; - filtered.splice(promptIdx, 1); - sessionStoreSetters.updateSession(taskRunId, { - skipPolledPromptCount: (session.skipPolledPromptCount ?? 0) - 1, - }); - return filtered; - } - - return events; - } - - // --- Helper Methods --- - - private async getAuthCredentials(): Promise { - const authState = await fetchAuthState(); - const apiHost = authState.cloudRegion - ? getCloudUrlFromRegion(authState.cloudRegion) - : null; - const projectId = authState.projectId; - const client = 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, - }; - } - - private async fetchSessionLogs( - logUrl: string | undefined, - taskRunId?: string, - options: { minEntryCount?: number } = {}, - ): Promise { - const empty: ParsedSessionLogs = { - rawEntries: [], - totalLineCount: 0, - parseFailureCount: 0, - }; - if (!logUrl && !taskRunId) return empty; - let localResult: ParsedSessionLogs | undefined; - - if (taskRunId) { - try { - const localContent = await trpcClient.logs.readLocalLogs.query({ - taskRunId, - }); - if (localContent?.trim()) { - localResult = this.parseLogContent(localContent); - if ( - !options.minEntryCount || - localResult.totalLineCount >= options.minEntryCount - ) { - return localResult; - } - } - } catch { - log.warn("Failed to read local logs, falling back to S3", { - taskRunId, - }); - } - } - - if (!logUrl) return localResult ?? empty; - - try { - const content = await trpcClient.logs.fetchS3Logs.query({ logUrl }); - if (!content?.trim()) return localResult ?? empty; - - const result = this.parseLogContent(content); - - if (taskRunId && result.rawEntries.length > 0) { - trpcClient.logs.writeLocalLogs - .mutate({ taskRunId, content }) - .catch((err) => { - log.warn("Failed to cache S3 logs locally", { taskRunId, err }); - }); - } - - if ( - localResult && - localResult.rawEntries.length > result.rawEntries.length - ) { - return localResult; - } - - return result; - } catch { - return localResult ?? empty; - } - } - - 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; - } - - this.cloudLogReconcileDeficiency.set(taskRunId, { - expectedCount, - observedLineCount: totalLineCount, - }); - log.warn("Cloud task log count inconsistency", { - taskRunId, - currentCount, - expectedCount, - fetchedCount: rawEntries.length, - parseFailureCount, - entriesReceived: newEntries.length, - }); - } - - 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: [], - }; - } - - private getSessionByRunId(taskRunId: string): AgentSession | undefined { - const sessions = sessionStoreSetters.getSessions(); - return sessions[taskRunId]; - } - - private async appendAndPersist( - taskId: string, - session: AgentSession, - event: AcpMessage, - storedEntry: StoredLogEntry, - ): Promise { - // Don't update processedLineCount - it tracks S3 log lines, not local events - sessionStoreSetters.appendEvents(session.taskRunId, [event]); - - const client = await getAuthenticatedClient(); - if (client) { - try { - await client.appendTaskRunLog(taskId, session.taskRunId, [storedEntry]); - } catch (error) { - log.warn("Failed to persist event to logs", { error }); - } - } - } -} diff --git a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts deleted file mode 100644 index 718206228b..0000000000 --- a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts +++ /dev/null @@ -1,507 +0,0 @@ -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 { 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; - /** 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 | 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 interface SessionState { - /** Sessions indexed by taskRunId */ - sessions: Record; - /** Index mapping taskId -> taskRunId for O(1) lookups */ - taskIdIndex: Record; -} - -// --- Store --- - -export const useSessionStore = create()( - immer(() => ({ - sessions: {}, - taskIdIndex: {}, - })), -); - -// --- Re-exports --- - -export type { PermissionRequest, ExecutionMode, SessionConfigOption }; -export { - getAvailableCommandsForTask, - getPendingPermissionsForTask, - getUserPromptsForTask, - useAdapterForTask, - useAvailableCommandsForTask, - useConfigOptionForTask, - useModeConfigOptionForTask, - useModelConfigOptionForTask, - useOptimisticItemsForTask, - usePendingPermissionsForTask, - useQueuedMessagesForTask, - useSessionForTask, - useSessions, - useThoughtLevelConfigOptionForTask, -} from "../hooks/useSession"; - -// --- Setters --- - -export const sessionStoreSetters = { - setSession: (session: AgentSession) => { - useSessionStore.setState((state) => { - // Clean up old session if taskId already has a different taskRunId - const existingTaskRunId = state.taskIdIndex[session.taskId]; - if (existingTaskRunId && existingTaskRunId !== session.taskRunId) { - delete state.sessions[existingTaskRunId]; - } - - state.sessions[session.taskRunId] = session; - state.taskIdIndex[session.taskId] = session.taskRunId; - }); - }, - - removeSession: (taskRunId: string) => { - useSessionStore.setState((state) => { - const session = state.sessions[taskRunId]; - if (session) { - delete state.taskIdIndex[session.taskId]; - } - delete state.sessions[taskRunId]; - }); - }, - - updateSession: (taskRunId: string, updates: Partial) => { - useSessionStore.setState((state) => { - if (state.sessions[taskRunId]) { - Object.assign(state.sessions[taskRunId], updates); - } - }); - }, - - appendEvents: ( - taskRunId: string, - events: AcpMessage[], - newLineCount?: number, - ) => { - useSessionStore.setState((state) => { - const session = state.sessions[taskRunId]; - if (session) { - session.events.push(...events); - if (newLineCount !== undefined) { - session.processedLineCount = newLineCount; - } - } - }); - }, - - updateCloudStatus: ( - taskRunId: string, - fields: { - status?: TaskRunStatus; - stage?: string | null; - output?: Record | null; - errorMessage?: string | null; - branch?: string | null; - }, - ) => { - useSessionStore.setState((state) => { - const session = state.sessions[taskRunId]; - if (!session) return; - if (fields.status !== undefined) session.cloudStatus = fields.status; - if (fields.stage !== undefined) session.cloudStage = fields.stage; - if (fields.output !== undefined) session.cloudOutput = fields.output; - if (fields.errorMessage !== undefined) - session.cloudErrorMessage = fields.errorMessage; - if (fields.branch !== undefined) session.cloudBranch = fields.branch; - }); - }, - - setPendingPermissions: ( - taskRunId: string, - permissions: Map, - ) => { - useSessionStore.setState((state) => { - if (state.sessions[taskRunId]) { - state.sessions[taskRunId].pendingPermissions = permissions; - } - }); - }, - - enqueueMessage: ( - taskId: string, - content: string, - rawPrompt?: string | ContentBlock[], - ) => { - const id = `queue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - useSessionStore.setState((state) => { - const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) return; - - const session = state.sessions[taskRunId]; - if (session) { - session.messageQueue.push({ - id, - content, - rawPrompt, - queuedAt: Date.now(), - }); - } - }); - }, - - removeQueuedMessage: (taskId: string, messageId: string) => { - useSessionStore.setState((state) => { - const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) return; - const session = state.sessions[taskRunId]; - if (session) { - session.messageQueue = session.messageQueue.filter( - (msg) => msg.id !== messageId, - ); - } - }); - }, - - clearMessageQueue: (taskId: string) => { - useSessionStore.setState((state) => { - const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) return; - - const session = state.sessions[taskRunId]; - if (session) { - session.messageQueue = []; - } - }); - }, - - dequeueMessagesAsText: (taskId: string): string | null => { - // Read the queue from the frozen committed state BEFORE entering the - // immer draft — same rationale as `dequeueMessages`: anything captured - // through a draft proxy can be revoked when setState exits. - const state = useSessionStore.getState(); - const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) return null; - const session = state.sessions[taskRunId]; - if (!session || session.messageQueue.length === 0) return null; - - const combined = session.messageQueue - .map((msg) => msg.content) - .join("\n\n"); - useSessionStore.setState((draft) => { - const trid = draft.taskIdIndex[taskId]; - if (!trid) return; - const draftSession = draft.sessions[trid]; - if (draftSession) draftSession.messageQueue = []; - }); - return combined; - }, - - dequeueMessages: (taskId: string): QueuedMessage[] => { - // Read the queue from the frozen committed state BEFORE entering the - // immer draft, otherwise the items returned are proxies that get - // revoked when setState exits and any later access throws - // "Cannot perform 'get' on a proxy that has been revoked". - const state = useSessionStore.getState(); - const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) return []; - const session = state.sessions[taskRunId]; - if (!session || session.messageQueue.length === 0) return []; - - const queuedMessages = [...session.messageQueue]; - - useSessionStore.setState((draft) => { - const trid = draft.taskIdIndex[taskId]; - if (!trid) return; - const draftSession = draft.sessions[trid]; - if (draftSession) { - draftSession.messageQueue = []; - } - }); - - return queuedMessages; - }, - - /** - * Splice messages back at the head of the queue. Used to roll back a - * dispatch attempt that drained the queue but failed before delivery. - */ - prependQueuedMessages: (taskId: string, messages: QueuedMessage[]) => { - if (messages.length === 0) return; - useSessionStore.setState((state) => { - const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) return; - const session = state.sessions[taskRunId]; - if (!session) return; - session.messageQueue = [...messages, ...session.messageQueue]; - }); - }, - - appendOptimisticItem: ( - taskRunId: string, - item: OptimisticItem extends infer T - ? T extends { id: string } - ? Omit - : never - : never, - ): void => { - const id = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - useSessionStore.setState((state) => { - const session = state.sessions[taskRunId]; - if (session) { - session.optimisticItems.push({ ...item, id } as OptimisticItem); - } - }); - }, - - clearOptimisticItems: (taskRunId: string): void => { - useSessionStore.setState((state) => { - const session = state.sessions[taskRunId]; - if (session) { - session.optimisticItems = []; - } - }); - }, - - clearTailOptimisticItems: (taskRunId: string): void => { - useSessionStore.setState((state) => { - const session = state.sessions[taskRunId]; - if (session) { - session.optimisticItems = session.optimisticItems.filter( - (item) => item.type !== "user_message" || item.pinToTop !== false, - ); - } - }); - }, - - replaceOptimisticWithEvent: (taskRunId: string, event: AcpMessage): void => { - useSessionStore.setState((state) => { - const session = state.sessions[taskRunId]; - if (session) { - session.events.push(event); - session.optimisticItems = []; - } - }); - }, - - /** O(1) lookup using taskIdIndex */ - getSessionByTaskId: (taskId: string): AgentSession | undefined => { - const state = useSessionStore.getState(); - const taskRunId = state.taskIdIndex[taskId]; - if (!taskRunId) return undefined; - return state.sessions[taskRunId]; - }, - - getSessions: (): Record => { - return useSessionStore.getState().sessions; - }, - - clearAll: () => { - useSessionStore.setState((state) => { - state.sessions = {}; - state.taskIdIndex = {}; - }); - }, -}; 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/sessions/utils/sendPromptToAgent.ts b/apps/code/src/renderer/features/sessions/utils/sendPromptToAgent.ts deleted file mode 100644 index a3cd2a3d59..0000000000 --- a/apps/code/src/renderer/features/sessions/utils/sendPromptToAgent.ts +++ /dev/null @@ -1,31 +0,0 @@ -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"; - -/** - * Sends a prompt to the agent session for a task, collapses the review - * panel to split mode if expanded, and switches to the logs/chat tab. - */ -export function sendPromptToAgent( - taskId: string, - prompt: string | ContentBlock[], -): void { - getSessionService().sendPrompt(taskId, prompt); - - const { getReviewMode, setReviewMode } = useReviewNavigationStore.getState(); - if (getReviewMode(taskId) === "expanded") { - setReviewMode(taskId, "split"); - } - - const { taskLayouts, setActiveTab } = usePanelLayoutStore.getState(); - const layout = taskLayouts[taskId]; - if (layout) { - const result = findTabInTree(layout.panelTree, DEFAULT_TAB_IDS.LOGS); - if (result) { - setActiveTab(taskId, result.panelId, DEFAULT_TAB_IDS.LOGS); - } - } -} diff --git a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx deleted file mode 100644 index 72dd67866e..0000000000 --- a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx +++ /dev/null @@ -1,67 +0,0 @@ -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 { Button, Flex, Switch } from "@radix-ui/themes"; -import { clearApplicationStorage } from "@utils/clearStorage"; - -export function AdvancedSettings() { - const showDebugLogsToggle = - useFeatureFlag("posthog-code-background-agent-logs") || import.meta.env.DEV; - const debugLogsCloudRuns = useSettingsStore((s) => s.debugLogsCloudRuns); - const setDebugLogsCloudRuns = useSettingsStore( - (s) => s.setDebugLogsCloudRuns, - ); - - return ( - - - - - - - - {showDebugLogsToggle && ( - - - - )} - - ); -} 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/settings/components/sections/WorkspacesSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/WorkspacesSettings.tsx deleted file mode 100644 index 924a13782a..0000000000 --- a/apps/code/src/renderer/features/settings/components/sections/WorkspacesSettings.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; -import { SettingRow } from "@features/settings/components/SettingRow"; -import { Folder, X } from "@phosphor-icons/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"; - -const log = logger.scope("workspaces-settings"); - -export function WorkspacesSettings() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const [localWorktreeLocation, setLocalWorktreeLocation] = - useState(""); - - const { data: worktreeLocation } = useQuery( - trpc.secureStore.getItem.queryOptions( - { key: "worktreeLocation" }, - { select: (result) => result ?? null }, - ), - ); - - useEffect(() => { - if (worktreeLocation) { - setLocalWorktreeLocation(worktreeLocation); - } - }, [worktreeLocation]); - - const handleWorktreeLocationChange = async (newLocation: string) => { - setLocalWorktreeLocation(newLocation); - try { - await trpcClient.secureStore.setItem.query({ - key: "worktreeLocation", - value: newLocation, - }); - } catch (error) { - log.error("Failed to set worktree location:", error); - } - }; - - const defaultsQuery = useQuery( - trpc.additionalDirectories.listDefaults.queryOptions(), - ); - const defaults = defaultsQuery.data ?? []; - - const invalidateDefaults = () => - queryClient.invalidateQueries( - trpc.additionalDirectories.listDefaults.pathFilter(), - ); - - const addMutation = useMutation( - trpc.additionalDirectories.addDefault.mutationOptions({ - onSuccess: invalidateDefaults, - }), - ); - const removeMutation = useMutation( - trpc.additionalDirectories.removeDefault.mutationOptions({ - onSuccess: invalidateDefaults, - }), - ); - - const handleAddDefaultDirectory = async () => { - try { - const path = await trpcClient.os.selectDirectory.query(); - if (path) { - await addMutation.mutateAsync({ path }); - } - } catch (err) { - log.error("Failed to add default directory", err); - toast.error("Failed to open folder picker"); - } - }; - - return ( -
- -
- -
-
-
-

Default folders for new chats

-

- Folders the agent can access in every new chat on your device. -

-
- {defaults.length === 0 && ( -

No default folders.

- )} - {defaults.map((path) => ( -
- - - {path} - - -
- ))} -
- -
-
-
-
- ); -} diff --git a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreesSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreesSettings.tsx deleted file mode 100644 index 6c1052fb10..0000000000 --- a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreesSettings.tsx +++ /dev/null @@ -1,235 +0,0 @@ -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 { 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 { useCallback, useMemo, useState } from "react"; -import type { WorktreeGroup } from "./WorktreeGroupSection"; -import { WorktreeGroupSection } from "./WorktreeGroupSection"; - -const log = logger.scope("worktrees-settings"); - -export function WorktreesSettings() { - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const { settings, updateSettings } = useSuspensionSettings(); - const deleteWorkspaceMutation = useMutation( - trpc.workspace.delete.mutationOptions(), - ); - const { mutateAsync: deleteTask } = useDeleteTask(); - const [deletingWorktrees, setDeletingWorktrees] = useState>( - new Set(), - ); - - const { folders } = useFolders(); - const { data: tasks } = useTasks(); - - const worktreeQueries = useQueries({ - queries: folders.map((folder) => - trpc.workspace.listGitWorktrees.queryOptions( - { 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 taskMap = useMemo(() => { - const map = new Map(); - if (tasks) { - for (const task of tasks) { - map.set(task.id, task); - } - } - return map; - }, [tasks]); - - const handleDeleteWorktree = useCallback( - async ( - worktreePath: string, - allTaskIds: string[], - 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, - }), - ), - ]); - } catch (error) { - log.error("Failed to delete worktree:", error); - } finally { - setDeletingWorktrees((prev) => { - const next = new Set(prev); - next.delete(worktreePath); - return next; - }); - } - }, - [deleteWorkspaceMutation, deleteTask, queryClient, trpc], - ); - - const commitNumericField = useCallback( - ( - e: - | React.FocusEvent - | React.KeyboardEvent, - field: "maxActiveWorktrees" | "autoSuspendAfterDays", - fallback: number, - ) => { - const input = e.currentTarget; - const val = Number.parseInt(input.value, 10); - const labels: Record = { - maxActiveWorktrees: "Max active worktrees", - autoSuspendAfterDays: "Auto-suspend days", - }; - if (val >= 1) { - updateSettings({ [field]: val }); - toast.success(`${labels[field]} updated to ${val}`); - } else { - input.value = String(settings?.[field] ?? fallback); - } - }, - [settings, updateSettings], - ); - - const isLoading = worktreeQueries.some((q) => q.isLoading); - - return ( - - - - - updateSettings({ autoSuspendEnabled: checked }) - } - size="1" - /> - - - commitNumericField(e, "maxActiveWorktrees", 5)} - onKeyDown={(e) => { - if (e.key === "Enter") - commitNumericField(e, "maxActiveWorktrees", 5); - }} - className="w-[64px]" - /> - - - commitNumericField(e, "autoSuspendAfterDays", 7)} - onKeyDown={(e) => { - if (e.key === "Enter") - commitNumericField(e, "autoSuspendAfterDays", 7); - }} - className="w-[64px]" - /> - - - - {isLoading ? ( - - Loading worktrees... - - ) : worktreeGroups.length === 0 ? ( - - Tasks that are run in a worktree will show up here. - - ) : ( - worktreeGroups.map((group) => ( - - )) - )} - - ); -} diff --git a/apps/code/src/renderer/features/settings/stores/settingsStore.test.ts b/apps/code/src/renderer/features/settings/stores/settingsStore.test.ts deleted file mode 100644 index 3ccaa293f1..0000000000 --- a/apps/code/src/renderer/features/settings/stores/settingsStore.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -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: removeItem }, - }, - }, -})); - -import { useSettingsStore } from "./settingsStore"; - -describe("feature settingsStore cloud selections", () => { - beforeEach(() => { - getItem.mockReset(); - setItem.mockReset(); - removeItem.mockReset(); - getItem.mockResolvedValue(null); - setItem.mockResolvedValue(undefined); - removeItem.mockResolvedValue(undefined); - - useSettingsStore.setState({ - allowBypassPermissions: false, - lastUsedCloudRepository: null, - }); - }); - - it("persists the last used cloud repository", async () => { - useSettingsStore.getState().setLastUsedCloudRepository("posthog/posthog"); - - await vi.waitFor(() => { - expect(setItem).toHaveBeenCalled(); - }); - - const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; - const persisted = JSON.parse(lastCall[0].value); - - expect(persisted.state.lastUsedCloudRepository).toBe("posthog/posthog"); - }); - - it("rehydrates the last used cloud repository", async () => { - getItem.mockResolvedValue( - JSON.stringify({ - state: { - lastUsedCloudRepository: "posthog/posthog", - }, - version: 0, - }), - ); - - useSettingsStore.setState({ - lastUsedCloudRepository: null, - }); - - await useSettingsStore.persist.rehydrate(); - - expect(useSettingsStore.getState().lastUsedCloudRepository).toBe( - "posthog/posthog", - ); - }); - - it("rehydrates the unsafe mode toggle", async () => { - getItem.mockResolvedValue( - JSON.stringify({ - state: { - allowBypassPermissions: true, - }, - version: 0, - }), - ); - - await useSettingsStore.persist.rehydrate(); - - expect(useSettingsStore.getState().allowBypassPermissions).toBe(true); - }); -}); - -describe("feature settingsStore terminal font", () => { - beforeEach(() => { - getItem.mockReset(); - setItem.mockReset(); - removeItem.mockReset(); - getItem.mockResolvedValue(null); - setItem.mockResolvedValue(undefined); - removeItem.mockResolvedValue(undefined); - - useSettingsStore.setState({ - terminalFont: "berkeley-mono", - terminalCustomFontFamily: "", - }); - }); - - it("defaults to berkeley-mono with no custom override", () => { - expect(useSettingsStore.getState().terminalFont).toBe("berkeley-mono"); - expect(useSettingsStore.getState().terminalCustomFontFamily).toBe(""); - }); - - it("persists terminal font selection and custom family", async () => { - useSettingsStore.getState().setTerminalFont("custom"); - useSettingsStore.getState().setTerminalCustomFontFamily("Fira Code"); - - await vi.waitFor(() => { - expect(setItem).toHaveBeenCalled(); - }); - - const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; - const persisted = JSON.parse(lastCall[0].value); - - expect(persisted.state.terminalFont).toBe("custom"); - expect(persisted.state.terminalCustomFontFamily).toBe("Fira Code"); - }); - - it("rehydrates terminal font selection and custom family", async () => { - getItem.mockResolvedValue( - JSON.stringify({ - state: { - terminalFont: "jetbrains-mono", - terminalCustomFontFamily: "Cascadia Code", - }, - version: 0, - }), - ); - - await useSettingsStore.persist.rehydrate(); - - expect(useSettingsStore.getState().terminalFont).toBe("jetbrains-mono"); - expect(useSettingsStore.getState().terminalCustomFontFamily).toBe( - "Cascadia Code", - ); - }); -}); diff --git a/apps/code/src/renderer/features/settings/stores/settingsStore.ts b/apps/code/src/renderer/features/settings/stores/settingsStore.ts deleted file mode 100644 index 69d626fcc1..0000000000 --- a/apps/code/src/renderer/features/settings/stores/settingsStore.ts +++ /dev/null @@ -1,329 +0,0 @@ -import type { WorkspaceMode } from "@main/services/workspace/schemas"; -import type { ExecutionMode } from "@shared/types"; -import { electronStorage } from "@utils/electronStorage"; -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -// ---------- Types ---------- - -export type DefaultRunMode = "local" | "cloud" | "last_used"; -export type LocalWorkspaceMode = "worktree" | "local"; -export type AgentAdapter = "claude" | "codex"; -export type DefaultInitialTaskMode = "plan" | "last_used"; -export type DefaultReasoningEffort = - | "low" - | "medium" - | "high" - | "xhigh" - | "max" - | "last_used"; - -export type SendMessagesWith = "enter" | "cmd+enter"; -export type AutoConvertLongText = "off" | "1000" | "2500" | "5000" | "10000"; -export type DiffOpenMode = "auto" | "split" | "same-pane" | "last-active-pane"; - -export type CompletionSound = - | "none" - | "guitar" - | "danilo" - | "revi" - | "meep" - | "meep-smol" - | "bubbles" - | "drop" - | "knock" - | "ring" - | "shoot" - | "slide" - | "switch" - | "wilhelm"; - -export type TerminalFont = - | "berkeley-mono" - | "jetbrains-mono" - | "system" - | "custom"; - -export interface HintState { - count: number; - learned: boolean; -} - -// ---------- Store shape ---------- - -interface SettingsStore { - // Run mode + last-used flow defaults - defaultRunMode: DefaultRunMode; - lastUsedRunMode: "local" | "cloud"; - lastUsedLocalWorkspaceMode: LocalWorkspaceMode; - lastUsedWorkspaceMode: WorkspaceMode; - lastUsedAdapter: AgentAdapter; - lastUsedModel: string | null; - lastUsedReasoningEffort: string | null; - lastUsedCloudRepository: string | null; - lastUsedEnvironments: Record; - defaultInitialTaskMode: DefaultInitialTaskMode; - lastUsedInitialTaskMode: ExecutionMode; - defaultReasoningEffort: DefaultReasoningEffort; - setDefaultRunMode: (mode: DefaultRunMode) => void; - setLastUsedRunMode: (mode: "local" | "cloud") => void; - setLastUsedLocalWorkspaceMode: (mode: LocalWorkspaceMode) => void; - setLastUsedWorkspaceMode: (mode: WorkspaceMode) => void; - setLastUsedAdapter: (adapter: AgentAdapter) => void; - setLastUsedModel: (model: string) => void; - setLastUsedReasoningEffort: (effort: string) => void; - setLastUsedCloudRepository: (repo: string | null) => void; - setLastUsedEnvironment: ( - repoPath: string, - environmentId: string | null, - ) => void; - getLastUsedEnvironment: (repoPath: string) => string | null; - setDefaultInitialTaskMode: (mode: DefaultInitialTaskMode) => void; - setLastUsedInitialTaskMode: (mode: ExecutionMode) => void; - setDefaultReasoningEffort: (effort: DefaultReasoningEffort) => void; - - // Notifications - desktopNotifications: boolean; - dockBadgeNotifications: boolean; - dockBounceNotifications: boolean; - completionSound: CompletionSound; - completionVolume: number; - setDesktopNotifications: (enabled: boolean) => void; - setDockBadgeNotifications: (enabled: boolean) => void; - setDockBounceNotifications: (enabled: boolean) => void; - setCompletionSound: (sound: CompletionSound) => void; - setCompletionVolume: (volume: number) => void; - - // Composer / chat - autoConvertLongText: AutoConvertLongText; - sendMessagesWith: SendMessagesWith; - customInstructions: string; - setAutoConvertLongText: (value: AutoConvertLongText) => void; - setSendMessagesWith: (mode: SendMessagesWith) => void; - setCustomInstructions: (instructions: string) => void; - - // Diff viewer - diffOpenMode: DiffOpenMode; - setDiffOpenMode: (mode: DiffOpenMode) => void; - - // System / power / permissions - allowBypassPermissions: boolean; - preventSleepWhileRunning: boolean; - debugLogsCloudRuns: boolean; - setAllowBypassPermissions: (enabled: boolean) => void; - setPreventSleepWhileRunning: (enabled: boolean) => void; - setDebugLogsCloudRuns: (enabled: boolean) => void; - - // Terminal - terminalFont: TerminalFont; - terminalCustomFontFamily: string; - setTerminalFont: (font: TerminalFont) => void; - setTerminalCustomFontFamily: (value: string) => void; - - // Experimental / misc - hedgehogMode: boolean; - mcpAppsDisabledServers: string[]; - setHedgehogMode: (enabled: boolean) => void; - setMcpAppsDisabledServers: (servers: string[]) => void; - - // Onboarding hints - hints: Record; - shouldShowHint: (key: string, max?: number) => boolean; - recordHintShown: (key: string) => void; - markHintLearned: (key: string) => void; -} - -// ---------- Store ---------- - -export const useSettingsStore = create()( - persist( - (set, get) => ({ - // Run mode + last-used flow defaults - defaultRunMode: "last_used", - lastUsedRunMode: "local", - lastUsedLocalWorkspaceMode: "local", - lastUsedWorkspaceMode: "local", - lastUsedAdapter: "claude", - lastUsedModel: null, - lastUsedReasoningEffort: null, - lastUsedCloudRepository: null, - lastUsedEnvironments: {}, - defaultInitialTaskMode: "plan", - lastUsedInitialTaskMode: "plan", - defaultReasoningEffort: "last_used", - setDefaultRunMode: (mode) => set({ defaultRunMode: mode }), - setLastUsedRunMode: (mode) => set({ lastUsedRunMode: mode }), - setLastUsedLocalWorkspaceMode: (mode) => - set({ lastUsedLocalWorkspaceMode: mode }), - setLastUsedWorkspaceMode: (mode) => set({ lastUsedWorkspaceMode: mode }), - setLastUsedAdapter: (adapter) => set({ lastUsedAdapter: adapter }), - setLastUsedModel: (model) => set({ lastUsedModel: model }), - setLastUsedReasoningEffort: (effort) => - set({ lastUsedReasoningEffort: effort }), - setLastUsedCloudRepository: (repo) => - set({ lastUsedCloudRepository: repo }), - setLastUsedEnvironment: (repoPath, environmentId) => - set((state) => { - const next = { ...state.lastUsedEnvironments }; - if (environmentId) { - next[repoPath] = environmentId; - } else { - delete next[repoPath]; - } - return { lastUsedEnvironments: next }; - }), - getLastUsedEnvironment: (repoPath) => - get().lastUsedEnvironments[repoPath] ?? null, - setDefaultInitialTaskMode: (mode) => - set({ defaultInitialTaskMode: mode }), - setLastUsedInitialTaskMode: (mode) => - set({ lastUsedInitialTaskMode: mode }), - setDefaultReasoningEffort: (effort) => - set({ defaultReasoningEffort: effort }), - - // Notifications - desktopNotifications: true, - dockBadgeNotifications: true, - dockBounceNotifications: false, - completionSound: "none", - completionVolume: 80, - setDesktopNotifications: (enabled) => - set({ desktopNotifications: enabled }), - setDockBadgeNotifications: (enabled) => - set({ dockBadgeNotifications: enabled }), - setDockBounceNotifications: (enabled) => - set({ dockBounceNotifications: enabled }), - setCompletionSound: (sound) => set({ completionSound: sound }), - setCompletionVolume: (volume) => set({ completionVolume: volume }), - - // Composer / chat - autoConvertLongText: "2500", - sendMessagesWith: "enter", - customInstructions: "", - setAutoConvertLongText: (value) => set({ autoConvertLongText: value }), - setSendMessagesWith: (mode) => set({ sendMessagesWith: mode }), - setCustomInstructions: (instructions) => - set({ customInstructions: instructions }), - - // Diff viewer - diffOpenMode: "auto", - setDiffOpenMode: (mode) => set({ diffOpenMode: mode }), - - // System / power / permissions - allowBypassPermissions: false, - preventSleepWhileRunning: false, - debugLogsCloudRuns: false, - setAllowBypassPermissions: (enabled) => - set({ allowBypassPermissions: enabled }), - setPreventSleepWhileRunning: (enabled) => - set({ preventSleepWhileRunning: enabled }), - setDebugLogsCloudRuns: (enabled) => set({ debugLogsCloudRuns: enabled }), - - // Terminal - terminalFont: "berkeley-mono", - terminalCustomFontFamily: "", - setTerminalFont: (font) => set({ terminalFont: font }), - setTerminalCustomFontFamily: (value) => - set({ terminalCustomFontFamily: value }), - - // Experimental / misc - hedgehogMode: false, - mcpAppsDisabledServers: [], - setHedgehogMode: (enabled) => set({ hedgehogMode: enabled }), - setMcpAppsDisabledServers: (servers) => - set({ mcpAppsDisabledServers: servers }), - - // Onboarding hints - hints: {}, - shouldShowHint: (key, max = 3) => { - const hint = get().hints[key]; - if (!hint) return true; - return !hint.learned && hint.count < max; - }, - recordHintShown: (key) => - set((state) => { - const current = state.hints[key] ?? { count: 0, learned: false }; - return { - hints: { - ...state.hints, - [key]: { ...current, count: current.count + 1 }, - }, - }; - }), - markHintLearned: (key) => - set((state) => { - const current = state.hints[key] ?? { count: 0, learned: false }; - return { - hints: { - ...state.hints, - [key]: { ...current, learned: true }, - }, - }; - }), - }), - { - name: "settings-storage", - storage: electronStorage, - partialize: (state) => ({ - // Run mode + last-used flow defaults - defaultRunMode: state.defaultRunMode, - lastUsedRunMode: state.lastUsedRunMode, - lastUsedLocalWorkspaceMode: state.lastUsedLocalWorkspaceMode, - lastUsedWorkspaceMode: state.lastUsedWorkspaceMode, - lastUsedAdapter: state.lastUsedAdapter, - lastUsedModel: state.lastUsedModel, - lastUsedReasoningEffort: state.lastUsedReasoningEffort, - lastUsedCloudRepository: state.lastUsedCloudRepository, - lastUsedEnvironments: state.lastUsedEnvironments, - defaultInitialTaskMode: state.defaultInitialTaskMode, - lastUsedInitialTaskMode: state.lastUsedInitialTaskMode, - defaultReasoningEffort: state.defaultReasoningEffort, - - // Notifications - desktopNotifications: state.desktopNotifications, - dockBadgeNotifications: state.dockBadgeNotifications, - dockBounceNotifications: state.dockBounceNotifications, - completionSound: state.completionSound, - completionVolume: state.completionVolume, - - // Composer / chat - autoConvertLongText: state.autoConvertLongText, - sendMessagesWith: state.sendMessagesWith, - customInstructions: state.customInstructions, - - // Diff viewer - diffOpenMode: state.diffOpenMode, - - // System / power / permissions - allowBypassPermissions: state.allowBypassPermissions, - preventSleepWhileRunning: state.preventSleepWhileRunning, - debugLogsCloudRuns: state.debugLogsCloudRuns, - - // Terminal - terminalFont: state.terminalFont, - terminalCustomFontFamily: state.terminalCustomFontFamily, - - // Experimental / misc - hedgehogMode: state.hedgehogMode, - mcpAppsDisabledServers: state.mcpAppsDisabledServers, - - // Onboarding hints - hints: state.hints, - }), - merge: (persisted, current) => { - const merged = { - ...current, - ...(persisted as Partial), - }; - if (typeof merged.autoConvertLongText === "boolean") { - (merged as Record).autoConvertLongText = - merged.autoConvertLongText ? "1000" : "off"; - } - if ((merged.autoConvertLongText as string) === "500") { - (merged as Record).autoConvertLongText = "1000"; - } - return merged; - }, - }, - ), -); 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/prompts.ts b/apps/code/src/renderer/features/setup/prompts.ts deleted file mode 100644 index 8423887964..0000000000 --- a/apps/code/src/renderer/features/setup/prompts.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { BASE_CATEGORY_ENUM } from "./types"; - -export const WIZARD_PROMPT = `/instrument-integration - -After the integration is wired up, also instrument error tracking and session replay (run \`/instrument-error-tracking\`, then add session replay if the framework's posthog-js config supports it). - -Run autonomously with sensible defaults — do not ask the user questions. If the PostHog API key isn't already in the project's env files and you can't read it from the PostHog MCP server, leave a placeholder env var and note it in the PR body rather than blocking.`; - -const DISCOVERY_PROMPT_BASE = `You are analyzing this codebase to find the highest-value first tasks for the developer. - -Scan the codebase for issues in two tiers. Tier 1 applies to every repo. Tier 2 only applies when PostHog is already installed (look for posthog-js, posthog-node, posthog-react-native or similar PostHog SDK imports). - -## Tier 1 -- Code health (always) - -- **Dead code**: Unused exports, unreachable branches, orphaned files, stale imports. Category: dead_code -- **Duplication / KISS violations**: Copy-pasted logic that should be a shared function, over-abstracted code that could be simpler. Category: duplication -- **Security vulnerabilities**: XSS, SQL injection, command injection, hardcoded secrets, open redirects, missing auth checks, insecure deserialization. Category: security -- **Bugs**: Null dereferences, race conditions, unchecked array access, off-by-one errors, unhandled promise rejections around I/O. Category: bug -- **Performance anti-patterns**: N+1 queries, unbounded loops, synchronous blocking on hot paths, missing pagination. Category: performance - -## Tier 2 -- PostHog-specific (only when PostHog SDK is detected) - -- **Stale feature flags**: Flags that are always evaluated the same way, flags referenced in code but never toggled, flags guarding code that shipped long ago. Category: stale_feature_flag -- **Error tracking gaps**: Catch blocks that swallow errors without reporting, missing error boundaries, untracked 5xx responses. Category: error_tracking -- **Event tracking improvements**: Key user actions (signup, purchase, invite, upgrade) with no analytics event, events missing useful properties (plan, user role, page context). Category: event_tracking -- **Funnel weak spots**: Multi-step flows (onboarding, checkout, activation) where intermediate steps have no tracking, making drop-off invisible. Category: funnel`; - -const DISCOVERY_PROMPT_EXPERIMENT_TIER = ` - -## Tier 3 -- Experiment opportunities (only when PostHog SDK is detected) - -- **Experimentable surfaces**: User-facing surfaces where an A/B test would meaningfully inform a product decision — pricing pages, paywalls, primary CTAs, signup/onboarding flows, empty states, recommendation lists, upgrade prompts. Category: experiment - - Title: a one-line hypothesis ("Test 'Get started free' vs 'Sign up' on landing CTA") - - Description: state the hypothesis as a sentence — what you would change and why you think it would move the metric - - Impact: name the primary metric you would measure (e.g. "Sign-up conversion on /landing") and what a winning variant would look like - - Recommendation: describe the control and test variants concretely (exact copy, layout change, or behavior), and note any flag wiring required (\`posthog.getFeatureFlag\`) - - Only suggest experiments where: (a) the surface is in code you can point at, (b) the variant is implementable without backend changes you can't see, and (c) the metric is something a typical PostHog event would capture - -If you find at least one credible Tier 3 experiment opportunity, include at least one experiment-category task in your output — even if doing so displaces a lower-impact Tier 1/2 finding. Do not fabricate an experiment to fill the slot: if no credible candidate exists, omit the category entirely.`; - -function buildDiscoveryRules(includeExperiments: boolean): string { - const allowed = ( - includeExperiments - ? [...BASE_CATEGORY_ENUM, "experiment"] - : [...BASE_CATEGORY_ENUM] - ).join(", "); - return ` - -## Rules - -- Be concrete: reference exact file paths, function names and line numbers — but put paths/lines in the dedicated \`file\` and \`lineHint\` fields, not in the title or description. -- Title: short, action-oriented header (under 60 characters), no paths or line numbers. -- Description: a clear paragraph (2–4 sentences) explaining the problem and the conditions under which it manifests. -- Impact: 1–3 sentences on why it matters (concrete consequence, blast radius, or risk). -- Recommendation: 2–4 sentences pointing at the right shape of the fix without writing the patch. Reference specific functions, types, or files involved. -- Prioritize by impact. Lead with findings that save the most time or prevent the most damage. -- Do NOT suggest documentation, comment, or style/formatting changes. -- Maximum 4 tasks. Quality over quantity. -- Allowed \`category\` values: ${allowed}. Do NOT emit any other category. - -When you are done analyzing, call create_output with your findings.`; -} - -export function buildDiscoveryPrompt({ - includeExperiments, -}: { - includeExperiments: boolean; -}): string { - const middle = includeExperiments ? DISCOVERY_PROMPT_EXPERIMENT_TIER : ""; - return `${DISCOVERY_PROMPT_BASE}${middle}${buildDiscoveryRules(includeExperiments)}`; -} 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/MainSidebar.tsx b/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx deleted file mode 100644 index 280f8c03bb..0000000000 --- a/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { useWorkspaces } from "@features/workspace/hooks/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; - if (target.isContentEditable) return true; - const tag = target.tagName; - return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; -} - -export function MainSidebar() { - const { data: workspaces = {}, isFetched } = useWorkspaces(); - const hasCompletedOnboarding = useOnboardingStore( - (state) => state.hasCompletedOnboarding, - ); - const setOpenAuto = useSidebarStore((state) => state.setOpenAuto); - - useEffect(() => { - if (isFetched) { - const workspaceCount = Object.keys(workspaces).length; - setOpenAuto(hasCompletedOnboarding || workspaceCount > 0); - } - }, [isFetched, workspaces, hasCompletedOnboarding, setOpenAuto]); - - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key !== "Escape") return; - if (isEditableTarget(e.target)) return; - const { selectedTaskIds, clearSelection } = - useTaskSelectionStore.getState(); - if (selectedTaskIds.length === 0) return; - clearSelection(); - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, []); - - return ( - - - - - - ); -} diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx deleted file mode 100644 index 81dc03740c..0000000000 --- a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; -import { SidebarUsageBar } from "@features/billing/components/SidebarUsageBar"; -import { ArchiveIcon } from "@phosphor-icons/react"; -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(); - const navigateToArchived = useNavigationStore( - (state) => state.navigateToArchived, - ); - return ( - - - - - - - {archivedTaskIds.size > 0 && ( - - - - )} - - - - - ); -}; diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx deleted file mode 100644 index 89ff09e1c4..0000000000 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ /dev/null @@ -1,440 +0,0 @@ -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 { - INBOX_PIPELINE_STATUS_FILTER, - INBOX_REFETCH_INTERVAL_MS, -} from "@features/inbox/utils/inboxConstants"; -import { - archiveTasksImperative, - 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"; -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"); - -function SidebarMenuComponent() { - const { - view, - navigateToTask, - navigateToTaskInput, - navigateToInbox, - navigateToCommandCenter, - navigateToSkills, - navigateToMcpServers, - } = useNavigationStore(); - - // Must mirror useSidebarData's filters so taskMap covers every rendered - // task — otherwise handleTaskClick silently bails for tasks not in the map. - const showAllUsers = useSidebarStore((s) => s.showAllUsers); - const showInternal = useSidebarStore((s) => s.showInternal); - const { data: allTasks = [] } = useTasks({ showAllUsers, showInternal }); - - const { data: workspaces = {} } = useWorkspaces(); - const { markAsViewed } = useTaskViewed(); - - const { showContextMenu, editingTaskId, setEditingTaskId } = - useTaskContextMenu(); - const { archiveTask } = useArchiveTask(); - const { renameTask } = useRenameTask(); - const { togglePin } = usePinnedTasks(); - - const sidebarData = useSidebarData({ - activeView: view, - }); - const inboxPollingActive = useRendererWindowFocusStore((s) => s.focused); - const { data: inboxProbe } = useInboxReports( - { status: INBOX_PIPELINE_STATUS_FILTER }, - { - refetchInterval: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : false, - refetchIntervalInBackground: false, - staleTime: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : 15_000, - }, - ); - const inboxResults = inboxProbe?.results ?? []; - const inboxSignalCount = inboxResults.filter(isReportUpForReview).length; - - const taskMap = new Map(); - for (const task of allTasks) { - taskMap.set(task.id, task); - } - - const commandCenterCells = useCommandCenterStore((s) => s.cells); - const assignTaskToCommandCenter = useCommandCenterStore((s) => s.assignTask); - const commandCenterActiveCount = commandCenterCells.filter( - (taskId) => taskId != null && taskMap.has(taskId), - ).length; - - const previousTaskIdRef = useRef(null); - - useEffect(() => { - const currentTaskId = - view.type === "task-detail" && view.data ? view.data.id : null; - - if ( - previousTaskIdRef.current && - previousTaskIdRef.current !== currentTaskId - ) { - markAsViewed(previousTaskIdRef.current); - } - - if (currentTaskId) { - markAsViewed(currentTaskId); - } - - previousTaskIdRef.current = currentTaskId; - }, [view, markAsViewed]); - - const handleNewTaskClick = () => { - navigateToTaskInput(); - }; - - const handleInboxClick = () => { - navigateToInbox(); - }; - - const handleCommandCenterClick = () => { - navigateToCommandCenter(); - }; - - const handleSkillsClick = () => { - navigateToSkills(); - }; - - const handleMcpServersClick = () => { - navigateToMcpServers(); - }; - - const openCommandMenu = useCommandMenuStore((s) => s.open); - const handleSearchClick = () => { - openCommandMenu(); - }; - - const queryClient = useQueryClient(); - - const selectedTaskIds = useTaskSelectionStore((s) => s.selectedTaskIds); - const toggleTaskSelection = useTaskSelectionStore( - (s) => s.toggleTaskSelection, - ); - const selectRange = useTaskSelectionStore((s) => s.selectRange); - const clearSelection = useTaskSelectionStore((s) => s.clearSelection); - const pruneSelection = useTaskSelectionStore((s) => s.pruneSelection); - - const organizeMode = useSidebarStore((s) => s.organizeMode); - const collapsedSections = useSidebarStore((s) => s.collapsedSections); - - const allSidebarTasks = useMemo( - () => [...sidebarData.pinnedTasks, ...sidebarData.flatTasks], - [sidebarData.pinnedTasks, sidebarData.flatTasks], - ); - - const allSidebarTaskIds = useMemo( - () => allSidebarTasks.map((t) => t.id), - [allSidebarTasks], - ); - - // Ordered list of currently visible task IDs in display order. Used as the - // 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, - ]); - - useEffect(() => { - pruneSelection(allSidebarTaskIds); - }, [allSidebarTaskIds, pruneSelection]); - - // 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 handleTaskClick = (taskId: string, e: React.MouseEvent) => { - if (e.shiftKey) { - e.preventDefault(); - selectRange(taskId, orderedVisibleTaskIds, activeTaskId); - return; - } - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - toggleTaskSelection(taskId); - return; - } - - clearSelection(); - const task = taskMap.get(taskId); - if (task) { - navigateToTask(task); - } - }; - - const handleBulkContextMenu = useCallback( - async (e: React.MouseEvent, taskIds: string[]) => { - e.preventDefault(); - e.stopPropagation(); - try { - const result = - await trpcClient.contextMenu.showBulkTaskContextMenu.mutate({ - taskCount: taskIds.length, - }); - if (!result.action) return; - if (result.action.type === "archive") { - const { archived, failed } = await archiveTasksImperative( - taskIds, - queryClient, - ); - clearSelection(); - if (failed === 0) { - toast.success( - `${archived} ${archived === 1 ? "task" : "tasks"} archived`, - ); - } else { - toast.error(`${archived} archived, ${failed} failed`); - } - } - } catch (error) { - log.error("Failed to show bulk context menu", error); - } - }, - [queryClient, clearSelection], - ); - - const handleTaskContextMenu = ( - taskId: string, - e: React.MouseEvent, - isPinned: boolean, - ) => { - // Bulk menu when 2+ tasks are in the effective selection (active + cmd/shift-clicked) - // and the right-clicked task is one of them. Otherwise clear and fall through. - if (effectiveBulkIds.length > 1) { - if (effectiveBulkIds.includes(taskId)) { - handleBulkContextMenu(e, effectiveBulkIds); - return; - } - clearSelection(); - } - - const task = taskMap.get(taskId); - if (task) { - const workspace = workspaces[taskId]; - const taskData = allSidebarTasks.find((t) => t.id === taskId); - const isInCommandCenter = commandCenterCells.some( - (id) => id === taskId && taskMap.has(id), - ); - const hasEmptyCommandCenterCell = commandCenterCells.some( - (id) => id == null || !taskMap.has(id), - ); - - showContextMenu(task, e, { - worktreePath: workspace?.worktreePath ?? undefined, - folderPath: workspace?.folderPath ?? undefined, - isPinned, - isSuspended: taskData?.isSuspended, - isInCommandCenter, - hasEmptyCommandCenterCell, - onTogglePin: () => togglePin(taskId), - onArchivePrior: handleArchivePrior, - onAddToCommandCenter: () => { - const cells = useCommandCenterStore.getState().cells; - const idx = cells.findIndex((id) => id == null || !taskMap.has(id)); - if (idx !== -1) { - assignTaskToCommandCenter(idx, taskId); - navigateToCommandCenter(); - } else { - toast.info("Command center is full"); - } - }, - }); - } - }; - - const handleTaskArchive = async (taskId: string) => { - await archiveTask({ taskId }); - }; - - 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); - - if (priorTaskIds.length === 0) { - toast.info("No older tasks to archive"); - return; - } - - const { archived, failed } = await archiveTasksImperative( - priorTaskIds, - queryClient, - ); - - if (failed === 0) { - toast.success( - `${archived} ${archived === 1 ? "task" : "tasks"} archived`, - ); - } else { - toast.error(`${archived} archived, ${failed} failed`); - } - }, - [sidebarData.pinnedTasks, sidebarData.flatTasks, queryClient], - ); - const handleTaskDoubleClick = useCallback( - (taskId: string) => { - setEditingTaskId(taskId); - }, - [setEditingTaskId], - ); - - const handleTaskEditSubmit = useCallback( - async (taskId: string, currentTitle: string, newTitle: string) => { - setEditingTaskId(null); - - try { - await renameTask({ - taskId, - currentTitle, - newTitle, - }); - } catch (error) { - log.error("Failed to rename task", error); - } - }, - [renameTask, setEditingTaskId], - ); - - const handleTaskEditCancel = useCallback(() => { - setEditingTaskId(null); - }, [setEditingTaskId]); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {sidebarData.isLoading ? ( - } - label="Loading tasks..." - disabled - /> - ) : ( - - )} - - - - ); -} - -export const SidebarMenu = memo(SidebarMenuComponent); 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/useCwd.ts b/apps/code/src/renderer/features/sidebar/hooks/useCwd.ts deleted file mode 100644 index 61446c8e66..0000000000 --- a/apps/code/src/renderer/features/sidebar/hooks/useCwd.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds"; -import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; - -export function useCwd(taskId: string): string | undefined { - const workspace = useWorkspace(taskId); - const suspendedIds = useSuspendedTaskIds(); - - if (!workspace) return undefined; - if (suspendedIds.has(taskId)) return undefined; - - return workspace.worktreePath ?? workspace.folderPath; -} 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/sidebar/stores/taskSelectionStore.ts b/apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts deleted file mode 100644 index a14a8bc09e..0000000000 --- a/apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { create } from "zustand"; - -interface TaskSelectionState { - selectedTaskIds: string[]; - /** The last task ID that was clicked — used as the anchor for shift-click range selection. */ - lastClickedId: string | null; -} - -interface TaskSelectionActions { - /** Replace the entire selection (plain click). */ - setSelectedTaskIds: (taskIds: string[]) => void; - /** Toggle a single task in/out of the selection (cmd-click). */ - toggleTaskSelection: (taskId: string) => void; - /** Select a contiguous range from the last-clicked task to `toId` within the given ordered list. - * Existing selection outside the range is preserved (shift-click behavior). - * If there is no last-clicked anchor (e.g. the user just navigated via a plain click), - * `fallbackAnchorId` is used — typically the currently active/routed task. */ - selectRange: ( - toId: string, - orderedIds: string[], - fallbackAnchorId?: string | null, - ) => void; - isTaskSelected: (taskId: string) => boolean; - clearSelection: () => void; - pruneSelection: (visibleTaskIds: string[]) => void; -} - -type TaskSelectionStore = TaskSelectionState & TaskSelectionActions; - -export const useTaskSelectionStore = create()( - (set, get) => ({ - selectedTaskIds: [], - lastClickedId: null, - - setSelectedTaskIds: (taskIds) => - set({ - selectedTaskIds: Array.from(new Set(taskIds)), - lastClickedId: taskIds.length === 1 ? taskIds[0] : get().lastClickedId, - }), - - toggleTaskSelection: (taskId) => - set((state) => { - const isRemoving = state.selectedTaskIds.includes(taskId); - return { - selectedTaskIds: isRemoving - ? state.selectedTaskIds.filter((id) => id !== taskId) - : [...state.selectedTaskIds, taskId], - lastClickedId: taskId, - }; - }), - - 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 }; - }), - - isTaskSelected: (taskId) => get().selectedTaskIds.includes(taskId), - - clearSelection: () => set({ selectedTaskIds: [], lastClickedId: null }), - - pruneSelection: (visibleTaskIds) => { - const visibleIds = new Set(visibleTaskIds); - set((state) => { - const filtered = state.selectedTaskIds.filter((id) => - visibleIds.has(id), - ); - if (filtered.length === state.selectedTaskIds.length) { - return state; - } - return { selectedTaskIds: filtered }; - }); - }, - }), -); diff --git a/apps/code/src/renderer/features/skill-buttons/prompts.ts b/apps/code/src/renderer/features/skill-buttons/prompts.ts deleted file mode 100644 index 4e68e5daa6..0000000000 --- a/apps/code/src/renderer/features/skill-buttons/prompts.ts +++ /dev/null @@ -1,144 +0,0 @@ -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"; - -export type { SkillButtonId }; - -export interface SkillButton { - id: SkillButtonId; - label: string; - prompt: string; - color: string; - Icon: Icon; - actionTitle: string; - actionDescription: string; - tooltip: string; -} - -export const SKILL_BUTTONS: Record = { - "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: - "Instrument PostHog events so you can measure this change in production", - }, - "create-feature-flags": { - id: "create-feature-flags", - 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.", - tooltip: - "Gate this change behind a PostHog feature flag for a safe rollout", - }, - "run-experiment": { - id: "run-experiment", - label: "Run experiment", - 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.", - tooltip: - "Scaffold a PostHog A/B experiment with control and test variants tied to a primary metric", - }, - "add-error-tracking": { - id: "add-error-tracking", - 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.", - tooltip: - "Capture exceptions in PostHog with stack traces so issues surface quickly in production", - }, - "instrument-llm-calls": { - id: "instrument-llm-calls", - 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.", - tooltip: - "Inspect traces, spans, latency, usage, and per-user costs for AI-powered features", - }, - "add-logging": { - id: "add-logging", - 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.", - tooltip: - "Capture structured application logs in PostHog for inspection and debugging", - }, -}; - -export const SKILL_BUTTON_ORDER: SkillButtonId[] = [ - "add-analytics", - "add-logging", - "add-error-tracking", - "instrument-llm-calls", - "create-feature-flags", - "run-experiment", -]; - -const SKILL_BUTTON_META_NAMESPACE = "posthogCode"; -const SKILL_BUTTON_META_FIELD = "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 })._meta; - const namespace = meta?.[SKILL_BUTTON_META_NAMESPACE] as - | Record - | undefined; - const id = namespace?.[SKILL_BUTTON_META_FIELD]; - if (typeof id === "string" && id in SKILL_BUTTONS) { - return id as SkillButtonId; - } - } - return null; -} diff --git a/apps/code/src/renderer/features/skill-buttons/stores/skillButtonsStore.ts b/apps/code/src/renderer/features/skill-buttons/stores/skillButtonsStore.ts deleted file mode 100644 index 229854e730..0000000000 --- a/apps/code/src/renderer/features/skill-buttons/stores/skillButtonsStore.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - SKILL_BUTTON_ORDER, - SKILL_BUTTONS, -} from "@features/skill-buttons/prompts"; -import type { SkillButtonId } from "@shared/types/analytics"; -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -interface SkillButtonsStoreState { - lastSelectedId: SkillButtonId; -} - -interface SkillButtonsStoreActions { - setLastSelectedId: (id: SkillButtonId) => void; -} - -type SkillButtonsStore = SkillButtonsStoreState & SkillButtonsStoreActions; - -const DEFAULT_PRIMARY: SkillButtonId = SKILL_BUTTON_ORDER[0]; - -export const useSkillButtonsStore = create()( - persist( - (set) => ({ - lastSelectedId: DEFAULT_PRIMARY, - setLastSelectedId: (lastSelectedId) => set({ lastSelectedId }), - }), - { - name: "skill-buttons-storage", - merge: (persisted, current) => { - const persistedState = persisted as { - lastSelectedId?: string; - }; - const restored = - persistedState.lastSelectedId && - persistedState.lastSelectedId in SKILL_BUTTONS - ? (persistedState.lastSelectedId as SkillButtonId) - : DEFAULT_PRIMARY; - return { - ...current, - lastSelectedId: restored, - }; - }, - }, - ), -); diff --git a/apps/code/src/renderer/features/skills/stores/skillsSidebarStore.ts b/apps/code/src/renderer/features/skills/stores/skillsSidebarStore.ts deleted file mode 100644 index 0a71e1c0a0..0000000000 --- a/apps/code/src/renderer/features/skills/stores/skillsSidebarStore.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createSidebarStore } from "@stores/createSidebarStore"; - -export const useSkillsSidebarStore = createSidebarStore({ - name: "skills-sidebar", - defaultWidth: 380, -}); diff --git a/apps/code/src/renderer/features/suspension/hooks/useRestoreTask.ts b/apps/code/src/renderer/features/suspension/hooks/useRestoreTask.ts deleted file mode 100644 index f764f92eb0..0000000000 --- a/apps/code/src/renderer/features/suspension/hooks/useRestoreTask.ts +++ /dev/null @@ -1,64 +0,0 @@ -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"; -import { useState } from "react"; - -const log = logger.scope("restore-task"); - -export function useRestoreTask() { - const queryClient = useQueryClient(); - const [isRestoring, setIsRestoring] = useState(false); - - const restoreTask = async (taskId: string, recreateBranch?: boolean) => { - setIsRestoring(true); - - try { - const result = await trpcClient.suspension.restore.mutate({ - taskId, - recreateBranch, - }); - - queryClient.invalidateQueries(trpc.suspension.pathFilter()); - queryClient.invalidateQueries(trpc.workspace.pathFilter()); - - const workspace = await workspaceApi.get(taskId); - const repoPath = workspace?.worktreePath ?? workspace?.folderPath; - if (repoPath) { - invalidateGitWorkingTreeQueries(repoPath); - invalidateGitBranchQueries(repoPath); - } - - log.info("Task restored", { - taskId, - worktreeName: result.worktreeName, - }); - - return result; - } catch (error) { - log.error("Failed to restore task", error); - - const message = - error instanceof Error ? error.message : "Failed to restore worktree"; - - if (message.includes("is already used by worktree")) { - toast.error( - "Branch is in use by another worktree. Try restoring with a new branch.", - ); - } else { - toast.error(message); - } - - throw error; - } finally { - setIsRestoring(false); - } - }; - - return { restoreTask, isRestoring }; -} 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/useSuspendedTaskIds.ts b/apps/code/src/renderer/features/suspension/hooks/useSuspendedTaskIds.ts deleted file mode 100644 index 1a56e45d67..0000000000 --- a/apps/code/src/renderer/features/suspension/hooks/useSuspendedTaskIds.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useTRPC } from "@renderer/trpc"; -import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; - -export function useSuspendedTaskIds(): Set { - const trpcReact = useTRPC(); - const { data } = useQuery( - trpcReact.suspension.suspendedTaskIds.queryOptions(), - ); - return useMemo(() => new Set(data ?? []), [data]); -} 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/components/TabContentRenderer.tsx b/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx deleted file mode 100644 index dfecb30cc6..0000000000 --- a/apps/code/src/renderer/features/task-detail/components/TabContentRenderer.tsx +++ /dev/null @@ -1,76 +0,0 @@ -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"; - -interface TabContentRendererProps { - tab: Tab; - taskId: string; - task: Task; -} - -export function TabContentRenderer({ - tab, - taskId, - task, -}: TabContentRendererProps) { - const isCloud = useIsWorkspaceCloudRun(taskId); - const { data } = tab; - - switch (data.type) { - case "logs": - return ; - - case "terminal": - return ( - - ); - - case "file": - return ( - - ); - - case "review": { - return isCloud ? ( - - ) : ( - - ); - } - - case "action": - return ( - - ); - - case "other": - switch (tab.id) { - case "files": - return ; - case "changes": - return ; - default: - return
Unknown tab: {tab.id}
; - } - - default: - return
Unknown tab type
; - } -} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx b/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx deleted file mode 100644 index 38ec43c016..0000000000 --- a/apps/code/src/renderer/features/task-detail/components/TaskLogsPanel.tsx +++ /dev/null @@ -1,163 +0,0 @@ -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 { Box, Flex } from "@radix-ui/themes"; -import type { Task } from "@shared/types"; -import { getTaskRepository } from "@utils/repository"; -import { useCallback, useEffect } from "react"; - -interface TaskLogsPanelProps { - taskId: string; - task: Task; - /** Hide the message input — log-only view. */ - hideInput?: boolean; -} - -export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { - const isWorkspaceLoaded = useWorkspaceLoaded(); - const { isPending: isCreatingWorkspace } = useCreateWorkspace(); - const repoKey = getTaskRepository(task); - const { folders } = useFolders(); - const hasDirectoryMapping = repoKey - ? folders.some((f) => f.remoteUrl === repoKey) - : false; - - const suspendedTaskIds = useSuspendedTaskIds(); - const isSuspended = suspendedTaskIds.has(taskId); - const { restoreTask, isRestoring } = useRestoreTask(); - - const isProvisioning = useProvisioningStore((s) => s.activeTasks.has(taskId)); - - const { requestFocus } = useDraftStore((s) => s.actions); - - const { - session, - repoPath, - isCloud, - isRunning, - hasError, - events, - isPromptPending, - promptStartedAt, - isInitializing, - cloudBranch, - cloudStatus, - errorTitle, - errorMessage, - } = useSessionViewState(taskId, task); - - useSessionConnection({ - taskId, - task, - session, - repoPath, - isCloud, - isSuspended, - }); - - const { - handleSendPrompt, - handleCancelPrompt, - handleRetry, - handleNewSession, - handleBashCommand, - } = useSessionCallbacks({ taskId, task, session, repoPath }); - - const { handleBeforeSubmit, dialogProps } = useBranchMismatchDialog({ - taskId, - repoPath, - onSendPrompt: handleSendPrompt, - }); - - const slackThreadUrl = - typeof task.latest_run?.state?.slack_thread_url === "string" - ? task.latest_run.state.slack_thread_url - : undefined; - - useEffect(() => { - requestFocus(taskId); - }, [taskId, requestFocus]); - - const handleRestoreWorktree = useCallback(async () => { - await restoreTask(taskId); - }, [taskId, restoreTask]); - - if (isProvisioning) { - return ; - } - - if ( - !repoPath && - !isCloud && - !isSuspended && - isWorkspaceLoaded && - !hasDirectoryMapping && - !isCreatingWorkspace - ) { - return ( - - - - - - ); - } - - return ( - - - - - - - - - - {dialogProps && } - - ); -} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskPendingView.tsx b/apps/code/src/renderer/features/task-detail/components/TaskPendingView.tsx deleted file mode 100644 index 37563e1082..0000000000 --- a/apps/code/src/renderer/features/task-detail/components/TaskPendingView.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { PendingChatView } from "@features/sessions/components/PendingChatView"; -import { Box } from "@radix-ui/themes"; -import { usePendingTaskPrompt } from "@stores/pendingTaskPromptStore"; - -interface TaskPendingViewProps { - pendingTaskKey: string; -} - -export function TaskPendingView({ pendingTaskKey }: TaskPendingViewProps) { - const pending = usePendingTaskPrompt(pendingTaskKey); - - return ( - - - - ); -} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskShellPanel.tsx b/apps/code/src/renderer/features/task-detail/components/TaskShellPanel.tsx deleted file mode 100644 index e3c0e489bf..0000000000 --- a/apps/code/src/renderer/features/task-detail/components/TaskShellPanel.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 { Box } from "@radix-ui/themes"; -import type { Task } from "@shared/types"; -import { useEffect } from "react"; - -interface TaskShellPanelProps { - taskId: string; - task: Task; - shellId?: string; -} - -export function TaskShellPanel({ - taskId, - task: _task, - shellId, -}: TaskShellPanelProps) { - const stateKey = shellId ? `${taskId}-${shellId}` : taskId; - const tabId = shellId || "shell"; - - const session = useSessionForTask(taskId); - const workspace = useWorkspace(taskId); - const workspacePath = workspace?.worktreePath ?? workspace?.folderPath; - - 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]); - - useEffect(() => { - if (processName) { - updateTabLabel(taskId, tabId, processName); - } - }, [processName, taskId, tabId, updateTabLabel]); - - if (!workspacePath || !session || session.status === "connecting") { - return null; - } - - return ( - - - - ); -} diff --git a/apps/code/src/renderer/features/task-detail/components/WorkspaceSetupPrompt.tsx b/apps/code/src/renderer/features/task-detail/components/WorkspaceSetupPrompt.tsx deleted file mode 100644 index a5c6e50985..0000000000 --- a/apps/code/src/renderer/features/task-detail/components/WorkspaceSetupPrompt.tsx +++ /dev/null @@ -1,148 +0,0 @@ -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 { 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"); - -interface WorkspaceSetupPromptProps { - taskId: string; - task: Task; -} - -export function WorkspaceSetupPrompt({ - taskId, - task, -}: WorkspaceSetupPromptProps) { - const [isSettingUp, setIsSettingUp] = useState(false); - const [selectedPath, setSelectedPath] = useState(""); - const [pendingPath, setPendingPath] = useState(null); - const [detectedRepo, setDetectedRepo] = useState(null); - const repository = getTaskRepository(task); - const { ensureWorkspace } = useEnsureWorkspace(); - - const proceedWithSetup = useCallback( - async (path: string) => { - setPendingPath(null); - setDetectedRepo(null); - 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 }); - toast.error("Failed to set up workspace. Please try again."); - } finally { - setSelectedPath(""); - setIsSettingUp(false); - } - }, - [taskId, ensureWorkspace], - ); - - 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; - } - } - } - - await proceedWithSetup(path); - }, - [repository, proceedWithSetup], - ); - - const handleConfirm = useCallback(async () => { - if (pendingPath) { - await proceedWithSetup(pendingPath); - } - }, [pendingPath, proceedWithSetup]); - - const handleBack = useCallback(() => { - setPendingPath(null); - setDetectedRepo(null); - }, []); - - return ( - - {isSettingUp ? ( - <> - - Setting up workspace... - - ) : pendingPath ? ( - <> - - - Repository mismatch - - - This task is linked to {repository} but the selected - folder belongs to {detectedRepo}. - - - - - - - ) : ( - <> - - - Select a repository folder - - {repository && ( - - This task is linked to {repository} - - )} - - - - - )} - - ); -} 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/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts deleted file mode 100644 index 8b553ab95c..0000000000 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ /dev/null @@ -1,351 +0,0 @@ -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 { - 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"; - -const log = logger.scope("task-creation"); - -interface UseTaskCreationOptions { - editorRef: React.RefObject; - selectedDirectory: string; - selectedRepository?: string | null; - githubIntegrationId?: number; - githubUserIntegrationId?: string; - workspaceMode: WorkspaceMode; - branch?: string | null; - editorIsEmpty: boolean; - executionMode?: ExecutionMode; - adapter?: "claude" | "codex"; - model?: string; - reasoningLevel?: string; - environmentId?: string | null; - sandboxEnvironmentId?: string; - signalReportId?: string; - onTaskCreated?: (task: Task) => void; -} - -interface UseTaskCreationReturn { - isCreatingTask: boolean; - canSubmit: boolean; - handleSubmit: (contentOverride?: EditorContent) => Promise; -} - -function prepareTaskInput( - content: Parameters[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, -): Promise { - try { - const workspaceMode = input.workspaceMode ?? "local"; - - let usesWorktreeLink: boolean | undefined; - let usesWorktreeInclude: boolean | undefined; - if (workspaceMode === "worktree" && selectedDirectory) { - try { - const usage = await trpcClient.workspace.getWorktreeFileUsage.query({ - mainRepoPath: selectedDirectory, - }); - usesWorktreeLink = usage.usesWorktreeLink; - usesWorktreeInclude = usage.usesWorktreeInclude; - } catch (error) { - log.warn("Failed to read worktree file usage for analytics", { - error, - }); - } - } - - track(ANALYTICS_EVENTS.TASK_CREATED, { - auto_run: !!input.executionMode, - created_from: "command-menu", - repository_provider: input.repository ? "github" : "none", - workspace_mode: workspaceMode, - has_branch: !!input.branch, - has_environment_setup: - workspaceMode === "worktree" ? !!input.environmentId : undefined, - has_sandbox_environment: - workspaceMode === "cloud" ? !!input.sandboxEnvironmentId : undefined, - cloud_run_source: - workspaceMode === "cloud" - ? (input.cloudRunSource ?? "manual") - : undefined, - cloud_pr_authorship_mode: - workspaceMode === "cloud" ? input.cloudPrAuthorshipMode : undefined, - signal_report_id: input.signalReportId, - uses_worktree_link: usesWorktreeLink, - uses_worktree_include: usesWorktreeInclude, - adapter: input.adapter, - }); - } catch (error) { - log.warn("Failed to track Task created event", { error }); - } -} - -function getErrorTitle(failedStep: string): string { - const titles: Record = { - 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, - selectedRepository, - githubIntegrationId, - githubUserIntegrationId, - workspaceMode, - branch, - editorIsEmpty, - executionMode, - adapter, - model, - reasoningLevel, - environmentId, - sandboxEnvironmentId, - signalReportId, - onTaskCreated, -}: UseTaskCreationOptions): UseTaskCreationReturn { - const [isCreatingTask, setIsCreatingTask] = useState(false); - const { - clearTaskInputReportAssociation, - navigateToTask, - navigateToPendingTask, - navigateToTaskInput, - } = useNavigationStore(); - const isAuthenticated = useAuthStateValue( - (state) => state.status === "authenticated", - ); - const { invalidateTasks } = useCreateTask(); - const { isOnline } = useConnectivity(); - - const hasRequiredPath = - workspaceMode === "cloud" ? !!selectedRepository : !!selectedDirectory; - const canSubmitBase = - isAuthenticated && isOnline && hasRequiredPath && !isCreatingTask; - const canSubmit = !!editorRef.current && canSubmitBase && !editorIsEmpty; - - const handleSubmit = useCallback( - async (contentOverride?: EditorContent): Promise => { - const editor = editorRef.current; - if (!editor) return false; - const allowSubmit = contentOverride ? canSubmitBase : canSubmit; - if (!allowSubmit) return false; - - setIsCreatingTask(true); - - const content = contentOverride ?? editor.getContent(); - const plainPromptText = contentToPlainText(content).trim(); - const shouldShowPendingView = !onTaskCreated && !!plainPromptText; - const pendingTaskKey = shouldShowPendingView - ? (globalThis.crypto?.randomUUID?.() ?? `pending-${Date.now()}`) - : null; - - if (pendingTaskKey) { - pendingTaskPromptStoreApi.set(pendingTaskKey, { - promptText: plainPromptText, - attachments: (content.attachments ?? []).map((a) => ({ - id: a.id, - label: a.label, - })), - }); - navigateToPendingTask(pendingTaskKey); - if (!contentOverride) { - editor.clear(); - } - } - - try { - if (!contentOverride) { - const plainText = editor.getText()?.trim() ?? plainPromptText; - if (plainText) { - useTaskInputHistoryStore.getState().addPrompt(plainText); - } - } - - const input = prepareTaskInput(content, { - selectedDirectory, - selectedRepository, - githubIntegrationId, - githubUserIntegrationId, - workspaceMode, - branch, - executionMode, - adapter, - model, - reasoningLevel, - environmentId, - sandboxEnvironmentId, - signalReportId, - }); - - if (executionMode) { - useSettingsStore.getState().setLastUsedInitialTaskMode(executionMode); - } - - const taskService = get(RENDERER_TOKENS.TaskService); - const result = await taskService.createTask(input, (output) => { - invalidateTasks(output.task); - if (signalReportId) { - clearTaskInputReportAssociation(); - } - if (pendingTaskKey) { - pendingTaskPromptStoreApi.move(pendingTaskKey, output.task.id); - } - if (onTaskCreated) { - onTaskCreated(output.task); - } else { - navigateToTask(output.task); - } - useTourStore.getState().completeTour(createFirstTaskTour.id); - if (!pendingTaskKey && !contentOverride) { - editor.clear(); - } - }); - - if (result.success) { - void trackTaskCreated(input, selectedDirectory); - } - - if (!result.success) { - const title = getErrorTitle(result.failedStep); - toast.error(title, { description: result.error }); - log.error("Task creation failed", { - failedStep: result.failedStep, - error: result.error, - }); - if (pendingTaskKey) { - pendingTaskPromptStoreApi.clear(pendingTaskKey); - navigateToTaskInput({ initialPrompt: plainPromptText }); - } - } - return result.success; - } catch (error) { - const description = - error instanceof Error ? error.message : "Unknown error"; - toast.error("Failed to create task", { description }); - log.error("Unexpected error during task creation", { error }); - if (pendingTaskKey) { - pendingTaskPromptStoreApi.clear(pendingTaskKey); - navigateToTaskInput({ initialPrompt: plainPromptText }); - } - return false; - } finally { - setIsCreatingTask(false); - } - }, - [ - canSubmit, - canSubmitBase, - editorRef, - selectedDirectory, - selectedRepository, - githubIntegrationId, - githubUserIntegrationId, - workspaceMode, - branch, - executionMode, - adapter, - model, - reasoningLevel, - environmentId, - sandboxEnvironmentId, - signalReportId, - clearTaskInputReportAssociation, - invalidateTasks, - navigateToTask, - navigateToPendingTask, - navigateToTaskInput, - onTaskCreated, - ], - ); - - return { - isCreatingTask, - canSubmit, - handleSubmit, - }; -} diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskData.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskData.ts deleted file mode 100644 index 2169dc389b..0000000000 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskData.ts +++ /dev/null @@ -1,64 +0,0 @@ -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 { useQuery } from "@tanstack/react-query"; -import { getTaskRepository } from "@utils/repository"; -import { useMemo } from "react"; - -interface UseTaskDataParams { - taskId: string; - initialTask: Task; -} - -export function useTaskData({ taskId, initialTask }: UseTaskDataParams) { - const trpcReact = useTRPC(); - const { data: tasks = [] } = useTasks(); - - const task = useMemo( - () => tasks.find((t) => t.id === taskId) || initialTask, - [tasks, taskId, initialTask], - ); - - const workspace = useWorkspace(taskId); - const repoPath = workspace?.folderPath ?? null; - - const { data: repoExists } = useQuery( - trpcReact.git.validateRepo.queryOptions( - { directoryPath: repoPath ?? "" }, - { enabled: !!repoPath }, - ), - ); - - const repository = getTaskRepository(task); - - const isCloning = cloneStore((state) => - repository ? state.isCloning(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, - }; - }, - (a, b) => a?.message === b?.message && a?.percent === b?.percent, - ); - - return { - task, - repoPath, - repoExists: repoExists ?? null, - isCloning, - cloneProgress, - }; -} 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.test.tsx b/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx deleted file mode 100644 index b99a971651..0000000000 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.test.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import type { Schemas } from "@posthog/api-client"; -import type { Task } from "@shared/types"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook } from "@testing-library/react"; -import { act, type ReactNode } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockUpdateTask = vi.hoisted(() => vi.fn()); -const mockClient = vi.hoisted(() => ({ updateTask: mockUpdateTask })); -const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn()); - -vi.mock("@features/auth/hooks/authClient", () => ({ - useOptionalAuthenticatedClient: () => mockClient, -})); - -vi.mock("@features/sessions/service/service", () => ({ - getSessionService: () => ({ - 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"; - -const TASK_ID = "task-1"; -const OTHER_TASK_ID = "task-2"; - -function createTask(overrides: Partial = {}): Task { - return { - id: TASK_ID, - task_number: 1, - slug: "task-1", - title: "Original title", - description: "Original description", - created_at: "2026-05-28T00:00:00.000Z", - updated_at: "2026-05-28T00:00:00.000Z", - origin_product: "user_created", - ...overrides, - }; -} - -function createSummary(overrides: Partial = {}) { - return { - id: TASK_ID, - title: "Original title", - ...overrides, - } as Schemas.TaskSummary; -} - -function renderRenameHook() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - const result = renderHook(() => useRenameTask(), { wrapper }); - return { ...result, queryClient }; -} - -describe("useRenameTask", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("applies the new title optimistically to list, summaries, and detail caches", async () => { - mockUpdateTask.mockResolvedValue(undefined); - const { result, queryClient } = renderRenameHook(); - - const listKey = taskKeys.list(); - const summaryKey = taskKeys.summaries([TASK_ID]); - const detailKey = taskKeys.detail(TASK_ID); - queryClient.setQueryData(listKey, [ - createTask(), - createTask({ id: OTHER_TASK_ID, title: "Other" }), - ]); - queryClient.setQueryData(summaryKey, [ - createSummary(), - createSummary({ id: OTHER_TASK_ID, title: "Other" }), - ]); - queryClient.setQueryData(detailKey, createTask()); - - await act(async () => { - await result.current.renameTask({ - taskId: TASK_ID, - currentTitle: "Original title", - newTitle: "Renamed", - }); - }); - - const list = queryClient.getQueryData(listKey); - expect(list?.find((t) => t.id === TASK_ID)).toMatchObject({ - title: "Renamed", - title_manually_set: true, - }); - expect(list?.find((t) => t.id === OTHER_TASK_ID)).toMatchObject({ - title: "Other", - }); - - const summaries = - queryClient.getQueryData(summaryKey); - expect(summaries?.find((t) => t.id === TASK_ID)?.title).toBe("Renamed"); - expect(summaries?.find((t) => t.id === OTHER_TASK_ID)?.title).toBe("Other"); - - const detail = queryClient.getQueryData(detailKey); - expect(detail).toMatchObject({ - title: "Renamed", - title_manually_set: true, - }); - - expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { - title: "Renamed", - title_manually_set: true, - }); - expect(mockUpdateSessionTaskTitle).toHaveBeenCalledWith(TASK_ID, "Renamed"); - }); - - it("rolls back all caches and notifies the session service with the original title on failure", async () => { - const failure = new Error("network down"); - mockUpdateTask.mockRejectedValue(failure); - const { result, queryClient } = renderRenameHook(); - - const listKey = taskKeys.list(); - const summaryKey = taskKeys.summaries([TASK_ID]); - const detailKey = taskKeys.detail(TASK_ID); - queryClient.setQueryData(listKey, [createTask()]); - queryClient.setQueryData(summaryKey, [ - createSummary(), - ]); - queryClient.setQueryData(detailKey, createTask()); - - let caught: unknown; - await act(async () => { - try { - await result.current.renameTask({ - taskId: TASK_ID, - currentTitle: "Original title", - newTitle: "Renamed", - }); - } catch (error) { - caught = error; - } - }); - expect(caught).toBe(failure); - - expect(queryClient.getQueryData(listKey)?.[0].title).toBe( - "Original title", - ); - expect( - queryClient.getQueryData(listKey)?.[0].title_manually_set, - ).toBeUndefined(); - expect( - queryClient.getQueryData(summaryKey)?.[0].title, - ).toBe("Original title"); - expect(queryClient.getQueryData(detailKey)?.title).toBe( - "Original title", - ); - - expect(mockUpdateSessionTaskTitle).toHaveBeenNthCalledWith( - 1, - TASK_ID, - "Renamed", - ); - expect(mockUpdateSessionTaskTitle).toHaveBeenNthCalledWith( - 2, - TASK_ID, - "Original title", - ); - }); - - it("skips rollback when a newer rename has advanced the title past ours", async () => { - const failure = new Error("network down"); - mockUpdateTask.mockRejectedValue(failure); - const { result, queryClient } = renderRenameHook(); - - const listKey = taskKeys.list(); - const summaryKey = taskKeys.summaries([TASK_ID]); - const detailKey = taskKeys.detail(TASK_ID); - queryClient.setQueryData(listKey, [createTask()]); - queryClient.setQueryData(summaryKey, [ - createSummary(), - ]); - queryClient.setQueryData(detailKey, createTask()); - - const renamePromise = result.current.renameTask({ - taskId: TASK_ID, - currentTitle: "Original title", - newTitle: "First rename", - }); - - queryClient.setQueryData(listKey, [ - createTask({ title: "Second rename", title_manually_set: true }), - ]); - queryClient.setQueryData(summaryKey, [ - createSummary({ title: "Second rename" }), - ]); - queryClient.setQueryData( - detailKey, - createTask({ title: "Second rename", title_manually_set: true }), - ); - - let caught: unknown; - await act(async () => { - try { - await renamePromise; - } catch (error) { - caught = error; - } - }); - expect(caught).toBe(failure); - - expect(queryClient.getQueryData(listKey)?.[0].title).toBe( - "Second rename", - ); - expect( - queryClient.getQueryData(summaryKey)?.[0].title, - ).toBe("Second rename"); - expect(queryClient.getQueryData(detailKey)?.title).toBe( - "Second rename", - ); - - expect(mockUpdateSessionTaskTitle).not.toHaveBeenCalledWith( - TASK_ID, - "Original title", - ); - }); - - it("does not write to the detail cache when no detail entry exists", async () => { - mockUpdateTask.mockResolvedValue(undefined); - const { result, queryClient } = renderRenameHook(); - - queryClient.setQueryData(taskKeys.list(), [createTask()]); - - await act(async () => { - await result.current.renameTask({ - taskId: TASK_ID, - currentTitle: "Original title", - newTitle: "Renamed", - }); - }); - - expect(queryClient.getQueryData(taskKeys.detail(TASK_ID))).toBeUndefined(); - expect(queryClient.getQueryData(taskKeys.list())?.[0].title).toBe( - "Renamed", - ); - }); -}); 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/tasks/stores/taskStore.types.ts b/apps/code/src/renderer/features/tasks/stores/taskStore.types.ts deleted file mode 100644 index d699d716bb..0000000000 --- a/apps/code/src/renderer/features/tasks/stores/taskStore.types.ts +++ /dev/null @@ -1,91 +0,0 @@ -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>; - -export type FilterMatchMode = "all" | "any"; - -export const TASK_STATUS_ORDER: string[] = [ - "failed", - "in_progress", - "queued", - "completed", - "backlog", -]; - -export interface TaskState { - selectedIndex: number | null; - hoveredIndex: number | null; - contextMenuIndex: number | null; - filter: string; - orderBy: OrderByField; - orderDirection: OrderDirection; - groupBy: GroupByField; - expandedGroups: Record; - activeFilters: ActiveFilters; - filterMatchMode: FilterMatchMode; - filterSearchQuery: string; - filterMenuSelectedIndex: number; - isFilterDropdownOpen: boolean; - editingFilterBadgeKey: string | null; - - setSelectedIndex: (index: number | null) => void; - setHoveredIndex: (index: number | null) => void; - setContextMenuIndex: (index: number | null) => void; - setFilter: (filter: string) => void; - setOrderBy: (orderBy: OrderByField) => void; - setOrderDirection: (orderDirection: OrderDirection) => void; - setGroupBy: (groupBy: GroupByField) => void; - toggleGroupExpanded: (groupName: string) => void; - setActiveFilters: (filters: ActiveFilters) => void; - clearActiveFilters: () => void; - toggleFilter: ( - category: FilterCategory, - value: string, - operator?: FilterOperator, - ) => void; - addFilter: ( - category: FilterCategory, - value: string, - operator?: FilterOperator, - ) => void; - updateFilter: ( - category: FilterCategory, - oldValue: string, - newValue: string, - ) => void; - toggleFilterOperator: (category: FilterCategory, value: string) => void; - setFilterMatchMode: (mode: FilterMatchMode) => void; - setFilterSearchQuery: (query: string) => void; - setFilterMenuSelectedIndex: (index: number) => void; - setIsFilterDropdownOpen: (open: boolean) => void; - setEditingFilterBadgeKey: (key: string | null) => void; -} diff --git a/apps/code/src/renderer/features/terminal/components/Terminal.tsx b/apps/code/src/renderer/features/terminal/components/Terminal.tsx deleted file mode 100644 index 90f021ede7..0000000000 --- a/apps/code/src/renderer/features/terminal/components/Terminal.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useSettingsStore } from "@features/settings/stores/settingsStore"; -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 { useCallback, useEffect, useRef } from "react"; -import { terminalManager } from "../services/TerminalManager"; -import { resolveTerminalFontFamily } from "../utils/resolveTerminalFontFamily"; - -export interface TerminalProps { - sessionId: string; - persistenceKey: string; - cwd?: string; - initialState?: string; - taskId?: string; - command?: string; - onReady?: () => void; - onExit?: (exitCode?: number) => void; -} - -export function Terminal({ - sessionId, - persistenceKey, - cwd, - initialState, - taskId, - command, - onReady, - onExit, -}: TerminalProps) { - const trpcReact = useTRPC(); - const terminalRef = useRef(null); - const isDarkMode = useThemeStore((state) => state.isDarkMode); - const terminalFont = useSettingsStore((s) => s.terminalFont); - const terminalCustomFontFamily = useSettingsStore( - (s) => s.terminalCustomFontFamily, - ); - - // Create instance (idempotent) - useEffect(() => { - if (!terminalManager.has(sessionId)) { - terminalManager.create({ - sessionId, - persistenceKey, - cwd, - initialState, - taskId, - command, - }); - } - }, [sessionId, persistenceKey, cwd, initialState, taskId, command]); - - // Attach/detach from DOM - useEffect(() => { - if (!terminalRef.current) return; - - terminalManager.attach(sessionId, terminalRef.current); - terminalManager.focus(sessionId); - - return () => { - terminalManager.detach(sessionId); - }; - }, [sessionId]); - - // Theme sync - useEffect(() => { - terminalManager.setTheme(isDarkMode); - }, [isDarkMode]); - - // Font sync - useEffect(() => { - terminalManager.setFontFamily( - resolveTerminalFontFamily(terminalFont, terminalCustomFontFamily), - ); - }, [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); - }, - }, - ), - ); - - // Event callbacks - useEffect(() => { - const offReady = terminalManager.on("ready", ({ sessionId: id }) => { - if (id === sessionId) { - onReady?.(); - } - }); - - const offExit = terminalManager.on( - "exit", - ({ sessionId: id, exitCode }) => { - if (id === sessionId) { - onExit?.(exitCode); - } - }, - ); - - return () => { - offReady(); - offExit(); - }; - }, [sessionId, onReady, onExit]); - - // mousedown so the xterm textarea is focused before the browser's native focus shift, not after. - const handleMouseDown = useCallback(() => { - terminalManager.focus(sessionId); - }, [sessionId]); - - return ( - -
- - - ); -} diff --git a/apps/code/src/renderer/features/terminal/stores/terminalStore.ts b/apps/code/src/renderer/features/terminal/stores/terminalStore.ts deleted file mode 100644 index 859aff6ac2..0000000000 --- a/apps/code/src/renderer/features/terminal/stores/terminalStore.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { trpcClient } from "@renderer/trpc/client"; -import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import { terminalManager } from "../services/TerminalManager"; - -interface TerminalState { - serializedState: string | null; - sessionId: string | null; - processName: string | null; -} - -interface TerminalStoreState { - terminalStates: Record; - pollingIntervals: Record; - 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 = { - serializedState: null, - sessionId: null, - processName: null, -}; - -export const useTerminalStore = create()( - persist( - (set, get) => ({ - terminalStates: {}, - pollingIntervals: {}, - - getTerminalState: (key: string) => { - return get().terminalStates[key] || DEFAULT_TERMINAL_STATE; - }, - - setSerializedState: (key: string, state: string) => { - set((prev) => ({ - terminalStates: { - ...prev.terminalStates, - [key]: { - ...prev.terminalStates[key], - serializedState: state, - }, - }, - })); - }, - - setSessionId: (key: string, sessionId: string) => { - set((prev) => ({ - terminalStates: { - ...prev.terminalStates, - [key]: { - ...prev.terminalStates[key], - sessionId, - }, - }, - })); - }, - - setProcessName: (key: string, processName: string | null) => { - set((prev) => ({ - terminalStates: { - ...prev.terminalStates, - [key]: { - ...prev.terminalStates[key], - processName, - }, - }, - })); - }, - - clearTerminalState: (key: string) => { - set((prev) => { - const newStates = { ...prev.terminalStates }; - delete newStates[key]; - return { terminalStates: newStates }; - }); - }, - - clearTerminalStatesForTask: (taskId: string) => { - set((prev) => { - const newStates = { ...prev.terminalStates }; - for (const key of Object.keys(newStates)) { - if (key === taskId || key.startsWith(`${taskId}-`)) { - delete newStates[key]; - } - } - 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", - partialize: (state) => ({ - terminalStates: Object.fromEntries( - Object.entries(state.terminalStates).map(([k, v]) => [ - k, - { serializedState: v.serializedState, sessionId: v.sessionId }, - ]), - ), - }), - }, - ), -); - -// Subscribe to manager events for auto-persistence -terminalManager.on("stateChange", ({ persistenceKey, serializedState }) => { - useTerminalStore - .getState() - .setSerializedState(persistenceKey, serializedState); -}); 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/createFirstTaskTour.ts b/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts deleted file mode 100644 index bfc8c9c123..0000000000 --- a/apps/code/src/renderer/features/tour/tours/createFirstTaskTour.ts +++ /dev/null @@ -1,32 +0,0 @@ -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"; - -export const createFirstTaskTour: TourDefinition = { - id: "create-first-task", - steps: [ - { - id: "folder-picker", - target: "folder-picker", - hogSrc: explorerHog, - message: "Pick a repo to work with. This tells me where your code lives!", - advanceOn: { type: "action" }, - }, - { - id: "task-editor", - target: "task-input-editor", - hogSrc: builderHog, - message: - "Describe what you want to build or fix. Be as specific as you like!", - advanceOn: { type: "action" }, - }, - { - id: "submit-button", - target: "task-input-submit", - hogSrc: happyHog, - message: "Hit send or press Enter to launch your first agent!", - advanceOn: { type: "click" }, - }, - ], -}; 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/tour/types.ts b/apps/code/src/renderer/features/tour/types.ts deleted file mode 100644 index 939653ba76..0000000000 --- a/apps/code/src/renderer/features/tour/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type TourStepAdvance = { type: "action" } | { type: "click" }; - -export type TooltipPlacement = "right" | "left" | "top" | "bottom"; - -export interface TourStep { - id: string; - target: string; - hogSrc: string; - message: string; - advanceOn: TourStepAdvance; - preferredPlacement?: TooltipPlacement; -} - -export interface TourDefinition { - id: string; - steps: TourStep[]; -} 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/useBranchMismatchDialog.ts b/apps/code/src/renderer/features/workspace/hooks/useBranchMismatchDialog.ts deleted file mode 100644 index 8f74fca239..0000000000 --- a/apps/code/src/renderer/features/workspace/hooks/useBranchMismatchDialog.ts +++ /dev/null @@ -1,150 +0,0 @@ -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 { useMutation } from "@tanstack/react-query"; -import { track } from "@utils/analytics"; -import { logger } from "@utils/logger"; -import { useCallback, useRef, useState } from "react"; - -const log = logger.scope("branch-mismatch"); - -interface UseBranchMismatchDialogOptions { - taskId: string; - repoPath: string | null; - onSendPrompt: (text: string) => void; -} - -export function useBranchMismatchDialog({ - taskId, - repoPath, - onSendPrompt, -}: UseBranchMismatchDialogOptions) { - const { shouldWarn, linkedBranch, currentBranch, dismissWarning } = - useBranchMismatchGuard(taskId); - - // State drives dialog visibility (`open`), refs avoid stale closures in - // mutation callbacks (onSuccess / handleContinue) that capture at mount time. - const [pendingMessage, setPendingMessage] = useState(null); - const pendingMessageRef = useRef(null); - const pendingClearRef = useRef<(() => void) | null>(null); - const onSendPromptRef = useRef(onSendPrompt); - onSendPromptRef.current = onSendPrompt; - const [switchError, setSwitchError] = useState(null); - - const { hasChanges: hasUncommittedChanges } = useGitQueries( - repoPath ?? undefined, - ); - - const trpc = useTRPC(); - const { mutate: checkoutBranch, isPending: isSwitching } = useMutation( - trpc.git.checkoutBranch.mutationOptions({ - onSuccess: () => { - if (repoPath) invalidateGitBranchQueries(repoPath); - dismissWarning(); - pendingClearRef.current?.(); - pendingClearRef.current = null; - const message = pendingMessageRef.current; - if (message) onSendPromptRef.current(message); - setPendingMessage(null); - pendingMessageRef.current = null; - }, - onError: (error) => { - log.error("Failed to switch branch", error); - setSwitchError( - error instanceof Error ? error.message : "Failed to switch branch", - ); - }, - }), - ); - - const handleBeforeSubmit = useCallback( - (text: string, clearEditor: () => void): boolean => { - if (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, - }); - } - return false; - } - return true; - }, - [shouldWarn, taskId, linkedBranch, currentBranch, hasUncommittedChanges], - ); - - const handleSwitch = useCallback(() => { - if (!linkedBranch || !repoPath) 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]); - - const handleContinue = useCallback(() => { - if (linkedBranch && currentBranch) { - track(ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION, { - task_id: taskId, - action: "continue", - linked_branch: linkedBranch, - current_branch: currentBranch, - }); - } - dismissWarning(); - pendingClearRef.current?.(); - pendingClearRef.current = null; - const message = pendingMessageRef.current; - if (message) onSendPromptRef.current(message); - setPendingMessage(null); - pendingMessageRef.current = null; - setSwitchError(null); - }, [dismissWarning, taskId, linkedBranch, currentBranch]); - - const handleCancel = useCallback(() => { - if (linkedBranch && currentBranch) { - track(ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION, { - task_id: taskId, - action: "cancel", - linked_branch: linkedBranch, - current_branch: currentBranch, - }); - } - setPendingMessage(null); - pendingMessageRef.current = null; - pendingClearRef.current = null; - setSwitchError(null); - }, [taskId, linkedBranch, currentBranch]); - - const dialogProps = - linkedBranch && currentBranch - ? { - open: pendingMessage !== null, - linkedBranch, - currentBranch, - hasUncommittedChanges, - switchError, - onSwitch: handleSwitch, - onContinue: handleContinue, - onCancel: handleCancel, - isSwitching, - } - : null; - - return { handleBeforeSubmit, dialogProps }; -} 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 index f0932a1ee9..247ead9792 100644 --- a/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts +++ b/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts @@ -1,160 +1,17 @@ -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, - }; -} +import type { Workspace, WorkspaceMode } from "@posthog/shared"; +import { trpcClient } from "@renderer/trpc/client"; + +export { + useIsWorkspaceCloudRun, + useWorkspace, + useWorkspaceLoaded, + useWorkspaces, +} from "@posthog/ui/features/workspace/useWorkspace"; +export { + useCreateWorkspace, + useDeleteWorkspace, + useEnsureWorkspace, +} from "@posthog/ui/features/workspace/useWorkspaceMutations"; export const workspaceApi = { async getAll(): Promise> { diff --git a/apps/code/src/renderer/features/workspace/hooks/useWorkspaceEvents.ts b/apps/code/src/renderer/features/workspace/hooks/useWorkspaceEvents.ts deleted file mode 100644 index 264c66b774..0000000000 --- a/apps/code/src/renderer/features/workspace/hooks/useWorkspaceEvents.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { trpcClient } from "@renderer/trpc/client"; -import { toast } from "@utils/toast"; -import { useEffect } from "react"; - -export function useWorkspaceEvents(taskId: string) { - useEffect(() => { - const warningSubscription = trpcClient.workspace.onWarning.subscribe( - undefined, - { - onData: (data) => { - if (data.taskId !== taskId) return; - toast.warning(data.title, { - description: data.message, - duration: 10000, - }); - }, - }, - ); - - return () => { - warningSubscription.unsubscribe(); - }; - }, [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/useDetectedCloudRepository.ts b/apps/code/src/renderer/hooks/useDetectedCloudRepository.ts deleted file mode 100644 index efc4491751..0000000000 --- a/apps/code/src/renderer/hooks/useDetectedCloudRepository.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useTRPC } from "@renderer/trpc"; -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, - }, - ), - ); - - if (!data?.organization || !data?.repository) return null; - return `${data.organization}/${data.repository}`.toLowerCase(); -} 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/useIntegrations.ts b/apps/code/src/renderer/hooks/useIntegrations.ts deleted file mode 100644 index 781d24cb94..0000000000 --- a/apps/code/src/renderer/hooks/useIntegrations.ts +++ /dev/null @@ -1,665 +0,0 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { AUTH_SCOPED_QUERY_META } from "@features/auth/hooks/authQueries"; -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"; -import { - useCallback, - useDeferredValue, - useEffect, - 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 -// skip the debounce so closing the picker (which resets search to "") clears -// 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; -} - -export function useIntegrations() { - const setIntegrations = useIntegrationStore((state) => state.setIntegrations); - - const query = useAuthenticatedQuery( - integrationKeys.list(), - (client) => client.getIntegrations() as Promise, - ); - - useEffect(() => { - if (query.data) { - setIntegrations(query.data); - } - }, [query.data, setIntegrations]); - - return query; -} - -function useAllGithubRepositories(githubIntegrations: Integration[]) { - const client = useOptionalAuthenticatedClient(); - - return useQueries({ - queries: githubIntegrations.map((integration) => ({ - queryKey: integrationKeys.repositories(integration.id), - queryFn: async () => { - if (!client) throw new Error("Not authenticated"); - const repos = await client.getGithubRepositories(integration.id); - return { integrationId: integration.id, repos }; - }, - enabled: !!client, - staleTime: 5 * 60 * 1000, - meta: AUTH_SCOPED_QUERY_META, - })), - combine: (results) => { - 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 function useUserGithubIntegrations() { - return useAuthenticatedQuery(userGithubIntegrationKeys.list(), (client) => - client.getGithubUserIntegrations(), - ); -} - -function useAllUserGithubRepositories( - githubIntegrations: UserGitHubIntegration[], -) { - const client = useOptionalAuthenticatedClient(); - - return useQueries({ - queries: githubIntegrations.map((integration) => ({ - queryKey: userGithubIntegrationKeys.repositories( - integration.installation_id, - ), - queryFn: async () => { - if (!client) throw new Error("Not authenticated"); - const repos = await client.getGithubUserRepositories( - integration.installation_id, - ); - return { - userIntegrationId: integration.id, - installationId: integration.installation_id, - repos, - }; - }, - enabled: !!client, - staleTime: 5 * 60 * 1000, - meta: AUTH_SCOPED_QUERY_META, - })), - combine: (results) => { - 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 = - 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, - }; - }, - }); -} - -const REPOSITORIES_PAGE_SIZE = 50; -const BRANCHES_FIRST_PAGE_SIZE = 50; -const BRANCHES_PAGE_SIZE = 100; - -export function useGithubRepositories( - search?: string, - enabled: boolean = true, -) { - const client = useOptionalAuthenticatedClient(); - const { githubIntegrations } = useIntegrationSelectors(); - const deferredSearch = useDeferredValue(search?.trim() ?? ""); - const [requestedLimit, setRequestedLimit] = useState(REPOSITORIES_PAGE_SIZE); - const queryEnabled = enabled && !!client && githubIntegrations.length > 0; - - useEffect(() => { - setRequestedLimit(REPOSITORIES_PAGE_SIZE); - }, []); - - const { repositoryMap, isPending, isRefreshing, hasMore } = useQueries({ - queries: githubIntegrations.map((integration) => ({ - queryKey: integrationKeys.repositoryPicker( - integration.id, - deferredSearch, - requestedLimit, - ), - queryFn: async () => { - if (!client) throw new Error("Not authenticated"); - - const page = await client.getGithubRepositoriesPage( - integration.id, - 0, - requestedLimit, - deferredSearch, - ); - - return { integrationId: integration.id, ...page }; - }, - enabled: queryEnabled, - staleTime: 5 * 60 * 1000, - placeholderData: (prev: unknown) => prev, - meta: AUTH_SCOPED_QUERY_META, - })), - combine: (results) => { - 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.integrationId; - } - } - } - - return { - repositoryMap: map, - isPending: pending, - isRefreshing: refreshing, - hasMore: hasMoreResults, - }; - }, - }); - - const loadMore = useCallback(() => { - setRequestedLimit((currentLimit) => currentLimit + REPOSITORIES_PAGE_SIZE); - }, []); - - return { - repositories: Object.keys(repositoryMap), - isPending: queryEnabled ? isPending : false, - isRefreshing: queryEnabled ? isRefreshing : false, - hasMore, - loadMore, - }; -} - -export function useUserGithubRepositories( - search?: string, - enabled: boolean = true, -) { - const client = useOptionalAuthenticatedClient(); - const { data: githubIntegrations = [] } = useUserGithubIntegrations(); - const deferredSearch = useDeferredValue(search?.trim() ?? ""); - const [requestedLimit, setRequestedLimit] = useState(REPOSITORIES_PAGE_SIZE); - const queryEnabled = enabled && !!client && githubIntegrations.length > 0; - - useEffect(() => { - setRequestedLimit(REPOSITORIES_PAGE_SIZE); - }, []); - - const { repositoryMap, isPending, isRefreshing, hasMore } = useQueries({ - queries: githubIntegrations.map((integration) => ({ - queryKey: userGithubIntegrationKeys.repositoryPicker( - integration.installation_id, - deferredSearch, - requestedLimit, - ), - queryFn: async () => { - if (!client) throw new Error("Not authenticated"); - - const page = await client.getGithubUserRepositoriesPage( - integration.installation_id, - 0, - requestedLimit, - deferredSearch, - ); - - return { - userIntegrationId: integration.id, - installationId: integration.installation_id, - ...page, - }; - }, - enabled: queryEnabled, - staleTime: 5 * 60 * 1000, - meta: AUTH_SCOPED_QUERY_META, - })), - combine: (results) => { - 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] = { - userIntegrationId: result.data.userIntegrationId, - installationId: result.data.installationId, - }; - } - } - } - - return { - repositoryMap: map, - isPending: pending, - isRefreshing: refreshing, - hasMore: hasMoreResults, - }; - }, - }); - - const loadMore = useCallback(() => { - setRequestedLimit((currentLimit) => currentLimit + REPOSITORIES_PAGE_SIZE); - }, []); - - return { - repositories: Object.keys(repositoryMap), - isPending: queryEnabled ? isPending : false, - isRefreshing: queryEnabled ? isRefreshing : false, - hasMore, - loadMore, - }; -} - -interface GithubBranchesPage { - branches: string[]; - defaultBranch: string | null; - hasMore: boolean; -} - -export function useGithubBranches( - integrationId?: number, - repo?: string | null, - search?: string, - enabled: boolean = true, -) { - const trimmedSearch = search?.trim() ?? ""; - const debouncedSearch = useDebounce( - trimmedSearch, - trimmedSearch ? BRANCH_SEARCH_DEBOUNCE_MS : 0, - ); - const queryEnabled = enabled && !!integrationId && !!repo; - - const query = useAuthenticatedInfiniteQuery( - integrationKeys.branches(integrationId, repo, debouncedSearch), - async (client, offset) => { - 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, - debouncedSearch, - ); - }, - { - enabled: queryEnabled, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (!lastPage.hasMore) return undefined; - return allPages.reduce((n, p) => n + p.branches.length, 0); - }, - 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 loadMore = useCallback(() => { - if (!query.hasNextPage || query.isFetchingNextPage) { - return; - } - - void query.fetchNextPage(); - }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]); - - const refresh = useCallback(async () => { - await query.refetch(); - }, [query.refetch]); - - return { - data, - isPending: queryEnabled ? query.isPending : false, - isRefreshing: queryEnabled ? query.isRefetching : false, - isFetchingMore: query.isFetchingNextPage, - hasMore: query.hasNextPage ?? false, - loadMore, - refresh, - }; -} - -export function useUserGithubBranches( - installationId?: string, - repo?: string | null, - search?: string, - enabled: boolean = true, -) { - const trimmedSearch = search?.trim() ?? ""; - const debouncedSearch = useDebounce( - trimmedSearch, - trimmedSearch ? BRANCH_SEARCH_DEBOUNCE_MS : 0, - ); - const queryEnabled = enabled && !!installationId && !!repo; - - const query = useAuthenticatedInfiniteQuery( - userGithubIntegrationKeys.branches(installationId, repo, debouncedSearch), - async (client, offset) => { - 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, - debouncedSearch, - ); - }, - { - enabled: queryEnabled, - initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (!lastPage.hasMore) return undefined; - return allPages.reduce((n, p) => n + p.branches.length, 0); - }, - 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 loadMore = useCallback(() => { - if (!query.hasNextPage || query.isFetchingNextPage) { - return; - } - - void query.fetchNextPage(); - }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]); - - const refresh = useCallback(async () => { - await query.refetch(); - }, [query.refetch]); - - return { - data, - isPending: queryEnabled ? query.isPending : false, - isRefreshing: queryEnabled ? query.isRefetching : false, - isFetchingMore: query.isFetchingNextPage, - hasMore: query.hasNextPage ?? false, - loadMore, - refresh, - }; -} - -export function useUserRepositoryIntegration() { - const client = useOptionalAuthenticatedClient(); - const queryClient = useQueryClient(); - const { data: githubIntegrations = [], isPending: integrationsPending } = - useUserGithubIntegrations(); - const [isRefreshingRepos, setIsRefreshingRepos] = useState(false); - - const { - repositoryMap, - reposByInstallationId, - isPending: reposPending, - failedInstallationIds, - } = useAllUserGithubRepositories(githubIntegrations); - - const repositories = useMemo( - () => Object.keys(repositoryMap), - [repositoryMap], - ); - - const getUserIntegrationIdForRepo = useCallback( - (repoKey: string) => - repositoryMap[repoKey?.toLowerCase()]?.userIntegrationId, - [repositoryMap], - ); - - const getInstallationIdForRepo = useCallback( - (repoKey: string) => repositoryMap[repoKey?.toLowerCase()]?.installationId, - [repositoryMap], - ); - - const isRepoInIntegration = useCallback( - (repoKey: string) => !repoKey || repoKey.toLowerCase() in repositoryMap, - [repositoryMap], - ); - - const refreshRepositories = useCallback(async () => { - if (!githubIntegrations.length || !client) { - return; - } - - 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"], - }); - } finally { - setIsRefreshingRepos(false); - } - }, [client, githubIntegrations, queryClient]); - - return { - repositories, - getUserIntegrationIdForRepo, - getInstallationIdForRepo, - isRepoInIntegration, - isLoadingRepos: integrationsPending || reposPending, - isRefreshingRepos, - refreshRepositories, - hasGithubIntegration: githubIntegrations.length > 0, - failedInstallationIds, - reposByInstallationId, - }; -} - -export function useRepositoryIntegration() { - const client = useOptionalAuthenticatedClient(); - const queryClient = useQueryClient(); - const { isPending: integrationsPending } = useIntegrations(); - const { githubIntegrations, hasGithubIntegration } = - useIntegrationSelectors(); - const [isRefreshingRepos, setIsRefreshingRepos] = useState(false); - - const { repositoryMap, isPending: reposPending } = - useAllGithubRepositories(githubIntegrations); - - const repositories = useMemo( - () => Object.keys(repositoryMap), - [repositoryMap], - ); - - const getIntegrationIdForRepo = useCallback( - (repoKey: string) => repositoryMap[repoKey?.toLowerCase()], - [repositoryMap], - ); - - const isRepoInIntegration = useCallback( - (repoKey: string) => !repoKey || repoKey.toLowerCase() in repositoryMap, - [repositoryMap], - ); - - const refreshRepositories = useCallback(async () => { - if (!githubIntegrations.length || !client) { - return; - } - - 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"], - }); - } finally { - setIsRefreshingRepos(false); - } - }, [client, githubIntegrations, queryClient]); - - return { - repositories, - getIntegrationIdForRepo, - isRepoInIntegration, - isLoadingIntegrations: integrationsPending, - isLoadingRepos: integrationsPending || reposPending, - isRefreshingRepos, - refreshRepositories, - hasGithubIntegration, - }; -} diff --git a/apps/code/src/renderer/hooks/useMeQuery.ts b/apps/code/src/renderer/hooks/useMeQuery.ts deleted file mode 100644 index 6496e0ab07..0000000000 --- a/apps/code/src/renderer/hooks/useMeQuery.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; - -export function useMeQuery() { - return useAuthenticatedQuery( - ["me"], - async (client) => { - const data = await client.getCurrentUser(); - return data; - }, - { staleTime: 5 * 60 * 1000 }, - ); -} 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/useProjectQuery.ts b/apps/code/src/renderer/hooks/useProjectQuery.ts deleted file mode 100644 index a0137df388..0000000000 --- a/apps/code/src/renderer/hooks/useProjectQuery.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; - -export function useProjectQuery() { - const projectId = useAuthStateValue((state) => state.projectId); - - return useAuthenticatedQuery( - ["project", projectId], - async (client) => { - if (!projectId) { - throw new Error("No project ID available"); - } - const data = await client.getProject(projectId); - return data; - }, - { - staleTime: 5 * 60 * 1000, - enabled: !!projectId, - }, - ); -} diff --git a/apps/code/src/renderer/hooks/useRepoFiles.ts b/apps/code/src/renderer/hooks/useRepoFiles.ts index b83598fc46..5514c43802 100644 Binary files a/apps/code/src/renderer/hooks/useRepoFiles.ts and b/apps/code/src/renderer/hooks/useRepoFiles.ts differ diff --git a/apps/code/src/renderer/hooks/useRepositoryDirectory.ts b/apps/code/src/renderer/hooks/useRepositoryDirectory.ts index 728b67f7c7..1758a520ac 100644 --- a/apps/code/src/renderer/hooks/useRepositoryDirectory.ts +++ b/apps/code/src/renderer/hooks/useRepositoryDirectory.ts @@ -1,6 +1,6 @@ import { workspaceApi } from "@renderer/features/workspace/hooks/useWorkspace"; import { trpcClient } from "@renderer/trpc/client"; -import { expandTildePath } from "@utils/path"; +import { expandTildePath } from "@posthog/shared"; export async function getTaskDirectory( taskId: string, diff --git a/apps/code/src/renderer/hooks/useSeat.ts b/apps/code/src/renderer/hooks/useSeat.ts deleted file mode 100644 index 063df469e0..0000000000 --- a/apps/code/src/renderer/hooks/useSeat.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useSeatStore } from "@features/billing/stores/seatStore"; -import { isProPlan, seatHasAccess } from "@shared/types/seat"; - -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 isPro = isProPlan(seat?.plan_key); - const isOrgPro = isProPlan(orgSeat?.plan_key); - const hasAccess = seat ? seatHasAccess(seat.status) : false; - const isCanceling = orgSeat?.status === "canceling"; - const planLabel = isPro ? "Pro" : "Free"; - const activeUntil = orgSeat?.active_until - ? new Date(orgSeat.active_until * 1000) - : null; - - const hasBetterPlanElsewhere = - seat !== null && - orgSeat !== null && - isProPlan(seat.plan_key) && - !isProPlan(orgSeat.plan_key); - - return { - seat, - orgSeat, - isLoading, - error, - redirectUrl, - billingOrgId, - isPro, - isOrgPro, - hasAccess, - isCanceling, - planLabel, - activeUntil, - hasBetterPlanElsewhere, - }; -} diff --git a/apps/code/src/renderer/hooks/useTaskContextMenu.ts b/apps/code/src/renderer/hooks/useTaskContextMenu.ts deleted file mode 100644 index 31c48107a5..0000000000 --- a/apps/code/src/renderer/hooks/useTaskContextMenu.ts +++ /dev/null @@ -1,124 +0,0 @@ -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 { useCallback, useState } from "react"; - -const log = logger.scope("context-menu"); - -export function useTaskContextMenu() { - const [editingTaskId, setEditingTaskId] = useState(null); - const { deleteWithConfirm } = useDeleteTask(); - const { archiveTask } = useArchiveTask(); - const { suspendTask } = useSuspendTask(); - const { restoreTask } = useRestoreTask(); - - const showContextMenu = useCallback( - async ( - task: Task, - event: React.MouseEvent, - options?: { - worktreePath?: string; - folderPath?: string; - isPinned?: boolean; - isSuspended?: boolean; - isInCommandCenter?: boolean; - hasEmptyCommandCenterCell?: boolean; - onTogglePin?: () => void; - onArchivePrior?: (taskId: string) => void; - onAddToCommandCenter?: () => void; - }, - ) => { - event.preventDefault(); - event.stopPropagation(); - - const { - worktreePath, - folderPath, - isPinned, - isSuspended, - isInCommandCenter, - hasEmptyCommandCenterCell, - onTogglePin, - onArchivePrior, - onAddToCommandCenter, - } = options ?? {}; - - try { - const result = await trpcClient.contextMenu.showTaskContextMenu.mutate({ - taskTitle: task.title, - worktreePath, - folderPath, - isPinned, - isSuspended, - isInCommandCenter, - hasEmptyCommandCenterCell, - }); - - if (!result.action) return; - - switch (result.action.type) { - case "rename": - setEditingTaskId(task.id); - break; - case "pin": - onTogglePin?.(); - break; - case "suspend": - if (isSuspended) { - await restoreTask(task.id); - } else { - await suspendTask({ taskId: task.id, reason: "manual" }); - } - break; - case "archive": - await archiveTask({ taskId: task.id }); - break; - case "archive-prior": - await onArchivePrior?.(task.id); - break; - case "delete": - await deleteWithConfirm({ - taskId: task.id, - taskTitle: task.title, - hasWorktree: !!worktreePath, - }); - break; - case "add-to-command-center": - onAddToCommandCenter?.(); - break; - case "external-app": { - const effectivePath = worktreePath ?? folderPath; - if (effectivePath) { - const workspace = await workspaceApi.get(task.id); - await handleExternalAppAction( - result.action.action, - effectivePath, - task.title, - { - workspace, - mainRepoPath: workspace?.folderPath, - }, - ); - } - break; - } - } - } catch (error) { - log.error("Failed to show context menu", error); - } - }, - [archiveTask, deleteWithConfirm, restoreTask, suspendTask], - ); - - return { - showContextMenu, - editingTaskId, - setEditingTaskId, - }; -} diff --git a/apps/code/src/renderer/hooks/useTaskDeepLink.ts b/apps/code/src/renderer/hooks/useTaskDeepLink.ts deleted file mode 100644 index 73c0b101d7..0000000000 --- a/apps/code/src/renderer/hooks/useTaskDeepLink.ts +++ /dev/null @@ -1,124 +0,0 @@ -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 { 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. - */ -export function useTaskDeepLink() { - const trpcReact = useTRPC(); - const navigateToTask = useNavigationStore((state) => state.navigateToTask); - const { markAsViewed } = useTaskViewed(); - const queryClient = useQueryClient(); - const isAuthenticated = useAuthStateValue( - (state) => state.status === "authenticated", - ); - const hasFetchedPending = useRef(false); - - const handleOpenTask = useCallback( - async (taskId: string, taskRunId?: string) => { - log.info( - `Opening task from deep link: ${taskId}${taskRunId ? `, run: ${taskRunId}` : ""}`, - ); - - try { - const taskService = get(RENDERER_TOKENS.TaskService); - const result = await taskService.openTask(taskId, taskRunId); - - if (!result.success) { - log.error("Failed to open task from deep link", { - taskId, - taskRunId, - error: result.error, - failedStep: result.failedStep, - }); - toast.error(`Failed to open task: ${result.error}`); - return; - } - - const { task } = result.data; - - // Add task to query cache so it shows in sidebar - queryClient.setQueryData(taskKeys.list(), (old) => { - if (!old) return [task]; - const existingIndex = old.findIndex((t) => t.id === task.id); - if (existingIndex >= 0) { - const updated = [...old]; - updated[existingIndex] = task; - return updated; - } - return [task, ...old]; - }); - - // Invalidate to ensure sync with server - queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - - markAsViewed(taskId); - navigateToTask(task); - - log.info( - `Successfully opened task from deep link: ${taskId}${taskRunId ? `, run: ${taskRunId}` : ""}`, - ); - } catch (error) { - log.error("Unexpected error opening task from deep link:", error); - toast.error("Failed to open task"); - } - }, - [navigateToTask, markAsViewed, queryClient], - ); - - // Check for pending deep link on mount (for cold start via deep link) - useEffect(() => { - if (!isAuthenticated || hasFetchedPending.current) return; - - const fetchPending = async () => { - hasFetchedPending.current = true; - try { - const pending = await trpcClient.deepLink.getPendingDeepLink.query(); - if (pending) { - log.info( - `Found pending deep link: taskId=${pending.taskId}, taskRunId=${pending.taskRunId ?? "none"}`, - ); - handleOpenTask(pending.taskId, pending.taskRunId); - } - } catch (error) { - log.error("Failed to check for pending deep link:", error); - } - }; - - fetchPending(); - }, [isAuthenticated, handleOpenTask]); - - // Subscribe to deep link events (for warm start via deep link) - useSubscription( - trpcReact.deepLink.onOpenTask.subscriptionOptions(undefined, { - onData: (data) => { - log.info( - `Received deep link event: taskId=${data.taskId}, taskRunId=${data.taskRunId ?? "none"}`, - ); - if (!data?.taskId) return; - handleOpenTask(data.taskId, data.taskRunId); - }, - }), - ); -} diff --git a/apps/code/src/renderer/main.tsx b/apps/code/src/renderer/main.tsx index 05caaf5565..b2e060454b 100644 --- a/apps/code/src/renderer/main.tsx +++ b/apps/code/src/renderer/main.tsx @@ -1,14 +1,22 @@ import "reflect-metadata"; +// Side effect: registers the host (electron-trpc-backed) storage with @posthog/ui +// before any persisted store hydrates. +import "@utils/electronStorage"; +import "@renderer/platform-adapters/connectivity"; +// 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 { startWorkbench } from "@posthog/di/contribution"; +import { ServiceProvider } from "@posthog/di/react"; import App from "@renderer/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"; @@ -65,7 +73,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/archive.ts b/apps/code/src/renderer/platform-adapters/archive.ts new file mode 100644 index 0000000000..34952801c4 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/archive.ts @@ -0,0 +1,11 @@ +import type { ArchiveClient } from "@posthog/core/archive/identifiers"; +import type { HostTrpcClient } from "@posthog/host-router/client"; + +export function createArchiveClient(host: HostTrpcClient): ArchiveClient { + return { + unarchive: (input) => host.archive.unarchive.mutate(input), + delete: (input) => host.archive.delete.mutate(input), + showArchivedTaskContextMenu: (input) => + host.contextMenu.showArchivedTaskContextMenu.mutate(input), + }; +} 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..ea18605aba --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/auth-side-effects.ts @@ -0,0 +1,46 @@ +import { + clearAuthScopedQueries, + refreshAuthStateQuery, +} from "@features/auth/hooks/authQueries"; +import { resetSessionService } from "@features/sessions/service/service"; +import type { CloudRegion } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +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 { track } from "@utils/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/billing-client.ts b/apps/code/src/renderer/platform-adapters/billing-client.ts new file mode 100644 index 0000000000..31a658a034 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/billing-client.ts @@ -0,0 +1,53 @@ +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import type { + SeatClient, + SubscriptionEventProps, +} from "@posthog/core/billing/identifiers"; +import type { SeatData } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { trpcClient } from "@renderer/trpc"; +import { track } from "@utils/analytics"; +import { queryClient } from "@utils/queryClient"; + +async function authedClient() { + const client = await getAuthenticatedClient(); + if (!client) { + throw new Error("Not authenticated"); + } + return client; +} + +export class RendererSeatClient implements SeatClient { + async getMySeat(options?: { best?: boolean }): Promise { + return (await authedClient()).getMySeat(options); + } + + async createSeat(planKey: string): Promise { + return (await authedClient()).createSeat(planKey); + } + + async upgradeSeat(planKey: string): Promise { + return (await authedClient()).upgradeSeat(planKey); + } + + async cancelSeat(): Promise { + await (await authedClient()).cancelSeat(); + } + + async reactivateSeat(): Promise { + return (await authedClient()).reactivateSeat(); + } + + invalidatePlanCache(): void { + trpcClient.llmGateway.invalidatePlanCache.mutate().catch(() => {}); + void 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/apps/code/src/renderer/platform-adapters/code-review-workspace-client.ts b/apps/code/src/renderer/platform-adapters/code-review-workspace-client.ts new file mode 100644 index 0000000000..8a9f79a6bb --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/code-review-workspace-client.ts @@ -0,0 +1,25 @@ +import type { CodeReviewWorkspaceClient } from "@posthog/core/code-review/revertHunkService"; +import { trpcClient } from "@renderer/trpc"; + +export class RendererCodeReviewWorkspaceClient + implements CodeReviewWorkspaceClient +{ + getFileAtHead( + directoryPath: string, + filePath: string, + ): Promise { + return trpcClient.git.getFileAtHead.query({ directoryPath, filePath }); + } + + readRepoFile(repoPath: string, filePath: string): Promise { + return trpcClient.fs.readRepoFile.query({ repoPath, filePath }); + } + + async writeRepoFile( + repoPath: string, + filePath: string, + content: string, + ): Promise { + await trpcClient.fs.writeRepoFile.mutate({ repoPath, filePath, content }); + } +} diff --git a/apps/code/src/renderer/platform-adapters/connectivity.ts b/apps/code/src/renderer/platform-adapters/connectivity.ts new file mode 100644 index 0000000000..a1e95b3b1b --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/connectivity.ts @@ -0,0 +1,16 @@ +import { connectivityStore } from "@posthog/core/connectivity/connectivityStore"; +import { initializeConnectivityToast } from "@posthog/ui/features/connectivity/connectivityToast"; +import { trpcClient } from "@renderer/trpc/client"; + +const { setOnline } = connectivityStore.getState(); + +void trpcClient.connectivity.getStatus + .query() + .then((status) => setOnline(status.isOnline)) + .catch(() => undefined); + +trpcClient.connectivity.onStatusChange.subscribe(undefined, { + onData: (status) => setOnline(status.isOnline), +}); + +initializeConnectivityToast(); diff --git a/apps/code/src/renderer/platform-adapters/external-apps-client.ts b/apps/code/src/renderer/platform-adapters/external-apps-client.ts new file mode 100644 index 0000000000..8bfe951e42 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/external-apps-client.ts @@ -0,0 +1,24 @@ +import type { + ExternalAppsDetectedApp, + ExternalAppsOpenResult, + ExternalAppsWorkspaceClient, +} from "@posthog/core/external-apps/identifiers"; +import { hostTrpcClient } from "@renderer/trpc/client"; + +export const externalAppsWorkspaceClient: ExternalAppsWorkspaceClient = { + openInApp( + appId: string, + targetPath: string, + ): Promise { + return hostTrpcClient.externalApps.openInApp.mutate({ appId, targetPath }); + }, + async setLastUsed(appId: string): Promise { + await hostTrpcClient.externalApps.setLastUsed.mutate({ appId }); + }, + async getDetectedApps(): Promise { + return hostTrpcClient.externalApps.getDetectedApps.query(); + }, + async copyPath(targetPath: string): Promise { + await hostTrpcClient.externalApps.copyPath.mutate({ targetPath }); + }, +}; diff --git a/apps/code/src/renderer/platform-adapters/feature-flags.ts b/apps/code/src/renderer/platform-adapters/feature-flags.ts new file mode 100644 index 0000000000..5dff1727dd --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/feature-flags.ts @@ -0,0 +1,14 @@ +import type { FeatureFlags } from "@posthog/ui/features/feature-flags/identifiers"; +import { isFeatureFlagEnabled, onFeatureFlagsLoaded } from "@utils/analytics"; +import { injectable } from "inversify"; + +@injectable() +export class RendererFeatureFlags implements FeatureFlags { + isEnabled(flagKey: string): boolean { + return isFeatureFlagEnabled(flagKey); + } + + onFlagsLoaded(handler: () => void): () => void { + return onFeatureFlagsLoaded(handler); + } +} diff --git a/apps/code/src/renderer/platform-adapters/file-watcher-control.ts b/apps/code/src/renderer/platform-adapters/file-watcher-control.ts new file mode 100644 index 0000000000..0f3ff9c259 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/file-watcher-control.ts @@ -0,0 +1,18 @@ +import type { FileWatcherClient } from "@posthog/ui/features/file-watcher/identifiers"; +import { trpcClient } from "@renderer/trpc/client"; +import { injectable } from "inversify"; + +/** + * Desktop adapter for the repo file-watcher control. Wraps the main-process + * trpc client (fileWatcher.start / fileWatcher.stop). + */ +@injectable() +export class TrpcFileWatcherControl implements FileWatcherClient { + async start(repoPath: string): Promise { + await trpcClient.fileWatcher.start.mutate({ repoPath }); + } + + async stop(repoPath: string): Promise { + await trpcClient.fileWatcher.stop.mutate({ repoPath }); + } +} 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/git-interaction.ts b/apps/code/src/renderer/platform-adapters/git-interaction.ts new file mode 100644 index 0000000000..b57b6a96f2 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/git-interaction.ts @@ -0,0 +1,92 @@ +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import type { + GitInteractionEffects, + IGitWriteClient, +} from "@posthog/core/git-interaction/gitInteractionService"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +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 { openExternalUrl } from "@posthog/ui/workbench/openExternal"; +import { hostTrpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; + +const log = logger.scope("git-interaction"); + +export const gitWriteClient: IGitWriteClient = { + commit: (input) => hostTrpcClient.git.commit.mutate(input), + push: (directoryPath, signal) => + hostTrpcClient.git.push.mutate({ directoryPath }, { signal }), + sync: (directoryPath, signal) => + hostTrpcClient.git.sync.mutate({ directoryPath }, { signal }), + publish: (directoryPath, signal) => + hostTrpcClient.git.publish.mutate({ directoryPath }, { signal }), + createBranch: async (directoryPath, branchName) => { + await hostTrpcClient.git.createBranch.mutate({ directoryPath, branchName }); + }, + createPr: (input) => hostTrpcClient.git.createPr.mutate(input), + openPr: (directoryPath) => + hostTrpcClient.git.openPr.mutate({ directoryPath }), + generateCommitMessage: (input) => + hostTrpcClient.git.generateCommitMessage.mutate(input), + generatePrTitleAndBody: (input) => + hostTrpcClient.git.generatePrTitleAndBody.mutate(input), + linkBranch: async (taskId, branchName) => { + await hostTrpcClient.workspace.linkBranch.mutate({ taskId, branchName }); + }, + onCreatePrProgress: (flowId, onStep) => { + const subscription = hostTrpcClient.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/apps/code/src/renderer/platform-adapters/github-connect-client.ts b/apps/code/src/renderer/platform-adapters/github-connect-client.ts new file mode 100644 index 0000000000..ce557cc589 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/github-connect-client.ts @@ -0,0 +1,18 @@ +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import type { GithubConnectClient } from "@posthog/core/onboarding/identifiers"; + +async function authedClient() { + const client = await getAuthenticatedClient(); + if (!client) { + throw new Error("Not authenticated"); + } + return client; +} + +export class RendererGithubConnectClient implements GithubConnectClient { + async disconnectGithubUserIntegration(installationId: string): Promise { + await (await authedClient()).disconnectGithubUserIntegration( + installationId, + ); + } +} diff --git a/apps/code/src/renderer/platform-adapters/github-issue-client.ts b/apps/code/src/renderer/platform-adapters/github-issue-client.ts new file mode 100644 index 0000000000..f7cf8a496d --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/github-issue-client.ts @@ -0,0 +1,12 @@ +import type { GitHubIssueClient } from "@posthog/core/deep-links/identifiers"; +import { trpcClient } from "@renderer/trpc/client"; + +export const githubIssueClient: GitHubIssueClient = { + getGithubIssue(owner, repo, issueNumber) { + return trpcClient.git.getGithubIssue.query({ + owner, + repo, + number: issueNumber, + }); + }, +}; 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/integrations-client.ts b/apps/code/src/renderer/platform-adapters/integrations-client.ts new file mode 100644 index 0000000000..7bbd8bf1d1 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/integrations-client.ts @@ -0,0 +1,51 @@ +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import type { + GithubConnectClient, + RepositoriesClient, + TeamFlowResult, +} from "@posthog/core/integrations/identifiers"; +import type { CloudRegion } from "@posthog/core/integrations/schemas"; +import { trpcClient } from "@renderer/trpc"; +import { hostTrpcClient } from "@renderer/trpc/client"; + +async function authedClient() { + const client = await getAuthenticatedClient(); + if (!client) { + throw new Error("Not authenticated"); + } + return client; +} + +export class RendererRepositoriesClient implements RepositoriesClient { + async refreshTeamRepository(integrationId: number): Promise { + return (await authedClient()).refreshGithubRepositories(integrationId); + } + + async refreshUserRepository(installationId: string): Promise { + return (await authedClient()).refreshGithubUserRepositories(installationId); + } +} + +export class RendererGithubConnectClient implements GithubConnectClient { + async startUserConnect(projectId: number): Promise<{ install_url: string }> { + return (await authedClient()).startGithubUserIntegrationConnect(projectId); + } + + async launchUrl(url: string): Promise { + try { + await trpcClient.os.openExternal.mutate({ url }); + } catch { + window.open(url, "_blank", "noopener,noreferrer"); + } + } + + async startTeamFlow(input: { + region: string; + projectId: number; + }): Promise { + return hostTrpcClient.githubIntegration.startFlow.mutate({ + region: input.region as CloudRegion, + projectId: input.projectId, + }); + } +} diff --git a/apps/code/src/renderer/platform-adapters/linear-oauth-flow.ts b/apps/code/src/renderer/platform-adapters/linear-oauth-flow.ts new file mode 100644 index 0000000000..259a8163d8 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/linear-oauth-flow.ts @@ -0,0 +1,12 @@ +import type { LinearOAuthFlow } from "@posthog/core/inbox/identifiers"; +import type { CloudRegion } from "@posthog/shared"; +import { hostTrpcClient } from "@renderer/trpc/client"; + +export class RendererLinearOAuthFlow implements LinearOAuthFlow { + async startFlow(region: string, projectId: number): Promise { + await hostTrpcClient.linearIntegration.startFlow.mutate({ + region: region as CloudRegion, + projectId, + }); + } +} diff --git a/apps/code/src/renderer/platform-adapters/llm-gateway-client.ts b/apps/code/src/renderer/platform-adapters/llm-gateway-client.ts new file mode 100644 index 0000000000..57cf1681f3 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/llm-gateway-client.ts @@ -0,0 +1,23 @@ +import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway"; +import type { + LlmMessage, + PromptOutput, +} from "@posthog/core/llm-gateway/schemas"; +import { hostTrpcClient } from "@renderer/trpc/client"; + +class RendererLlmGateway { + prompt( + messages: LlmMessage[], + options: { system?: string; maxTokens?: number; model?: string } = {}, + ): Promise { + return hostTrpcClient.llmGateway.prompt.mutate({ + messages, + system: options.system, + maxTokens: options.maxTokens, + model: options.model, + }); + } +} + +export const rendererLlmGateway = + new RendererLlmGateway() as unknown as LlmGatewayService; diff --git a/apps/code/src/renderer/platform-adapters/local-handoff-host.ts b/apps/code/src/renderer/platform-adapters/local-handoff-host.ts new file mode 100644 index 0000000000..3e8157b33a --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/local-handoff-host.ts @@ -0,0 +1,14 @@ +import type { LocalHandoffHost } from "@posthog/ui/features/sessions/identifiers"; +import { trpcClient } from "@renderer/trpc/client"; + +export const localHandoffHost: LocalHandoffHost = { + getRepositoryByRemoteUrl(input) { + return trpcClient.folders.getRepositoryByRemoteUrl.query(input); + }, + selectDirectory() { + return trpcClient.os.selectDirectory.query(); + }, + addFolder(input) { + return trpcClient.folders.addFolder.mutate(input); + }, +}; diff --git a/apps/code/src/renderer/platform-adapters/message-editor-host.ts b/apps/code/src/renderer/platform-adapters/message-editor-host.ts new file mode 100644 index 0000000000..8eca789ddb --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/message-editor-host.ts @@ -0,0 +1,55 @@ +import { fetchRepoFiles } from "@hooks/useRepoFiles"; +import type { MessageEditorHost } from "@posthog/ui/features/message-editor/identifiers"; +import { trpc, trpcClient } from "@renderer/trpc/client"; +import { queryClient } from "@utils/queryClient"; + +export const messageEditorHost: MessageEditorHost = { + searchGithubRefs(input) { + return queryClient.fetchQuery({ + ...trpc.git.searchGithubRefs.queryOptions(input), + staleTime: 30_000, + }); + }, + fetchRepoFiles(repoPath, options) { + return fetchRepoFiles(repoPath, options); + }, + readAbsoluteFile(input) { + return trpcClient.fs.readAbsoluteFile.query(input); + }, + selectDirectory() { + return trpcClient.os.selectDirectory.query(); + }, + saveClipboardImage(input) { + return trpcClient.os.saveClipboardImage.mutate(input); + }, + saveClipboardText(input) { + return trpcClient.os.saveClipboardText.mutate(input); + }, + saveClipboardFile(input) { + return trpcClient.os.saveClipboardFile.mutate(input); + }, + downscaleImageFile(input) { + return trpcClient.os.downscaleImageFile.mutate(input); + }, + getGithubPullRequest(input) { + return queryClient.fetchQuery({ + ...trpc.git.getGithubPullRequest.queryOptions(input), + staleTime: 60_000, + }); + }, + getGithubIssue(input) { + return queryClient.fetchQuery({ + ...trpc.git.getGithubIssue.queryOptions(input), + staleTime: 60_000, + }); + }, + getGhStatus() { + return trpcClient.git.getGhStatus.query(); + }, + selectAttachments(input) { + return trpcClient.os.selectAttachments.query(input); + }, + readFileAsDataUrl(input) { + return trpcClient.os.readFileAsDataUrl.query(input); + }, +}; diff --git a/apps/code/src/renderer/platform-adapters/navigation-task-binder.ts b/apps/code/src/renderer/platform-adapters/navigation-task-binder.ts new file mode 100644 index 0000000000..f55d7ce423 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/navigation-task-binder.ts @@ -0,0 +1,72 @@ +import { foldersApi } from "@features/folders/hooks/useFolders"; +import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; +import { getTaskDirectory } from "@hooks/useRepositoryDirectory"; +import { getTaskRepository } from "@posthog/shared"; +import type { + EnsureWorkspaceResult, + NavigationTaskBinder, +} from "@posthog/ui/features/navigation/taskBinder"; +import type { Task } from "@posthog/shared/domain-types"; +import { logger } from "@utils/logger"; + +const log = logger.scope("navigation-store"); + +// PORT NOTE: bridge for @posthog/ui navigation store's task-open side effect. +// The store owns pure navigation/history; this host adapter owns the +// cross-feature workspace/folder auto-registration that used to live inline in +// navigateToTask (a forbidden store-owned multi-step flow). Retire when this +// orchestration moves into a main/core service emitting events. +export const navigationTaskBinder: NavigationTaskBinder = { + async ensureWorkspaceForTask( + task: Task, + ): Promise { + 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, + }); + return { staleFolderId: folder.id }; + } + + 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", + }); + } + }, +}; diff --git a/apps/code/src/renderer/platform-adapters/notifications.ts b/apps/code/src/renderer/platform-adapters/notifications.ts new file mode 100644 index 0000000000..83d5edbbb5 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/notifications.ts @@ -0,0 +1,30 @@ +import type { + INotifications, + NotificationOptions, +} from "@posthog/platform/notifications"; +import { trpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; +import { injectable } from "inversify"; + +const log = logger.scope("notifications-adapter"); + +@injectable() +export class TrpcNotificationsService implements INotifications { + notify(options: NotificationOptions): void { + trpcClient.notification.send.mutate(options).catch((err) => { + log.error("Failed to send notification", err); + }); + } + + showUnreadIndicator(): void { + trpcClient.notification.showDockBadge.mutate().catch((err) => { + log.error("Failed to show unread indicator", err); + }); + } + + requestAttention(): void { + trpcClient.notification.bounceDock.mutate().catch((err) => { + log.error("Failed to request attention", err); + }); + } +} diff --git a/apps/code/src/renderer/platform-adapters/report-model-resolver.ts b/apps/code/src/renderer/platform-adapters/report-model-resolver.ts new file mode 100644 index 0000000000..09357d950f --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/report-model-resolver.ts @@ -0,0 +1,24 @@ +import type { ReportModelResolver } from "@posthog/core/inbox/identifiers"; +import { selectModelFromOptions } from "@posthog/core/inbox/reportTaskCreation"; +import { hostTrpcClient } from "@renderer/trpc/client"; +import { logger } from "@utils/logger"; + +const log = logger.scope("report-model-resolver"); + +export class RendererReportModelResolver implements ReportModelResolver { + async resolveDefaultModel( + apiHost: string, + adapter: "claude" | "codex", + ): Promise { + try { + const options = await hostTrpcClient.agent.getPreviewConfigOptions.query({ + apiHost, + adapter, + }); + return selectModelFromOptions(options); + } catch (error) { + log.warn("Failed to resolve default model", { error, adapter }); + return undefined; + } + } +} diff --git a/apps/code/src/renderer/platform-adapters/setup-run-service.ts b/apps/code/src/renderer/platform-adapters/setup-run-service.ts new file mode 100644 index 0000000000..661a0ea008 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/setup-run-service.ts @@ -0,0 +1,191 @@ +import { createAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { fetchAuthState } from "@features/auth/hooks/authQueries"; +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 { getCloudUrlFromRegion } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { + isTerminalStatus, + type TaskRunStatus, +} from "@posthog/shared/domain-types"; +import { trpcClient } from "@renderer/trpc/client"; +import { EXPERIMENT_SUGGESTIONS_FLAG } from "@shared/constants"; +import { + captureException, + isFeatureFlagEnabled, + track, +} from "@utils/analytics"; +import { injectable } from "inversify"; + +/** + * Desktop adapter for the setup discovery/enrichment orchestration. Wraps + * trpc (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. + */ +@injectable() +export class RendererSetupRunService implements ISetupRunService { + private client: PostHogAPIClient | null = null; + + 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; + }): 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; + }): Promise { + await trpcClient.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 { + await trpcClient.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 trpcClient.agent.onSessionEvent.subscribe( + { taskRunId: input.taskRunId }, + { onData: handlers.onData, onError: handlers.onError }, + ); + } + + async detectPosthogInstallState( + repoPath: string, + ): Promise<"initialized" | "not_installed" | "installed_no_init"> { + return trpcClient.enrichment.detectPosthogInstallState.query({ repoPath }); + } + + async findStaleFlagSuggestions( + repoPath: string, + ): Promise { + return trpcClient.enrichment.findStaleFlagSuggestions.query({ repoPath }); + } + + includeExperiments(): boolean { + return ( + isFeatureFlagEnabled(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/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-creation-host.ts b/apps/code/src/renderer/platform-adapters/task-creation-host.ts new file mode 100644 index 0000000000..68ff760ce0 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/task-creation-host.ts @@ -0,0 +1,116 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; +import { getTaskDirectory } from "@hooks/useRepositoryDirectory"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +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 { + CloudPromptTransport, + CreatedWorkspaceInfo, + CreateWorkspaceArgs, + DetectedRepo, + ITaskCreationHost, + SetupActionDispatch, + TaskEnvironment, + TaskFolderInfo, +} from "@posthog/core/task-detail/taskCreationHost"; +import { resolveService } from "@posthog/di/container"; +import type { Workspace } from "@posthog/shared"; +import { DEFAULT_PANEL_IDS } from "@posthog/ui/features/panels/panelConstants"; +import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; +import { useProvisioningStore } from "@posthog/ui/features/provisioning/store"; +import { trpcClient } from "@renderer/trpc/client"; +import { injectable } from "inversify"; + +@injectable() +export class TrpcTaskCreationHost implements ITaskCreationHost { + getAuthenticatedClient(): Promise { + return getAuthenticatedClient(); + } + + getTaskDirectory(taskId: string, repoKey?: string): Promise { + return getTaskDirectory(taskId, repoKey); + } + + getWorkspace(taskId: string): Promise { + return workspaceApi.get(taskId); + } + + createWorkspace(args: CreateWorkspaceArgs): Promise { + return trpcClient.workspace.create.mutate(args); + } + + async deleteWorkspace(args: { + taskId: string; + mainRepoPath: string; + }): Promise { + await trpcClient.workspace.delete.mutate(args); + } + + getFolders(): Promise { + return trpcClient.folders.getFolders.query(); + } + + addFolder(args: { folderPath: string }): Promise { + return trpcClient.folders.addFolder.mutate(args); + } + + getEnvironment(args: { + repoPath: string; + id: string; + }): Promise { + return trpcClient.environment.get.query(args); + } + + detectRepo(args: { directoryPath: string }): Promise { + return trpcClient.git.detectRepo.query(args); + } + + getCloudPromptTransport( + prompt: string | ContentBlock[], + filePaths?: string[], + ): CloudPromptTransport { + return getCloudPromptTransport(prompt, filePaths); + } + + uploadRunAttachments( + client: PostHogAPIClient, + taskId: string, + runId: string, + filePaths: string[], + ): Promise { + return resolveService( + 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/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/task-detail.ts b/apps/code/src/renderer/platform-adapters/task-detail.ts new file mode 100644 index 0000000000..13f5acbd44 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/task-detail.ts @@ -0,0 +1,45 @@ +import type { TaskCreationEffects } from "@posthog/core/task-detail/taskCreationEffects"; +import type { + TaskCreationInput, + TaskCreationOutput, + Workspace, +} from "@posthog/shared"; +import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { WORKSPACE_QUERY_KEY } from "@posthog/ui/features/workspace/identifiers"; +import { queryClient } from "@renderer/utils/queryClient"; + +export const taskCreationEffects: TaskCreationEffects = { + onWorkspaceCreated(output: TaskCreationOutput): void { + if (!output.workspace) return; + const workspace = output.workspace; + queryClient.setQueriesData>( + { queryKey: WORKSPACE_QUERY_KEY }, + (old) => ({ ...old, [output.task.id]: workspace }), + ); + void queryClient.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/apps/code/src/renderer/platform-adapters/terminal.ts b/apps/code/src/renderer/platform-adapters/terminal.ts new file mode 100644 index 0000000000..7a1281f96b --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/terminal.ts @@ -0,0 +1,7 @@ +import type { ShellProcessReader } from "@posthog/core/terminal/identifiers"; +import { trpcClient } from "@renderer/trpc/client"; + +export const shellProcessReader: ShellProcessReader = { + getProcess: async (input) => + (await trpcClient.shell.getProcess.query(input)) ?? null, +}; 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/platform-adapters/workspace-setup.ts b/apps/code/src/renderer/platform-adapters/workspace-setup.ts new file mode 100644 index 0000000000..c6ee5c2d98 --- /dev/null +++ b/apps/code/src/renderer/platform-adapters/workspace-setup.ts @@ -0,0 +1,16 @@ +import type { WorkspaceSetupGitClient } from "@posthog/core/workspace/identifiers"; +import type { DetectedRepoFullName } from "@posthog/core/workspace/repoMismatch"; +import { logger } from "@posthog/ui/workbench/logger"; +import { trpcClient } from "@renderer/trpc/client"; +import { injectable } from "inversify"; + +@injectable() +export class TrpcWorkspaceSetupGitClient implements WorkspaceSetupGitClient { + detectRepo(args: { + directoryPath: string; + }): Promise { + return trpcClient.git.detectRepo.query(args); + } +} + +export const workspaceSetupLogger = logger.scope("workspace-setup-service"); diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/apps/code/src/renderer/sagas/task/task-creation.test.ts deleted file mode 100644 index d31e22c147..0000000000 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -import type { Task, TaskRun } from "@shared/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 }, - }, - }, -})); - -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - fs: { - readAbsoluteFile: { query: vi.fn() }, - readFileAsBase64: { query: mockReadFileAsBase64 }, - }, - }, -})); - -vi.mock("@hooks/useRepositoryDirectory", () => ({ - getTaskDirectory: mockGetTaskDirectory, -})); - -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 createTask = (overrides: Partial = {}): Task => ({ - id: "task-123", - task_number: 1, - slug: "task-123", - title: "Test task", - description: "Ship the fix", - origin_product: "user_created", - repository: "posthog/posthog", - created_at: "2026-04-03T00:00:00Z", - updated_at: "2026-04-03T00:00:00Z", - ...overrides, -}); - -const createRun = (overrides: Partial = {}): TaskRun => ({ - id: "run-123", - task: "task-123", - team: 1, - branch: "release/remembered-branch", - environment: "cloud", - status: "queued", - log_url: "https://example.com/logs/run-123", - error_message: null, - output: null, - state: {}, - created_at: "2026-04-03T00:00:00Z", - updated_at: "2026-04-03T00:00:00Z", - completed_at: null, - ...overrides, -}); - -describe("TaskCreationSaga", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockWorkspaceCreate.mockResolvedValue(undefined); - mockWorkspaceDelete.mockResolvedValue(undefined); - mockGetTaskDirectory.mockResolvedValue(null); - mockReadFileAsBase64.mockResolvedValue(null); - }); - - it("waits for the cloud run response before surfacing the task", async () => { - const createdTask = createTask(); - const startedTask = createTask({ latest_run: createRun() }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - const sendRunCommandMock = vi.fn(); - const onTaskReady = vi.fn(); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: sendRunCommandMock, - updateTask: vi.fn(), - } as never, - onTaskReady, - }); - - const result = await saga.run({ - content: "Ship the fix", - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "release/remembered-branch", - adapter: "codex", - model: "gpt-5.4", - reasoningLevel: "high", - }); - - expect(result.success).toBe(true); - if (!result.success) { - throw new Error("Expected task creation to succeed"); - } - - expect(createTaskRunMock).toHaveBeenCalledWith("task-123", { - environment: "cloud", - mode: "interactive", - branch: "release/remembered-branch", - adapter: "codex", - model: "gpt-5.4", - reasoningLevel: "high", - sandboxEnvironmentId: undefined, - prAuthorshipMode: "user", - runSource: "manual", - signalReportId: undefined, - initialPermissionMode: "auto", - }); - expect(startTaskRunMock).toHaveBeenCalledWith("task-123", "run-123", { - pendingUserMessage: "Ship the fix", - pendingUserArtifactIds: undefined, - }); - expect(sendRunCommandMock).not.toHaveBeenCalled(); - expect(onTaskReady).toHaveBeenCalledTimes(1); - expect(onTaskReady.mock.calls[0][0].task.latest_run?.branch).toBe( - "release/remembered-branch", - ); - expect(result.data.task.latest_run?.branch).toBe( - "release/remembered-branch", - ); - expect(startTaskRunMock.mock.invocationCallOrder[0]).toBeLessThan( - onTaskReady.mock.invocationCallOrder[0], - ); - }); - - it("uploads initial cloud attachments before starting the run", async () => { - const createdTask = createTask(); - const startedTask = createTask({ latest_run: createRun() }); - 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)); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - prepareTaskRunArtifactUploads: prepareTaskRunArtifactUploadsMock, - finalizeTaskRunArtifactUploads: finalizeTaskRunArtifactUploadsMock, - sendRunCommand: sendRunCommandMock, - updateTask: vi.fn(), - } as never, - onTaskReady, - }); - - const result = await saga.run({ - content: 'read this file ', - taskDescription: "read this file\n\nAttached files: test.txt", - filePaths: ["/tmp/test.txt"], - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "release/remembered-branch", - adapter: "codex", - model: "gpt-5.4", - reasoningLevel: "medium", - }); - - expect(result.success).toBe(true); - if (!result.success) { - throw new Error("Expected task creation to succeed"); - } - - expect(createTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - description: "read this file\n\nAttached files: test.txt", - }), - ); - expect(createTaskRunMock).toHaveBeenCalledWith("task-123", { - environment: "cloud", - mode: "interactive", - branch: "release/remembered-branch", - adapter: "codex", - model: "gpt-5.4", - reasoningLevel: "medium", - sandboxEnvironmentId: undefined, - prAuthorshipMode: "user", - runSource: "manual", - signalReportId: undefined, - initialPermissionMode: "auto", - }); - 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], - ); - expect( - prepareTaskRunArtifactUploadsMock.mock.invocationCallOrder[0], - ).toBeLessThan(startTaskRunMock.mock.invocationCallOrder[0]); - expect(startTaskRunMock.mock.invocationCallOrder[0]).toBeLessThan( - onTaskReady.mock.invocationCallOrder[0], - ); - }); - - it("uses the selected user GitHub integration for cloud task creation", async () => { - const createdTask = createTask({ - github_user_integration: "user-integration-123", - }); - const startedTask = createTask({ latest_run: createRun() }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - }); - - const result = await saga.run({ - content: "Ship the fix", - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - githubUserIntegrationId: "user-integration-123", - }); - - expect(result.success).toBe(true); - expect(createTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - repository: "posthog/posthog", - github_user_integration: "user-integration-123", - github_integration: undefined, - }), - ); - expect(createTaskRunMock).toHaveBeenCalledWith( - "task-123", - expect.objectContaining({ - prAuthorshipMode: "user", - runSource: "manual", - }), - ); - }); - - it("uses user authorship for signal report cloud task creation", async () => { - const createdTask = createTask({ origin_product: "signal_report" }); - const startedTask = createTask({ - origin_product: "signal_report", - latest_run: createRun(), - }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - }); - - const result = await saga.run({ - content: "Ship the report", - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - cloudRunSource: "signal_report", - signalReportId: "report-123", - githubIntegrationId: 123, - }); - - expect(result.success).toBe(true); - expect(createTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - github_integration: 123, - github_user_integration: undefined, - origin_product: "signal_report", - }), - ); - expect(createTaskRunMock).toHaveBeenCalledWith( - "task-123", - expect.objectContaining({ - prAuthorshipMode: "user", - runSource: "signal_report", - }), - ); - }); - - it("does not prefill a task title from the prompt", async () => { - const createdTask = createTask(); - const startedTask = createTask({ latest_run: createRun() }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - }); - - await saga.run({ - content: "Ship the fix", - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - }); - - expect(createTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - description: "Ship the fix", - }), - ); - expect(createTaskMock.mock.calls[0]?.[0]).not.toHaveProperty("title"); - }); - - it("does not prefill a task title for attachment-only prompts", async () => { - const createdTask = createTask(); - const startedTask = createTask({ latest_run: createRun() }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - }); - - await saga.run({ - taskDescription: '', - repository: "posthog/posthog", - workspaceMode: "cloud", - branch: "main", - }); - - expect(createTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - description: '', - }), - ); - expect(createTaskMock.mock.calls[0]?.[0]).not.toHaveProperty("title"); - }); - - it("uses user authorship for repo-less cloud tasks with a selected user GitHub integration", async () => { - const createdTask = createTask({ - repository: null, - github_user_integration: "user-integration-123", - }); - const startedTask = createTask({ - repository: null, - latest_run: createRun(), - }); - const createTaskMock = vi.fn().mockResolvedValue(createdTask); - const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); - const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); - - const saga = new TaskCreationSaga({ - posthogClient: { - createTask: createTaskMock, - deleteTask: vi.fn(), - getTask: vi.fn(), - createTaskRun: createTaskRunMock, - startTaskRun: startTaskRunMock, - sendRunCommand: vi.fn(), - updateTask: vi.fn(), - } as never, - }); - - const result = await saga.run({ - content: "Clone the private repo", - workspaceMode: "cloud", - branch: "main", - githubUserIntegrationId: "user-integration-123", - }); - - expect(result.success).toBe(true); - expect(createTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - repository: undefined, - github_user_integration: "user-integration-123", - github_integration: undefined, - }), - ); - expect(createTaskRunMock).toHaveBeenCalledWith( - "task-123", - expect.objectContaining({ - prAuthorshipMode: "user", - runSource: "manual", - }), - ); - }); -}); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts deleted file mode 100644 index 3b7ad84d15..0000000000 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ /dev/null @@ -1,432 +0,0 @@ -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 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"; -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; -} - -export interface TaskCreationDeps { - posthogClient: PostHogAPIClient; - onTaskReady?: (output: TaskCreationOutput) => void; -} - -export class TaskCreationSaga extends Saga< - TaskCreationInput, - TaskCreationOutput -> { - readonly sagaName = "TaskCreationSaga"; - - constructor(private deps: TaskCreationDeps) { - super(sagaLogger); - } - - protected async execute( - input: TaskCreationInput, - ): Promise { - // 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 - ? this.resolveFolder(input.repoPath) - : undefined; - - let task = taskId - ? await this.readOnlyStep("fetch_task", () => - this.deps.posthogClient.getTask(taskId), - ) - : await this.createTask(input); - - const repoKey = getTaskRepository(task); - const repoPath = - input.repoPath ?? - (await this.readOnlyStep("resolve_repo_path", () => - 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); - if (this.deps.onTaskReady) { - this.deps.onTaskReady({ task, workspace }); - } - } - - if (repoPath) { - const folder = folderPromise - ? await this.readOnlyStep("folder_registration", () => folderPromise) - : await this.readOnlyStep("folder_registration", () => - this.resolveFolder(repoPath), - ); - - const workspaceInfo = await this.step({ - name: "workspace_creation", - execute: async () => { - return trpcClient.workspace.create.mutate({ - taskId: task.id, - mainRepoPath: repoPath, - folderId: folder.id, - folderPath: repoPath, - mode: workspaceMode, - branch: branch ?? undefined, - }); - }, - rollback: async () => { - log.info("Rolling back: deleting workspace", { taskId: task.id }); - await trpcClient.workspace.delete.mutate({ - taskId: task.id, - mainRepoPath: repoPath, - }); - }, - }); - - workspace = { - taskId: task.id, - folderId: folder.id, - folderPath: repoPath, - mode: workspaceMode, - worktreePath: workspaceInfo.worktree?.worktreePath ?? null, - worktreeName: workspaceInfo.worktree?.worktreeName ?? null, - branchName: workspaceInfo.worktree?.branchName ?? null, - baseBranch: workspaceInfo.worktree?.baseBranch ?? null, - linkedBranch: workspaceInfo.linkedBranch ?? null, - createdAt: - workspaceInfo.worktree?.createdAt ?? new Date().toISOString(), - }; - } else if (workspaceMode === "cloud") { - await this.step({ - name: "cloud_workspace_creation", - execute: async () => { - return trpcClient.workspace.create.mutate({ - taskId: task.id, - mainRepoPath: "", - folderId: "", - folderPath: "", - mode: "cloud", - branch: branch ?? undefined, - }); - }, - rollback: async () => { - log.info("Rolling back: deleting cloud workspace", { - taskId: task.id, - }); - await trpcClient.workspace.delete.mutate({ - taskId: task.id, - mainRepoPath: "", - }); - }, - }); - - workspace = { - taskId: task.id, - folderId: "", - folderPath: "", - mode: "cloud", - worktreePath: null, - worktreeName: null, - branchName: null, - baseBranch: branch, - linkedBranch: null, - createdAt: new Date().toISOString(), - }; - } - - const shouldStartCloudRun = workspaceMode === "cloud" && !task.latest_run; - - if (!hasProvisioning && !shouldStartCloudRun && this.deps.onTaskReady) { - this.deps.onTaskReady({ task, workspace }); - } - - if (hasProvisioning) { - useProvisioningStore.getState().clear(task.id); - } - - if ( - input.environmentId && - workspace?.worktreePath && - repoPath && - !input.taskId - ) { - this.dispatchEnvironmentSetup( - task.id, - input.environmentId, - repoPath, - workspace.worktreePath, - ); - } - - // Step 5: Start cloud run (only for new cloud tasks) - if (shouldStartCloudRun) { - task = await this.step({ - name: "cloud_run", - execute: async () => { - const prAuthorshipMode = input.cloudPrAuthorshipMode ?? "user"; - - const transport = - (input.content || input.filePaths?.length) && - workspaceMode === "cloud" - ? getCloudPromptTransport(input.content ?? "", input.filePaths) - : null; - const taskRun = await this.deps.posthogClient.createTaskRun(task.id, { - environment: "cloud", - mode: "interactive", - branch, - adapter: input.adapter, - model: input.model, - reasoningLevel: input.reasoningLevel, - sandboxEnvironmentId: input.sandboxEnvironmentId, - prAuthorshipMode, - runSource: input.cloudRunSource ?? "manual", - signalReportId: input.signalReportId, - initialPermissionMode: input.adapter - ? (input.executionMode ?? - (input.adapter === "codex" ? "auto" : "plan")) - : input.executionMode, - }); - if (!taskRun?.id) { - throw new Error("Failed to create cloud run"); - } - - const pendingUserArtifactIds = transport - ? await uploadRunAttachments( - this.deps.posthogClient, - task.id, - taskRun.id, - transport.filePaths, - ) - : []; - - return this.deps.posthogClient.startTaskRun(task.id, taskRun.id, { - pendingUserMessage: transport?.messageText, - pendingUserArtifactIds: - pendingUserArtifactIds.length > 0 - ? pendingUserArtifactIds - : undefined, - }); - }, - rollback: async () => { - log.info("Rolling back: cloud run (no-op)", { taskId: task.id }); - }, - }); - - if (!hasProvisioning && this.deps.onTaskReady) { - this.deps.onTaskReady({ task, workspace }); - } - } - - // 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 - - if (shouldConnect) { - const initialPrompt = - !input.taskId && input.content - ? await this.readOnlyStep("build_prompt_blocks", () => - buildPromptBlocks( - input.content ?? "", - input.filePaths ?? [], - agentCwd ?? "", - ), - ) - : undefined; - - 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 ?? "", - }; - if (initialPrompt) connectParams.initialPrompt = initialPrompt; - if (input.executionMode) - connectParams.executionMode = input.executionMode; - if (input.adapter) connectParams.adapter = input.adapter; - if (input.model) connectParams.model = input.model; - if (input.reasoningLevel) - connectParams.reasoningLevel = input.reasoningLevel; - - getSessionService().connectToTask(connectParams); - return { taskId: task.id }; - }, - rollback: async ({ taskId }) => { - log.info("Rolling back: disconnecting agent session", { taskId }); - await getSessionService().disconnectFromTask(taskId); - }, - }); - } - - return { task, workspace }; - } - - private async resolveFolder(repoPath: string) { - const folders = await trpcClient.folders.getFolders.query(); - let existingFolder = folders.find((f) => f.path === repoPath); - - if (!existingFolder) { - existingFolder = await trpcClient.folders.addFolder.mutate({ - folderPath: repoPath, - }); - } - return existingFolder; - } - - private dispatchEnvironmentSetup( - taskId: string, - environmentId: string, - repoPath: string, - worktreePath: string, - ): void { - trpcClient.environment.get - .query({ 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}`, - }); - }) - .catch((error) => { - log.error("Failed to dispatch environment setup script", { - taskId, - environmentId, - error, - }); - }); - } - - private async createTask(input: TaskCreationInput): Promise { - let repository = input.repository; - - const repoPathForDetection = input.repoPath; - if (!repository && repoPathForDetection) { - const detected = await this.readOnlyStep("repo_detection", () => - trpcClient.git.detectRepo.query({ - directoryPath: repoPathForDetection, - }), - ); - if (detected) { - repository = `${detected.organization}/${detected.repository}`; - } - } - - return this.step({ - name: "task_creation", - execute: async () => { - const description = input.taskDescription ?? input.content ?? ""; - const result = await this.deps.posthogClient.createTask({ - description, - repository: repository ?? undefined, - github_integration: - input.workspaceMode === "cloud" && - input.cloudRunSource === "signal_report" - ? input.githubIntegrationId - : undefined, - github_user_integration: - input.workspaceMode === "cloud" && - input.cloudRunSource !== "signal_report" - ? input.githubUserIntegrationId - : undefined, - origin_product: input.signalReportId - ? "signal_report" - : "user_created", - signal_report: input.signalReportId ?? undefined, - signal_report_task_relationship: input.signalReportId - ? SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP - : undefined, - }); - return result as unknown as Task; - }, - rollback: async (createdTask) => { - log.info("Rolling back: deleting task", { taskId: createdTask.id }); - await this.deps.posthogClient.deleteTask(createdTask.id); - }, - }); - } -} 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/navigationStore.test.ts b/apps/code/src/renderer/stores/navigationStore.test.ts deleted file mode 100644 index f1773a568b..0000000000 --- a/apps/code/src/renderer/stores/navigationStore.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { Task } from "@shared/types"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { getItem, setItem } = vi.hoisted(() => ({ - getItem: vi.fn(), - setItem: vi.fn(), -})); - -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - secureStore: { - getItem: { query: getItem }, - setItem: { query: setItem }, - removeItem: { query: vi.fn() }, - }, - }, -})); - -vi.mock("@utils/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), -})); - -import { useNavigationStore } from "./navigationStore"; - -const mockTask: Task = { - id: "task-123", - task_number: 1, - slug: "test-task", - title: "Test task", - description: "Test task description", - origin_product: "twig", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", -}; - -const getStore = () => useNavigationStore.getState(); -const getView = () => getStore().view; - -describe("navigationStore", () => { - beforeEach(() => { - getItem.mockReset(); - setItem.mockReset(); - getItem.mockResolvedValue(null); - setItem.mockResolvedValue(undefined); - useNavigationStore.setState({ - view: { type: "task-input" }, - history: [{ type: "task-input" }], - historyIndex: 0, - }); - }); - - it("starts with task-input view", () => { - expect(getView().type).toBe("task-input"); - }); - - describe("navigation", () => { - it("navigates to task detail with taskId", async () => { - await getStore().navigateToTask(mockTask); - expect(getView()).toMatchObject({ - type: "task-detail", - data: mockTask, - taskId: "task-123", - }); - }); - - it("navigates to folder settings", () => { - getStore().navigateToFolderSettings("folder-123"); - expect(getView()).toMatchObject({ - type: "folder-settings", - folderId: "folder-123", - }); - }); - - it("navigates to task input with folderId", () => { - getStore().navigateToTaskInput("folder-123"); - expect(getView()).toMatchObject({ - type: "task-input", - folderId: "folder-123", - }); - }); - - it("navigates to task input with report association", () => { - getStore().navigateToTaskInput({ - initialPrompt: "Fix this report", - reportAssociation: { reportId: "report-123", title: "Broken signup" }, - }); - - expect(getView()).toMatchObject({ - type: "task-input", - initialPrompt: "Fix this report", - reportAssociation: { reportId: "report-123", title: "Broken signup" }, - }); - expect(getView().taskInputRequestId).toBeTruthy(); - }); - - it("mints a fresh taskInputRequestId on each navigation with transient state", () => { - getStore().navigateToTaskInput({ - initialPrompt: "Discuss this", - reportAssociation: { reportId: "report-456", title: "Slow checkout" }, - }); - const firstRequestId = getView().taskInputRequestId; - expect(firstRequestId).toBeTruthy(); - - getStore().navigateToInbox(); - getStore().navigateToTaskInput({ - initialPrompt: "Discuss this", - reportAssociation: { reportId: "report-456", title: "Slow checkout" }, - }); - expect(getView().taskInputRequestId).not.toBe(firstRequestId); - }); - - it("clears task input report association", () => { - getStore().navigateToTaskInput({ - initialPrompt: "Fix this report", - initialCloudRepository: "posthog/code", - reportAssociation: { reportId: "report-123", title: "Broken signup" }, - }); - - getStore().clearTaskInputReportAssociation(); - - expect(getView().reportAssociation).toBeUndefined(); - expect(getView().initialCloudRepository).toBeUndefined(); - expect( - getStore().history[getStore().historyIndex].reportAssociation, - ).toBeUndefined(); - expect( - getStore().history[getStore().historyIndex].initialCloudRepository, - ).toBeUndefined(); - expect(getStore().taskInputReportAssociation).toBeUndefined(); - }); - - it("clears cloud-only task input state without report association", () => { - getStore().navigateToTaskInput({ - initialCloudRepository: "posthog/code", - }); - - getStore().clearTaskInputReportAssociation(); - - expect(getView().initialCloudRepository).toBeUndefined(); - expect(getStore().taskInputCloudRepository).toBeUndefined(); - expect( - getStore().history[getStore().historyIndex].initialCloudRepository, - ).toBeUndefined(); - }); - - it("clears persisted task input report association after returning to task input", () => { - getStore().navigateToTaskInput({ - initialPrompt: "Fix this report", - initialCloudRepository: "posthog/code", - reportAssociation: { reportId: "report-123", title: "Broken signup" }, - }); - getStore().navigateToInbox(); - getStore().navigateToTaskInput(); - - getStore().clearTaskInputReportAssociation(); - - expect(getStore().taskInputReportAssociation).toBeUndefined(); - expect(getStore().taskInputCloudRepository).toBeUndefined(); - expect(getView().initialCloudRepository).toBeUndefined(); - }); - - it("keeps task input report association after leaving task input", () => { - getStore().navigateToTaskInput({ - initialPrompt: "Fix this report", - initialCloudRepository: "posthog/code", - reportAssociation: { reportId: "report-123", title: "Broken signup" }, - }); - - getStore().navigateToInbox(); - getStore().navigateToTaskInput(); - - expect(getStore().taskInputReportAssociation).toEqual({ - reportId: "report-123", - title: "Broken signup", - }); - expect(getStore().taskInputCloudRepository).toBe("posthog/code"); - }); - - it("navigates to inbox", () => { - getStore().navigateToInbox(); - expect(getView()).toMatchObject({ - type: "inbox", - }); - }); - - it("navigates to pending task with key", () => { - getStore().navigateToPendingTask("pending-key-123"); - expect(getView()).toMatchObject({ - type: "task-pending", - pendingTaskKey: "pending-key-123", - }); - }); - - it("replaces task-pending in history when navigating to real task", async () => { - getStore().navigateToTaskInput(); - getStore().navigateToPendingTask("pending-key-123"); - const indexBeforeReal = getStore().history.length - 1; - expect(getStore().history[indexBeforeReal].type).toBe("task-pending"); - - await getStore().navigateToTask(mockTask); - - const finalHistory = getStore().history; - expect(finalHistory[finalHistory.length - 1].type).toBe("task-detail"); - expect(finalHistory.some((v) => v.type === "task-pending")).toBe(false); - }); - }); - - describe("history", () => { - it("tracks history and supports back/forward", async () => { - await getStore().navigateToTask(mockTask); - getStore().navigateToFolderSettings("folder-123"); - - expect(getStore().history).toHaveLength(3); - expect(getStore().canGoBack()).toBe(true); - - getStore().goBack(); - expect(getView().type).toBe("task-detail"); - - expect(getStore().canGoForward()).toBe(true); - getStore().goForward(); - expect(getView().type).toBe("folder-settings"); - }); - }); - - describe("persistence", () => { - it("persists view type and taskId but not full task data", async () => { - await getStore().navigateToTask(mockTask); - - await vi.waitFor(() => { - expect(setItem).toHaveBeenCalled(); - }); - - const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; - const persisted = JSON.parse(lastCall[0].value); - expect(persisted.state.view).toEqual({ - type: "task-detail", - taskId: "task-123", - folderId: undefined, - }); - }); - - it("restores view from electronStorage without task data", async () => { - const storedState = JSON.stringify({ - state: { - view: { - type: "task-detail", - taskId: "task-123", - folderId: undefined, - }, - }, - version: 0, - }); - - getItem.mockResolvedValue(storedState); - - useNavigationStore.setState({ - view: { type: "task-input" }, - history: [{ type: "task-input" }], - historyIndex: 0, - }); - - await useNavigationStore.persist.rehydrate(); - - expect(getView()).toMatchObject({ - type: "task-detail", - taskId: "task-123", - }); - expect(getView().data).toBeUndefined(); - }); - }); -}); diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts deleted file mode 100644 index 3bfb98fb3b..0000000000 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ /dev/null @@ -1,376 +0,0 @@ -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 { create } from "zustand"; -import { persist } from "zustand/middleware"; - -const log = logger.scope("navigation-store"); - -type ViewType = - | "task-detail" - | "task-pending" - | "task-input" - | "folder-settings" - | "inbox" - | "archived" - | "command-center" - | "skills" - | "mcp-servers"; - -export interface TaskInputReportAssociation { - reportId: string; - title: string; -} - -export interface TaskInputNavigationOptions { - folderId?: string; - initialPrompt?: string; - initialCloudRepository?: string; - initialModel?: string; - initialMode?: string; - reportAssociation?: TaskInputReportAssociation; -} - -interface ViewState { - type: ViewType; - data?: Task; - taskId?: string; - folderId?: string; - taskInputRequestId?: string; - initialPrompt?: string; - initialCloudRepository?: string; - initialModel?: string; - initialMode?: string; - reportAssociation?: TaskInputReportAssociation; - pendingTaskKey?: string; -} - -interface NavigationStore { - view: ViewState; - history: ViewState[]; - historyIndex: number; - taskInputReportAssociation?: TaskInputReportAssociation; - taskInputCloudRepository?: string; - navigateToTask: (task: Task) => void; - navigateToPendingTask: (pendingTaskKey: string) => void; - navigateToTaskInput: ( - folderIdOrOptions?: string | TaskInputNavigationOptions, - ) => void; - clearTaskInputReportAssociation: () => void; - navigateToFolderSettings: (folderId: string) => void; - navigateToInbox: () => void; - navigateToArchived: () => void; - navigateToCommandCenter: () => void; - navigateToSkills: () => void; - navigateToMcpServers: () => void; - goBack: () => void; - goForward: () => void; - canGoBack: () => boolean; - canGoForward: () => boolean; - hydrateTask: (tasks: Task[]) => void; -} - -const isSameView = (view1: ViewState, view2: ViewState): boolean => { - if (view1.type !== view2.type) return false; - if (view1.type === "task-detail" && view2.type === "task-detail") { - return view1.data?.id === view2.data?.id; - } - if (view1.type === "task-pending" && view2.type === "task-pending") { - return view1.pendingTaskKey === view2.pendingTaskKey; - } - if (view1.type === "task-input" && view2.type === "task-input") { - return ( - view1.folderId === view2.folderId && - view1.taskInputRequestId === view2.taskInputRequestId - ); - } - if (view1.type === "folder-settings" && view2.type === "folder-settings") { - return view1.folderId === view2.folderId; - } - if (view1.type === "inbox" && view2.type === "inbox") { - return true; - } - if (view1.type === "archived" && view2.type === "archived") { - return true; - } - if (view1.type === "command-center" && view2.type === "command-center") { - return true; - } - if (view1.type === "skills" && view2.type === "skills") { - return true; - } - if (view1.type === "mcp-servers" && view2.type === "mcp-servers") { - return true; - } - return false; -}; - -export const useNavigationStore = create()( - persist( - (set, get) => { - const navigate = (newView: ViewState) => { - const { view, history, historyIndex } = get(); - if (isSameView(view, newView)) { - return; - } - // Replace transient task-pending entries instead of stacking them in - // history — going back to a pending view after the real task lands - // would render an empty placeholder. - const baseHistory = - view.type === "task-pending" - ? history.slice(0, historyIndex) - : history.slice(0, historyIndex + 1); - const newHistory = [...baseHistory, newView]; - set({ - view: newView, - history: newHistory, - historyIndex: newHistory.length - 1, - }); - setActiveTaskAnalyticsContext( - newView.type === "task-detail" ? (newView.data ?? null) : null, - ); - }; - - return { - view: { type: "task-input" }, - history: [{ type: "task-input" }], - historyIndex: 0, - taskInputReportAssociation: undefined, - taskInputCloudRepository: undefined, - - navigateToTask: async (task: Task) => { - navigate({ type: "task-detail", data: task, taskId: task.id }); - track(ANALYTICS_EVENTS.TASK_VIEWED, { - 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", - }); - } - }, - - navigateToPendingTask: (pendingTaskKey: string) => { - navigate({ type: "task-pending", pendingTaskKey }); - }, - - navigateToTaskInput: (folderIdOrOptions) => { - const options = - typeof folderIdOrOptions === "string" - ? { folderId: folderIdOrOptions } - : (folderIdOrOptions ?? {}); - const hasTransientState = - !!options.initialPrompt || - !!options.initialCloudRepository || - !!options.initialModel || - !!options.initialMode || - !!options.reportAssociation; - if (options.reportAssociation || options.initialCloudRepository) { - set({ - taskInputReportAssociation: options.reportAssociation, - taskInputCloudRepository: options.initialCloudRepository, - }); - } - navigate({ - type: "task-input", - folderId: options.folderId, - initialPrompt: options.initialPrompt, - initialCloudRepository: options.initialCloudRepository, - initialModel: options.initialModel, - initialMode: options.initialMode, - reportAssociation: options.reportAssociation, - taskInputRequestId: hasTransientState - ? (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`) - : undefined, - }); - }, - - clearTaskInputReportAssociation: () => { - const { - view, - history, - historyIndex, - taskInputReportAssociation, - taskInputCloudRepository, - } = get(); - if ( - !taskInputReportAssociation && - !view.reportAssociation && - !taskInputCloudRepository && - !view.initialCloudRepository - ) { - return; - } - - const updatedView = { - ...view, - reportAssociation: undefined, - initialCloudRepository: undefined, - }; - const updatedHistory = [...history]; - if (updatedHistory[historyIndex]?.type === "task-input") { - updatedHistory[historyIndex] = { - ...updatedHistory[historyIndex], - reportAssociation: undefined, - initialCloudRepository: undefined, - }; - } - - set({ - view: updatedView, - history: updatedHistory, - taskInputReportAssociation: undefined, - taskInputCloudRepository: undefined, - }); - }, - - navigateToFolderSettings: (folderId: string) => { - navigate({ type: "folder-settings", folderId }); - }, - - navigateToInbox: () => { - navigate({ type: "inbox" }); - }, - - navigateToArchived: () => { - navigate({ type: "archived" }); - }, - - navigateToCommandCenter: () => { - navigate({ type: "command-center" }); - track(ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED); - }, - - navigateToSkills: () => { - navigate({ type: "skills" }); - }, - - navigateToMcpServers: () => { - navigate({ type: "mcp-servers" }); - }, - - goBack: () => { - const { history, historyIndex } = get(); - if (historyIndex > 0) { - const newIndex = historyIndex - 1; - const newView = history[newIndex]; - set({ - view: newView, - historyIndex: newIndex, - }); - setActiveTaskAnalyticsContext( - newView.type === "task-detail" ? (newView.data ?? null) : null, - ); - } - }, - - goForward: () => { - const { history, historyIndex } = get(); - if (historyIndex < history.length - 1) { - const newIndex = historyIndex + 1; - const newView = history[newIndex]; - set({ - view: newView, - historyIndex: newIndex, - }); - setActiveTaskAnalyticsContext( - newView.type === "task-detail" ? (newView.data ?? null) : null, - ); - } - }, - - canGoBack: () => { - const { historyIndex } = get(); - return historyIndex > 0; - }, - - canGoForward: () => { - const { history, historyIndex } = get(); - return historyIndex < history.length - 1; - }, - - hydrateTask: (tasks: Task[]) => { - const { view, navigateToTask, navigateToTaskInput } = get(); - if (view.type !== "task-detail" || !view.taskId || view.data) return; - - const task = tasks.find((t) => t.id === view.taskId); - if (task) { - navigateToTask(task); - } else { - navigateToTaskInput(); - } - }, - }; - }, - { - name: "navigation-storage", - storage: electronStorage, - partialize: (state) => ({ - view: - state.view.type === "task-pending" - ? { type: "task-input" as const } - : { - type: state.view.type, - taskId: state.view.taskId, - folderId: state.view.folderId, - }, - }), - }, - ), -); 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..e6fa42b192 100644 --- a/apps/code/src/renderer/trpc/client.ts +++ b/apps/code/src/renderer/trpc/client.ts @@ -1,3 +1,4 @@ +import type { HostRouter } from "@posthog/host-router/router"; import { ipcLink } from "@posthog/electron-trpc/renderer"; import { createTRPCClient } from "@trpc/client"; import { @@ -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/analytics.ts b/apps/code/src/renderer/utils/analytics.ts index d17665203f..31c760e302 100644 --- a/apps/code/src/renderer/utils/analytics.ts +++ b/apps/code/src/renderer/utils/analytics.ts @@ -3,12 +3,12 @@ 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"; +} from "@posthog/shared/analytics-events"; +import type { Task } from "@posthog/shared/domain-types"; +import type { PermissionRequest } from "@posthog/ui/features/sessions/sessionLogTypes"; import { logger } from "./logger"; const log = logger.scope("analytics"); diff --git a/apps/code/src/renderer/utils/browser.ts b/apps/code/src/renderer/utils/browser.ts deleted file mode 100644 index 157a84f928..0000000000 --- a/apps/code/src/renderer/utils/browser.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { trpcClient } from "@renderer/trpc/client"; - -export async function openUrlInBrowser(url: string): Promise { - try { - await trpcClient.os.openExternal.mutate({ url }); - } catch { - window.open(url, "_blank", "noopener,noreferrer"); - } -} 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/dialog.ts b/apps/code/src/renderer/utils/dialog.ts deleted file mode 100644 index c2a906ddc3..0000000000 --- a/apps/code/src/renderer/utils/dialog.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { trpcClient } from "@renderer/trpc"; - -interface MessageBoxOptions { - type?: "none" | "info" | "error" | "question" | "warning"; - title?: string; - message?: string; - detail?: string; - buttons?: string[]; - defaultId?: number; - 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 }); -} 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.test.ts b/apps/code/src/renderer/utils/generateTitle.test.ts deleted file mode 100644 index 6f05822267..0000000000 --- a/apps/code/src/renderer/utils/generateTitle.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockReadAbsoluteFile = vi.hoisted(() => vi.fn()); -const mockLlmPrompt = vi.hoisted(() => 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"; - -describe("enrichDescriptionWithFileContent", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns description unchanged when it contains real text", async () => { - const description = "Fix the login bug"; - const result = await enrichDescriptionWithFileContent(description); - expect(result).toBe(description); - expect(mockReadAbsoluteFile).not.toHaveBeenCalled(); - }); - - it("reads text file content when description only has file tags", async () => { - mockReadAbsoluteFile.mockResolvedValue("const x = 1;\nexport default x;"); - const description = '1. '; - const result = await enrichDescriptionWithFileContent(description); - expect(result).toBe("const x = 1;\nexport default x;"); - expect(mockReadAbsoluteFile).toHaveBeenCalledWith({ - filePath: "/tmp/code.ts", - }); - }); - - it("handles multiple file tags", async () => { - mockReadAbsoluteFile - .mockResolvedValueOnce("file one") - .mockResolvedValueOnce("file two"); - - const description = - '1. \n2. '; - const result = await enrichDescriptionWithFileContent(description); - expect(result).toBe("file one\n\nfile two"); - }); - - it("uses filePaths argument over parsed tags", async () => { - mockReadAbsoluteFile.mockResolvedValue("from explicit path"); - const description = '1. '; - const result = await enrichDescriptionWithFileContent(description, [ - "/tmp/explicit.ts", - ]); - expect(result).toBe("from explicit path"); - expect(mockReadAbsoluteFile).toHaveBeenCalledWith({ - filePath: "/tmp/explicit.ts", - }); - }); - - it.each([ - { - label: "binary file", - description: '1. ', - setup: () => {}, - }, - { - label: "read throws", - description: '1. ', - setup: () => mockReadAbsoluteFile.mockRejectedValue(new Error("ENOENT")), - }, - { - label: "read returns null", - description: '1. ', - setup: () => mockReadAbsoluteFile.mockResolvedValue(null), - }, - ])( - "falls back to filename hint -- $label", - async ({ description, setup }) => { - setup(); - const result = await enrichDescriptionWithFileContent(description); - const filename = description.match(/path="[^"]*\/([^"]+)"/)?.[1]; - expect(result).toBe(`[Attached: ${filename}]`); - }, - ); - - it("truncates content longer than 500 chars", async () => { - const longContent = "x".repeat(600); - mockReadAbsoluteFile.mockResolvedValue(longContent); - const description = '1. '; - const result = await enrichDescriptionWithFileContent(description); - expect(result).toBe("x".repeat(500)); - }); - - it("strips 'Attached files:' lines when checking for real text", async () => { - mockReadAbsoluteFile.mockResolvedValue("content"); - const description = '1. \nAttached files: a.ts'; - const result = await 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); - expect(result).toBe(description); - }); - - it("mixes binary and text files", async () => { - mockReadAbsoluteFile.mockResolvedValue("text content"); - const result = await enrichDescriptionWithFileContent("", [ - "/tmp/image.jpg", - "/tmp/code.ts", - ]); - expect(result).toBe("[Attached: image.jpg]\n\ntext content"); - }); - - it("returns description unchanged for folder-only input", async () => { - const description = ''; - const result = await enrichDescriptionWithFileContent(description); - expect(result).toBe(description); - expect(mockReadAbsoluteFile).not.toHaveBeenCalled(); - }); - - it("reads file and drops folder for mixed file+folder input", async () => { - mockReadAbsoluteFile.mockResolvedValue("file body"); - const description = - ''; - const result = await enrichDescriptionWithFileContent(description); - expect(result).toBe("file body"); - expect(mockReadAbsoluteFile).toHaveBeenCalledTimes(1); - expect(mockReadAbsoluteFile).toHaveBeenCalledWith({ - filePath: "/tmp/a.ts", - }); - }); - - it("treats non-chip XML-like text as real content", async () => { - const description = "
hello world
"; - const result = await enrichDescriptionWithFileContent(description); - expect(result).toBe(description); - expect(mockReadAbsoluteFile).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({ - content: `TITLE: ${longTitle}\nSUMMARY: A summary`, - }); - - const result = await 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({ - content: 'TITLE: "Fix login bug"\nSUMMARY: Fixing auth', - }); - - const result = await 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"); - expect(result).toBeNull(); - }); -}); 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 index f29b278786..5590e39cb0 100644 --- a/apps/code/src/renderer/utils/notifications.ts +++ b/apps/code/src/renderer/utils/notifications.ts @@ -1,117 +1,25 @@ -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); - }); -} +// PORT NOTE: bridge to @posthog/ui TaskNotificationService. The notification +// gating now lives in that package service (injected settings/view/sound ports +// + NOTIFICATIONS_SERVICE adapter). Delete these free functions when the +// sessions service resolves TaskNotificationService via useService directly. +import { TaskNotificationService } from "@posthog/ui/features/notifications/notifications"; +import { container } from "@renderer/di/container"; 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(); - } + container + .get(TaskNotificationService) + .notifyPromptComplete(taskTitle, stopReason, taskId); } 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(); - } + container + .get(TaskNotificationService) + .notifyPermissionRequest(taskTitle, taskId); } 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/promptContent.ts b/apps/code/src/renderer/utils/promptContent.ts deleted file mode 100644 index 66436bcc3a..0000000000 --- a/apps/code/src/renderer/utils/promptContent.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { ContentBlock } from "@agentclientprotocol/sdk"; -import { getFileName } from "@utils/path"; - -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/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/renderer/utils/session.test.ts b/apps/code/src/renderer/utils/session.test.ts deleted file mode 100644 index 8f62f80fa7..0000000000 --- a/apps/code/src/renderer/utils/session.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { ContentBlock } from "@agentclientprotocol/sdk"; -import type { AcpMessage } from "@shared/types/session-events"; -import { describe, expect, it } from "vitest"; - -import { makeAttachmentUri } from "./promptContent"; -import { extractUserPromptsFromEvents, isFatalSessionError } from "./session"; - -describe("isFatalSessionError", () => { - it("detects fatal 'Internal error' pattern", () => { - expect(isFatalSessionError("Internal error: process crashed")).toBe(true); - }); - - it("detects fatal 'process exited' pattern", () => { - expect(isFatalSessionError("process exited with code 1")).toBe(true); - }); - - it("detects fatal 'Session not found' pattern", () => { - expect(isFatalSessionError("Session not found")).toBe(true); - }); - - it("detects fatal 'Session did not end' pattern", () => { - expect(isFatalSessionError("Session did not end cleanly")).toBe(true); - }); - - it("detects fatal 'not ready for writing' pattern", () => { - expect(isFatalSessionError("not ready for writing")).toBe(true); - }); - - it("detects fatal pattern in errorDetails", () => { - expect(isFatalSessionError("Unknown error", "Internal error: boom")).toBe( - true, - ); - }); - - it("returns false for non-fatal errors", () => { - expect(isFatalSessionError("Network timeout")).toBe(false); - }); - - it("returns false for empty string", () => { - expect(isFatalSessionError("")).toBe(false); - }); -}); - -function promptEvent(prompt: ContentBlock[], ts = 1): AcpMessage { - return { - type: "acp_message", - ts, - message: { - jsonrpc: "2.0", - id: ts, - method: "session/prompt", - params: { prompt }, - }, - }; -} - -describe("extractUserPromptsFromEvents", () => { - it("extracts text from a plain text prompt", () => { - const events = [promptEvent([{ type: "text", text: "fix the bug" }])]; - expect(extractUserPromptsFromEvents(events)).toEqual(["fix the bug"]); - }); - - it("skips hidden text blocks", () => { - const events = [ - promptEvent([ - { - type: "text", - text: "hidden context", - _meta: { ui: { hidden: true } }, - } as ContentBlock, - { type: "text", text: "visible prompt" }, - ]), - ]; - expect(extractUserPromptsFromEvents(events)).toEqual(["visible prompt"]); - }); - - it("returns attachment labels when prompt has no text", () => { - const uri = makeAttachmentUri("/tmp/screenshot.png"); - const events = [ - promptEvent([ - { - type: "resource", - resource: { uri, text: "", mimeType: "image/png" }, - }, - ]), - ]; - expect(extractUserPromptsFromEvents(events)).toEqual([ - "[Attached files: screenshot.png]", - ]); - }); - - it("returns text when prompt has both text and attachments", () => { - const uri = makeAttachmentUri("/tmp/data.csv"); - const events = [ - promptEvent([ - { type: "text", text: "analyze this" }, - { type: "resource", resource: { uri, text: "", mimeType: "text/csv" } }, - ]), - ]; - expect(extractUserPromptsFromEvents(events)).toEqual(["analyze this"]); - }); - - it("joins multiple attachment labels with commas", () => { - const uri1 = makeAttachmentUri("/tmp/a.png"); - const uri2 = makeAttachmentUri("/tmp/b.pdf"); - const events = [ - promptEvent([ - { - type: "resource", - resource: { uri: uri1, text: "", mimeType: "image/png" }, - }, - { - type: "resource", - resource: { uri: uri2, text: "", mimeType: "application/pdf" }, - }, - ]), - ]; - expect(extractUserPromptsFromEvents(events)).toEqual([ - "[Attached files: a.png, b.pdf]", - ]); - }); - - it("falls back to attachment labels when all text blocks are hidden", () => { - const uri = makeAttachmentUri("/tmp/report.md"); - const events = [ - promptEvent([ - { - type: "text", - text: "hidden", - _meta: { ui: { hidden: true } }, - } as ContentBlock, - { - type: "resource", - resource: { uri, text: "", mimeType: "text/markdown" }, - }, - ]), - ]; - expect(extractUserPromptsFromEvents(events)).toEqual([ - "[Attached files: report.md]", - ]); - }); - - it("skips events with empty prompt arrays", () => { - const events = [promptEvent([])]; - expect(extractUserPromptsFromEvents(events)).toEqual([]); - }); - - it("collects prompts from multiple events in order", () => { - const uri = makeAttachmentUri("/tmp/logo.svg"); - const events = [ - promptEvent([{ type: "text", text: "first" }], 1), - promptEvent( - [ - { - type: "resource", - resource: { uri, text: "", mimeType: "image/svg+xml" }, - }, - ], - 2, - ), - promptEvent([{ type: "text", text: "third" }], 3), - ]; - expect(extractUserPromptsFromEvents(events)).toEqual([ - "first", - "[Attached files: logo.svg]", - "third", - ]); - }); -}); diff --git a/apps/code/src/renderer/utils/session.ts b/apps/code/src/renderer/utils/session.ts deleted file mode 100644 index ec99a997d2..0000000000 --- a/apps/code/src/renderer/utils/session.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Pure transformation functions for session data. - * No side effects, no store access - just data transformations. - */ -import type { - AvailableCommand, - ContentBlock, - SessionNotification, -} from "@agentclientprotocol/sdk"; -import type { - AcpMessage, - JsonRpcMessage, - JsonRpcRequest, - StoredLogEntry, - UserShellExecuteParams, -} from "@shared/types/session-events"; -import { - isJsonRpcNotification, - isJsonRpcRequest, -} from "@shared/types/session-events"; -import { extractPromptDisplayContent } from "@utils/promptContent"; - -/** - * Convert a stored log entry to an ACP message. - */ -function storedEntryToAcpMessage(entry: StoredLogEntry): AcpMessage { - return { - type: "acp_message", - ts: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(), - message: (entry.notification ?? {}) as JsonRpcMessage, - }; -} - -/** - * Create a user message event for display. - */ -export function createUserPromptEvent( - prompt: ContentBlock[], - ts: number, -): AcpMessage { - return { - type: "acp_message", - ts, - message: { - jsonrpc: "2.0", - id: ts, - method: "session/prompt", - params: { - prompt, - }, - } as JsonRpcRequest, - }; -} - -export function createUserMessageEvent(text: string, ts: number): AcpMessage { - return createUserPromptEvent([{ type: "text", text }], ts); -} - -/** - * Create a user shell execute event. - * When id is provided, it's used to track async execution (start/complete). - * When result is undefined, it represents a command that's still running. - */ -export function createUserShellExecuteEvent( - command: string, - cwd: string, - result?: { stdout: string; stderr: string; exitCode: number }, - id?: string, -): AcpMessage { - return { - type: "acp_message", - ts: Date.now(), - message: { - jsonrpc: "2.0", - method: "_array/user_shell_execute", - params: { id, command, cwd, result }, - }, - }; -} - -/** - * Collects completed user shell executes that occurred after the last prompt request. - * These are included as hidden context in the next prompt so the agent - * knows what commands the user ran between turns. - * - * Scans backwards from the end of events, stopping at the most recent - * session/prompt request (not response), collecting any _array/user_shell_execute - * notifications found along the way. Deduplicates by ID, keeping only completed executes. - */ -export function getUserShellExecutesSinceLastPrompt( - events: AcpMessage[], -): UserShellExecuteParams[] { - const execMap = new Map<string, UserShellExecuteParams>(); - - for (let i = events.length - 1; i >= 0; i--) { - const msg = events[i].message; - - if (isJsonRpcRequest(msg) && msg.method === "session/prompt") break; - - if ( - isJsonRpcNotification(msg) && - msg.method === "_array/user_shell_execute" - ) { - const params = msg.params as UserShellExecuteParams; - if (params.result && params.id && !execMap.has(params.id)) { - execMap.set(params.id, params); - } - } - } - - return Array.from(execMap.values()).reverse(); -} - -/** - * Convert shell executes to content blocks for prompt context. - */ -export function shellExecutesToContextBlocks( - shellExecutes: UserShellExecuteParams[], -): ContentBlock[] { - return shellExecutes - .filter((cmd) => cmd.result) - .map((cmd) => ({ - type: "text" as const, - text: `[User executed command in ${cmd.cwd}]\n$ ${cmd.command}\n${ - cmd.result?.stdout || cmd.result?.stderr || "(no output)" - }`, - _meta: { ui: { hidden: true } }, - })); -} - -/** - * Convert stored log entries to ACP messages. - * Optionally prepends a user message with the task description. - */ -export function convertStoredEntriesToEvents( - entries: StoredLogEntry[], - taskDescription?: string, -): AcpMessage[] { - const events: AcpMessage[] = []; - - if (taskDescription) { - const startTs = entries[0]?.timestamp - ? new Date(entries[0].timestamp).getTime() - 1 - : Date.now(); - events.push(createUserMessageEvent(taskDescription, startTs)); - } - - for (const entry of entries) { - events.push(storedEntryToAcpMessage(entry)); - } - - return events; -} - -/** - * Extract available commands from session events. - * Scans backwards to find the most recent available_commands_update. - */ -export function extractAvailableCommandsFromEvents( - events: AcpMessage[], -): AvailableCommand[] { - for (let i = events.length - 1; i >= 0; i--) { - const msg = events[i].message; - if ( - "method" in msg && - msg.method === "session/update" && - !("id" in msg) && - "params" in msg - ) { - const params = msg.params as SessionNotification | undefined; - const update = params?.update; - if (update?.sessionUpdate === "available_commands_update") { - return update.availableCommands || []; - } - } - } - return []; -} - -/** - * Extract user prompts from session events. - * Returns an array of user prompt strings, most recent last. - */ -export function extractUserPromptsFromEvents(events: AcpMessage[]): string[] { - const prompts: string[] = []; - - for (const event of events) { - const msg = event.message; - if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { - const params = msg.params as { prompt?: ContentBlock[] }; - if (params?.prompt?.length) { - const { text, attachments } = extractPromptDisplayContent( - params.prompt, - { filterHidden: true }, - ); - - if (text) { - prompts.push(text); - } else if (attachments.length > 0) { - const labels = attachments.map((a) => a.label).join(", "); - prompts.push(`[Attached files: ${labels}]`); - } - } - } - } - - return prompts; -} - -export function extractPromptText(prompt: string | ContentBlock[]): string { - if (typeof prompt === "string") return prompt; - return extractPromptDisplayContent(prompt).text; -} - -/** - * Convert prompt input to ContentBlocks. - */ -export function normalizePromptToBlocks( - prompt: string | ContentBlock[], -): ContentBlock[] { - return typeof prompt === "string" ? [{ type: "text", text: prompt }] : prompt; -} - -export { isFatalSessionError, isRateLimitError } from "@shared/errors"; diff --git a/apps/code/src/renderer/utils/sounds.ts b/apps/code/src/renderer/utils/sounds.ts deleted file mode 100644 index b0abc7b0f3..0000000000 --- a/apps/code/src/renderer/utils/sounds.ts +++ /dev/null @@ -1,56 +0,0 @@ -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"; - -const SOUND_URLS: Record<Exclude<CompletionSound, "none">, string> = { - guitar: guitarUrl, - danilo: daniloUrl, - revi: reviUrl, - meep: meepUrl, - "meep-smol": meepSmolUrl, - bubbles: bubblesUrl, - drop: dropUrl, - knock: knockUrl, - ring: ringUrl, - shoot: shootUrl, - slide: slideUrl, - switch: switchUrl, - wilhelm: wilhelmUrl, -}; - -let currentAudio: HTMLAudioElement | null = null; - -export function playCompletionSound(sound: CompletionSound, volume = 80): void { - if (sound === "none") return; - - const url = SOUND_URLS[sound]; - if (!url) return; - - if (currentAudio) { - currentAudio.pause(); - currentAudio = null; - } - - const audio = new Audio(url); - audio.volume = Math.max(0, Math.min(100, volume)) / 100; - currentAudio = audio; - audio.play().catch(() => { - // Audio play can fail if user hasn't interacted with the page yet - }); - audio.addEventListener("ended", () => { - if (currentAudio === audio) { - currentAudio = null; - } - }); -} diff --git a/apps/code/src/renderer/utils/urls.ts b/apps/code/src/renderer/utils/urls.ts deleted file mode 100644 index 81e47d90ea..0000000000 --- a/apps/code/src/renderer/utils/urls.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { getCachedAuthState } from "@features/auth/hooks/authQueries"; -import type { CloudRegion } from "@shared/types/regions"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; - -export function getPostHogUrl( - pathOrUrl: string, - regionOverride?: CloudRegion | null, -): string | null { - if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl; - const region = regionOverride ?? getCachedAuthState().cloudRegion; - if (!region) return null; - const base = getCloudUrlFromRegion(region); - return `${base}${pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`}`; -} - -export function getBillingUrl( - regionOverride?: CloudRegion | null, -): string | null { - return getPostHogUrl("/organization/billing/overview", regionOverride); -} diff --git a/apps/code/src/shared/constants.ts b/apps/code/src/shared/constants.ts index 73b6097d8f..976c73c946 100644 --- a/apps/code/src/shared/constants.ts +++ b/apps/code/src/shared/constants.ts @@ -1,9 +1,10 @@ -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 { + BILLING_FLAG, + BRANCH_PREFIX, + EXPERIMENT_SUGGESTIONS_FLAG, + INBOX_GATED_DUE_TO_SCALE_FLAG, + SYNC_CLOUD_TASKS_FLAG, +} from "@posthog/shared"; export const DATA_DIR = ".posthog-code"; export const WORKTREES_DIR = ".posthog-code/worktrees"; export const LEGACY_DATA_DIRS = [ diff --git a/apps/code/src/shared/constants/oauth.ts b/apps/code/src/shared/constants/oauth.ts deleted file mode 100644 index f59ce0cca2..0000000000 --- a/apps/code/src/shared/constants/oauth.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { CloudRegion } from "@shared/types/regions"; - -export const POSTHOG_US_CLIENT_ID = "HCWoE0aRFMYxIxFNTTwkOORn5LBjOt2GVDzwSw5W"; -export const POSTHOG_EU_CLIENT_ID = "AIvijgMS0dxKEmr5z6odvRd8Pkh5vts3nPTzgzU9"; -export const POSTHOG_DEV_CLIENT_ID = "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ"; - -// Bump OAUTH_SCOPE_VERSION below whenever OAUTH_SCOPES changes to force re-authentication -export const OAUTH_SCOPES = ["*"]; - -export const OAUTH_SCOPE_VERSION = 4; - -// Token refresh settings -export const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000; // 30 minutes before expiry -export const TOKEN_REFRESH_FORCE_MS = 60 * 1000; // Force refresh when <1 min to expiry, even with active sessions - -export function getOauthClientIdFromRegion(region: CloudRegion): string { - switch (region) { - case "us": - return POSTHOG_US_CLIENT_ID; - case "eu": - return POSTHOG_EU_CLIENT_ID; - case "dev": - return POSTHOG_DEV_CLIENT_ID; - } -} diff --git a/apps/code/src/shared/deeplink.test.ts b/apps/code/src/shared/deeplink.test.ts deleted file mode 100644 index dfd168f84f..0000000000 --- a/apps/code/src/shared/deeplink.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildInboxDeeplink } from "./deeplink"; - -describe("buildInboxDeeplink", () => { - it("returns just the UUID when no title is given", () => { - expect(buildInboxDeeplink("abc-123", null, { isDevBuild: false })).toBe( - "posthog-code://inbox/abc-123", - ); - expect( - buildInboxDeeplink("abc-123", undefined, { isDevBuild: false }), - ).toBe("posthog-code://inbox/abc-123"); - expect(buildInboxDeeplink("abc-123", "", { isDevBuild: false })).toBe( - "posthog-code://inbox/abc-123", - ); - }); - - it("emits `--` for runs that mix a colon with other unsafe chars", () => { - expect( - buildInboxDeeplink("abc-123", "fix(inbox): Add foo", { - isDevBuild: false, - }), - ).toBe("posthog-code://inbox/abc-123/fix-inbox--Add-foo"); - }); - - it("emits a single `-` for a colon-only run", () => { - expect( - buildInboxDeeplink("abc-123", "feat:bar", { isDevBuild: false }), - ).toBe("posthog-code://inbox/abc-123/feat-bar"); - }); - - it("omits the slug when the title slugifies to empty", () => { - expect(buildInboxDeeplink("abc-123", ":::", { isDevBuild: false })).toBe( - "posthog-code://inbox/abc-123", - ); - expect(buildInboxDeeplink("abc-123", " ", { isDevBuild: false })).toBe( - "posthog-code://inbox/abc-123", - ); - }); - - it("uses the dev scheme when isDevBuild is true", () => { - expect( - buildInboxDeeplink("abc-123", "Hello World", { isDevBuild: true }), - ).toBe("posthog-code-dev://inbox/abc-123/Hello-World"); - }); - - it("preserves URL-unreserved punctuation (- _ . ~)", () => { - expect( - buildInboxDeeplink("abc-123", "v1.2.3_final~ish", { isDevBuild: false }), - ).toBe("posthog-code://inbox/abc-123/v1.2.3_final~ish"); - }); - - it("collapses runs of unsafe punctuation into a single hyphen", () => { - expect( - buildInboxDeeplink("abc-123", "Cost $5, 50% off!", { isDevBuild: false }), - ).toBe("posthog-code://inbox/abc-123/Cost-5-50-off"); - }); - - it("folds accented Latin letters to their ASCII base", () => { - expect( - buildInboxDeeplink("abc-123", "café résumé naïve", { isDevBuild: false }), - ).toBe("posthog-code://inbox/abc-123/cafe-resume-naive"); - }); - - it("hyphenizes non-Latin scripts that have no ASCII fold", () => { - expect( - buildInboxDeeplink("abc-123", "Hello Привет world", { - isDevBuild: false, - }), - ).toBe("posthog-code://inbox/abc-123/Hello-world"); - }); -}); 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/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.ts b/apps/code/src/shared/types.ts deleted file mode 100644 index 53d4f54f34..0000000000 --- a/apps/code/src/shared/types.ts +++ /dev/null @@ -1,583 +0,0 @@ -import { z } from "zod"; -import type { DismissalReasonOptionValue } from "./dismissalReasons"; -import type { StoredLogEntry } from "./types/session-events"; - -// Execution mode schema and type - shared between main and renderer -export const executionModeSchema = z.enum([ - "default", - "acceptEdits", - "plan", - "bypassPermissions", - "auto", - "read-only", - "full-access", -]); -export type ExecutionMode = z.infer<typeof executionModeSchema>; - -// Effort level schema and type - shared between main and renderer -export const effortLevelSchema = z.enum([ - "low", - "medium", - "high", - "xhigh", - "max", -]); -export type EffortLevel = z.infer<typeof effortLevelSchema>; - -interface UserBasic { - id: number; - uuid: string; - distinct_id?: string | null; - first_name?: string; - last_name?: string; - email: string; - is_email_verified?: boolean | null; -} - -export interface Task { - id: string; - task_number: number | null; - slug: string; - title: string; - title_manually_set?: boolean; - description: string; - created_at: string; - updated_at: string; - created_by?: UserBasic | null; - origin_product: string; - repository?: string | null; // Format: "organization/repository" (e.g., "posthog/posthog-js") - github_integration?: number | null; - github_user_integration?: string | null; - json_schema?: Record<string, unknown> | null; - signal_report?: string | null; - internal?: boolean; - latest_run?: TaskRun; -} - -export type TaskRunStatus = - | "not_started" - | "queued" - | "in_progress" - | "completed" - | "failed" - | "cancelled"; - -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]) - ); -} - -export interface TaskRun { - id: string; - task: string; // Task ID - team: number; - branch: string | null; - runtime_adapter?: "claude" | "codex" | null; - model?: string | null; - reasoning_effort?: "low" | "medium" | "high" | "xhigh" | "max" | null; - stage?: string | null; // Current stage (e.g., 'research', 'plan', 'build') - environment?: "local" | "cloud"; - 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) - created_at: string; - updated_at: string; - completed_at: string | null; -} - -export type NetworkAccessLevel = "trusted" | "full" | "custom"; - -export interface SandboxEnvironment { - id: string; - name: string; - network_access_level: NetworkAccessLevel; - allowed_domains: string[]; - include_default_domains: boolean; - repositories: string[]; - has_environment_variables: boolean; - private: boolean; - effective_domains: string[]; - created_by?: UserBasic | null; - created_at: string; - updated_at: string; -} - -export interface SandboxEnvironmentInput { - name: string; - network_access_level: NetworkAccessLevel; - allowed_domains?: string[]; - include_default_domains?: boolean; - repositories?: string[]; - environment_variables?: Record<string, string>; - private?: boolean; -} - -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<string, unknown> | 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<string, unknown> | 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<string, unknown>; -} - -export interface CloudTaskPermissionRequestUpdate extends CloudTaskUpdateBase { - kind: "permission_request"; - requestId: string; - toolCall: { - toolCallId: string; - title: string; - kind: string; - content?: unknown[]; - rawInput?: Record<string, unknown>; - _meta?: Record<string, unknown>; - }; - options: CloudPermissionOption[]; -} - -export type CloudTaskUpdatePayload = - | CloudTaskLogsUpdate - | CloudTaskStatusUpdate - | CloudTaskSnapshotUpdate - | CloudTaskErrorUpdate - | CloudTaskPermissionRequestUpdate; - -// Mention types for editors -type MentionType = - | "file" - | "folder" - | "error" - | "experiment" - | "insight" - | "feature_flag" - | "generic"; - -export interface MentionItem { - // File items - path?: string; - name?: string; - kind?: "file" | "directory"; - // URL items - url?: string; - type?: MentionType; - label?: string; - id?: string; - urlId?: string; -} - -// Git file status types -export type GitFileStatus = - | "modified" - | "added" - | "deleted" - | "renamed" - | "untracked"; - -export type GitBusyOperation = "rebase" | "merge" | "cherry-pick" | "revert"; - -export type GitBusyState = - | { busy: false } - | { busy: true; operation: GitBusyOperation }; - -export interface ChangedFile { - path: string; - status: GitFileStatus; - originalPath?: string; // For renames: the old path - linesAdded?: number; - linesRemoved?: number; - staged?: boolean; - patch?: string; // Unified diff patch from GitHub API -} - -// External apps detection types -export type ExternalAppType = - | "editor" - | "terminal" - | "file-manager" - | "git-client"; - -export interface DetectedApplication { - id: string; // "vscode", "cursor", "iterm" - name: string; // "Visual Studio Code" - type: ExternalAppType; - path: string; // "/Applications/Visual Studio Code.app" - command: string; // Launch command - icon?: string; // Base64 data URL -} - -export type SignalReportStatus = - | "potential" - | "candidate" - | "in_progress" - | "ready" - | "failed" - | "pending_input" - | "suppressed" - | "deleted"; - -/** Actionability priority from the researched report (actionability judgment artefact). */ -export type SignalReportPriority = "P0" | "P1" | "P2" | "P3" | "P4"; - -/** Actionability choice from the researched report. */ -export type SignalReportActionability = - | "immediately_actionable" - | "requires_human_input" - | "not_actionable"; - -/** - * One or more `SignalReportStatus` values joined by commas, e.g. `potential` or `potential,candidate,ready`. - * This looks horrendous but it's superb, trust me bro. - */ -export type CommaSeparatedSignalReportStatuses = - | SignalReportStatus - | `${SignalReportStatus},${SignalReportStatus}` - | `${SignalReportStatus},${SignalReportStatus},${SignalReportStatus}` - | `${SignalReportStatus},${SignalReportStatus},${SignalReportStatus},${SignalReportStatus}` - | `${SignalReportStatus},${SignalReportStatus},${SignalReportStatus},${SignalReportStatus},${SignalReportStatus}`; - -export interface SignalReport { - id: string; - title: string | null; - summary: string | null; - status: SignalReportStatus; - total_weight: number; - signal_count: number; - signals_at_run?: number; - created_at: string; - updated_at: string; - artefact_count: number; - /** P0–P4 from priority judgment when the report is researched */ - priority?: SignalReportPriority | null; - /** Actionability choice from the actionability judgment artefact. */ - actionability?: SignalReportActionability | null; - /** Whether the issue appears already fixed, from the actionability judgment artefact. */ - already_addressed?: boolean | null; - /** Whether the current user is a suggested reviewer for this report (server-annotated). */ - is_suggested_reviewer?: boolean; - /** Distinct source products contributing signals to this report. */ - source_products?: string[]; - /** PR URL from the latest implementation task run, if available. */ - implementation_pr_url?: string | null; -} - -export interface SignalReportArtefactContent { - session_id: string; - start_time: string; - end_time: string; - distinct_id: string; - content: string; - distance_to_centroid: number | null; -} - -export interface SignalReportArtefact { - id: string; - type: string; - content: SignalReportArtefactContent; - created_at: string; -} - -/** Artefact with `type: "priority_judgment"` — priority assessment from the agentic report. */ -export interface PriorityJudgmentArtefact { - id: string; - type: "priority_judgment"; - content: PriorityJudgmentContent; - created_at: string; -} - -export interface PriorityJudgmentContent { - explanation: string; - priority: SignalReportPriority; -} - -/** Artefact with `type: "actionability_judgment"` — actionability assessment from the agentic report. */ -export interface ActionabilityJudgmentArtefact { - id: string; - type: "actionability_judgment"; - content: ActionabilityJudgmentContent; - created_at: string; -} - -export interface ActionabilityJudgmentContent { - explanation: string; - actionability: SignalReportActionability; - already_addressed: boolean; -} - -/** Artefact with `type: "signal_finding"` — per-signal research finding from the agentic report. */ -export interface SignalFindingArtefact { - id: string; - type: "signal_finding"; - content: SignalFindingContent; - created_at: string; -} - -export interface SignalFindingContent { - signal_id: string; - relevant_code_paths: string[]; - relevant_commit_hashes: Record<string, string>; - data_queried: string; - verified: boolean; -} - -/** Artefact with `type: "suggested_reviewers"` — content is an enriched reviewer list. */ -export interface SuggestedReviewersArtefact { - id: string; - type: "suggested_reviewers"; - content: SuggestedReviewer[]; - created_at: string; -} - -/** Artefact with `type: "dismissal"` — captures the user's rationale when suppressing a report. */ -export interface DismissalArtefact { - id: string; - type: "dismissal"; - content: DismissalContent; - created_at: string; -} - -export interface DismissalContent { - reason: DismissalReasonOptionValue; - /** Optional free-form detail provided alongside the reason. */ - note: string; - /** PostHog numeric user id of the dismisser, when available. */ - user_id: number | null; - /** PostHog UUID of the dismisser, when available. */ - user_uuid: string | null; -} - -export interface SuggestedReviewerCommit { - sha: string; - url: string; - reason: string; -} - -export interface SuggestedReviewerUser { - id: number; - uuid: string; - email: string; - first_name: string; - last_name: string; -} - -export interface AvailableSuggestedReviewer { - uuid: string; - name: string; - email: string; - github_login: string; -} - -export interface SuggestedReviewer { - github_login: string; - github_name: string | null; - relevant_commits: SuggestedReviewerCommit[]; - user: SuggestedReviewerUser | null; -} - -interface MatchedSignalMetadata { - parent_signal_id: string; - match_query: string; - reason: string; -} - -interface NoMatchSignalMetadata { - reason: string; - rejected_signal_ids: string[]; -} - -export type SignalMatchMetadata = MatchedSignalMetadata | NoMatchSignalMetadata; - -export interface Signal { - signal_id: string; - content: string; - source_product: string; - source_type: string; - source_id: string; - weight: number; - timestamp: string; - extra: Record<string, unknown>; - match_metadata?: SignalMatchMetadata | null; -} - -export interface SignalReportsResponse { - results: SignalReport[]; - count: number; -} - -export interface SignalProcessingStateResponse { - paused_until: string | null; -} - -export interface AvailableSuggestedReviewersResponse { - results: AvailableSuggestedReviewer[]; - count: number; -} - -export interface SignalReportSignalsResponse { - report: SignalReport | null; - signals: Signal[]; -} - -export interface SignalReportArtefactsResponse { - results: ( - | SignalReportArtefact - | PriorityJudgmentArtefact - | ActionabilityJudgmentArtefact - | SignalFindingArtefact - | SuggestedReviewersArtefact - | DismissalArtefact - )[]; - count: number; - unavailableReason?: - | "forbidden" - | "not_found" - | "invalid_payload" - | "request_failed"; -} - -export type SignalReportOrderingField = - | "priority" - | "signal_count" - | "total_weight" - | "created_at" - | "updated_at"; - -export interface SignalReportsQueryParams { - limit?: number; - offset?: number; - status?: CommaSeparatedSignalReportStatuses | string; - /** - * Comma-separated sort keys (prefix `-` for descending). `status` is semantic stage - * rank (not lexicographic `status` column order). Also: `signal_count`, `total_weight`, - * `created_at`, `updated_at`, `id`. Example: `status,-total_weight`. - */ - ordering?: string; - /** Comma-separated source products — only returns reports with signals from these sources. */ - source_product?: string; - /** Comma-separated PostHog user UUIDs — only returns reports with these suggested reviewers. */ - suggested_reviewers?: string; -} - -/** Values match `SignalReportTask.Relationship` on the PostHog API. */ -export const SIGNAL_REPORT_TASK_RELATIONSHIPS = [ - "repo_selection", - "research", - "implementation", -] as const; - -export type SignalReportTaskRelationship = - (typeof SIGNAL_REPORT_TASK_RELATIONSHIPS)[number]; - -/** Inbox / cloud PR tasks must use this when creating the `SignalReportTask` link. */ -export const SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP: SignalReportTaskRelationship = - "implementation"; - -export interface SignalReportTask { - id: string; - relationship: SignalReportTaskRelationship; - task_id: string; - created_at: string; -} - -export interface SignalTeamConfig { - id: string; - default_autostart_priority: SignalReportPriority; - created_at: string; - updated_at: string; -} - -export interface SignalUserAutonomyConfig { - id?: string; - autostart_priority: SignalReportPriority | null; - /** ID of the team-scoped Slack `Integration` row used to deliver inbox-item notifications. */ - slack_notification_integration_id?: number | null; - /** `channel_id|#channel-name` target — same convention used by Insight Alerts. */ - slack_notification_channel?: string | null; - /** Minimum priority that triggers a notification (P0 highest). `null` = every priority. */ - slack_notification_min_priority?: SignalReportPriority | null; - created_at?: string; - updated_at?: string; -} - -export interface SlackChannelOption { - id: string; - name: string; - is_private: boolean; - is_member: boolean; - is_ext_shared: boolean; - is_private_without_access: boolean; -} - -export interface SlackChannelsResponse { - channels: SlackChannelOption[]; - lastRefreshedAt?: string; - has_more?: boolean; -} - -export interface SlackChannelsQueryParams { - search?: string; - limit?: number; - offset?: number; - 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); diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts deleted file mode 100644 index 23053a9b8b..0000000000 --- a/apps/code/src/shared/types/analytics.ts +++ /dev/null @@ -1,889 +0,0 @@ -// Analytics event types and properties - -import type { - PromptHistoryOpenedProperties, - PromptHistorySelectedProperties, -} from "@features/message-editor/analytics"; - -type ExecutionType = "cloud" | "local"; -export type RepositoryProvider = "github" | "gitlab" | "local" | "none"; -type TaskCreatedFrom = "cli" | "command-menu"; -type RepositorySelectSource = "task-creation" | "task-detail"; -type GitActionType = - | "push" - | "pull" - | "sync" - | "publish" - | "commit" - | "commit-push" - | "create-pr" - | "view-pr" - | "update-pr" - | "branch-here"; -export type FeedbackType = "good" | "bad" | "general"; -type FileOpenSource = "sidebar" | "agent-suggestion" | "search" | "diff"; -export type FileChangeType = "added" | "modified" | "deleted"; -type StopReason = "user_cancelled" | "completed" | "error" | "timeout"; -export type SkillButtonId = - | "add-analytics" - | "create-feature-flags" - | "run-experiment" - | "add-error-tracking" - | "instrument-llm-calls" - | "add-logging"; -type SkillButtonSource = "primary" | "dropdown"; -export type CommandMenuAction = - | "home" - | "new-task" - | "settings" - | "logout" - | "toggle-theme" - | "toggle-left-sidebar" - | "open-review-panel" - | "open-task"; - -// Event property interfaces -export interface TaskListViewProperties { - filter_type?: string; - sort_field?: string; - view_mode?: string; -} - -export interface TaskCreateProperties { - auto_run: boolean; - created_from: TaskCreatedFrom; - repository_provider?: RepositoryProvider; - workspace_mode?: "local" | "worktree" | "cloud"; - has_branch?: boolean; - /** Worktree mode: a project environment with a setup script was selected */ - has_environment_setup?: boolean; - /** Cloud mode: a sandbox environment was selected */ - has_sandbox_environment?: boolean; - cloud_run_source?: "manual" | "signal_report"; - cloud_pr_authorship_mode?: "user" | "bot"; - signal_report_id?: string; - /** Worktree mode: repo has a non-empty .worktreelink file */ - uses_worktree_link?: boolean; - /** Worktree mode: repo has a non-empty .worktreeinclude file */ - uses_worktree_include?: boolean; - adapter?: "claude" | "codex"; -} - -export interface TaskViewProperties { - task_id: string; -} - -export interface TaskRunProperties { - task_id: string; - execution_type: ExecutionType; -} - -export interface RepositorySelectProperties { - repository_provider: RepositoryProvider; - source: RepositorySelectSource; -} - -export interface UserIdentifyProperties { - email?: string; - uuid?: string; - project_id?: string; - region?: string; -} -export interface TaskRunStartedProperties { - task_id: string; - execution_type: ExecutionType; - model?: string; - initial_mode?: string; - adapter?: string; -} - -export interface TaskRunCompletedProperties { - task_id: string; - execution_type: ExecutionType; - duration_seconds: number; - prompts_sent: number; - stop_reason: StopReason; -} - -export interface TaskRunCancelledProperties { - task_id: string; - execution_type: ExecutionType; - duration_seconds: number; - prompts_sent: number; -} - -export interface PromptSentProperties { - task_id: string; - is_initial: boolean; - execution_type: ExecutionType; - prompt_length_chars: number; -} - -// Git operations -export interface GitActionExecutedProperties { - action_type: GitActionType; - success: boolean; - task_id?: string; - /** Number of staged files at time of action */ - staged_file_count?: number; - /** Number of unstaged files at time of action */ - unstaged_file_count?: number; - /** Whether user chose to commit all changes (vs staged only) */ - commit_all?: boolean; - /** Whether stagedOnly mode was used for the commit */ - staged_only?: boolean; -} - -export interface PrCreatedProperties { - task_id?: string; - success: boolean; -} - -export interface AgentFileActivityProperties { - task_id: string; - branch_name: string | null; -} - -// Branch link events -type BranchLinkSource = "agent" | "user" | "unknown"; - -export interface BranchLinkedProperties { - task_id: string; - branch_name: string; - source: BranchLinkSource; -} - -export interface BranchUnlinkedProperties { - task_id: string; - source: BranchLinkSource; -} - -export interface BranchLinkDefaultBranchUnknownProperties { - task_id: string; - branch_name: string; -} - -// File interactions -export interface FileOpenedProperties { - file_extension: string; - source: FileOpenSource; - task_id?: string; -} - -export interface FileDiffViewedProperties { - file_extension: string; - change_type: FileChangeType; - task_id?: string; -} - -export interface ReviewPanelViewedProperties { - task_id: string; -} - -export interface DiffViewModeChangedProperties { - from_mode: "split" | "unified"; - to_mode: "split" | "unified"; -} - -// Workspace events -export interface WorkspaceCreatedProperties { - task_id: string; - mode: "cloud" | "worktree" | "local"; -} - -export interface WorkspaceScriptsStartedProperties { - task_id: string; - scripts_count: number; -} - -export interface FolderRegisteredProperties { - path_hash: string; -} - -// Navigation events -export interface CommandMenuActionProperties { - action_type: CommandMenuAction; -} - -export interface SkillButtonTriggeredProperties { - task_id: string; - button_id: SkillButtonId; - source: SkillButtonSource; -} - -// Settings events -export interface SettingChangedProperties { - setting_name: string; - new_value: string | boolean | number; - old_value?: string | boolean | number; -} - -// Error events -export interface TaskCreationFailedProperties { - error_type: string; - failed_step?: string; -} - -export interface AgentSessionErrorProperties { - task_id: string; - error_type: string; -} - -// Permission events -export interface PermissionRespondedProperties { - task_id: string; - tool_name?: string; - option_id?: string; - option_kind?: string; - custom_input?: string; -} - -export interface PermissionCancelledProperties { - task_id: string; - tool_name?: string; -} - -// Session config events -export interface SessionConfigChangedProperties { - task_id: string; - category: string; - from_value: string; - to_value: string; -} - -// Tour events -type TourAction = "started" | "step_advanced" | "dismissed" | "completed"; - -export interface TourEventProperties { - tour_id: string; - action: TourAction; - step_id?: string; - step_index?: number; - total_steps?: number; -} - -// Branch mismatch events -type BranchMismatchAction = "switch" | "continue" | "cancel"; - -export interface BranchMismatchWarningShownProperties { - task_id: string; - linked_branch: string; - current_branch: string; - has_uncommitted_changes: boolean; -} - -export interface BranchMismatchActionProperties { - task_id: string; - action: BranchMismatchAction; - linked_branch: string; - current_branch: string; -} - -// Deep link events -export interface DeepLinkNewTaskProperties { - has_prompt: boolean; - has_repo: boolean; - mode?: string; - model?: string; -} - -export interface DeepLinkPlanProperties { - has_repo: boolean; - mode?: string; - model?: string; - plan_length_chars: number; -} - -export interface DeepLinkIssueProperties { - owner: string; - repo: string; - issue_number: number; - mode?: string; - model?: string; -} - -export interface DeepLinkIssueFailedProperties { - owner: string; - repo: string; - issue_number: number; - reason: "not_found" | "fetch_failed"; - error_message?: string; -} - -// Feedback events -export interface TaskFeedbackProperties { - task_id: string; - task_run_id?: string; - log_url?: string; - event_count: number; - feedback_type: FeedbackType; - feedback_comment?: string; -} - -// Onboarding events -export type OnboardingStepId = - | "welcome" - | "project-select" - | "invite-code" - | "connect-github" - | "install-cli" - | "select-repo"; - -type OnboardingSkipReason = "no_repo_selected" | "dev_skip"; - -export interface OnboardingStepViewedProperties { - step_id: OnboardingStepId; - step_index: number; - total_steps: number; -} - -export interface OnboardingStepCompletedProperties { - step_id: OnboardingStepId; - step_index: number; - total_steps: number; - duration_seconds: number; - github_connected?: boolean; - git_installed?: boolean; - gh_installed?: boolean; - gh_authenticated?: boolean; -} - -export interface OnboardingStepSkippedProperties { - step_id: OnboardingStepId; - step_index: number; - reason: OnboardingSkipReason; -} - -export interface OnboardingSignInInitiatedProperties { - region: string; -} - -export interface OnboardingProjectSelectedProperties { - had_multiple_orgs: boolean; - had_multiple_projects: boolean; -} - -export interface OnboardingInviteCodeSubmittedProperties { - success: boolean; - error_type?: string; -} - -export interface OnboardingFolderSelectedProperties { - has_git_remote: boolean; - repository_provider: RepositoryProvider; -} - -export interface OnboardingCliCheckCompletedProperties { - git_installed: boolean; - gh_installed: boolean; - gh_authenticated: boolean; -} - -export interface OnboardingCompletedProperties { - duration_seconds: number; - github_connected: boolean; - repo_skipped: boolean; -} - -export type OnboardingGithubConnectFlow = - | "team_existing" - | "team_alternative" - | "user_new"; - -export interface OnboardingGithubConnectStartedProperties { - flow_type: OnboardingGithubConnectFlow; - is_retry: boolean; -} - -export interface OnboardingGithubConnectFailedProperties { - reason: "timeout" | "error"; - error_type?: string; -} - -export interface OnboardingAbandonedProperties { - last_step_id: OnboardingStepId; - duration_seconds: number; -} - -export interface AiConsentGateShownProperties { - is_org_admin: boolean; -} - -// Setup / onboarding events -type SetupDiscoveredTaskCategory = - | "bug" - | "security" - | "dead_code" - | "duplication" - | "performance" - | "stale_feature_flag" - | "error_tracking" - | "event_tracking" - | "funnel" - | "posthog_setup" - | "experiment"; - -export interface SetupDiscoveryStartedProperties { - discovery_task_id: string; - discovery_task_run_id: string; -} - -export interface SetupDiscoveryCompletedProperties { - discovery_task_id: string; - discovery_task_run_id: string; - task_count: number; - duration_seconds: number; - signal_source: "structured_output" | "terminal_status" | "missing_output"; -} - -export interface SetupDiscoveryFailedProperties { - discovery_task_id?: string; - discovery_task_run_id?: string; - reason: "failed" | "cancelled" | "timeout" | "startup_error"; - error_message?: string; -} - -export interface SetupTaskSelectedProperties { - discovered_task_id: string; - category: SetupDiscoveredTaskCategory; - position: number; - total_discovered: number; -} - -export interface SetupTaskDismissedProperties { - discovered_task_id: string; - category: SetupDiscoveredTaskCategory; - position: number; - total_discovered: number; -} - -// Inbox events -export type InboxReportOpenMethod = - | "click" - | "click_cmd" - | "click_shift" - | "keyboard" - | "deeplink" - | "unknown"; - -export type InboxReportCloseMethod = - | "next_report" - | "deselected" - | "navigated_away" - | "unmount"; - -export type InboxReportActionType = - | "dismiss" - | "snooze" - | "delete" - | "reingest" - | "create_pr" - | "open_pr" - | "copy_link" - | "discuss" - | "expand_signal" - | "collapse_signal" - | "expand_signal_section" - | "view_signal_external" - | "expand_why" - | "click_suggested_reviewer" - | "expand_task_section" - | "play_session_recording"; - -export type InboxReportActionSurface = - | "detail_pane" - | "toolbar" - | "keyboard" - | "list_row"; - -export interface InboxViewedProperties { - report_count: number; - total_count: number; - ready_count: number; - has_active_filters: boolean; - source_product_filter: string[]; - status_filter_count: number; - is_empty: boolean; - /** True when the inbox is scale-gated (GatedDueToScalePane shown, data not loaded). */ - is_gated_due_to_scale: boolean; - /** Breakdown of the visible report_count by priority (P0–P4, or "unknown"). */ - priority_p0_count: number; - priority_p1_count: number; - priority_p2_count: number; - priority_p3_count: number; - priority_p4_count: number; - priority_unknown_count: number; - /** Breakdown of the visible report_count by actionability. */ - actionability_immediately_actionable_count: number; - actionability_requires_human_input_count: number; - actionability_not_actionable_count: number; - actionability_unknown_count: number; -} - -export interface InboxReportOpenedProperties { - report_id: string; - report_title: string | null; - report_age_hours: number; - status: string | null; - priority: string | null; - actionability: string | null; - source_products: string[]; - rank: number; - list_size: number; - open_method: InboxReportOpenMethod; - previous_report_id: string | null; -} - -export interface InboxReportClosedProperties { - report_id: string; - report_title: string | null; - report_age_hours: number; - priority: string | null; - actionability: string | null; - time_spent_ms: number; - scrolled: boolean; - close_method: InboxReportCloseMethod; -} - -export interface InboxReportScrolledProperties { - report_id: string; - report_title: string | null; - report_age_hours: number; - priority: string | null; - actionability: string | null; - rank: number; - list_size: number; - time_since_open_ms: number; -} - -export interface SpendAnalysisTaskOpenedProperties { - /** Total LLM spend in USD across all products for the analysed window. */ - total_cost_usd: number; - /** PostHog Code spend in USD for the analysed window (subset of total). */ - scoped_cost_usd: number; - /** Number of `$ai_generation` events in the analysed window. */ - scoped_event_count: number; - /** Length of the analysed window in days. */ - window_days: number; - /** Number of tool rows the receiving agent will see (capped at 10 in the prompt). */ - tool_row_count: number; - /** Number of model rows the receiving agent will see. */ - model_row_count: number; -} - -export interface InboxReportActionProperties { - report_id: string; - report_title: string | null; - report_age_hours: number; - priority: string | null; - actionability: string | null; - action_type: InboxReportActionType; - surface: InboxReportActionSurface; - is_bulk: boolean; - bulk_size: number; - rank: number; - list_size: number; - dismissal_reason?: string; - dismissal_note?: string; - signal_id?: string; - signal_source_product?: string; - signal_source_type?: string; - signal_section?: "relevant_code" | "data_queried"; - why_field?: "priority" | "actionability"; - task_section?: "research" | "implementation"; - // True when the user submitted Discuss with a first question via the popover. - has_question?: boolean; - // The first question text the user typed before hitting Discuss. Truncated to - // 500 chars to keep event payloads bounded. - question_text?: string; -} - -export interface SignalSourceConnectedProperties { - source_product: - | "session_replay" - | "error_tracking" - | "github" - | "linear" - | "zendesk" - | "conversations" - | "pganalyze" - | "llm_analytics"; - /** True when this is a brand-new createSignalSourceConfig, false for re-enable of an existing config. */ - is_first_connection: boolean; - /** True when the connection went through the DataSourceSetup wizard (warehouse OAuth path). */ - via_setup_wizard: boolean; -} - -// Subscription / billing events - -export type UpgradePromptShownSurface = "usage_limit_modal" | "upgrade_dialog"; - -export type UpgradePromptClickedSurface = - | "usage_limit_modal" - | "sidebar" - | "plan_page_card" - | "upgrade_dialog"; - -export interface UpgradePromptShownProperties { - surface: UpgradePromptShownSurface; -} - -export interface UpgradePromptClickedProperties { - surface: UpgradePromptClickedSurface; -} - -export interface SubscriptionStartedProperties { - plan_key: string; - previous_plan_key?: string; -} - -export interface SubscriptionCancelledProperties { - plan_key: string; -} - -// Event names as constants -export const ANALYTICS_EVENTS = { - // App lifecycle - APP_STARTED: "App started", - APP_QUIT: "App quit", - - // Authentication - USER_LOGGED_IN: "User logged in", - USER_LOGGED_OUT: "User logged out", - - // Task management - TASK_LIST_VIEWED: "Task list viewed", - TASK_CREATED: "Task created", - TASK_VIEWED: "Task viewed", - TASK_RUN: "Task run", - TASK_RUN_STARTED: "Task run started", - TASK_RUN_COMPLETED: "Task run completed", - TASK_RUN_CANCELLED: "Task run cancelled", - PROMPT_SENT: "Prompt sent", - - // Repository - REPOSITORY_SELECTED: "Repository selected", - - // Git operations - GIT_ACTION_EXECUTED: "Git action executed", - PR_CREATED: "PR created", - AGENT_FILE_ACTIVITY: "Agent file activity", - BRANCH_LINKED: "Branch linked", - BRANCH_UNLINKED: "Branch unlinked", - BRANCH_LINK_DEFAULT_BRANCH_UNKNOWN: "Branch link default branch unknown", - - // File interactions - FILE_OPENED: "File opened", - FILE_DIFF_VIEWED: "File diff viewed", - REVIEW_PANEL_VIEWED: "Review panel viewed", - DIFF_VIEW_MODE_CHANGED: "Diff view mode changed", - - // Workspace events - WORKSPACE_CREATED: "Workspace created", - WORKSPACE_SCRIPTS_STARTED: "Workspace scripts started", - FOLDER_REGISTERED: "Folder registered", - - // Navigation events - SETTINGS_VIEWED: "Settings viewed", - COMMAND_MENU_OPENED: "Command menu opened", - COMMAND_MENU_ACTION: "Command menu action", - COMMAND_CENTER_VIEWED: "Command center viewed", - SKILL_BUTTON_TRIGGERED: "Skill button triggered", - - // Permission events - PERMISSION_RESPONDED: "Permission responded", - PERMISSION_CANCELLED: "Permission cancelled", - - // Session config events - SESSION_CONFIG_CHANGED: "Session config changed", - - // Settings events - SETTING_CHANGED: "Setting changed", - - // Feedback events - TASK_FEEDBACK: "Task feedback", - - // Branch mismatch events - BRANCH_MISMATCH_WARNING_SHOWN: "Branch mismatch warning shown", - BRANCH_MISMATCH_ACTION: "Branch mismatch action", - - // Tour events - TOUR_EVENT: "Tour event", - - // Onboarding events - ONBOARDING_STARTED: "Onboarding started", - ONBOARDING_STEP_VIEWED: "Onboarding step viewed", - ONBOARDING_STEP_COMPLETED: "Onboarding step completed", - ONBOARDING_STEP_SKIPPED: "Onboarding step skipped", - ONBOARDING_SIGN_IN_INITIATED: "Onboarding sign in initiated", - ONBOARDING_PROJECT_SELECTED: "Onboarding project selected", - ONBOARDING_INVITE_CODE_SUBMITTED: "Onboarding invite code submitted", - ONBOARDING_FOLDER_SELECTED: "Onboarding folder selected", - ONBOARDING_GITHUB_CONNECT_STARTED: "Onboarding github connect started", - ONBOARDING_GITHUB_CONNECT_FAILED: "Onboarding github connect failed", - ONBOARDING_GITHUB_CONNECTED: "Onboarding github connected", - ONBOARDING_CLI_CHECK_COMPLETED: "Onboarding cli check completed", - ONBOARDING_COMPLETED: "Onboarding completed", - ONBOARDING_ABANDONED: "Onboarding abandoned", - AI_CONSENT_GATE_SHOWN: "Ai consent gate shown", - AI_CONSENT_APPROVED: "Ai consent approved", - - // Setup / onboarding events - SETUP_DISCOVERY_STARTED: "Setup discovery started", - SETUP_DISCOVERY_COMPLETED: "Setup discovery completed", - SETUP_DISCOVERY_FAILED: "Setup discovery failed", - SETUP_TASK_SELECTED: "Setup task selected", - SETUP_TASK_DISMISSED: "Setup task dismissed", - - // Deep link events - DEEP_LINK_NEW_TASK: "Deep link new task", - DEEP_LINK_PLAN: "Deep link plan", - DEEP_LINK_ISSUE: "Deep link issue", - DEEP_LINK_ISSUE_FAILED: "Deep link issue failed", - - // Error events - TASK_CREATION_FAILED: "Task creation failed", - AGENT_SESSION_ERROR: "Agent session error", - - // Inbox events - INBOX_INTEREST_REGISTERED: "Inbox interest registered", - INBOX_VIEWED: "Inbox viewed", - INBOX_REPORT_OPENED: "Inbox report opened", - INBOX_REPORT_CLOSED: "Inbox report closed", - INBOX_REPORT_ACTION: "Inbox report action", - INBOX_REPORT_SCROLLED: "Inbox report scrolled", - SIGNAL_SOURCE_CONNECTED: "Signal source connected", - - // Spend analysis events - SPEND_ANALYSIS_TASK_OPENED: "Spend analysis task opened", - - // Prompt history events - PROMPT_HISTORY_OPENED: "Prompt history opened", - PROMPT_HISTORY_SELECTED: "Prompt history selected", - - // Subscription events - UPGRADE_PROMPT_SHOWN: "Upgrade prompt shown", - UPGRADE_PROMPT_CLICKED: "Upgrade prompt clicked", - SUBSCRIPTION_STARTED: "Subscription started", - SUBSCRIPTION_CANCELLED: "Subscription cancelled", -} as const; - -// Event property mapping -export type EventPropertyMap = { - [ANALYTICS_EVENTS.TASK_LIST_VIEWED]: TaskListViewProperties | undefined; - [ANALYTICS_EVENTS.TASK_CREATED]: TaskCreateProperties; - [ANALYTICS_EVENTS.TASK_VIEWED]: TaskViewProperties; - [ANALYTICS_EVENTS.TASK_RUN]: TaskRunProperties; - [ANALYTICS_EVENTS.REPOSITORY_SELECTED]: RepositorySelectProperties; - [ANALYTICS_EVENTS.USER_LOGGED_IN]: UserIdentifyProperties | undefined; - [ANALYTICS_EVENTS.USER_LOGGED_OUT]: never; - - // Task execution events - [ANALYTICS_EVENTS.TASK_RUN_STARTED]: TaskRunStartedProperties; - [ANALYTICS_EVENTS.TASK_RUN_COMPLETED]: TaskRunCompletedProperties; - [ANALYTICS_EVENTS.TASK_RUN_CANCELLED]: TaskRunCancelledProperties; - [ANALYTICS_EVENTS.PROMPT_SENT]: PromptSentProperties; - - // Git operations - [ANALYTICS_EVENTS.GIT_ACTION_EXECUTED]: GitActionExecutedProperties; - [ANALYTICS_EVENTS.PR_CREATED]: PrCreatedProperties; - [ANALYTICS_EVENTS.AGENT_FILE_ACTIVITY]: AgentFileActivityProperties; - [ANALYTICS_EVENTS.BRANCH_LINKED]: BranchLinkedProperties; - [ANALYTICS_EVENTS.BRANCH_UNLINKED]: BranchUnlinkedProperties; - [ANALYTICS_EVENTS.BRANCH_LINK_DEFAULT_BRANCH_UNKNOWN]: BranchLinkDefaultBranchUnknownProperties; - - // File interactions - [ANALYTICS_EVENTS.FILE_OPENED]: FileOpenedProperties; - [ANALYTICS_EVENTS.FILE_DIFF_VIEWED]: FileDiffViewedProperties; - [ANALYTICS_EVENTS.REVIEW_PANEL_VIEWED]: ReviewPanelViewedProperties; - [ANALYTICS_EVENTS.DIFF_VIEW_MODE_CHANGED]: DiffViewModeChangedProperties; - - // Workspace events - [ANALYTICS_EVENTS.WORKSPACE_CREATED]: WorkspaceCreatedProperties; - [ANALYTICS_EVENTS.WORKSPACE_SCRIPTS_STARTED]: WorkspaceScriptsStartedProperties; - [ANALYTICS_EVENTS.FOLDER_REGISTERED]: FolderRegisteredProperties; - - // Navigation events - [ANALYTICS_EVENTS.SETTINGS_VIEWED]: never; - [ANALYTICS_EVENTS.COMMAND_MENU_OPENED]: never; - [ANALYTICS_EVENTS.COMMAND_MENU_ACTION]: CommandMenuActionProperties; - [ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED]: never; - [ANALYTICS_EVENTS.SKILL_BUTTON_TRIGGERED]: SkillButtonTriggeredProperties; - - // Permission events - [ANALYTICS_EVENTS.PERMISSION_RESPONDED]: PermissionRespondedProperties; - [ANALYTICS_EVENTS.PERMISSION_CANCELLED]: PermissionCancelledProperties; - - // Session config events - [ANALYTICS_EVENTS.SESSION_CONFIG_CHANGED]: SessionConfigChangedProperties; - - // Settings events - [ANALYTICS_EVENTS.SETTING_CHANGED]: SettingChangedProperties; - - // Feedback events - [ANALYTICS_EVENTS.TASK_FEEDBACK]: TaskFeedbackProperties; - - // Branch mismatch events - [ANALYTICS_EVENTS.BRANCH_MISMATCH_WARNING_SHOWN]: BranchMismatchWarningShownProperties; - [ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION]: BranchMismatchActionProperties; - - // Tour events - [ANALYTICS_EVENTS.TOUR_EVENT]: TourEventProperties; - - // Onboarding events - [ANALYTICS_EVENTS.ONBOARDING_STARTED]: never; - [ANALYTICS_EVENTS.ONBOARDING_STEP_VIEWED]: OnboardingStepViewedProperties; - [ANALYTICS_EVENTS.ONBOARDING_STEP_COMPLETED]: OnboardingStepCompletedProperties; - [ANALYTICS_EVENTS.ONBOARDING_STEP_SKIPPED]: OnboardingStepSkippedProperties; - [ANALYTICS_EVENTS.ONBOARDING_SIGN_IN_INITIATED]: OnboardingSignInInitiatedProperties; - [ANALYTICS_EVENTS.ONBOARDING_PROJECT_SELECTED]: OnboardingProjectSelectedProperties; - [ANALYTICS_EVENTS.ONBOARDING_INVITE_CODE_SUBMITTED]: OnboardingInviteCodeSubmittedProperties; - [ANALYTICS_EVENTS.ONBOARDING_FOLDER_SELECTED]: OnboardingFolderSelectedProperties; - [ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECT_STARTED]: OnboardingGithubConnectStartedProperties; - [ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECT_FAILED]: OnboardingGithubConnectFailedProperties; - [ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECTED]: never; - [ANALYTICS_EVENTS.ONBOARDING_CLI_CHECK_COMPLETED]: OnboardingCliCheckCompletedProperties; - [ANALYTICS_EVENTS.ONBOARDING_COMPLETED]: OnboardingCompletedProperties; - [ANALYTICS_EVENTS.ONBOARDING_ABANDONED]: OnboardingAbandonedProperties; - [ANALYTICS_EVENTS.AI_CONSENT_GATE_SHOWN]: AiConsentGateShownProperties; - [ANALYTICS_EVENTS.AI_CONSENT_APPROVED]: never; - - // Setup / onboarding events - [ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED]: SetupDiscoveryStartedProperties; - [ANALYTICS_EVENTS.SETUP_DISCOVERY_COMPLETED]: SetupDiscoveryCompletedProperties; - [ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED]: SetupDiscoveryFailedProperties; - [ANALYTICS_EVENTS.SETUP_TASK_SELECTED]: SetupTaskSelectedProperties; - [ANALYTICS_EVENTS.SETUP_TASK_DISMISSED]: SetupTaskDismissedProperties; - - // Deep link events - [ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK]: DeepLinkNewTaskProperties; - [ANALYTICS_EVENTS.DEEP_LINK_PLAN]: DeepLinkPlanProperties; - [ANALYTICS_EVENTS.DEEP_LINK_ISSUE]: DeepLinkIssueProperties; - [ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED]: DeepLinkIssueFailedProperties; - - // Error events - [ANALYTICS_EVENTS.TASK_CREATION_FAILED]: TaskCreationFailedProperties; - [ANALYTICS_EVENTS.AGENT_SESSION_ERROR]: AgentSessionErrorProperties; - - // Inbox events - [ANALYTICS_EVENTS.INBOX_INTEREST_REGISTERED]: never; - [ANALYTICS_EVENTS.INBOX_VIEWED]: InboxViewedProperties; - [ANALYTICS_EVENTS.INBOX_REPORT_OPENED]: InboxReportOpenedProperties; - [ANALYTICS_EVENTS.INBOX_REPORT_CLOSED]: InboxReportClosedProperties; - [ANALYTICS_EVENTS.INBOX_REPORT_ACTION]: InboxReportActionProperties; - [ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED]: InboxReportScrolledProperties; - [ANALYTICS_EVENTS.SIGNAL_SOURCE_CONNECTED]: SignalSourceConnectedProperties; - - // Spend analysis events - [ANALYTICS_EVENTS.SPEND_ANALYSIS_TASK_OPENED]: SpendAnalysisTaskOpenedProperties; - - // Prompt history events - [ANALYTICS_EVENTS.PROMPT_HISTORY_OPENED]: PromptHistoryOpenedProperties; - [ANALYTICS_EVENTS.PROMPT_HISTORY_SELECTED]: PromptHistorySelectedProperties; - - // Subscription events - [ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN]: UpgradePromptShownProperties; - [ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED]: UpgradePromptClickedProperties; - [ANALYTICS_EVENTS.SUBSCRIPTION_STARTED]: SubscriptionStartedProperties; - [ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED]: SubscriptionCancelledProperties; -}; diff --git a/apps/code/src/shared/types/archive.ts b/apps/code/src/shared/types/archive.ts deleted file mode 100644 index 64abecd8e3..0000000000 --- a/apps/code/src/shared/types/archive.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from "zod"; - -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>; 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/src/shared/utils/urls.ts b/apps/code/src/shared/utils/urls.ts deleted file mode 100644 index 71b3e29ea6..0000000000 --- a/apps/code/src/shared/utils/urls.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CloudRegion } from "@shared/types/regions"; - -export function getCloudUrlFromRegion(region: CloudRegion): string { - switch (region) { - case "us": - return "https://us.posthog.com"; - case "eu": - return "https://eu.posthog.com"; - case "dev": - return "http://localhost:8010"; - } -} diff --git a/apps/code/tailwind.config.js b/apps/code/tailwind.config.js index 4778ccd2b0..a6fd15cc57 100644 --- a/apps/code/tailwind.config.js +++ b/apps/code/tailwind.config.js @@ -3,7 +3,11 @@ import { radixThemePreset } from "radix-themes-tw"; /** @type {import('tailwindcss').Config} */ module.exports = { presets: [radixThemePreset], - content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + "../../packages/ui/src/**/*.{ts,tsx}", + ], theme: { extend: { animation: { 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/biome.jsonc b/biome.jsonc index 2526157d43..7a0ae20cff 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -323,6 +323,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..207f0d9bd3 100644 --- a/knip.json +++ b/knip.json @@ -1,12 +1,11 @@ { "$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" + "scripts/*.{mjs,js,ts}" ] }, "apps/code": { @@ -15,21 +14,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 +43,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 +71,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<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; -} - -// 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<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 ProcessSpawnedCallback { onProcessSpawned?: (info: { pid: number; @@ -140,14 +69,6 @@ export type OnLogCallback = ( data?: unknown, ) => void; -export interface PostHogAPIConfig { - apiUrl: string; - getApiKey: () => string | Promise<string>; - refreshApiKey?: () => string | Promise<string>; - 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/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts new file mode 100644 index 0000000000..cd15f99d8c --- /dev/null +++ b/packages/api-client/src/posthog-client.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it, vi } from "vitest"; +import { PostHogAPIClient } from "./posthog-client"; + +describe("PostHogAPIClient", () => { + it("sends supported reasoning effort for cloud Codex runs", async () => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + const post = vi.fn().mockResolvedValue({ + id: "task-123", + title: "Task", + description: "Task", + created_at: "2026-04-14T00:00:00Z", + updated_at: "2026-04-14T00:00:00Z", + origin_product: "user_created", + }); + + (client as unknown as { api: { post: typeof post } }).api = { post }; + + await client.runTaskInCloud("task-123", "feature/max-effort", { + adapter: "codex", + model: "gpt-5.4", + reasoningLevel: "high", + }); + + expect(post).toHaveBeenCalledWith( + "/api/projects/{project_id}/tasks/{id}/run/", + expect.objectContaining({ + path: { project_id: "123", id: "task-123" }, + body: expect.objectContaining({ + mode: "interactive", + branch: "feature/max-effort", + runtime_adapter: "codex", + model: "gpt-5.4", + reasoning_effort: "high", + }), + }), + ); + }); + + it("preserves Codex-native permission modes for cloud runs", async () => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + const post = vi.fn().mockResolvedValue({ + id: "task-123", + title: "Task", + description: "Task", + created_at: "2026-04-14T00:00:00Z", + updated_at: "2026-04-14T00:00:00Z", + origin_product: "user_created", + }); + + (client as unknown as { api: { post: typeof post } }).api = { post }; + + await client.runTaskInCloud("task-123", "feature/codex-mode", { + adapter: "codex", + model: "gpt-5.4", + initialPermissionMode: "auto", + }); + + expect(post).toHaveBeenCalledWith( + "/api/projects/{project_id}/tasks/{id}/run/", + expect.objectContaining({ + body: expect.objectContaining({ + initial_permission_mode: "auto", + }), + }), + ); + }); + + it("rejects unsupported reasoning effort for cloud Codex runs", async () => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + const post = vi.fn(); + (client as unknown as { api: { post: typeof post } }).api = { post }; + + await expect( + client.runTaskInCloud("task-123", "feature/max-effort", { + adapter: "codex", + model: "gpt-5.4", + reasoningLevel: "max", + }), + ).rejects.toThrow( + "Reasoning effort 'max' is not supported for codex model 'gpt-5.4'.", + ); + + expect(post).not.toHaveBeenCalled(); + }); + + it("rejects unsupported minimal reasoning effort for cloud runs", async () => { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + const post = vi.fn(); + (client as unknown as { api: { post: typeof post } }).api = { post }; + + await expect( + client.runTaskInCloud("task-123", "feature/legacy-effort", { + adapter: "claude", + model: "claude-opus-4-8", + reasoningLevel: "minimal", + }), + ).rejects.toThrow( + "Reasoning effort 'minimal' is not supported for claude model 'claude-opus-4-8'.", + ); + + expect(post).not.toHaveBeenCalled(); + }); + + it("creates cloud task runs without relying on generated request typing", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id: "run-123", environment: "cloud" }), + }); + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { + baseUrl: "http://localhost:8000", + fetcher: { fetch }, + }; + + await expect( + client.createTaskRun("task-123", { + environment: "cloud", + mode: "interactive", + branch: "feature/direct-upload", + adapter: "codex", + model: "gpt-5.4", + reasoningLevel: "high", + initialPermissionMode: "auto", + }), + ).resolves.toEqual({ id: "run-123", environment: "cloud" }); + + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: "post", + path: "/api/projects/123/tasks/task-123/runs/", + overrides: { + body: JSON.stringify({ + mode: "interactive", + branch: "feature/direct-upload", + runtime_adapter: "codex", + model: "gpt-5.4", + reasoning_effort: "high", + initial_permission_mode: "auto", + environment: "cloud", + }), + }, + }), + ); + }); + + it("starts an existing cloud task run with run-scoped artifact ids", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id: "task-123", latest_run: { id: "run-123" } }), + }); + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { + baseUrl: "http://localhost:8000", + fetcher: { fetch }, + }; + + await expect( + client.startTaskRun("task-123", "run-123", { + pendingUserMessage: "Read the attached file first", + pendingUserArtifactIds: ["artifact-1"], + }), + ).resolves.toEqual({ id: "task-123", latest_run: { id: "run-123" } }); + + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: "post", + path: "/api/projects/123/tasks/task-123/runs/run-123/start/", + overrides: { + body: JSON.stringify({ + pending_user_message: "Read the attached file first", + pending_user_artifact_ids: ["artifact-1"], + }), + }, + }), + ); + }); + + describe("getSignalReport", () => { + function makeClient(fetch: ReturnType<typeof vi.fn>) { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { + baseUrl: "http://localhost:8000", + fetcher: { fetch }, + }; + return client; + } + + it("returns the parsed report on success", async () => { + const fetch = vi.fn().mockResolvedValue({ + json: async () => ({ id: "abc", title: "hi" }), + }); + const client = makeClient(fetch); + + await expect(client.getSignalReport("abc")).resolves.toEqual({ + id: "abc", + title: "hi", + }); + }); + + it("returns null when the shared fetcher throws a 404", async () => { + const fetch = vi + .fn() + .mockRejectedValue( + new Error('Failed request: [404] {"detail":"Not found."}'), + ); + const client = makeClient(fetch); + + await expect(client.getSignalReport("abc")).resolves.toBeNull(); + }); + + it("returns null when the shared fetcher throws a 403", async () => { + const fetch = vi + .fn() + .mockRejectedValue( + new Error('Failed request: [403] {"detail":"Forbidden."}'), + ); + const client = makeClient(fetch); + + await expect(client.getSignalReport("abc")).resolves.toBeNull(); + }); + + it("rethrows non-404/403 errors", async () => { + const fetch = vi + .fn() + .mockRejectedValue(new Error("Failed request: [500] boom")); + const client = makeClient(fetch); + + await expect(client.getSignalReport("abc")).rejects.toThrow("[500]"); + }); + }); + + describe("getTaskSummaries", () => { + const SUMMARIES_PATH = "/api/projects/123/tasks/summaries/"; + + function buildClient(fetch: ReturnType<typeof vi.fn>) { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } }; + return client; + } + + function page(results: object[], next: string | null = null) { + return { + ok: true, + json: async () => ({ count: 0, previous: null, next, results }), + }; + } + + function buildFetchForPages(...pages: ReturnType<typeof page>[]) { + const fetch = vi.fn(); + for (const p of pages) fetch.mockResolvedValueOnce(p); + return fetch; + } + + it("returns immediately for empty input without hitting the network", async () => { + const fetch = vi.fn(); + await expect(buildClient(fetch).getTaskSummaries([])).resolves.toEqual( + [], + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("returns single-page results without further requests", async () => { + const fetch = buildFetchForPages(page([{ id: "a" }])); + await expect(buildClient(fetch).getTaskSummaries(["a"])).resolves.toEqual( + [{ id: "a" }], + ); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + { + name: "same-host next URL", + nextUrl: `http://localhost:8000${SUMMARIES_PATH}?limit=2&offset=2`, + expectedSecondPath: `${SUMMARIES_PATH}?limit=2&offset=2`, + }, + { + name: "cross-host next URL (proxy variance)", + nextUrl: `https://internal.posthog.example${SUMMARIES_PATH}?limit=1&offset=1`, + expectedSecondPath: `${SUMMARIES_PATH}?limit=1&offset=1`, + }, + ])( + "follows the next cursor across pages and merges results: $name", + async ({ nextUrl, expectedSecondPath }) => { + const fetch = buildFetchForPages( + page([{ id: "a" }, { id: "b" }], nextUrl), + page([{ id: "c" }]), + ); + await expect( + buildClient(fetch).getTaskSummaries(["a", "b", "c"]), + ).resolves.toEqual([{ id: "a" }, { id: "b" }, { id: "c" }]); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[0][0]).toMatchObject({ + method: "post", + path: SUMMARIES_PATH, + }); + expect(fetch.mock.calls[1][0]).toMatchObject({ + method: "post", + path: expectedSecondPath, + }); + }, + ); + + it("throws when the server responds non-OK", async () => { + const fetch = vi + .fn() + .mockResolvedValue({ ok: false, statusText: "Bad Request" }); + await expect(buildClient(fetch).getTaskSummaries(["a"])).rejects.toThrow( + "Bad Request", + ); + }); + + it("returns partial results when MAX_PAGES is exceeded", async () => { + const fetch = vi + .fn() + .mockResolvedValue( + page( + [{ id: "x" }], + `http://localhost:8000${SUMMARIES_PATH}?offset=1`, + ), + ); + const result = await buildClient(fetch).getTaskSummaries(["a"]); + expect(fetch).toHaveBeenCalledTimes(50); + expect(result.length).toBe(50); + }); + }); +}); diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts new file mode 100644 index 0000000000..dda7b3cda3 --- /dev/null +++ b/packages/api-client/src/posthog-client.ts @@ -0,0 +1,3002 @@ +import "./generated.augment"; +import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; +import type { PermissionMode } from "@posthog/agent/execution-mode"; +import type { + CloudRunSource, + PrAuthorshipMode, + SeatData, + StoredLogEntry, +} from "@posthog/shared"; +import { + DISMISSAL_REASON_OPTIONS, + type DismissalReasonOptionValue, + SEAT_PRODUCT_KEY, +} from "@posthog/shared"; +import type { + ActionabilityJudgmentArtefact, + AvailableSuggestedReviewer, + AvailableSuggestedReviewersResponse, + DismissalArtefact, + PriorityJudgmentArtefact, + SandboxEnvironment, + SandboxEnvironmentInput, + Signal, + SignalFindingArtefact, + SignalProcessingStateResponse, + SignalReport, + SignalReportArtefact, + SignalReportArtefactsResponse, + SignalReportSignalsResponse, + SignalReportsQueryParams, + SignalReportsResponse, + SignalReportTask, + SignalReportTaskRelationship, + SignalTeamConfig, + SignalUserAutonomyConfig, + SlackChannelsQueryParams, + SlackChannelsResponse, + SuggestedReviewersArtefact, + Task, + TaskRun, +} 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; +} + +// PORT NOTE: host-agnostic logger. The desktop host calls +// setPosthogApiClientLogger(logger.scope("posthog-client")) at boot; defaults +// to a no-op so the package never imports the app logger. +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; + constructor(redirectUrl: string) { + super("Billing subscription required"); + this.name = "SeatSubscriptionRequiredError"; + this.redirectUrl = redirectUrl; + } +} + +export class SeatPaymentFailedError extends Error { + constructor(message?: string) { + super(message ?? "Payment failed"); + this.name = "SeatPaymentFailedError"; + } +} + +export const MCP_CATEGORIES = [ + { id: "all", label: "All" }, + { id: "business", label: "Business Operations" }, + { id: "data", label: "Data & Analytics" }, + { id: "design", label: "Design & Content" }, + { id: "dev", label: "Developer Tools & APIs" }, + { id: "infra", label: "Infrastructure" }, + { id: "productivity", label: "Productivity & Collaboration" }, +] as const; + +import type { + McpApprovalState, + McpAuthType, + McpCategory, + McpInstallationTool, + McpRecommendedServer, + McpServerInstallation, +} from "./types"; +export type { + McpApprovalState, + McpAuthType, + McpCategory, + McpInstallationTool, + McpRecommendedServer, + McpServerInstallation, +}; + +export type Evaluation = Schemas.Evaluation; + +export interface UserGitHubIntegration { + id: string; + kind: "github"; + installation_id: string; + repository_selection?: string | null; + account?: { + type?: string | null; + name?: string | null; + } | null; + uses_shared_installation?: boolean; + created_at?: string; +} + +export interface SignalSourceConfig { + id: string; + source_product: + | "session_replay" + | "llm_analytics" + | "github" + | "linear" + | "zendesk" + | "conversations" + | "error_tracking" + | "pganalyze"; + source_type: + | "session_analysis_cluster" + | "evaluation" + | "issue" + | "ticket" + | "issue_created" + | "issue_reopened" + | "issue_spiking"; + enabled: boolean; + config: Record<string, unknown>; + created_at: string; + updated_at: string; + status: "running" | "completed" | "failed" | null; +} + +export interface ExternalDataSourceSchema { + id: string; + name: string; + should_sync: boolean; + /** e.g. `full_refresh` (full table replication), `incremental`, `append` */ + sync_type?: string | null; +} + +export interface ExternalDataSource { + id: string; + source_type: string; + status: string; + // The generated `ExternalDataSourceSerializers` types this as `string`, + // but the actual API returns an array of schema objects + schemas?: ExternalDataSourceSchema[] | string; +} + +export interface TaskArtifactUploadRequest { + name: string; + type: "user_attachment"; + size: number; + content_type?: string; + source?: string; +} + +export interface DirectUploadPresignedPost { + url: string; + fields: Record<string, string>; +} + +export interface PreparedTaskArtifactUpload extends TaskArtifactUploadRequest { + id: string; + storage_path: string; + expires_in: number; + presigned_post: DirectUploadPresignedPost; +} + +export interface FinalizedTaskArtifactUpload { + id: string; + name: string; + type: string; + source?: string; + size?: number; + content_type?: string; + storage_path: string; + uploaded_at?: string; +} + +type CloudRuntimeAdapter = "claude" | "codex"; + +interface CloudRunOptions { + adapter?: CloudRuntimeAdapter; + model?: string; + reasoningLevel?: string; + sandboxEnvironmentId?: string; + prAuthorshipMode?: PrAuthorshipMode; + runSource?: CloudRunSource; + signalReportId?: string; + initialPermissionMode?: PermissionMode; +} + +interface CreateTaskRunOptions extends CloudRunOptions { + environment?: "local" | "cloud"; + mode?: "interactive" | "background"; + branch?: string | null; +} + +interface StartTaskRunOptions { + pendingUserMessage?: string; + pendingUserArtifactIds?: string[]; +} + +function buildCloudRunRequestBody( + options?: CloudRunOptions & { + branch?: string | null; + mode?: "interactive" | "background"; + resumeFromRunId?: string; + pendingUserMessage?: string; + pendingUserArtifactIds?: string[]; + }, +): Record<string, unknown> { + const body: Record<string, unknown> = { + mode: options?.mode ?? "interactive", + }; + + if (options?.branch) { + body.branch = options.branch; + } + if (options?.adapter) { + body.runtime_adapter = options.adapter; + if (options.model) { + body.model = options.model; + } + if (options.reasoningLevel) { + if (!options.model) { + throw new Error( + "A cloud reasoning level requires a model to be selected.", + ); + } + if ( + !isSupportedReasoningEffort( + options.adapter, + options.model, + options.reasoningLevel, + ) + ) { + throw new Error( + `Reasoning effort '${options.reasoningLevel}' is not supported for ${options.adapter} model '${options.model}'.`, + ); + } + body.reasoning_effort = options.reasoningLevel; + } + } + if (options?.resumeFromRunId) { + body.resume_from_run_id = options.resumeFromRunId; + } + if (options?.pendingUserMessage) { + body.pending_user_message = options.pendingUserMessage; + } + if (options?.pendingUserArtifactIds?.length) { + body.pending_user_artifact_ids = options.pendingUserArtifactIds; + } + if (options?.sandboxEnvironmentId) { + body.sandbox_environment_id = options.sandboxEnvironmentId; + } + if (options?.prAuthorshipMode) { + body.pr_authorship_mode = options.prAuthorshipMode; + } + if (options?.runSource) { + body.run_source = options.runSource; + } + if (options?.signalReportId) { + body.signal_report_id = options.signalReportId; + } + if (options?.initialPermissionMode) { + body.initial_permission_mode = options.initialPermissionMode; + } + + return body; +} + +function isObjectRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null; +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +type AnyArtefact = + | SignalReportArtefact + | PriorityJudgmentArtefact + | ActionabilityJudgmentArtefact + | SignalFindingArtefact + | SuggestedReviewersArtefact + | DismissalArtefact; + +const DISMISSAL_REASONS = new Set<DismissalReasonOptionValue>( + DISMISSAL_REASON_OPTIONS.map((o) => o.value), +); + +const PRIORITY_VALUES = new Set(["P0", "P1", "P2", "P3", "P4"]); + +function normalizePriorityJudgmentArtefact( + value: Record<string, unknown>, +): PriorityJudgmentArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + + const contentValue = isObjectRecord(value.content) ? value.content : null; + if (!contentValue) return null; + + const priority = optionalString(contentValue.priority); + if (!priority || !PRIORITY_VALUES.has(priority)) return null; + + return { + id, + type: "priority_judgment", + created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + content: { + explanation: optionalString(contentValue.explanation) ?? "", + priority: priority as PriorityJudgmentArtefact["content"]["priority"], + }, + }; +} + +const ACTIONABILITY_VALUES = new Set([ + "immediately_actionable", + "requires_human_input", + "not_actionable", +]); + +function normalizeActionabilityJudgmentArtefact( + value: Record<string, unknown>, +): ActionabilityJudgmentArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + + const contentValue = isObjectRecord(value.content) ? value.content : null; + if (!contentValue) return null; + + // Support both agentic ("actionability") and legacy ("choice") field names + const actionability = + optionalString(contentValue.actionability) ?? + optionalString(contentValue.choice); + if (!actionability || !ACTIONABILITY_VALUES.has(actionability)) return null; + + return { + id, + type: "actionability_judgment", + created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + content: { + explanation: optionalString(contentValue.explanation) ?? "", + actionability: + actionability as ActionabilityJudgmentArtefact["content"]["actionability"], + already_addressed: + typeof contentValue.already_addressed === "boolean" + ? contentValue.already_addressed + : false, + }, + }; +} + +function normalizeSignalFindingArtefact( + value: Record<string, unknown>, +): SignalFindingArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + + const contentValue = isObjectRecord(value.content) ? value.content : null; + if (!contentValue) return null; + + const signalId = optionalString(contentValue.signal_id); + if (!signalId) return null; + + return { + id, + type: "signal_finding", + created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + content: { + signal_id: signalId, + relevant_code_paths: Array.isArray(contentValue.relevant_code_paths) + ? contentValue.relevant_code_paths.filter( + (p: unknown): p is string => typeof p === "string", + ) + : [], + relevant_commit_hashes: isObjectRecord( + contentValue.relevant_commit_hashes, + ) + ? Object.fromEntries( + Object.entries(contentValue.relevant_commit_hashes).filter( + (e): e is [string, string] => typeof e[1] === "string", + ), + ) + : {}, + data_queried: optionalString(contentValue.data_queried) ?? "", + verified: + typeof contentValue.verified === "boolean" + ? contentValue.verified + : false, + }, + }; +} + +function normalizeDismissalArtefact( + value: Record<string, unknown>, +): DismissalArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + + const contentValue = isObjectRecord(value.content) ? value.content : null; + if (!contentValue) return null; + + const rawReason = optionalString(contentValue.reason); + const reason = + rawReason && DISMISSAL_REASONS.has(rawReason as DismissalReasonOptionValue) + ? (rawReason as DismissalReasonOptionValue) + : null; + + if (reason == null) { + return null; + } + + return { + id, + type: "dismissal", + created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + content: { + reason, + note: optionalString(contentValue.note) ?? "", + user_id: + typeof contentValue.user_id === "number" ? contentValue.user_id : null, + user_uuid: optionalString(contentValue.user_uuid), + }, + }; +} + +function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { + if (!isObjectRecord(value)) { + return null; + } + + const dispatchType = optionalString(value.type); + if (dispatchType === "signal_finding") { + return normalizeSignalFindingArtefact(value); + } + if (dispatchType === "actionability_judgment") { + return normalizeActionabilityJudgmentArtefact(value); + } + if (dispatchType === "priority_judgment") { + return normalizePriorityJudgmentArtefact(value); + } + if (dispatchType === "dismissal") { + return normalizeDismissalArtefact(value); + } + + const id = optionalString(value.id); + if (!id) { + return null; + } + + const type = dispatchType ?? "unknown"; + const created_at = + optionalString(value.created_at) ?? new Date(0).toISOString(); + + // suggested_reviewers: content is an array of reviewer objects + if (type === "suggested_reviewers" && Array.isArray(value.content)) { + return { + id, + type: "suggested_reviewers" as const, + created_at, + content: value.content as SuggestedReviewersArtefact["content"], + }; + } + + // video_segment and other artefacts with object content + const contentValue = isObjectRecord(value.content) ? value.content : null; + if (!contentValue) { + return null; + } + + const content = optionalString(contentValue.content); + const sessionId = optionalString(contentValue.session_id); + + // The backend may return empty content objects when binary decode fails. + if (!content && !sessionId) { + return null; + } + + return { + id, + type, + created_at, + content: { + session_id: sessionId ?? "", + start_time: optionalString(contentValue.start_time) ?? "", + end_time: optionalString(contentValue.end_time) ?? "", + distinct_id: optionalString(contentValue.distinct_id) ?? "", + content: content ?? "", + distance_to_centroid: + typeof contentValue.distance_to_centroid === "number" + ? contentValue.distance_to_centroid + : null, + }, + }; +} + +function parseSignalReportArtefactsPayload( + value: unknown, +): SignalReportArtefactsResponse { + const payload = isObjectRecord(value) ? value : null; + const rawResults = Array.isArray(payload?.results) + ? payload.results + : Array.isArray(value) + ? value + : []; + + const results = rawResults + .map(normalizeSignalReportArtefact) + .filter((artefact): artefact is AnyArtefact => artefact !== null); + const count = + typeof payload?.count === "number" ? payload.count : results.length; + + if (rawResults.length > 0 && results.length === 0) { + return { + results: [], + count: 0, + unavailableReason: "invalid_payload", + }; + } + + return { + results, + count, + }; +} + +function normalizeAvailableSuggestedReviewer( + uuid: string, + value: unknown, +): AvailableSuggestedReviewer | null { + if (!isObjectRecord(value)) { + return null; + } + + const normalizedUuid = optionalString(uuid); + if (!normalizedUuid) { + return null; + } + + return { + uuid: normalizedUuid, + name: optionalString(value.name) ?? "", + email: optionalString(value.email) ?? "", + github_login: optionalString(value.github_login) ?? "", + }; +} + +function parseAvailableSuggestedReviewersPayload( + value: unknown, +): AvailableSuggestedReviewersResponse { + if (!isObjectRecord(value)) { + return { + results: [], + count: 0, + }; + } + + const results = Object.entries(value) + .map(([uuid, reviewer]) => + normalizeAvailableSuggestedReviewer(uuid, reviewer), + ) + .filter( + (reviewer): reviewer is AvailableSuggestedReviewer => reviewer !== null, + ); + + return { + results, + count: results.length, + }; +} + +export class PostHogAPIClient { + private api: ReturnType<typeof createApiClient>; + private _teamId: number | null = null; + + constructor( + apiHost: string, + getAccessToken: () => Promise<string>, + refreshAccessToken: () => Promise<string>, + teamId?: number, + ) { + const baseUrl = apiHost.endsWith("/") ? apiHost.slice(0, -1) : apiHost; + this.api = createApiClient( + buildApiFetcher({ + getAccessToken, + refreshAccessToken, + appVersion: clientAppVersion, + }), + baseUrl, + ); + if (teamId) { + this._teamId = teamId; + } + } + + setTeamId(teamId: number): void { + this._teamId = teamId; + } + + private async getTeamId(): Promise<number> { + if (this._teamId !== null) { + return this._teamId; + } + + const user = await this.api.get("/api/users/{uuid}/", { + path: { uuid: "@me" }, + }); + + if (user?.team?.id) { + this._teamId = user.team.id; + return this._teamId; + } + + throw new Error("No team found for user"); + } + + async getCurrentUser() { + const data = await this.api.get("/api/users/{uuid}/", { + path: { uuid: "@me" }, + }); + return data; + } + + async getGithubLogin(): Promise<string | null> { + const data = (await this.api.get("/api/users/{uuid}/github_login/", { + path: { uuid: "@me" }, + })) as { github_login: string | null }; + return data.github_login; + } + + /** + * `POST .../integrations/github/start/`. Optional `teamId` matches app project when session `current_team` differs. + */ + async startGithubUserIntegrationConnect(teamId?: number): Promise<{ + install_url: string; + connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install"; + }> { + const id = teamId ?? (await this.getTeamId()); + const urlPath = `/api/users/@me/integrations/github/start/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ team_id: id, connect_from: "posthog_code" }), + }, + }); + if (!response.ok) { + const err = (await response.json().catch(() => ({}))) as { + detail?: unknown; + }; + const detail = + typeof err.detail === "string" + ? err.detail + : "Failed to start GitHub connection"; + throw new Error(detail); + } + return (await response.json()) as { + install_url: string; + connect_flow?: "oauth_authorize" | "oauth_discover" | "app_install"; + }; + } + + async getGithubUserIntegrations(): Promise<UserGitHubIntegration[]> { + const urlPath = `/api/users/@me/integrations/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch personal GitHub integrations: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + results?: UserGitHubIntegration[]; + }; + return data.results ?? []; + } + + async disconnectGithubUserIntegration(installationId: string): Promise<void> { + const urlPath = `/api/users/@me/integrations/github/${encodeURIComponent(installationId)}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path: urlPath, + }); + if (!response.ok && response.status !== 404) { + throw new Error( + `Failed to disconnect GitHub integration: ${response.statusText}`, + ); + } + } + + async switchOrganization(orgId: string): Promise<void> { + await this.api.patch("/api/users/{uuid}/", { + path: { uuid: "@me" }, + body: { set_current_organization: orgId } as Record<string, unknown>, + }); + } + + async getProject(projectId: number) { + //@ts-expect-error this is not in the generated client + const data = await this.api.get("/api/projects/{project_id}/", { + path: { project_id: projectId.toString() }, + }); + return data as Schemas.Team; + } + + async listSignalSourceConfigs( + projectId: number, + ): Promise<SignalSourceConfig[]> { + const urlPath = `/api/projects/${projectId}/signals/source_configs/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch signal source configs: ${response.statusText}`, + ); + } + const data = (await response.json()) as + | { results: SignalSourceConfig[] } + | SignalSourceConfig[]; + return Array.isArray(data) ? data : (data.results ?? []); + } + + async createSignalSourceConfig( + projectId: number, + options: { + source_product: SignalSourceConfig["source_product"]; + source_type: SignalSourceConfig["source_type"]; + enabled: boolean; + config?: Record<string, unknown>; + }, + ): Promise<SignalSourceConfig> { + const urlPath = `/api/projects/${projectId}/signals/source_configs/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify(options), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to create signal source config: ${response.statusText}`, + ); + } + return (await response.json()) as SignalSourceConfig; + } + + async updateSignalSourceConfig( + projectId: number, + configId: string, + updates: { enabled: boolean }, + ): Promise<SignalSourceConfig> { + const urlPath = `/api/projects/${projectId}/signals/source_configs/${configId}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: urlPath, + overrides: { + body: JSON.stringify(updates), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to update signal source config: ${response.statusText}`, + ); + } + return (await response.json()) as SignalSourceConfig; + } + + async listEvaluations(projectId: number): Promise<Evaluation[]> { + const data = await this.api.get( + "/api/environments/{project_id}/evaluations/", + { + path: { project_id: projectId.toString() }, + query: { limit: 200 }, + }, + ); + return data.results ?? []; + } + + async updateEvaluation( + projectId: number, + evaluationId: string, + updates: { enabled: boolean }, + ): Promise<Evaluation> { + return await this.api.patch( + "/api/environments/{project_id}/evaluations/{id}/", + { + path: { + project_id: projectId.toString(), + id: evaluationId, + }, + body: updates, + }, + ); + } + + async listExternalDataSources( + projectId: number, + ): Promise<ExternalDataSource[]> { + const data = (await this.api.get( + "/api/projects/{project_id}/external_data_sources/", + { + path: { project_id: projectId.toString() }, + query: {}, + }, + )) as unknown as { results?: ExternalDataSource[] } | ExternalDataSource[]; + return Array.isArray(data) ? data : (data.results ?? []); + } + + async createExternalDataSource( + projectId: number, + payload: { + source_type: string; + payload: Record<string, unknown>; + }, + ): Promise<ExternalDataSource> { + const response = await this.api.post( + "/api/projects/{project_id}/external_data_sources/", + { + path: { project_id: projectId.toString() }, + body: payload as unknown as Schemas.ExternalDataSourceCreate, + withResponse: true, + throwOnStatusError: false, + }, + ); + if (!response.ok) { + const errorData = isObjectRecord(response.data) + ? (response.data as { detail?: string }) + : {}; + throw new Error( + errorData.detail ?? + `Failed to create external data source: ${response.statusText}`, + ); + } + return response.data as unknown as ExternalDataSource; + } + + async updateExternalDataSchema( + projectId: number, + schemaId: string, + updates: { should_sync: boolean; sync_type?: string }, + ): Promise<void> { + const urlPath = `/api/projects/${projectId}/external_data_schemas/${schemaId}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: urlPath, + overrides: { + body: JSON.stringify(updates), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to update external data schema: ${response.statusText}`, + ); + } + } + + async getTasks(options?: { + repository?: string; + createdBy?: number; + originProduct?: string; + internal?: boolean; + }) { + const teamId = await this.getTeamId(); + const params: Record<string, string | number | boolean> = { + limit: 500, + }; + + if (options?.repository) { + params.repository = options.repository; + } + + if (options?.createdBy) { + params.created_by = options.createdBy; + } + + if (options?.originProduct) { + params.origin_product = options.originProduct; + } + + if (options?.internal) { + params.internal = true; + } + + const data = await this.api.get(`/api/projects/{project_id}/tasks/`, { + path: { project_id: teamId.toString() }, + query: params, + }); + + return data.results ?? []; + } + + async getTaskSummaries(ids: string[]) { + if (ids.length === 0) return []; + const TASK_SUMMARIES_MAX_PAGES = 50; + const teamId = await this.getTeamId(); + const all: Schemas.TaskSummary[] = []; + let urlPath: string = `/api/projects/${teamId}/tasks/summaries/`; + for (let i = 0; i < TASK_SUMMARIES_MAX_PAGES; i++) { + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ ids } satisfies Schemas.TaskSummariesRequest), + }, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch task summaries: ${response.statusText}`, + ); + } + const page = (await response.json()) as Schemas.PaginatedTaskSummaryList; + all.push(...page.results); + if (!page.next) return all; + const nextUrl = new URL(page.next); + urlPath = `${nextUrl.pathname}${nextUrl.search}`; + } + log.warn( + `getTaskSummaries hit MAX_PAGES (${TASK_SUMMARIES_MAX_PAGES}); returning partial results`, + { ids: ids.length, returned: all.length }, + ); + return all; + } + + async getTask(taskId: string) { + const teamId = await this.getTeamId(); + const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, { + path: { project_id: teamId.toString(), id: taskId }, + }); + return data as unknown as Task; + } + + async createTask( + options: Pick<Task, "description"> & + Partial< + Pick< + Task, + | "title" + | "repository" + | "json_schema" + | "origin_product" + | "signal_report" + > + > & { + github_integration?: number | null; + github_user_integration?: string | null; + /** POST-only: `SignalReportTask.relationship` to create when linking to `signal_report`. */ + signal_report_task_relationship?: SignalReportTaskRelationship; + }, + ) { + const teamId = await this.getTeamId(); + const { origin_product: originProduct, ...taskOptions } = options; + + const data = await this.api.post(`/api/projects/{project_id}/tasks/`, { + path: { project_id: teamId.toString() }, + body: { + ...taskOptions, + origin_product: originProduct ?? "user_created", + } as unknown as Schemas.Task, + }); + + return data; + } + + async updateTask(taskId: string, updates: Partial<Schemas.Task>) { + const teamId = await this.getTeamId(); + const data = await this.api.patch( + `/api/projects/{project_id}/tasks/{id}/`, + { + path: { project_id: teamId.toString(), id: taskId }, + body: updates, + }, + ); + + return data; + } + + async deleteTask(taskId: string) { + const teamId = await this.getTeamId(); + await this.api.delete(`/api/projects/{project_id}/tasks/{id}/`, { + path: { project_id: teamId.toString(), id: taskId }, + }); + } + + async duplicateTask(taskId: string) { + const task = await this.getTask(taskId); + return this.createTask({ + description: task.description ?? "", + title: task.title, + repository: task.repository, + json_schema: task.json_schema, + origin_product: task.origin_product, + github_integration: task.github_integration, + github_user_integration: task.github_user_integration, + }); + } + + async sendRunCommand( + taskId: string, + runId: string, + method: "user_message" | "cancel" | "close", + params?: Record<string, unknown>, + ): Promise<{ success: boolean; result?: unknown; error?: string }> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/command/`, + ); + const body = { + jsonrpc: "2.0", + method, + params: params ?? {}, + id: `posthog-code-${Date.now()}`, + }; + + try { + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/command/`, + overrides: { + body: JSON.stringify(body), + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + let errorMessage = `Command failed: ${response.statusText}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = + errorJson.error?.message ?? errorJson.error ?? errorMessage; + } catch { + if (errorText) errorMessage = errorText; + } + return { success: false, error: errorMessage }; + } + + const data = (await response.json()) as { + error?: { message?: string }; + result?: unknown; + }; + if (data.error) { + return { + success: false, + error: data.error.message ?? JSON.stringify(data.error), + }; + } + + return { success: true, result: data.result }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + async runTaskInCloud( + taskId: string, + branch?: string | null, + options?: CloudRunOptions & { + resumeFromRunId?: string; + pendingUserMessage?: string; + pendingUserArtifactIds?: string[]; + }, + ): Promise<Task> { + const teamId = await this.getTeamId(); + const body = buildCloudRunRequestBody({ + ...options, + branch, + mode: "interactive", + }); + + const data = await this.api.post( + `/api/projects/{project_id}/tasks/{id}/run/`, + { + path: { project_id: teamId.toString(), id: taskId }, + body, + }, + ); + + return data as unknown as Task; + } + + async prepareTaskStagedArtifactUploads( + taskId: string, + artifacts: TaskArtifactUploadRequest[], + ): Promise<PreparedTaskArtifactUpload[]> { + if (!artifacts.length) { + return []; + } + + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/staged_artifacts/prepare_upload/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/staged_artifacts/prepare_upload/`, + overrides: { + body: JSON.stringify({ artifacts }), + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to prepare staged uploads: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + artifacts?: PreparedTaskArtifactUpload[]; + }; + return data.artifacts ?? []; + } + + async finalizeTaskStagedArtifactUploads( + taskId: string, + artifacts: PreparedTaskArtifactUpload[], + ): Promise<FinalizedTaskArtifactUpload[]> { + if (!artifacts.length) { + return []; + } + + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/staged_artifacts/finalize_upload/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/staged_artifacts/finalize_upload/`, + overrides: { + body: JSON.stringify({ + artifacts: artifacts.map((artifact) => ({ + id: artifact.id, + name: artifact.name, + type: artifact.type, + source: artifact.source, + content_type: artifact.content_type, + storage_path: artifact.storage_path, + })), + }), + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to finalize staged uploads: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + artifacts?: FinalizedTaskArtifactUpload[]; + }; + return data.artifacts ?? []; + } + + async prepareTaskRunArtifactUploads( + taskId: string, + runId: string, + artifacts: TaskArtifactUploadRequest[], + ): Promise<PreparedTaskArtifactUpload[]> { + if (!artifacts.length) { + return []; + } + + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/prepare_upload/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/prepare_upload/`, + overrides: { + body: JSON.stringify({ artifacts }), + }, + }); + + if (!response.ok) { + throw new Error(`Failed to prepare uploads: ${response.statusText}`); + } + + const data = (await response.json()) as { + artifacts?: PreparedTaskArtifactUpload[]; + }; + return data.artifacts ?? []; + } + + async finalizeTaskRunArtifactUploads( + taskId: string, + runId: string, + artifacts: PreparedTaskArtifactUpload[], + ): Promise<FinalizedTaskArtifactUpload[]> { + if (!artifacts.length) { + return []; + } + + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/finalize_upload/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/finalize_upload/`, + overrides: { + body: JSON.stringify({ + artifacts: artifacts.map((artifact) => ({ + id: artifact.id, + name: artifact.name, + type: artifact.type, + source: artifact.source, + content_type: artifact.content_type, + storage_path: artifact.storage_path, + })), + }), + }, + }); + + if (!response.ok) { + throw new Error(`Failed to finalize uploads: ${response.statusText}`); + } + + const data = (await response.json()) as { + artifacts?: FinalizedTaskArtifactUpload[]; + }; + return data.artifacts ?? []; + } + + async resumeRunInCloud(taskId: string, runId: string): Promise<TaskRun> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/resume_in_cloud/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/resume_in_cloud/`, + }); + + if (!response.ok) { + throw new Error(`Failed to resume run in cloud: ${response.statusText}`); + } + + return (await response.json()) as TaskRun; + } + + async listTaskRuns(taskId: string): Promise<TaskRun[]> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/`, + ); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/runs/`, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch task runs: ${response.statusText}`); + } + + const data = (await response.json()) as { results?: TaskRun[] }; + return data.results ?? []; + } + + async getTaskRun(taskId: string, runId: string): Promise<TaskRun> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/`, + ); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/`, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch task run: ${response.statusText}`); + } + + return (await response.json()) as TaskRun; + } + + async createTaskRun( + taskId: string, + options?: CreateTaskRunOptions, + ): Promise<TaskRun> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/runs/`, + overrides: { + body: JSON.stringify({ + ...buildCloudRunRequestBody({ + ...options, + mode: options?.mode ?? "background", + }), + environment: options?.environment ?? "local", + }), + }, + }); + + if (!response.ok) { + throw new Error(`Failed to create task run: ${response.statusText}`); + } + + return (await response.json()) as TaskRun; + } + + async startTaskRun( + taskId: string, + runId: string, + options?: StartTaskRunOptions, + ): Promise<Task> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/start/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/start/`, + overrides: { + body: JSON.stringify({ + pending_user_message: options?.pendingUserMessage, + pending_user_artifact_ids: options?.pendingUserArtifactIds, + }), + }, + }); + + if (!response.ok) { + throw new Error(`Failed to start task run: ${response.statusText}`); + } + + return (await response.json()) as Task; + } + + async updateTaskRun( + taskId: string, + runId: string, + updates: Partial< + Pick< + TaskRun, + "status" | "branch" | "stage" | "error_message" | "output" | "state" + > + >, + ): Promise<TaskRun> { + const teamId = await this.getTeamId(); + const data = await this.api.patch( + `/api/projects/{project_id}/tasks/{task_id}/runs/{id}/`, + { + path: { + project_id: teamId.toString(), + task_id: taskId, + id: runId, + }, + body: updates as Record<string, unknown>, + }, + ); + return data as unknown as TaskRun; + } + + /** + * Append events to a task run's S3 log file + */ + async appendTaskRunLog( + taskId: string, + runId: string, + entries: StoredLogEntry[], + ): Promise<void> { + const teamId = await this.getTeamId(); + const url = `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/append_log/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(url), + path: url, + overrides: { + body: JSON.stringify({ entries }), + }, + }); + if (!response.ok) { + throw new Error(`Failed to append log: ${response.statusText}`); + } + } + + async getTaskRunSessionLogs( + taskId: string, + runId: string, + options?: { limit?: number; after?: string }, + ): Promise<StoredLogEntry[]> { + try { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/session_logs/`, + ); + url.searchParams.set("limit", String(options?.limit ?? 5000)); + if (options?.after) { + url.searchParams.set("after", options.after); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/session_logs/`, + }); + + if (!response.ok) { + log.warn( + `Failed to fetch session logs: ${response.status} ${response.statusText}`, + ); + return []; + } + + return (await response.json()) as StoredLogEntry[]; + } catch (err) { + log.warn("Failed to fetch task run session logs", err); + return []; + } + } + + async getTaskLogs(taskId: string): Promise<StoredLogEntry[]> { + try { + const task = (await this.getTask(taskId)) as unknown as Task; + const logUrl = task?.latest_run?.log_url; + + if (!logUrl) { + return []; + } + + const response = await fetch(logUrl); + + if (!response.ok) { + log.warn( + `Failed to fetch logs: ${response.status} ${response.statusText}`, + ); + return []; + } + + const content = await response.text(); + + if (!content.trim()) { + return []; + } + return content + .trim() + .split("\n") + .map((line) => JSON.parse(line) as StoredLogEntry); + } catch (err) { + log.warn("Failed to fetch task logs from latest run", err); + return []; + } + } + + async getIntegrations() { + const teamId = await this.getTeamId(); + return this.getIntegrationsForProject(teamId); + } + + async getIntegrationsForProject(projectId: number) { + const url = new URL( + `${this.api.baseUrl}/api/environments/${projectId}/integrations/`, + ); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/environments/${projectId}/integrations/`, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch integrations: ${response.statusText}`); + } + + const data = (await response.json()) as { + results?: { kind: string; id: number | string; [key: string]: unknown }[]; + }; + return data.results ?? []; + } + + async getGithubBranches( + integrationId: string | number, + repo: string, + ): Promise<{ branches: string[]; defaultBranch: string | null }> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, + ); + url.searchParams.set("repo", repo); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch GitHub branches: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + branches?: string[]; + results?: string[]; + default_branch?: string | null; + }; + return { + branches: data.branches ?? data.results ?? [], + defaultBranch: data.default_branch ?? null, + }; + } + + async getGithubBranchesPage( + integrationId: string | number, + repo: string, + offset: number, + limit: number, + search?: string, + ): Promise<{ + branches: string[]; + defaultBranch: string | null; + hasMore: boolean; + }> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, + ); + url.searchParams.set("repo", repo); + url.searchParams.set("offset", String(offset)); + url.searchParams.set("limit", String(limit)); + if (search?.trim()) { + url.searchParams.set("search", search.trim()); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch GitHub branches: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + branches?: string[]; + results?: string[]; + default_branch?: string | null; + has_more?: boolean; + }; + return { + branches: data.branches ?? data.results ?? [], + defaultBranch: data.default_branch ?? null, + hasMore: data.has_more ?? false, + }; + } + + async getGithubUserBranchesPage( + installationId: string | number, + repo: string, + offset: number, + limit: number, + search?: string, + ): Promise<{ + branches: string[]; + defaultBranch: string | null; + hasMore: boolean; + }> { + const urlPath = `/api/users/@me/integrations/github/${installationId}/branches/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + url.searchParams.set("repo", repo); + url.searchParams.set("offset", String(offset)); + url.searchParams.set("limit", String(limit)); + if (search?.trim()) { + url.searchParams.set("search", search.trim()); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch personal GitHub branches: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + branches?: string[]; + results?: string[]; + default_branch?: string | null; + has_more?: boolean; + }; + return { + branches: data.branches ?? data.results ?? [], + defaultBranch: data.default_branch ?? null, + hasMore: data.has_more ?? false, + }; + } + + async getGithubRepositories( + integrationId: string | number, + ): Promise<string[]> { + const repositories: string[] = []; + let offset = 0; + + while (true) { + const page = await this.getGithubRepositoriesPage( + integrationId, + offset, + 500, + ); + repositories.push(...page.repositories); + + if (!page.hasMore) { + return repositories; + } + + offset += page.repositories.length; + } + } + + async getGithubRepositoriesPage( + integrationId: string | number, + offset: number, + limit: number, + search?: string, + ): Promise<{ + repositories: string[]; + hasMore: boolean; + }> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_repos/`, + ); + url.searchParams.set("offset", String(offset)); + url.searchParams.set("limit", String(limit)); + if (search?.trim()) { + url.searchParams.set("search", search.trim()); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/environments/${teamId}/integrations/${integrationId}/github_repos/`, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch GitHub repositories: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { has_more?: boolean }; + return { + repositories: this.normalizeGithubRepositories(data), + hasMore: data.has_more ?? false, + }; + } + + async getGithubUserRepositories( + installationId: string | number, + ): Promise<string[]> { + const repositories: string[] = []; + let offset = 0; + + while (true) { + const page = await this.getGithubUserRepositoriesPage( + installationId, + offset, + 500, + ); + repositories.push(...page.repositories); + + if (!page.hasMore) { + return repositories; + } + + offset += page.repositories.length; + } + } + + async getGithubUserRepositoriesPage( + installationId: string | number, + offset: number, + limit: number, + search?: string, + ): Promise<{ + repositories: string[]; + hasMore: boolean; + }> { + const urlPath = `/api/users/@me/integrations/github/${installationId}/repos/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + url.searchParams.set("offset", String(offset)); + url.searchParams.set("limit", String(limit)); + if (search?.trim()) { + url.searchParams.set("search", search.trim()); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch personal GitHub repositories: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { has_more?: boolean }; + return { + repositories: this.normalizeGithubRepositories(data), + hasMore: data.has_more ?? false, + }; + } + + async refreshGithubRepositories( + integrationId: string | number, + ): Promise<string[]> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_repos/refresh/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/environments/${teamId}/integrations/${integrationId}/github_repos/refresh/`, + }); + + if (!response.ok) { + throw new Error( + `Failed to refresh GitHub repositories: ${response.statusText}`, + ); + } + + const data: unknown = await response.json(); + return this.normalizeGithubRepositories(data); + } + + async refreshGithubUserRepositories( + installationId: string | number, + ): Promise<string[]> { + const urlPath = `/api/users/@me/integrations/github/${installationId}/repos/refresh/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + }); + + if (!response.ok) { + throw new Error( + `Failed to refresh personal GitHub repositories: ${response.statusText}`, + ); + } + + const data: unknown = await response.json(); + return this.normalizeGithubRepositories(data); + } + + private normalizeGithubRepositories(data: unknown): string[] { + const repos = + (data as { repositories?: unknown[] }).repositories ?? + (data as { results?: unknown[] }).results ?? + (Array.isArray(data) ? data : []); + + return (repos as (string | { full_name?: string; name?: string })[]).map( + (repo) => { + if (typeof repo === "string") return repo; + return (repo.full_name ?? repo.name ?? "").toLowerCase(); + }, + ); + } + + async getAgents() { + const teamId = await this.getTeamId(); + const url = new URL(`${this.api.baseUrl}/api/projects/${teamId}/agents/`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/projects/${teamId}/agents/`, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch agents: ${response.statusText}`); + } + + const data = (await response.json()) as { results?: unknown[] }; + return data.results ?? []; + } + + async getUsers() { + const data = (await this.api.get("/api/users/", { + query: { limit: 1000 }, + })) as unknown as { results: Schemas.User[] } | Schemas.User[]; + return Array.isArray(data) ? data : (data.results ?? []); + } + + async updateTeam(updates: { + session_recording_opt_in?: boolean; + autocapture_exceptions_opt_in?: boolean; + }): Promise<Schemas.Team> { + const teamId = await this.getTeamId(); + const url = new URL(`${this.api.baseUrl}/api/projects/${teamId}/`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: `/api/projects/${teamId}/`, + overrides: { + body: JSON.stringify(updates), + }, + }); + + if (!response.ok) { + const responseText = await response.text(); + let detail = responseText; + try { + const parsed = JSON.parse(responseText) as + | { detail?: string } + | Record<string, unknown>; + if ( + typeof parsed === "object" && + parsed !== null && + "detail" in parsed && + typeof parsed.detail === "string" + ) { + detail = parsed.detail; + } else if (typeof parsed === "object" && parsed !== null) { + detail = Object.entries(parsed) + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join(", "); + } + } catch { + // keep plain text fallback + } + + throw new Error( + `Failed to update team: ${detail || response.statusText}`, + ); + } + + return (await response.json()) as Schemas.Team; + } + + async getSignalReport(reportId: string): Promise<SignalReport | null> { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/signals/reports/${reportId}/`; + const url = new URL(`${this.api.baseUrl}${path}`); + + try { + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + return (await response.json()) as SignalReport; + } catch (error) { + // The shared fetcher throws "Failed request: [<status>] <body>" for any + // non-2xx. Treat missing / forbidden as "not available in the current + // team" and surface other errors to the caller. + const msg = error instanceof Error ? error.message : String(error); + if (msg.includes("[404]") || msg.includes("[403]")) { + return null; + } + throw error; + } + } + + async getSignalReports( + params?: SignalReportsQueryParams, + ): Promise<SignalReportsResponse> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/`, + ); + + if (params?.limit != null) { + url.searchParams.set("limit", String(params.limit)); + } + if (params?.offset != null) { + url.searchParams.set("offset", String(params.offset)); + } + if (params?.status) { + url.searchParams.set("status", params.status); + } + if (params?.ordering) { + url.searchParams.set("ordering", params.ordering); + } + if (params?.source_product) { + url.searchParams.set("source_product", params.source_product); + } + if (params?.suggested_reviewers) { + url.searchParams.set("suggested_reviewers", params.suggested_reviewers); + } + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/projects/${teamId}/signals/reports/`, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch signal reports: ${response.statusText}`); + } + + const data = (await response.json()) as { + results?: SignalReport[]; + count?: number; + }; + return { + results: data.results ?? [], + count: data.count ?? data.results?.length ?? 0, + }; + } + + async getSignalProcessingState(): Promise<SignalProcessingStateResponse> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/processing/`, + ); + const path = `/api/projects/${teamId}/signals/processing/`; + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch signal processing state: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { paused_until?: string | null }; + return { + paused_until: + typeof data?.paused_until === "string" ? data.paused_until : null, + }; + } + + async getAvailableSuggestedReviewers( + query?: string, + ): Promise<AvailableSuggestedReviewersResponse> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/available_reviewers/`, + ); + const path = `/api/projects/${teamId}/signals/reports/available_reviewers/`; + + if (query?.trim()) { + url.searchParams.set("query", query.trim()); + } + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch available suggested reviewers: ${response.statusText}`, + ); + } + + return parseAvailableSuggestedReviewersPayload(await response.json()); + } + + async getSignalReportSignals( + reportId: string, + ): Promise<SignalReportSignalsResponse> { + try { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/signals/`, + ); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/projects/${teamId}/signals/reports/${reportId}/signals/`, + }); + + if (!response.ok) { + log.warn("Signal report signals unavailable", { + reportId, + status: response.status, + }); + return { report: null, signals: [] }; + } + + const data = (await response.json()) as { + report?: SignalReport | null; + signals?: Signal[]; + }; + return { + report: data.report ?? null, + signals: data.signals ?? [], + }; + } catch (error) { + log.warn("Failed to fetch signal report signals", { reportId, error }); + return { report: null, signals: [] }; + } + } + + async getSignalReportArtefacts( + reportId: string, + ): Promise<SignalReportArtefactsResponse> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/artefacts/`, + ); + const path = `/api/projects/${teamId}/signals/reports/${reportId}/artefacts/`; + + try { + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + const responseText = await response.text(); + const unavailableReason = + response.status === 403 + ? "forbidden" + : response.status === 404 + ? "not_found" + : "request_failed"; + + log.warn("Signal report artefacts unavailable", { + teamId, + reportId, + status: response.status, + statusText: response.statusText, + body: responseText || undefined, + }); + + return { results: [], count: 0, unavailableReason }; + } + + const data = (await response.json()) as unknown; + const parsed = parseSignalReportArtefactsPayload(data); + + if (parsed.unavailableReason) { + log.warn("Signal report artefacts payload did not match schema", { + teamId, + reportId, + }); + } + + return parsed; + } catch (error) { + log.warn("Failed to fetch signal report artefacts", { + teamId, + reportId, + error, + }); + return { + results: [], + count: 0, + unavailableReason: "request_failed", + }; + } + } + + async updateSignalReportState( + reportId: string, + input: + | { + state: "potential"; + snooze_for?: number; + reset_weight?: boolean; + error?: string; + } + | { + state: "suppressed"; + /** When omitted, the server suppresses without creating a dismissal artefact. */ + dismissal_reason?: DismissalReasonOptionValue; + dismissal_note?: string; + reset_weight?: boolean; + error?: string; + }, + ): Promise<SignalReport> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/state/`, + ); + const path = `/api/projects/${teamId}/signals/reports/${reportId}/state/`; + + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { + body: JSON.stringify(input), + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || "Failed to update signal report state"); + } + + return (await response.json()) as SignalReport; + } + + async deleteSignalReport(reportId: string): Promise<{ + status: "deletion_started" | "already_running"; + report_id: string; + }> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/`, + ); + const path = `/api/projects/${teamId}/signals/reports/${reportId}/`; + + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || "Failed to delete signal report"); + } + + return (await response.json()) as { + status: "deletion_started" | "already_running"; + report_id: string; + }; + } + + async reingestSignalReport(reportId: string): Promise<{ + status: "reingestion_started" | "already_running"; + report_id: string; + }> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/reingest/`, + ); + const path = `/api/projects/${teamId}/signals/reports/${reportId}/reingest/`; + + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || "Failed to reingest signal report"); + } + + return (await response.json()) as { + status: "reingestion_started" | "already_running"; + report_id: string; + }; + } + + async getSignalReportTasks( + reportId: string, + options?: { relationship?: SignalReportTask["relationship"] }, + ): Promise<SignalReportTask[]> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/tasks/`, + ); + if (options?.relationship) { + url.searchParams.set("relationship", options.relationship); + } + const path = `/api/projects/${teamId}/signals/reports/${reportId}/tasks/`; + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch signal report tasks: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { results?: SignalReportTask[] }; + return data.results ?? []; + } + + async getSignalTeamConfig(): Promise<SignalTeamConfig> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/config/`, + ); + const path = `/api/projects/${teamId}/signals/config/`; + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch signal team config: ${response.statusText}`, + ); + } + + return (await response.json()) as SignalTeamConfig; + } + + async updateSignalTeamConfig(updates: { + default_autostart_priority: string; + }): Promise<SignalTeamConfig> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signals/config/`, + ); + const path = `/api/projects/${teamId}/signals/config/`; + + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { + body: JSON.stringify(updates), + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to update signal team config: ${response.statusText}`, + ); + } + + return (await response.json()) as SignalTeamConfig; + } + + async getSignalUserAutonomyConfig(): Promise<SignalUserAutonomyConfig | null> { + const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); + const path = "/api/users/@me/signal_autonomy/"; + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + return (await response.json()) as SignalUserAutonomyConfig; + } + + async updateSignalUserAutonomyConfig( + updates: Partial<{ + autostart_priority: string | null; + slack_notification_integration_id: number | null; + slack_notification_channel: string | null; + slack_notification_min_priority: string | null; + }>, + ): Promise<SignalUserAutonomyConfig> { + const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); + const path = "/api/users/@me/signal_autonomy/"; + + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { + body: JSON.stringify(updates), + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to update signal user autonomy config: ${response.statusText}`, + ); + } + return (await response.json()) as SignalUserAutonomyConfig; + } + + async getSlackChannelsForIntegration( + integrationId: number, + params?: SlackChannelsQueryParams, + ): Promise<SlackChannelsResponse> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/channels/`, + ); + const search = params?.search?.trim(); + if (search) { + url.searchParams.set("search", search); + } + if (params?.limit != null) { + url.searchParams.set("limit", String(params.limit)); + } + if (params?.offset != null) { + url.searchParams.set("offset", String(params.offset)); + } + if (params?.channelId) { + url.searchParams.set("channel_id", params.channelId); + } + const path = `/api/environments/${teamId}/integrations/${integrationId}/channels/${url.search}`; + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Slack channels: ${response.statusText}`); + } + return (await response.json()) as SlackChannelsResponse; + } + + async deleteSignalUserAutonomyConfig(): Promise<void> { + const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); + const path = "/api/users/@me/signal_autonomy/"; + + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path, + }); + + if (!response.ok) { + throw new Error( + `Failed to delete signal user autonomy config: ${response.statusText}`, + ); + } + } + + async getMcpServers(): Promise<McpRecommendedServer[]> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/mcp_servers/`, + ); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/environments/${teamId}/mcp_servers/`, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch MCP servers: ${response.statusText}`); + } + + const data = (await response.json()) as { + results?: McpRecommendedServer[]; + }; + return data.results ?? []; + } + + async getMcpServerInstallations(): Promise<McpServerInstallation[]> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/`, + ); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/environments/${teamId}/mcp_server_installations/`, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch MCP server installations: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + results?: McpServerInstallation[]; + }; + return data.results ?? []; + } + + async installCustomMcpServer(options: { + name: string; + url: string; + auth_type: McpAuthType; + api_key?: string; + description?: string; + client_id?: string; + client_secret?: string; + install_source?: "posthog" | "posthog-code"; + posthog_code_callback_url?: string; + }): Promise<McpServerInstallation | Schemas.OAuthRedirectResponse> { + const teamId = await this.getTeamId(); + const apiUrl = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/install_custom/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url: apiUrl, + path: `/api/environments/${teamId}/mcp_server_installations/install_custom/`, + overrides: { + body: JSON.stringify(options), + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + (errorData as { detail?: string }).detail ?? + `Failed to install MCP server: ${response.statusText}`, + ); + } + + return (await response.json()) as + | McpServerInstallation + | Schemas.OAuthRedirectResponse; + } + + async updateMcpServerInstallation( + installationId: string, + updates: { + display_name?: string; + description?: string; + is_enabled?: boolean; + }, + ): Promise<McpServerInstallation> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/${installationId}/`, + ); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: `/api/environments/${teamId}/mcp_server_installations/${installationId}/`, + overrides: { + body: JSON.stringify(updates), + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + (errorData as { detail?: string }).detail ?? + `Failed to update MCP server: ${response.statusText}`, + ); + } + + return (await response.json()) as McpServerInstallation; + } + + async uninstallMcpServer(installationId: string): Promise<void> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/${installationId}/`, + ); + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path: `/api/environments/${teamId}/mcp_server_installations/${installationId}/`, + }); + + if (!response.ok && response.status !== 204) { + throw new Error(`Failed to uninstall MCP server: ${response.statusText}`); + } + } + + async installMcpTemplate(options: { + template_id: string; + api_key?: string; + install_source?: "posthog" | "posthog-code"; + posthog_code_callback_url?: string; + }): Promise<McpServerInstallation | Schemas.OAuthRedirectResponse> { + const teamId = await this.getTeamId(); + const path = `/api/environments/${teamId}/mcp_server_installations/install_template/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(`${this.api.baseUrl}${path}`), + path, + overrides: { body: JSON.stringify(options) }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + (errorData as { detail?: string }).detail ?? + `Failed to install MCP template: ${response.statusText}`, + ); + } + + return (await response.json()) as + | McpServerInstallation + | Schemas.OAuthRedirectResponse; + } + + async authorizeMcpInstallation(options: { + installation_id: string; + install_source?: "posthog" | "posthog-code"; + posthog_code_callback_url?: string; + }): Promise<Schemas.OAuthRedirectResponse> { + const teamId = await this.getTeamId(); + const path = `/api/environments/${teamId}/mcp_server_installations/authorize/`; + const url = new URL(`${this.api.baseUrl}${path}`); + url.searchParams.set("installation_id", options.installation_id); + if (options.install_source) { + url.searchParams.set("install_source", options.install_source); + } + if (options.posthog_code_callback_url) { + url.searchParams.set( + "posthog_code_callback_url", + options.posthog_code_callback_url, + ); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + (errorData as { detail?: string }).detail ?? + `Failed to authorize MCP installation: ${response.statusText}`, + ); + } + + return (await response.json()) as Schemas.OAuthRedirectResponse; + } + + async getMcpInstallationTools( + installationId: string, + options: { includeRemoved?: boolean } = {}, + ): Promise<McpInstallationTool[]> { + const teamId = await this.getTeamId(); + const path = `/api/environments/${teamId}/mcp_server_installations/${installationId}/tools/`; + const url = new URL(`${this.api.baseUrl}${path}`); + if (options.includeRemoved) { + url.searchParams.set("include_removed", "1"); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch MCP installation tools: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + results?: McpInstallationTool[]; + }; + return data.results ?? []; + } + + async updateMcpToolApproval( + installationId: string, + toolName: string, + approval_state: McpApprovalState, + ): Promise<McpInstallationTool> { + const teamId = await this.getTeamId(); + const path = `/api/environments/${teamId}/mcp_server_installations/${installationId}/tools/${encodeURIComponent(toolName)}/`; + const response = await this.api.fetcher.fetch({ + method: "patch", + url: new URL(`${this.api.baseUrl}${path}`), + path, + overrides: { body: JSON.stringify({ approval_state }) }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + (errorData as { detail?: string }).detail ?? + `Failed to update tool approval: ${response.statusText}`, + ); + } + + return (await response.json()) as McpInstallationTool; + } + + async refreshMcpInstallationTools( + installationId: string, + ): Promise<McpInstallationTool[]> { + const teamId = await this.getTeamId(); + const path = `/api/environments/${teamId}/mcp_server_installations/${installationId}/tools/refresh/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(`${this.api.baseUrl}${path}`), + path, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + (errorData as { detail?: string }).detail ?? + `Failed to refresh MCP tools: ${response.statusText}`, + ); + } + + const data = (await response.json()) as { + results?: McpInstallationTool[]; + }; + return data.results ?? []; + } + + async getMySeat( + options: { best?: boolean } = { best: true }, + ): Promise<SeatData | null> { + try { + const url = new URL(`${this.api.baseUrl}/api/seats/me/`); + url.searchParams.set("product_key", SEAT_PRODUCT_KEY); + if (options.best) { + url.searchParams.set("best", "true"); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: "/api/seats/me/", + }); + return (await response.json()) as SeatData; + } catch (error) { + if (this.isFetcherStatusError(error, 404)) { + return null; + } + throw error; + } + } + + async createSeat(planKey: string): Promise<SeatData> { + try { + const user = await this.getCurrentUser(); + const distinctId = user.distinct_id; + if (!distinctId) { + throw new Error("Cannot create seat: user has no distinct_id"); + } + const url = new URL(`${this.api.baseUrl}/api/seats/`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: "/api/seats/", + overrides: { + body: JSON.stringify({ + product_key: SEAT_PRODUCT_KEY, + plan_key: planKey, + user_distinct_id: distinctId, + }), + }, + }); + return (await response.json()) as SeatData; + } catch (error) { + this.throwSeatError(error); + } + } + + async upgradeSeat(planKey: string): Promise<SeatData> { + try { + const url = new URL(`${this.api.baseUrl}/api/seats/me/`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: "/api/seats/me/", + overrides: { + body: JSON.stringify({ + product_key: SEAT_PRODUCT_KEY, + plan_key: planKey, + }), + }, + }); + return (await response.json()) as SeatData; + } catch (error) { + this.throwSeatError(error); + } + } + + async cancelSeat(): Promise<void> { + try { + const url = new URL(`${this.api.baseUrl}/api/seats/me/`); + url.searchParams.set("product_key", SEAT_PRODUCT_KEY); + await this.api.fetcher.fetch({ + method: "delete", + url, + path: "/api/seats/me/", + }); + } catch (error) { + if (this.isFetcherStatusError(error, 204)) { + return; + } + this.throwSeatError(error); + } + } + + async reactivateSeat(): Promise<SeatData> { + try { + const url = new URL(`${this.api.baseUrl}/api/seats/me/reactivate/`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: "/api/seats/me/reactivate/", + overrides: { + body: JSON.stringify({ product_key: SEAT_PRODUCT_KEY }), + }, + }); + return (await response.json()) as SeatData; + } catch (error) { + this.throwSeatError(error); + } + } + + private isFetcherStatusError(error: unknown, status: number): boolean { + return error instanceof Error && error.message.includes(`[${status}]`); + } + + private parseFetcherError(error: unknown): { + status: number; + body: Record<string, unknown>; + } | null { + if (!(error instanceof Error)) return null; + const match = error.message.match(/\[(\d+)\]\s*(.*)/); + if (!match) return null; + try { + return { + status: Number.parseInt(match[1], 10), + body: JSON.parse(match[2]) as Record<string, unknown>, + }; + } catch { + return { status: Number.parseInt(match[1], 10), body: {} }; + } + } + + private throwSeatError(error: unknown): never { + const parsed = this.parseFetcherError(error); + + if (parsed) { + if ( + parsed.status === 400 && + typeof parsed.body.redirect_url === "string" + ) { + throw new SeatSubscriptionRequiredError(parsed.body.redirect_url); + } + if (parsed.status === 402) { + const message = + typeof parsed.body.error === "string" ? parsed.body.error : undefined; + throw new SeatPaymentFailedError(message); + } + } + + throw error; + } + + /** + * Check if a feature flag is enabled for the current project. + * Returns true if the flag exists and is active, false otherwise. + */ + async isFeatureFlagEnabled(flagKey: string): Promise<boolean> { + try { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/feature_flags/`, + ); + url.searchParams.set("key", flagKey); + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/projects/${teamId}/feature_flags/`, + }); + + if (!response.ok) { + log.warn(`Failed to fetch feature flags: ${response.statusText}`); + return false; + } + + 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, + ); + + return flag?.active ?? false; + } catch (error) { + log.warn(`Error checking feature flag "${flagKey}":`, error); + return false; + } + } + + // Sandbox Environments + + async listSandboxEnvironments(): Promise<SandboxEnvironment[]> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_environments/`, + ); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/projects/${teamId}/sandbox_environments/`, + }); + if (!response.ok) { + throw new Error( + `Failed to fetch sandbox environments: ${response.statusText}`, + ); + } + const data = (await response.json()) as { + results?: SandboxEnvironment[]; + }; + return data.results ?? []; + } + + async createSandboxEnvironment( + input: SandboxEnvironmentInput, + ): Promise<SandboxEnvironment> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_environments/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/projects/${teamId}/sandbox_environments/`, + overrides: { + body: JSON.stringify(input), + }, + }); + if (!response.ok) { + throw new Error( + `Failed to create sandbox environment: ${response.statusText}`, + ); + } + return (await response.json()) as SandboxEnvironment; + } + + async updateSandboxEnvironment( + id: string, + input: Partial<SandboxEnvironmentInput>, + ): Promise<SandboxEnvironment> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_environments/${id}/`, + ); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: `/api/projects/${teamId}/sandbox_environments/${id}/`, + overrides: { + body: JSON.stringify(input), + }, + }); + if (!response.ok) { + throw new Error( + `Failed to update sandbox environment: ${response.statusText}`, + ); + } + return (await response.json()) as SandboxEnvironment; + } + + async deleteSandboxEnvironment(id: string): Promise<void> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/sandbox_environments/${id}/`, + ); + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path: `/api/projects/${teamId}/sandbox_environments/${id}/`, + }); + if (!response.ok) { + throw new Error( + `Failed to delete sandbox environment: ${response.statusText}`, + ); + } + } + + /** Find an exported asset by session recording ID. */ + async findExportBySessionRecordingId( + projectId: number, + sessionRecordingId: string, + ): Promise<number | null> { + const urlPath = `/api/projects/${projectId}/exports/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + url.searchParams.set("session_recording_id", sessionRecordingId); + url.searchParams.set("export_format", "video/mp4"); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { + results?: Array<{ id: number; has_content: boolean }>; + }; + const match = data.results?.find((e) => e.has_content); + return match?.id ?? null; + } + + /** Get the presigned content URL for an exported asset (e.g. rasterized recording). */ + async getExportContentUrl( + projectId: number, + exportId: number, + ): Promise<string | null> { + const urlPath = `/api/projects/${projectId}/exports/${exportId}/content/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const blob = await response.blob(); + return URL.createObjectURL(blob); + } + + /** + * Fetch the requesting user's personal LLM spend analysis. `dateFrom` / `dateTo` + * accept absolute dates (`2026-04-23`) or relative strings (`-7d`, `-1m`), and + * default to the last 30 days. When `product` is set the tool / model / trace + * breakdowns are scoped to that `ai_product` (e.g. `posthog_code`); when omitted + * they aggregate across every product. + */ + async getPersonalSpendAnalysis( + options: { dateFrom?: string; dateTo?: string; product?: string } = {}, + ): Promise<SpendAnalysisResponse> { + const { dateFrom = "-30d", dateTo, product } = options; + const urlPath = `/api/llm_analytics/@me/spend/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + url.searchParams.set("date_from", dateFrom); + if (dateTo) { + url.searchParams.set("date_to", dateTo); + } + if (product) { + url.searchParams.set("product", product); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error(`Failed to fetch spend analysis: ${response.status}`); + } + return (await response.json()) as SpendAnalysisResponse; + } +} 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> = {}): 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<string>(); + 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<typeof makeDeps>; + + 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<void>; + invalidatePathFilter(): void; + setArchivedTaskIds(updater: (old: string[] | undefined) => string[]): void; + setArchiveList( + updater: (old: ArchivedTask[] | undefined) => ArchivedTask[], + ): void; +} + +export interface ArchiveOrchestrationDeps { + getWorkspace(taskId: string): Promise<ArchiveWorkspaceInfo | null>; + getPinnedTaskIds(): Promise<string[]>; + unpin(taskId: string): Promise<void>; + togglePin(taskId: string): Promise<void>; + navigateAwayFromTaskIfActive(taskId: string): void; + snapshotTerminalStates(taskId: string): Record<string, unknown>; + clearTerminalStates(taskId: string): void; + restoreTerminalStates(states: Record<string, unknown>): 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<void>; + disconnectFromTask(taskId: string): Promise<void>; + archive(taskId: string): Promise<void>; + logError(message: string, error: unknown): void; + cache: ArchiveCacheWriter; +} + +export interface ArchiveTaskOptions { + skipNavigate?: boolean; +} + +export async function archiveTask( + taskId: string, + deps: ArchiveOrchestrationDeps, + options?: ArchiveTaskOptions, +): Promise<void> { + 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<ArchiveTasksResult> { + 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<RestoreOutcome> { + 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<DeleteOutcome> { + 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<ContextMenuOutcome> { + 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<unknown>; + delete(input: { taskId: string }): Promise<unknown>; + 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<UnarchiveResult> { + 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<DeleteArchivedTaskResult> { + 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<ContextMenuActionResult> { + 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/packages/core/src/auth/auth.test.ts b/packages/core/src/auth/auth.test.ts new file mode 100644 index 0000000000..926ee51fc9 --- /dev/null +++ b/packages/core/src/auth/auth.test.ts @@ -0,0 +1,587 @@ +import type { WorkbenchLogger } from "@posthog/di/logger"; +import type { IPowerManager } from "@posthog/platform/power-manager"; +import { OAUTH_SCOPE_VERSION } from "@posthog/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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<typeof import("@posthog/shared")>(); + return { + ...actual, + sleepWithBackoff: vi.fn().mockResolvedValue(undefined), + }; +}); + +const mockPowerManager = vi.hoisted(() => ({ + onResume: vi.fn(() => () => {}), + preventSleep: vi.fn(() => () => {}), +})); + +function createSessionPort(): IAuthSessionStore { + let current: AuthSessionRecord | null = null; + return { + getCurrent: () => (current ? { ...current } : null), + saveCurrent: (input: PersistAuthSessionRecord) => { + current = { ...input }; + }, + clearCurrent: () => { + current = null; + }, + }; +} + +function createPreferencePort(): IAuthPreferenceStore { + const store = new Map<string, AuthPreferenceRecord>(); + 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: { + accessToken?: string; + refreshToken?: string; + scopedTeams?: number[]; + scopedOrgs?: string[]; + } = {}, +) { + return { + success: true as const, + data: { + access_token: overrides.accessToken ?? "access-token", + refresh_token: overrides.refreshToken ?? "refresh-token", + expires_in: 3600, + token_type: "Bearer", + scope: "", + scoped_teams: overrides.scopedTeams ?? [42], + scoped_organizations: overrides.scopedOrgs ?? ["org-1"], + }, + }; +} + +describe("AuthService", () => { + let sessionPort: IAuthSessionStore; + let preferencePort: IAuthPreferenceStore; + + const oauthFlow = { + refreshToken: vi.fn(), + startFlow: vi.fn(), + startSignupFlow: vi.fn(), + cancelFlow: vi.fn(), + }; + + let connectivityHandler: ((status: ConnectivityStatus) => void) | null = null; + const connectivity: IAuthConnectivity = { + getStatus: vi.fn(() => ({ isOnline: true })), + onStatusChange: vi.fn((handler) => { + connectivityHandler = handler; + return () => { + connectivityHandler = null; + }; + }), + }; + + let service: AuthService; + + function seedStoredSession( + overrides: { + refreshToken?: string; + selectedProjectId?: number | null; + scopeVersion?: number; + } = {}, + ) { + sessionPort.saveCurrent({ + refreshTokenEncrypted: overrides.refreshToken ?? "stored-refresh-token", + cloudRegion: "us", + selectedProjectId: overrides.selectedProjectId ?? null, + scopeVersion: overrides.scopeVersion ?? OAUTH_SCOPE_VERSION, + }); + } + + function emitStatus(isOnline: boolean) { + connectivityHandler?.({ isOnline }); + } + + function getResumeHandler(): () => void { + const call = mockPowerManager.onResume.mock.calls[0]; + return (call as unknown as [() => void])[0]; + } + + const stubAuthFetch = (accountKey = "user-1") => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | Request) => { + const url = typeof input === "string" ? input : input.url; + + if (url.includes("/api/users/@me/")) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ uuid: accountKey }), + } as unknown as Response; + } + + return { + ok: true, + json: vi.fn().mockResolvedValue({ has_access: true }), + } as unknown as Response; + }) as unknown as typeof fetch, + ); + }; + + 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(); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + service.shutdown(); + await service.logout(); + }); + + it("bootstraps to anonymous when there is no stored session", async () => { + await service.initialize(); + + expect(service.getState()).toEqual({ + status: "anonymous", + bootstrapComplete: true, + cloudRegion: null, + projectId: null, + availableProjectIds: [], + availableOrgIds: [], + hasCodeAccess: null, + needsScopeReauth: false, + }); + }); + + it("requires scope reauthentication when the stored scope version is stale", async () => { + seedStoredSession({ + refreshToken: "refresh-token", + selectedProjectId: 123, + scopeVersion: OAUTH_SCOPE_VERSION - 1, + }); + + await service.initialize(); + + expect(service.getState()).toEqual({ + status: "anonymous", + bootstrapComplete: true, + cloudRegion: "us", + projectId: 123, + availableProjectIds: [], + availableOrgIds: [], + hasCodeAccess: null, + needsScopeReauth: true, + }); + }); + + it("restores an authenticated session by refreshing the stored refresh token", async () => { + seedStoredSession({ selectedProjectId: 42 }); + oauthFlow.refreshToken.mockResolvedValue( + mockTokenResponse({ + accessToken: "new-access-token", + refreshToken: "rotated-refresh-token", + scopedTeams: [42, 84], + }), + ); + stubAuthFetch(); + + await service.initialize(); + + expect(service.getState()).toMatchObject({ + status: "authenticated", + bootstrapComplete: true, + cloudRegion: "us", + projectId: 42, + availableProjectIds: [42, 84], + availableOrgIds: ["org-1"], + hasCodeAccess: true, + needsScopeReauth: false, + }); + + expect(sessionPort.getCurrent()?.refreshTokenEncrypted).toBe( + "rotated-refresh-token", + ); + }); + + it("forces a token refresh when explicitly requested", async () => { + oauthFlow.startFlow.mockResolvedValue( + mockTokenResponse({ + accessToken: "initial-access-token", + refreshToken: "initial-refresh-token", + }), + ); + oauthFlow.refreshToken.mockResolvedValue( + mockTokenResponse({ + accessToken: "refreshed-access-token", + refreshToken: "rotated-refresh-token", + }), + ); + stubAuthFetch(); + + await service.login("us"); + const token = await service.refreshAccessToken(); + + expect(token.accessToken).toBe("refreshed-access-token"); + expect(oauthFlow.refreshToken).toHaveBeenCalledWith( + "initial-refresh-token", + "us", + ); + expect(sessionPort.getCurrent()?.refreshTokenEncrypted).toBe( + "rotated-refresh-token", + ); + }); + + it("preserves the selected project across logout and re-login for the same account", async () => { + oauthFlow.startFlow + .mockResolvedValueOnce( + mockTokenResponse({ + accessToken: "initial-access-token", + refreshToken: "initial-refresh-token", + scopedTeams: [42, 84], + }), + ) + .mockResolvedValueOnce( + mockTokenResponse({ + accessToken: "second-access-token", + refreshToken: "second-refresh-token", + scopedTeams: [42, 84], + }), + ); + oauthFlow.refreshToken.mockResolvedValue( + mockTokenResponse({ + accessToken: "refreshed-access-token", + refreshToken: "refreshed-refresh-token", + scopedTeams: [42, 84], + }), + ); + stubAuthFetch(); + + await service.login("us"); + await service.selectProject(84); + await service.logout(); + + expect(service.getState()).toMatchObject({ + status: "anonymous", + cloudRegion: "us", + projectId: 84, + }); + + await service.login("us"); + + expect(service.getState()).toMatchObject({ + status: "authenticated", + cloudRegion: "us", + projectId: 84, + availableProjectIds: [42, 84], + }); + }); + + it("restores the selected project after app restart while logged out", async () => { + oauthFlow.startFlow + .mockResolvedValueOnce( + mockTokenResponse({ + accessToken: "initial-access-token", + refreshToken: "initial-refresh-token", + scopedTeams: [42, 84], + }), + ) + .mockResolvedValueOnce( + mockTokenResponse({ + accessToken: "second-access-token", + refreshToken: "second-refresh-token", + scopedTeams: [42, 84], + }), + ); + oauthFlow.refreshToken.mockResolvedValue( + mockTokenResponse({ + accessToken: "refreshed-access-token", + refreshToken: "refreshed-refresh-token", + scopedTeams: [42, 84], + }), + ); + stubAuthFetch(); + + await service.login("us"); + await service.selectProject(84); + await service.logout(); + + service = createService(); + + await service.login("us"); + + expect(service.getState()).toMatchObject({ + status: "authenticated", + cloudRegion: "us", + projectId: 84, + availableProjectIds: [42, 84], + }); + }); + + describe("lifecycle: connectivity recovery", () => { + it("recovers session when connectivity changes to online", async () => { + seedStoredSession({ selectedProjectId: 42 }); + vi.mocked(connectivity.getStatus).mockReturnValue({ isOnline: false }); + await service.initialize(); + expect(service.getState().status).toBe("anonymous"); + + vi.mocked(connectivity.getStatus).mockReturnValue({ isOnline: true }); + oauthFlow.refreshToken.mockResolvedValue(mockTokenResponse()); + stubAuthFetch(); + + emitStatus(true); + + await vi.waitFor(() => { + expect(service.getState().status).toBe("authenticated"); + }); + }); + + it("does nothing when session already exists", async () => { + oauthFlow.startFlow.mockResolvedValue(mockTokenResponse()); + stubAuthFetch(); + await service.login("us"); + oauthFlow.refreshToken.mockClear(); + + emitStatus(true); + + await new Promise((r) => setTimeout(r, 10)); + expect(oauthFlow.refreshToken).not.toHaveBeenCalled(); + }); + + it("ignores offline events", async () => { + seedStoredSession(); + + emitStatus(false); + + await new Promise((r) => setTimeout(r, 10)); + expect(oauthFlow.refreshToken).not.toHaveBeenCalled(); + }); + + it("deduplicates concurrent recovery attempts", async () => { + seedStoredSession(); + + let resolveRefresh!: () => void; + oauthFlow.refreshToken.mockReturnValue( + new Promise((resolve) => { + resolveRefresh = () => resolve(mockTokenResponse()); + }), + ); + stubAuthFetch(); + + emitStatus(true); + emitStatus(true); + + await new Promise((r) => setTimeout(r, 10)); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1); + + resolveRefresh(); + + await vi.waitFor(() => { + expect(service.getState().status).toBe("authenticated"); + }); + }); + }); + + describe("lifecycle: power monitor resume", () => { + it("registers and unregisters the resume handler", () => { + expect(mockPowerManager.onResume).toHaveBeenCalledWith( + expect.any(Function), + ); + const unsubscribe = mockPowerManager.onResume.mock.results[0]?.value as + | (() => void) + | undefined; + + service.shutdown(); + expect(unsubscribe).toBeDefined(); + }); + + it("attempts session recovery on resume", async () => { + seedStoredSession(); + oauthFlow.refreshToken.mockResolvedValue(mockTokenResponse()); + stubAuthFetch(); + + getResumeHandler()(); + + await vi.waitFor(() => { + expect(service.getState().status).toBe("authenticated"); + }); + }); + }); + + describe("refresh retry with error codes", () => { + it.each([ + { errorCode: "network_error" as const, label: "network_error" }, + { errorCode: "server_error" as const, label: "server_error" }, + ])( + "retries on $label and succeeds on second attempt", + async ({ errorCode }) => { + seedStoredSession(); + oauthFlow.refreshToken + .mockResolvedValueOnce({ + success: false, + error: "Transient failure", + errorCode, + }) + .mockResolvedValueOnce(mockTokenResponse()); + stubAuthFetch(); + + await service.initialize(); + + expect(service.getState().status).toBe("authenticated"); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(2); + }, + ); + + it("does not retry on auth_error and forces logout", async () => { + seedStoredSession({ selectedProjectId: 42 }); + oauthFlow.refreshToken.mockResolvedValue({ + success: false, + error: "Token revoked", + errorCode: "auth_error", + }); + + await service.initialize(); + + expect(service.getState()).toMatchObject({ + status: "anonymous", + cloudRegion: "us", + projectId: 42, + }); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1); + expect(sessionPort.getCurrent()).toBeNull(); + }); + + it("does not retry on unknown_error", async () => { + seedStoredSession(); + oauthFlow.refreshToken.mockResolvedValue({ + success: false, + error: "Something weird", + errorCode: "unknown_error", + }); + + await service.initialize(); + + expect(service.getState().status).toBe("anonymous"); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(1); + }); + + it("gives up after all retry attempts are exhausted", async () => { + seedStoredSession(); + oauthFlow.refreshToken.mockResolvedValue({ + success: false, + error: "Network error", + errorCode: "network_error", + }); + + await service.initialize(); + + expect(service.getState().status).toBe("anonymous"); + expect(oauthFlow.refreshToken).toHaveBeenCalledTimes(3); + }); + }); + + describe("redeemInviteCode uses authenticatedFetch", () => { + it("retries on 401 via authenticatedFetch", async () => { + oauthFlow.startFlow.mockResolvedValue( + mockTokenResponse({ + accessToken: "initial-token", + refreshToken: "refresh-token", + }), + ); + oauthFlow.refreshToken.mockResolvedValue( + mockTokenResponse({ + accessToken: "refreshed-token", + refreshToken: "new-refresh-token", + }), + ); + + let redeemCallCount = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | Request) => { + const url = typeof input === "string" ? input : input.url; + + if (url.includes("/api/users/@me/")) { + return { + ok: true, + json: vi.fn().mockResolvedValue({ uuid: "user-1" }), + } as unknown as Response; + } + + if (url.includes("/invites/redeem/")) { + redeemCallCount++; + if (redeemCallCount === 1) { + return { + ok: false, + status: 401, + json: () => Promise.resolve({}), + } as unknown as Response; + } + return { + ok: true, + status: 200, + json: () => Promise.resolve({ success: true }), + } as unknown as Response; + } + + return { + ok: true, + json: vi.fn().mockResolvedValue({ has_access: true }), + } as unknown as Response; + }) as unknown as typeof fetch, + ); + + await service.login("us"); + const state = await service.redeemInviteCode("test-code"); + + expect(state.hasCodeAccess).toBe(true); + expect(redeemCallCount).toBe(2); + }); + }); +}); diff --git a/packages/core/src/auth/auth.ts b/packages/core/src/auth/auth.ts new file mode 100644 index 0000000000..faca6b10d0 --- /dev/null +++ b/packages/core/src/auth/auth.ts @@ -0,0 +1,676 @@ +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 { + 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, + type AuthState, + type AuthTokenResponse, + type ValidAccessTokenOutput, +} from "./schemas"; + +const TOKEN_EXPIRY_SKEW_MS = 60_000; +type FetchLike = ( + input: string | Request, + init?: RequestInit, +) => Promise<Response>; + +interface InMemorySession { + accountKey: string | null; + accessToken: string; + accessTokenExpiresAt: number; + refreshToken: string; + cloudRegion: CloudRegion; + projectId: number | null; + availableProjectIds: number[]; + availableOrgIds: string[]; +} + +interface StoredSessionInput { + refreshToken: string; + cloudRegion: CloudRegion; + selectedProjectId: number | null; +} + +interface TokenResponseOptions { + cloudRegion: CloudRegion; + selectedProjectId: number | null; +} + +@injectable() +export class AuthService extends TypedEventEmitter<AuthServiceEvents> { + private state: AuthState = { + status: "anonymous", + bootstrapComplete: false, + cloudRegion: null, + projectId: null, + availableProjectIds: [], + availableOrgIds: [], + hasCodeAccess: null, + needsScopeReauth: false, + }; + private session: InMemorySession | null = null; + private initializePromise: Promise<void> | null = null; + private refreshPromise: Promise<InMemorySession> | null = null; + constructor( + @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(); + } + async initialize(): Promise<void> { + if (this.initializePromise) { + return this.initializePromise; + } + + this.initializePromise = this.doInitialize(); + return this.initializePromise; + } + getState(): AuthState { + return { ...this.state }; + } + async login(region: CloudRegion): Promise<AuthState> { + await this.authenticateWithFlow( + () => this.oauthFlow.startFlow(region), + region, + "OAuth flow failed", + ); + return this.getState(); + } + async signup(region: CloudRegion): Promise<AuthState> { + await this.authenticateWithFlow( + () => this.oauthFlow.startSignupFlow(region), + region, + "Signup failed", + ); + return this.getState(); + } + async getValidAccessToken(): Promise<ValidAccessTokenOutput> { + const override = this.tokenOverride; + if (override) { + await this.initialize(); + const region = this.session?.cloudRegion ?? "us"; + return { + accessToken: override, + apiHost: getCloudUrlFromRegion(region), + }; + } + + await this.initialize(); + + const session = await this.ensureValidSession(); + return { + accessToken: session.accessToken, + apiHost: getCloudUrlFromRegion(session.cloudRegion), + }; + } + async refreshAccessToken(): Promise<ValidAccessTokenOutput> { + const override = this.tokenOverride; + if (override) { + await this.initialize(); + const region = this.session?.cloudRegion ?? "us"; + return { + accessToken: override, + apiHost: getCloudUrlFromRegion(region), + }; + } + + await this.initialize(); + + const session = await this.ensureValidSession(true); + return { + accessToken: session.accessToken, + apiHost: getCloudUrlFromRegion(session.cloudRegion), + }; + } + async invalidateAccessTokenForTest(): Promise<void> { + await this.initialize(); + + if (!this.session) { + return; + } + + this.session = { + ...this.session, + accessToken: `${this.session.accessToken}_invalid`, + accessTokenExpiresAt: Date.now() + 5 * 60 * 1000, + }; + } + async authenticatedFetch( + fetchImpl: FetchLike, + input: string | Request, + init: RequestInit = {}, + ): Promise<Response> { + const initialAuth = await this.getValidAccessToken(); + let response = await this.executeAuthenticatedFetch( + fetchImpl, + input, + init, + initialAuth.accessToken, + ); + + if (response.status === 401 || response.status === 403) { + const refreshedAuth = await this.refreshAccessToken(); + response = await this.executeAuthenticatedFetch( + fetchImpl, + input, + init, + refreshedAuth.accessToken, + ); + } + + return response; + } + async redeemInviteCode(code: string): Promise<AuthState> { + const { apiHost } = await this.getValidAccessToken(); + const response = await this.authenticatedFetch( + fetch, + `${apiHost}/api/code/invites/redeem/`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + }, + ); + + const data = (await response.json().catch(() => ({}))) as { + success?: boolean; + error?: string; + }; + + if (!response.ok || !data.success) { + throw new Error(data.error || "Failed to redeem invite code"); + } + + this.updateState({ hasCodeAccess: true }); + return this.getState(); + } + async selectProject(projectId: number): Promise<AuthState> { + await this.initialize(); + + const session = this.requireSession(); + + if (!session.availableProjectIds.includes(projectId)) { + throw new Error("Invalid project selection"); + } + + this.session = { + ...session, + projectId, + }; + + this.persistProjectPreference(this.session); + this.persistSession({ + refreshToken: this.session.refreshToken, + cloudRegion: this.session.cloudRegion, + selectedProjectId: projectId, + }); + + this.updateState({ projectId }); + return this.getState(); + } + async logout(): Promise<AuthState> { + const { cloudRegion, projectId } = this.state; + + this.authSession.clearCurrent(); + this.session = null; + this.setAnonymousState({ cloudRegion, projectId }); + return this.getState(); + } + private executeAuthenticatedFetch( + fetchImpl: FetchLike, + input: string | Request, + init: RequestInit, + accessToken: string, + ): Promise<Response> { + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${accessToken}`); + + return fetchImpl(input, { + ...init, + headers, + }); + } + private async doInitialize(): Promise<void> { + const stored = this.authSession.getCurrent(); + + if (!stored) { + this.setAnonymousState({ bootstrapComplete: true }); + return; + } + + if (stored.scopeVersion < OAUTH_SCOPE_VERSION) { + this.session = null; + this.setAnonymousState({ + bootstrapComplete: true, + cloudRegion: stored.cloudRegion, + projectId: stored.selectedProjectId, + needsScopeReauth: true, + }); + return; + } + + const storedSession = this.resolveStoredSession(); + if (!storedSession) { + this.logger.warn("Stored auth session could not be decrypted"); + this.authSession.clearCurrent(); + this.setAnonymousState({ bootstrapComplete: true }); + return; + } + + try { + await this.refreshAndSyncSession(storedSession); + } catch (error) { + this.logger.warn("Failed to restore stored auth session", { error }); + this.session = null; + this.setAnonymousState({ + bootstrapComplete: true, + cloudRegion: storedSession.cloudRegion, + projectId: storedSession.selectedProjectId, + }); + } + } + private async ensureValidSession( + forceRefresh = false, + ): Promise<InMemorySession> { + if ( + this.session && + !forceRefresh && + !this.isSessionExpiring(this.session) + ) { + return this.session; + } + + if (this.refreshPromise) { + return this.refreshPromise; + } + + const sessionInput = this.getSessionInputForRefresh(); + + this.refreshPromise = this.refreshSession(sessionInput).finally(() => { + this.refreshPromise = null; + }); + + const session = await this.refreshPromise; + await this.syncAuthenticatedSession(session); + return session; + } + + private getSessionInputForRefresh(): StoredSessionInput { + if (this.session) { + return { + refreshToken: this.session.refreshToken, + cloudRegion: this.session.cloudRegion, + selectedProjectId: this.session.projectId, + }; + } + + const storedSession = this.resolveStoredSession(); + if (!storedSession) { + throw new NotAuthenticatedError(); + } + + return storedSession; + } + private async refreshSession( + input: StoredSessionInput, + ): Promise<InMemorySession> { + if (!this.connectivity.getStatus().isOnline) { + throw new Error("Offline"); + } + + let lastError = "Token refresh failed"; + + for ( + let attempt = 0; + attempt < AuthService.REFRESH_MAX_ATTEMPTS; + attempt++ + ) { + const result = await this.oauthFlow.refreshToken( + input.refreshToken, + input.cloudRegion, + ); + + if (result.success && result.data) { + return await this.createSessionFromTokenResponse(result.data, input); + } + + lastError = result.error || "Token refresh failed"; + + if (result.errorCode === "auth_error") { + this.logger.warn("Refresh token rejected by server, forcing logout"); + this.authSession.clearCurrent(); + this.session = null; + this.setAnonymousState({ + cloudRegion: input.cloudRegion, + projectId: input.selectedProjectId, + }); + throw new Error(lastError); + } + + const isRetryable = + result.errorCode === "network_error" || + result.errorCode === "server_error"; + + if (!isRetryable) { + throw new Error(lastError); + } + + const isLastAttempt = attempt === AuthService.REFRESH_MAX_ATTEMPTS - 1; + if (isLastAttempt) break; + + this.logger.warn("Transient refresh failure, retrying", { + attempt, + errorCode: result.errorCode, + }); + await sleepWithBackoff(attempt, AuthService.REFRESH_BACKOFF); + } + + throw new Error(lastError); + } + private async createSessionFromTokenResponse( + tokenResponse: AuthTokenResponse, + options: TokenResponseOptions, + ): Promise<InMemorySession> { + const availableProjectIds = tokenResponse.scoped_teams ?? []; + const availableOrgIds = tokenResponse.scoped_organizations ?? []; + const accountKey = await this.fetchAccountKey( + tokenResponse.access_token, + options.cloudRegion, + ); + const preferredProjectId = + options.selectedProjectId ?? + (accountKey + ? (this.authPreference.get(accountKey, options.cloudRegion) + ?.lastSelectedProjectId ?? null) + : null); + const projectId = + preferredProjectId && availableProjectIds.includes(preferredProjectId) + ? preferredProjectId + : (availableProjectIds[0] ?? null); + + const session: InMemorySession = { + accountKey, + accessToken: tokenResponse.access_token, + accessTokenExpiresAt: Date.now() + tokenResponse.expires_in * 1000, + refreshToken: tokenResponse.refresh_token, + cloudRegion: options.cloudRegion, + projectId, + availableProjectIds, + availableOrgIds, + }; + + return session; + } + private async authenticateWithFlow( + runFlow: () => Promise<{ + success: boolean; + data?: AuthTokenResponse; + error?: string; + }>, + region: CloudRegion, + fallbackError: string, + ): Promise<void> { + const result = await runFlow(); + if (!result.success || !result.data) { + throw new Error(result.error || fallbackError); + } + + const session = await this.createSessionFromTokenResponse(result.data, { + cloudRegion: region, + selectedProjectId: this.state.projectId, + }); + await this.syncAuthenticatedSession(session); + } + private async refreshAndSyncSession( + input: StoredSessionInput, + ): Promise<void> { + const session = await this.refreshSession(input); + await this.syncAuthenticatedSession(session); + } + private async syncAuthenticatedSession( + session: InMemorySession, + ): Promise<void> { + this.persistProjectPreference(session); + this.persistSession({ + refreshToken: session.refreshToken, + cloudRegion: session.cloudRegion, + selectedProjectId: session.projectId, + }); + + this.session = session; + this.updateState({ + status: "authenticated", + bootstrapComplete: true, + cloudRegion: session.cloudRegion, + projectId: session.projectId, + availableProjectIds: session.availableProjectIds, + availableOrgIds: session.availableOrgIds, + needsScopeReauth: false, + }); + await this.updateCodeAccessFromSession(); + } + private persistSession(input: { + refreshToken: string; + cloudRegion: CloudRegion; + selectedProjectId: number | null; + }): void { + this.authSession.saveCurrent({ + refreshTokenEncrypted: this.cipher.encrypt(input.refreshToken), + cloudRegion: input.cloudRegion, + selectedProjectId: input.selectedProjectId, + scopeVersion: OAUTH_SCOPE_VERSION, + }); + } + private persistProjectPreference(session: InMemorySession): void { + if (!session.accountKey) { + return; + } + + this.authPreference.save({ + accountKey: session.accountKey, + cloudRegion: session.cloudRegion, + lastSelectedProjectId: session.projectId, + }); + } + private isSessionExpiring(session: InMemorySession): boolean { + return session.accessTokenExpiresAt - Date.now() <= TOKEN_EXPIRY_SKEW_MS; + } + private async fetchAccountKey( + accessToken: string, + cloudRegion: CloudRegion, + ): Promise<string | null> { + try { + const response = await fetch( + `${getCloudUrlFromRegion(cloudRegion)}/api/users/@me/`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + return null; + } + + const data = (await response.json().catch(() => ({}))) as { + uuid?: unknown; + distinct_id?: unknown; + email?: unknown; + }; + + if (typeof data.uuid === "string" && data.uuid.length > 0) { + return data.uuid; + } + if (typeof data.distinct_id === "string" && data.distinct_id.length > 0) { + return data.distinct_id; + } + if (typeof data.email === "string" && data.email.length > 0) { + return data.email; + } + + return null; + } catch (error) { + this.logger.warn("Failed to resolve auth account key", { error }); + return null; + } + } + private requireSession(): InMemorySession { + if (!this.session) { + throw new NotAuthenticatedError(); + } + return this.session; + } + private setAnonymousState( + partial: Pick< + Partial<AuthState>, + "bootstrapComplete" | "cloudRegion" | "projectId" | "needsScopeReauth" + > = {}, + ): void { + this.updateState({ + status: "anonymous", + bootstrapComplete: partial.bootstrapComplete ?? true, + cloudRegion: partial.cloudRegion ?? null, + projectId: partial.projectId ?? null, + availableProjectIds: [], + availableOrgIds: [], + hasCodeAccess: null, + needsScopeReauth: partial.needsScopeReauth ?? false, + }); + } + private async updateCodeAccessFromSession(): Promise<void> { + if (!this.session) { + this.updateState({ hasCodeAccess: null }); + return; + } + + try { + const apiHost = getCloudUrlFromRegion(this.session.cloudRegion); + const response = await this.executeAuthenticatedFetch( + fetch, + `${apiHost}/api/code/invites/check-access/`, + {}, + this.session.accessToken, + ); + const data = (await response.json().catch(() => ({}))) as { + has_access?: boolean; + }; + + this.updateState({ hasCodeAccess: data.has_access === true }); + } catch (error) { + this.logger.warn("Failed to update code access state", { error }); + this.updateState({ hasCodeAccess: false }); + } + } + private static readonly REFRESH_MAX_ATTEMPTS = 3; + private static readonly REFRESH_BACKOFF: BackoffOptions = { + initialDelayMs: 1_000, + maxDelayMs: 5_000, + multiplier: 2, + }; + private recoveryPromise: Promise<void> | null = null; + private connectivityUnsubscribe: (() => void) | null = null; + private resumeUnsubscribe: (() => void) | null = null; + @postConstruct() + init(): void { + this.connectivityUnsubscribe = this.connectivity.onStatusChange( + (status) => { + if (status.isOnline) { + this.attemptSessionRecovery(); + } + }, + ); + + this.resumeUnsubscribe = this.powerManager.onResume(this.handleResume); + } + @preDestroy() + shutdown(): void { + this.connectivityUnsubscribe?.(); + this.connectivityUnsubscribe = null; + this.resumeUnsubscribe?.(); + this.resumeUnsubscribe = null; + } + private handleResume = (): void => { + this.attemptSessionRecovery(); + }; + private resolveStoredSession(): StoredSessionInput | null { + const stored = this.authSession.getCurrent(); + if (!stored) return null; + + const refreshToken = this.cipher.decrypt(stored.refreshTokenEncrypted); + if (!refreshToken) return null; + + return { + refreshToken, + cloudRegion: stored.cloudRegion, + selectedProjectId: stored.selectedProjectId, + }; + } + private attemptSessionRecovery(): void { + if (this.session) return; + if (this.recoveryPromise) return; + + const stored = this.authSession.getCurrent(); + if (!stored) return; + if (stored.scopeVersion < OAUTH_SCOPE_VERSION) return; + + const storedSession = this.resolveStoredSession(); + if (!storedSession) return; + + this.recoveryPromise = this.refreshAndSyncSession(storedSession) + .catch((error) => { + this.logger.warn("Session recovery failed", { error }); + }) + .finally(() => { + this.recoveryPromise = null; + }); + } + + private updateState(partial: Partial<AuthState>): void { + this.state = { + ...this.state, + ...partial, + }; + this.emit(AuthServiceEvent.StateChanged, this.getState()); + } +} 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<StartFlowOutput>; + startSignupFlow(region: CloudRegion): Promise<StartFlowOutput>; + refreshToken( + refreshToken: string, + region: CloudRegion, + ): Promise<RefreshTokenOutput>; + 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/packages/core/src/auth/oauth.schemas.ts b/packages/core/src/auth/oauth.schemas.ts new file mode 100644 index 0000000000..e909c4471b --- /dev/null +++ b/packages/core/src/auth/oauth.schemas.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; + +export const cloudRegion = z.enum(["us", "eu", "dev"]); +export type CloudRegion = z.infer<typeof cloudRegion>; + +/** + * Error codes for OAuth operations. + * - network_error: Transient network issue, should retry + * - server_error: Server error (5xx), should retry + * - auth_error: Authentication failed (invalid token, 401/403), should logout + * - unknown_error: Other errors + */ +export const oAuthErrorCode = z.enum([ + "network_error", + "server_error", + "auth_error", + "unknown_error", +]); +export type OAuthErrorCode = z.infer<typeof oAuthErrorCode>; + +export const oAuthTokenResponse = z.object({ + access_token: z.string(), + expires_in: z.number(), + token_type: z.string(), + scope: z.string().optional().default(""), + refresh_token: z.string(), + scoped_teams: z.array(z.number()).optional(), + scoped_organizations: z.array(z.string()).optional(), +}); +export type OAuthTokenResponse = z.infer<typeof oAuthTokenResponse>; + +export const startFlowInput = z.object({ + region: cloudRegion, +}); +export type StartFlowInput = z.infer<typeof startFlowInput>; + +export const startFlowOutput = z.object({ + success: z.boolean(), + data: oAuthTokenResponse.optional(), + error: z.string().optional(), + errorCode: oAuthErrorCode.optional(), +}); +export type StartFlowOutput = z.infer<typeof startFlowOutput>; + +export const startSignupFlowInput = startFlowInput; +export type StartSignupFlowInput = z.infer<typeof startSignupFlowInput>; + +export const refreshTokenInput = z.object({ + refreshToken: z.string(), + region: cloudRegion, +}); +export type RefreshTokenInput = z.infer<typeof refreshTokenInput>; + +export const refreshTokenOutput = z.object({ + success: z.boolean(), + data: oAuthTokenResponse.optional(), + error: z.string().optional(), + errorCode: oAuthErrorCode.optional(), +}); +export type RefreshTokenOutput = z.infer<typeof refreshTokenOutput>; + +export const cancelFlowOutput = z.object({ + success: z.boolean(), + error: z.string().optional(), +}); +export type CancelFlowOutput = z.infer<typeof cancelFlowOutput>; + +export const openExternalUrlInput = z.object({ + url: z.string().url(), +}); +export type OpenExternalUrlInput = z.infer<typeof openExternalUrlInput>; diff --git a/packages/core/src/auth/schemas.ts b/packages/core/src/auth/schemas.ts new file mode 100644 index 0000000000..9f2e7fd26a --- /dev/null +++ b/packages/core/src/auth/schemas.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { cloudRegion, type oAuthTokenResponse } from "./oauth.schemas"; + +export const authStatusSchema = z.enum(["anonymous", "authenticated"]); +export type AuthStatus = z.infer<typeof authStatusSchema>; + +export const authStateSchema = z.object({ + status: authStatusSchema, + bootstrapComplete: z.boolean(), + cloudRegion: cloudRegion.nullable(), + projectId: z.number().nullable(), + availableProjectIds: z.array(z.number()), + availableOrgIds: z.array(z.string()), + hasCodeAccess: z.boolean().nullable(), + needsScopeReauth: z.boolean(), +}); +export type AuthState = z.infer<typeof authStateSchema>; + +export const loginInput = z.object({ + region: cloudRegion, +}); +export type LoginInput = z.infer<typeof loginInput>; + +export const loginOutput = z.object({ + state: authStateSchema, +}); +export type LoginOutput = z.infer<typeof loginOutput>; + +export const redeemInviteCodeInput = z.object({ + code: z.string().min(1), +}); + +export const selectProjectInput = z.object({ + projectId: z.number(), +}); + +export const validAccessTokenOutput = z.object({ + accessToken: z.string(), + apiHost: z.string(), +}); +export type ValidAccessTokenOutput = z.infer<typeof validAccessTokenOutput>; + +export const AuthServiceEvent = { + StateChanged: "state-changed", +} as const; + +export interface AuthServiceEvents { + [AuthServiceEvent.StateChanged]: AuthState; +} + +export type AuthTokenResponse = z.infer<typeof oAuthTokenResponse>; 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<SeatData | null>; + createSeat(planKey: string): Promise<SeatData>; + upgradeSeat(planKey: string): Promise<SeatData>; + cancelSeat(): Promise<void>; + reactivateSeat(): Promise<SeatData>; + 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..fec2606fdf --- /dev/null +++ b/packages/core/src/billing/seatService.test.ts @@ -0,0 +1,280 @@ +import { + PLAN_FREE, + PLAN_PRO, + PLAN_PRO_ALPHA, + type SeatData, +} from "@posthog/shared"; +import type { WorkbenchLogger } from "@posthog/di/logger"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SeatClient } from "./identifiers"; +import { SeatService } from "./seatService"; + +function makeSeat(overrides: Partial<SeatData> = {}): 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> = {}): 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..21808d29b0 --- /dev/null +++ b/packages/core/src/billing/seatService.ts @@ -0,0 +1,178 @@ +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<SeatData | null> { + 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<SeatOperationResult> { + 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<SeatOperationResult> { + 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<SeatOperationResult> { + 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<SeatOperationResult> { + 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<SeatOperationResult> { + 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> = {}): 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/packages/core/src/billing/seatView.ts b/packages/core/src/billing/seatView.ts new file mode 100644 index 0000000000..f85a79283e --- /dev/null +++ b/packages/core/src/billing/seatView.ts @@ -0,0 +1,41 @@ +import { isProPlan, type SeatData, seatHasAccess } from "@posthog/shared"; + +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; + const isCanceling = orgSeat?.status === "canceling"; + const planLabel = isPro ? "Pro" : "Free"; + const activeUntil = orgSeat?.active_until + ? new Date(orgSeat.active_until * 1000) + : null; + + const hasBetterPlanElsewhere = + seat !== null && + orgSeat !== null && + isProPlan(seat.plan_key) && + !isProPlan(orgSeat.plan_key); + + return { + isPro, + isOrgPro, + hasAccess, + isCanceling, + planLabel, + activeUntil, + hasBetterPlanElsewhere, + }; +} diff --git a/packages/core/src/billing/spendAnalysisFormat.ts b/packages/core/src/billing/spendAnalysisFormat.ts new file mode 100644 index 0000000000..2cc26238db --- /dev/null +++ b/packages/core/src/billing/spendAnalysisFormat.ts @@ -0,0 +1,22 @@ +export function formatUsd(amount: number): string { + if (amount === 0) return "$0"; + if (amount < 0.01) return "<$0.01"; + if (amount < 100) return `$${amount.toFixed(2)}`; + return `$${Math.round(amount).toLocaleString()}`; +} + +export function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`; + return n.toString(); +} + +export function windowDays(fromIso: string, toIso: string): number { + const fromMs = new Date(fromIso).getTime(); + const toMs = new Date(toIso).getTime(); + 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<TRow> { + items: TRow[]; + truncated: boolean; +} + +export interface SpendAnalysisResponse { + summary: SpendAnalysisSummary; + by_product: SpendAnalysisBreakdown<SpendAnalysisProductRow>; + by_tool: SpendAnalysisBreakdown<SpendAnalysisToolRow>; + by_model: SpendAnalysisBreakdown<SpendAnalysisModelRow>; +} 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/packages/core/src/billing/usageDisplay.test.ts b/packages/core/src/billing/usageDisplay.test.ts new file mode 100644 index 0000000000..b9af92bd72 --- /dev/null +++ b/packages/core/src/billing/usageDisplay.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import type { UsageOutput } from "../usage/schemas"; +import { formatResetTime, isUsageExceeded } from "./usageDisplay"; + +function makeUsage( + overrides: Partial<{ + sustained: boolean; + burst: boolean; + isRateLimited: boolean; + }> = {}, +): UsageOutput { + return { + product: "posthog_code", + user_id: 1, + sustained: { + used_percent: 50, + reset_at: "2026-05-01T13:00:00.000Z", + exceeded: overrides.sustained ?? false, + }, + burst: { + used_percent: 30, + reset_at: "2026-05-01T12:10:00.000Z", + exceeded: overrides.burst ?? false, + }, + is_rate_limited: overrides.isRateLimited ?? false, + is_pro: false, + }; +} + +describe("isUsageExceeded", () => { + it("returns false when nothing is exceeded", () => { + expect(isUsageExceeded(makeUsage())).toBe(false); + }); + + it("returns true when sustained is exceeded", () => { + expect(isUsageExceeded(makeUsage({ sustained: true }))).toBe(true); + }); + + it("returns true when burst is exceeded", () => { + expect(isUsageExceeded(makeUsage({ burst: true }))).toBe(true); + }); + + it("returns true when rate limited", () => { + expect(isUsageExceeded(makeUsage({ isRateLimited: true }))).toBe(true); + }); + + it("returns true when all flags are set", () => { + expect( + isUsageExceeded( + makeUsage({ sustained: true, burst: true, isRateLimited: true }), + ), + ).toBe(true); + }); +}); + +describe("formatResetTime", () => { + const NOW = Date.parse("2026-05-01T12:00:00.000Z"); + const isoAt = (msFromNow: number) => new Date(NOW + msFromNow).toISOString(); + + it.each([ + { + name: "returns minutes-only under 1h", + resetAt: isoAt(30 * 60 * 1000), + expected: "Resets in 30m" as string | RegExp, + }, + { + name: "returns hours + minutes under 24h", + resetAt: isoAt((4 * 3600 + 30 * 60) * 1000), + expected: "Resets in 4h 30m", + }, + { + name: "returns hours only when minutes round to 0", + resetAt: isoAt(4 * 3600 * 1000), + expected: "Resets in 4h", + }, + { + name: "returns localized date when over 24h away", + resetAt: isoAt(30 * 86400 * 1000), + expected: /^Resets [A-Za-z]+ \d+ at /, + }, + { + name: "treats an already-past reset_at as shortly", + resetAt: isoAt(-60_000), + expected: "Resets shortly", + }, + { + name: "treats an unparseable reset_at as shortly", + resetAt: "not-a-date", + expected: "Resets shortly", + }, + ])("$name", ({ resetAt, expected }) => { + const result = formatResetTime(resetAt, NOW); + if (expected instanceof RegExp) { + expect(result).toMatch(expected); + } else { + expect(result).toBe(expected); + } + }); +}); diff --git a/packages/core/src/billing/usageDisplay.ts b/packages/core/src/billing/usageDisplay.ts new file mode 100644 index 0000000000..6d6d83b981 --- /dev/null +++ b/packages/core/src/billing/usageDisplay.ts @@ -0,0 +1,40 @@ +import type { UsageOutput } from "../usage/schemas"; + +export function isUsageExceeded(usage: UsageOutput): boolean { + return ( + usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded + ); +} + +export function formatResetTime( + resetAtIso: string, + now: number = Date.now(), +): string { + const parsed = Date.parse(resetAtIso); + const ms = Number.isNaN(parsed) ? 0 : Math.max(0, parsed - now); + + const totalMinutes = Math.ceil(ms / 60_000); + if (totalMinutes <= 0) return "Resets shortly"; + if (totalMinutes < 60) return `Resets in ${totalMinutes}m`; + + const totalHours = ms / 3_600_000; + if (totalHours < 24) { + const hours = Math.floor(totalHours); + const minutes = Math.round((totalHours - hours) * 60); + return minutes === 0 + ? `Resets in ${hours}h` + : `Resets in ${hours}h ${minutes}m`; + } + + const target = new Date(now + ms); + const date = target.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + const time = target.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }); + return `Resets ${date} at ${time}`; +} 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>): 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<string, CloneOperation>, + repository: string, +): boolean { + return Object.values(operations).some( + (op) => op.status === "cloning" && op.repository === repository, + ); +} + +export function findCloneForRepo( + operations: Record<string, CloneOperation>, + 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<string, unknown> | 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<string, unknown> | 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<string, unknown>; +} + +export interface CloudTaskPermissionRequestUpdate extends CloudTaskUpdateBase { + kind: "permission_request"; + requestId: string; + toolCall: { + toolCallId: string; + title: string; + kind: string; + content?: unknown[]; + rawInput?: Record<string, unknown>; + _meta?: Record<string, unknown>; + }; + 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/packages/core/src/cloud-task/cloud-task.test.ts b/packages/core/src/cloud-task/cloud-task.test.ts new file mode 100644 index 0000000000..223a3eb213 --- /dev/null +++ b/packages/core/src/cloud-task/cloud-task.test.ts @@ -0,0 +1,1616 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CloudTaskEvent } from "./schemas"; + +const mockNetFetch = vi.hoisted(() => vi.fn()); +const mockStreamFetch = vi.hoisted(() => vi.fn()); + +// The service now uses global fetch for BOTH authenticated API calls (JSON) +// and SSE streaming. The two used to be distinct (net.fetch vs global fetch). +// To preserve the existing test fixtures, route by URL: /stream/ → stream mock, +// everything else → API mock. +const fetchRouter = vi.hoisted(() => + vi.fn((input: string | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.url; + const impl = url.includes("/stream/") ? mockStreamFetch : mockNetFetch; + return impl(input, init); + }), +); + +import { CloudTaskService } from "./cloud-task"; + +const mockAuthService = { + authenticatedFetch: vi.fn(), +}; + +function createJsonResponse( + data: unknown, + status = 200, + headers?: Record<string, string>, +): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json", ...(headers ?? {}) }, + }); +} + +function createSseResponse(payload: string, status = 200): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(encoder.encode(payload)); + controller.close(); + }, + }); + + return new Response(stream, { + status, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +function createOpenSseResponse(payload: string, status = 200): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(encoder.encode(payload)); + }, + }); + + return new Response(stream, { + status, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +async function waitFor( + predicate: () => boolean, + timeoutMs = 2_000, +): Promise<void> { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error("Timed out waiting for condition"); + } + if (vi.isFakeTimers()) { + await vi.advanceTimersByTimeAsync(10); + } else { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } +} + +describe("CloudTaskService", () => { + let service: CloudTaskService; + + beforeEach(() => { + 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 (input: string | Request, init?: RequestInit) => { + return fetchRouter(input, { + ...init, + headers: { + ...(init?.headers ?? {}), + Authorization: "Bearer token", + }, + }); + }, + ); + }); + + afterEach(() => { + service.unwatchAll(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("bootstraps paged backlog for active runs and drains deduped live SSE entries", async () => { + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + mockNetFetch + .mockResolvedValueOnce( + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: "build", + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }), + ) + .mockResolvedValueOnce( + createJsonResponse( + [ + { + type: "notification", + timestamp: "2026-01-01T00:00:00Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "older history", + }, + }, + }, + ], + 200, + { "X-Has-More": "true" }, + ), + ) + .mockResolvedValueOnce( + createJsonResponse( + [ + { + type: "notification", + timestamp: "2026-01-01T00:00:01Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "hello", + }, + }, + }, + ], + 200, + { "X-Has-More": "false" }, + ), + ); + + mockStreamFetch.mockResolvedValueOnce( + createOpenSseResponse( + 'id: 1\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:01Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"hello"}}}\n\nid: 2\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:02Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"live tail"}}}\n\n', + ), + ); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => updates.length >= 2); + + expect(updates).toEqual([ + { + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + newEntries: [ + { + type: "notification", + timestamp: "2026-01-01T00:00:00Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "older history", + }, + }, + }, + { + type: "notification", + timestamp: "2026-01-01T00:00:01Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "hello", + }, + }, + }, + ], + totalEntryCount: 2, + status: "in_progress", + stage: "build", + output: null, + errorMessage: null, + branch: "main", + }, + { + taskId: "task-1", + runId: "run-1", + kind: "logs", + newEntries: [ + { + type: "notification", + timestamp: "2026-01-01T00:00:02Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "live tail", + }, + }, + }, + ], + totalEntryCount: 3, + }, + ]); + + expect(mockStreamFetch).toHaveBeenCalledWith( + "https://app.example.com/api/projects/2/tasks/task-1/runs/run-1/stream/?start=latest", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer token", + Accept: "text/event-stream", + }), + }), + ); + }); + + it("reconnects with Last-Event-ID after a stream error", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + mockNetFetch + .mockResolvedValueOnce( + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }), + ) + .mockResolvedValueOnce( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ); + + mockStreamFetch + .mockResolvedValueOnce( + createSseResponse( + 'id: 1\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:01Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"hello"}}}\n\nevent: error\ndata: {"error":"boom"}\n\n', + ), + ) + .mockResolvedValueOnce( + createOpenSseResponse( + 'id: 2\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:02Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"again"}}}\n\n', + ), + ); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await vi.advanceTimersByTimeAsync(2_000); + await waitFor(() => updates.length >= 2); + + expect(mockStreamFetch).toHaveBeenNthCalledWith( + 2, + "https://app.example.com/api/projects/2/tasks/task-1/runs/run-1/stream/", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer token", + Accept: "text/event-stream", + "Last-Event-ID": "1", + }), + }), + ); + }); + + it("replays a current snapshot when a subscriber attaches to an existing watcher", async () => { + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + const historicalEntry = { + type: "notification", + timestamp: "2026-01-01T00:00:00Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "older history", + }, + }, + }; + const liveEntry = { + type: "notification", + timestamp: "2026-01-01T00:00:01Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "live tail", + }, + }, + }; + + const runResponse = { + id: "run-1", + status: "in_progress", + stage: "build", + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }; + + mockNetFetch + .mockResolvedValueOnce(createJsonResponse(runResponse)) + .mockResolvedValueOnce( + createJsonResponse([historicalEntry], 200, { "X-Has-More": "false" }), + ) + .mockResolvedValueOnce(createJsonResponse(runResponse)) + .mockResolvedValueOnce( + createJsonResponse([historicalEntry], 200, { "X-Has-More": "false" }), + ); + + mockStreamFetch.mockResolvedValueOnce( + createOpenSseResponse(`id: 1\ndata: ${JSON.stringify(liveEntry)}\n\n`), + ); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => updates.length >= 2); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => + updates.some( + (update) => + typeof update === "object" && + update !== null && + (update as { kind?: string; totalEntryCount?: number }).kind === + "snapshot" && + (update as { totalEntryCount?: number }).totalEntryCount === 2, + ), + ); + + const replayedSnapshot = updates.find( + (update) => + typeof update === "object" && + update !== null && + (update as { kind?: string; totalEntryCount?: number }).kind === + "snapshot" && + (update as { totalEntryCount?: number }).totalEntryCount === 2, + ); + + expect(replayedSnapshot).toEqual({ + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + newEntries: [historicalEntry, liveEntry], + totalEntryCount: 2, + status: "in_progress", + stage: "build", + output: null, + errorMessage: null, + branch: "main", + }); + + const getWatcherEmittedEntryCount = (): number => { + const watcher = ( + service as unknown as { + watchers: Map<string, { emittedLogEntries: unknown[] }>; + } + ).watchers.get("task-1:run-1"); + return watcher?.emittedLogEntries.length ?? 0; + }; + + expect(getWatcherEmittedEntryCount()).toBe(1); + + mockNetFetch.mockResolvedValueOnce( + createJsonResponse([historicalEntry, liveEntry], 200, { + "X-Has-More": "false", + }), + ); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => getWatcherEmittedEntryCount() === 0); + }); + + it("ignores keepalive SSE events while keeping the stream open", async () => { + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + mockNetFetch + .mockResolvedValueOnce( + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: "build", + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }), + ) + .mockResolvedValueOnce( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ); + + mockStreamFetch.mockResolvedValueOnce( + createOpenSseResponse( + 'event: keepalive\ndata: {"type":"keepalive"}\n\nid: 2\ndata: {"type":"notification","timestamp":"2026-01-01T00:00:02Z","notification":{"jsonrpc":"2.0","method":"_posthog/console","params":{"sessionId":"run-1","level":"info","message":"live tail"}}}\n\n', + ), + ); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => updates.length >= 2); + + expect(updates).toEqual([ + { + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + newEntries: [], + totalEntryCount: 0, + status: "in_progress", + stage: "build", + output: null, + errorMessage: null, + branch: "main", + }, + { + taskId: "task-1", + runId: "run-1", + kind: "logs", + newEntries: [ + { + type: "notification", + timestamp: "2026-01-01T00:00:02Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "live tail", + }, + }, + }, + ], + totalEntryCount: 1, + }, + ]); + }); + + it("reconnects after clean stream completion when the run remains active", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + const prUrl = "https://github.com/PostHog/code/pull/123"; + let statusFetchCount = 0; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + const createInProgressRun = (output: Record<string, unknown> | null) => + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: "build", + output, + error_message: null, + branch: "main", + updated_at: output ? "2026-01-01T00:00:01Z" : "2026-01-01T00:00:00Z", + }); + + mockNetFetch.mockImplementation((input: string | Request) => { + const url = typeof input === "string" ? input : input.url; + if (url.includes("/session_logs/")) { + return Promise.resolve( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ); + } + + statusFetchCount += 1; + return Promise.resolve( + createInProgressRun(statusFetchCount === 1 ? null : { pr_url: prUrl }), + ); + }); + + mockStreamFetch.mockImplementation(() => + Promise.resolve(createSseResponse("")), + ); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => mockStreamFetch.mock.calls.length === 1); + await waitFor(() => mockStreamFetch.mock.calls.length >= 7, 20_000); + + expect(updates).toContainEqual( + expect.objectContaining({ + taskId: "task-1", + runId: "run-1", + status: "in_progress", + output: { pr_url: prUrl }, + }), + ); + expect( + updates.some( + (update) => + typeof update === "object" && + update !== null && + (update as { kind?: string }).kind === "error", + ), + ).toBe(false); + + expect( + ( + service as unknown as { + watchers: Map<string, unknown>; + } + ).watchers.has("task-1:run-1"), + ).toBe(true); + }); + + it("fails the watcher after exhausting the cumulative reconnect budget on clean-EOF loops", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + const makeInProgressRun = () => + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }); + + mockNetFetch.mockImplementation((input: string | Request) => { + const url = typeof input === "string" ? input : input.url; + if (url.includes("/session_logs/")) { + return Promise.resolve( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ); + } + return Promise.resolve(makeInProgressRun()); + }); + + mockStreamFetch.mockImplementation(() => + Promise.resolve(createSseResponse("")), + ); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => mockStreamFetch.mock.calls.length === 1); + await vi.advanceTimersByTimeAsync(60 * 60_000); + + await waitFor( + () => + updates.some( + (u) => + typeof u === "object" && + u !== null && + (u as { kind?: string }).kind === "error", + ), + 10_000, + ); + + expect(updates).toContainEqual({ + taskId: "task-1", + runId: "run-1", + kind: "error", + errorTitle: "Cloud run unreachable", + errorMessage: + "Could not maintain a connection to the cloud run after many attempts. Click retry once the issue is resolved.", + retryable: true, + }); + }); + + it("emits a retryable cloud error after repeated stream failures", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + const makeInProgressRun = () => + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }); + + mockNetFetch + .mockResolvedValueOnce(makeInProgressRun()) // bootstrap: fetchTaskRun + .mockResolvedValueOnce( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ) // bootstrap: fetchSessionLogs + // Each stream error triggers handleStreamCompletion → fetchTaskRun + .mockImplementation(() => Promise.resolve(makeInProgressRun())); + + mockStreamFetch.mockImplementation(() => + Promise.resolve( + createSseResponse( + 'event: keepalive\ndata: {"type":"keepalive"}\n\nevent: error\ndata: {"error":"boom"}\n\n', + ), + ), + ); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => mockStreamFetch.mock.calls.length === 1); + await vi.advanceTimersByTimeAsync(70_000); + await waitFor( + () => + updates.some( + (u) => + typeof u === "object" && + u !== null && + (u as { kind?: string }).kind === "error", + ), + 10_000, + ); + + expect(mockStreamFetch.mock.calls.length).toBe(6); + // 2 bootstrap calls + 1 post-bootstrap status verification + 6 + // handleStreamCompletion calls (one per stream error) + expect(mockNetFetch).toHaveBeenCalledTimes(9); + expect(updates).toContainEqual({ + taskId: "task-1", + runId: "run-1", + kind: "error", + errorTitle: "Cloud stream disconnected", + errorMessage: + "Lost connection to the cloud run stream. Retry to reconnect.", + retryable: true, + }); + }); + + it("clears the backend-error budget after a healthy long-lived cut", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + const makeInProgressRun = () => + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }); + + mockNetFetch + .mockResolvedValueOnce(makeInProgressRun()) // bootstrap: fetchTaskRun + .mockResolvedValueOnce( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ) // bootstrap: fetchSessionLogs + .mockImplementation(() => Promise.resolve(makeInProgressRun())); + + // First connection delivers an explicit backend error frame (accruing the + // backend-error budget). Subsequent connections are healthy long-lived cuts + // (>= SSE_HEALTHY_CONNECTION_MS): each proves the stream recovered and must + // clear the backend-error budget, so it never accumulates for the run's life. + let streamCall = 0; + mockStreamFetch.mockImplementation(() => { + streamCall += 1; + if (streamCall === 1) { + return Promise.resolve( + createSseResponse('event: error\ndata: {"error":"boom"}\n\n'), + ); + } + const encoder = new TextEncoder(); + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue( + encoder.encode('event: keepalive\ndata: {"type":"keepalive"}\n\n'), + ); + setTimeout(() => controller.error(new Error("terminated")), 65_000); + }, + }); + return Promise.resolve( + new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ); + }); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + const getWatcher = () => + ( + service as unknown as { + watchers: Map< + string, + { + reconnectAttempts: number; + streamErrorAttempts: number; + failed: boolean; + } + >; + } + ).watchers.get("task-1:run-1"); + + // The backend error must have accrued the backend-error budget first... + await waitFor(() => (getWatcher()?.streamErrorAttempts ?? 0) >= 1, 20_000); + // ...then the healthy long-lived cut on the next connection clears it. + await vi.advanceTimersByTimeAsync(67_000 * 2); + await waitFor(() => getWatcher()?.streamErrorAttempts === 0, 20_000); + + const watcher = getWatcher(); + expect(watcher?.failed).toBe(false); + expect(watcher?.streamErrorAttempts).toBe(0); + expect(watcher?.reconnectAttempts).toBe(0); + expect( + updates.some( + (u) => + typeof u === "object" && + u !== null && + (u as { kind?: string }).kind === "error", + ), + ).toBe(false); + }); + + it("counts quick stream failures and surfaces a retryable error", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + const makeInProgressRun = () => + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }); + + mockNetFetch + .mockResolvedValueOnce(makeInProgressRun()) + .mockResolvedValueOnce( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ) + .mockImplementation(() => Promise.resolve(makeInProgressRun())); + + // Connections that fail immediately (under SSE_HEALTHY_CONNECTION_MS) are + // genuine churn and must keep counting toward the retry budget. + mockStreamFetch.mockImplementation(() => + Promise.resolve( + createSseResponse('event: error\ndata: {"error":"boom"}\n\n'), + ), + ); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => mockStreamFetch.mock.calls.length === 1); + await vi.advanceTimersByTimeAsync(70_000); + await waitFor( + () => + updates.some( + (u) => + typeof u === "object" && + u !== null && + (u as { kind?: string }).kind === "error", + ), + 10_000, + ); + + expect(updates).toContainEqual({ + taskId: "task-1", + runId: "run-1", + kind: "error", + errorTitle: "Cloud stream disconnected", + errorMessage: + "Lost connection to the cloud run stream. Retry to reconnect.", + retryable: true, + }); + }); + + it("stops the watcher without reconnecting once the run is terminal", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + let statusFetchCount = 0; + mockNetFetch.mockImplementation((input: string | Request) => { + const url = typeof input === "string" ? input : input.url; + if (url.includes("/session_logs/")) { + return Promise.resolve( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ); + } + statusFetchCount += 1; + // Bootstrap sees an active run; the post-stream status check sees terminal. + return Promise.resolve( + createJsonResponse({ + id: "run-1", + status: statusFetchCount === 1 ? "in_progress" : "completed", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: + statusFetchCount === 1 + ? "2026-01-01T00:00:00Z" + : "2026-01-01T00:00:01Z", + }), + ); + }); + + mockStreamFetch.mockImplementation(() => + Promise.resolve(createSseResponse("")), + ); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => mockStreamFetch.mock.calls.length === 1); + await vi.advanceTimersByTimeAsync(10_000); + + expect(updates).toContainEqual( + expect.objectContaining({ + taskId: "task-1", + runId: "run-1", + kind: "status", + status: "completed", + }), + ); + expect(mockStreamFetch.mock.calls.length).toBe(1); + expect( + (service as unknown as { watchers: Map<string, unknown> }).watchers.has( + "task-1:run-1", + ), + ).toBe(false); + }); + + it("surfaces a retryable error when the backend errors even on a long-lived stream", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + const makeInProgressRun = () => + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }); + + mockNetFetch + .mockResolvedValueOnce(makeInProgressRun()) + .mockResolvedValueOnce( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ) + .mockImplementation(() => Promise.resolve(makeInProgressRun())); + + // Each connection stays open with a keepalive for 65s (> the healthy + // threshold) and only THEN emits an explicit backend `event: error` frame. + // An explicit backend error must always count toward the budget, so even a + // long-lived stream eventually surfaces the retryable disconnect error. + mockStreamFetch.mockImplementation(() => { + const encoder = new TextEncoder(); + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue( + encoder.encode('event: keepalive\ndata: {"type":"keepalive"}\n\n'), + ); + setTimeout(() => { + controller.enqueue( + encoder.encode('event: error\ndata: {"error":"boom"}\n\n'), + ); + controller.close(); + }, 65_000); + }, + }); + return Promise.resolve( + new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ); + }); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => mockStreamFetch.mock.calls.length === 1); + // Drive >= 6 long-lived-then-backend-error cycles (65s open + backoff each). + await vi.advanceTimersByTimeAsync(65_000 * 7 + 70_000); + await waitFor( + () => + updates.some( + (u) => + typeof u === "object" && + u !== null && + (u as { kind?: string }).kind === "error", + ), + 10_000, + ); + + expect(updates).toContainEqual({ + taskId: "task-1", + runId: "run-1", + kind: "error", + errorTitle: "Cloud stream disconnected", + errorMessage: + "Lost connection to the cloud run stream. Retry to reconnect.", + retryable: true, + }); + }); + + it("treats a long-lived transport cut as healthy even with no frames received", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + const makeInProgressRun = () => + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }); + + mockNetFetch + .mockResolvedValueOnce(makeInProgressRun()) + .mockResolvedValueOnce( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ) + .mockImplementation(() => Promise.resolve(makeInProgressRun())); + + // Each connection opens but delivers NOTHING, then is transport-cut at 65s. + // Healthiness is duration-only on purpose — it must NOT depend on keepalive + // frames surviving the proxy — so even a frame-less long-lived cut is healthy + // and never exhausts the budget. + mockStreamFetch.mockImplementation(() => { + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + setTimeout(() => controller.error(new Error("terminated")), 65_000); + }, + }); + return Promise.resolve( + new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ); + }); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => mockStreamFetch.mock.calls.length === 1); + await vi.advanceTimersByTimeAsync(67_000 * 8); + await waitFor(() => mockStreamFetch.mock.calls.length >= 6, 20_000); + + expect( + updates.some( + (u) => + typeof u === "object" && + u !== null && + (u as { kind?: string }).kind === "error", + ), + ).toBe(false); + + const watcher = ( + service as unknown as { + watchers: Map<string, { reconnectAttempts: number; failed: boolean }>; + } + ).watchers.get("task-1:run-1"); + expect(watcher?.failed).toBe(false); + expect(watcher?.reconnectAttempts).toBe(0); + }); + + it("resets the transport reconnect budget once a keepalive proves recovery", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + const makeInProgressRun = () => + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }); + + mockNetFetch + .mockResolvedValueOnce(makeInProgressRun()) + .mockResolvedValueOnce( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ) + .mockImplementation(() => Promise.resolve(makeInProgressRun())); + + // First 3 connections fail fast at the transport level (established, then + // errored immediately, no frame) and accrue reconnect attempts. The 4th + // delivers a keepalive and stays open — proving the transport recovered, so + // the accrued attempts must reset rather than carry forward into the budget. + let streamCall = 0; + const keepaliveControllerRef: { + current: ReadableStreamDefaultController<Uint8Array> | null; + } = { current: null }; + const encoder = new TextEncoder(); + mockStreamFetch.mockImplementation(() => { + streamCall += 1; + if (streamCall <= 3) { + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + controller.error(new Error("terminated")); + }, + }); + return Promise.resolve( + new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ); + } + // 4th connection stays open with no frame; the test injects the keepalive + // below so it can observe the accrued budget BEFORE the reset. + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + keepaliveControllerRef.current = controller; + }, + }); + return Promise.resolve( + new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ); + }); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + const getWatcher = () => + ( + service as unknown as { + watchers: Map<string, { reconnectAttempts: number; failed: boolean }>; + } + ).watchers.get("task-1:run-1"); + + await waitFor(() => mockStreamFetch.mock.calls.length === 1); + // Drive the 3 fast transport failures and open the held 4th connection. + await vi.advanceTimersByTimeAsync(30_000); + await waitFor( + () => streamCall >= 4 && !!keepaliveControllerRef.current, + 20_000, + ); + + // Non-vacuous precondition: the fast failures actually accrued the budget. + expect(getWatcher()?.reconnectAttempts ?? 0).toBeGreaterThan(0); + + // A keepalive on the recovered connection must reset the transport budget. + keepaliveControllerRef.current?.enqueue( + encoder.encode('event: keepalive\ndata: {"type":"keepalive"}\n\n'), + ); + await waitFor(() => getWatcher()?.reconnectAttempts === 0, 20_000); + + const watcher = getWatcher(); + expect(watcher?.failed).toBe(false); + expect(watcher?.reconnectAttempts).toBe(0); + expect( + updates.some( + (u) => + typeof u === "object" && + u !== null && + (u as { kind?: string }).kind === "error", + ), + ).toBe(false); + }); + + it("does not let a stale backend-error count inflate a transport reconnect delay", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + const makeInProgressRun = () => + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }); + + mockNetFetch + .mockResolvedValueOnce(makeInProgressRun()) // bootstrap: fetchTaskRun + .mockResolvedValueOnce( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ) // bootstrap: fetchSessionLogs + .mockImplementation(() => Promise.resolve(makeInProgressRun())); + + // Connections 1-4 each emit a backend `event: error` frame, building the + // backend-error budget to 4 — those reconnects correctly pace on + // streamErrorAttempts. Connection 5 is held open until the test injects a + // quick TRANSPORT cut, which must pace its reconnect on the just-incremented + // transport budget (1 -> ~2s), NOT on the stale backend-error budget + // (4 -> ~16s). Math.max(both) for the delay would wrongly use the latter. + let streamCall = 0; + const transportControllerRef: { + current: ReadableStreamDefaultController<Uint8Array> | null; + } = { current: null }; + mockStreamFetch.mockImplementation(() => { + streamCall += 1; + if (streamCall <= 4) { + return Promise.resolve( + createSseResponse('event: error\ndata: {"error":"boom"}\n\n'), + ); + } + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + if (streamCall === 5) { + transportControllerRef.current = controller; + } + }, + }); + return Promise.resolve( + new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ); + }); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + const getWatcher = () => + ( + service as unknown as { + watchers: Map< + string, + { + reconnectAttempts: number; + streamErrorAttempts: number; + failed: boolean; + } + >; + } + ).watchers.get("task-1:run-1"); + + await waitFor(() => mockStreamFetch.mock.calls.length === 1); + // Drive the four backend-error reconnects (2s + 4s + 8s + 16s of backoff) + // and open the held fifth connection. + await vi.advanceTimersByTimeAsync(35_000); + await waitFor( + () => streamCall >= 5 && !!transportControllerRef.current, + 20_000, + ); + + // Non-vacuous precondition: the backend-error budget is stale-high while the + // transport budget is still zero. + expect(getWatcher()?.streamErrorAttempts).toBe(4); + expect(getWatcher()?.reconnectAttempts).toBe(0); + expect(getWatcher()?.failed).toBe(false); + + // A quick transport cut on the open fifth connection charges ONE transport + // attempt; its reconnect must wait ~2s (transport budget), not ~16s. + transportControllerRef.current?.error(new Error("terminated")); + await waitFor(() => getWatcher()?.reconnectAttempts === 1, 20_000); + expect(getWatcher()?.streamErrorAttempts).toBe(4); + + const callsBeforeProbe = mockStreamFetch.mock.calls.length; + // 5s is past the fixed ~2s transport backoff but well short of the buggy + // ~16s backend-error backoff, so the sixth connection only opens if the + // delay was paced on the transport budget. + await vi.advanceTimersByTimeAsync(5_000); + expect(mockStreamFetch.mock.calls.length).toBe(callsBeforeProbe + 1); + expect(getWatcher()?.failed).toBe(false); + }); + + it("surfaces an error instead of retrying forever when run-state fetch keeps failing after a clean stream end", async () => { + vi.useFakeTimers(); + + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + // Bootstrap succeeds (run + empty backlog); every subsequent run-state + // fetch returns 500 (a non-fatal status -> fetchTaskRun resolves null). + mockNetFetch + .mockResolvedValueOnce( + createJsonResponse({ + id: "run-1", + status: "in_progress", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + }), + ) // bootstrap: fetchTaskRun + .mockResolvedValueOnce( + createJsonResponse([], 200, { "X-Has-More": "false" }), + ) // bootstrap: fetchSessionLogs + .mockImplementation(() => + Promise.resolve(createJsonResponse({ detail: "boom" }, 500)), + ); + + // First connection is held open so bootstrap can finish; the test then + // closes it cleanly. Every later connection ends cleanly on its own, so the + // only thing that can fail is the post-stream run-state fetch (500). + let streamCall = 0; + const firstControllerRef: { + current: ReadableStreamDefaultController<Uint8Array> | null; + } = { current: null }; + mockStreamFetch.mockImplementation(() => { + streamCall += 1; + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + if (streamCall === 1) { + firstControllerRef.current = controller; + } else { + controller.close(); + } + }, + }); + return Promise.resolve( + new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ); + }); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + // Wait for bootstrap to emit its snapshot and hold the live connection open. + await waitFor( + () => + !!firstControllerRef.current && + updates.some( + (u) => + typeof u === "object" && + u !== null && + (u as { kind?: string }).kind === "snapshot", + ), + ); + + // Close the live stream cleanly: each clean end now fetches run state, which + // 500s. The reconnect must charge the budget so it eventually gives up. + firstControllerRef.current?.close(); + + // Budget is 5 attempts (2s + 4s + 8s + 16s + 30s + 30s of backoff). + await vi.advanceTimersByTimeAsync(120_000); + await waitFor( + () => + updates.some( + (u) => + typeof u === "object" && + u !== null && + (u as { kind?: string }).kind === "error", + ), + 20_000, + ); + + expect(updates).toContainEqual({ + taskId: "task-1", + runId: "run-1", + kind: "error", + errorTitle: "Cloud run state unavailable", + errorMessage: + "Could not fetch the latest cloud run state after the stream ended. Retry to reconnect.", + retryable: true, + }); + }); + + const guardedFetchStatusExpectations = [ + [ + 401, + { + errorTitle: "Cloud authentication expired", + errorMessage: "Please reauthenticate and retry the cloud run stream.", + retryable: true, + }, + ], + [ + 403, + { + errorTitle: "Cloud access denied", + errorMessage: + "You no longer have access to this cloud run. Reauthenticate and retry.", + retryable: true, + }, + ], + [ + 404, + { + errorTitle: "Cloud run not found", + errorMessage: + "This cloud run could not be found. It may have been deleted or moved.", + retryable: false, + }, + ], + ] as const; + + const guardedFetchStatusCases = ( + ["status fetch", "persisted log fetch"] as const + ).flatMap((fetchPhase) => + guardedFetchStatusExpectations.map(([status, expectedError]) => ({ + fetchPhase, + status, + expectedError, + })), + ); + + it.each(guardedFetchStatusCases)( + "fails the watcher when $fetchPhase returns $status", + async ({ fetchPhase, status, expectedError }) => { + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + if (fetchPhase === "status fetch") { + mockNetFetch.mockResolvedValueOnce( + createJsonResponse({ detail: "Access denied" }, status), + ); + } else { + mockNetFetch + .mockResolvedValueOnce( + createJsonResponse({ + id: "run-1", + status: "completed", + stage: null, + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + completed_at: "2026-01-01T00:00:01Z", + }), + ) + .mockResolvedValueOnce( + createJsonResponse({ detail: "Access denied" }, status), + ); + } + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => updates.length === 1); + + expect(mockStreamFetch).not.toHaveBeenCalled(); + expect(updates).toContainEqual({ + taskId: "task-1", + runId: "run-1", + kind: "error", + ...expectedError, + }); + }, + ); + + it("loads paginated persisted logs once for an already terminal run", async () => { + const updates: unknown[] = []; + service.on(CloudTaskEvent.Update, (payload) => updates.push(payload)); + + mockNetFetch + .mockResolvedValueOnce( + createJsonResponse({ + id: "run-1", + status: "completed", + stage: "build", + output: null, + error_message: null, + branch: "main", + updated_at: "2026-01-01T00:00:00Z", + completed_at: "2026-01-01T00:00:00Z", + }), + ) + .mockResolvedValueOnce( + createJsonResponse( + [ + { + type: "notification", + timestamp: "2026-01-01T00:00:01Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "done-1", + }, + }, + }, + ], + 200, + { "X-Has-More": "true" }, + ), + ) + .mockResolvedValueOnce( + createJsonResponse( + [ + { + type: "notification", + timestamp: "2026-01-01T00:00:02Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "done-2", + }, + }, + }, + ], + 200, + { "X-Has-More": "false" }, + ), + ); + + service.watch({ + taskId: "task-1", + runId: "run-1", + apiHost: "https://app.example.com", + teamId: 2, + }); + + await waitFor(() => updates.length >= 1); + + expect(updates).toEqual([ + { + taskId: "task-1", + runId: "run-1", + kind: "snapshot", + newEntries: [ + { + type: "notification", + timestamp: "2026-01-01T00:00:01Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "done-1", + }, + }, + }, + { + type: "notification", + timestamp: "2026-01-01T00:00:02Z", + notification: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { + sessionId: "run-1", + level: "info", + message: "done-2", + }, + }, + }, + ], + totalEntryCount: 2, + status: "completed", + stage: "build", + output: null, + errorMessage: null, + branch: "main", + }, + ]); + expect(mockNetFetch).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/core/src/cloud-task/cloud-task.ts b/packages/core/src/cloud-task/cloud-task.ts new file mode 100644 index 0000000000..dd55cbdcdf --- /dev/null +++ b/packages/core/src/cloud-task/cloud-task.ts @@ -0,0 +1,1337 @@ +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 type { CloudTaskPermissionRequestUpdate } from "./cloud-task-types"; +import { CLOUD_TASK_AUTH, type ICloudTaskAuth } from "./identifiers"; +import { + CloudTaskEvent, + type CloudTaskEvents, + isTerminalStatus, + type SendCommandInput, + type SendCommandOutput, + type TaskRunStatus, + type WatchInput, +} from "./schemas"; +import { type SseEvent, SseEventParser } from "./sse-parser"; + +const MAX_SSE_RECONNECT_ATTEMPTS = 5; +const MAX_CUMULATIVE_RECONNECT_ATTEMPTS = 30; +const SSE_RECONNECT_BASE_DELAY_MS = 2_000; +const SSE_RECONNECT_MAX_DELAY_MS = 30_000; +const SSE_HEALTHY_CONNECTION_MS = 60_000; +const EVENT_BATCH_FLUSH_MS = 16; +const EVENT_BATCH_MAX_SIZE = 50; +const SESSION_LOG_PAGE_LIMIT = 5_000; + +interface SessionLogsPage { + entries: StoredLogEntry[]; + hasMore: boolean; +} + +interface CloudTaskConnectionError { + title: string; + message: string; + retryable: boolean; + autoRetry?: boolean; +} + +class CloudTaskStreamError extends Error { + constructor( + message: string, + public readonly details: CloudTaskConnectionError, + public readonly status?: number, + ) { + super(message); + this.name = "CloudTaskStreamError"; + } +} + +class BackendStreamError extends Error { + constructor(message: string) { + super(message); + this.name = "BackendStreamError"; + } +} + +interface TaskRunResponse { + id: string; + status: TaskRunStatus; + stage?: string | null; + output?: Record<string, unknown> | null; + error_message?: string | null; + branch?: string | null; + updated_at?: string; + completed_at?: string | null; +} + +interface TaskRunStateEvent { + type: "task_run_state"; + status?: TaskRunStatus; + stage?: string | null; + output?: Record<string, unknown> | null; + error_message?: string | null; + branch?: string | null; + updated_at?: string | null; + completed_at?: string | null; +} + +interface WatcherState { + taskId: string; + runId: string; + apiHost: string; + teamId: number; + subscriberCount: number; + sseAbortController: AbortController | null; + reconnectTimeoutId: ReturnType<typeof setTimeout> | null; + batchFlushTimeoutId: ReturnType<typeof setTimeout> | null; + pendingLogEntries: StoredLogEntry[]; + totalEntryCount: number; + reconnectAttempts: number; + streamErrorAttempts: number; + cumulativeReconnectAttempts: number; + lastEventId: string | null; + lastStatus: TaskRunStatus | null; + lastStage: string | null; + lastOutput: Record<string, unknown> | null; + lastErrorMessage: string | null; + lastBranch: string | null; + lastStatusUpdatedAt: string | null; + isBootstrapping: boolean; + hasEmittedSnapshot: boolean; + bufferedLogBatches: StoredLogEntry[][]; + emittedLogEntries: StoredLogEntry[]; + failed: boolean; + needsPostBootstrapReconnect: boolean; + needsStopAfterBootstrap: boolean; +} + +function watcherKey(taskId: string, runId: string): string { + return `${taskId}:${runId}`; +} + +function isTaskRunStateEvent(data: unknown): data is TaskRunStateEvent { + return ( + typeof data === "object" && + data !== null && + (data as { type?: string }).type === "task_run_state" + ); +} + +interface SseErrorEventData { + error: string; +} + +function isSseErrorEvent(data: unknown): data is SseErrorEventData { + return ( + typeof data === "object" && + data !== null && + "error" in data && + typeof (data as SseErrorEventData).error === "string" + ); +} + +interface PermissionRequestEventData { + type: "permission_request"; + requestId: string; + toolCall: CloudTaskPermissionRequestUpdate["toolCall"]; + options: CloudTaskPermissionRequestUpdate["options"]; +} + +function isPermissionRequestEvent( + data: unknown, +): data is PermissionRequestEventData { + return ( + typeof data === "object" && + data !== null && + (data as { type?: string }).type === "permission_request" && + typeof (data as { requestId?: string }).requestId === "string" + ); +} + +function createStreamStatusError(status: number): CloudTaskStreamError { + switch (status) { + case 401: + return new CloudTaskStreamError( + "Cloud authentication expired", + { + title: "Cloud authentication expired", + message: "Please reauthenticate and retry the cloud run stream.", + retryable: true, + autoRetry: false, + }, + status, + ); + case 403: + return new CloudTaskStreamError( + "Cloud access denied", + { + title: "Cloud access denied", + message: + "You no longer have access to this cloud run. Reauthenticate and retry.", + retryable: true, + autoRetry: false, + }, + status, + ); + case 404: + return new CloudTaskStreamError( + "Cloud run not found", + { + title: "Cloud run not found", + message: + "This cloud run could not be found. It may have been deleted or moved.", + retryable: false, + autoRetry: false, + }, + status, + ); + case 406: + return new CloudTaskStreamError( + "Cloud stream unavailable", + { + title: "Cloud stream unavailable", + message: + "The backend rejected the live stream request. Restart the backend and retry.", + retryable: true, + autoRetry: false, + }, + status, + ); + default: + return new CloudTaskStreamError( + `Stream request failed with status ${status}`, + { + title: "Cloud stream failed", + message: `The cloud stream request failed with status ${status}. Retry to reconnect.`, + retryable: true, + autoRetry: true, + }, + status, + ); + } +} + +function shouldFailWatcherForFetchStatus(status: number): boolean { + return status === 401 || status === 403 || status === 404; +} + +@injectable() +export class CloudTaskService extends TypedEventEmitter<CloudTaskEvents> { + private watchers = new Map<string, WatcherState>(); + private readonly log: ScopedLogger; + + constructor( + @inject(CLOUD_TASK_AUTH) + private readonly auth: ICloudTaskAuth, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + super(); + this.log = logger.scope("cloud-task"); + } + + watch(input: WatchInput): void { + const key = watcherKey(input.taskId, input.runId); + + const existing = this.watchers.get(key); + if (existing) { + existing.subscriberCount++; + this.log.info("Cloud task watcher subscriber added", { + key, + subscribers: existing.subscriberCount, + }); + void this.emitCurrentSnapshot(key); + return; + } + + this.startWatcher(input, 1); + } + + unwatch(taskId: string, runId: string): void { + const key = watcherKey(taskId, runId); + const watcher = this.watchers.get(key); + if (!watcher) { + return; + } + + watcher.subscriberCount--; + if (watcher.subscriberCount <= 0) { + this.stopWatcher(key); + } else { + this.log.info("Cloud task watcher subscriber removed", { + key, + subscribers: watcher.subscriberCount, + }); + } + } + + retry(taskId: string, runId: string): void { + const key = watcherKey(taskId, runId); + const watcher = this.watchers.get(key); + if (!watcher) return; + + if (watcher.reconnectTimeoutId) { + clearTimeout(watcher.reconnectTimeoutId); + watcher.reconnectTimeoutId = null; + } + + watcher.sseAbortController?.abort(); + watcher.sseAbortController = null; + + if (watcher.batchFlushTimeoutId) { + clearTimeout(watcher.batchFlushTimeoutId); + watcher.batchFlushTimeoutId = null; + } + + watcher.reconnectAttempts = 0; + watcher.streamErrorAttempts = 0; + watcher.cumulativeReconnectAttempts = 0; + watcher.failed = false; + watcher.pendingLogEntries = []; + watcher.bufferedLogBatches = []; + watcher.needsPostBootstrapReconnect = false; + watcher.needsStopAfterBootstrap = false; + + this.log.info("Retrying cloud task watcher", { + key, + hasSnapshot: watcher.hasEmittedSnapshot, + }); + + if (!watcher.hasEmittedSnapshot) { + watcher.lastEventId = null; + watcher.totalEntryCount = 0; + watcher.isBootstrapping = false; + void this.bootstrapWatcher(key); + return; + } + + void this.connectSse(key, { startLatest: !watcher.lastEventId }); + } + + async sendCommand(input: SendCommandInput): Promise<SendCommandOutput> { + const url = `${input.apiHost}/api/projects/${input.teamId}/tasks/${input.taskId}/runs/${input.runId}/command/`; + const body = { + jsonrpc: "2.0", + method: input.method, + params: input.params ?? {}, + id: `posthog-code-${Date.now()}`, + }; + + try { + const response = await this.auth.authenticatedFetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + let errorMessage = `Command failed with status ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + if (errorJson.error?.message) { + errorMessage = errorJson.error.message; + } else if (errorJson.error) { + errorMessage = + typeof errorJson.error === "string" + ? errorJson.error + : JSON.stringify(errorJson.error); + } + } catch { + if (errorText) errorMessage = errorText; + } + + this.log.warn("Cloud task command failed", { + taskId: input.taskId, + runId: input.runId, + method: input.method, + status: response.status, + error: errorMessage, + }); + return { success: false, error: errorMessage }; + } + + const data = (await response.json()) as { + error?: { message?: string }; + result?: unknown; + }; + + if (data.error) { + this.log.warn("Cloud task command returned error", { + taskId: input.taskId, + method: input.method, + error: data.error, + }); + return { + success: false, + error: data.error.message ?? JSON.stringify(data.error), + }; + } + + this.log.info("Cloud task command sent", { + taskId: input.taskId, + runId: input.runId, + method: input.method, + }); + + return { success: true, result: data.result }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + this.log.error("Cloud task command error", { + taskId: input.taskId, + method: input.method, + error: errorMessage, + }); + return { success: false, error: errorMessage }; + } + } + + @preDestroy() + unwatchAll(): void { + for (const key of [...this.watchers.keys()]) { + this.stopWatcher(key); + } + } + + private startWatcher(input: WatchInput, subscriberCount: number): void { + const key = watcherKey(input.taskId, input.runId); + + const watcher: WatcherState = { + taskId: input.taskId, + runId: input.runId, + apiHost: input.apiHost, + teamId: input.teamId, + subscriberCount, + sseAbortController: null, + reconnectTimeoutId: null, + batchFlushTimeoutId: null, + pendingLogEntries: [], + totalEntryCount: 0, + reconnectAttempts: 0, + streamErrorAttempts: 0, + cumulativeReconnectAttempts: 0, + lastEventId: null, + lastStatus: null, + lastStage: null, + lastOutput: null, + lastErrorMessage: null, + lastBranch: null, + lastStatusUpdatedAt: null, + isBootstrapping: false, + hasEmittedSnapshot: false, + bufferedLogBatches: [], + emittedLogEntries: [], + failed: false, + needsPostBootstrapReconnect: false, + needsStopAfterBootstrap: false, + }; + + this.watchers.set(key, watcher); + this.log.info("Cloud task watcher started", { key }); + void this.bootstrapWatcher(key); + } + + private stopWatcher(key: string): void { + const watcher = this.watchers.get(key); + if (!watcher) return; + + watcher.sseAbortController?.abort(); + + if (watcher.reconnectTimeoutId) { + clearTimeout(watcher.reconnectTimeoutId); + watcher.reconnectTimeoutId = null; + } + + if (watcher.batchFlushTimeoutId) { + clearTimeout(watcher.batchFlushTimeoutId); + watcher.batchFlushTimeoutId = null; + } + + this.flushLogBatch(key); + this.watchers.delete(key); + this.log.info("Cloud task watcher stopped", { key }); + } + + private async bootstrapWatcher(key: string): Promise<void> { + const watcher = this.watchers.get(key); + if (!watcher) return; + + watcher.failed = false; + watcher.needsPostBootstrapReconnect = false; + watcher.needsStopAfterBootstrap = false; + + const run = await this.fetchTaskRun(watcher); + const currentWatcher = this.watchers.get(key); + if (!currentWatcher || currentWatcher !== watcher) return; + if (watcher.failed) return; + + if (!run) { + this.failWatcher(key, { + title: "Failed to load cloud run", + message: "Could not fetch the cloud run state. Retry to reconnect.", + retryable: true, + }); + return; + } + + this.applyTaskRunState(watcher, run); + + if (isTerminalStatus(run.status)) { + const historicalEntries = await this.fetchAllSessionLogs(watcher); + const terminalWatcher = this.watchers.get(key); + if (!terminalWatcher || terminalWatcher !== watcher) return; + if (watcher.failed) return; + if (!historicalEntries) { + this.failWatcher(key, { + title: "Failed to load task history", + message: + "Could not load the persisted cloud task logs. Retry to reconnect.", + retryable: true, + }); + return; + } + + watcher.totalEntryCount = historicalEntries.length; + watcher.hasEmittedSnapshot = true; + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "snapshot", + newEntries: historicalEntries, + totalEntryCount: watcher.totalEntryCount, + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + }); + this.stopWatcher(key); + return; + } + + watcher.isBootstrapping = true; + watcher.bufferedLogBatches = []; + void this.connectSse(key, { startLatest: true }); + + const historicalEntries = await this.fetchAllSessionLogs(watcher); + const bootstrappingWatcher = this.watchers.get(key); + if (!bootstrappingWatcher || bootstrappingWatcher !== watcher) return; + if (watcher.failed) return; + if (!historicalEntries) { + this.failWatcher(key, { + title: "Failed to load cloud run history", + message: + "Could not load the existing cloud run logs. Retry to reconnect.", + retryable: true, + }); + return; + } + + // Flush any pending live entries into the bootstrap buffer before snapshot. + this.flushLogBatch(key); + + watcher.totalEntryCount = historicalEntries.length; + watcher.hasEmittedSnapshot = true; + + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "snapshot", + newEntries: historicalEntries, + totalEntryCount: watcher.totalEntryCount, + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + }); + + watcher.isBootstrapping = false; + this.drainBufferedLogBatches(key, historicalEntries); + + if (watcher.failed) { + return; + } + + if ( + watcher.needsStopAfterBootstrap || + isTerminalStatus(watcher.lastStatus) + ) { + watcher.needsStopAfterBootstrap = false; + this.stopWatcher(key); + return; + } + + if (watcher.needsPostBootstrapReconnect) { + watcher.needsPostBootstrapReconnect = false; + this.scheduleReconnect(key, undefined, { countAttempt: false }); + } + + void this.verifyPostBootstrapStatus(key); + } + + private async verifyPostBootstrapStatus(key: string): Promise<void> { + const watcher = this.watchers.get(key); + if (!watcher) return; + if (isTerminalStatus(watcher.lastStatus)) return; + + const run = await this.fetchTaskRun(watcher); + const currentWatcher = this.watchers.get(key); + if (!currentWatcher || currentWatcher !== watcher) return; + if (!run) return; + + if (!this.applyTaskRunState(watcher, run)) return; + if (isTerminalStatus(watcher.lastStatus)) return; + + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "status", + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + }); + } + + private async connectSse( + key: string, + options?: { startLatest?: boolean }, + ): Promise<void> { + const watcher = this.watchers.get(key); + if (!watcher) return; + + const controller = new AbortController(); + watcher.sseAbortController = controller; + + const url = new URL( + `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/stream/`, + ); + if (options?.startLatest && !watcher.lastEventId) { + url.searchParams.set("start", "latest"); + } + const headers: Record<string, string> = { + Accept: "text/event-stream", + }; + if (watcher.lastEventId) { + headers["Last-Event-ID"] = watcher.lastEventId; + } + + 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, + // so a long-lived connection cut by transport churn isn't penalized as a + // failed reconnect attempt (see SSE_HEALTHY_CONNECTION_MS). + let connectedAt = 0; + let streamWasEstablished = false; + + try { + const response = await this.auth.authenticatedFetch(url.toString(), { + method: "GET", + headers, + signal: controller.signal, + }); + + if (!response.ok) { + throw createStreamStatusError(response.status); + } + + if (!response.body) { + throw new Error("Stream response did not include a body"); + } + + connectedAt = Date.now(); + streamWasEstablished = true; + + const reader = response.body.getReader(); + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + if (!value) { + continue; + } + + const chunk = decoder.decode(value, { stream: true }); + const events = parser.parse(chunk); + for (const event of events) { + this.handleSseEvent(key, event); + } + } + + const trailingEvents = parser.parse(decoder.decode()); + for (const event of trailingEvents) { + this.handleSseEvent(key, event); + } + + this.flushLogBatch(key); + + if (controller.signal.aborted) { + return; + } + + await this.handleStreamCompletion(key, { reconnectIfNonTerminal: true }); + } catch (error) { + this.flushLogBatch(key); + + if (controller.signal.aborted) { + return; + } + + if ( + error instanceof CloudTaskStreamError && + error.details.autoRetry === false + ) { + this.failWatcher(key, error.details); + return; + } + + const errorMessage = + error instanceof Error ? error.message : "Unknown stream error"; + + const isBackendError = error instanceof BackendStreamError; + const wasHealthyStream = + !isBackendError && + streamWasEstablished && + Date.now() - connectedAt >= SSE_HEALTHY_CONNECTION_MS; + + const watcher = this.watchers.get(key); + if (watcher) { + if (isBackendError) { + watcher.streamErrorAttempts += 1; + } else if (wasHealthyStream) { + watcher.streamErrorAttempts = 0; + } + } + + this.log.warn("Cloud task stream error", { + key, + error: errorMessage, + wasHealthyStream, + isBackendError, + }); + await this.handleStreamCompletion(key, { + reconnectIfNonTerminal: true, + reconnectError: error, + countReconnectAttempt: !isBackendError && !wasHealthyStream, + }); + } finally { + const currentWatcher = this.watchers.get(key); + if (currentWatcher?.sseAbortController === controller) { + currentWatcher.sseAbortController = null; + } + } + } + + private handleSseEvent(key: string, event: SseEvent): void { + const watcher = this.watchers.get(key); + if (!watcher || watcher.failed) return; + + if (event.id) { + watcher.lastEventId = event.id; + } + + if (event.event === "error") { + const message = isSseErrorEvent(event.data) + ? event.data.error + : "Unknown stream error"; + throw new BackendStreamError(message); + } + + // A keepalive or real event proves the transport recovered, so clear the + // transport reconnect budget. A keepalive stops here: it does NOT clear the + // backend-error budget, since it doesn't prove the stream itself produced + // data. + watcher.reconnectAttempts = 0; + + if ( + event.event === "keepalive" || + (typeof event.data === "object" && + event.data !== null && + "type" in event.data && + event.data.type === "keepalive") + ) { + return; + } + + // A real data event proves the stream materialized; clear the backend-error + // and cumulative budgets too. + watcher.streamErrorAttempts = 0; + watcher.cumulativeReconnectAttempts = 0; + + if (isTaskRunStateEvent(event.data)) { + if (this.applyTaskRunState(watcher, event.data)) { + if (!watcher.isBootstrapping && !isTerminalStatus(watcher.lastStatus)) { + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "status", + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + }); + } + } + return; + } + + if (isPermissionRequestEvent(event.data)) { + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "permission_request" as const, + requestId: event.data.requestId, + toolCall: event.data.toolCall, + options: event.data.options, + }); + return; + } + + watcher.pendingLogEntries.push(event.data as StoredLogEntry); + if (watcher.pendingLogEntries.length >= EVENT_BATCH_MAX_SIZE) { + this.flushLogBatch(key); + return; + } + + if (!watcher.batchFlushTimeoutId) { + watcher.batchFlushTimeoutId = setTimeout(() => { + watcher.batchFlushTimeoutId = null; + this.flushLogBatch(key); + }, EVENT_BATCH_FLUSH_MS); + } + } + + private flushLogBatch(key: string): void { + const watcher = this.watchers.get(key); + if (!watcher || watcher.pendingLogEntries.length === 0) return; + + if (watcher.batchFlushTimeoutId) { + clearTimeout(watcher.batchFlushTimeoutId); + watcher.batchFlushTimeoutId = null; + } + + const entries = watcher.pendingLogEntries; + watcher.pendingLogEntries = []; + + if (watcher.isBootstrapping) { + watcher.bufferedLogBatches.push(entries); + return; + } + + watcher.totalEntryCount += entries.length; + this.rememberEmittedLogEntries(watcher, entries); + + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "logs", + newEntries: entries, + totalEntryCount: watcher.totalEntryCount, + }); + } + + private drainBufferedLogBatches( + key: string, + historicalEntries: StoredLogEntry[], + ): void { + const watcher = this.watchers.get(key); + if (!watcher || watcher.bufferedLogBatches.length === 0) return; + + // Content-based dedup because SSE IDs (Redis stream IDs) don't exist in + // the S3-backed historical entries — the JSON payload is the only shared key + const historicalCounts = new Map<string, number>(); + for (const entry of historicalEntries) { + const serialized = JSON.stringify(entry); + historicalCounts.set( + serialized, + (historicalCounts.get(serialized) ?? 0) + 1, + ); + } + + for (const entries of watcher.bufferedLogBatches) { + const dedupedEntries = entries.filter((entry) => { + const serialized = JSON.stringify(entry); + const remaining = historicalCounts.get(serialized) ?? 0; + if (remaining <= 0) { + return true; + } + + historicalCounts.set(serialized, remaining - 1); + return false; + }); + + if (dedupedEntries.length === 0) { + continue; + } + + watcher.totalEntryCount += dedupedEntries.length; + this.rememberEmittedLogEntries(watcher, dedupedEntries); + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "logs", + newEntries: dedupedEntries, + totalEntryCount: watcher.totalEntryCount, + }); + } + + watcher.bufferedLogBatches = []; + } + + private rememberEmittedLogEntries( + watcher: WatcherState, + entries: StoredLogEntry[], + ): void { + watcher.emittedLogEntries.push(...entries); + } + + private mergeHistoricalAndEmittedEntries( + historicalEntries: StoredLogEntry[], + emittedEntries: StoredLogEntry[], + ): { + snapshotEntries: StoredLogEntry[]; + missingEmittedEntries: StoredLogEntry[]; + } { + if (emittedEntries.length === 0) { + return { snapshotEntries: historicalEntries, missingEmittedEntries: [] }; + } + + const historicalCounts = new Map<string, number>(); + for (const entry of historicalEntries) { + const serialized = JSON.stringify(entry); + historicalCounts.set( + serialized, + (historicalCounts.get(serialized) ?? 0) + 1, + ); + } + + const missingEmittedEntries = emittedEntries.filter((entry) => { + const serialized = JSON.stringify(entry); + const remaining = historicalCounts.get(serialized) ?? 0; + if (remaining <= 0) { + return true; + } + + historicalCounts.set(serialized, remaining - 1); + return false; + }); + + return { + snapshotEntries: [...historicalEntries, ...missingEmittedEntries], + missingEmittedEntries, + }; + } + + private async emitCurrentSnapshot(key: string): Promise<void> { + const watcher = this.watchers.get(key); + if (!watcher || watcher.failed) return; + + const historicalEntries = await this.fetchAllSessionLogs(watcher); + const currentWatcher = this.watchers.get(key); + if (!currentWatcher || currentWatcher !== watcher || watcher.failed) { + return; + } + + if (!historicalEntries) { + this.log.warn("Cloud task snapshot replay failed", { + taskId: watcher.taskId, + runId: watcher.runId, + }); + return; + } + + const { snapshotEntries, missingEmittedEntries } = + this.mergeHistoricalAndEmittedEntries( + historicalEntries, + watcher.emittedLogEntries, + ); + watcher.emittedLogEntries = missingEmittedEntries; + if (snapshotEntries.length > watcher.totalEntryCount) { + watcher.totalEntryCount = snapshotEntries.length; + } + + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "snapshot", + newEntries: snapshotEntries, + totalEntryCount: snapshotEntries.length, + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + }); + } + + private failWatcher(key: string, error: CloudTaskConnectionError): void { + const watcher = this.watchers.get(key); + if (!watcher) return; + + watcher.failed = true; + watcher.isBootstrapping = false; + watcher.pendingLogEntries = []; + watcher.bufferedLogBatches = []; + + if (watcher.reconnectTimeoutId) { + clearTimeout(watcher.reconnectTimeoutId); + watcher.reconnectTimeoutId = null; + } + + if (watcher.batchFlushTimeoutId) { + clearTimeout(watcher.batchFlushTimeoutId); + watcher.batchFlushTimeoutId = null; + } + + watcher.sseAbortController?.abort(); + watcher.sseAbortController = null; + + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "error", + errorTitle: error.title, + errorMessage: error.message, + retryable: error.retryable, + }); + } + + private scheduleReconnect( + key: string, + error?: unknown, + options: { countAttempt?: boolean } = {}, + ): void { + const watcher = this.watchers.get(key); + if (!watcher || watcher.failed || isTerminalStatus(watcher.lastStatus)) { + return; + } + + if (watcher.reconnectTimeoutId) { + clearTimeout(watcher.reconnectTimeoutId); + } + + // Cumulative counter bounds runaway loops that clean-EOF (countAttempt=false) + // and would otherwise dodge `reconnectAttempts`. + watcher.cumulativeReconnectAttempts += 1; + const countAttempt = options.countAttempt ?? true; + if (countAttempt) { + watcher.reconnectAttempts += 1; + } + + if ( + watcher.cumulativeReconnectAttempts > MAX_CUMULATIVE_RECONNECT_ATTEMPTS + ) { + this.failWatcher(key, { + title: "Cloud run unreachable", + message: + "Could not maintain a connection to the cloud run after many attempts. Click retry once the issue is resolved.", + retryable: true, + }); + return; + } + + // The watcher fails once either budget is exhausted: transport reconnect + // failures or backend stream-error frames. + const attemptCount = Math.max( + watcher.reconnectAttempts, + watcher.streamErrorAttempts, + ); + if (attemptCount > MAX_SSE_RECONNECT_ATTEMPTS) { + const details = + error instanceof CloudTaskStreamError + ? error.details + : { + title: "Cloud stream disconnected", + message: + "Lost connection to the cloud run stream. Retry to reconnect.", + retryable: true, + }; + this.failWatcher(key, details); + return; + } + + const backoffAttempts = + error instanceof BackendStreamError + ? watcher.streamErrorAttempts + : watcher.reconnectAttempts; + const delay = Math.min( + SSE_RECONNECT_BASE_DELAY_MS * 2 ** Math.max(backoffAttempts - 1, 0), + SSE_RECONNECT_MAX_DELAY_MS, + ); + + watcher.reconnectTimeoutId = setTimeout(() => { + const currentWatcher = this.watchers.get(key); + if (!currentWatcher) return; + currentWatcher.reconnectTimeoutId = null; + void this.connectSse(key, { + startLatest: + currentWatcher.isBootstrapping || currentWatcher.hasEmittedSnapshot, + }); + }, delay); + } + + private async handleStreamCompletion( + key: string, + options: { + reconnectIfNonTerminal: boolean; + reconnectError?: unknown; + countReconnectAttempt?: boolean; + }, + ): Promise<void> { + const watcher = this.watchers.get(key); + if (!watcher) return; + + const { reconnectIfNonTerminal } = options; + const run = await this.fetchTaskRun(watcher); + const currentWatcher = this.watchers.get(key); + if (!currentWatcher || currentWatcher !== watcher) return; + if (watcher.failed) return; + + if (watcher.isBootstrapping) { + if (!run) { + watcher.needsPostBootstrapReconnect = true; + return; + } + + this.applyTaskRunState(watcher, run); + if (isTerminalStatus(watcher.lastStatus) || !reconnectIfNonTerminal) { + watcher.needsStopAfterBootstrap = true; + } else { + watcher.needsPostBootstrapReconnect = true; + } + return; + } + + if (!run) { + this.scheduleReconnect( + key, + new CloudTaskStreamError("Failed to fetch terminal cloud run state", { + title: "Cloud run state unavailable", + message: + "Could not fetch the latest cloud run state after the stream ended. Retry to reconnect.", + retryable: true, + }), + { countAttempt: options.countReconnectAttempt ?? true }, + ); + return; + } + + const stateChanged = this.applyTaskRunState(watcher, run); + + if (!isTerminalStatus(watcher.lastStatus) && reconnectIfNonTerminal) { + if (stateChanged) { + // Polled progress proves the run is alive — reset both budgets. + watcher.reconnectAttempts = 0; + watcher.cumulativeReconnectAttempts = 0; + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "status", + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + }); + } + this.log.warn("Cloud task stream ended before terminal status", { + key, + status: watcher.lastStatus, + }); + this.scheduleReconnect(key, options.reconnectError, { + countAttempt: options.countReconnectAttempt ?? false, + }); + return; + } + + // Always emit the latest status before stopping. Terminal states are + // intentionally deferred until stream completion; clean EOFs can also mean + // the backend has no more stream events even when the run status remains active. + this.emit(CloudTaskEvent.Update, { + taskId: watcher.taskId, + runId: watcher.runId, + kind: "status", + status: watcher.lastStatus ?? undefined, + stage: watcher.lastStage, + output: watcher.lastOutput, + errorMessage: watcher.lastErrorMessage, + branch: watcher.lastBranch, + }); + + this.stopWatcher(key); + } + + private applyTaskRunState( + watcher: WatcherState, + run: + | Pick< + TaskRunResponse, + | "status" + | "stage" + | "output" + | "error_message" + | "branch" + | "updated_at" + > + | TaskRunStateEvent, + ): boolean { + const updatedAt = run.updated_at ?? null; + if ( + updatedAt && + watcher.lastStatusUpdatedAt && + Date.parse(updatedAt) <= Date.parse(watcher.lastStatusUpdatedAt) + ) { + return false; + } + + const nextStatus = run.status ?? watcher.lastStatus; + const nextStage = run.stage ?? null; + const nextOutput = run.output ?? null; + const nextErrorMessage = run.error_message ?? null; + const nextBranch = run.branch ?? null; + + const changed = + nextStatus !== watcher.lastStatus || + nextStage !== watcher.lastStage || + JSON.stringify(nextOutput) !== JSON.stringify(watcher.lastOutput) || + nextErrorMessage !== watcher.lastErrorMessage || + nextBranch !== watcher.lastBranch; + + watcher.lastStatus = nextStatus ?? null; + watcher.lastStage = nextStage; + watcher.lastOutput = nextOutput; + watcher.lastErrorMessage = nextErrorMessage; + watcher.lastBranch = nextBranch; + if (updatedAt) { + watcher.lastStatusUpdatedAt = updatedAt; + } + + return changed; + } + + private async fetchSessionLogsPage( + watcher: WatcherState, + offset: number, + ): Promise<SessionLogsPage | null> { + const url = new URL( + `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/session_logs/`, + ); + url.searchParams.set("limit", SESSION_LOG_PAGE_LIMIT.toString()); + url.searchParams.set("offset", offset.toString()); + + try { + const authedResponse = await this.auth.authenticatedFetch( + url.toString(), + { + method: "GET", + }, + ); + + if (!authedResponse.ok) { + this.log.warn("Cloud task session logs fetch failed", { + status: authedResponse.status, + taskId: watcher.taskId, + runId: watcher.runId, + offset, + }); + if (shouldFailWatcherForFetchStatus(authedResponse.status)) { + this.failWatcher( + watcherKey(watcher.taskId, watcher.runId), + createStreamStatusError(authedResponse.status).details, + ); + } + return null; + } + + const raw = await authedResponse.text(); + return { + entries: JSON.parse(raw) as StoredLogEntry[], + hasMore: authedResponse.headers.get("X-Has-More") === "true", + }; + } catch (error) { + this.log.warn("Cloud task session logs fetch error", { + taskId: watcher.taskId, + runId: watcher.runId, + offset, + error, + }); + return null; + } + } + + private async fetchAllSessionLogs( + watcher: WatcherState, + ): Promise<StoredLogEntry[] | null> { + const entries: StoredLogEntry[] = []; + let offset = 0; + + while (true) { + const page = await this.fetchSessionLogsPage(watcher, offset); + if (!page) { + return null; + } + + for (const entry of page.entries) { + entries.push(entry); + } + if (!page.hasMore || page.entries.length === 0) { + return entries; + } + + offset += page.entries.length; + } + } + + private async fetchTaskRun( + watcher: WatcherState, + ): Promise<TaskRunResponse | null> { + const url = `${watcher.apiHost}/api/projects/${watcher.teamId}/tasks/${watcher.taskId}/runs/${watcher.runId}/`; + + try { + const authedResponse = await this.auth.authenticatedFetch(url, { + method: "GET", + }); + + if (!authedResponse.ok) { + this.log.warn("Cloud task status fetch failed", { + status: authedResponse.status, + taskId: watcher.taskId, + runId: watcher.runId, + }); + if (shouldFailWatcherForFetchStatus(authedResponse.status)) { + this.failWatcher( + watcherKey(watcher.taskId, watcher.runId), + createStreamStatusError(authedResponse.status).details, + ); + } + return null; + } + + return (await authedResponse.json()) as TaskRunResponse; + } catch (error) { + this.log.warn("Cloud task status fetch error", { + taskId: watcher.taskId, + runId: watcher.runId, + error, + }); + return null; + } + } +} 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<Response>; +} diff --git a/packages/core/src/cloud-task/schemas.ts b/packages/core/src/cloud-task/schemas.ts new file mode 100644 index 0000000000..4b11754fba --- /dev/null +++ b/packages/core/src/cloud-task/schemas.ts @@ -0,0 +1,78 @@ +import type { TaskRunStatus } from "@posthog/shared"; +import { z } from "zod"; +import type { CloudTaskUpdatePayload } from "./cloud-task-types"; + +export type { CloudTaskUpdatePayload, TaskRunStatus }; + +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 --- + +export const CloudTaskEvent = { + Update: "cloud-task-update", +} as const; + +export interface CloudTaskEvents { + [CloudTaskEvent.Update]: CloudTaskUpdatePayload; +} + +// --- tRPC Schemas --- + +export const watchInput = z.object({ + taskId: z.string(), + runId: z.string(), + apiHost: z.string(), + teamId: z.number(), +}); + +export type WatchInput = z.infer<typeof watchInput>; + +export const unwatchInput = z.object({ + taskId: z.string(), + runId: z.string(), +}); + +export const retryInput = z.object({ + taskId: z.string(), + runId: z.string(), +}); + +export const onUpdateInput = z.object({ + taskId: z.string(), + runId: z.string(), +}); + +export const sendCommandInput = z.object({ + taskId: z.string(), + runId: z.string(), + apiHost: z.string(), + teamId: z.number(), + method: z.enum([ + "user_message", + "cancel", + "close", + "permission_response", + "set_config_option", + ]), + params: z.record(z.string(), z.unknown()).optional(), +}); + +export type SendCommandInput = z.infer<typeof sendCommandInput>; + +export const sendCommandOutput = z.object({ + success: z.boolean(), + result: z.unknown().optional(), + error: z.string().optional(), +}); + +export type SendCommandOutput = z.infer<typeof sendCommandOutput>; 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<string, unknown>, + ) => 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<SerializedFlag["staleness"]>; + +const STALENESS_LABELS: Record<Staleness, string> = { + 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<string, ToolCallFileDiff> | undefined { + if (hasRemoteFiles) return undefined; + const diffs = new Map<string, ToolCallFileDiff>(); + 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>): 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<PrReviewComment>, + overrides: Partial<PrCommentThread> = {}, +): 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<number, PrCommentThread>([ + [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<number, PrCommentThread>([ + [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<number, PrCommentThread>([ + [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<number, PrCommentThread>([ + [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/packages/core/src/code-review/prCommentAnnotations.ts b/packages/core/src/code-review/prCommentAnnotations.ts new file mode 100644 index 0000000000..691ee445fd --- /dev/null +++ b/packages/core/src/code-review/prCommentAnnotations.ts @@ -0,0 +1,52 @@ +import type { DiffLineAnnotation } from "@pierre/diffs"; +import type { AnnotationMetadata, PrCommentThread } from "./types"; + +export type { PrCommentThread } from "./types"; + +function buildAnnotation( + thread: PrCommentThread, +): DiffLineAnnotation<AnnotationMetadata> | null { + const root = thread.comments[0]; + if (!root) return null; + + const isFileLevel = root.line == null && root.original_line == null; + const line = root.line ?? root.original_line ?? 1; + + const isOutdated = + !isFileLevel && root.line == null && root.original_line != null; + const side = isFileLevel + ? "additions" + : root.side === "LEFT" + ? "deletions" + : "additions"; + + return { + side, + lineNumber: line, + metadata: { + kind: "pr-comment", + threadId: thread.rootId, + nodeId: thread.nodeId, + isResolved: thread.isResolved, + comments: thread.comments, + isOutdated, + isFileLevel, + startLine: root.start_line, + endLine: line, + side, + }, + }; +} + +export function buildFileAnnotations( + threads: Map<number, PrCommentThread>, + filePath: string, +): DiffLineAnnotation<AnnotationMetadata>[] { + const annotations: DiffLineAnnotation<AnnotationMetadata>[] = []; + for (const thread of threads.values()) { + if (thread.filePath !== filePath) continue; + const annotation = buildAnnotation(thread); + if (annotation) annotations.push(annotation); + } + return annotations; +} 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> = {}, +): 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<typeof vi.fn>; + 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<typeof vi.fn>; + 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<string | null>; + readRepoFile(repoPath: string, filePath: string): Promise<string | null>; + writeRepoFile( + repoPath: string, + filePath: string, + content: string, + ): Promise<void>; +} + +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<void> { + 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<boolean> { + 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<string> | 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> = {}): 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<typeof sumHunkStats>[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<string, number> { + const index = new Map<string, number>(); + 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/packages/core/src/code-review/types.ts b/packages/core/src/code-review/types.ts new file mode 100644 index 0000000000..07a2fe25a7 --- /dev/null +++ b/packages/core/src/code-review/types.ts @@ -0,0 +1,66 @@ +import type { AnnotationSide, FileDiffOptions } from "@pierre/diffs"; +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"; + hunkIndex: number; +} + +export interface CommentMetadata { + kind: "comment"; + startLine: number; + endLine: number; + side: AnnotationSide; +} + +export interface DraftCommentMetadata { + kind: "draft-comment"; + draftId: string; + startLine: number; + endLine: number; + side: AnnotationSide; +} + +export interface PrCommentMetadata { + kind: "pr-comment"; + threadId: number; + nodeId: string; + isResolved: boolean; + comments: PrReviewComment[]; + isOutdated: boolean; + isFileLevel: boolean; + startLine: number | null; + endLine: number; + side: AnnotationSide; +} + +export type AnnotationMetadata = + | HunkRevertMetadata + | CommentMetadata + | DraftCommentMetadata + | PrCommentMetadata; + +export type DiffOptions = FileDiffOptions<AnnotationMetadata>; 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> = {}): 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<string, Task>; + sessionByTaskId: Map<string, AgentSession>; + workspaces: Record<string, { mode: WorkspaceMode } | undefined> | 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<string>; + archivedIds: Set<string>; + 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<string, unknown> | 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> = {}, +): 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<ConnectivityState>((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<void>((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/packages/core/src/context-menu/context-menu.ts b/packages/core/src/context-menu/context-menu.ts new file mode 100644 index 0000000000..67ad39cfbc --- /dev/null +++ b/packages/core/src/context-menu/context-menu.ts @@ -0,0 +1,391 @@ +import { + CONTEXT_MENU_SERVICE, + type ContextMenuItem, + type IContextMenu, +} from "@posthog/platform/context-menu"; +import { DIALOG_SERVICE, type IDialog } from "@posthog/platform/dialog"; +import { inject, injectable } from "inversify"; +import { + CONTEXT_MENU_EXTERNAL_APPS_SERVICE, + type ContextMenuExternalApp, + type IContextMenuExternalApps, +} from "./identifiers"; +import type { + ArchivedTaskAction, + ArchivedTaskContextMenuInput, + ArchivedTaskContextMenuResult, + BulkTaskAction, + BulkTaskContextMenuInput, + ConfirmDeleteArchivedTaskInput, + ConfirmDeleteArchivedTaskResult, + ConfirmDeleteTaskInput, + ConfirmDeleteTaskResult, + FileAction, + FileContextMenuInput, + FileContextMenuResult, + FolderAction, + FolderContextMenuInput, + FolderContextMenuResult, + SplitContextMenuResult, + SplitDirection, + TabAction, + TabContextMenuInput, + TabContextMenuResult, + TaskAction, + TaskContextMenuInput, + TaskContextMenuResult, +} from "./schemas"; +import type { + ActionItemDef, + ConfirmOptions, + MenuItemDef, + SeparatorDef, +} from "./types"; + +@injectable() +export class ContextMenuService { + constructor( + @inject(CONTEXT_MENU_EXTERNAL_APPS_SERVICE) + private readonly externalApps: IContextMenuExternalApps, + @inject(DIALOG_SERVICE) + private readonly dialog: IDialog, + @inject(CONTEXT_MENU_SERVICE) + private readonly contextMenu: IContextMenu, + ) {} + + private async getExternalAppsData() { + const [apps, lastUsed] = await Promise.all([ + this.externalApps.getDetectedApps(), + this.externalApps.getLastUsed(), + ]); + return { apps, lastUsedAppId: lastUsed.lastUsedApp }; + } + + async confirmDeleteTask( + input: ConfirmDeleteTaskInput, + ): Promise<ConfirmDeleteTaskResult> { + const confirmed = await this.confirm({ + title: "Delete Task", + message: `Delete "${input.taskTitle}"?`, + detail: input.hasWorktree + ? "This will permanently delete the task and its associated worktree." + : "This will permanently delete the task.", + confirmLabel: "Delete", + }); + return { confirmed }; + } + + async confirmDeleteArchivedTask( + input: ConfirmDeleteArchivedTaskInput, + ): Promise<ConfirmDeleteArchivedTaskResult> { + const confirmed = await this.confirm({ + title: "Delete Archived Task", + message: `Delete "${input.taskTitle}"?`, + detail: "This will permanently delete the archived task.", + confirmLabel: "Delete", + }); + return { confirmed }; + } + + async confirmDeleteWorktree({ + worktreePath, + linkedTaskCount, + }: { + worktreePath: string; + linkedTaskCount: number; + }): Promise<{ confirmed: boolean }> { + const confirmed = await this.confirm({ + title: "Delete Worktree", + message: `Delete worktree at ${worktreePath}?`, + detail: + linkedTaskCount > 0 + ? `This will remove ${linkedTaskCount} linked task${linkedTaskCount === 1 ? "" : "s"} and delete the worktree.` + : "This will delete the worktree from disk.", + confirmLabel: "Delete", + }); + return { confirmed }; + } + + async showTaskContextMenu( + input: TaskContextMenuInput, + ): Promise<TaskContextMenuResult> { + const { + worktreePath, + folderPath, + isPinned, + isSuspended, + isInCommandCenter, + hasEmptyCommandCenterCell, + } = input; + const { apps, lastUsedAppId } = await this.getExternalAppsData(); + const hasPath = worktreePath || folderPath; + + return this.showMenu<TaskAction>([ + this.item(isPinned ? "Unpin" : "Pin", { type: "pin" }), + this.item("Rename", { type: "rename" }), + ...(worktreePath + ? [ + this.separator(), + this.item(isSuspended ? "Unsuspend" : "Suspend", { + type: "suspend" as const, + }), + ] + : []), + ...(hasPath + ? [ + ...(worktreePath ? [] : [this.separator()]), + ...this.externalAppItems<TaskAction>(apps, lastUsedAppId), + ] + : []), + ...(!isInCommandCenter + ? [ + this.separator(), + this.item( + "Add to Command Center", + { type: "add-to-command-center" as const }, + { enabled: hasEmptyCommandCenterCell ?? true }, + ), + ] + : []), + this.separator(), + this.item("Archive", { type: "archive" }), + this.item( + "Archive prior tasks", + { type: "archive-prior" }, + { + confirm: { + title: "Archive Prior Tasks", + message: "Archive all tasks older than this one?", + detail: + "This will archive every task created before this one. You can unarchive them later.", + confirmLabel: "Archive", + }, + }, + ), + ]); + } + + async showBulkTaskContextMenu( + input: BulkTaskContextMenuInput, + ): Promise<{ action: BulkTaskAction | null }> { + const { taskCount } = input; + const label = `Archive ${taskCount} tasks`; + return this.showMenu<BulkTaskAction>([ + this.item( + label, + { type: "archive" }, + { + confirm: { + title: "Archive Tasks", + message: `Archive ${taskCount} tasks?`, + detail: "You can unarchive them later.", + confirmLabel: "Archive", + }, + }, + ), + ]); + } + + async showArchivedTaskContextMenu( + input: ArchivedTaskContextMenuInput, + ): Promise<ArchivedTaskContextMenuResult> { + return this.showMenu<ArchivedTaskAction>([ + this.item("Unarchive", { type: "restore" }), + this.item( + "Delete", + { type: "delete" }, + { + confirm: { + title: "Delete Archived Task", + message: `Delete "${input.taskTitle}"?`, + detail: "This will permanently delete the archived task.", + confirmLabel: "Delete", + }, + }, + ), + ]); + } + + async showFolderContextMenu( + input: FolderContextMenuInput, + ): Promise<FolderContextMenuResult> { + const { folderName, folderPath } = input; + const { apps, lastUsedAppId } = await this.getExternalAppsData(); + + return this.showMenu<FolderAction>([ + this.item( + "Remove folder", + { type: "remove" }, + { + confirm: { + title: "Remove Folder", + message: `Remove "${folderName}" from Array?`, + detail: + "This will clean up any worktrees but keep your folder and tasks intact.", + confirmLabel: "Remove", + }, + }, + ), + ...(folderPath + ? [ + this.separator(), + ...this.externalAppItems<FolderAction>(apps, lastUsedAppId), + ] + : []), + ]); + } + + async showTabContextMenu( + input: TabContextMenuInput, + ): Promise<TabContextMenuResult> { + const { canClose, filePath } = input; + const { apps, lastUsedAppId } = await this.getExternalAppsData(); + + return this.showMenu<TabAction>([ + this.item( + "Close tab", + { type: "close" }, + { + accelerator: "CmdOrCtrl+W", + enabled: canClose, + }, + ), + this.item("Close other tabs", { type: "close-others" }), + this.item("Close tabs to the right", { type: "close-right" }), + ...(filePath + ? [ + this.separator(), + ...this.externalAppItems<TabAction>(apps, lastUsedAppId), + ] + : []), + ]); + } + + async showSplitContextMenu(): Promise<SplitContextMenuResult> { + const result = await this.showMenu<SplitDirection>([ + this.item("Split right", "right"), + this.item("Split left", "left"), + this.item("Split down", "down"), + this.item("Split up", "up"), + ]); + return { direction: result.action }; + } + + async showFileContextMenu( + input: FileContextMenuInput, + ): Promise<FileContextMenuResult> { + const { apps, lastUsedAppId } = await this.getExternalAppsData(); + + return this.showMenu<FileAction>([ + ...(input.showCollapseAll + ? [ + this.item<FileAction>("Collapse All", { type: "collapse-all" }), + this.separator(), + ] + : []), + ...this.externalAppItems<FileAction>(apps, lastUsedAppId), + ]); + } + + private externalAppItems<T>( + apps: ContextMenuExternalApp[], + lastUsedAppId?: string, + ): MenuItemDef<T>[] { + if (apps.length === 0) { + return [this.disabled("No external apps detected")]; + } + + const lastUsedApp = apps.find((app) => app.id === lastUsedAppId) || apps[0]; + const openIn = (appId: string): T => + ({ type: "external-app", action: { type: "open-in-app", appId } }) as T; + return [ + this.item(`Open in ${lastUsedApp.name}`, openIn(lastUsedApp.id)), + { + type: "submenu", + label: "Open in", + items: apps.map((app) => ({ + label: app.name, + icon: app.icon, + action: openIn(app.id), + })), + }, + ]; + } + + private item<T>( + label: string, + action: T, + options?: Partial<Omit<ActionItemDef<T>, "type" | "label" | "action">>, + ): ActionItemDef<T> { + return { type: "item", label, action, ...options }; + } + + private separator(): SeparatorDef { + return { type: "separator" }; + } + + private disabled(label: string): MenuItemDef<never> { + return { type: "disabled", label }; + } + + private showMenu<T>(items: MenuItemDef<T>[]): Promise<{ action: T | null }> { + return new Promise((resolve) => { + let pendingConfirm = false; + + const toContextMenuItem = (def: MenuItemDef<T>): ContextMenuItem => { + switch (def.type) { + case "separator": + return { separator: true }; + case "disabled": + return { label: def.label, enabled: false, click: () => {} }; + case "submenu": + return { + label: def.label, + submenu: def.items.map((sub) => ({ + label: sub.label, + icon: sub.icon, + click: () => resolve({ action: sub.action }), + })), + click: () => {}, + }; + case "item": { + const confirmOptions = def.confirm; + const click = confirmOptions + ? async () => { + pendingConfirm = true; + const confirmed = await this.confirm(confirmOptions); + resolve({ action: confirmed ? def.action : null }); + } + : () => resolve({ action: def.action }); + return { + label: def.label, + enabled: def.enabled, + accelerator: def.accelerator, + icon: def.icon, + click, + }; + } + } + }; + + this.contextMenu.show(items.map(toContextMenuItem), { + onDismiss: () => { + if (!pendingConfirm) resolve({ action: null }); + }, + }); + }); + } + + private async confirm(options: ConfirmOptions): Promise<boolean> { + const response = await this.dialog.confirm({ + severity: "question", + title: options.title, + message: options.message, + detail: options.detail, + options: ["Cancel", options.confirmLabel], + defaultIndex: 1, + cancelIndex: 0, + }); + return response === 1; + } +} 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<ContextMenuExternalApp[]>; + 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/packages/core/src/context-menu/schemas.ts b/packages/core/src/context-menu/schemas.ts new file mode 100644 index 0000000000..7bb23275d2 --- /dev/null +++ b/packages/core/src/context-menu/schemas.ts @@ -0,0 +1,169 @@ +import { z } from "zod"; + +export const taskContextMenuInput = z.object({ + taskTitle: z.string(), + worktreePath: z.string().optional(), + folderPath: z.string().optional(), + isPinned: z.boolean().optional(), + isSuspended: z.boolean().optional(), + isInCommandCenter: z.boolean().optional(), + hasEmptyCommandCenterCell: z.boolean().optional(), +}); + +export const bulkTaskContextMenuInput = z.object({ + taskCount: z.number().int().min(2), +}); + +export const archivedTaskContextMenuInput = z.object({ + taskTitle: z.string(), +}); + +export const folderContextMenuInput = z.object({ + folderName: z.string(), + folderPath: z.string().optional(), +}); + +export const tabContextMenuInput = z.object({ + canClose: z.boolean(), + filePath: z.string().optional(), +}); + +export const fileContextMenuInput = z.object({ + filePath: z.string(), + showCollapseAll: z.boolean().optional(), +}); + +const externalAppAction = z.discriminatedUnion("type", [ + z.object({ type: z.literal("open-in-app"), appId: z.string() }), + z.object({ type: z.literal("copy-path") }), +]); + +const taskAction = z.discriminatedUnion("type", [ + z.object({ type: z.literal("rename") }), + z.object({ type: z.literal("pin") }), + z.object({ type: z.literal("suspend") }), + z.object({ type: z.literal("archive") }), + z.object({ type: z.literal("archive-prior") }), + z.object({ type: z.literal("delete") }), + z.object({ type: z.literal("add-to-command-center") }), + z.object({ type: z.literal("external-app"), action: externalAppAction }), +]); + +const bulkTaskAction = z.discriminatedUnion("type", [ + z.object({ type: z.literal("archive") }), +]); + +const archivedTaskAction = z.discriminatedUnion("type", [ + z.object({ type: z.literal("restore") }), + z.object({ type: z.literal("delete") }), +]); + +const folderAction = z.discriminatedUnion("type", [ + z.object({ type: z.literal("remove") }), + z.object({ type: z.literal("external-app"), action: externalAppAction }), +]); + +const tabAction = z.discriminatedUnion("type", [ + z.object({ type: z.literal("close") }), + z.object({ type: z.literal("close-others") }), + z.object({ type: z.literal("close-right") }), + z.object({ type: z.literal("external-app"), action: externalAppAction }), +]); + +const fileAction = z.discriminatedUnion("type", [ + z.object({ type: z.literal("collapse-all") }), + z.object({ type: z.literal("external-app"), action: externalAppAction }), +]); + +const splitDirection = z.enum(["left", "right", "up", "down"]); + +export const taskContextMenuOutput = z.object({ + action: taskAction.nullable(), +}); +export const bulkTaskContextMenuOutput = z.object({ + action: bulkTaskAction.nullable(), +}); +export const archivedTaskContextMenuOutput = z.object({ + action: archivedTaskAction.nullable(), +}); +export const folderContextMenuOutput = z.object({ + action: folderAction.nullable(), +}); +export const tabContextMenuOutput = z.object({ action: tabAction.nullable() }); +export const fileContextMenuOutput = z.object({ + action: fileAction.nullable(), +}); +export const splitContextMenuOutput = z.object({ + direction: splitDirection.nullable(), +}); + +export type TaskContextMenuInput = z.infer<typeof taskContextMenuInput>; +export type BulkTaskContextMenuInput = z.infer<typeof bulkTaskContextMenuInput>; +export type ArchivedTaskContextMenuInput = z.infer< + typeof archivedTaskContextMenuInput +>; +export type FolderContextMenuInput = z.infer<typeof folderContextMenuInput>; +export type TabContextMenuInput = z.infer<typeof tabContextMenuInput>; +export type FileContextMenuInput = z.infer<typeof fileContextMenuInput>; + +export type ExternalAppAction = z.infer<typeof externalAppAction>; +export type TaskAction = z.infer<typeof taskAction>; +export type BulkTaskAction = z.infer<typeof bulkTaskAction>; +export type ArchivedTaskAction = z.infer<typeof archivedTaskAction>; +export type FolderAction = z.infer<typeof folderAction>; +export type TabAction = z.infer<typeof tabAction>; +export type FileAction = z.infer<typeof fileAction>; +export type SplitDirection = z.infer<typeof splitDirection>; + +export const confirmDeleteTaskInput = z.object({ + taskTitle: z.string(), + hasWorktree: z.boolean(), +}); + +export const confirmDeleteTaskOutput = z.object({ + confirmed: z.boolean(), +}); + +export const confirmDeleteArchivedTaskInput = z.object({ + taskTitle: z.string(), +}); + +export const confirmDeleteArchivedTaskOutput = z.object({ + confirmed: z.boolean(), +}); + +export const confirmDeleteWorktreeInput = z.object({ + worktreePath: z.string(), + linkedTaskCount: z.number(), +}); + +export const confirmDeleteWorktreeOutput = z.object({ + confirmed: z.boolean(), +}); + +export type ConfirmDeleteTaskInput = z.infer<typeof confirmDeleteTaskInput>; +export type ConfirmDeleteTaskResult = z.infer<typeof confirmDeleteTaskOutput>; +export type ConfirmDeleteArchivedTaskInput = z.infer< + typeof confirmDeleteArchivedTaskInput +>; +export type ConfirmDeleteArchivedTaskResult = z.infer< + typeof confirmDeleteArchivedTaskOutput +>; +export type ConfirmDeleteWorktreeInput = z.infer< + typeof confirmDeleteWorktreeInput +>; +export type ConfirmDeleteWorktreeResult = z.infer< + typeof confirmDeleteWorktreeOutput +>; + +export type TaskContextMenuResult = z.infer<typeof taskContextMenuOutput>; +export type BulkTaskContextMenuResult = z.infer< + typeof bulkTaskContextMenuOutput +>; +export type ArchivedTaskContextMenuResult = z.infer< + typeof archivedTaskContextMenuOutput +>; +export type FolderContextMenuResult = z.infer<typeof folderContextMenuOutput>; +export type TabContextMenuResult = z.infer<typeof tabContextMenuOutput>; +export type FileContextMenuResult = z.infer<typeof fileContextMenuOutput>; +export type SplitContextMenuResult = z.infer<typeof splitContextMenuOutput>; 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<GithubRef | null>; +} + +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> = {}): 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<GitHubIssueClient["getGithubIssue"]>() + .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<NewTaskLinkResolution> { + 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<NewTaskLinkPayload, { action: "new" }>, + ): 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<NewTaskLinkPayload, { action: "plan" }>, + ): 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<NewTaskLinkPayload, { action: "issue" }>, + ): Promise<NewTaskLinkResolution> { + let issue: Awaited<ReturnType<GitHubIssueClient["getGithubIssue"]>>; + 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<string | null>>(); 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 <folder path="/abs/dir" /> and <file path="/tmp/test.txt" />'; - 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 <file path="/tmp/test.txt" />', + [], + 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 <file path="C:\\\\tmp\\\\100%\\\\a#b?.txt" />', + [], + 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 <file path="\\\\server\\share\\My Folder\\file.txt" />', + [], + 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 <file path="/tmp/screenshot.png" />', + [], + 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 <file path="/tmp/huge.png" />'), + buildCloudPromptBlocks( + 'see <file path="/tmp/huge.png" />', + [], + readFileAsBase64, + ), ).rejects.toThrow(/too large/); }); it("rejects unsupported image formats", async () => { await expect( - buildCloudPromptBlocks('see <file path="/tmp/photo.bmp" />'), + buildCloudPromptBlocks( + 'see <file path="/tmp/photo.bmp" />', + [], + readFileAsBase64, + ), ).rejects.toThrow(/Unsupported image/); }); it("treats SVG attachments as text resource links", async () => { const blocks = await buildCloudPromptBlocks( 'see <file path="/tmp/icon.svg" />', + [], + 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 <file path="/tmp/photo.heic" />'), + buildCloudPromptBlocks( + 'see <file path="/tmp/photo.heic" />', + [], + readFileAsBase64, + ), ).rejects.toThrow(/Unsupported attachment/); await expect( - buildCloudPromptBlocks('see <file path="/tmp/photo.heif" />'), + buildCloudPromptBlocks( + 'see <file path="/tmp/photo.heif" />', + [], + readFileAsBase64, + ), ).rejects.toThrow(/Unsupported attachment/); }); it("does not rely on readAbsoluteFile for txt attachments", async () => { const blocks = await buildCloudPromptBlocks( 'read <file path="/tmp/maybe-missing-on-disk.txt" />', + [], + 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 <file path="/tmp/broken.png" />'), + buildCloudPromptBlocks( + 'see <file path="/tmp/broken.png" />', + [], + 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<string | null>; const ABSOLUTE_FILE_TAG_REGEX = /<file\s+path="([^"]+)"\s*\/>/g; const FOLDER_TAG_REGEX = /<folder\s+path="[^"]+"\s*\/>/g; @@ -158,12 +157,15 @@ export function buildCloudTaskDescription( : attachmentSummary; } -async function buildAttachmentBlock(filePath: string): Promise<ContentBlock> { +async function buildAttachmentBlock( + filePath: string, + readFileAsBase64: ReadFileAsBase64, +): Promise<ContentBlock> { 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<ContentBlock> { ); } - // 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<ContentBlock> { }; } -/** Test/harness prompts: text → `resource_link` only (production cloud uses uploads + artifact_ids). */ export async function buildCloudPromptBlocks( prompt: string, filePaths: string[] = [], + readFileAsBase64: ReadFileAsBase64, ): Promise<ContentBlock[]> { 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<typeof vi.fn>; +} { + 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<typeof vi.fn>; +} { + 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<typeof makeClient>; + let focus: ReturnType<typeof makeFocus>; + 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<ExternalAppActionOutcome> { + 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<EnsureFocusResult> { + 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<ExternalAppsOpenResult>; + setLastUsed(appId: string): Promise<void>; + getDetectedApps(): Promise<ExternalAppsDetectedApp[]>; + copyPath(targetPath: string): Promise<void>; +} + +export interface ExternalAppsFocusSession { + worktreePath: string; +} + +export interface ExternalAppsFocusParams { + mainRepoPath: string; + worktreePath: string; + branch: string; +} + +export interface ExternalAppsFocusCoordinator { + getSession(): ExternalAppsFocusSession | null; + enableFocus(params: ExternalAppsFocusParams): Promise<FocusSagaResult>; +} 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<typeof focusResultSchema>; + +export const stashResultSchema = focusResultSchema.extend({ + stashRef: z.string().optional(), +}); + +export type StashResult = z.infer<typeof stashResultSchema>; + +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<typeof focusSessionSchema>; + +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<void>; + deleteSession(mainRepoPath: string): Promise<void>; + isFocusActive(mainRepoPath: string): boolean; + validateFocusOperation( + currentBranch: string | null, + targetBranch: string, + ): string | null; + isDirty(repoPath: string): Promise<boolean>; + getCommitSha(repoPath: string): Promise<string>; + findWorktreeByBranch( + mainRepoPath: string, + branch: string, + ): Promise<string | null>; + toRelativeWorktreePath(absolutePath: string, mainRepoPath: string): string; + toAbsoluteWorktreePath(relativePath: string): string; + worktreeExistsAtPath(relativePath: string): Promise<boolean>; + stash(repoPath: string, message: string): Promise<StashResult>; + stashPop(repoPath: string): Promise<FocusResult>; + stashApply(repoPath: string, stashRef: string): Promise<FocusResult>; + checkout(repoPath: string, branch: string): Promise<FocusResult>; + detachWorktree(worktreePath: string): Promise<FocusResult>; + reattachWorktree(worktreePath: string, branch: string): Promise<FocusResult>; + cleanWorkingTree(repoPath: string): Promise<void>; + startSync(mainRepoPath: string, worktreePath: string): Promise<void>; + stopSync(): Promise<void>; + startWatchingMainRepo(mainRepoPath: string): Promise<void>; + stopWatchingMainRepo(): Promise<void>; + toIterable<K extends keyof FocusServiceEvents>( + event: K, + options: { signal?: AbortSignal }, + ): AsyncIterable<FocusServiceEvents[K]>; +} 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> = {}): FocusSession { + return { + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + branch: "feature", + originalBranch: "main", + mainStashRef: null, + commitSha: "sha-main", + ...overrides, + }; +} + +function createParams( + overrides: Partial<EnableFocusParams> = {}, +): EnableFocusParams { + return { + mainRepoPath: MAIN_REPO, + worktreePath: WORKTREE, + branch: "feature", + ...overrides, + }; +} + +type Deps = { + [K in keyof FocusControllerDeps]: ReturnType<typeof vi.fn>; +} & FocusControllerDeps; + +function createDeps(overrides: Partial<FocusControllerDeps> = {}): 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/git-interaction/branchCreation.test.ts b/packages/core/src/git-interaction/branchCreation.test.ts new file mode 100644 index 0000000000..311f082bc4 --- /dev/null +++ b/packages/core/src/git-interaction/branchCreation.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createBranch, getBranchNameInputState } from "./branchCreation"; + +const mockCreateBranch = vi.fn(); +const writeClient = { + createBranch: mockCreateBranch, +}; + +describe("branchCreation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getBranchNameInputState", () => { + it("sanitizes spaces and returns no error for valid names", () => { + expect(getBranchNameInputState("feature my branch")).toEqual({ + sanitized: "feature-my-branch", + error: null, + }); + }); + + it("returns validation errors for invalid names", () => { + expect(getBranchNameInputState("feature..branch")).toEqual({ + sanitized: "feature..branch", + error: 'Branch name cannot contain "..".', + }); + }); + }); + + describe("createBranch", () => { + it("returns missing-repo error when repo path is not provided", async () => { + const result = await createBranch({ + writeClient, + repoPath: undefined, + rawBranchName: "feature/test", + }); + + expect(result).toEqual({ + success: false, + error: "Select a repository folder first.", + reason: "missing-repo", + }); + expect(mockCreateBranch).not.toHaveBeenCalled(); + }); + + it("returns validation error for empty branch name", async () => { + const result = await createBranch({ + writeClient, + repoPath: "/repo", + rawBranchName: " ", + }); + + expect(result).toEqual({ + success: false, + error: "Branch name is required.", + reason: "validation", + }); + expect(mockCreateBranch).not.toHaveBeenCalled(); + }); + + it("returns validation error for invalid branch names", async () => { + const result = await createBranch({ + writeClient, + repoPath: "/repo", + rawBranchName: "feature..branch", + }); + + expect(result).toEqual({ + success: false, + error: 'Branch name cannot contain "..".', + reason: "validation", + }); + expect(mockCreateBranch).not.toHaveBeenCalled(); + }); + + it("creates branch with trimmed name", async () => { + mockCreateBranch.mockResolvedValueOnce(undefined); + + const result = await createBranch({ + writeClient, + repoPath: "/repo", + rawBranchName: " feature/test ", + }); + + expect(mockCreateBranch).toHaveBeenCalledWith("/repo", "feature/test"); + expect(result).toEqual({ + success: true, + branchName: "feature/test", + }); + }); + + it("returns request error with message when mutate throws Error", async () => { + const error = new Error("boom"); + mockCreateBranch.mockRejectedValueOnce(error); + + const result = await createBranch({ + writeClient, + repoPath: "/repo", + rawBranchName: "feature/test", + }); + + expect(result).toEqual({ + success: false, + error: "boom", + reason: "request", + rawError: error, + }); + }); + + it("returns fallback error when mutate throws non-Error value", async () => { + mockCreateBranch.mockRejectedValueOnce("oops"); + + const result = await createBranch({ + writeClient, + repoPath: "/repo", + rawBranchName: "feature/test", + }); + + expect(result).toEqual({ + success: false, + error: "Failed to create branch.", + reason: "request", + rawError: "oops", + }); + }); + }); +}); diff --git a/packages/core/src/git-interaction/branchCreation.ts b/packages/core/src/git-interaction/branchCreation.ts new file mode 100644 index 0000000000..eb36664969 --- /dev/null +++ b/packages/core/src/git-interaction/branchCreation.ts @@ -0,0 +1,88 @@ +import { sanitizeBranchName, validateBranchName } from "./branchName"; + +interface BranchCreator { + createBranch(repoPath: string, branchName: string): Promise<void>; +} + +export interface BranchNameInputState { + sanitized: string; + error: string | null; +} + +export type CreateBranchResult = + | { + success: true; + branchName: string; + } + | { + success: false; + error: string; + reason: "missing-repo" | "validation" | "request"; + rawError?: unknown; + }; + +interface CreateBranchInput { + writeClient: BranchCreator; + repoPath?: string; + rawBranchName: string; +} + +function getCreateBranchError(error: unknown): string { + return error instanceof Error ? error.message : "Failed to create branch."; +} + +export function getBranchNameInputState(value: string): BranchNameInputState { + const sanitized = sanitizeBranchName(value); + return { + sanitized, + error: validateBranchName(sanitized), + }; +} + +export async function createBranch({ + writeClient, + repoPath, + rawBranchName, +}: CreateBranchInput): Promise<CreateBranchResult> { + if (!repoPath) { + return { + success: false, + error: "Select a repository folder first.", + reason: "missing-repo", + }; + } + + const branchName = rawBranchName.trim(); + if (!branchName) { + return { + success: false, + error: "Branch name is required.", + reason: "validation", + }; + } + + const validationError = validateBranchName(branchName); + if (validationError) { + return { + success: false, + error: validationError, + reason: "validation", + }; + } + + try { + await writeClient.createBranch(repoPath, branchName); + + return { + success: true, + branchName, + }; + } catch (error) { + return { + success: false, + error: getCreateBranchError(error), + reason: "request", + rawError: error, + }; + } +} diff --git a/packages/core/src/git-interaction/branchName.test.ts b/packages/core/src/git-interaction/branchName.test.ts new file mode 100644 index 0000000000..b6247967d9 --- /dev/null +++ b/packages/core/src/git-interaction/branchName.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + sanitizeBranchName, + suggestBranchName, + validateBranchName, +} from "./branchName"; + +describe("sanitizeBranchName", () => { + it("replaces spaces with dashes", () => { + expect(sanitizeBranchName("my new branch")).toBe("my-new-branch"); + }); + + it("replaces multiple consecutive spaces", () => { + expect(sanitizeBranchName("a b c")).toBe("a--b---c"); + }); + + it("returns the same string when there are no spaces", () => { + expect(sanitizeBranchName("feature/foo-bar")).toBe("feature/foo-bar"); + }); + + it("handles empty string", () => { + expect(sanitizeBranchName("")).toBe(""); + }); +}); + +describe("validateBranchName", () => { + it("returns null for valid branch names", () => { + expect(validateBranchName("feature/my-branch")).toBeNull(); + expect(validateBranchName("fix-123")).toBeNull(); + expect(validateBranchName("release/v1.0.0")).toBeNull(); + expect(validateBranchName("user/feature")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(validateBranchName("")).toBeNull(); + }); + + it("rejects control characters", () => { + expect(validateBranchName("branch\x00name")).not.toBeNull(); + expect(validateBranchName("branch\x1fname")).not.toBeNull(); + expect(validateBranchName("branch\x7fname")).not.toBeNull(); + }); + + it('rejects ".."', () => { + expect(validateBranchName("branch..name")).not.toBeNull(); + }); + + it("rejects forbidden characters", () => { + for (const char of ["~", "^", ":", "?", "*", "[", "]", "\\"]) { + expect(validateBranchName(`branch${char}name`)).not.toBeNull(); + } + }); + + it("rejects spaces", () => { + expect(validateBranchName("branch name")).not.toBeNull(); + }); + + it("rejects names starting or ending with a dot", () => { + expect(validateBranchName(".branch")).not.toBeNull(); + expect(validateBranchName("branch.")).not.toBeNull(); + }); + + it('rejects names ending with ".lock"', () => { + expect(validateBranchName("branch.lock")).not.toBeNull(); + }); + + it('rejects "@{"', () => { + expect(validateBranchName("branch@{0}")).not.toBeNull(); + }); + + it('rejects bare "@"', () => { + expect(validateBranchName("@")).not.toBeNull(); + }); + + it('allows "@" as part of a longer name', () => { + expect(validateBranchName("user@feature")).toBeNull(); + }); + + it('rejects "//"', () => { + expect(validateBranchName("branch//name")).not.toBeNull(); + }); + + it("rejects path components starting or ending with a dot", () => { + expect(validateBranchName("feature/.hidden")).not.toBeNull(); + expect(validateBranchName("feature/name.")).not.toBeNull(); + }); + + it("returns a descriptive error message", () => { + 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/packages/core/src/git-interaction/branchName.ts b/packages/core/src/git-interaction/branchName.ts new file mode 100644 index 0000000000..d5301081eb --- /dev/null +++ b/packages/core/src/git-interaction/branchName.ts @@ -0,0 +1,83 @@ +import { BRANCH_PREFIX } from "@posthog/shared"; + +export function sanitizeBranchName(input: string): string { + return input.replace(/ /g, "-"); +} + +export function validateBranchName(name: string): string | null { + if (name === "") return null; + + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching ASCII control characters forbidden by git + if (/[\x00-\x1f\x7f]/.test(name)) { + return "Branch name cannot contain control characters."; + } + + if (name.includes("..")) { + return 'Branch name cannot contain "..".'; + } + + if (/[~^:?*[\]\\]/.test(name)) { + return "Branch name cannot contain ~, ^, :, ?, *, [, ], or \\."; + } + + if (name.includes(" ")) { + return "Branch name cannot contain spaces."; + } + + if (name.startsWith(".") || name.endsWith(".")) { + return "Branch name cannot start or end with a dot."; + } + + if (name.endsWith(".lock")) { + return 'Branch name cannot end with ".lock".'; + } + + if (name.includes("@{")) { + return 'Branch name cannot contain "@{".'; + } + + if (name === "@") { + return 'Branch name cannot be "@".'; + } + + if (name.includes("//")) { + return 'Branch name cannot contain "//".'; + } + + const components = name.split("/"); + for (const component of components) { + if (component.startsWith(".") || component.endsWith(".")) { + return "Path components cannot start or end with a dot."; + } + } + + 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/packages/core/src/git-interaction/diffStats.ts b/packages/core/src/git-interaction/diffStats.ts new file mode 100644 index 0000000000..6a67aae272 --- /dev/null +++ b/packages/core/src/git-interaction/diffStats.ts @@ -0,0 +1,43 @@ +import type { ChangedFile } from "@posthog/shared/domain-types"; + +export interface DiffStats { + filesChanged: number; + linesAdded: number; + linesRemoved: number; +} + +export function computeDiffStats(files: ChangedFile[]): DiffStats { + let linesAdded = 0; + let linesRemoved = 0; + const uniquePaths = new Set<string>(); + for (const file of files) { + linesAdded += file.linesAdded ?? 0; + linesRemoved += file.linesRemoved ?? 0; + uniquePaths.add(file.path); + } + return { filesChanged: uniquePaths.size, linesAdded, linesRemoved }; +} + +export function formatFileCountLabel( + stagedOnly: boolean, + stagedFileCount: number, + totalFileCount: number, +): string { + if (stagedOnly) { + return `${stagedFileCount} staged file${stagedFileCount === 1 ? "" : "s"}`; + } + 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> = {}, +): 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> = {}, +): 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<string, unknown> = {}) { + 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<string, unknown> = {}) { + 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<CommitOutput>; + push(directoryPath: string, signal: AbortSignal): Promise<PushOutput>; + sync(directoryPath: string, signal: AbortSignal): Promise<SyncOutput>; + publish(directoryPath: string, signal: AbortSignal): Promise<PublishOutput>; + createBranch(directoryPath: string, branchName: string): Promise<void>; + createPr(input: { + directoryPath: string; + flowId: string; + branchName?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + draft?: boolean; + stagedOnly?: boolean; + taskId: string; + conversationContext?: string; + }): Promise<CreatePrOutput>; + openPr(directoryPath: string): Promise<OpenPrOutput>; + 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<void>; + 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<string, unknown>): 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<RunCommitResult> { + 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<RunPushResult> { + 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<PushOutput | SyncOutput | PublishOutput> { + 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<RunBranchResult> { + 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<RunCreatePrResult> { + 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<string | null> { + 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/packages/core/src/git-interaction/gitStatusUtils.ts b/packages/core/src/git-interaction/gitStatusUtils.ts new file mode 100644 index 0000000000..f16b84d82f --- /dev/null +++ b/packages/core/src/git-interaction/gitStatusUtils.ts @@ -0,0 +1,23 @@ +import type { GitFileStatus } from "@posthog/shared/domain-types"; + +export type StatusColor = "green" | "orange" | "red" | "blue" | "gray"; +export interface StatusIndicator { + label: string; + fullLabel: string; + color: StatusColor; +} +export function getStatusIndicator(status: GitFileStatus): StatusIndicator { + switch (status) { + case "added": + case "untracked": + return { label: "A", fullLabel: "Added", color: "green" }; + case "deleted": + return { label: "D", fullLabel: "Deleted", color: "red" }; + case "modified": + return { label: "M", fullLabel: "Modified", color: "orange" }; + case "renamed": + return { label: "R", fullLabel: "Renamed", color: "blue" }; + default: + return { label: "?", fullLabel: "Unknown", color: "gray" }; + } +} 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/packages/core/src/git-interaction/prStatus.ts b/packages/core/src/git-interaction/prStatus.ts new file mode 100644 index 0000000000..9d9e990902 --- /dev/null +++ b/packages/core/src/git-interaction/prStatus.ts @@ -0,0 +1,82 @@ +import type { PrActionType } from "@posthog/shared"; + +export type PrVisualIcon = "merged" | "pull-request"; + +export interface PrAction { + id: PrActionType; + label: string; +} + +export interface PrVisualConfig { + color: "gray" | "green" | "red" | "purple"; + icon: PrVisualIcon; + label: string; + actions: PrAction[]; +} + +export function getPrVisualConfig( + state: string, + merged: boolean, + draft: boolean, +): PrVisualConfig { + if (merged) { + return { + color: "purple", + icon: "merged", + label: "Merged", + actions: [], + }; + } + if (state === "closed") { + return { + color: "red", + icon: "pull-request", + label: "Closed", + actions: [{ id: "reopen", label: "Reopen PR" }], + }; + } + if (draft) { + return { + color: "gray", + icon: "pull-request", + label: "Draft", + actions: [ + { id: "ready", label: "Ready for review" }, + { id: "close", label: "Close PR" }, + ], + }; + } + return { + color: "green", + icon: "pull-request", + label: "Open", + actions: [ + { id: "draft", label: "Convert to draft" }, + { id: "close", label: "Close PR" }, + ], + }; +} + +export function getOptimisticPrState(action: PrActionType) { + switch (action) { + case "close": + return { state: "closed", merged: false, draft: false }; + case "reopen": + return { state: "open", merged: false, draft: false }; + case "ready": + return { state: "open", merged: false, draft: false }; + case "draft": + return { state: "open", merged: false, draft: true }; + } +} + +export const PR_ACTION_LABELS: Record<PrActionType, string> = { + close: "PR closed", + reopen: "PR reopened", + ready: "PR marked as ready for review", + draft: "PR converted to draft", +}; + +export function parsePrNumber(prUrl: string): string | undefined { + return prUrl.match(/\/pull\/(\d+)/)?.[1]; +} 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> = {}): 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<string | null>; createBranch(dir: string, name: string): Promise<void>; - checkoutBranch( - dir: string, - name: string, - ): Promise<{ previousBranch: string; currentBranch: string }>; - getChangedFilesHead(dir: string): Promise<ChangedFile[]>; + getChangedFilesHead(dir: string): Promise<readonly unknown[]>; generateCommitMessage(dir: string): Promise<{ message: string }>; + getHeadSha(dir: string): Promise<string>; commit( dir: string, message: string, options?: { stagedOnly?: boolean; taskId?: string }, - ): Promise<CommitOutput>; - getSyncStatus(dir: string): Promise<GitSyncStatus>; - push(dir: string): Promise<PushOutput>; - publish(dir: string): Promise<PublishOutput>; + ): Promise<GitOpResult>; + /** Soft-reset to `sha` (commit rollback). */ + resetSoft(dir: string, sha: string): Promise<void>; + getSyncStatus(dir: string): Promise<{ hasRemote: boolean }>; + push(dir: string): Promise<GitOpResult>; + publish(dir: string): Promise<GitOpResult>; 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<CreatePrSagaInput, CreatePrSagaOutput> { @@ -121,7 +122,7 @@ export class CreatePrSaga extends Saga<CreatePrSagaInput, CreatePrSagaOutput> { 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<CreatePrSagaInput, CreatePrSagaOutput> { 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> = {}): 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<typeof GitPrService>[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<typeof vi.fn>).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> = {}): 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: <short descriptive title, max 72 chars> + +BODY: +<detailed description> + +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<CreatePrResult> { + 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<string>; + getUnstagedDiff(directoryPath: string): Promise<string>; + getCommitConventions(directoryPath: string): Promise<GitCommitConventions>; + getChangedFilesHead(directoryPath: string): Promise<GitChangedFileSummary[]>; + getDefaultBranch(directoryPath: string): Promise<string>; + getCurrentBranch(directoryPath: string): Promise<string | null>; + getDiffAgainstRemote( + directoryPath: string, + baseBranch: string, + ): Promise<string>; + getCommitsBetweenBranches( + directoryPath: string, + baseBranch: string, + head: string | undefined, + limit: number, + ): Promise<GitCommitSummary[]>; + getPrTemplate(directoryPath: string): Promise<GitPrTemplate>; + fetchIfStale(directoryPath: string): Promise<void>; +} + +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<Record<string, string> | undefined>; + getCurrentBranch(directoryPath: string): Promise<string | null>; + createBranch(directoryPath: string, name: string): Promise<void>; + getChangedFilesHead(directoryPath: string): Promise<readonly unknown[]>; + getHeadSha(directoryPath: string): Promise<string>; + commit( + directoryPath: string, + message: string, + options: { + stagedOnly?: boolean; + taskId?: string; + env?: Record<string, string>; + }, + ): Promise<{ success: boolean; message: string }>; + resetSoft(directoryPath: string, sha: string): Promise<void>; + getSyncStatus(directoryPath: string): Promise<{ hasRemote: boolean }>; + push( + directoryPath: string, + env?: Record<string, string>, + ): Promise<{ success: boolean; message: string }>; + publish( + directoryPath: string, + env?: Record<string, string>, + ): Promise<{ success: boolean; message: string }>; + createPrViaGh( + directoryPath: string, + title?: string, + body?: string, + draft?: boolean, + env?: Record<string, string>, + ): Promise<{ success: boolean; message: string; prUrl: string | null }>; + linkBranch(taskId: string, branch: string, source: "user"): void; + getPrState(directoryPath: string): Promise<unknown>; +} diff --git a/packages/core/src/git/host-git.ts b/packages/core/src/git/host-git.ts new file mode 100644 index 0000000000..ae50128552 --- /dev/null +++ b/packages/core/src/git/host-git.ts @@ -0,0 +1,38 @@ +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<CreatePrOutput>; + toIterable<K extends keyof GitServiceEvents>( + event: K, + options: { signal?: AbortSignal }, + ): AsyncIterable<GitServiceEvents[K]>; +} + +export interface HostGitWorkspaceClient { + git: WorkspaceClient["git"]; +} + +export interface HostGitAgentService { + getSessionEnvForTask(taskId: string): Promise<Record<string, string>>; +} diff --git a/packages/core/src/git/identifiers.ts b/packages/core/src/git/identifiers.ts new file mode 100644 index 0000000000..93e79e34b6 --- /dev/null +++ b/packages/core/src/git/identifiers.ts @@ -0,0 +1,5 @@ +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"); diff --git a/packages/core/src/git/router-schemas.ts b/packages/core/src/git/router-schemas.ts new file mode 100644 index 0000000000..2a6c155b9b --- /dev/null +++ b/packages/core/src/git/router-schemas.ts @@ -0,0 +1,569 @@ +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"; + +export const directoryPathInput = z.object({ + directoryPath: z.string(), +}); + +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 diffStatsSchema = z.object({ + filesChanged: z.number(), + linesAdded: z.number(), + linesRemoved: z.number(), +}); + +export type DiffStats = z.infer<typeof diffStatsSchema>; + +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 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 detectRepoInput = z.object({ + directoryPath: z.string(), +}); + +export const detectRepoOutput = z + .object({ + organization: z.string(), + repository: z.string(), + remote: z.string().optional(), + branch: z.string().optional(), + }) + .nullable(); + +export type DetectRepoInput = z.infer<typeof detectRepoInput>; +export type DetectRepoResult = z.infer<typeof detectRepoOutput>; + +export const validateRepoInput = z.object({ + directoryPath: z.string(), +}); + +export const validateRepoOutput = z.boolean(); + +export const cloneRepositoryInput = z.object({ + repoUrl: z.string(), + targetPath: z.string(), + cloneId: z.string(), +}); + +export const cloneRepositoryOutput = z.object({ + cloneId: z.string(), +}); + +export const cloneProgressStatus = z.enum(["cloning", "complete", "error"]); + +export const cloneProgressPayload = z.object({ + cloneId: z.string(), + status: cloneProgressStatus, + message: z.string(), +}); + +export type CloneProgressPayload = z.infer<typeof cloneProgressPayload>; + +export const getChangedFilesHeadInput = directoryPathInput; +export const getChangedFilesHeadOutput = z.array(changedFileSchema); + +export const getFileAtHeadInput = z.object({ + directoryPath: z.string(), + filePath: z.string(), +}); +export const getFileAtHeadOutput = z.string().nullable(); + +export const diffInput = z.object({ + directoryPath: z.string(), + ignoreWhitespace: z.boolean().optional(), +}); +export const diffOutput = z.string(); + +export const getDiffStatsInput = directoryPathInput; +export const getDiffStatsOutput = diffStatsSchema; + +export const stageFilesInput = z.object({ + directoryPath: z.string(), + paths: z.array(z.string()), +}); + +export const getCurrentBranchInput = directoryPathInput; +export const getCurrentBranchOutput = z.string().nullable(); + +export const getAllBranchesInput = directoryPathInput; +export const getAllBranchesOutput = z.array(z.string()); + +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 getGitBusyStateInput = directoryPathInput; +export const getGitBusyStateOutput = gitBusyStateSchema; + +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 getGitSyncStatusInput = directoryPathInput; +export const getGitSyncStatusOutput = gitSyncStatusSchema; + +export const getLatestCommitInput = directoryPathInput; +export const getLatestCommitOutput = gitCommitInfoSchema.nullable(); + +export const getGitRepoInfoInput = directoryPathInput; +export const getGitRepoInfoOutput = gitRepoInfoSchema.nullable(); + +export const pushInput = z.object({ + directoryPath: z.string(), + remote: z.string().default("origin"), + branch: z.string().optional(), + setUpstream: z.boolean().default(false), +}); + +export type PushInput = z.infer<typeof pushInput>; + +export const pullInput = z.object({ + directoryPath: z.string(), + remote: z.string().default("origin"), + branch: z.string().optional(), +}); + +export type PullInput = z.infer<typeof pullInput>; + +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(), + taskId: z.string().optional(), +}); + +export type CommitInput = z.infer<typeof commitInput>; + +export const gitStatusOutput = z.object({ + installed: z.boolean(), + version: z.string().nullable(), +}); + +export type GitStatusOutput = z.infer<typeof gitStatusOutput>; + +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 const prStatusInput = directoryPathInput; +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 type PrStatusInput = z.infer<typeof prStatusInput>; +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 type GetPrUrlForBranchInput = z.infer<typeof getPrUrlForBranchInput>; +export type GetPrUrlForBranchOutput = z.infer<typeof getPrUrlForBranchOutput>; + +export const createPrInput = z.object({ + directoryPath: z.string(), + flowId: z.string(), + branchName: z.string().optional(), + commitMessage: z.string().optional(), + prTitle: z.string().optional(), + prBody: z.string().optional(), + draft: z.boolean().optional(), + stagedOnly: z.boolean().optional(), + taskId: z.string().optional(), + conversationContext: z.string().optional(), +}); + +export type CreatePrInput = z.infer<typeof createPrInput>; + +export const openPrInput = directoryPathInput; +export const openPrOutput = z.object({ + success: z.boolean(), + message: z.string(), + prUrl: z.string().nullable(), +}); + +export type OpenPrInput = z.infer<typeof openPrInput>; +export type OpenPrOutput = z.infer<typeof openPrOutput>; + +export const publishInput = z.object({ + directoryPath: z.string(), + remote: z.string().default("origin"), +}); + +export type PublishInput = z.infer<typeof publishInput>; + +export const syncInput = z.object({ + directoryPath: z.string(), + remote: z.string().default("origin"), +}); + +export type SyncInput = z.infer<typeof syncInput>; + +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 getPrChangedFilesInput = z.object({ + prUrl: z.string(), +}); +export const getPrChangedFilesOutput = z.array(changedFileSchema); + +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 { + prActionTypeSchema, + prReviewCommentSchema, + prReviewCommentUserSchema, + prReviewThreadSchema, +}; +export type { PrActionType, PrReviewComment, PrReviewThread }; + +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 updatePrByUrlInput = z.object({ + prUrl: z.string(), + action: prActionTypeSchema, +}); +export const updatePrByUrlOutput = z.object({ + success: z.boolean(), + message: z.string(), +}); +export type UpdatePrByUrlOutput = z.infer<typeof updatePrByUrlOutput>; + +export const getBranchChangedFilesInput = z.object({ + repo: z.string(), + branch: z.string(), +}); +export const getBranchChangedFilesOutput = z.array(changedFileSchema); + +export const getLocalBranchChangedFilesInput = z.object({ + directoryPath: z.string(), + branch: z.string(), +}); +export const getLocalBranchChangedFilesOutput = z.array(changedFileSchema); + +export const generateCommitMessageInput = z.object({ + directoryPath: z.string(), + conversationContext: z.string().optional(), +}); + +export const generateCommitMessageOutput = z.object({ + message: z.string(), +}); + +export const generatePrTitleAndBodyInput = z.object({ + directoryPath: z.string(), + conversationContext: z.string().optional(), +}); + +export const generatePrTitleAndBodyOutput = z.object({ + title: z.string(), + body: z.string(), +}); + +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 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 pushOutput = z.object({ + success: z.boolean(), + message: z.string(), + state: gitStateSnapshotSchema.optional(), +}); + +export type PushOutput = z.infer<typeof pushOutput>; + +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 publishOutput = z.object({ + success: z.boolean(), + message: z.string(), + branch: z.string(), + state: gitStateSnapshotSchema.optional(), +}); + +export type PublishOutput = z.infer<typeof publishOutput>; + +export const syncOutput = z.object({ + success: z.boolean(), + pullMessage: z.string(), + pushMessage: z.string(), + state: gitStateSnapshotSchema.optional(), +}); + +export type SyncOutput = z.infer<typeof syncOutput>; + +export const createPrStep = z.enum([ + "creating-branch", + "committing", + "pushing", + "creating-pr", + "complete", + "error", +]); + +export type CreatePrStep = z.infer<typeof createPrStep>; + +export const createPrOutput = z.object({ + success: z.boolean(), + message: z.string(), + prUrl: z.string().nullable(), + failedStep: createPrStep.nullable(), + state: gitStateSnapshotSchema.optional(), +}); + +export type CreatePrOutput = z.infer<typeof createPrOutput>; + +export const discardFileChangesOutput = z.object({ + success: z.boolean(), + state: gitStateSnapshotSchema.optional(), +}); + +export type DiscardFileChangesOutput = z.infer<typeof discardFileChangesOutput>; + +export { + githubIssueSchema, + githubIssueStateSchema, + githubRefKindSchema, + githubRefSchema, + githubRefStateSchema, +}; +export type { + GitHubIssue, + GithubIssueState, + GithubPullRequest, + GithubRef, + GithubRefKind, + GithubRefState, +}; + +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 createPrProgressPayload = z.object({ + flowId: z.string(), + step: createPrStep, + message: z.string(), + prUrl: z.string().optional(), +}); + +export type CreatePrProgressPayload = z.infer<typeof createPrProgressPayload>; 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> = {}, ): HandoffSagaInput { @@ -34,8 +29,8 @@ function createInput( } function createCheckpoint( - overrides: Partial<AgentTypes.GitCheckpointEvent> = {}, -): AgentTypes.GitCheckpointEvent { + overrides: Partial<GitHandoffCheckpoint> = {}, +): 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> = {}, +): HandoffResumeState { + return { + conversation: [], + latestGitCheckpoint: null, + ...overrides, + }; +} + function createDeps(overrides: Partial<HandoffSagaDeps> = {}): 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> = {}): HandoffSagaDeps { }; } -function createResumeState( - overrides: Partial<AgentResume.ResumeState> = {}, -): AgentResume.ResumeState { - return { - conversation: [], - latestGitCheckpoint: null, - interrupted: false, - logEntryCount: 0, - ...overrides, - }; -} - function getProgressSteps(deps: HandoffSagaDeps): string[] { return (deps.onProgress as ReturnType<typeof vi.fn>).mock.calls.map( (call: unknown[]) => call[0] as string, @@ -100,11 +89,20 @@ async function runSaga( overrides: { input?: Partial<HandoffSagaInput>; deps?: Partial<HandoffSagaDeps>; - resumeState?: Partial<AgentResume.ResumeState>; + resumeState?: Partial<HandoffResumeState>; + 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<typeof vi.fn>).mock .invocationCallOrder[0]; - const fetchOrder = mockResumeFromLog.mock.invocationCallOrder[0]; + const fetchOrder = (deps.fetchResumeState as ReturnType<typeof vi.fn>).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<typeof vi.fn>) + .mock.invocationCallOrder[0]; + const fetchOrder = (deps.fetchResumeState as ReturnType<typeof vi.fn>).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<void>; + 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<void>; reconnectSession(params: { taskId: string; @@ -39,14 +42,18 @@ export interface HandoffSagaDeps extends HandoffBaseDeps { logUrl: string; sessionId?: string; adapter?: "claude" | "codex"; - }): Promise<SessionResponse | null>; + }): Promise<{ sessionId: string } | null>; closeCloudRun( taskId: string, runId: string, apiHost: string, teamId: number, - localGitState?: AgentTypes.HandoffLocalGitState, + localGitState?: HandoffLocalGitState, ): Promise<void>; + attachWorkspaceToFolder( + taskId: string, + repoPath: string, + ): { revert: () => void }; seedLocalLogs(runId: string, logUrl: string): Promise<void>; setPendingContext(taskRunId: string, context: string): void; } @@ -78,24 +85,16 @@ export class HandoffSaga extends Saga<HandoffSagaInput, HandoffSagaOutput> { ); }); - 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<HandoffSagaInput, HandoffSagaOutput> { repoPath, taskId, runId, - apiClient, input.localGitState, ); checkpointApplied = true; @@ -154,7 +152,7 @@ export class HandoffSaga extends Saga<HandoffSagaInput, HandoffSagaOutput> { repoPath, apiHost, projectId: teamId, - logUrl: cloudLogUrl, + logUrl: cloudLogUrl ?? "", sessionId: input.sessionId, adapter: input.adapter, }); @@ -186,10 +184,10 @@ export class HandoffSaga extends Saga<HandoffSagaInput, HandoffSagaOutput> { } 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<AgentTypes.GitCheckpointEvent | null>; - persistCheckpointToLog( - checkpoint: AgentTypes.GitCheckpointEvent, - ): Promise<void>; - countLocalLogEntries(runId: string): number; + localGitState?: HandoffLocalGitState, + ): Promise<GitHandoffCheckpoint | null>; + persistCheckpointToLog(checkpoint: GitHandoffCheckpoint): Promise<void>; + countLocalLogEntries(runId: string): Promise<number>; resumeRunInCloud(): Promise<void>; } @@ -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<HandoffHost> = {}): { + 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> = {}, +): 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<HandoffServiceEvents> { + 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<HandoffPreflightResult> { + 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<HandoffExecuteResult> { + 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<HandoffToCloudPreflightResult> { + 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<HandoffToCloudExecuteResult> { + 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/packages/core/src/handoff/schemas.ts b/packages/core/src/handoff/schemas.ts new file mode 100644 index 0000000000..b384ed27a4 --- /dev/null +++ b/packages/core/src/handoff/schemas.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; +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(), + runId: z.string(), + repoPath: z.string(), +}); + +const handoffApiInput = handoffBaseInput.extend({ + apiHost: z.string(), + teamId: z.number(), +}); + +export const handoffErrorCodeSchema = z.enum(["github_authorization_required"]); + +export type HandoffErrorCode = z.infer<typeof handoffErrorCodeSchema>; + +const handoffBaseResult = z.object({ + success: z.boolean(), + error: z.string().optional(), + code: handoffErrorCodeSchema.optional(), +}); + +export const handoffPreflightInput = handoffApiInput; + +export type HandoffPreflightInput = z.infer<typeof handoffPreflightInput>; + +export const handoffPreflightResult = z.object({ + canHandoff: z.boolean(), + reason: z.string().optional(), + localTreeDirty: z.boolean(), + localGitState: handoffLocalGitStateSchema.optional(), + changedFiles: z + .array( + z.object({ + path: z.string(), + status: z.enum([ + "modified", + "added", + "deleted", + "renamed", + "untracked", + ]), + linesAdded: z.number().optional(), + linesRemoved: z.number().optional(), + }), + ) + .optional(), +}); + +export type HandoffPreflightResult = z.infer<typeof handoffPreflightResult>; + +export const handoffExecuteInput = handoffApiInput.extend({ + sessionId: z.string().optional(), + adapter: z.enum(["claude", "codex"]).optional(), + localGitState: handoffLocalGitStateSchema.optional(), +}); + +export type HandoffExecuteInput = z.infer<typeof handoffExecuteInput>; + +export const handoffExecuteResult = handoffBaseResult.extend({ + sessionId: z.string().optional(), +}); + +export type HandoffExecuteResult = z.infer<typeof handoffExecuteResult>; + +export const handoffToCloudPreflightInput = handoffBaseInput; + +export type HandoffToCloudPreflightInput = z.infer< + typeof handoffToCloudPreflightInput +>; + +export const handoffToCloudPreflightResult = z.object({ + canHandoff: z.boolean(), + reason: z.string().optional(), + localGitState: handoffLocalGitStateSchema.optional(), +}); + +export type HandoffToCloudPreflightResult = z.infer< + typeof handoffToCloudPreflightResult +>; + +export const handoffToCloudExecuteInput = handoffApiInput.extend({ + localGitState: handoffLocalGitStateSchema.optional(), +}); + +export type HandoffToCloudExecuteInput = z.infer< + typeof handoffToCloudExecuteInput +>; + +export const handoffToCloudExecuteResult = handoffBaseResult.extend({ + logEntryCount: z.number().optional(), +}); + +export type HandoffToCloudExecuteResult = z.infer< + typeof handoffToCloudExecuteResult +>; + +export interface HandoffProgressPayload { + taskId: string; + step: HandoffStep; + message: string; +} + +export const HandoffEvent = { + Progress: "handoff-progress", +} as const; + +export interface HandoffServiceEvents { + [HandoffEvent.Progress]: HandoffProgressPayload; +} 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<void>; + 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<PostHogAPIClient> = {}) { + 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<typeof vi.fn>) + .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<unknown>, + ): Promise<BulkActionResult> { + const results = await Promise.allSettled(reportIds.map(perReport)); + return tallySettledResults(results); + } + + async suppressReports( + client: PostHogAPIClient, + reportIds: string[], + dismissal?: DismissReportInput, + ): Promise<BulkActionResult> { + return this.runBulk(reportIds, (reportId) => + client.updateSignalReportState(reportId, buildSuppressRequest(dismissal)), + ); + } + + async snoozeReports( + client: PostHogAPIClient, + reportIds: string[], + ): Promise<BulkActionResult> { + return this.runBulk(reportIds, (reportId) => + client.updateSignalReportState(reportId, buildSnoozeRequest()), + ); + } + + async deleteReports( + client: PostHogAPIClient, + reportIds: string[], + ): Promise<BulkActionResult> { + return this.runBulk(reportIds, (reportId) => + client.deleteSignalReport(reportId), + ); + } + + async reingestReports( + client: PostHogAPIClient, + reportIds: string[], + ): Promise<BulkActionResult> { + 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<SignalReport["status"]>([ + "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<unknown>[], +): 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<DataSourceType, string[]> = { + 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<void> { + 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<void> { + 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<void> { + await client.createExternalDataSource(projectId, { + source_type: "Linear", + payload: { + linear_integration_id: linearIntegrationId, + schemas: schemasPayload("linear"), + }, + }); + } + + async createZendeskDataSource( + client: PostHogAPIClient, + projectId: number, + params: ZendeskDataSourceParams, + ): Promise<void> { + 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<void> { + 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<number | string> { + 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<string | undefined>; +} + +export interface LinearOAuthFlow { + startFlow(region: string, projectId: number): Promise<void>; +} 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> = {}): 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<string, ReportListSnapshotEntry>; + 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<string, SignalFindingArtefact["content"]> { + const map = new Map<string, SignalFindingArtefact["content"]>(); + 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/packages/core/src/inbox/reportFilters.test.ts b/packages/core/src/inbox/reportFilters.test.ts new file mode 100644 index 0000000000..b94979dbcd --- /dev/null +++ b/packages/core/src/inbox/reportFilters.test.ts @@ -0,0 +1,155 @@ +import type { SignalReport } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + buildSignalReportListOrdering, + buildSuggestedReviewerFilterParam, + filterReportsBySearch, +} from "./reportFilters"; + +function makeReport(overrides: Partial<SignalReport> = {}): SignalReport { + return { + id: "1", + title: "Test report", + summary: "A summary of the report", + status: "ready", + total_weight: 50, + signal_count: 10, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-02T00:00:00Z", + artefact_count: 3, + ...overrides, + }; +} + +describe("filterReportsBySearch", () => { + const reports = [ + makeReport({ + id: "1", + title: "Login errors spike", + summary: "Users cannot log in", + }), + makeReport({ + id: "2", + title: "Checkout flow broken", + summary: "Payment page crashes", + }), + makeReport({ + id: "3", + title: "Slow dashboard load", + summary: "Performance degradation", + }), + ]; + + it("returns all reports when query is empty", () => { + expect(filterReportsBySearch(reports, "")).toEqual(reports); + }); + + it("returns all reports when query is whitespace", () => { + expect(filterReportsBySearch(reports, " ")).toEqual(reports); + }); + + it("filters by title match", () => { + const result = filterReportsBySearch(reports, "login"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("1"); + }); + + it("filters by summary match", () => { + const result = filterReportsBySearch(reports, "payment"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("2"); + }); + + it("is case insensitive", () => { + const result = filterReportsBySearch(reports, "DASHBOARD"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("3"); + }); + + it("handles null title", () => { + const withNull = [ + makeReport({ id: "4", title: null, summary: "Some summary" }), + ]; + const result = filterReportsBySearch(withNull, "some"); + expect(result).toHaveLength(1); + }); + + it("handles null summary", () => { + const withNull = [makeReport({ id: "5", title: "A title", summary: null })]; + const result = filterReportsBySearch(withNull, "title"); + expect(result).toHaveLength(1); + }); + + it("handles both null title and summary", () => { + const withNull = [makeReport({ id: "6", title: null, summary: null })]; + const result = filterReportsBySearch(withNull, "anything"); + expect(result).toHaveLength(0); + }); + + it("returns empty array when no matches", () => { + const result = filterReportsBySearch(reports, "nonexistent"); + expect(result).toHaveLength(0); + }); + + it("returns empty array for empty input", () => { + expect(filterReportsBySearch([], "test")).toEqual([]); + }); +}); + +describe("buildSignalReportListOrdering", () => { + it("puts status then suggested reviewer then descending field", () => { + expect(buildSignalReportListOrdering("total_weight", "desc")).toBe( + "status,-is_suggested_reviewer,-total_weight", + ); + }); + + it("puts status then suggested reviewer then ascending field", () => { + expect(buildSignalReportListOrdering("created_at", "asc")).toBe( + "status,-is_suggested_reviewer,created_at", + ); + }); + + it("works for signal_count", () => { + expect(buildSignalReportListOrdering("signal_count", "desc")).toBe( + "status,-is_suggested_reviewer,-signal_count", + ); + }); +}); + +describe("buildSuggestedReviewerFilterParam", () => { + it("returns undefined for an empty array", () => { + expect(buildSuggestedReviewerFilterParam([])).toBeUndefined(); + }); + + it("trims reviewer ids and joins them with commas", () => { + expect( + buildSuggestedReviewerFilterParam([ + " reviewer-1 ", + "reviewer-2", + " reviewer-3", + ]), + ).toBe("reviewer-1,reviewer-2,reviewer-3"); + }); + + it("deduplicates reviewer ids after trimming", () => { + expect( + buildSuggestedReviewerFilterParam([ + " reviewer-1 ", + "reviewer-2", + "reviewer-1", + " reviewer-2 ", + ]), + ).toBe("reviewer-1,reviewer-2"); + }); + + it("drops blank reviewer ids", () => { + expect( + buildSuggestedReviewerFilterParam([ + "reviewer-1", + " ", + "reviewer-2", + "", + ]), + ).toBe("reviewer-1,reviewer-2"); + }); +}); diff --git a/packages/core/src/inbox/reportFilters.ts b/packages/core/src/inbox/reportFilters.ts new file mode 100644 index 0000000000..a0ae6ea5e1 --- /dev/null +++ b/packages/core/src/inbox/reportFilters.ts @@ -0,0 +1,90 @@ +import type { + SignalReport, + SignalReportOrderingField, + SignalReportStatus, +} from "@posthog/shared/domain-types"; + +function normalizeReviewerId(value: string): string { + return value.trim(); +} + +/** + * Reports that are surfaced to the current user as needing review: ready, + * immediately actionable, and addressed to them. Used for both the sidebar + * red badge count and the inbox toolbar "up for review" byline so the two + * numbers always agree. + */ +export function isReportUpForReview(report: SignalReport): boolean { + return ( + report.status === "ready" && + report.is_suggested_reviewer === true && + report.actionability === "immediately_actionable" + ); +} + +export function filterReportsBySearch( + reports: SignalReport[], + query: string, +): SignalReport[] { + const trimmed = query.trim(); + if (!trimmed) return reports; + + const lower = trimmed.toLowerCase(); + return reports.filter( + (report) => + report.title?.toLowerCase().includes(lower) || + report.summary?.toLowerCase().includes(lower) || + report.id.toLowerCase().includes(lower), + ); +} + +/** + * Build a comma-separated status filter string for the API from an array of statuses. + */ +export function buildStatusFilterParam(statuses: SignalReportStatus[]): string { + return statuses.join(","); +} + +/** + * Comma-separated `ordering` for the signal report list API: + * 1. Status rank (ready first — semantic server-side rank, always applied) + * 2. Suggested reviewer (current user's reports first) + * 3. Toolbar-selected field (priority, total_weight, created_at, etc.) + */ +export function buildSignalReportListOrdering( + field: SignalReportOrderingField, + direction: "asc" | "desc", +): string { + const fieldKey = direction === "desc" ? `-${field}` : field; + return `status,-is_suggested_reviewer,${fieldKey}`; +} + +export function buildSuggestedReviewerFilterParam( + reviewerIds: string[], +): string | undefined { + const normalizedIds = reviewerIds.map(normalizeReviewerId).filter(Boolean); + + if (normalizedIds.length === 0) { + return undefined; + } + + 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<string>(); + for (const source of sources) { + if (source.enabled) { + enabled.add(source.source_product); + } + } + return Array.from(enabled); +} diff --git a/packages/core/src/inbox/reportPrompts.ts b/packages/core/src/inbox/reportPrompts.ts new file mode 100644 index 0000000000..9f1bed5958 --- /dev/null +++ b/packages/core/src/inbox/reportPrompts.ts @@ -0,0 +1,35 @@ +import { + buildInboxDeeplink, + buildDiscussReportPrompt as buildSharedDiscussReportPrompt, + getDeeplinkProtocol, +} from "@posthog/shared"; + +interface BuildCreatePrReportPromptOptions { + reportId: string; + isDevBuild: boolean; +} + +export function buildCreatePrReportPrompt({ + reportId, + isDevBuild, +}: BuildCreatePrReportPromptOptions): string { + 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<Task | null>, +): Promise<string | null> { + 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<string, unknown>).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> = {}, +): 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<TaskService> = {}, + resolver: Partial<ReportModelResolver> = {}, +) { + 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<CreateSignalReportTaskResult> { + 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<PostHogAPIClient> = {}) { + 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<SourceProduct, "error_tracking" | "llm_analytics">, + 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<Record<SignalSourceProduct, SignalSourceState>> { + const serverValues = computeSourceValues(configs); + const states: Partial<Record<SignalSourceProduct, SignalSourceState>> = {}; + 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<SignalSourceProduct>(); + + isPending(product: SignalSourceProduct): boolean { + return this.pending.has(product); + } + + async ensureRequiredTableSyncing( + client: PostHogAPIClient, + projectId: number, + product: WarehouseSourceProduct, + externalSources: ExternalDataSource[] | undefined, + ): Promise<void> { + 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<ToggleSourceResult> { + 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<void> { + 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<SignalSourceProduct, "error_tracking">, + enabled: boolean, + configs: SignalSourceConfig[] | undefined, + ): Promise<void> { + 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<ToggleSourceResult> { + 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<void> { + await client.updateEvaluation(projectId, evaluationId, { enabled }); + } + + async updateAutostartPriority( + client: PostHogAPIClient, + priority: string, + ): Promise<void> { + await client.updateSignalTeamConfig({ + default_autostart_priority: priority, + }); + } + + async updateUserAutonomyPriority( + client: PostHogAPIClient, + priority: string | null, + ): Promise<void> { + 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<string, number | string | null> { + const body: Record<string, number | string | null> = {}; + 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<SignalUserAutonomyConfig> { + 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/packages/core/src/inbox/suggestedReviewers.test.ts b/packages/core/src/inbox/suggestedReviewers.test.ts new file mode 100644 index 0000000000..e160f7b56c --- /dev/null +++ b/packages/core/src/inbox/suggestedReviewers.test.ts @@ -0,0 +1,182 @@ +import type { AvailableSuggestedReviewer } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + buildSuggestedReviewerFilterOptions, + getSuggestedReviewerDisplayName, +} from "./suggestedReviewers"; + +function makeReviewer( + overrides: Partial<AvailableSuggestedReviewer> = {}, +): AvailableSuggestedReviewer { + return { + uuid: "reviewer-1", + name: "Alice Jones", + email: "alice@example.com", + github_login: "alicejones", + ...overrides, + }; +} + +describe("getSuggestedReviewerDisplayName", () => { + it("returns name when present", () => { + expect( + getSuggestedReviewerDisplayName({ + ...makeReviewer({ name: "Alice Jones" }), + isMe: false, + }), + ).toBe("Alice Jones"); + }); + + it("falls back to email when name is missing", () => { + expect( + getSuggestedReviewerDisplayName({ + ...makeReviewer({ + name: "", + email: "fallback@example.com", + }), + isMe: false, + }), + ).toBe("fallback@example.com"); + }); + + it("falls back to Unknown user when name and email are missing", () => { + expect( + getSuggestedReviewerDisplayName({ + ...makeReviewer({ + name: "", + email: "", + }), + isMe: false, + }), + ).toBe("Unknown user"); + }); + + it("appends Me for the pinned current user", () => { + expect( + getSuggestedReviewerDisplayName({ + ...makeReviewer({ name: "Boss Person" }), + isMe: true, + }), + ).toBe("Boss Person (Me)"); + }); +}); + +describe("buildSuggestedReviewerFilterOptions", () => { + it("pins the current user to the top and marks them as me", () => { + const me = { + uuid: "me-id", + first_name: "Boss", + last_name: "Person", + email: "boss@example.com", + }; + const options = buildSuggestedReviewerFilterOptions( + [ + makeReviewer({ + uuid: "other-id", + name: "Alice Jones", + }), + ], + me, + ); + + expect(options).toHaveLength(2); + expect(options[0]).toMatchObject({ + uuid: "me-id", + name: "Boss Person", + isMe: true, + showSeparatorBelow: true, + }); + expect(getSuggestedReviewerDisplayName(options[0])).toBe( + "Boss Person (Me)", + ); + expect(options[1]).toMatchObject({ + uuid: "other-id", + name: "Alice Jones", + isMe: false, + showSeparatorBelow: false, + }); + }); + + it("deduplicates the current user if already present in backend results", () => { + const me = { + uuid: "me-id", + first_name: "Boss", + last_name: "Person", + email: "boss@example.com", + }; + const options = buildSuggestedReviewerFilterOptions( + [ + makeReviewer({ + uuid: "me-id", + name: "Old Name", + email: "old@example.com", + }), + makeReviewer({ + uuid: "other-id", + name: "Alice Jones", + }), + ], + me, + ); + + expect(options.map((option) => option.uuid)).toEqual(["me-id", "other-id"]); + expect(options[0]).toMatchObject({ + uuid: "me-id", + name: "Boss Person", + email: "boss@example.com", + isMe: true, + }); + }); + + it("sorts backend reviewers alphabetically by name", () => { + const options = buildSuggestedReviewerFilterOptions( + [ + makeReviewer({ uuid: "c", name: "Charlie Zebra" }), + makeReviewer({ uuid: "a", name: "Alice Jones" }), + makeReviewer({ uuid: "b", name: "Bob Smith" }), + ], + null, + ); + + expect(options.map((option) => option.uuid)).toEqual(["a", "b", "c"]); + expect(options.map((option) => option.name)).toEqual([ + "Alice Jones", + "Bob Smith", + "Charlie Zebra", + ]); + }); + + it("uses email and uuid as stable alphabetical tie-breakers", () => { + const options = buildSuggestedReviewerFilterOptions( + [ + makeReviewer({ uuid: "b", name: "", email: "b@example.com" }), + makeReviewer({ uuid: "a", name: "", email: "a@example.com" }), + makeReviewer({ uuid: "c", name: "", email: "a@example.com" }), + ], + null, + ); + + expect(options.map((option) => option.uuid)).toEqual(["a", "c", "b"]); + }); + + it("returns backend reviewers unchanged when there is no current user", () => { + const reviewers = [ + makeReviewer({ uuid: "b", name: "Bob Smith" }), + makeReviewer({ uuid: "a", name: "Alice Jones" }), + ]; + + const options = buildSuggestedReviewerFilterOptions(reviewers, null); + + expect(options).toHaveLength(2); + expect(options[0]).toMatchObject({ + uuid: "a", + name: "Alice Jones", + isMe: false, + }); + expect(options[1]).toMatchObject({ + uuid: "b", + name: "Bob Smith", + isMe: false, + }); + }); +}); diff --git a/packages/core/src/inbox/suggestedReviewers.ts b/packages/core/src/inbox/suggestedReviewers.ts new file mode 100644 index 0000000000..c46f59672f --- /dev/null +++ b/packages/core/src/inbox/suggestedReviewers.ts @@ -0,0 +1,101 @@ +import type { AvailableSuggestedReviewer } from "@posthog/shared/domain-types"; + +export interface CurrentSuggestedReviewerUser { + uuid: string; + email?: string | null; + first_name?: string | null; + last_name?: string | null; +} + +export interface SuggestedReviewerFilterOption { + uuid: string; + name: string; + email: string; + github_login: string; + isMe: boolean; + showSeparatorBelow: boolean; +} + +function normalizeString(value: string | null | undefined): string { + return typeof value === "string" ? value.trim() : ""; +} + +function buildCurrentUserName( + currentUser?: CurrentSuggestedReviewerUser | null, +): string { + const firstName = normalizeString(currentUser?.first_name); + const lastName = normalizeString(currentUser?.last_name); + return [firstName, lastName].filter(Boolean).join(" "); +} + +function sortReviewerOptionsByName( + reviewers: SuggestedReviewerFilterOption[], +): SuggestedReviewerFilterOption[] { + return [...reviewers].sort((a, b) => { + const aName = normalizeString(a.name).toLowerCase(); + const bName = normalizeString(b.name).toLowerCase(); + const aEmail = normalizeString(a.email).toLowerCase(); + const bEmail = normalizeString(b.email).toLowerCase(); + + return ( + aName.localeCompare(bName) || + aEmail.localeCompare(bEmail) || + a.uuid.localeCompare(b.uuid) + ); + }); +} + +export function getSuggestedReviewerDisplayName( + reviewer: Pick<SuggestedReviewerFilterOption, "name" | "email" | "isMe">, +): string { + const baseLabel = + normalizeString(reviewer.name) || + normalizeString(reviewer.email) || + "Unknown user"; + + return reviewer.isMe ? `${baseLabel} (Me)` : baseLabel; +} + +export function buildSuggestedReviewerFilterOptions( + reviewers: AvailableSuggestedReviewer[], + currentUser?: CurrentSuggestedReviewerUser | null, +): SuggestedReviewerFilterOption[] { + const byUuid = new Map<string, SuggestedReviewerFilterOption>(); + + for (const reviewer of reviewers) { + const uuid = normalizeString(reviewer.uuid); + if (!uuid || byUuid.has(uuid)) { + continue; + } + + byUuid.set(uuid, { + uuid, + name: normalizeString(reviewer.name), + email: normalizeString(reviewer.email), + github_login: normalizeString(reviewer.github_login), + isMe: false, + showSeparatorBelow: false, + }); + } + + const currentUserUuid = normalizeString(currentUser?.uuid); + if (currentUserUuid) { + const existing = byUuid.get(currentUserUuid); + byUuid.set(currentUserUuid, { + uuid: currentUserUuid, + name: buildCurrentUserName(currentUser) || existing?.name || "", + email: normalizeString(currentUser?.email) || existing?.email || "", + github_login: existing?.github_login || "", + isMe: true, + showSeparatorBelow: true, + }); + } + + const options = Array.from(byUuid.values()); + const meOption = options.find((option) => option.isMe) ?? null; + const otherOptions = sortReviewerOptionsByName( + options.filter((option) => !option.isMe), + ); + + return meOption ? [meOption, ...otherOptions] : otherOptions; +} 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<GithubBranchesPage>, +): 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<GithubBranchesPage> | 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<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.", +}; + +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<ReadonlyArray<unknown>> { + const keys: ReadonlyArray<unknown>[] = []; + 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<ReadonlyArray<unknown>> { + 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..36b31dcec5 --- /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 { service, 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/packages/core/src/integrations/github.ts b/packages/core/src/integrations/github.ts new file mode 100644 index 0000000000..1a847f3dec --- /dev/null +++ b/packages/core/src/integrations/github.ts @@ -0,0 +1,164 @@ +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 { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; +import type { StartIntegrationFlowOutput } from "./schemas"; + +const FLOW_TIMEOUT_MS = 5 * 60 * 1000; + +export const GitHubIntegrationEvent = { + Callback: "callback", + FlowTimedOut: "flowTimedOut", +} as const; + +export interface IntegrationCallback { + provider: string; + projectId: number | null; + installationId: string | null; + status: "success" | "error"; + errorCode: string | null; + errorMessage: string | null; +} + +export interface FlowTimedOut { + projectId: number; +} + +export interface GitHubIntegrationEvents { + [GitHubIntegrationEvent.Callback]: IntegrationCallback; + [GitHubIntegrationEvent.FlowTimedOut]: FlowTimedOut; +} + +@injectable() +export class GitHubIntegrationService extends TypedEventEmitter<GitHubIntegrationEvents> { + private pendingCallback: IntegrationCallback | null = null; + private flowTimeout: ReturnType<typeof setTimeout> | null = null; + private readonly log: ScopedLogger; + + constructor( + @inject(DEEP_LINK_SERVICE) + private readonly deepLinkService: IDeepLinkRegistry, + @inject(URL_LAUNCHER_SERVICE) + private readonly urlLauncher: IUrlLauncher, + @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), + ); + } + + public async startFlow( + region: CloudRegion, + projectId: number, + ): Promise<StartIntegrationFlowOutput> { + try { + const cloudUrl = getCloudUrlFromRegion(region); + const nextPath = `/account-connected/github-integration?provider=github&project_id=${projectId}&connect_from=posthog_code`; + const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=github&next=${encodeURIComponent(nextPath)}`; + + this.clearFlowTimeout(); + this.flowTimeout = setTimeout(() => { + this.log.warn("GitHub integration flow timed out", { projectId }); + this.flowTimeout = null; + this.emit(GitHubIntegrationEvent.FlowTimedOut, { projectId }); + }, FLOW_TIMEOUT_MS); + + await this.urlLauncher.launch(authorizeUrl); + + return { success: true }; + } catch (error) { + this.clearFlowTimeout(); + this.log.error("Failed to start GitHub integration flow", { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + public consumePendingCallback(): IntegrationCallback | null { + const pending = this.pendingCallback; + this.pendingCallback = null; + return pending; + } + + private handleCallback(params: URLSearchParams): boolean { + const projectIdRaw = params.get("project_id"); + const parsedProjectId = projectIdRaw ? Number(projectIdRaw) : null; + const status = params.get("status") === "error" ? "error" : "success"; + + const callback: IntegrationCallback = { + provider: params.get("provider") ?? "", + projectId: + parsedProjectId !== null && Number.isFinite(parsedProjectId) + ? parsedProjectId + : null, + installationId: params.get("installation_id") || null, + status, + errorCode: params.get("error_code") || null, + errorMessage: params.get("error_message") || null, + }; + + this.clearFlowTimeout(); + + if (status === "error") { + this.log.error("Received integration callback with error", { + provider: callback.provider, + projectId: callback.projectId, + errorCode: callback.errorCode, + errorMessage: callback.errorMessage, + }); + } + + const hasListeners = + this.listenerCount(GitHubIntegrationEvent.Callback) > 0; + if (hasListeners) { + this.emit(GitHubIntegrationEvent.Callback, callback); + } else { + this.pendingCallback = callback; + } + + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + + return true; + } + + private clearFlowTimeout(): void { + if (this.flowTimeout) { + clearTimeout(this.flowTimeout); + this.flowTimeout = null; + } + } +} diff --git a/packages/core/src/integrations/githubConnectService.test.ts b/packages/core/src/integrations/githubConnectService.test.ts new file mode 100644 index 0000000000..ee7e8a1885 --- /dev/null +++ b/packages/core/src/integrations/githubConnectService.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; +import { GithubConnectService } from "./githubConnectService"; +import type { GithubConnectClient } from "./identifiers"; + +function makeClient( + overrides: Partial<GithubConnectClient> = {}, +): 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<ConnectOutcome> { + 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<ConnectOutcome> { + await this.runUserFlow(projectId); + return { flow: "user" }; + } + + private async runUserFlow(projectId: number): Promise<void> { + 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<unknown>; + refreshUserRepository(installationId: string): Promise<unknown>; +} + +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<void>; + startTeamFlow(input: { + region: string; + projectId: number; + }): Promise<TeamFlowResult>; +} + +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/packages/core/src/integrations/linear.ts b/packages/core/src/integrations/linear.ts new file mode 100644 index 0000000000..a61ee53bb1 --- /dev/null +++ b/packages/core/src/integrations/linear.ts @@ -0,0 +1,35 @@ +import { + type IUrlLauncher, + URL_LAUNCHER_SERVICE, +} from "@posthog/platform/url-launcher"; +import { type CloudRegion, getCloudUrlFromRegion } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import type { StartIntegrationFlowOutput } from "./schemas"; + +@injectable() +export class LinearIntegrationService { + constructor( + @inject(URL_LAUNCHER_SERVICE) + private readonly urlLauncher: IUrlLauncher, + ) {} + + public async startFlow( + region: CloudRegion, + projectId: number, + ): Promise<StartIntegrationFlowOutput> { + try { + const cloudUrl = getCloudUrlFromRegion(region); + const next = `${cloudUrl}/project/${projectId}`; + const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=linear&next=${encodeURIComponent(next)}`; + + await this.urlLauncher.launch(authorizeUrl); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } +} 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<T>( + data: T | undefined, + flags: Partial<Omit<RepositoryQueryResult<T>, "data">> = {}, +): RepositoryQueryResult<T> { + 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<TeamRepositoriesResult>[] = [ + 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<TeamRepositoriesResult>(undefined, { isPending: true }), + ]); + expect(combined.isPending).toBe(true); + }); +}); + +describe("combineUserGithubRepositories", () => { + it("tracks reposByInstallationId and tallies failed installation ids", () => { + const results: RepositoryQueryResult<UserRepositoriesResult>[] = [ + result({ + userIntegrationId: "u1", + installationId: "i1", + repos: ["a/x"], + }), + result<UserRepositoriesResult>(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<UserRepositoryIntegrationRef>([ + { + 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<TData> { + data: TData | undefined; + isPending: boolean; + isError: boolean; + isRefetching: boolean; +} + +export interface TeamRepositoriesResult { + integrationId: number; + repos?: string[] | null; +} + +export interface CombinedTeamRepositories { + repositoryMap: Record<string, number>; + isPending: boolean; +} + +export function combineGithubRepositories( + results: ReadonlyArray<RepositoryQueryResult<TeamRepositoriesResult>>, +): CombinedTeamRepositories { + 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 }; +} + +export interface UserRepositoryIntegrationRef { + userIntegrationId: string; + installationId: string; +} + +export interface UserRepositoriesResult { + userIntegrationId: string; + installationId: string; + repos?: string[] | null; +} + +export interface CombinedUserRepositories { + repositoryMap: Record<string, UserRepositoryIntegrationRef>; + reposByInstallationId: Record<string, string[]>; + isPending: boolean; + failedInstallationIds: string[]; +} + +export function combineUserGithubRepositories( + results: ReadonlyArray<RepositoryQueryResult<UserRepositoriesResult>>, + installationIds: ReadonlyArray<string | null | undefined>, +): CombinedUserRepositories { + 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 = 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<TRef> { + ref: TRef; + repositories?: string[] | null; + hasMore?: boolean; +} + +export interface CombinedRepositoryPicker<TRef> { + repositoryMap: Record<string, TRef>; + isPending: boolean; + isRefreshing: boolean; + hasMore: boolean; +} + +export function combineRepositoryPicker<TRef>( + results: ReadonlyArray<RepositoryQueryResult<RepositoryPageResult<TRef>>>, +): CombinedRepositoryPicker<TRef> { + const map: Record<string, TRef> = {}; + 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<TRef>( + repositoryMap: Record<string, TRef>, + repoKey: string, +): TRef | undefined { + return repositoryMap[normalizeRepoKey(repoKey)]; +} + +export function getIntegrationIdForRepo( + repositoryMap: Record<string, number>, + repoKey: string, +): number | undefined { + return repositoryMap[normalizeRepoKey(repoKey)]; +} + +export function isRepoInIntegration( + repositoryMap: Record<string, unknown>, + 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<typeof vi.fn>) + .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<void> { + if (integrationIds.length === 0) { + return; + } + await Promise.all( + integrationIds.map((integrationId) => + this.client.refreshTeamRepository(integrationId), + ), + ); + } + + async refreshUserRepositories(installationIds: string[]): Promise<void> { + if (installationIds.length === 0) { + return; + } + await Promise.all( + installationIds.map((installationId) => + this.client.refreshUserRepository(installationId), + ), + ); + } + + async refreshTeamRepositoriesAndKeys( + integrationIds: number[], + ): Promise<RepositoryRefetchKey[]> { + await this.refreshTeamRepositories(integrationIds); + return teamRepositoryRefreshKeys(integrationIds); + } + + async refreshUserRepositoriesAndKeys( + installationIds: string[], + ): Promise<RepositoryRefetchKey[]> { + 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<unknown>; + exact: boolean; +} + +export function teamRepositoryRefreshKeys( + integrationIds: ReadonlyArray<number>, +): 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<string>, +): 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/packages/core/src/integrations/schemas.ts b/packages/core/src/integrations/schemas.ts new file mode 100644 index 0000000000..f2d3220591 --- /dev/null +++ b/packages/core/src/integrations/schemas.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +export const cloudRegion = z.enum(["us", "eu", "dev"]); +export type CloudRegion = z.infer<typeof cloudRegion>; + +export const startIntegrationFlowInput = z.object({ + region: cloudRegion, + projectId: z.number(), +}); +export type StartIntegrationFlowInput = z.infer< + typeof startIntegrationFlowInput +>; + +export const startIntegrationFlowOutput = z.object({ + success: z.boolean(), + error: z.string().optional(), +}); +export type StartIntegrationFlowOutput = z.infer< + typeof startIntegrationFlowOutput +>; 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/packages/core/src/integrations/selectors.ts b/packages/core/src/integrations/selectors.ts new file mode 100644 index 0000000000..7e89beb3d9 --- /dev/null +++ b/packages/core/src/integrations/selectors.ts @@ -0,0 +1,38 @@ +export interface IntegrationAccount { + name?: string; + type?: string; +} + +export interface IntegrationConfig { + account?: IntegrationAccount; + [key: string]: unknown; +} + +export interface Integration { + id: number; + kind: string; + config?: IntegrationConfig; + display_name?: string; + [key: string]: unknown; +} + +export interface ClassifiedIntegrations { + githubIntegrations: Integration[]; + hasGithubIntegration: boolean; + slackIntegrations: Integration[]; + hasSlackIntegration: boolean; +} + +export function classifyIntegrations( + integrations: ReadonlyArray<Integration>, +): ClassifiedIntegrations { + const githubIntegrations = integrations.filter((i) => i.kind === "github"); + const slackIntegrations = integrations.filter((i) => i.kind === "slack"); + + return { + githubIntegrations, + hasGithubIntegration: githubIntegrations.length > 0, + 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/packages/core/src/integrations/slack.ts b/packages/core/src/integrations/slack.ts new file mode 100644 index 0000000000..dc63706c50 --- /dev/null +++ b/packages/core/src/integrations/slack.ts @@ -0,0 +1,167 @@ +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 { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; +import type { StartIntegrationFlowOutput } from "./schemas"; + +const FLOW_TIMEOUT_MS = 5 * 60 * 1000; + +export const SlackIntegrationEvent = { + Callback: "callback", + FlowTimedOut: "flowTimedOut", +} as const; + +export interface SlackIntegrationCallback { + projectId: number | null; + integrationId: number | null; + status: "success" | "error"; + errorCode: string | null; + errorMessage: string | null; +} + +export interface SlackFlowTimedOut { + projectId: number; +} + +export interface SlackIntegrationEvents { + [SlackIntegrationEvent.Callback]: SlackIntegrationCallback; + [SlackIntegrationEvent.FlowTimedOut]: SlackFlowTimedOut; +} + +@injectable() +export class SlackIntegrationService extends TypedEventEmitter<SlackIntegrationEvents> { + private pendingCallback: SlackIntegrationCallback | null = null; + private flowTimeout: ReturnType<typeof setTimeout> | null = null; + private readonly log: ScopedLogger; + + constructor( + @inject(DEEP_LINK_SERVICE) + private readonly deepLinkService: IDeepLinkRegistry, + @inject(URL_LAUNCHER_SERVICE) + private readonly urlLauncher: IUrlLauncher, + @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), + ); + } + + public async startFlow( + region: CloudRegion, + projectId: number, + ): Promise<StartIntegrationFlowOutput> { + try { + const cloudUrl = getCloudUrlFromRegion(region); + 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(() => { + this.log.warn("Slack integration flow timed out", { projectId }); + this.flowTimeout = null; + this.emit(SlackIntegrationEvent.FlowTimedOut, { projectId }); + }, FLOW_TIMEOUT_MS); + + await this.urlLauncher.launch(authorizeUrl); + + return { success: true }; + } catch (error) { + this.clearFlowTimeout(); + this.log.error("Failed to start Slack integration flow", { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + public consumePendingCallback(): SlackIntegrationCallback | null { + const pending = this.pendingCallback; + this.pendingCallback = null; + return pending; + } + + private handleCallback(params: URLSearchParams): boolean { + const projectIdRaw = params.get("project_id"); + const parsedProjectId = projectIdRaw ? Number(projectIdRaw) : null; + const integrationIdRaw = params.get("integration_id"); + const parsedIntegrationId = integrationIdRaw + ? Number(integrationIdRaw) + : null; + const status = params.get("status") === "error" ? "error" : "success"; + + const callback: SlackIntegrationCallback = { + projectId: + parsedProjectId !== null && Number.isFinite(parsedProjectId) + ? parsedProjectId + : null, + integrationId: + parsedIntegrationId !== null && Number.isFinite(parsedIntegrationId) + ? parsedIntegrationId + : null, + status, + errorCode: params.get("error_code") || null, + errorMessage: params.get("error_message") || null, + }; + + this.clearFlowTimeout(); + + if (status === "error") { + this.log.error("Received Slack integration callback with error", { + projectId: callback.projectId, + errorCode: callback.errorCode, + errorMessage: callback.errorMessage, + }); + } + + const hasListeners = this.listenerCount(SlackIntegrationEvent.Callback) > 0; + if (hasListeners) { + this.emit(SlackIntegrationEvent.Callback, callback); + } else { + this.pendingCallback = callback; + } + + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + + return true; + } + + private clearFlowTimeout(): void { + if (this.flowTimeout) { + clearTimeout(this.flowTimeout); + this.flowTimeout = null; + } + } +} diff --git a/packages/core/src/links/identifiers.ts b/packages/core/src/links/identifiers.ts new file mode 100644 index 0000000000..a894ef818d --- /dev/null +++ b/packages/core/src/links/identifiers.ts @@ -0,0 +1,12 @@ +export interface LinkLogger { + 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 TASK_LINK_SERVICE = Symbol.for("posthog.core.taskLinkService"); +export const INBOX_LINK_SERVICE = Symbol.for("posthog.core.inboxLinkService"); +export const NEW_TASK_LINK_SERVICE = Symbol.for( + "posthog.core.newTaskLinkService", +); diff --git a/packages/core/src/links/inbox-link.test.ts b/packages/core/src/links/inbox-link.test.ts new file mode 100644 index 0000000000..de4942929c --- /dev/null +++ b/packages/core/src/links/inbox-link.test.ts @@ -0,0 +1,130 @@ +import type { + DeepLinkHandler, + IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import type { IMainWindow } from "@posthog/platform/main-window"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +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<string, DeepLinkHandler>(); + const service = { + registerHandler: vi.fn((key: string, handler: DeepLinkHandler) => { + handlers.set(key, handler); + }), + trigger: (key: string, path: string) => { + const handler = handlers.get(key); + if (!handler) throw new Error(`No handler for ${key}`); + return handler(path, new URLSearchParams()); + }, + }; + return service as unknown as IDeepLinkRegistry & { + trigger: (key: string, path: string) => boolean; + }; +} + +function makeMainWindow() { + return { + focus: vi.fn(), + restore: vi.fn(), + isMinimized: vi.fn().mockReturnValue(false), + } as unknown as IMainWindow & { + focus: ReturnType<typeof vi.fn>; + restore: ReturnType<typeof vi.fn>; + isMinimized: ReturnType<typeof vi.fn>; + }; +} + +describe("InboxLinkService", () => { + let deepLinkService: ReturnType<typeof makeDeepLinkService>; + let mainWindow: ReturnType<typeof makeMainWindow>; + let service: InboxLinkService; + + beforeEach(() => { + deepLinkService = makeDeepLinkService(); + mainWindow = makeMainWindow(); + service = new InboxLinkService(deepLinkService, mainWindow, makeLogger()); + }); + + it("registers an 'inbox' handler on the DeepLinkService", () => { + expect(deepLinkService.registerHandler).toHaveBeenCalledWith( + "inbox", + expect.any(Function), + ); + }); + + it("emits OpenReport when a listener is attached", () => { + const listener = vi.fn(); + service.on(InboxLinkEvent.OpenReport, listener); + + const result = deepLinkService.trigger("inbox", "abc-123"); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" }); + }); + + it("queues a pending deep link when no listener is attached", () => { + deepLinkService.trigger("inbox", "pending-id"); + + const pending = service.consumePendingDeepLink(); + expect(pending).toEqual({ reportId: "pending-id" }); + + // Draining clears it + expect(service.consumePendingDeepLink()).toBeNull(); + }); + + it("takes only the first path segment as the report id", () => { + const listener = vi.fn(); + service.on(InboxLinkEvent.OpenReport, listener); + + deepLinkService.trigger("inbox", "abc-123/extra/segments"); + + expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" }); + }); + + it("ignores a trailing slug segment after the report id", () => { + const listener = vi.fn(); + service.on(InboxLinkEvent.OpenReport, listener); + + deepLinkService.trigger("inbox", "abc-123/fix-inbox--Add-foo"); + + expect(listener).toHaveBeenCalledWith({ reportId: "abc-123" }); + }); + + it("returns false and does not emit when the path is empty", () => { + const listener = vi.fn(); + service.on(InboxLinkEvent.OpenReport, listener); + + const result = deepLinkService.trigger("inbox", ""); + + expect(result).toBe(false); + expect(listener).not.toHaveBeenCalled(); + }); + + it("focuses the main window on link arrival", () => { + deepLinkService.trigger("inbox", "abc-123"); + + expect(mainWindow.focus).toHaveBeenCalledTimes(1); + expect(mainWindow.restore).not.toHaveBeenCalled(); + }); + + it("restores the main window when it is minimized", () => { + mainWindow.isMinimized.mockReturnValue(true); + + deepLinkService.trigger("inbox", "abc-123"); + + expect(mainWindow.restore).toHaveBeenCalledTimes(1); + expect(mainWindow.focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/links/inbox-link.ts b/packages/core/src/links/inbox-link.ts new file mode 100644 index 0000000000..7b8eb75a3b --- /dev/null +++ b/packages/core/src/links/inbox-link.ts @@ -0,0 +1,86 @@ +import { + DEEP_LINK_SERVICE, + type IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import type { LinkLogger } from "./identifiers"; + +export const InboxLinkEvent = { + OpenReport: "openReport", +} as const; + +export interface InboxLinkEvents { + [InboxLinkEvent.OpenReport]: { reportId: string }; +} + +export interface PendingInboxDeepLink { + reportId: string; +} + +@injectable() +export class InboxLinkService extends TypedEventEmitter<InboxLinkEvents> { + private pendingDeepLink: PendingInboxDeepLink | null = null; + private readonly log: LinkLogger; + + constructor( + @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), + ); + } + + private handleInboxLink(path: string): boolean { + const reportId = path.split("/")[0]; + + if (!reportId) { + this.log.warn("Inbox link missing report ID"); + return false; + } + + const hasListeners = this.listenerCount(InboxLinkEvent.OpenReport) > 0; + + if (hasListeners) { + this.log.info(`Emitting inbox link event: reportId=${reportId}`); + this.emit(InboxLinkEvent.OpenReport, { reportId }); + } else { + this.log.info( + `Queueing inbox link (renderer not ready): reportId=${reportId}`, + ); + this.pendingDeepLink = { reportId }; + } + + this.log.info("Deep link focusing window", { reportId }); + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + + return true; + } + + public consumePendingDeepLink(): PendingInboxDeepLink | null { + const pending = this.pendingDeepLink; + this.pendingDeepLink = null; + if (pending) { + this.log.info( + `Consumed pending inbox link: reportId=${pending.reportId}`, + ); + } + return pending; + } +} diff --git a/packages/core/src/links/new-task-link.test.ts b/packages/core/src/links/new-task-link.test.ts new file mode 100644 index 0000000000..d0f4458b62 --- /dev/null +++ b/packages/core/src/links/new-task-link.test.ts @@ -0,0 +1,449 @@ +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 { 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< + string, + (path: string, params: URLSearchParams) => boolean + >(); + return { + registerHandler: vi.fn((key, handler) => handlers.set(key, handler)), + _handlers: handlers, + _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; +} + +describe("NewTaskLinkService", () => { + let service: NewTaskLinkService; + let mockDeepLink: ReturnType<typeof createMockDeepLinkService>; + let mockWindow: IMainWindow; + + beforeEach(() => { + vi.clearAllMocks(); + mockDeepLink = createMockDeepLinkService(); + mockWindow = createMockMainWindow(); + service = new NewTaskLinkService( + mockDeepLink as unknown as IDeepLinkRegistry, + mockWindow, + makeLogger(), + ); + }); + + describe("constructor", () => { + it("registers handlers for new, plan and issue", () => { + expect(mockDeepLink.registerHandler).toHaveBeenCalledWith( + "new", + expect.any(Function), + ); + expect(mockDeepLink.registerHandler).toHaveBeenCalledWith( + "plan", + expect.any(Function), + ); + expect(mockDeepLink.registerHandler).toHaveBeenCalledWith( + "issue", + expect.any(Function), + ); + expect(mockDeepLink.registerHandler).toHaveBeenCalledTimes(3); + }); + }); + + describe("handleNew", () => { + it("rejects empty params", () => { + const result = mockDeepLink._invoke("new", new URLSearchParams()); + expect(result).toBe(false); + }); + + it("rejects when only mode is provided", () => { + const result = mockDeepLink._invoke( + "new", + new URLSearchParams("mode=plan"), + ); + expect(result).toBe(false); + }); + + it("rejects when only model is provided", () => { + const result = mockDeepLink._invoke( + "new", + new URLSearchParams("model=opus"), + ); + expect(result).toBe(false); + }); + + it("rejects when only mode and model are provided", () => { + const result = mockDeepLink._invoke( + "new", + new URLSearchParams("mode=plan&model=opus"), + ); + expect(result).toBe(false); + }); + + it("accepts prompt only", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const result = mockDeepLink._invoke( + "new", + new URLSearchParams("prompt=hello+world"), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + action: "new", + prompt: "hello world", + }), + ); + }); + + it("accepts repo only", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const result = mockDeepLink._invoke( + "new", + new URLSearchParams("repo=posthog/posthog"), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + action: "new", + repo: "posthog/posthog", + prompt: undefined, + }), + ); + }); + + it("passes shared params (repo, mode, model)", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + mockDeepLink._invoke( + "new", + new URLSearchParams("prompt=test&repo=org/repo&mode=cloud&model=opus"), + ); + + expect(listener).toHaveBeenCalledWith({ + action: "new", + prompt: "test", + repo: "org/repo", + mode: "cloud", + model: "opus", + }); + }); + }); + + describe("handlePlan", () => { + it("rejects missing plan param", () => { + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams("repo=org/repo"), + ); + expect(result).toBe(false); + }); + + it("rejects invalid base64", () => { + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams("plan=!!!invalid-base64!!!"), + ); + expect(result).toBe(false); + }); + + it("accepts valid base64 plan", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const planText = "# My Plan\n\n1. Do thing\n2. Do other thing"; + const encoded = btoa(planText); + + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams(`plan=${encoded}&repo=org/repo`), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + action: "plan", + plan: planText, + repo: "org/repo", + }), + ); + }); + + it("accepts URL-safe base64 with - and _ instead of + and /", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + // "??>" base64 is "Pz8+" — contains `+` so URL-safe substitutes to `-`. + const planText = "??>"; + const standard = Buffer.from(planText, "utf-8").toString("base64"); + const urlSafe = standard + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams(`plan=${urlSafe}`), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ action: "plan", plan: planText }), + ); + }); + + it("recovers when + was decoded to space by URLSearchParams", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + // "Pz8+" arrives as "Pz8 " because URLSearchParams turns + into space. + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams("plan=Pz8+"), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ action: "plan", plan: "??>" }), + ); + }); + + it("round-trips UTF-8 (emoji, non-ASCII)", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const planText = "Plan 🚀: café — naïve résumé"; + const encoded = Buffer.from(planText, "utf-8").toString("base64"); + + const result = mockDeepLink._invoke( + "plan", + new URLSearchParams(`plan=${encoded}`), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ action: "plan", plan: planText }), + ); + }); + + it("passes shared params", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const encoded = btoa("plan content"); + mockDeepLink._invoke( + "plan", + new URLSearchParams(`plan=${encoded}&mode=worktree&model=sonnet`), + ); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "worktree", + model: "sonnet", + }), + ); + }); + }); + + describe("handleIssue", () => { + it("rejects missing url param", () => { + const result = mockDeepLink._invoke("issue", new URLSearchParams()); + expect(result).toBe(false); + }); + + it("rejects non-GitHub URLs", () => { + const result = mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://gitlab.com/org/repo/issues/1"), + ); + expect(result).toBe(false); + }); + + it("rejects GitHub URLs that are not issues", () => { + const result = mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/org/repo/pull/1"), + ); + expect(result).toBe(false); + }); + + it("rejects issue URLs with non-numeric issue number", () => { + const result = mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/org/repo/issues/abc"), + ); + expect(result).toBe(false); + }); + + it("rejects issue URLs with extra trailing path segments", () => { + const result = mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/org/repo/issues/42/edit"), + ); + expect(result).toBe(false); + }); + + it("rejects issue URLs with zero or negative issue number", () => { + expect( + mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/org/repo/issues/0"), + ), + ).toBe(false); + + expect( + mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/org/repo/issues/-1"), + ), + ).toBe(false); + }); + + it("accepts valid GitHub issue URL", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + const result = mockDeepLink._invoke( + "issue", + new URLSearchParams("url=https://github.com/posthog/posthog/issues/42"), + ); + + expect(result).toBe(true); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + action: "issue", + url: "https://github.com/posthog/posthog/issues/42", + owner: "posthog", + issueRepo: "posthog", + issueNumber: 42, + }), + ); + }); + + it("passes shared params", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + mockDeepLink._invoke( + "issue", + new URLSearchParams( + "url=https://github.com/org/repo/issues/1&repo=other/repo&model=opus", + ), + ); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + repo: "other/repo", + model: "opus", + }), + ); + }); + }); + + describe("emitOrQueue", () => { + it("emits when listeners exist", () => { + const listener = vi.fn(); + service.on(NewTaskLinkEvent.Action, listener); + + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + expect(listener).toHaveBeenCalledTimes(1); + expect(service.consumePendingLink()).toBeNull(); + }); + + it("queues when no listeners exist", () => { + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + const pending = service.consumePendingLink(); + expect(pending).toEqual( + expect.objectContaining({ action: "new", prompt: "test" }), + ); + }); + + it("focuses the window", () => { + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + expect(mockWindow.focus).toHaveBeenCalled(); + }); + + it("restores the window if minimized", () => { + vi.mocked(mockWindow.isMinimized).mockReturnValue(true); + + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + expect(mockWindow.restore).toHaveBeenCalled(); + expect(mockWindow.focus).toHaveBeenCalled(); + }); + + it("does not restore the window if not minimized", () => { + vi.mocked(mockWindow.isMinimized).mockReturnValue(false); + + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + expect(mockWindow.restore).not.toHaveBeenCalled(); + }); + }); + + describe("consumePendingLink", () => { + it("returns null when no pending link", () => { + expect(service.consumePendingLink()).toBeNull(); + }); + + it("clears after consuming", () => { + mockDeepLink._invoke("new", new URLSearchParams("prompt=test")); + + expect(service.consumePendingLink()).not.toBeNull(); + expect(service.consumePendingLink()).toBeNull(); + }); + + it("latest link overwrites previous pending", () => { + mockDeepLink._invoke("new", new URLSearchParams("prompt=first")); + mockDeepLink._invoke("new", new URLSearchParams("prompt=second")); + + const pending = service.consumePendingLink(); + expect(pending).toEqual(expect.objectContaining({ prompt: "second" })); + }); + }); +}); diff --git a/packages/core/src/links/new-task-link.ts b/packages/core/src/links/new-task-link.ts new file mode 100644 index 0000000000..c666fab5cd --- /dev/null +++ b/packages/core/src/links/new-task-link.ts @@ -0,0 +1,176 @@ +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 { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; +import type { LinkLogger } from "./identifiers"; + +export const NewTaskLinkEvent = { + Action: "action", +} as const; + +export type { NewTaskLinkPayload }; + +export interface NewTaskLinkEvents { + [NewTaskLinkEvent.Action]: NewTaskLinkPayload; +} + +@injectable() +export class NewTaskLinkService extends TypedEventEmitter<NewTaskLinkEvents> { + private pendingLink: NewTaskLinkPayload | null = null; + private readonly log: LinkLogger; + + constructor( + @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), + ); + this.deepLinkService.registerHandler("plan", (_path, params) => + this.handlePlan(params), + ); + this.deepLinkService.registerHandler("issue", (_path, params) => + this.handleIssue(params), + ); + } + + private extractSharedParams(params: URLSearchParams): NewTaskSharedParams { + return { + repo: params.get("repo") ?? undefined, + mode: params.get("mode") ?? undefined, + model: params.get("model") ?? undefined, + }; + } + + private handleNew(params: URLSearchParams): boolean { + const shared = this.extractSharedParams(params); + const prompt = params.get("prompt") ?? undefined; + + if (!prompt && !shared.repo) { + this.log.warn("New task link requires at least prompt or repo"); + return false; + } + + const payload: NewTaskLinkPayload = { + action: "new", + prompt, + ...shared, + }; + + this.log.info("Handling new task link", { + hasPrompt: !!prompt, + repo: shared.repo, + }); + return this.emitOrQueue(payload); + } + + private handlePlan(params: URLSearchParams): boolean { + const planEncoded = params.get("plan"); + + if (!planEncoded) { + this.log.warn("Plan link missing plan parameter"); + return false; + } + + const plan = decodePlanBase64(planEncoded); + if (plan === null) { + this.log.error("Plan link has invalid base64 encoding"); + return false; + } + + const shared = this.extractSharedParams(params); + const payload: NewTaskLinkPayload = { + action: "plan", + plan, + ...shared, + }; + + this.log.info("Handling plan link", { + planLength: plan.length, + repo: shared.repo, + }); + return this.emitOrQueue(payload); + } + + private handleIssue(params: URLSearchParams): boolean { + const url = params.get("url"); + + if (!url) { + this.log.warn("Issue link missing url parameter"); + return false; + } + + const parsed = parseGitHubIssueUrl(url); + if (!parsed) { + this.log.warn("Issue link has invalid GitHub issue URL", { url }); + return false; + } + + const shared = this.extractSharedParams(params); + const payload: NewTaskLinkPayload = { + action: "issue", + url, + owner: parsed.owner, + issueRepo: parsed.repo, + issueNumber: parsed.number, + ...shared, + }; + + this.log.info("Handling issue link", { + owner: parsed.owner, + repo: parsed.repo, + number: parsed.number, + }); + return this.emitOrQueue(payload); + } + + private emitOrQueue(payload: NewTaskLinkPayload): boolean { + const hasListeners = this.listenerCount(NewTaskLinkEvent.Action) > 0; + + if (hasListeners) { + this.log.info(`Emitting new task link event: action=${payload.action}`); + this.emit(NewTaskLinkEvent.Action, payload); + } else { + this.log.info( + `Queueing new task link (renderer not ready): action=${payload.action}`, + ); + this.pendingLink = payload; + } + + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + + return true; + } + + public consumePendingLink(): NewTaskLinkPayload | null { + const pending = this.pendingLink; + this.pendingLink = null; + if (pending) { + 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<typeof createMockDeepLinkService>; + 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/<id> 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/packages/core/src/links/task-link.ts b/packages/core/src/links/task-link.ts new file mode 100644 index 0000000000..841c70541d --- /dev/null +++ b/packages/core/src/links/task-link.ts @@ -0,0 +1,91 @@ +import { + DEEP_LINK_SERVICE, + type IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import type { LinkLogger } from "./identifiers"; + +export const TaskLinkEvent = { + OpenTask: "openTask", +} as const; + +export interface TaskLinkEvents { + [TaskLinkEvent.OpenTask]: { taskId: string; taskRunId?: string }; +} + +export interface PendingDeepLink { + taskId: string; + taskRunId?: string; +} + +@injectable() +export class TaskLinkService extends TypedEventEmitter<TaskLinkEvents> { + private pendingDeepLink: PendingDeepLink | null = null; + private readonly log: LinkLogger; + + constructor( + @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), + ); + } + + private handleTaskLink(path: string): boolean { + const parts = path.split("/"); + const taskId = parts[0]; + const taskRunId = parts[1] === "run" ? parts[2] : undefined; + + if (!taskId) { + this.log.warn("Task link missing task ID"); + return false; + } + + const hasListeners = this.listenerCount(TaskLinkEvent.OpenTask) > 0; + + if (hasListeners) { + this.log.info( + `Emitting task link event: taskId=${taskId}, taskRunId=${taskRunId ?? "none"}`, + ); + this.emit(TaskLinkEvent.OpenTask, { taskId, taskRunId }); + } else { + this.log.info( + `Queueing task link (renderer not ready): taskId=${taskId}, taskRunId=${taskRunId ?? "none"}`, + ); + this.pendingDeepLink = { taskId, taskRunId }; + } + + this.log.info("Deep link focusing window", { taskId, taskRunId }); + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + + return true; + } + + public consumePendingDeepLink(): PendingDeepLink | null { + const pending = this.pendingDeepLink; + this.pendingDeepLink = null; + if (pending) { + this.log.info( + `Consumed pending task link: taskId=${pending.taskId}, taskRunId=${pending.taskRunId ?? "none"}`, + ); + } + return pending; + } +} 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<Response>; +} + +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<Response>((_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/packages/core/src/llm-gateway/llm-gateway.ts b/packages/core/src/llm-gateway/llm-gateway.ts new file mode 100644 index 0000000000..d2eae2f7e3 --- /dev/null +++ b/packages/core/src/llm-gateway/llm-gateway.ts @@ -0,0 +1,227 @@ +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; +import { + LLM_GATEWAY_HOST, + type LlmGatewayAuth, + type LlmGatewayEndpoints, + type LlmGatewayHost, + type LlmGatewayLogger, +} from "./identifiers"; +import { + type AnthropicErrorResponse, + type AnthropicMessagesRequest, + type AnthropicMessagesResponse, + type LlmMessage, + type PromptOutput, + type UsageOutput, + usageOutput, +} from "./schemas"; + +export class LlmGatewayError extends Error { + constructor( + message: string, + public readonly type: string, + public readonly code?: string, + public readonly statusCode?: number, + ) { + super(message); + this.name = "LlmGatewayError"; + } +} + +@injectable() +export class LlmGatewayService { + constructor( + @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[], + options: { + system?: string; + maxTokens?: number; + model?: string; + signal?: AbortSignal; + timeoutMs?: number; + } = {}, + ): Promise<PromptOutput> { + const { + system, + maxTokens, + model = this.endpoints.defaultModel, + signal, + timeoutMs = 60_000, + } = options; + + const auth = await this.auth.getValidAccessToken(); + const messagesUrl = this.endpoints.messagesUrl(auth.apiHost); + + const requestBody: AnthropicMessagesRequest = { + model, + messages: messages.map((m) => ({ role: m.role, content: m.content })), + stream: false, + }; + + if (maxTokens !== undefined) { + requestBody.max_tokens = maxTokens; + } + + if (system) { + requestBody.system = system; + } + + this.log.debug("Sending request to LLM gateway", { + url: messagesUrl, + model, + messageCount: messages.length, + }); + + const timeoutController = new AbortController(); + const timeoutId = setTimeout(() => { + timeoutController.abort(); + }, timeoutMs); + const onCallerAbort = () => timeoutController.abort(); + if (signal) { + if (signal.aborted) timeoutController.abort(); + else signal.addEventListener("abort", onCallerAbort, { once: true }); + } + + let response: Response; + try { + response = await this.auth.authenticatedFetch(messagesUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + signal: timeoutController.signal, + }); + } catch (err) { + if (timeoutController.signal.aborted && !signal?.aborted) { + throw new LlmGatewayError( + `LLM gateway request timed out after ${timeoutMs}ms`, + "timeout", + ); + } + throw err; + } finally { + clearTimeout(timeoutId); + signal?.removeEventListener("abort", onCallerAbort); + } + + if (!response.ok) { + const errorBody = await response.text(); + let errorData: AnthropicErrorResponse | null = null; + + try { + errorData = JSON.parse(errorBody) as AnthropicErrorResponse; + } catch { + this.log.error("Failed to parse error response", { + errorBody, + status: response.status, + }); + } + + const errorMessage = + errorData?.error?.message || + `HTTP ${response.status}: ${response.statusText}`; + const errorType = errorData?.error?.type || "unknown_error"; + const errorCode = errorData?.error?.code; + + this.log.error("LLM gateway request failed", { + status: response.status, + errorType, + errorMessage, + }); + + throw new LlmGatewayError( + errorMessage, + errorType, + errorCode, + response.status, + ); + } + + const data = (await response.json()) as AnthropicMessagesResponse; + + const textContent = data.content.find((c) => c.type === "text"); + const content = textContent?.text || ""; + + this.log.debug("LLM gateway response received", { + model: data.model, + stopReason: data.stop_reason, + inputTokens: data.usage.input_tokens, + outputTokens: data.usage.output_tokens, + }); + + return { + content, + model: data.model, + stopReason: data.stop_reason, + usage: { + inputTokens: data.usage.input_tokens, + outputTokens: data.usage.output_tokens, + }, + }; + } + + async fetchUsage(): Promise<UsageOutput> { + const auth = await this.auth.getValidAccessToken(); + const usageUrl = this.endpoints.usageUrl(auth.apiHost); + + this.log.debug("Fetching usage from gateway", { url: usageUrl }); + + let response: Response; + try { + response = await this.auth.authenticatedFetch(usageUrl); + } catch (err) { + this.log.warn("Usage fetch network error", { + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } + + if (!response.ok) { + this.log.warn("Usage fetch failed", { status: response.status }); + throw new LlmGatewayError( + `Failed to fetch usage: HTTP ${response.status}`, + "usage_error", + undefined, + response.status, + ); + } + + return usageOutput.parse(await response.json()); + } + + async invalidatePlanCache(): Promise<void> { + const auth = await this.auth.getValidAccessToken(); + const url = this.endpoints.invalidatePlanCacheUrl(auth.apiHost); + + this.log.debug("Invalidating plan cache", { url }); + + const response = await this.auth.authenticatedFetch(url, { + method: "POST", + }); + + if (!response.ok) { + throw new LlmGatewayError( + `Failed to invalidate plan cache: HTTP ${response.status}`, + "plan_cache_error", + undefined, + response.status, + ); + } + } +} diff --git a/packages/core/src/llm-gateway/schemas.ts b/packages/core/src/llm-gateway/schemas.ts new file mode 100644 index 0000000000..9b985139b9 --- /dev/null +++ b/packages/core/src/llm-gateway/schemas.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +export const llmMessageSchema = z.object({ + role: z.enum(["user", "assistant"]), + content: z.string(), +}); + +export type LlmMessage = z.infer<typeof llmMessageSchema>; + +export const promptInput = z.object({ + system: z.string().optional(), + messages: z.array(llmMessageSchema), + maxTokens: z.number().optional(), + model: z.string().optional(), +}); + +export type PromptInput = z.infer<typeof promptInput>; + +export const promptOutput = z.object({ + content: z.string(), + model: z.string(), + stopReason: z.string().nullable(), + usage: z.object({ + inputTokens: z.number(), + outputTokens: z.number(), + }), +}); + +export type PromptOutput = z.infer<typeof promptOutput>; + +export interface AnthropicMessagesRequest { + model: string; + messages: Array<{ role: "user" | "assistant"; content: string }>; + max_tokens?: number; + system?: string; + stream?: boolean; +} + +export interface AnthropicMessagesResponse { + id: string; + type: "message"; + role: "assistant"; + content: Array<{ type: "text"; text: string }>; + model: string; + stop_reason: string | null; + usage: { + input_tokens: number; + output_tokens: number; + }; +} + +export interface AnthropicErrorResponse { + error: { + message: string; + type: string; + code?: string; + }; +} + +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/packages/core/src/mcp-apps/mcp-apps.ts b/packages/core/src/mcp-apps/mcp-apps.ts new file mode 100644 index 0000000000..a3efb3a96e --- /dev/null +++ b/packages/core/src/mcp-apps/mcp-apps.ts @@ -0,0 +1,489 @@ +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, + URL_LAUNCHER_SERVICE, +} from "@posthog/platform/url-launcher"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import { + type McpAppsDiscoveryCompleteEvent, + McpAppsServiceEvent, + type McpAppsServiceEvents, + type McpAppsToolCancelledEvent, + type McpAppsToolInputEvent, + type McpAppsToolResultEvent, + type McpResourceUiMeta, + type McpServerConnectionConfig, + type McpToolUiAssociation, + type McpToolUiMeta, + type McpUiResource, +} from "./schemas"; + +const UI_MIME_TYPE = "text/html;profile=mcp-app"; +const MAX_HTML_SIZE = 5 * 1024 * 1024; // 5MB + +interface ServerConnection { + name: string; + client: Client; + transport: StreamableHTTPClientTransport; +} + +@injectable() +export class McpAppsService extends TypedEventEmitter<McpAppsServiceEvents> { + private connections = new Map<string, ServerConnection>(); + private resourceCache = new Map<string, McpUiResource>(); + private toolAssociations = new Map<string, McpToolUiAssociation>(); + private toolDefinitions = new Map<string, Tool>(); + private serverConfigs = new Map<string, McpServerConnectionConfig>(); + private pendingConnections = new Map<string, Promise<ServerConnection>>(); + private pendingFetches = new Map<string, Promise<McpUiResource | null>>(); + private resourceMetaCache = new Map<string, McpResourceUiMeta>(); + private readonly log: ScopedLogger; + + constructor( + @inject(URL_LAUNCHER_SERVICE) + private readonly urlLauncher: IUrlLauncher, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, + ) { + super(); + + this.log = workbenchLogger.scope("mcp-apps-service"); + } + + /** + * Store server configs for lazy connections later. + * No connections are created at this point. + */ + setServerConfigs(configs: McpServerConnectionConfig[]): void { + this.serverConfigs.clear(); + for (const config of configs) { + this.serverConfigs.set(config.name, config); + } + } + + /** + * Called when the agent confirms MCP servers are connected. + * Connects to each server, calls listTools() to discover _meta.ui fields + * (which the agent SDK strips), then populates tool associations and + * emits DiscoveryComplete. + */ + async handleDiscovery(serverNames: string[]): Promise<void> { + await Promise.allSettled( + serverNames + .filter((name) => this.serverConfigs.has(name)) + .map((name) => this.discoverServerUiTools(name)), + ); + + const toolKeys = [...this.toolAssociations.keys()]; + this.log.info("Discovery complete", { + serverNames, + toolKeys, + associationCount: this.toolAssociations.size, + }); + + this.emit(McpAppsServiceEvent.DiscoveryComplete, { + toolKeys, + } satisfies McpAppsDiscoveryCompleteEvent); + } + + /** + * Connect to a single server and call listTools() to discover which + * tools have _meta.ui fields. The connection is kept for later reuse + * (proxy calls, resource reads, lazy HTML fetches). + */ + private async discoverServerUiTools(serverName: string): Promise<void> { + try { + const conn = await this.getOrCreateConnection(serverName); + + const [toolsList, resourcesList] = await Promise.all([ + conn.client.listTools(), + conn.client.listResources().catch((err) => { + this.log.warn("listResources failed during discovery", { + serverName, + error: err instanceof Error ? err.message : String(err), + }); + return null; + }), + ]); + + for (const tool of toolsList.tools) { + const uiMeta = (tool as McpToolUiMeta)._meta?.ui; + if (!uiMeta?.resourceUri) continue; + + const toolKey = `mcp__${serverName}__${tool.name}`; + this.toolAssociations.set(toolKey, { + toolKey, + serverName, + toolName: tool.name, + resourceUri: uiMeta.resourceUri, + visibility: uiMeta.visibility, + }); + this.toolDefinitions.set(toolKey, tool); + } + + // Cache resource metadata (CSP, permissions) for use in fetchUiResource + if (resourcesList) { + for (const resource of resourcesList.resources) { + const meta = resource as McpResourceUiMeta; + if (meta._meta?.ui) { + this.resourceMetaCache.set(resource.uri, meta); + } + } + } + } catch (err) { + this.log.warn("Failed to discover UI tools for server", { + serverName, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + /** + * Get or create a lazy MCP connection for a server. + * Deduplicates concurrent connection attempts for the same server. + */ + private async getOrCreateConnection( + serverName: string, + ): Promise<ServerConnection> { + const existing = this.connections.get(serverName); + if (existing) { + this.log.debug("Reusing existing MCP connection", { serverName }); + return existing; + } + + // Deduplicate concurrent connection attempts + const pending = this.pendingConnections.get(serverName); + if (pending) { + this.log.info("Joining pending MCP connection attempt", { serverName }); + return pending; + } + + const config = this.serverConfigs.get(serverName); + if (!config) { + throw new Error(`No server config for: ${serverName}`); + } + + const connectionPromise = this.createConnection(config); + this.pendingConnections.set(serverName, connectionPromise); + + try { + const conn = await connectionPromise; + this.connections.set(serverName, conn); + return conn; + } finally { + this.pendingConnections.delete(serverName); + } + } + + private async createConnection( + config: McpServerConnectionConfig, + ): Promise<ServerConnection> { + const transport = new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: { + headers: config.headers, + }, + }); + + const client = new Client( + { name: "Twig", version: "1.0.0" }, + { + capabilities: { + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: [UI_MIME_TYPE], + }, + }, + } as Record<string, unknown>, + }, + ); + + await client.connect(transport); + + this.log.info("Lazy MCP connection established", { + serverName: config.name, + serverVersion: client.getServerVersion(), + }); + + return { name: config.name, client, transport }; + } + + /** + * Get the UI resource for a tool. Fetches lazily on first access: + * creates an MCP connection if needed, then reads the resource HTML. + * Deduplicates concurrent fetches for the same resource URI. + */ + async getUiResourceForTool(toolKey: string): Promise<McpUiResource | null> { + const association = this.toolAssociations.get(toolKey); + if (!association) { + this.log.debug("getUiResourceForTool: no association found", { toolKey }); + return null; + } + + // Return cached resource immediately + const cached = this.resourceCache.get(association.resourceUri); + if (cached) { + 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) { + this.log.debug("getUiResourceForTool: joining pending fetch", { + toolKey, + uri: association.resourceUri, + }); + return pendingFetch; + } + + // Start the fetch for this resource URI + this.log.debug("getUiResourceForTool: starting lazy fetch", { + toolKey, + serverName: association.serverName, + uri: association.resourceUri, + }); + const fetchPromise = this.fetchUiResource(association); + this.pendingFetches.set(association.resourceUri, fetchPromise); + + try { + return await fetchPromise; + } finally { + this.pendingFetches.delete(association.resourceUri); + } + } + + private async fetchUiResource( + association: McpToolUiAssociation, + ): Promise<McpUiResource | null> { + try { + const conn = await this.getOrCreateConnection(association.serverName); + const resourceResult = await conn.client.readResource({ + uri: association.resourceUri, + }); + + const textContent = resourceResult.contents.find( + (c) => "text" in c && c.mimeType === UI_MIME_TYPE, + ); + if (!textContent || !("text" in textContent)) { + this.log.warn("UI resource had no matching text content", { + serverName: association.serverName, + uri: association.resourceUri, + contentsCount: resourceResult.contents.length, + }); + return null; + } + + if (textContent.text.length > MAX_HTML_SIZE) { + this.log.warn("UI resource HTML exceeds size limit", { + uri: association.resourceUri, + size: textContent.text.length, + limit: MAX_HTML_SIZE, + }); + return null; + } + + // Use metadata cached during discovery + const resourceMeta = this.resourceMetaCache.get(association.resourceUri); + + const resource: McpUiResource = { + uri: association.resourceUri, + name: resourceMeta?.name, + mimeType: UI_MIME_TYPE, + csp: resourceMeta?._meta?.ui?.csp, + permissions: resourceMeta?._meta?.ui?.permissions, + html: textContent.text, + serverName: association.serverName, + }; + + this.resourceCache.set(association.resourceUri, resource); + this.log.info("Lazily fetched and cached UI resource", { + serverName: association.serverName, + uri: association.resourceUri, + htmlLength: textContent.text.length, + hasCsp: !!resource.csp, + }); + + return resource; + } catch (err) { + this.log.warn("Failed to lazily fetch UI resource", { + serverName: association.serverName, + uri: association.resourceUri, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + } + + hasUiForTool(toolKey: string): boolean { + const has = this.toolAssociations.has(toolKey); + this.log.debug("hasUiForTool", { toolKey, result: has }); + return has; + } + + getToolDefinition(toolKey: string): Tool | null { + return this.toolDefinitions.get(toolKey) ?? null; + } + + async proxyToolCall( + serverName: string, + toolName: string, + args?: Record<string, unknown>, + ): Promise<unknown> { + // Validate visibility: reject if tool is model-only + const toolKey = `mcp__${serverName}__${toolName}`; + const association = this.toolAssociations.get(toolKey); + if (association?.visibility && !association.visibility.includes("app")) { + throw new Error( + `Tool "${toolName}" is not accessible to apps (visibility: ${association.visibility.join(", ")})`, + ); + } + + const conn = await this.getOrCreateConnection(serverName); + const result = await conn.client.callTool({ + name: toolName, + arguments: args, + }); + + return result; + } + + async proxyResourceRead(serverName: string, uri: string): Promise<unknown> { + // Only allow ui:// scheme reads + if (!uri.startsWith("ui://")) { + throw new Error(`Only ui:// URIs are allowed, got: ${uri}`); + } + + const conn = await this.getOrCreateConnection(serverName); + const result = await conn.client.readResource({ uri }); + return result; + } + + async openLink(url: string): Promise<void> { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error( + `Only http/https URLs are allowed, got: ${parsed.protocol}`, + ); + } + await this.urlLauncher.launch(url); + } + + notifyToolInput(toolKey: string, toolCallId: string, args: unknown): void { + this.log.info("notifyToolInput", { toolKey, toolCallId }); + this.emit(McpAppsServiceEvent.ToolInput, { + toolKey, + toolCallId, + args, + } satisfies McpAppsToolInputEvent); + } + + notifyToolResult( + toolKey: string, + toolCallId: string, + result: unknown, + isError?: boolean, + ): void { + this.log.info("notifyToolResult", { toolKey, toolCallId, isError }); + this.emit(McpAppsServiceEvent.ToolResult, { + toolKey, + toolCallId, + result, + isError, + } satisfies McpAppsToolResultEvent); + } + + notifyToolCancelled(toolKey: string, toolCallId: string): void { + this.log.info("notifyToolCancelled", { toolKey, toolCallId }); + this.emit(McpAppsServiceEvent.ToolCancelled, { + toolKey, + toolCallId, + } satisfies McpAppsToolCancelledEvent); + } + + /** + * Clear all cached resources and connections, re-run discovery, and + * emit DiscoveryComplete so the renderer refetches everything. + * Intended for developer debugging via the File > Developer menu. + */ + async refreshDiscovery(): Promise<void> { + this.log.info("refreshDiscovery: clearing caches and re-running discovery"); + + // Close existing connections + for (const [, conn] of this.connections) { + await conn.client.close().catch(() => {}); + } + this.connections.clear(); + this.resourceCache.clear(); + this.resourceMetaCache.clear(); + this.toolAssociations.clear(); + this.toolDefinitions.clear(); + this.pendingConnections.clear(); + this.pendingFetches.clear(); + + // Re-discover using stored server configs + const serverNames = [...this.serverConfigs.keys()]; + if (serverNames.length > 0) { + await this.handleDiscovery(serverNames); + } else { + this.log.warn( + "refreshDiscovery: no server configs stored, nothing to discover", + ); + } + } + + async disconnectServer(serverName: string): Promise<void> { + const conn = this.connections.get(serverName); + if (!conn) return; + + try { + await conn.client.close(); + } catch (err) { + this.log.warn("Error closing MCP connection", { + serverName, + error: err instanceof Error ? err.message : String(err), + }); + } + this.connections.delete(serverName); + + // Clean up associations and cached resources for this server + const urisToEvict = new Set<string>(); + for (const [key, assoc] of this.toolAssociations) { + if (assoc.serverName === serverName) { + urisToEvict.add(assoc.resourceUri); + this.toolAssociations.delete(key); + } + } + + // Only evict cached resources not referenced by remaining associations + const stillReferenced = new Set( + [...this.toolAssociations.values()].map((a) => a.resourceUri), + ); + for (const uri of urisToEvict) { + if (!stillReferenced.has(uri)) { + this.resourceCache.delete(uri); + } + } + } + + async cleanup(): Promise<void> { + const serverNames = [...this.connections.keys()]; + for (const name of serverNames) { + await this.disconnectServer(name); + } + this.resourceCache.clear(); + this.resourceMetaCache.clear(); + this.toolAssociations.clear(); + this.toolDefinitions.clear(); + this.serverConfigs.clear(); + this.pendingConnections.clear(); + this.pendingFetches.clear(); + } +} 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> = {}, +): 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<CustomServerFormValues, "name" | "url">, +): 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/packages/core/src/mcp-servers/filters.test.ts b/packages/core/src/mcp-servers/filters.test.ts new file mode 100644 index 0000000000..594e224463 --- /dev/null +++ b/packages/core/src/mcp-servers/filters.test.ts @@ -0,0 +1,73 @@ +import type { McpRecommendedServer } from "@posthog/api-client/types"; +import { describe, expect, it } from "vitest"; +import { filterServersByCategory, filterServersByQuery } from "./filters"; + +function server( + overrides: Partial<McpRecommendedServer>, +): McpRecommendedServer { + return { + id: "test-id", + name: "Test", + url: "https://example.com/mcp", + description: "", + auth_type: "oauth", + ...overrides, + } as McpRecommendedServer; +} + +describe("filterServersByCategory", () => { + const all = [ + server({ id: "a", category: "dev", name: "Alpha" }), + server({ id: "b", category: "data", name: "Beta" }), + server({ id: "c", category: "dev", name: "Gamma" }), + server({ id: "d", name: "Delta" }), // no category + ]; + + it("returns everything when category is 'all'", () => { + expect(filterServersByCategory(all, "all")).toHaveLength(4); + }); + + it("filters down to the exact category", () => { + const out = filterServersByCategory(all, "dev"); + expect(out.map((s) => s.id).sort()).toEqual(["a", "c"]); + }); + + it("returns empty when nothing matches", () => { + expect(filterServersByCategory(all, "infra")).toEqual([]); + }); +}); + +describe("filterServersByQuery", () => { + const all = [ + server({ id: "a", name: "Linear", description: "Ticket tracker" }), + server({ id: "b", name: "GitHub", description: "Code hosting" }), + server({ + id: "c", + name: "Notion", + description: "Docs and knowledge base", + }), + ]; + + it("returns all when query is empty or whitespace", () => { + expect(filterServersByQuery(all, "")).toHaveLength(3); + expect(filterServersByQuery(all, " ")).toHaveLength(3); + }); + + it("matches against name", () => { + expect(filterServersByQuery(all, "linear").map((s) => s.id)).toEqual(["a"]); + }); + + it("matches against description", () => { + expect(filterServersByQuery(all, "tracker").map((s) => s.id)).toEqual([ + "a", + ]); + }); + + it("is case insensitive", () => { + expect(filterServersByQuery(all, "NOTION").map((s) => s.id)).toEqual(["c"]); + }); + + it("returns empty when nothing matches", () => { + expect(filterServersByQuery(all, "zzz")).toEqual([]); + }); +}); diff --git a/packages/core/src/mcp-servers/filters.ts b/packages/core/src/mcp-servers/filters.ts new file mode 100644 index 0000000000..cab7a152ba --- /dev/null +++ b/packages/core/src/mcp-servers/filters.ts @@ -0,0 +1,47 @@ +import type { + McpCategory, + McpRecommendedServer, + McpServerInstallation, +} from "@posthog/api-client/types"; + +export function filterServersByCategory( + servers: McpRecommendedServer[], + category: McpCategory | "all", +): McpRecommendedServer[] { + if (category === "all") return servers; + return servers.filter((s) => s.category === category); +} + +export function filterServersByQuery( + servers: McpRecommendedServer[], + query: string, +): McpRecommendedServer[] { + const q = query.trim().toLowerCase(); + if (!q) return servers; + return servers.filter( + (s) => + s.name.toLowerCase().includes(q) || + s.description?.toLowerCase().includes(q), + ); +} + +export function filterInstallationsByQuery( + installations: McpServerInstallation[], + templatesById: Map<string, McpRecommendedServer>, + query: string, +): McpServerInstallation[] { + const q = query.trim().toLowerCase(); + if (!q) return installations; + return installations.filter((i) => { + const template = i.template_id ? templatesById.get(i.template_id) : null; + const fields = [ + i.display_name, + i.name, + i.url, + i.description, + template?.name, + template?.description, + ]; + return fields.some((f) => f?.toLowerCase().includes(q)); + }); +} 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<InstallResult>; + 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<InstallResult>; + authorizeMcpInstallation(options: { + installation_id: string; + install_source?: "posthog" | "posthog-code"; + posthog_code_callback_url?: string; + }): Promise<OAuthRedirect>; +} + +export interface IOAuthCallback { + getCallbackUrl(): Promise<{ callbackUrl: string }>; + openAndWaitForCallback(args: { + redirectUrl: string; + }): Promise<OAuthCallbackResult>; +} + +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<OAuthCallbackResult> { + 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<OAuthCallbackResult> { + 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<OAuthCallbackResult> { + 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> = {}, +): 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>, +): 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<string, McpRecommendedServer>(); + 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<string, McpRecommendedServer>, +): 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/packages/core/src/mcp-servers/status.test.ts b/packages/core/src/mcp-servers/status.test.ts new file mode 100644 index 0000000000..8b6d02c9ca --- /dev/null +++ b/packages/core/src/mcp-servers/status.test.ts @@ -0,0 +1,47 @@ +import type { McpServerInstallation } from "@posthog/api-client/types"; +import { describe, expect, it } from "vitest"; +import { getInstallationStatus } from "./status"; + +function makeInstallation( + overrides: Partial<McpServerInstallation> = {}, +): McpServerInstallation { + return { + id: "inst-1", + template_id: null, + name: "Test", + 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, + }; +} + +describe("getInstallationStatus", () => { + it("returns connected for a live installation", () => { + expect(getInstallationStatus(makeInstallation())).toBe("connected"); + }); + + it("returns pending_oauth when the OAuth flow is incomplete", () => { + expect( + getInstallationStatus(makeInstallation({ pending_oauth: true })), + ).toBe("pending_oauth"); + }); + + it("returns needs_reauth when the server demands re-auth", () => { + expect( + getInstallationStatus(makeInstallation({ needs_reauth: true })), + ).toBe("needs_reauth"); + }); + + it("prefers pending_oauth over needs_reauth if both set", () => { + expect( + getInstallationStatus( + makeInstallation({ pending_oauth: true, needs_reauth: true }), + ), + ).toBe("pending_oauth"); + }); +}); 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/packages/core/src/mcp-servers/toolBulk.test.ts b/packages/core/src/mcp-servers/toolBulk.test.ts new file mode 100644 index 0000000000..a85ebb56ef --- /dev/null +++ b/packages/core/src/mcp-servers/toolBulk.test.ts @@ -0,0 +1,95 @@ +import type { McpInstallationTool } from "@posthog/api-client/types"; +import { describe, expect, it, vi } from "vitest"; +import { dispatchBulkApproval } from "./toolBulk"; + +function tool( + name: string, + overrides: Partial<McpInstallationTool> = {}, +): 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("dispatchBulkApproval", () => { + it("calls updateMcpToolApproval once per non-removed tool with the chosen state", async () => { + const update = vi.fn().mockResolvedValue(undefined); + const tools = [ + tool("a"), + tool("b"), + tool("c", { removed_at: "2026-04-01T00:00:00Z" }), + ]; + + await dispatchBulkApproval( + { updateMcpToolApproval: update }, + "inst-1", + tools, + "approved", + ); + + expect(update).toHaveBeenCalledTimes(2); + expect(update).toHaveBeenCalledWith("inst-1", "a", "approved"); + expect(update).toHaveBeenCalledWith("inst-1", "b", "approved"); + expect(update).not.toHaveBeenCalledWith( + expect.anything(), + "c", + expect.anything(), + ); + }); + + it("fires requests in parallel rather than sequentially", async () => { + let concurrent = 0; + let peak = 0; + const update = vi.fn(async () => { + concurrent += 1; + peak = Math.max(peak, concurrent); + await new Promise((r) => setTimeout(r, 5)); + concurrent -= 1; + }); + + await dispatchBulkApproval( + { updateMcpToolApproval: update }, + "inst-1", + [tool("a"), tool("b"), tool("c")], + "do_not_use", + ); + + expect(peak).toBeGreaterThan(1); + }); + + it("rejects if any update fails", async () => { + const update = vi.fn(async (_id: string, name: string) => { + if (name === "b") throw new Error("boom"); + }); + + await expect( + dispatchBulkApproval( + { updateMcpToolApproval: update }, + "inst-1", + [tool("a"), tool("b"), tool("c")], + "approved", + ), + ).rejects.toThrow("boom"); + }); + + it("is a no-op when the tool list is empty", async () => { + const update = vi.fn().mockResolvedValue(undefined); + await dispatchBulkApproval( + { updateMcpToolApproval: update }, + "inst-1", + [], + "approved", + ); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/mcp-servers/toolBulk.ts b/packages/core/src/mcp-servers/toolBulk.ts new file mode 100644 index 0000000000..214a9f2685 --- /dev/null +++ b/packages/core/src/mcp-servers/toolBulk.ts @@ -0,0 +1,35 @@ +import type { + McpApprovalState, + McpInstallationTool, +} from "@posthog/api-client/types"; + +interface ToolApprovalClient { + updateMcpToolApproval: ( + installationId: string, + toolName: string, + approval_state: McpApprovalState, + ) => Promise<unknown>; +} + +/** + * Fire a PATCH per non-removed tool in parallel. Returns once every request + * resolves (or rejects — callers should surface the error). + */ +export async function dispatchBulkApproval( + client: ToolApprovalClient, + installationId: string, + tools: McpInstallationTool[], + approval_state: McpApprovalState, +): Promise<void> { + await Promise.all( + tools + .filter((t) => !t.removed_at) + .map((t) => + client.updateMcpToolApproval( + installationId, + t.tool_name, + approval_state, + ), + ), + ); +} 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> = {}, +): 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<McpApprovalState, number> { + 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>, + ); +} + +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> = {}): 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/packages/core/src/message-editor/content.ts b/packages/core/src/message-editor/content.ts new file mode 100644 index 0000000000..07b8646a36 --- /dev/null +++ b/packages/core/src/message-editor/content.ts @@ -0,0 +1,221 @@ +import { escapeXmlAttr, unescapeXmlAttr } from "@posthog/shared"; + +export interface MentionChip { + type: + | "file" + | "folder" + | "command" + | "error" + | "experiment" + | "insight" + | "feature_flag" + | "github_issue" + | "github_pr"; + id: string; + label: string; + pastedText?: boolean; + chipId?: string; +} + +export interface FileAttachment { + id: string; + label: string; +} + +export interface EditorContent { + segments: Array< + { type: "text"; text: string } | { type: "chip"; chip: MentionChip } + >; + attachments?: FileAttachment[]; +} + +export function contentToPlainText(content: EditorContent): string { + return content.segments + .map((seg) => { + if (seg.type === "text") return seg.text; + const chip = seg.chip; + if (chip.type === "file" || chip.type === "folder") + return `@${chip.label}`; + if (chip.type === "command") return `/${chip.label}`; + return `@${chip.label}`; + }) + .join(""); +} + +function isAbsolutePathLike(p: string): boolean { + return p.startsWith("/") || p.startsWith("~") || /^[A-Za-z]:[\\/]/.test(p); +} + +export function contentToXml(content: EditorContent): string { + const inlineFilePaths = new Set<string>(); + const parts = content.segments.map((seg) => { + if (seg.type === "text") return seg.text; + const chip = seg.chip; + const escapedId = escapeXmlAttr(chip.id); + switch (chip.type) { + case "file": + inlineFilePaths.add(chip.id); + return `<file path="${escapedId}" />`; + case "folder": + inlineFilePaths.add(chip.id); + return `<folder path="${escapedId}" />`; + case "command": + if (chip.id && chip.id !== chip.label && isAbsolutePathLike(chip.id)) { + return `<folder path="${escapedId}" />`; + } + return `/${chip.label}`; + case "error": + return `<error id="${escapedId}" />`; + case "experiment": + return `<experiment id="${escapedId}" />`; + case "insight": + return `<insight id="${escapedId}" />`; + case "feature_flag": + return `<feature_flag id="${escapedId}" />`; + case "github_issue": + case "github_pr": { + const labelMatch = chip.label.match(/^#(\d+)(?:\s*-\s*(.*))?$/); + const number = labelMatch?.[1] ?? ""; + const title = labelMatch?.[2] ?? ""; + return `<${chip.type} number="${escapeXmlAttr(number)}" title="${escapeXmlAttr(title)}" url="${escapedId}" />`; + } + default: + return `@${chip.label}`; + } + }); + + // Append file tags for attachments not already referenced inline + if (content.attachments) { + for (const att of content.attachments) { + if (!inlineFilePaths.has(att.id)) { + parts.push(`<file path="${escapeXmlAttr(att.id)}" />`); + } + } + } + + return parts.join(""); +} + +const CHIP_TAG_REGEX = + /<(file|folder|error|experiment|insight|feature_flag|github_issue|github_pr)\b([^>]*?)\s*\/>/g; +const ATTR_REGEX = /(\w+)="([^"]*)"/g; + +export function deriveFileLabel(filePath: string): string { + const segments = filePath.split("/").filter(Boolean); + const fileName = segments.pop() ?? filePath; + const parentDir = segments.pop(); + return parentDir ? `${parentDir}/${fileName}` : fileName; +} + +function parseAttrs(raw: string): Record<string, string> { + const attrs: Record<string, string> = {}; + for (const match of raw.matchAll(ATTR_REGEX)) { + attrs[match[1]] = unescapeXmlAttr(match[2]); + } + return attrs; +} + +function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { + const attrs = parseAttrs(rawAttrs); + switch (tag) { + case "file": { + const path = attrs.path; + if (!path) return null; + return { type: "file", id: path, label: deriveFileLabel(path) }; + } + case "folder": { + const path = attrs.path; + if (!path) return null; + return { type: "folder", id: path, label: deriveFileLabel(path) }; + } + case "error": + case "experiment": + case "insight": + case "feature_flag": { + const id = attrs.id; + if (!id) return null; + return { type: tag, id, label: id }; + } + case "github_issue": + case "github_pr": { + const number = attrs.number ?? ""; + const title = attrs.title ?? ""; + const url = attrs.url ?? ""; + if (!number && !url) return null; + const label = title ? `#${number} - ${title}` : `#${number}`; + return { type: tag, id: url, label }; + } + default: + return null; + } +} + +export function xmlToContent(xml: string): EditorContent { + const segments: EditorContent["segments"] = []; + let lastIndex = 0; + + for (const match of xml.matchAll(CHIP_TAG_REGEX)) { + const matchIndex = match.index ?? 0; + const chip = chipFromTag(match[1], match[2] ?? ""); + if (!chip) continue; + + if (matchIndex > lastIndex) { + segments.push({ type: "text", text: xml.slice(lastIndex, matchIndex) }); + } + segments.push({ type: "chip", chip }); + lastIndex = matchIndex + match[0].length; + } + + if (lastIndex < xml.length) { + segments.push({ type: "text", text: xml.slice(lastIndex) }); + } + + if (segments.length === 0) { + segments.push({ type: "text", text: xml }); + } + + return { segments }; +} + +export function xmlToPlainText(xml: string): string { + return contentToPlainText(xmlToContent(xml)); +} + +export function isContentEmpty( + content: EditorContent | null | string, +): boolean { + if (!content) return true; + if (typeof content === "string") return !content.trim(); + if (content.attachments && content.attachments.length > 0) return false; + if (!content.segments) return true; + return content.segments.every( + (seg) => seg.type === "text" && !seg.text.trim(), + ); +} + +export function extractFilePaths(content: EditorContent): string[] { + const filePaths: string[] = []; + const seen = new Set<string>(); + + for (const seg of content.segments) { + if ( + seg.type === "chip" && + (seg.chip.type === "file" || seg.chip.type === "folder") && + !seen.has(seg.chip.id) + ) { + seen.add(seg.chip.id); + filePaths.push(seg.chip.id); + } + } + + if (content.attachments) { + for (const att of content.attachments) { + if (!seen.has(att.id)) { + seen.add(att.id); + filePaths.push(att.id); + } + } + } + + return filePaths; +} 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/packages/core/src/message-editor/githubIssueChip.ts b/packages/core/src/message-editor/githubIssueChip.ts new file mode 100644 index 0000000000..76b1026573 --- /dev/null +++ b/packages/core/src/message-editor/githubIssueChip.ts @@ -0,0 +1,52 @@ +import type { GithubRefState } from "@posthog/shared"; +import type { MentionChip } from "./content"; +import type { ParsedGithubIssueUrl } from "./githubIssueUrl"; + +export interface GithubIssueChipSource { + number: number; + title: string; + url: string; +} + +export function githubIssueToMentionChip( + issue: GithubIssueChipSource, +): MentionChip { + return { + type: "github_issue", + id: issue.url, + label: `#${issue.number} - ${issue.title}`, + }; +} + +export function githubPullRequestToMentionChip( + pr: GithubIssueChipSource, +): MentionChip { + return { + type: "github_pr", + id: pr.url, + label: `#${pr.number} - ${pr.title}`, + }; +} + +export const GITHUB_ISSUE_STATE_COLORS: Record<GithubRefState, string> = { + OPEN: "#238636", + CLOSED: "#AB7DF8", + MERGED: "#8957E5", +}; + +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/packages/core/src/message-editor/githubIssueUrl.ts b/packages/core/src/message-editor/githubIssueUrl.ts new file mode 100644 index 0000000000..094eab0c1d --- /dev/null +++ b/packages/core/src/message-editor/githubIssueUrl.ts @@ -0,0 +1,33 @@ +import type { GithubRefKind } from "@posthog/shared"; + +export type { GithubRefKind }; + +export interface ParsedGithubIssueUrl { + kind: GithubRefKind; + owner: string; + repo: string; + number: number; + normalizedUrl: string; +} + +const GITHUB_ISSUE_URL_PATTERN = + /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/(issues|pull)\/(\d+)(?:[/?#].*)?$/; + +export function parseGithubIssueUrl(text: string): ParsedGithubIssueUrl | null { + const trimmed = text.trim(); + const match = trimmed.match(GITHUB_ISSUE_URL_PATTERN); + if (!match) return null; + + const [, owner, repo, segment, rawNumber] = match; + const number = Number(rawNumber); + if (!Number.isInteger(number) || number <= 0) return null; + + const kind: GithubRefKind = segment === "pull" ? "pr" : "issue"; + return { + kind, + owner, + repo, + number, + normalizedUrl: `https://github.com/${owner}/${repo}/${segment}/${number}`, + }; +} 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/packages/core/src/message-editor/persistFile.test.ts b/packages/core/src/message-editor/persistFile.test.ts new file mode 100644 index 0000000000..e3b2d99570 --- /dev/null +++ b/packages/core/src/message-editor/persistFile.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockSaveClipboardImage = vi.hoisted(() => vi.fn()); +const mockSaveClipboardText = vi.hoisted(() => vi.fn()); +const mockSaveClipboardFile = vi.hoisted(() => vi.fn()); +const mockDownscaleImageFile = vi.hoisted(() => vi.fn()); + +vi.mock("@posthog/shared", async () => { + const actual = + await vi.importActual<typeof import("@posthog/shared")>("@posthog/shared"); + return { ...actual, getImageMimeType: () => "image/png" }; +}); + +import { + arrayBufferToBase64, + type FilePersistHost, + persistBrowserFile, + persistImageFile, + persistImageFilePath, + persistTextContent, + 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(); + }); + + it("passes original text filenames through clipboard persistence", async () => { + mockSaveClipboardText.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-123/notes.md", + name: "notes.md", + }); + + const result = await persistTextContent(host, "# hello", "notes.md"); + + expect(mockSaveClipboardText).toHaveBeenCalledWith({ + text: "# hello", + originalName: "notes.md", + }); + expect(result).toEqual({ + path: "/tmp/posthog-code-clipboard/attachment-123/notes.md", + name: "notes.md", + }); + }); + + it("persists image files via saveClipboardImage", async () => { + mockSaveClipboardImage.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-789/photo.png", + name: "photo.png", + mimeType: "image/png", + }); + + const file = { + name: "photo.png", + type: "image/png", + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), + } as unknown as File; + + const result = await persistImageFile(host, file); + + expect(mockSaveClipboardImage).toHaveBeenCalledWith( + expect.objectContaining({ + mimeType: "image/png", + originalName: "photo.png", + }), + ); + expect(result).toEqual({ + path: "/tmp/posthog-code-clipboard/attachment-789/photo.png", + name: "photo.png", + mimeType: "image/png", + }); + }); + + it("routes image files through persistBrowserFile", async () => { + mockSaveClipboardImage.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-abc/img.png", + name: "img.png", + mimeType: "image/png", + }); + + const file = { + name: "img.png", + type: "image/png", + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), + } as unknown as File; + + const result = await persistBrowserFile(host, file); + + expect(result).toEqual({ + id: "/tmp/posthog-code-clipboard/attachment-abc/img.png", + label: "img.png", + }); + }); + + it("persists arbitrary non-image files via saveClipboardFile", async () => { + mockSaveClipboardFile.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-def/archive.zip", + name: "archive.zip", + }); + + const file = { + name: "archive.zip", + type: "application/zip", + arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)), + } as unknown as File; + + await expect(persistBrowserFile(host, file)).resolves.toEqual({ + id: "/tmp/posthog-code-clipboard/attachment-def/archive.zip", + label: "archive.zip", + }); + + expect(mockSaveClipboardFile).toHaveBeenCalledWith({ + base64Data: expect.any(String), + originalName: "archive.zip", + }); + }); +}); + +describe("persistImageFilePath", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls downscaleImageFile and returns { id, label }", async () => { + mockDownscaleImageFile.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-aaa/photo.jpg", + name: "photo.jpg", + mimeType: "image/jpeg", + }); + + const result = await persistImageFilePath( + host, + "/Users/me/Desktop/photo.png", + ); + + expect(mockDownscaleImageFile).toHaveBeenCalledWith({ + filePath: "/Users/me/Desktop/photo.png", + }); + expect(result).toEqual({ + id: "/tmp/posthog-code-clipboard/attachment-aaa/photo.jpg", + label: "photo.jpg", + }); + }); + + it("propagates errors from downscaleImageFile", async () => { + mockDownscaleImageFile.mockRejectedValue(new Error("Image too large")); + + await expect(persistImageFilePath(host, "/big/image.png")).rejects.toThrow( + "Image too large", + ); + }); +}); + +describe("resolveDroppedFile", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null when filePath is empty", async () => { + const file = { name: "test.txt" } as File; + expect(await resolveDroppedFile(host, file, "")).toBeNull(); + }); + + it("returns file attachment directly for non-image files", async () => { + const file = { name: "doc.pdf" } as 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 () => { + mockDownscaleImageFile.mockResolvedValue({ + path: "/tmp/posthog-code-clipboard/attachment-bbb/photo.jpg", + name: "photo.jpg", + mimeType: "image/jpeg", + }); + + const file = { name: "photo.png" } as File; + const result = await resolveDroppedFile(host, file, "/Users/me/photo.png"); + + expect(mockDownscaleImageFile).toHaveBeenCalledWith({ + filePath: "/Users/me/photo.png", + }); + expect(result).toEqual({ + id: "/tmp/posthog-code-clipboard/attachment-bbb/photo.jpg", + label: "photo.jpg", + }); + }); + + 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(host, file, "/Users/me/corrupt.png", { + onDownscaleFailed, + }), + ).toEqual({ + id: "/Users/me/corrupt.png", + label: "corrupt.png", + }); + expect(onDownscaleFailed).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core/src/message-editor/persistFile.ts b/packages/core/src/message-editor/persistFile.ts new file mode 100644 index 0000000000..96584418ce --- /dev/null +++ b/packages/core/src/message-editor/persistFile.ts @@ -0,0 +1,127 @@ +import { getImageMimeType, isRasterImageFile } from "@posthog/shared"; +import type { FileAttachment } from "./content"; + +const CHUNK_SIZE = 8192; + +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + const chunks: string[] = []; + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE))); + } + return btoa(chunks.join("")); +} + +export interface PersistedFile { + path: string; + name: string; + mimeType?: string; +} + +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<PersistedFile> { + const arrayBuffer = await file.arrayBuffer(); + const base64Data = arrayBufferToBase64(arrayBuffer); + const mimeType = file.type || getImageMimeType(file.name); + + const result = await host.saveClipboardImage({ + base64Data, + mimeType, + originalName: file.name, + }); + return { path: result.path, name: result.name, mimeType: result.mimeType }; +} + +export async function persistTextContent( + host: FilePersistHost, + text: string, + originalName?: string, +): Promise<PersistedFile> { + const result = await host.saveClipboardText({ text, originalName }); + return { path: result.path, name: result.name }; +} + +export async function persistGenericFile( + host: FilePersistHost, + file: File, +): Promise<PersistedFile> { + const arrayBuffer = await file.arrayBuffer(); + const base64Data = arrayBufferToBase64(arrayBuffer); + + const result = await host.saveClipboardFile({ + base64Data, + originalName: file.name, + }); + + return { + path: result.path, + name: result.name, + mimeType: file.type || undefined, + }; +} + +export async function persistImageFilePath( + host: FilePersistHost, + filePath: string, +): Promise<{ id: string; label: string }> { + 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<FileAttachment | null> { + if (!filePath) return null; + + if (isRasterImageFile(file.name)) { + try { + return await persistImageFilePath(host, filePath); + } catch { + options?.onDownscaleFailed?.(); + return { id: filePath, label: file.name }; + } + } + + return { id: filePath, label: file.name }; +} + +export async function persistBrowserFile( + host: FilePersistHost, + file: File, +): Promise<{ id: string; label: string }> { + if (file.type.startsWith("image/")) { + const result = await persistImageFile(host, file); + return { id: result.path, label: result.name }; + } + + 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<T extends CommandLike> { + id: string; + label: string; + description?: string; + command: T; +} + +const COMMAND_FUSE_OPTIONS: IFuseOptions<CommandLike> = { + keys: [ + { name: "name", weight: 0.7 }, + { name: "description", weight: 0.3 }, + ], + threshold: 0.3, + includeScore: true, +}; + +export function searchCommands<T extends CommandLike>( + 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<T extends CommandLike>( + codeCommands: T[], + agentCommands: T[], +): T[] { + const merged = [...codeCommands, ...agentCommands]; + return [...new Map(merged.map((cmd) => [cmd.name, cmd])).values()]; +} + +export function shapeCommandSuggestions<T extends CommandLike>( + commands: T[], +): CommandSuggestionShape<T>[] { + 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/packages/core/src/notification/notification.ts b/packages/core/src/notification/notification.ts new file mode 100644 index 0000000000..8abaa9a3ed --- /dev/null +++ b/packages/core/src/notification/notification.ts @@ -0,0 +1,85 @@ +import { + type IMainWindow, + MAIN_WINDOW_SERVICE, +} from "@posthog/platform/main-window"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { type INotifier, NOTIFIER_SERVICE } from "@posthog/platform/notifier"; +import { inject, injectable, postConstruct } from "inversify"; +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(TASK_LINK_SERVICE) + private readonly taskLinkService: TaskLinkService, + @inject(NOTIFIER_SERVICE) + private readonly notifier: INotifier, + @inject(MAIN_WINDOW_SERVICE) + private readonly mainWindow: IMainWindow, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + this.log = logger.scope("notification"); + } + + @postConstruct() + init(): void { + this.mainWindow.onFocus(() => this.clearDockBadge()); + } + + send(title: string, body: string, silent: boolean, taskId?: string): void { + if (!this.notifier.isSupported()) { + this.log.warn("Notifications not supported on this platform"); + return; + } + + this.notifier.notify({ + title, + body, + silent, + onClick: () => { + this.log.info("Notification clicked, focusing window", { + title, + taskId, + }); + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + + if (taskId) { + this.taskLinkService.emit(TaskLinkEvent.OpenTask, { taskId }); + this.log.info("Notification clicked, navigating to task", { taskId }); + } + }, + }); + this.log.info("Notification sent", { title, body, silent, taskId }); + } + + showDockBadge(): void { + if (this.hasBadge) return; + this.hasBadge = true; + this.notifier.setUnreadIndicator(true); + this.log.info("Dock badge shown"); + } + + bounceDock(): void { + this.notifier.requestAttention(); + this.log.info("Dock bounce triggered"); + } + + private clearDockBadge(): void { + if (!this.hasBadge) return; + this.hasBadge = false; + this.notifier.setUnreadIndicator(false); + 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<string>; +} + +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<OAuthEnv> = {}) { + 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/packages/core/src/oauth/oauth.ts b/packages/core/src/oauth/oauth.ts new file mode 100644 index 0000000000..825ad4fc89 --- /dev/null +++ b/packages/core/src/oauth/oauth.ts @@ -0,0 +1,462 @@ +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, + sleepWithBackoff, +} from "@posthog/shared"; +import { inject, injectable } from "inversify"; +import { OAUTH_HOST, type OAuthHost } from "./identifiers"; +import type { + CancelFlowOutput, + CloudRegion, + OAuthTokenResponse, + RefreshTokenOutput, + StartFlowOutput, +} from "./schemas"; + +const OAUTH_TIMEOUT_MS = 180_000; // 3 minutes +const DEV_CALLBACK_PORT = 8237; + +const NETWORK_ERROR_MESSAGE = + "Could not connect to PostHog. Please check your internet connection and try again."; + +const TOKEN_FETCH_MAX_ATTEMPTS = 3; +const TOKEN_FETCH_BACKOFF: BackoffOptions = { + initialDelayMs: 1_000, + maxDelayMs: 5_000, + multiplier: 2, +}; + +interface OAuthConfig { + scopes: string[]; + cloudRegion: CloudRegion; +} + +interface PendingOAuthFlow { + codeVerifier: string; + config: OAuthConfig; + resolve: (code: string) => void; + reject: (error: Error) => void; + timeoutId?: NodeJS.Timeout; + abortController?: AbortController; +} + +@injectable() +export class OAuthService { + private pendingFlow: PendingOAuthFlow | null = null; + private readonly log: ScopedLogger; + + constructor( + @inject(DEEP_LINK_SERVICE) + private readonly deepLinkService: IDeepLinkRegistry, + @inject(URL_LAUNCHER_SERVICE) + private readonly urlLauncher: IUrlLauncher, + @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), + ); + } + + private handleOAuthCallback(searchParams: URLSearchParams): boolean { + const code = searchParams.get("code"); + const error = searchParams.get("error"); + + 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. + this.log.info( + "OAuth callback deep link with no in-app flow — refocusing (e.g. return from web auth)", + ); + 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; + } + + const { resolve, reject, timeoutId } = this.pendingFlow; + clearTimeout(timeoutId); + this.pendingFlow = null; + + if (error) { + reject(new Error(`OAuth error: ${error}`)); + return true; + } + + if (code) { + resolve(code); + return true; + } + + reject(new Error("OAuth callback missing code")); + return true; + } + + /** + * Get the redirect URI based on environment. + */ + private getRedirectUri(): string { + return this.host.isDev + ? `http://localhost:${DEV_CALLBACK_PORT}/callback` + : `${this.deepLinkService.getProtocol()}://callback`; + } + + /** + * Start the OAuth flow. + * Uses HTTP callback in development, deep links in production. + */ + public async startFlow(region: CloudRegion): Promise<StartFlowOutput> { + try { + // Cancel any existing flow + this.cancelFlow(); + + const config: OAuthConfig = { + scopes: OAUTH_SCOPES, + cloudRegion: region, + }; + + const codeVerifier = this.generateCodeVerifier(); + const authUrl = this.buildAuthorizeUrl(region, codeVerifier); + + return await this.startFlowWithUrl( + config, + codeVerifier, + authUrl.toString(), + ); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * Start the OAuth flow from the signup page. + */ + public async startSignupFlow(region: CloudRegion): Promise<StartFlowOutput> { + try { + // Cancel any existing flow + this.cancelFlow(); + + const config: OAuthConfig = { + scopes: OAUTH_SCOPES, + cloudRegion: region, + }; + + const codeVerifier = this.generateCodeVerifier(); + const authUrl = this.buildAuthorizeUrl(region, codeVerifier); + const signupUrl = this.buildSignupUrl(region, authUrl); + + return await this.startFlowWithUrl( + config, + codeVerifier, + signupUrl.toString(), + ); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * Refresh an access token using a refresh token. + */ + public async refreshToken( + refreshToken: string, + region: CloudRegion, + ): Promise<RefreshTokenOutput> { + try { + const cloudUrl = getCloudUrlFromRegion(region); + + const response = await fetch(`${cloudUrl}/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: getOauthClientIdFromRegion(region), + }), + }); + + if (!response.ok) { + // 401/403 are auth errors - the token is invalid + const isAuthError = response.status === 401 || response.status === 403; + // 5xx are server errors - should be retried + const isServerError = response.status >= 500; + this.log.warn( + `Token refresh failed: ${response.status} ${response.statusText}`, + ); + return { + success: false, + error: `Token refresh failed: ${response.status} ${response.statusText}`, + errorCode: isAuthError + ? "auth_error" + : isServerError + ? "server_error" + : "unknown_error", + }; + } + + const tokenResponse = (await response.json()) as OAuthTokenResponse; + + return { + success: true, + data: tokenResponse, + }; + } catch { + return { + success: false, + error: NETWORK_ERROR_MESSAGE, + errorCode: "network_error", + }; + } + } + + /** + * Cancel any pending OAuth flow. + */ + public cancelFlow(): CancelFlowOutput { + try { + if (this.pendingFlow) { + if (this.pendingFlow.abortController) { + // Dev HTTP-callback path: stop the workspace-server callback server. + this.pendingFlow.abortController.abort(); + this.pendingFlow = null; + } else { + if (this.pendingFlow.timeoutId) { + clearTimeout(this.pendingFlow.timeoutId); + } + this.pendingFlow.reject(new Error("OAuth flow cancelled")); + this.pendingFlow = null; + } + } + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * Wait for OAuth callback via deep link (production). + */ + private async waitForDeepLinkCallback( + codeVerifier: string, + config: OAuthConfig, + authUrl: string, + ): Promise<string> { + return new Promise<string>((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pendingFlow = null; + reject(new Error("Authorization timed out")); + }, OAUTH_TIMEOUT_MS); + + this.pendingFlow = { + codeVerifier, + config, + resolve, + reject, + timeoutId, + }; + + // Open the browser for authentication + this.urlLauncher.launch(authUrl).catch((error) => { + clearTimeout(timeoutId); + this.pendingFlow = null; + reject(new Error(`Failed to open browser: ${error.message}`)); + }); + }); + } + + /** + * Wait for OAuth callback via the workspace-server HTTP server (development). + */ + private async waitForHttpCallback( + codeVerifier: string, + config: OAuthConfig, + authUrl: string, + ): Promise<string> { + const abortController = new AbortController(); + this.pendingFlow = { + codeVerifier, + config, + resolve: () => {}, + reject: () => {}, + abortController, + }; + + 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; + } + } + + private async exchangeCodeForToken( + code: string, + codeVerifier: string, + config: OAuthConfig, + ): Promise<OAuthTokenResponse> { + const cloudUrl = getCloudUrlFromRegion(config.cloudRegion); + const redirectUri = this.getRedirectUri(); + const body = JSON.stringify({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: getOauthClientIdFromRegion(config.cloudRegion), + code_verifier: codeVerifier, + }); + + let lastError = "Token exchange failed"; + + for (let attempt = 0; attempt < TOKEN_FETCH_MAX_ATTEMPTS; attempt++) { + let response: Response; + try { + response = await fetch(`${cloudUrl}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + } catch (error) { + // fetch threw — DNS/TLS/socket failure. The raw message ("Failed to fetch", + // "fetch failed", "terminated", etc.) leaks to the UI as-is, so we replace + // it with something users can act on. + lastError = NETWORK_ERROR_MESSAGE; + this.log.warn("Token exchange network error", { + attempt, + error: error instanceof Error ? error.message : String(error), + }); + if (attempt === TOKEN_FETCH_MAX_ATTEMPTS - 1) break; + await sleepWithBackoff(attempt, TOKEN_FETCH_BACKOFF); + continue; + } + + if (response.ok) { + return (await response.json()) as OAuthTokenResponse; + } + + lastError = `Token exchange failed: ${response.status} ${response.statusText}`; + const isServerError = response.status >= 500; + if (!isServerError) { + throw new Error(lastError); + } + + this.log.warn("Token exchange server error", { + attempt, + status: response.status, + }); + if (attempt === TOKEN_FETCH_MAX_ATTEMPTS - 1) break; + await sleepWithBackoff(attempt, TOKEN_FETCH_BACKOFF); + } + + throw new Error(lastError); + } + + private buildAuthorizeUrl(region: CloudRegion, codeVerifier: string): URL { + const codeChallenge = this.generateCodeChallenge(codeVerifier); + const redirectUri = this.getRedirectUri(); + const cloudUrl = getCloudUrlFromRegion(region); + const authUrl = new URL(`${cloudUrl}/oauth/authorize`); + authUrl.searchParams.set("client_id", getOauthClientIdFromRegion(region)); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + authUrl.searchParams.set("scope", OAUTH_SCOPES.join(" ")); + authUrl.searchParams.set("required_access_level", "project"); + return authUrl; + } + + private buildSignupUrl(region: CloudRegion, authUrl: URL): URL { + const cloudUrl = getCloudUrlFromRegion(region); + const signupUrl = new URL(`${cloudUrl}/signup`); + const nextPath = `${authUrl.pathname}${authUrl.search}`; + signupUrl.searchParams.set("next", nextPath); + return signupUrl; + } + + private async startFlowWithUrl( + config: OAuthConfig, + codeVerifier: string, + authUrl: string, + ): Promise<StartFlowOutput> { + const code = this.host.isDev + ? await this.waitForHttpCallback(codeVerifier, config, authUrl) + : await this.waitForDeepLinkCallback(codeVerifier, config, authUrl); + + const tokenResponse = await this.exchangeCodeForToken( + code, + codeVerifier, + config, + ); + + return { + success: true, + data: tokenResponse, + }; + } + + private generateCodeVerifier(): string { + return this.crypto.randomBase64Url(32); + } + + private generateCodeChallenge(verifier: string): string { + return this.crypto.sha256Base64Url(verifier); + } + + /** + * Open an external URL in the default browser. + */ + public async openExternalUrl(url: string): Promise<void> { + await this.urlLauncher.launch(url); + } +} 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<void>((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<void>((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<void> { + 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<void>, + ): Promise<void> { + 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<void>; +} + +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<TIntegration>[]; + projectsWithGithub: ProjectWithIntegrations<TIntegration>[]; +} { + const projectsWithIntegrations = projects + .map((project, index) => { + const integrations = integrationsByIndex[index] ?? []; + const hasGithubIntegration = integrations.some( + (integration) => integration.kind === "github", + ); + return { + ...project, + integrations, + hasGithubIntegration, + } as ProjectWithIntegrations<TIntegration>; + }) + .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/packages/core/src/panels/panelConstants.ts b/packages/core/src/panels/panelConstants.ts new file mode 100644 index 0000000000..d7e300f2fa --- /dev/null +++ b/packages/core/src/panels/panelConstants.ts @@ -0,0 +1,21 @@ +export const PANEL_SIZES = { + MIN_PANEL_SIZE: 15, + DEFAULT_SPLIT: [70, 30] as const, + EVEN_SPLIT: [50, 50] as const, + SIZE_DIFF_THRESHOLD: 0.1, +} as const; + +export const DEFAULT_PANEL_IDS = { + ROOT: "root", + MAIN_PANEL: "main-panel", + RIGHT_GROUP: "right-group", + TOP_RIGHT: "top-right", + BOTTOM_RIGHT: "bottom-right", +} as const; + +export const DEFAULT_TAB_IDS = { + LOGS: "logs", + SHELL: "shell", + FILES: "files", + CHANGES: "changes", +} as const; 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>, +): 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<TaskLayout> { + 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<TaskLayout> { + 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<TaskLayout> { + 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<TaskLayout> { + 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<TaskLayout> { + 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<TaskLayout> { + 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<TaskLayout> { + 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<TaskLayout> { + 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<TaskLayout> { + 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<Pick<Tab, "hasUnsavedChanges">>, +): Partial<TaskLayout> { + 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<TaskLayout> { + 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<TaskLayout> { + 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<TaskLayout> { + 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<TaskLayout> { + 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/packages/core/src/panels/panelSizeMath.ts b/packages/core/src/panels/panelSizeMath.ts new file mode 100644 index 0000000000..0a6bd44b13 --- /dev/null +++ b/packages/core/src/panels/panelSizeMath.ts @@ -0,0 +1,75 @@ +import { PANEL_SIZES } from "./panelConstants"; +import type { GroupPanel } from "./panelTypes"; + +const MIN_PANEL_SIZE = 15; + +export const normalizeSizes = ( + sizes: number[], + childCount: number, +): number[] => { + if (!sizes?.length) { + return new Array(childCount).fill(100 / childCount); + } + + const normalized = [...sizes]; + while (normalized.length < childCount) normalized.push(100 / childCount); + if (normalized.length > childCount) normalized.length = childCount; + + const validSizes = normalized.map((size) => + size > 0 ? size : MIN_PANEL_SIZE, + ); + const total = validSizes.reduce((sum, size) => sum + size, 0); + + if (total === 0) return new Array(childCount).fill(100 / childCount); + + const scaled = validSizes.map((size) => (size / total) * 100); + const withMinimums = scaled.map((size) => Math.max(size, MIN_PANEL_SIZE)); + const finalTotal = withMinimums.reduce((sum, size) => sum + size, 0); + + return withMinimums.map((size) => (size / finalTotal) * 100); +}; + +export const calculateSplitSizes = (): [number, number] => [50, 50]; + +export const redistributeSizes = ( + sizes: number[], + removedIndex: number, +): number[] => { + if (sizes.length <= 1) return [100]; + + const removedSize = sizes[removedIndex] ?? 0; + const remainingSizes = sizes.filter((_, i) => i !== removedIndex); + + if (!remainingSizes.length) return [100]; + + const remainingTotal = remainingSizes.reduce((sum, size) => sum + size, 0); + + if (remainingTotal === 0) { + return new Array(remainingSizes.length).fill(100 / remainingSizes.length); + } + + const redistributed = remainingSizes.map((size) => { + const proportion = size / remainingTotal; + return size + removedSize * proportion; + }); + + 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/packages/core/src/panels/panelStoreHelpers.ts b/packages/core/src/panels/panelStoreHelpers.ts new file mode 100644 index 0000000000..2b41453240 --- /dev/null +++ b/packages/core/src/panels/panelStoreHelpers.ts @@ -0,0 +1,223 @@ +import { DEFAULT_TAB_IDS } from "./panelConstants"; +import type { + GroupPanel, + LeafPanel, + PanelNode, + SplitDirection, + Tab, + TaskLayout, +} from "./panelTypes"; + +export const DEFAULT_FALLBACK_TAB = DEFAULT_TAB_IDS.LOGS; + +export type TabType = "file" | "system"; + +export interface ParsedTabId { + type: TabType; + value: string; +} + +export function createFileTabId(filePath: string): string { + return `file-${filePath}`; +} + +export function parseTabId(tabId: string): ParsedTabId & { status?: string } { + if (tabId.startsWith("file-")) { + return { type: "file", value: tabId.slice(5) }; + } + return { type: "system", value: tabId }; +} + +export function createTabLabel(tabId: string): string { + const parsed = parseTabId(tabId); + if (parsed.type === "file") { + return parsed.value.split("/").pop() || parsed.value; + } + return parsed.value; +} + +export function findPanelById( + node: PanelNode, + panelId: string, +): PanelNode | null { + if (node.id === panelId) { + return node; + } + + if (node.type === "group") { + for (const child of node.children) { + const found = findPanelById(child, panelId); + if (found) return found; + } + } + + return null; +} + +export function getLeafPanel( + tree: PanelNode, + panelId: string, +): LeafPanel | null { + const panel = findPanelById(tree, panelId); + return panel?.type === "leaf" ? panel : null; +} + +export function getGroupPanel( + tree: PanelNode, + panelId: string, +): GroupPanel | null { + const panel = findPanelById(tree, panelId); + return panel?.type === "group" ? panel : null; +} + +let nextPanelId = 1; + +export function generatePanelId(): string { + return `panel-${nextPanelId++}`; +} + +export function resetPanelIdCounter(): void { + nextPanelId = 1; +} + +export function createNewTab( + tabId: string, + closeable = true, + isPreview = false, +): Tab { + const parsed = parseTabId(tabId); + let data: Tab["data"]; + + switch (parsed.type) { + case "file": + data = { + type: "file", + relativePath: parsed.value, + absolutePath: "", + repoPath: "", + }; + break; + case "system": + if (tabId === "logs") { + data = { type: "logs" }; + } else if (tabId.startsWith("shell")) { + data = { + type: "terminal", + terminalId: tabId, + cwd: "", + }; + } else { + data = { type: "other" }; + } + break; + default: + data = { type: "other" }; + } + + return { + id: tabId, + label: createTabLabel(tabId), + data, + component: null, + closeable, + draggable: true, + isPreview, + }; +} + +export function addNewTabToPanel( + panel: PanelNode, + tabId: string, + closeable = true, + isPreview = false, +): PanelNode { + if (panel.type !== "leaf") return panel; + + const tabs = isPreview + ? panel.content.tabs.filter((tab) => !tab.isPreview) + : panel.content.tabs; + + return { + ...panel, + content: { + ...panel.content, + tabs: [...tabs, createNewTab(tabId, closeable, isPreview)], + activeTabId: tabId, + }, + }; +} + +export function selectNextTabAfterClose( + tabs: Tab[], + closedTabIndex: number, + activeTabId: string, + closedTabId: string, +): string { + if (activeTabId !== closedTabId) { + return activeTabId; + } + + if (tabs.length === 0) { + return DEFAULT_FALLBACK_TAB; + } + + const nextIndex = Math.min(closedTabIndex, tabs.length - 1); + return tabs[nextIndex].id; +} + +export interface SplitConfig { + splitDirection: "horizontal" | "vertical"; + isAfter: boolean; +} + +export function getSplitConfig(direction: SplitDirection): SplitConfig { + const horizontalDirections: SplitDirection[] = ["left", "right"]; + const afterDirections: SplitDirection[] = ["right", "bottom"]; + + return { + splitDirection: horizontalDirections.includes(direction) + ? "horizontal" + : "vertical", + isAfter: afterDirections.includes(direction), + }; +} + +export function updateMetadataForTab( + layout: TaskLayout, + tabId: string, + action: "add" | "remove", +): Pick<TaskLayout, "openFiles"> { + const parsed = parseTabId(tabId); + + if (parsed.type === "file") { + const openFiles = + action === "add" + ? [...layout.openFiles, parsed.value] + : layout.openFiles.filter((f) => f !== parsed.value); + return { openFiles }; + } + + return { openFiles: layout.openFiles }; +} + +export function applyCleanupWithFallback( + cleanedTree: PanelNode | null, + originalTree: PanelNode, +): PanelNode { + return cleanedTree || originalTree; +} + +export function isTabActiveInTree(tree: PanelNode, tabId: string): boolean { + if (tree.type === "leaf") { + return tree.content.activeTabId === tabId; + } + return tree.children.some((child) => isTabActiveInTree(child, tabId)); +} + +export function isFileTabActiveInTree( + tree: PanelNode, + filePath: string, +): boolean { + const tabId = createFileTabId(filePath); + return isTabActiveInTree(tree, tabId); +} diff --git a/packages/core/src/panels/panelTree.ts b/packages/core/src/panels/panelTree.ts new file mode 100644 index 0000000000..ce790737bb --- /dev/null +++ b/packages/core/src/panels/panelTree.ts @@ -0,0 +1,232 @@ +import { normalizeSizes, redistributeSizes } from "./panelSizeMath"; +import type { PanelNode, Tab } from "./panelTypes"; + +const isLeafNode = ( + node: PanelNode | null, +): node is Extract<PanelNode, { type: "leaf" }> => node?.type === "leaf"; + +const isGroupNode = ( + node: PanelNode | null, +): node is Extract<PanelNode, { type: "group" }> => node?.type === "group"; + +export const removeTabFromPanel = ( + node: PanelNode, + tabId: string, +): PanelNode => { + if (!isLeafNode(node)) return node; + + const newTabs = node.content.tabs.filter((t) => t.id !== tabId); + const newActiveTabId = + node.content.activeTabId === tabId + ? newTabs[0]?.id || "" + : node.content.activeTabId; + + return { + ...node, + content: { ...node.content, tabs: newTabs, activeTabId: newActiveTabId }, + }; +}; + +export const addTabToPanel = (node: PanelNode, tab: Tab): PanelNode => { + if (!isLeafNode(node)) return node; + + return { + ...node, + content: { + ...node.content, + tabs: [...node.content.tabs, tab], + activeTabId: tab.id, + }, + }; +}; + +export const setActiveTabInPanel = ( + node: PanelNode, + tabId: string, +): PanelNode => { + if (!isLeafNode(node)) return node; + + return { + ...node, + content: { ...node.content, activeTabId: tabId }, + }; +}; + +export const findTabInPanel = ( + panel: Extract<PanelNode, { type: "leaf" }>, + tabId: string, +): Tab | undefined => panel.content.tabs.find((t) => t.id === tabId); + +export const findTabInTree = ( + node: PanelNode, + tabId: string, +): { panelId: string; tab: Tab } | null => { + if (node.type === "leaf") { + const tab = node.content.tabs.find((t) => t.id === tabId); + if (tab) { + return { panelId: node.id, tab }; + } + return null; + } + + if (node.type === "group") { + for (const child of node.children) { + const result = findTabInTree(child, tabId); + if (result) return result; + } + } + + return null; +}; + +export const updateTreeNode = ( + node: PanelNode, + targetId: string, + updateFn: (node: PanelNode) => PanelNode, +): PanelNode => { + if (node.id === targetId) return updateFn(node); + + if (isGroupNode(node)) { + return { + ...node, + children: node.children.map((child) => + updateTreeNode(child, targetId, updateFn), + ), + }; + } + + return node; +}; + +export const cleanupNode = (node: PanelNode): PanelNode | null => { + if (isLeafNode(node)) { + return node.content.tabs.length === 0 ? null : node; + } + + const childrenWithIndices = node.children.map((child, index) => ({ + child: cleanupNode(child), + originalIndex: index, + })); + + const cleanedWithIndices = childrenWithIndices.filter( + (item): item is { child: PanelNode; originalIndex: number } => + item.child !== null, + ); + + if (cleanedWithIndices.length === 0) return null; + if (cleanedWithIndices.length === 1) return cleanedWithIndices[0].child; + + let finalSizes = node.sizes; + + if (cleanedWithIndices.length < node.children.length) { + if (node.sizes) { + const removedIndices = new Set( + node.children + .map((_, i) => i) + .filter( + (i) => !cleanedWithIndices.some((item) => item.originalIndex === i), + ), + ); + + let newSizes = node.sizes; + for (const removedIndex of Array.from(removedIndices).sort( + (a, b) => b - a, + )) { + newSizes = redistributeSizes(newSizes, removedIndex); + } + finalSizes = newSizes; + } else { + finalSizes = normalizeSizes([], cleanedWithIndices.length); + } + } else if (!finalSizes || finalSizes.length !== cleanedWithIndices.length) { + finalSizes = normalizeSizes(finalSizes || [], cleanedWithIndices.length); + } + + return { + ...node, + children: cleanedWithIndices.map((item) => item.child), + sizes: finalSizes, + }; +}; + +export const mergeTreeContent = ( + existingTree: PanelNode, + newTree: PanelNode, +): PanelNode => { + if (existingTree.type !== newTree.type) { + return existingTree; + } + + if (isLeafNode(existingTree) && isLeafNode(newTree)) { + const newTabsMap = new Map( + newTree.content.tabs.map((tab) => [tab.id, tab]), + ); + const existingTabIds = new Set(existingTree.content.tabs.map((t) => t.id)); + + const updatedTabs = existingTree.content.tabs + .map((existingTab) => { + const newTab = newTabsMap.get(existingTab.id); + if (newTab) { + return { + ...existingTab, + component: newTab.component, + onClose: newTab.onClose, + onSelect: newTab.onSelect, + label: newTab.label, + icon: newTab.icon, + }; + } + return existingTab; + }) + .filter((tab) => newTabsMap.has(tab.id)); + + const newTabsToAdd = newTree.content.tabs.filter( + (tab) => !existingTabIds.has(tab.id), + ); + + const finalTabs = [...updatedTabs, ...newTabsToAdd]; + + const activeTabId = finalTabs.some( + (t) => t.id === existingTree.content.activeTabId, + ) + ? existingTree.content.activeTabId + : finalTabs[0]?.id || ""; + + return { + ...existingTree, + content: { + ...existingTree.content, + tabs: finalTabs, + activeTabId, + }, + }; + } + + if (isGroupNode(existingTree) && isGroupNode(newTree)) { + const mergedChildren = existingTree.children.map((existingChild, index) => { + const newChild = newTree.children[index]; + if (newChild) { + return mergeTreeContent(existingChild, newChild); + } + return existingChild; + }); + + const childrenChanged = mergedChildren.some( + (child, index) => child !== existingTree.children[index], + ); + + if (!childrenChanged) { + return existingTree; + } + + return { + ...existingTree, + children: mergedChildren, + }; + } + + return existingTree; +}; + +export const isLeaf = isLeafNode; +export const isGroup = isGroupNode; diff --git a/packages/core/src/panels/panelTypes.ts b/packages/core/src/panels/panelTypes.ts new file mode 100644 index 0000000000..88e9f2798c --- /dev/null +++ b/packages/core/src/panels/panelTypes.ts @@ -0,0 +1,85 @@ +export type PanelId = string; +export type TabId = string; +export type GroupId = string; + +export type TabData = + | { + type: "file"; + relativePath: string; + absolutePath: string; + repoPath: string; + } + | { + type: "terminal"; + terminalId: string; + cwd: string; + } + | { + type: "action"; + actionId: string; + command: string; + cwd: string; + label: string; + } + | { + type: "logs"; + } + | { + type: "review"; + } + | { + type: "other"; + }; + +export type TabRender = unknown; + +export type Tab = { + id: TabId; + label: string; + data: TabData; + component?: TabRender; + closeable?: boolean; + draggable?: boolean; + onClose?: () => void; + onSelect?: () => void; + icon?: TabRender; + hasUnsavedChanges?: boolean; + badge?: TabRender; + 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; + +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<T extends RepoPathCandidate>( + workspaces: Record<string, T | null | undefined>, + 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/packages/core/src/provisioning/provisioning.ts b/packages/core/src/provisioning/provisioning.ts new file mode 100644 index 0000000000..1e1624acc2 --- /dev/null +++ b/packages/core/src/provisioning/provisioning.ts @@ -0,0 +1,22 @@ +import { TypedEventEmitter } from "@posthog/shared"; +import { injectable } from "inversify"; + +export const ProvisioningEvent = { + Output: "output", +} as const; + +export interface ProvisioningOutputPayload { + taskId: string; + data: string; +} + +export interface ProvisioningServiceEvents { + [ProvisioningEvent.Output]: ProvisioningOutputPayload; +} + +@injectable() +export class ProvisioningService extends TypedEventEmitter<ProvisioningServiceEvents> { + emitOutput(taskId: string, data: string): void { + this.emit(ProvisioningEvent.Output, { taskId, data }); + } +} 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<typeof secureStoreGetInput>; +export type SecureStoreSetInput = z.infer<typeof secureStoreSetInput>; +export type SecureStoreRemoveInput = z.infer<typeof secureStoreRemoveInput>; 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>): 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<Task, "title" | "description">, +): 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<Task, "title" | "description">; +}): 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<string, string>; +} + +export interface PreparedCloudArtifact extends CloudArtifactUploadRequest { + id: string; + presigned_post: CloudArtifactPresignedPost; +} + +export interface FinalizedCloudArtifact { + id: string; +} + +export interface CloudArtifactClient { + prepareTaskStagedArtifactUploads( + taskId: string, + artifacts: CloudArtifactUploadRequest[], + ): Promise<PreparedCloudArtifact[]>; + finalizeTaskStagedArtifactUploads( + taskId: string, + artifacts: PreparedCloudArtifact[], + ): Promise<FinalizedCloudArtifact[]>; + prepareTaskRunArtifactUploads( + taskId: string, + runId: string, + artifacts: CloudArtifactUploadRequest[], + ): Promise<PreparedCloudArtifact[]>; + finalizeTaskRunArtifactUploads( + taskId: string, + runId: string, + artifacts: PreparedCloudArtifact[], + ): Promise<FinalizedCloudArtifact[]>; +} + +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<typeof vi.fn> + ).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<typeof vi.fn> + ).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<string, string> = { + 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<ArrayBuffer>; + upload: CloudArtifactUploadRequest; +} + +function base64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> { + 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<string[]> { + 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<string[]> { + 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<LoadedCloudAttachment[]> { + 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<void> { + 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..bdbcd33324 --- /dev/null +++ b/packages/core/src/sessions/cloudLogGap.test.ts @@ -0,0 +1,160 @@ +import type { StoredLogEntry } from "@posthog/shared"; +import { + type CloudLogGapReconcileRequest, + classifyCloudLogAppend, + classifyCloudLogGap, + mergeCloudLogGapRequests, +} from "./cloudLogGap"; +import { describe, expect, it } from "vitest"; + +function entry(line: string): StoredLogEntry { + return { + type: "notification", + notification: { method: line }, + } as unknown as StoredLogEntry; +} + +function request( + over: Partial<CloudLogGapReconcileRequest> = {}, +): 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..b6f03298f3 --- /dev/null +++ b/packages/core/src/sessions/cloudLogGapReconciler.test.ts @@ -0,0 +1,205 @@ +import type { StoredLogEntry } from "@posthog/shared"; +import type { CloudLogGapReconcileRequest } from "./cloudLogGap"; +import { + type CloudLogGapFetchResult, + CloudLogGapReconciler, + type CloudLogGapReconcilerDeps, + type CloudLogGapReconcilerSession, +} from "./cloudLogGapReconciler"; +import { describe, expect, it, vi } from "vitest"; + +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> = {}, +): 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<CloudLogGapFetchResult> => + 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<CloudLogGapFetchResult>(() => {}), + ); + 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<string, unknown>): 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<CloudLogGapFetchResult>; + 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<string, ReconcileState>(); + private readonly deficiency = new Map<string, CloudLogGapDeficiency>(); + + 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<void> { + 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<void> { + 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<ContentBlock, { type: "text" }> => + 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..b74a1ef4a4 --- /dev/null +++ b/packages/core/src/sessions/cloudRunIdleTracker.test.ts @@ -0,0 +1,133 @@ +import type { AcpMessage } from "@posthog/shared"; +import { CloudRunIdleTracker } from "./cloudRunIdleTracker"; +import type { AgentSession } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; + +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..28eb724669 --- /dev/null +++ b/packages/core/src/sessions/cloudRunOptions.test.ts @@ -0,0 +1,88 @@ +import type { TaskRun } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + getCloudPrAuthorshipMode, + getCloudRunSource, + getCloudRuntimeOptions, +} from "./cloudRunOptions"; +import type { AgentSession } from "@posthog/shared"; + +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>): 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<string, unknown>, +): 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<string, unknown>, +): 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..373096a89b --- /dev/null +++ b/packages/core/src/sessions/cloudSessionConfig.test.ts @@ -0,0 +1,76 @@ +import type { StoredLogEntry } from "@posthog/shared"; +import { + buildCloudDefaultConfigOptions, + extractLatestConfigOptionsFromEntries, +} from "./cloudSessionConfig"; +import { describe, expect, it } from "vitest"; + +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<SessionStatus, "disconnected" | "error">; + 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/packages/core/src/sessions/contextUsage.test.ts b/packages/core/src/sessions/contextUsage.test.ts new file mode 100644 index 0000000000..67f518b69c --- /dev/null +++ b/packages/core/src/sessions/contextUsage.test.ts @@ -0,0 +1,79 @@ +import type { AcpMessage } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { extractContextUsage } from "./contextUsage"; + +function usageUpdateEvent(used: number, size: number): AcpMessage { + return { + type: "acp_message", + ts: 1, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "s1", + update: { sessionUpdate: "usage_update", used, size }, + }, + }, + }; +} + +function breakdownEvent( + breakdown: Record<string, number>, + method = "_posthog/usage_update", +): AcpMessage { + return { + type: "acp_message", + ts: 1, + message: { jsonrpc: "2.0", method, params: { sessionId: "s1", breakdown } }, + }; +} + +describe("extractContextUsage", () => { + it("returns null with no usage event", () => { + expect(extractContextUsage([])).toBeNull(); + }); + + it("derives aggregate from the latest session/update", () => { + const result = extractContextUsage([usageUpdateEvent(50_000, 200_000)]); + expect(result?.used).toBe(50_000); + expect(result?.size).toBe(200_000); + expect(result?.percentage).toBe(25); + expect(result?.breakdown).toBeNull(); + }); + + it("merges breakdown from a _posthog/usage_update notification", () => { + const result = extractContextUsage([ + usageUpdateEvent(50_000, 200_000), + breakdownEvent({ + systemPrompt: 4000, + tools: 500, + rules: 0, + skills: 0, + mcp: 0, + subagents: 0, + conversation: 45_500, + }), + ]); + expect(result?.breakdown?.systemPrompt).toBe(4000); + expect(result?.breakdown?.conversation).toBe(45_500); + }); + + it("tolerates the double-underscore method prefix from extNotification", () => { + const result = extractContextUsage([ + usageUpdateEvent(50_000, 200_000), + breakdownEvent( + { + systemPrompt: 4000, + tools: 0, + rules: 0, + skills: 0, + mcp: 0, + subagents: 0, + conversation: 46_000, + }, + "__posthog/usage_update", + ), + ]); + expect(result?.breakdown?.systemPrompt).toBe(4000); + }); +}); diff --git a/packages/core/src/sessions/contextUsage.ts b/packages/core/src/sessions/contextUsage.ts new file mode 100644 index 0000000000..4ae4311cd5 --- /dev/null +++ b/packages/core/src/sessions/contextUsage.ts @@ -0,0 +1,90 @@ +import type { AcpMessage } from "@posthog/shared"; + +export interface ContextBreakdown { + systemPrompt: number; + tools: number; + rules: number; + skills: number; + mcp: number; + subagents: number; + conversation: number; +} + +export interface ContextUsage { + used: number; + size: number; + percentage: number; + cost: { amount: number; currency: string } | null; + breakdown: ContextBreakdown | null; +} + +export function extractContextUsage(events: AcpMessage[]): ContextUsage | null { + let aggregate: Omit<ContextUsage, "breakdown"> | null = null; + let breakdown: ContextBreakdown | null = null; + + for (let i = events.length - 1; i >= 0; i--) { + const msg = events[i].message; + if (!aggregate) { + aggregate = extractAggregate(msg); + } + if (!breakdown) { + breakdown = extractBreakdown(msg); + } + if (aggregate && breakdown) break; + } + + if (!aggregate) return null; + return { ...aggregate, breakdown }; +} + +function extractAggregate( + msg: AcpMessage["message"], +): Omit<ContextUsage, "breakdown"> | null { + if ( + "method" in msg && + msg.method === "session/update" && + !("id" in msg) && + "params" in msg + ) { + const params = msg.params as + | { + update?: { + sessionUpdate?: string; + used?: number; + size?: number; + cost?: { amount: number; currency: string } | null; + }; + } + | undefined; + const update = params?.update; + if ( + update?.sessionUpdate === "usage_update" && + typeof update.used === "number" && + typeof update.size === "number" + ) { + const percentage = + update.size > 0 + ? Math.min(100, Math.round((update.used / update.size) * 100)) + : 0; + return { + used: update.used, + size: update.size, + percentage, + cost: update.cost ?? null, + }; + } + } + return null; +} + +function extractBreakdown(msg: AcpMessage["message"]): ContextBreakdown | null { + if (!("method" in msg) || !("params" in msg)) return null; + if ( + msg.method !== "_posthog/usage_update" && + msg.method !== "__posthog/usage_update" + ) { + return null; + } + const params = msg.params as { breakdown?: ContextBreakdown } | undefined; + return params?.breakdown ?? null; +} 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<typeof makeDeps>; + + 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<string | null>; + addFolder(input: { + folderPath: string; + remoteUrl?: string; + }): Promise<unknown>; +} + +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<void> { + 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<void> { + await this.resumePending(); + } + + public async resumePending(): Promise<void> { + 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<string | null> { + if (!remoteUrl) return null; + const repo = await this.host.getRepositoryByRemoteUrl({ + remoteUrl, + }); + return repo?.path ?? null; + } + + private async resolveRepoPathFromPicker( + remoteUrl: string | null | undefined, + ): Promise<string | null> { + 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<string, unknown>; + }>, + 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/packages/core/src/sessions/promptContent.ts b/packages/core/src/sessions/promptContent.ts new file mode 100644 index 0000000000..5754d7f4e1 --- /dev/null +++ b/packages/core/src/sessions/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/packages/core/src/sessions/sessionEvents.test.ts b/packages/core/src/sessions/sessionEvents.test.ts new file mode 100644 index 0000000000..d57bb33126 --- /dev/null +++ b/packages/core/src/sessions/sessionEvents.test.ts @@ -0,0 +1,239 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import type { AcpMessage } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; + +import { makeAttachmentUri } from "./promptContent"; +import { + extractUserPromptsFromEvents, + hasSessionPromptEvent, + isAbsoluteFolderPath, + isFatalSessionError, + promptReferencesAbsoluteFolder, +} from "./sessionEvents"; + +describe("isFatalSessionError", () => { + it("detects fatal 'Internal error' pattern", () => { + expect(isFatalSessionError("Internal error: process crashed")).toBe(true); + }); + + it("detects fatal 'process exited' pattern", () => { + expect(isFatalSessionError("process exited with code 1")).toBe(true); + }); + + it("detects fatal 'Session not found' pattern", () => { + expect(isFatalSessionError("Session not found")).toBe(true); + }); + + it("detects fatal 'Session did not end' pattern", () => { + expect(isFatalSessionError("Session did not end cleanly")).toBe(true); + }); + + it("detects fatal 'not ready for writing' pattern", () => { + expect(isFatalSessionError("not ready for writing")).toBe(true); + }); + + it("detects fatal pattern in errorDetails", () => { + expect(isFatalSessionError("Unknown error", "Internal error: boom")).toBe( + true, + ); + }); + + it("returns false for non-fatal errors", () => { + expect(isFatalSessionError("Network timeout")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isFatalSessionError("")).toBe(false); + }); +}); + +function promptEvent(prompt: ContentBlock[], ts = 1): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + id: ts, + method: "session/prompt", + params: { prompt }, + }, + }; +} + +describe("extractUserPromptsFromEvents", () => { + it("extracts text from a plain text prompt", () => { + const events = [promptEvent([{ type: "text", text: "fix the bug" }])]; + expect(extractUserPromptsFromEvents(events)).toEqual(["fix the bug"]); + }); + + it("skips hidden text blocks", () => { + const events = [ + promptEvent([ + { + type: "text", + text: "hidden context", + _meta: { ui: { hidden: true } }, + } as ContentBlock, + { type: "text", text: "visible prompt" }, + ]), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual(["visible prompt"]); + }); + + it("returns attachment labels when prompt has no text", () => { + const uri = makeAttachmentUri("/tmp/screenshot.png"); + const events = [ + promptEvent([ + { + type: "resource", + resource: { uri, text: "", mimeType: "image/png" }, + }, + ]), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual([ + "[Attached files: screenshot.png]", + ]); + }); + + it("returns text when prompt has both text and attachments", () => { + const uri = makeAttachmentUri("/tmp/data.csv"); + const events = [ + promptEvent([ + { type: "text", text: "analyze this" }, + { type: "resource", resource: { uri, text: "", mimeType: "text/csv" } }, + ]), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual(["analyze this"]); + }); + + it("joins multiple attachment labels with commas", () => { + const uri1 = makeAttachmentUri("/tmp/a.png"); + const uri2 = makeAttachmentUri("/tmp/b.pdf"); + const events = [ + promptEvent([ + { + type: "resource", + resource: { uri: uri1, text: "", mimeType: "image/png" }, + }, + { + type: "resource", + resource: { uri: uri2, text: "", mimeType: "application/pdf" }, + }, + ]), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual([ + "[Attached files: a.png, b.pdf]", + ]); + }); + + it("falls back to attachment labels when all text blocks are hidden", () => { + const uri = makeAttachmentUri("/tmp/report.md"); + const events = [ + promptEvent([ + { + type: "text", + text: "hidden", + _meta: { ui: { hidden: true } }, + } as ContentBlock, + { + type: "resource", + resource: { uri, text: "", mimeType: "text/markdown" }, + }, + ]), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual([ + "[Attached files: report.md]", + ]); + }); + + it("skips events with empty prompt arrays", () => { + const events = [promptEvent([])]; + expect(extractUserPromptsFromEvents(events)).toEqual([]); + }); + + it("collects prompts from multiple events in order", () => { + const uri = makeAttachmentUri("/tmp/logo.svg"); + const events = [ + promptEvent([{ type: "text", text: "first" }], 1), + promptEvent( + [ + { + type: "resource", + resource: { uri, text: "", mimeType: "image/svg+xml" }, + }, + ], + 2, + ), + promptEvent([{ type: "text", text: "third" }], 3), + ]; + expect(extractUserPromptsFromEvents(events)).toEqual([ + "first", + "[Attached files: logo.svg]", + "third", + ]); + }); +}); + +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 <folder path="/Users/x/repo" />'), + ).toBe(true); + }); + + it("returns false for a relative folder tag", () => { + expect( + promptReferencesAbsoluteFolder('see <folder path="src/lib" />'), + ).toBe(false); + }); + + it("scans ContentBlock text for absolute folder tags", () => { + const blocks: ContentBlock[] = [ + { type: "text", text: "intro" }, + { type: "text", text: '<folder path="~/work" />' }, + ]; + expect(promptReferencesAbsoluteFolder(blocks)).toBe(true); + }); + + it("returns false when no folder tag is present", () => { + expect(promptReferencesAbsoluteFolder("just text")).toBe(false); + }); +}); diff --git a/packages/core/src/sessions/sessionEvents.ts b/packages/core/src/sessions/sessionEvents.ts new file mode 100644 index 0000000000..efb1ef85f3 --- /dev/null +++ b/packages/core/src/sessions/sessionEvents.ts @@ -0,0 +1,275 @@ +/** + * Pure transformation functions for session data. + * No side effects, no store access - just data transformations. + */ +import type { + AvailableCommand, + ContentBlock, + SessionNotification, +} from "@agentclientprotocol/sdk"; +import type { + AcpMessage, + JsonRpcMessage, + JsonRpcRequest, + StoredLogEntry, + UserShellExecuteParams, +} 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. + */ +function storedEntryToAcpMessage(entry: StoredLogEntry): AcpMessage { + return { + type: "acp_message", + ts: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(), + message: (entry.notification ?? {}) as JsonRpcMessage, + }; +} + +/** + * Create a user message event for display. + */ +export function createUserPromptEvent( + prompt: ContentBlock[], + ts: number, +): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + id: ts, + method: "session/prompt", + params: { + prompt, + }, + } as JsonRpcRequest, + }; +} + +export function createUserMessageEvent(text: string, ts: number): AcpMessage { + return createUserPromptEvent([{ type: "text", text }], ts); +} + +/** + * Create a user shell execute event. + * When id is provided, it's used to track async execution (start/complete). + * When result is undefined, it represents a command that's still running. + */ +export function createUserShellExecuteEvent( + command: string, + cwd: string, + result?: { stdout: string; stderr: string; exitCode: number }, + id?: string, +): AcpMessage { + return { + type: "acp_message", + ts: Date.now(), + message: { + jsonrpc: "2.0", + method: "_array/user_shell_execute", + params: { id, command, cwd, result }, + }, + }; +} + +/** + * Collects completed user shell executes that occurred after the last prompt request. + * These are included as hidden context in the next prompt so the agent + * knows what commands the user ran between turns. + * + * Scans backwards from the end of events, stopping at the most recent + * session/prompt request (not response), collecting any _array/user_shell_execute + * notifications found along the way. Deduplicates by ID, keeping only completed executes. + */ +export function getUserShellExecutesSinceLastPrompt( + events: AcpMessage[], +): UserShellExecuteParams[] { + const execMap = new Map<string, UserShellExecuteParams>(); + + for (let i = events.length - 1; i >= 0; i--) { + const msg = events[i].message; + + if (isJsonRpcRequest(msg) && msg.method === "session/prompt") break; + + if ( + isJsonRpcNotification(msg) && + msg.method === "_array/user_shell_execute" + ) { + const params = msg.params as UserShellExecuteParams; + if (params.result && params.id && !execMap.has(params.id)) { + execMap.set(params.id, params); + } + } + } + + return Array.from(execMap.values()).reverse(); +} + +/** + * Convert shell executes to content blocks for prompt context. + */ +export function shellExecutesToContextBlocks( + shellExecutes: UserShellExecuteParams[], +): ContentBlock[] { + return shellExecutes + .filter((cmd) => cmd.result) + .map((cmd) => ({ + type: "text" as const, + text: `[User executed command in ${cmd.cwd}]\n$ ${cmd.command}\n${ + cmd.result?.stdout || cmd.result?.stderr || "(no output)" + }`, + _meta: { ui: { hidden: true } }, + })); +} + +/** + * Convert stored log entries to ACP messages. + * Optionally prepends a user message with the task description. + */ +export function convertStoredEntriesToEvents( + entries: StoredLogEntry[], + taskDescription?: string, +): AcpMessage[] { + const events: AcpMessage[] = []; + + if (taskDescription) { + const startTs = entries[0]?.timestamp + ? new Date(entries[0].timestamp).getTime() - 1 + : Date.now(); + events.push(createUserMessageEvent(taskDescription, startTs)); + } + + for (const entry of entries) { + events.push(storedEntryToAcpMessage(entry)); + } + + return events; +} + +/** + * Extract available commands from session events. + * Scans backwards to find the most recent available_commands_update. + */ +export function extractAvailableCommandsFromEvents( + events: AcpMessage[], +): AvailableCommand[] { + for (let i = events.length - 1; i >= 0; i--) { + const msg = events[i].message; + if ( + "method" in msg && + msg.method === "session/update" && + !("id" in msg) && + "params" in msg + ) { + const params = msg.params as SessionNotification | undefined; + const update = params?.update; + if (update?.sessionUpdate === "available_commands_update") { + return update.availableCommands || []; + } + } + } + return []; +} + +/** + * Extract user prompts from session events. + * Returns an array of user prompt strings, most recent last. + */ +export function extractUserPromptsFromEvents(events: AcpMessage[]): string[] { + const prompts: string[] = []; + + for (const event of events) { + const msg = event.message; + if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { + const params = msg.params as { prompt?: ContentBlock[] }; + if (params?.prompt?.length) { + const { text, attachments } = extractPromptDisplayContent( + params.prompt, + { filterHidden: true }, + ); + + if (text) { + prompts.push(text); + } else if (attachments.length > 0) { + const labels = attachments.map((a) => a.label).join(", "); + prompts.push(`[Attached files: ${labels}]`); + } + } + } + } + + return prompts; +} + +export function extractPromptText(prompt: string | ContentBlock[]): string { + if (typeof prompt === "string") return prompt; + return extractPromptDisplayContent(prompt).text; +} + +/** + * Convert prompt input to ContentBlocks. + */ +export function normalizePromptToBlocks( + prompt: string | ContentBlock[], +): ContentBlock[] { + return typeof prompt === "string" ? [{ type: "text", text: prompt }] : prompt; +} + +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 = /<folder\s+path="([^"]+)"\s*\/>/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 `<folder path="…" />` 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/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts new file mode 100644 index 0000000000..af033d3931 --- /dev/null +++ b/packages/core/src/sessions/sessionService.ts @@ -0,0 +1,4099 @@ +// 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 { + type AcpMessage, + type Adapter, + type AgentSession, + type CloudRegion, + type ExecutionMode, + flattenSelectOptions, + getBackoffDelay, + getCloudUrlFromRegion, + getConfigOptionByCategory, + isFatalSessionError, + isJsonRpcNotification, + isJsonRpcRequest, + isJsonRpcResponse, + isRateLimitError, + mergeConfigOptions, + 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, + effortLevelSchema, + isTerminalStatus, + type Task, +} 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 { + computeAutoRetryFinalState, + OFFLINE_SESSION_MESSAGE, + routeLocalConnect, +} from "./connectRouting"; +import { + type PermissionSelectionPlan, + planPermissionResponse, +} from "./permissionResponse"; +import { + convertStoredEntriesToEvents, + createUserPromptEvent, + createUserShellExecuteEvent, + extractPromptText, + getUserShellExecutesSinceLastPrompt, + hasSessionPromptEvent, + isTurnCompleteEvent, + normalizePromptToBlocks, + promptReferencesAbsoluteFolder, + shellExecutesToContextBlocks, +} from "./sessionEvents"; +import { createBaseSession } from "./sessionFactory"; +import { + type ParsedSessionLogs, + parseSessionLogContent, + planSkippedPromptFilter, +} from "./sessionLogs"; + +const LOCAL_SESSION_RECONNECT_ATTEMPTS = 3; +const LOCAL_SESSION_RECONNECT_BACKOFF = { + initialDelayMs: 1_000, + maxDelayMs: 5_000, +}; +const LOCAL_SESSION_RECOVERY_MESSAGE = + "Lost connection to the agent. Reconnecting…"; +const LOCAL_SESSION_RECOVERY_FAILED_MESSAGE = + "Connecting to to the agent has been lost. Retry, or start a new session."; +const GITHUB_AUTHORIZATION_REQUIRED_CODE = "github_authorization_required"; +const AUTO_RETRY_MAX_ATTEMPTS = 2; +const AUTO_RETRY_DELAY_MS = 10_000; + +class GitHubAuthorizationRequiredForCloudHandoffError extends Error { + constructor( + message = "Connect GitHub before continuing this task in cloud.", + ) { + super(message); + this.name = "GitHubAuthorizationRequiredForCloudHandoffError"; + } +} + +type TrpcMutation = { mutate: (input?: any) => Promise<any> }; +type TrpcQuery = { query: (input?: any) => Promise<any> }; +type TrpcSubscription = { + subscribe: ( + input: any, + handlers: { onData: (data: any) => void; onError?: (err: unknown) => void }, + ) => { unsubscribe: () => void }; +}; + +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 }; +} + +export interface ISessionStore { + setSession(session: AgentSession): void; + removeSession(taskRunId: string): void; + updateSession(taskRunId: string, updates: Partial<AgentSession>): void; + appendEvents( + taskRunId: string, + events: AcpMessage[], + newLineCount?: number, + ): void; + updateCloudStatus( + taskRunId: string, + fields: { + status?: TaskRunStatus; + stage?: string | null; + output?: Record<string, unknown> | null; + errorMessage?: string | null; + branch?: string | null; + }, + ): void; + setPendingPermissions( + taskRunId: string, + permissions: Map<string, PermissionRequest>, + ): 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<T, "id"> + : never + : never, + ): void; + clearOptimisticItems(taskRunId: string): void; + clearTailOptimisticItems(taskRunId: string): void; + replaceOptimisticWithEvent(taskRunId: string, event: AcpMessage): void; + getSessionByTaskId(taskId: string): AgentSession | undefined; + getSessions(): Record<string, AgentSession>; +} + +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<string[]>; + uploadTaskStagedAttachments: ( + client: CloudArtifactClient, + taskId: string, + filePaths: string[], + ) => Promise<string[]>; +} + +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<string, unknown>) => void; + buildPermissionToolMetadata: (...args: any[]) => any; + notifyPermissionRequest: (...args: any[]) => any; + notifyPromptComplete: (...args: any[]) => any; + getIsOnline: () => boolean; + fetchAuthState: () => Promise<any>; + getAuthenticatedClient: () => Promise<any>; + 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; +} + +type AuthClient = NonNullable< + Awaited<ReturnType<SessionServiceDeps["getAuthenticatedClient"]>> +>; + +interface AuthCredentials { + apiHost: string; + projectId: number; + client: AuthClient; +} + +export interface ConnectParams { + task: Task; + repoPath: string; + initialPrompt?: ContentBlock[]; + executionMode?: ExecutionMode; + adapter?: "claude" | "codex"; + model?: string; + reasoningLevel?: string; +} + +export interface CloudConnectionAuth { + status: string; + bootstrapComplete?: boolean; + projectId?: number | null; + cloudRegion?: CloudRegion | null; +} + +export interface ReconcileTaskConnectionParams { + task: Task; + session: AgentSession | undefined; + repoPath: string | null; + isCloud: boolean; + isSuspended?: boolean; + isOnline: boolean; + cloudAuth: CloudConnectionAuth; + onCloudStatusChange?: () => void; +} + +const ACTIVITY_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; + +export type SessionPlan = Extract<SessionUpdate, { sessionUpdate: "plan" }>; + +export const SESSION_SERVICE = Symbol.for("posthog.core.sessions.service"); + +export class SessionService { + private connectingTasks = new Map<string, Promise<void>>(); + private reconcilingTasks = new Set<string>(); + private activityHeartbeats = new Map< + string, + ReturnType<typeof setInterval> + >(); + private localRepoPaths = new Map<string, string>(); + private localRecoveryAttempts = new Map<string, Promise<boolean>>(); + /** Re-entrance guard for cloud queue dispatch (per taskId). */ + private dispatchingCloudQueues = new Set<string>(); + /** Coalesces deferred cloud queue flush timers (per taskId). */ + private scheduledCloudQueueFlushes = new Set<string>(); + private cloudRunIdleTracker: CloudRunIdleTracker; + private nextCloudTaskWatchToken = 0; + private subscriptions = new Map< + string, + { + event: { unsubscribe: () => void }; + permission?: { unsubscribe: () => void }; + } + >(); + /** Active cloud task watchers, keyed by taskId */ + private cloudTaskWatchers = new Map< + string, + { + runId: string; + apiHost: string; + teamId: number; + startToken: number; + subscription: { unsubscribe: () => void }; + onStatusChange?: () => void; + } + >(); + private cloudLogGapReconciler: CloudLogGapReconciler; + /** Maps toolCallId → cloud requestId for routing permission responses */ + private cloudPermissionRequestIds = new Map<string, string>(); + private idleKilledSubscription: { unsubscribe: () => void } | null = null; + /** + * Cached preview-config-options responses keyed by `${apiHost}::${adapter}`. + * Shared across cloud sessions so switching model/adapter reuses the list. + */ + private previewConfigOptionsCache = new Map< + string, + Promise<SessionConfigOption[]> + >(); + + 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; + d.log.info("Session idle-killed by main process", { taskRunId }); + this.handleIdleKill(taskRunId); + }, + onError: (err: unknown) => { + d.log.debug("Idle-killed subscription error", { error: err }); + }, + }, + ); + } + + /** + * Connect to a task session. + * Uses locking to prevent duplicate concurrent connections. + */ + async connectToTask(params: ConnectParams): Promise<void> { + const { task } = params; + const taskId = task.id; + this.localRepoPaths.set(taskId, params.repoPath); + + // Return existing connection promise if already connecting + const existingPromise = this.connectingTasks.get(taskId); + if (existingPromise) { + return existingPromise; + } + + // Check for existing connected session + const existingSession = this.d.store.getSessionByTaskId(taskId); + if (existingSession?.status === "connected") { + this.d.log.info("Already connected to task", { taskId }); + return; + } + if (existingSession?.status === "connecting") { + this.d.log.info("Session already in connecting state", { taskId }); + return; + } + + // Create and store the connection promise + const connectPromise = this.doConnect(params).finally(() => { + this.connectingTasks.delete(taskId); + }); + this.connectingTasks.set(taskId, connectPromise); + + return connectPromise; + } + + private async doConnect(params: ConnectParams): Promise<void> { + const { + task, + repoPath, + initialPrompt, + executionMode, + adapter, + model, + reasoningLevel, + } = params; + const { id: taskId, latest_run: latestRun } = task; + const taskTitle = task.title || task.description || "Task"; + + if (latestRun?.environment === "cloud") { + this.d.log.info("Skipping local session connect for cloud run", { + taskId, + taskRunId: latestRun.id, + }); + return; + } + + try { + const auth = await this.getAuthCredentials(); + 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 = createBaseSession(taskRunId, taskId, taskTitle); + session.status = "error"; + session.errorMessage = + "Authentication required. Please sign in to continue."; + if (initialPrompt?.length) { + session.initialPrompt = initialPrompt; + } + this.d.store.setSession(session); + return; + } + + 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( + logUrl, + existingRunId, + ); + const events = convertStoredEntriesToEvents(rawEntries); + const session = createBaseSession(existingRunId, taskId, taskTitle); + session.events = events; + session.logUrl = logUrl; + session.status = "disconnected"; + session.errorMessage = OFFLINE_SESSION_MESSAGE; + this.d.store.setSession(session); + return; + } + + const [workspaceResult, logResult] = await Promise.all([ + this.d.trpc.workspace.verify.query({ taskId }), + this.fetchSessionLogs(logUrl, existingRunId), + ]); + + if (!workspaceResult.exists) { + this.d.log.warn("Workspace no longer exists, showing error state", { + taskId, + missingPath: workspaceResult.missingPath, + }); + const events = convertStoredEntriesToEvents(logResult.rawEntries); + const session = createBaseSession(existingRunId, taskId, taskTitle); + session.events = events; + 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."; + this.d.store.setSession(session); + return; + } + + await this.reconnectToLocalSession( + taskId, + existingRunId, + taskTitle, + logUrl, + repoPath, + auth, + logResult, + ); + } else { + if (!this.d.getIsOnline()) { + this.d.log.info("Skipping connection attempt - offline", { taskId }); + const taskRunId = latestRun?.id ?? `offline-${taskId}`; + const session = createBaseSession(taskRunId, taskId, taskTitle); + session.status = "disconnected"; + session.errorMessage = + "No internet connection. Connect when you're back online."; + this.d.store.setSession(session); + return; + } + + await this.createNewLocalSession( + taskId, + taskTitle, + repoPath, + auth, + initialPrompt, + executionMode, + adapter, + model, + reasoningLevel, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.d.log.error("Failed to connect to task", { message }); + + const taskRunId = latestRun?.id ?? `error-${taskId}`; + const session = createBaseSession(taskRunId, taskId, taskTitle); + if (initialPrompt?.length) { + session.initialPrompt = initialPrompt; + } + if (latestRun?.log_url) { + try { + const { rawEntries } = await this.fetchSessionLogs( + latestRun.log_url, + latestRun.id, + ); + session.events = convertStoredEntriesToEvents(rawEntries); + session.logUrl = latestRun.log_url; + } catch { + // Ignore log fetch errors + } + } + + const shouldAutoRetry = this.d.getIsOnline(); + session.status = shouldAutoRetry ? "connecting" : "error"; + if (!shouldAutoRetry) { + session.errorTitle = "Failed to connect"; + session.errorMessage = message; + } + this.d.store.setSession(session); + + if (!shouldAutoRetry) return; + + let lastRetryMessage = message; + let wentOffline = false; + for (let attempt = 1; attempt <= AUTO_RETRY_MAX_ATTEMPTS; attempt++) { + this.d.log.warn("Auto-retrying failed connection", { + taskId, + attempt, + delayMs: AUTO_RETRY_DELAY_MS, + }); + await new Promise((resolve) => + setTimeout(resolve, AUTO_RETRY_DELAY_MS), + ); + if (!this.d.getIsOnline()) { + this.d.log.warn("Skipping retry — device went offline", { + taskId, + attempt, + }); + wentOffline = true; + break; + } + try { + await this.clearSessionError(taskId, repoPath); + return; + } catch (retryError) { + lastRetryMessage = + retryError instanceof Error + ? retryError.message + : String(retryError); + this.d.log.error("Auto-retry via clearSessionError failed", { + taskId, + attempt, + error: lastRetryMessage, + }); + } + } + + const currentSession = this.d.store.getSessionByTaskId(taskId); + if (!currentSession) return; + this.d.store.updateSession( + currentSession.taskRunId, + computeAutoRetryFinalState({ + wentOffline, + lastRetryMessage, + originalMessage: message, + }), + ); + } + } + + private async reconnectToLocalSession( + taskId: string, + taskRunId: string, + taskTitle: string, + logUrl: string | undefined, + repoPath: string, + auth: AuthCredentials, + prefetchedLogs?: { + rawEntries: StoredLogEntry[]; + sessionId?: string; + adapter?: Adapter; + }, + ): Promise<boolean> { + const { rawEntries, sessionId, adapter } = + prefetchedLogs ?? (await this.fetchSessionLogs(logUrl, taskRunId)); + const events = convertStoredEntriesToEvents(rawEntries); + + const storedAdapter = this.d.adapterStore.getAdapter(taskRunId); + const resolvedAdapter = adapter ?? storedAdapter; + const persistedConfigOptions = this.d.getPersistedConfigOptions(taskRunId); + + const previous = this.d.store.getSessions()[taskRunId]; + + const session = createBaseSession(taskRunId, taskId, taskTitle); + session.events = events; + if (logUrl) { + session.logUrl = logUrl; + } + if (persistedConfigOptions) { + session.configOptions = persistedConfigOptions; + } + if (resolvedAdapter) { + session.adapter = resolvedAdapter; + this.d.adapterStore.setAdapter(taskRunId, resolvedAdapter); + } + + if (previous) { + session.optimisticItems = previous.optimisticItems; + session.messageQueue = previous.messageQueue; + session.isPromptPending = previous.isPromptPending; + session.promptStartedAt = previous.promptStartedAt; + session.pausedDurationMs = previous.pausedDurationMs; + } + + this.d.store.setSession(session); + this.subscribeToChannel(taskRunId); + + try { + const modeOpt = getConfigOptionByCategory(persistedConfigOptions, "mode"); + const persistedMode = + modeOpt?.type === "select" ? modeOpt.currentValue : undefined; + + this.d.trpc.workspace.verify + .query({ taskId }) + .then((workspaceResult) => { + if (!workspaceResult.exists) { + this.d.log.warn("Workspace no longer exists", { + taskId, + missingPath: workspaceResult.missingPath, + }); + this.d.store.updateSession(taskRunId, { + status: "error", + errorMessage: workspaceResult.missingPath + ? `Working directory no longer exists: ${workspaceResult.missingPath}` + : "The working directory for this task no longer exists. Please start a new session.", + }); + } + }) + .catch((err) => { + this.d.log.warn("Failed to verify workspace", { taskId, err }); + }); + + const { customInstructions } = this.d.settings; + const result = await this.d.trpc.agent.reconnect.mutate({ + taskId, + taskRunId, + repoPath, + apiHost: auth.apiHost, + projectId: auth.projectId, + logUrl, + sessionId, + adapter: resolvedAdapter, + permissionMode: persistedMode, + customInstructions: customInstructions || undefined, + }); + + if (result) { + // Cast and merge live configOptions with persisted values. + // Fall back to persisted options if the agent doesn't return any + // (e.g. after session compaction). + let configOptions = result.configOptions as + | SessionConfigOption[] + | undefined; + if (configOptions && persistedConfigOptions) { + configOptions = mergeConfigOptions( + configOptions, + persistedConfigOptions, + ); + } else if (!configOptions) { + configOptions = persistedConfigOptions ?? undefined; + } + + this.d.store.updateSession(taskRunId, { + status: "connected", + configOptions, + }); + + // Persist the merged config options + if (configOptions) { + this.d.setPersistedConfigOptions(taskRunId, configOptions); + } + + // Restore persisted config options to server in parallel + if (persistedConfigOptions) { + await Promise.all( + persistedConfigOptions.map((opt) => + this.d.trpc.agent.setConfigOption + .mutate({ + sessionId: taskRunId, + configId: opt.id, + value: String(opt.currentValue), + }) + .catch((error) => { + this.d.log.warn( + "Failed to restore persisted config option after reconnect", + { + taskId, + configId: opt.id, + error, + }, + ); + }), + ), + ); + } + return true; + } else { + this.d.log.warn("Reconnect returned null", { taskId, taskRunId }); + this.setErrorSession( + taskId, + taskRunId, + taskTitle, + "Session could not be resumed. Please retry or start a new session.", + ); + return false; + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.d.log.warn("Reconnect failed", { taskId, error: errorMessage }); + this.setErrorSession( + taskId, + taskRunId, + taskTitle, + errorMessage || + "Failed to reconnect. Please retry or start a new session.", + ); + return false; + } + } + + private async teardownSession(taskRunId: string): Promise<void> { + const session = this.getSessionByRunId(taskRunId); + + try { + await this.d.trpc.agent.cancel.mutate({ sessionId: taskRunId }); + } catch (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); + this.d.store.removeSession(taskRunId); + this.cloudRunIdleTracker.delete(taskRunId); + this.cloudLogGapReconciler.forgetDeficiency(taskRunId); + if (session) { + this.localRepoPaths.delete(session.taskId); + this.localRecoveryAttempts.delete(session.taskId); + } + this.d.adapterStore.removeAdapter(taskRunId); + this.d.removePersistedConfigOptions(taskRunId); + } + + /** + * Handle an idle-kill from the main process without destroying session state. + * The main process already cleaned up the agent, so we only need to + * unsubscribe from the channel and mark the session as errored. + * Preserves events, logUrl, configOptions and adapter so that Retry + * can reconnect with full context via resumeSession. + */ + private handleIdleKill(taskRunId: string): void { + this.unsubscribeFromChannel(taskRunId); + this.d.store.updateSession(taskRunId, { + status: "error", + errorMessage: "Session disconnected due to inactivity. Reconnecting…", + isPromptPending: false, + isCompacting: false, + promptStartedAt: null, + idleKilled: true, + }); + } + + private setErrorSession( + taskId: string, + taskRunId: string, + taskTitle: string, + errorMessage: string, + errorTitle?: string, + ): void { + // Preserve events and logUrl from the existing session so the + // 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 = this.d.store.getSessionByTaskId(taskId); + const session = createBaseSession(taskRunId, taskId, taskTitle); + session.status = "error"; + session.errorTitle = errorTitle; + session.errorMessage = errorMessage; + if (existing?.events?.length) { + session.events = existing.events; + } + if (existing?.logUrl) { + session.logUrl = existing.logUrl; + } + if (existing?.initialPrompt?.length) { + session.initialPrompt = existing.initialPrompt; + } + this.d.store.setSession(session); + } + + private async tryAutoRecoverLocalSession( + taskId: string, + taskRunId: string, + reason: string, + ): Promise<boolean> { + const existingRecovery = this.localRecoveryAttempts.get(taskId); + if (existingRecovery) { + return existingRecovery; + } + + const recoveryPromise = this.runAutoRecoverLocalSession( + taskId, + taskRunId, + reason, + ).finally(() => { + this.localRecoveryAttempts.delete(taskId); + }); + + this.localRecoveryAttempts.set(taskId, recoveryPromise); + return recoveryPromise; + } + + private async runAutoRecoverLocalSession( + taskId: string, + taskRunId: string, + reason: string, + ): Promise<boolean> { + const repoPath = this.localRepoPaths.get(taskId); + const session = this.d.store.getSessionByTaskId(taskId); + if (!repoPath || !session || session.isCloud) { + return false; + } + + this.d.log.warn("Attempting automatic local session recovery", { + taskId, + taskRunId, + reason, + }); + + this.d.store.updateSession(taskRunId, { + status: "disconnected", + errorTitle: undefined, + errorMessage: LOCAL_SESSION_RECOVERY_MESSAGE, + isPromptPending: false, + isCompacting: false, + promptStartedAt: null, + }); + + for ( + let attempt = 0; + attempt < LOCAL_SESSION_RECONNECT_ATTEMPTS; + attempt++ + ) { + const currentSession = this.d.store.getSessionByTaskId(taskId); + if (!currentSession || currentSession.taskRunId !== taskRunId) { + return false; + } + + if (attempt > 0) { + const delay = getBackoffDelay( + attempt - 1, + LOCAL_SESSION_RECONNECT_BACKOFF, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + const recovered = await this.reconnectInPlace(taskId, repoPath); + if (recovered) { + this.d.log.info("Automatic local session recovery succeeded", { + taskId, + taskRunId, + attempt: attempt + 1, + }); + return true; + } + } + + const latestSession = this.d.store.getSessionByTaskId(taskId); + if (latestSession?.taskRunId === taskRunId) { + this.setErrorSession( + taskId, + taskRunId, + latestSession.taskTitle, + LOCAL_SESSION_RECOVERY_FAILED_MESSAGE, + "Connection lost", + ); + } + + this.d.log.warn("Automatic local session recovery exhausted", { + taskId, + taskRunId, + }); + + return false; + } + + private startAutoRecoverLocalSession( + taskId: string, + taskRunId: string, + taskTitle: string, + reason: string, + fallbackMessage: string, + ): void { + void this.tryAutoRecoverLocalSession(taskId, taskRunId, reason).then( + (recovered) => { + if (recovered) { + return; + } + + const latestSession = this.d.store.getSessionByTaskId(taskId); + if (!latestSession || latestSession.taskRunId !== taskRunId) { + return; + } + + if (latestSession.status !== "error") { + this.setErrorSession( + taskId, + taskRunId, + taskTitle, + fallbackMessage, + "Connection lost", + ); + } + }, + ); + } + + private async createNewLocalSession( + taskId: string, + taskTitle: string, + repoPath: string, + auth: AuthCredentials, + initialPrompt?: ContentBlock[], + executionMode?: ExecutionMode, + adapter?: "claude" | "codex", + model?: string, + reasoningLevel?: string, + ): Promise<void> { + const { client } = auth; + if (!client) { + throw new Error("Unable to reach server. Please check your connection."); + } + + const taskRun = await client.createTaskRun(taskId); + if (!taskRun?.id) { + throw new Error("Failed to create task run. Please try again."); + } + + 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, + apiHost: auth.apiHost, + projectId: auth.projectId, + permissionMode: executionMode, + adapter, + customInstructions: startCustomInstructions || undefined, + effort: effortLevelSchema.safeParse(reasoningLevel).success + ? (reasoningLevel as EffortLevel) + : undefined, + model: preferredModel, + }); + + const session = createBaseSession(taskRun.id, taskId, taskTitle); + session.channel = result.channel; + session.status = "connected"; + session.adapter = adapter; + const configOptions = result.configOptions as + | SessionConfigOption[] + | undefined; + session.configOptions = configOptions; + + // Persist the config options + if (configOptions) { + this.d.setPersistedConfigOptions(taskRun.id, configOptions); + } + + // Persist the adapter + if (adapter) { + this.d.adapterStore.setAdapter(taskRun.id, adapter); + } + + // Store the initial prompt on the session so retry/reset flows can + // re-send it if the session errors after this point (e.g. subscription + // error, agent crash, or prompt failure). + if (initialPrompt?.length) { + session.initialPrompt = initialPrompt; + } + + this.d.store.setSession(session); + this.subscribeToChannel(taskRun.id); + + this.d.track(ANALYTICS_EVENTS.TASK_RUN_STARTED, { + task_id: taskId, + execution_type: "local", + initial_mode: executionMode, + adapter, + }); + + if (initialPrompt?.length) { + await this.sendPrompt(taskId, initialPrompt); + } + } + + async loadLogsOnly(params: { + taskId: string; + taskRunId: string; + taskTitle: string; + logUrl: string; + }): Promise<void> { + const { taskId, taskRunId, taskTitle, logUrl } = params; + 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 = createBaseSession(taskRunId, taskId, taskTitle); + session.events = events; + session.logUrl = logUrl; + session.status = "disconnected"; + this.d.store.setSession(session); + } + + async disconnectFromTask(taskId: string): Promise<void> { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) return; + + await this.teardownSession(session.taskRunId); + } + + // --- Subscription Management --- + + private subscribeToChannel(taskRunId: string): void { + if (this.subscriptions.has(taskRunId)) { + return; + } + + const eventSubscription = this.d.trpc.agent.onSessionEvent.subscribe( + { taskRunId }, + { + onData: (payload: unknown) => { + this.handleSessionEvent(taskRunId, payload as AcpMessage); + }, + onError: (err) => { + this.d.log.error("Session subscription error", { + taskRunId, + error: err, + }); + const session = this.getSessionByRunId(taskRunId); + if (!session || session.isCloud) { + this.d.store.updateSession(taskRunId, { + status: "error", + errorMessage: + "Lost connection to the agent. Please restart the task.", + }); + return; + } + + this.startAutoRecoverLocalSession( + session.taskId, + taskRunId, + session.taskTitle, + "subscription_error", + "Lost connection to the agent. Please retry or start a new session.", + ); + }, + }, + ); + + const permissionSubscription = + this.d.trpc.agent.onPermissionRequest.subscribe( + { taskRunId }, + { + onData: async (payload) => { + this.handlePermissionRequest(taskRunId, payload); + }, + onError: (err) => { + this.d.log.error("Permission subscription error", { + taskRunId, + error: err, + }); + }, + }, + ); + + this.subscriptions.set(taskRunId, { + event: eventSubscription, + permission: permissionSubscription, + }); + } + + private unsubscribeFromChannel(taskRunId: string): void { + const subscription = this.subscriptions.get(taskRunId); + subscription?.event.unsubscribe(); + subscription?.permission?.unsubscribe(); + this.subscriptions.delete(taskRunId); + } + + /** + * Reset all service state and clean up subscriptions. + * Called on logout or app reset. + */ + reset(): void { + this.d.log.info("Resetting session service", { + subscriptionCount: this.subscriptions.size, + connectingCount: this.connectingTasks.size, + cloudWatcherCount: this.cloudTaskWatchers.size, + }); + + // Unsubscribe from all active subscriptions + for (const taskRunId of this.subscriptions.keys()) { + this.unsubscribeFromChannel(taskRunId); + } + + // Clean up all cloud task watchers + for (const taskId of [...this.cloudTaskWatchers.keys()]) { + this.stopCloudTaskWatch(taskId); + } + + this.connectingTasks.clear(); + this.localRepoPaths.clear(); + this.localRecoveryAttempts.clear(); + this.cloudPermissionRequestIds.clear(); + this.cloudLogGapReconciler.clear(); + this.dispatchingCloudQueues.clear(); + this.scheduledCloudQueueFlushes.clear(); + this.cloudRunIdleTracker.clear(); + this.idleKilledSubscription?.unsubscribe(); + this.idleKilledSubscription = null; + } + + private updatePromptStateFromEvents( + taskRunId: string, + events: AcpMessage[], + { isLive = false }: { isLive?: boolean } = {}, + ): void { + for (const acpMsg of events) { + const msg = acpMsg.message; + if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { + this.d.store.updateSession(taskRunId, { + isPromptPending: true, + promptStartedAt: acpMsg.ts, + pausedDurationMs: 0, + currentPromptId: msg.id, + }); + const promptSession = this.d.store.getSessions()[taskRunId]; + if (promptSession?.isCloud) { + this.cloudRunIdleTracker.markBusy(promptSession); + if (promptSession.agentIdleForRunId) { + this.d.store.updateSession(taskRunId, { + agentIdleForRunId: undefined, + }); + } + } + } + if ( + "id" in msg && + "result" in msg && + typeof msg.result === "object" && + msg.result !== null && + "stopReason" in msg.result + ) { + // 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 = this.d.store.getSessions()[taskRunId]; + if (session && session.currentPromptId !== msg.id) { + continue; + } + this.d.store.updateSession(taskRunId, { + isPromptPending: false, + promptStartedAt: null, + currentPromptId: null, + }); + } + if (isTurnCompleteEvent(acpMsg)) { + // Local sessions use the JSON-RPC response as the canonical turn-done + // signal; clearing currentPromptId here would race the id-match guard + // above. Cloud sessions never see that response. + const session = this.getSessionByRunId(taskRunId); + if (session?.isCloud) { + this.d.store.updateSession(taskRunId, { + isPromptPending: false, + promptStartedAt: null, + currentPromptId: null, + }); + if (isLive) { + // Queued messages will start a new turn — suppress the "done" notification in that case. + if (session.messageQueue.length === 0) { + this.d.notifyPromptComplete( + session.taskTitle, + "end_turn", + session.taskId, + ); + } + this.d.taskViewedApi.markActivity(session.taskId); + } + } + } + // Lifecycle handshake from the agent — flip status to "connected" + // so the UI can release the queue-while-not-ready guard. This is + // the explicit "agent is up and accepting user messages" signal, + // emitted by `agent-server.ts` once the ACP session is fully + // wired. We deliberately do NOT drain the queue here: the agent + // is about to start `sendInitialTaskMessage` (or `sendResumeMessage`), + // and dispatching a queued user_message right now would race with + // its `clientConnection.prompt()` and one of the prompts would end + // up cancelled. The `turn_complete` handler below drains once the + // agent's initial / resume turn is actually finished. + if ( + "method" in msg && + isNotification(msg.method, POSTHOG_NOTIFICATIONS.RUN_STARTED) + ) { + const session = this.d.store.getSessions()[taskRunId]; + const params = (msg as { params?: { agentVersion?: unknown } }).params; + const agentVersion = + typeof params?.agentVersion === "string" + ? params.agentVersion + : undefined; + const updates: Partial<AgentSession> = {}; + if (agentVersion && session?.agentVersion !== agentVersion) { + updates.agentVersion = agentVersion; + } + if (session?.isCloud && session.status !== "connected") { + updates.status = "connected"; + } + if (Object.keys(updates).length > 0) { + this.d.store.updateSession(taskRunId, updates); + } + } + // Canonical "turn boundary" — flush any queued cloud messages now + // that the agent is idle and accepting the next prompt. + if ( + "method" in msg && + isNotification(msg.method, POSTHOG_NOTIFICATIONS.TURN_COMPLETE) + ) { + 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 + // finished, so the agent is idle for this run, lets a later + // transport drop recover readiness. + const updates: Partial<AgentSession> = {}; + if (session.status !== "connected") { + updates.status = "connected"; + } + if (session.agentIdleForRunId !== taskRunId) { + updates.agentIdleForRunId = taskRunId; + } + if (Object.keys(updates).length > 0) { + this.d.store.updateSession(taskRunId, updates); + } + this.cloudRunIdleTracker.markIdle(session); + if (session.messageQueue.length > 0) { + this.scheduleCloudQueueFlush(session.taskId, "turn_complete"); + } + } + } + } + } + + private handleSessionEvent(taskRunId: string, acpMsg: AcpMessage): void { + const session = this.d.store.getSessions()[taskRunId]; + if (!session) return; + + const isUserPromptEcho = + isJsonRpcRequest(acpMsg.message) && + acpMsg.message.method === "session/prompt"; + + // 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) { + this.d.store.updateSession(taskRunId, { + initialPrompt: undefined, + }); + } + + if (isUserPromptEcho) { + this.d.store.replaceOptimisticWithEvent(taskRunId, acpMsg); + } else { + this.d.store.appendEvents(taskRunId, [acpMsg]); + } + this.updatePromptStateFromEvents(taskRunId, [acpMsg], { isLive: true }); + + const msg = acpMsg.message; + + if ( + "id" in msg && + "result" in msg && + typeof msg.result === "object" && + msg.result !== null && + "stopReason" in msg.result + ) { + // Ignore responses that don't match the currently in-flight prompt id. + // A late response from a cancelled prior turn must not drain the queue + // or fire the "prompt complete" notification for the newer turn. + // We check against `session` (captured at the top of this function, pre-update), + // because updatePromptStateFromEvents above already cleared currentPromptId + // for a valid match — re-reading from the store would lose the distinction + // between "valid match just cleared" and "no turn was in flight". + if (session.currentPromptId !== msg.id) { + return; + } + + const stopReason = (msg.result as { stopReason?: string }).stopReason; + const hasQueuedMessages = this.drainQueuedMessages(taskRunId, session); + + // Only notify when queue is empty - queued messages will start a new turn + if (stopReason && !hasQueuedMessages) { + this.d.notifyPromptComplete( + session.taskTitle, + stopReason, + session.taskId, + ); + } + + this.d.taskViewedApi.markActivity(session.taskId); + } + + if ("method" in msg && msg.method === "session/update" && "params" in msg) { + const params = msg.params as { + update?: { + sessionUpdate?: string; + configOptions?: SessionConfigOption[]; + }; + }; + + // Handle config option updates (replaces current_mode_update) + if ( + params?.update?.sessionUpdate === "config_option_update" && + params.update.configOptions + ) { + const configOptions = params.update.configOptions; + this.d.store.updateSession(taskRunId, { + configOptions, + }); + // Persist the updated config options + this.d.setPersistedConfigOptions(taskRunId, configOptions); + this.d.log.info("Session config options updated", { taskRunId }); + } + + // Handle context usage updates + if (params?.update?.sessionUpdate === "usage_update") { + const update = params.update as { + used?: number; + size?: number; + }; + if ( + typeof update.used === "number" && + typeof update.size === "number" + ) { + this.d.store.updateSession(taskRunId, { + contextUsed: update.used, + contextSize: update.size, + }); + } + } + } + + // Handle SDK_SESSION notifications for adapter info + if ( + "method" in msg && + isNotification(msg.method, POSTHOG_NOTIFICATIONS.SDK_SESSION) && + "params" in msg + ) { + const params = msg.params as { + adapter?: Adapter; + }; + if (params?.adapter) { + this.d.store.updateSession(taskRunId, { + adapter: params.adapter, + }); + this.d.adapterStore.setAdapter(taskRunId, params.adapter); + } + } + + if ( + "method" in msg && + "params" in msg && + isNotification(msg.method, POSTHOG_NOTIFICATIONS.STATUS) + ) { + const params = msg.params as { status?: string; isComplete?: boolean }; + if (params?.status === "compacting") { + this.d.store.updateSession(taskRunId, { + isCompacting: !params.isComplete, + }); + } + } + + if ( + "method" in msg && + isNotification(msg.method, POSTHOG_NOTIFICATIONS.COMPACT_BOUNDARY) + ) { + this.d.store.updateSession(taskRunId, { + isCompacting: false, + }); + + this.drainQueuedMessages(taskRunId, session); + } + } + + private drainQueuedMessages( + taskRunId: string, + session: AgentSession, + ): boolean { + const freshSession = this.d.store.getSessions()[taskRunId]; + const hasQueuedMessages = + freshSession && + freshSession.messageQueue.length > 0 && + freshSession.status === "connected"; + + if (hasQueuedMessages) { + setTimeout(() => { + this.sendQueuedMessages(session.taskId).catch((err) => { + this.d.log.error("Failed to send queued messages", { + taskId: session.taskId, + error: err, + }); + }); + }, 0); + } + + return hasQueuedMessages; + } + + private handlePermissionRequest( + taskRunId: string, + payload: Omit<RequestPermissionRequest, "sessionId"> & { + taskRunId: string; + }, + ): void { + this.d.log.info("Permission request received in renderer", { + taskRunId, + toolCallId: payload.toolCall.toolCallId, + title: payload.toolCall.title, + }); + + // Get fresh session state + const session = this.d.store.getSessions()[taskRunId]; + if (!session) { + this.d.log.warn("Session not found for permission request", { + taskRunId, + }); + return; + } + + const newPermissions = new Map(session.pendingPermissions); + // Add receivedAt to create PermissionRequest + newPermissions.set(payload.toolCall.toolCallId, { + ...payload, + receivedAt: Date.now(), + }); + + 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 { + this.d.log.info("Cloud permission request received", { + taskRunId, + requestId: update.requestId, + toolCallId: update.toolCall.toolCallId, + title: update.toolCall.title, + }); + + const session = this.d.store.getSessions()[taskRunId]; + if (!session) { + this.d.log.warn("Session not found for cloud permission request", { + taskRunId, + }); + return; + } + + // Store the cloud requestId so we can route the response back + this.cloudPermissionRequestIds.set( + update.toolCall.toolCallId, + update.requestId, + ); + + const newPermissions = new Map(session.pendingPermissions); + newPermissions.set(update.toolCall.toolCallId, { + toolCall: update.toolCall as PermissionRequest["toolCall"], + options: update.options as PermissionRequest["options"], + taskRunId, + receivedAt: Date.now(), + }); + + this.d.store.setPendingPermissions(taskRunId, newPermissions); + this.d.taskViewedApi.markActivity(session.taskId); + this.d.notifyPermissionRequest(session.taskTitle, session.taskId); + } + + // --- Prompt Handling --- + + /** + * Send a prompt to the agent. + * Queues if a prompt is already pending. + */ + async sendPrompt( + taskId: string, + prompt: string | ContentBlock[], + ): Promise<{ stopReason: string }> { + if (!this.d.getIsOnline()) { + throw new Error( + "No internet connection. Please check your connection and try again.", + ); + } + + 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 (this.d.addDirectoryDialog.open) { + throw new Error( + "Confirm the folder access dialog before sending your message.", + ); + } + + if (session.isCloud) { + return this.sendCloudPrompt(session, prompt); + } + + if (session.status !== "connected") { + if (session.status === "error") { + throw new Error( + session.errorMessage || + "Session is in error state. Please retry or start a new session.", + ); + } + if (session.status === "connecting") { + throw new Error( + "Session is still connecting. Please wait and try again.", + ); + } + throw new Error(`Session is not ready (status: ${session.status})`); + } + + if (session.isPromptPending || session.isCompacting) { + const promptText = extractPromptText(prompt); + this.d.store.enqueueMessage(taskId, promptText); + this.d.log.info("Message queued", { + taskId, + queueLength: session.messageQueue.length + 1, + reason: session.isCompacting ? "compacting" : "prompt_pending", + }); + return { stopReason: "queued" }; + } + + let blocks = normalizePromptToBlocks(prompt); + + const shellExecutes = getUserShellExecutesSinceLastPrompt(session.events); + if (shellExecutes.length > 0) { + const contextBlocks = shellExecutesToContextBlocks(shellExecutes); + blocks = [...contextBlocks, ...blocks]; + } + + const promptText = extractPromptText(prompt); + this.d.track(ANALYTICS_EVENTS.PROMPT_SENT, { + task_id: taskId, + is_initial: session.events.length === 0, + execution_type: "local", + prompt_length_chars: promptText.length, + }); + + // Show the user's message in the chat immediately, before any respawn + this.applyOptimisticPrompt(session.taskRunId, blocks, promptText); + + if (promptReferencesAbsoluteFolder(prompt)) { + const repoPath = this.localRepoPaths.get(taskId); + if (repoPath) { + try { + await this.reconnectInPlace(taskId, repoPath); + } catch (err) { + 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, + }); + 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.", + }); + throw err instanceof Error + ? err + : new Error("Failed to apply additional directories"); + } + const refreshed = this.d.store.getSessionByTaskId(taskId); + if (refreshed) { + session = refreshed; + } + } + } + + return this.sendLocalPrompt(session, blocks, promptText, { + optimisticApplied: true, + }); + } + + /** + * Send all queued messages as a single prompt. + * Called internally when a turn completes and there are queued messages. + * Queue is cleared atomically before sending - if sending fails, messages are lost + * (this is acceptable since the user can re-type; avoiding complex retry logic). + */ + private async sendQueuedMessages( + taskId: string, + ): Promise<{ stopReason: string }> { + const combinedText = this.d.store.dequeueMessagesAsText(taskId); + if (!combinedText) { + return { stopReason: "skipped" }; + } + + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) { + this.d.log.warn("No session found for queued messages, messages lost", { + taskId, + lostMessageLength: combinedText.length, + }); + return { stopReason: "no_session" }; + } + + this.d.log.info("Sending queued messages as single prompt", { + taskId, + promptLength: combinedText.length, + }); + + let blocks = normalizePromptToBlocks(combinedText); + + const shellExecutes = getUserShellExecutesSinceLastPrompt(session.events); + if (shellExecutes.length > 0) { + const contextBlocks = shellExecutesToContextBlocks(shellExecutes); + blocks = [...contextBlocks, ...blocks]; + } + + this.d.track(ANALYTICS_EVENTS.PROMPT_SENT, { + task_id: taskId, + is_initial: false, + execution_type: "local", + prompt_length_chars: combinedText.length, + }); + + try { + return await this.sendLocalPrompt(session, blocks, combinedText); + } catch (error) { + // Log that queued messages were lost due to send failure + this.d.log.error("Failed to send queued messages, messages lost", { + taskId, + lostMessageLength: combinedText.length, + error, + }); + throw error; + } + } + + private applyOptimisticPrompt( + taskRunId: string, + blocks: ContentBlock[], + promptText: string, + ): void { + this.d.store.updateSession(taskRunId, { + isPromptPending: true, + promptStartedAt: Date.now(), + pausedDurationMs: 0, + }); + + const skillButtonId = this.d.h.extractSkillButtonId(blocks); + if (skillButtonId) { + this.d.store.appendOptimisticItem(taskRunId, { + type: "skill_button_action", + buttonId: skillButtonId, + }); + } else { + this.d.store.appendOptimisticItem(taskRunId, { + type: "user_message", + content: promptText, + timestamp: Date.now(), + }); + } + } + + private async sendLocalPrompt( + session: AgentSession, + blocks: ContentBlock[], + promptText: string, + options: { optimisticApplied?: boolean } = {}, + ): Promise<{ stopReason: string }> { + if (!options.optimisticApplied) { + this.applyOptimisticPrompt(session.taskRunId, blocks, promptText); + } + + try { + const result = await this.d.trpc.agent.prompt.mutate({ + sessionId: session.taskRunId, + prompt: blocks, + }); + this.d.store.updateSession(session.taskRunId, { + isPromptPending: false, + promptStartedAt: null, + }); + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorDetails = (error as { data?: { details?: string } }).data + ?.details; + + this.d.store.clearOptimisticItems(session.taskRunId); + + if (isRateLimitError(errorMessage, errorDetails)) { + this.d.log.warn("Rate limit exceeded, showing usage limit modal", { + taskRunId: session.taskRunId, + }); + this.d.store.updateSession(session.taskRunId, { + isPromptPending: false, + promptStartedAt: null, + }); + this.d.usageLimit.show(); + return { stopReason: "rate_limited" }; + } + + if (isFatalSessionError(errorMessage, errorDetails)) { + this.d.log.error("Fatal prompt error, attempting recovery", { + taskRunId: session.taskRunId, + errorMessage, + errorDetails, + }); + this.startAutoRecoverLocalSession( + session.taskId, + session.taskRunId, + session.taskTitle, + errorDetails || errorMessage, + errorDetails || + "Session connection lost. Please retry or start a new session.", + ); + } else { + this.d.store.updateSession(session.taskRunId, { + isPromptPending: false, + isCompacting: false, + promptStartedAt: null, + }); + } + + throw error; + } + } + + /** + * Cancel the current prompt. + */ + async cancelPrompt(taskId: string): Promise<boolean> { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) return false; + + this.d.store.updateSession(session.taskRunId, { + isPromptPending: false, + promptStartedAt: null, + }); + + if (session.isCloud) { + return this.cancelCloudPrompt(session); + } + + try { + const result = await this.d.trpc.agent.cancelPrompt.mutate({ + sessionId: session.taskRunId, + }); + + const durationSeconds = Math.round( + (Date.now() - session.startedAt) / 1000, + ); + const promptCount = session.events.filter( + (e) => "method" in e.message && e.message.method === "session/prompt", + ).length; + this.d.track(ANALYTICS_EVENTS.TASK_RUN_CANCELLED, { + task_id: taskId, + execution_type: "local", + duration_seconds: durationSeconds, + prompts_sent: promptCount, + }); + + return result; + } catch (error) { + this.d.log.error("Failed to cancel prompt", error); + return false; + } + } + + // --- Cloud Commands --- + + private async sendCloudPrompt( + session: AgentSession, + prompt: string | ContentBlock[], + options?: { skipQueueGuard?: boolean }, + ): Promise<{ stopReason: string }> { + const transport = this.d.h.getCloudPromptTransport(prompt); + if (!transport.messageText && transport.filePaths.length === 0) { + return { stopReason: "empty" }; + } + + if (isTerminalStatus(session.cloudStatus)) { + // If the agent never booted (no `run_started`), resuming spins another + // sandbox that hits the same provisioning failure — surface the error + // instead of looping. + if (session.cloudStatus === "failed" && session.status !== "connected") { + throw new Error( + session.cloudErrorMessage ?? + "Cloud run couldn't start. Check that GitHub is connected for this project, then try again.", + ); + } + return this.resumeCloudRun(session, prompt); + } + + if (session.cloudStatus !== "in_progress") { + this.d.store.enqueueMessage(session.taskId, transport.promptText); + this.d.log.info("Cloud message queued (sandbox not ready)", { + taskId: session.taskId, + cloudStatus: session.cloudStatus, + }); + return { stopReason: "queued" }; + } + + // Agent-readiness guard: until we've received `_posthog/run_started` + // (which flips `session.status` to `"connected"`), the agent may + // still be booting / restoring after a sandbox restart, or mid- + // initial-prompt — sending now would race with its + // `clientConnection.prompt(initialPrompt)` on the same ACP session. + // Funnel through the queue; the run_started or turn_complete + // handlers will drain it once the agent is provably ready. + if ( + !options?.skipQueueGuard && + session.isCloud && + session.status !== "connected" + ) { + 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, + }); + // The watcher may have exhausted its reconnect budget and been left in a + // failed state — without an SSE stream, no `turn_complete` will arrive + // to drain the queue. Kick a retry so the stream comes back online; the + // queued message dispatches naturally once `run_started`/`turn_complete` + // is observed. + if (session.status === "disconnected" || session.status === "error") { + this.retryCloudTaskWatch(session.taskId).catch((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) { + this.d.store.enqueueMessage(session.taskId, transport.promptText, prompt); + this.d.log.info("Cloud message queued", { + taskId: session.taskId, + queueLength: session.messageQueue.length + 1, + }); + return { stopReason: "queued" }; + } + + const [auth, cloudCommandAuth] = await Promise.all([ + this.getAuthCredentials(), + this.getCloudCommandAuth(), + ]); + if (!auth || !cloudCommandAuth) { + throw new Error("Authentication required for cloud commands"); + } + + this.watchCloudTask( + session.taskId, + session.taskRunId, + cloudCommandAuth.apiHost, + cloudCommandAuth.teamId, + undefined, + session.logUrl, + undefined, + session.adapter ?? "claude", + ); + + const artifactIds = await this.d.h.uploadRunAttachments( + auth.client, + session.taskId, + session.taskRunId, + transport.filePaths, + ); + const params: Record<string, unknown> = {}; + if (transport.messageText) { + params.content = transport.messageText; + } + if (artifactIds.length > 0) { + params.artifact_ids = artifactIds; + } + + const currentSessionBeforeSend = + this.getSessionByRunId(session.taskRunId) ?? session; + const idleEvidenceBeforeSend = this.cloudRunIdleTracker.capture( + currentSessionBeforeSend, + ); + this.d.store.updateSession(session.taskRunId, { + isPromptPending: true, + promptStartedAt: Date.now(), + pausedDurationMs: 0, + agentIdleForRunId: undefined, + }); + this.cloudRunIdleTracker.markBusy(currentSessionBeforeSend); + this.d.store.appendOptimisticItem(session.taskRunId, { + type: "user_message", + content: transport.promptText, + timestamp: Date.now(), + pinToTop: false, + }); + + this.d.track(ANALYTICS_EVENTS.PROMPT_SENT, { + task_id: session.taskId, + is_initial: session.events.length === 0, + execution_type: "cloud", + prompt_length_chars: transport.promptText.length, + }); + + try { + const result = await this.d.trpc.cloudTask.sendCommand.mutate({ + taskId: session.taskId, + runId: session.taskRunId, + apiHost: cloudCommandAuth.apiHost, + teamId: cloudCommandAuth.teamId, + method: "user_message", + params, + }); + + if (!result.success) { + throw new Error(result.error ?? "Failed to send cloud command"); + } + + const commandResult = result.result as + | { queued?: boolean; stopReason?: string } + | undefined; + const stopReason = commandResult?.queued + ? "queued" + : (commandResult?.stopReason ?? "end_turn"); + + return { stopReason }; + } catch (error) { + this.d.store.updateSession(session.taskRunId, { + isPromptPending: false, + promptStartedAt: null, + }); + this.d.store.clearTailOptimisticItems(session.taskRunId); + const currentSessionAfterFailure = this.getSessionByRunId( + session.taskRunId, + ); + if (currentSessionAfterFailure) { + const restoreResult = this.cloudRunIdleTracker.restoreAfterFailedSend( + idleEvidenceBeforeSend, + currentSessionAfterFailure, + ); + if (restoreResult) { + this.d.log.warn("Restored idle evidence after failed cloud send", { + taskId: session.taskId, + taskRunId: session.taskRunId, + }); + if ( + currentSessionAfterFailure.agentIdleForRunId !== + restoreResult.agentIdleForRunId + ) { + this.d.store.updateSession(session.taskRunId, { + agentIdleForRunId: restoreResult.agentIdleForRunId, + }); + } + } + } + throw error; + } + } + + /** + * Dispatches all currently queued cloud messages as a single combined + * prompt. Drains the queue up-front and rolls it back on failure so the + * next dispatch trigger (turn_complete, cloudStatus flip) can retry. A + * per-taskId re-entrance guard prevents concurrent triggers from + * double-dispatching. + * + * Pre-flight conditions match what `sendCloudPrompt` would otherwise + * silently re-queue on (sandbox not in_progress, prompt already pending). + * Skipping early lets the next trigger retry instead of re-queueing the + * already-dequeued prompt back into the same queue. + */ + private async sendQueuedCloudMessages(taskId: string): Promise<void> { + if (this.dispatchingCloudQueues.has(taskId)) return; + + this.dispatchingCloudQueues.add(taskId); + try { + 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. + // Otherwise gate on the agent-ready handshake (`run_started` flips + // status to "connected") to avoid racing with `sendInitialTaskMessage`. + const isTerminal = isTerminalStatus(session.cloudStatus); + const canSendNow = + isTerminal || + (session.cloudStatus === "in_progress" && + session.status === "connected"); + if (!canSendNow || session.isPromptPending) return; + + const drained = this.d.store.dequeueMessages(taskId); + const combined = this.d.h.combineQueuedCloudPrompts(drained); + if (!combined) return; + + this.d.log.info("Sending queued cloud messages", { + taskId, + drainedCount: drained.length, + }); + + try { + await this.sendCloudPrompt(session, combined, { + skipQueueGuard: true, + }); + } catch (err) { + this.d.log.warn("Cloud queue dispatch failed; re-enqueueing", { + taskId, + error: String(err), + }); + this.d.store.prependQueuedMessages(taskId, drained); + } + } finally { + this.dispatchingCloudQueues.delete(taskId); + } + } + + private async resumeCloudRun( + session: AgentSession, + prompt: string | ContentBlock[], + ): Promise<{ stopReason: string }> { + const authCredentials = await this.getAuthCredentials(); + if (!authCredentials) { + throw new Error("Authentication required for cloud commands"); + } + const auth = await this.getCloudCommandAuth(); + if (!auth) { + throw new Error("Authentication required for cloud commands"); + } + + const transport = this.d.h.getCloudPromptTransport(prompt); + if (!transport.messageText && transport.filePaths.length === 0) { + return { stopReason: "empty" }; + } + const artifactIds = await this.d.h.uploadTaskStagedAttachments( + authCredentials.client, + session.taskId, + transport.filePaths, + ); + + const previousRun = await authCredentials.client.getTaskRun( + session.taskId, + session.taskRunId, + ); + const previousState = previousRun.state as Record<string, unknown>; + const previousOutput = (previousRun.output ?? {}) as Record< + string, + unknown + >; + // Prefer the actual working branch the agent last pushed to (synced by + // agent-server after each turn), then the run-level branch field, then + // the original base branch from state. This preserves unmerged work when + // the snapshot has expired and the sandbox is rebuilt from scratch. + const previousBaseBranch = + (typeof previousOutput.head_branch === "string" + ? previousOutput.head_branch + : null) ?? + previousRun.branch ?? + (typeof previousState.pr_base_branch === "string" + ? previousState.pr_base_branch + : null) ?? + session.cloudBranch; + const prAuthorshipMode = getCloudPrAuthorshipMode(previousState); + + this.d.log.info("Creating resume run for terminal cloud task", { + taskId: session.taskId, + previousRunId: session.taskRunId, + previousStatus: session.cloudStatus, + }); + + 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. + // The agent will load conversation history and restore the sandbox snapshot. + const updatedTask = await authCredentials.client.runTaskInCloud( + session.taskId, + previousBaseBranch, + { + adapter: runtimeOptions.adapter, + model: runtimeOptions.model, + reasoningLevel: runtimeOptions.reasoningLevel, + resumeFromRunId: session.taskRunId, + pendingUserMessage: transport.messageText, + pendingUserArtifactIds: + artifactIds.length > 0 ? artifactIds : undefined, + prAuthorshipMode, + runSource: getCloudRunSource(previousState), + signalReportId: + typeof previousState.signal_report_id === "string" + ? previousState.signal_report_id + : undefined, + }, + ); + const newRun = updatedTask.latest_run; + if (!newRun?.id) { + throw new Error("Failed to create resume run"); + } + + // Replace session with one for the new run, preserving conversation history. + // setSession handles old session cleanup via taskIdIndex. + const newSession = createBaseSession( + newRun.id, + session.taskId, + session.taskTitle, + ); + newSession.status = "disconnected"; + newSession.isCloud = true; + // Carry over existing events and add optimistic user bubble for the follow-up. + // Reset processedLineCount to 0 because the new run's log stream starts fresh. + newSession.events = [ + ...session.events, + createUserPromptEvent( + transport.filePaths.length > 0 + ? this.d.h.cloudPromptToBlocks(prompt) + : [{ type: "text", text: transport.promptText }], + Date.now(), + ), + ]; + newSession.processedLineCount = 0; + // 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; + 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. + + // Start the watcher immediately so we don't miss status updates. + const initialMode = + typeof newRun.state?.initial_permission_mode === "string" + ? newRun.state.initial_permission_mode + : undefined; + const priorModel = getConfigOptionByCategory( + session.configOptions, + "model", + )?.currentValue; + const initialModel = + newRun.model ?? (typeof priorModel === "string" ? priorModel : undefined); + this.watchCloudTask( + session.taskId, + newRun.id, + auth.apiHost, + auth.teamId, + undefined, + newRun.log_url, + initialMode, + newRun.runtime_adapter ?? session.adapter ?? "claude", + initialModel, + ); + + // Invalidate task queries so the UI picks up the new run metadata + this.d.queryClient.invalidateQueries({ queryKey: ["tasks"] }); + + this.d.track(ANALYTICS_EVENTS.PROMPT_SENT, { + task_id: session.taskId, + is_initial: false, + execution_type: "cloud", + prompt_length_chars: transport.promptText.length, + }); + + return { stopReason: "queued" }; + } + + private async cancelCloudPrompt(session: AgentSession): Promise<boolean> { + if (isTerminalStatus(session.cloudStatus)) { + this.d.log.info("Skipping cancel for terminal cloud run", { + taskId: session.taskId, + status: session.cloudStatus, + }); + return false; + } + + const auth = await this.getCloudCommandAuth(); + if (!auth) { + this.d.log.error("No auth for cloud cancel"); + return false; + } + + try { + const result = await this.d.trpc.cloudTask.sendCommand.mutate({ + taskId: session.taskId, + runId: session.taskRunId, + apiHost: auth.apiHost, + teamId: auth.teamId, + method: "cancel", + }); + + const durationSeconds = Math.round( + (Date.now() - session.startedAt) / 1000, + ); + const promptCount = session.events.filter( + (e) => "method" in e.message && e.message.method === "session/prompt", + ).length; + this.d.track(ANALYTICS_EVENTS.TASK_RUN_CANCELLED, { + task_id: session.taskId, + execution_type: "cloud", + duration_seconds: durationSeconds, + prompts_sent: promptCount, + }); + + if (!result.success) { + this.d.log.warn("Cloud cancel command failed", { error: result.error }); + return false; + } + + return true; + } catch (error) { + this.d.log.error("Failed to cancel cloud prompt", error); + return false; + } + } + + private async getCloudCommandAuth(): Promise<{ + apiHost: string; + teamId: number; + } | null> { + const authState = await this.d.fetchAuthState(); + if (!authState.cloudRegion || !authState.projectId) return null; + return { + apiHost: getCloudUrlFromRegion(authState.cloudRegion), + teamId: authState.projectId, + }; + } + + /** + * Send a command to the cloud agent server via the backend proxy. + * Handles auth lookup and throws if credentials are unavailable. + */ + private async sendCloudCommand( + session: AgentSession, + method: "permission_response" | "set_config_option", + params: Record<string, unknown>, + ): Promise<void> { + const auth = await this.getCloudCommandAuth(); + if (!auth) { + throw new Error("No cloud auth credentials available"); + } + await this.d.trpc.cloudTask.sendCommand.mutate({ + taskId: session.taskId, + runId: session.taskRunId, + apiHost: auth.apiHost, + teamId: auth.teamId, + method, + params, + }); + } + + // --- Permissions --- + + private resolvePermission(session: AgentSession, toolCallId: string): void { + const permission = session.pendingPermissions.get(toolCallId); + const newPermissions = new Map(session.pendingPermissions); + newPermissions.delete(toolCallId); + this.d.store.setPendingPermissions(session.taskRunId, newPermissions); + + if (permission?.receivedAt) { + this.d.store.updateSession(session.taskRunId, { + pausedDurationMs: + (session.pausedDurationMs ?? 0) + + (Date.now() - permission.receivedAt), + }); + } + } + + /** + * Respond to a permission request. + */ + async respondToPermission( + taskId: string, + toolCallId: string, + optionId: string, + customInput?: string, + answers?: Record<string, string>, + ): Promise<void> { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) { + this.d.log.error("No session found for permission response", { taskId }); + return; + } + + const permission = session.pendingPermissions.get(toolCallId); + this.d.track(ANALYTICS_EVENTS.PERMISSION_RESPONDED, { + task_id: taskId, + ...this.d.buildPermissionToolMetadata(permission, optionId, customInput), + }); + + const cloudRequestId = this.cloudPermissionRequestIds.get(toolCallId); + this.resolvePermission(session, toolCallId); + + try { + if (session.isCloud && cloudRequestId) { + this.cloudPermissionRequestIds.delete(toolCallId); + await this.sendCloudCommand(session, "permission_response", { + requestId: cloudRequestId, + optionId, + customInput, + answers, + }); + } else { + await this.d.trpc.agent.respondToPermission.mutate({ + taskRunId: session.taskRunId, + toolCallId, + optionId, + customInput, + answers, + }); + } + + this.d.log.info("Permission response sent", { + taskId, + toolCallId, + optionId, + isCloud: !!cloudRequestId, + hasCustomInput: !!customInput, + }); + } catch (error) { + this.d.log.error("Failed to respond to permission", { + taskId, + toolCallId, + optionId, + error, + }); + } + } + + /** + * Cancel a permission request. + */ + async cancelPermission(taskId: string, toolCallId: string): Promise<void> { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) { + this.d.log.error("No session found for permission cancellation", { + taskId, + }); + return; + } + + const permission = session.pendingPermissions.get(toolCallId); + this.d.track(ANALYTICS_EVENTS.PERMISSION_CANCELLED, { + task_id: taskId, + ...this.d.buildPermissionToolMetadata(permission), + }); + + const cloudRequestId = this.cloudPermissionRequestIds.get(toolCallId); + this.resolvePermission(session, toolCallId); + + try { + if (session.isCloud && cloudRequestId) { + this.cloudPermissionRequestIds.delete(toolCallId); + await this.sendCloudCommand(session, "permission_response", { + requestId: cloudRequestId, + optionId: "reject_with_feedback", + customInput: "User cancelled the permission request.", + }); + } else { + await this.d.trpc.agent.cancelPermission.mutate({ + taskRunId: session.taskRunId, + toolCallId, + }); + } + + this.d.log.info("Permission cancelled", { + taskId, + toolCallId, + isCloud: !!cloudRequestId, + }); + } catch (error) { + this.d.log.error("Failed to cancel permission", { + taskId, + toolCallId, + error, + }); + } + } + + // --- Config Option Changes (Optimistic Updates) --- + + /** + * Set a session configuration option with optimistic update and rollback. + * This is the unified method for model, mode, thought level, etc. + */ + async setSessionConfigOption( + taskId: string, + configId: string, + value: string, + ): Promise<void> { + 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) { + this.d.log.warn("Config option not found", { taskId, configId }); + return; + } + + const previousValue = configOptions[optionIndex].currentValue; + + // Skip if value is already set — avoids expensive IPC round-trip (e.g. setModel ~2s) + if (previousValue === value) { + return; + } + + // Optimistic update + const updatedOptions = configOptions.map((opt) => + opt.id === configId + ? ({ ...opt, currentValue: value } as SessionConfigOption) + : opt, + ); + this.d.store.updateSession(session.taskRunId, { + configOptions: updatedOptions, + }); + this.d.updatePersistedConfigOptionValue(session.taskRunId, configId, value); + + if ( + !session.isCloud && + (session.idleKilled || + session.status === "disconnected" || + session.status === "connecting") + ) { + return; + } + + try { + if (session.isCloud) { + await this.sendCloudCommand(session, "set_config_option", { + configId, + value, + }); + } else { + await this.d.trpc.agent.setConfigOption.mutate({ + sessionId: session.taskRunId, + configId, + value, + }); + } + } catch (error) { + // Rollback on error + const rolledBackOptions = configOptions.map((opt) => + opt.id === configId + ? ({ ...opt, currentValue: previousValue } as SessionConfigOption) + : opt, + ); + this.d.store.updateSession(session.taskRunId, { + configOptions: rolledBackOptions, + }); + this.d.updatePersistedConfigOptionValue( + session.taskRunId, + configId, + String(previousValue), + ); + this.d.log.error("Failed to set session config option", { + taskId, + configId, + value, + error, + }); + this.d.toast.error("Failed to change setting. Please try again."); + } + } + + /** + * Set a session configuration option by category (e.g., "mode", "model"). + * This is a convenience method that looks up the config ID by category. + */ + async setSessionConfigOptionByCategory( + taskId: string, + category: string, + value: string, + ): Promise<void> { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) return; + + const configOption = getConfigOptionByCategory( + session.configOptions, + category, + ); + if (!configOption) { + this.d.log.warn("Config option not found for category", { + taskId, + category, + }); + return; + } + + if (configOption.currentValue !== value) { + this.d.track(ANALYTICS_EVENTS.SESSION_CONFIG_CHANGED, { + task_id: taskId, + category, + from_value: String(configOption.currentValue), + to_value: value, + }); + } + + await this.setSessionConfigOption(taskId, configOption.id, value); + } + + /** + * Start a user shell execute event (shows command as running). + * Call completeUserShellExecute with the same id when the command finishes. + */ + async startUserShellExecute( + taskId: string, + id: string, + command: string, + cwd: string, + ): Promise<void> { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) return; + + const event = createUserShellExecuteEvent(command, cwd, undefined, id); + this.d.store.appendEvents(session.taskRunId, [event]); + } + + /** + * Complete a user shell execute event with results. + * Must be called after startUserShellExecute with the same id. + */ + async completeUserShellExecute( + taskId: string, + id: string, + command: string, + cwd: string, + result: { stdout: string; stderr: string; exitCode: number }, + ): Promise<void> { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) return; + + const storedEntry: StoredLogEntry = { + type: "notification", + timestamp: new Date().toISOString(), + notification: { + method: "_array/user_shell_execute", + params: { id, command, cwd, result }, + }, + }; + + const event = createUserShellExecuteEvent(command, cwd, result, id); + + await this.appendAndPersist(taskId, session, event, storedEntry); + } + + /** + * Retry connecting to the existing session (resume attempt using + * the sessionId from logs). Does NOT tear down — avoids the connect + * effect loop. + * + * If the session failed before any conversation started (has an + * initialPrompt saved from the original creation attempt), creates + * a fresh session and re-sends the prompt instead of reconnecting + * to an empty session. + */ + async clearSessionError(taskId: string, repoPath: string): Promise<void> { + this.localRepoPaths.set(taskId, repoPath); + const session = this.d.store.getSessionByTaskId(taskId); + if (session?.initialPrompt?.length) { + const { taskTitle, initialPrompt } = session; + await this.teardownSession(session.taskRunId); + const auth = await this.getAuthCredentials(); + if (!auth) { + throw new Error( + "Unable to reach server. Please check your connection.", + ); + } + await this.createNewLocalSession( + taskId, + taskTitle, + repoPath, + auth, + initialPrompt, + ); + return; + } + await this.reconnectInPlace(taskId, repoPath); + } + + /** + * Start a fresh session for a task, abandoning the old conversation. + * Clears the backend sessionId so the next reconnect creates a new + * session instead of attempting to resume the stale one. + */ + async resetSession(taskId: string, repoPath: string): Promise<void> { + this.localRepoPaths.set(taskId, repoPath); + await this.reconnectInPlace(taskId, repoPath, null); + } + + /** + * Cancel the current backend agent and reconnect under the same taskRunId. + * Does NOT remove the session from the store (avoids connect effect loop). + * Overwrites the store session in place via reconnectToLocalSession. + * + * @param overrideSessionId - Controls which sessionId is used for reconnect: + * - `undefined` (default): use the sessionId from logs (resume attempt) + * - `null`: strip the sessionId so the backend creates a fresh session + * - `string`: use that specific sessionId + */ + private async reconnectInPlace( + taskId: string, + repoPath: string, + overrideSessionId?: string | null, + ): Promise<boolean> { + this.localRepoPaths.set(taskId, repoPath); + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) return false; + + const { taskRunId, taskTitle, logUrl } = session; + + // Cancel lingering backend agent (ignore errors — it may not exist + // after a failed reconnect) + try { + await this.d.trpc.agent.cancel.mutate({ sessionId: taskRunId }); + } catch { + // expected when backend has no session + } + this.unsubscribeFromChannel(taskRunId); + + const auth = await this.getAuthCredentials(); + if (!auth) { + throw new Error("Unable to reach server. Please check your connection."); + } + + const prefetchedLogs = await this.fetchSessionLogs(logUrl, taskRunId); + + // Determine sessionId: undefined = use from logs, null = strip (fresh), string = use as-is + const sessionId = + overrideSessionId === null + ? undefined + : (overrideSessionId ?? prefetchedLogs.sessionId); + + return this.reconnectToLocalSession( + taskId, + taskRunId, + taskTitle, + logUrl, + repoPath, + auth, + { ...prefetchedLogs, sessionId }, + ); + } + + /** + * Fetch model/effort options from the main-process preview-config endpoint + * and merge them into the cloud session's configOptions. Cached per + * (apiHost, adapter) so repeated visits don't refetch. + * + * Runs fire-and-forget: the session stays usable with just the `mode` option + * if the fetch fails or is still in flight. + */ + private async fetchAndApplyCloudPreviewOptions( + taskRunId: string, + apiHost: string, + adapter: Adapter, + initialModel?: string, + ): Promise<void> { + const cacheKey = `${apiHost}::${adapter}`; + let pending = this.previewConfigOptionsCache.get(cacheKey); + if (!pending) { + pending = this.d.trpc.agent.getPreviewConfigOptions + .query({ apiHost, adapter }) + .catch((err: unknown) => { + this.d.log.warn( + "Failed to fetch preview config options for cloud session", + { + apiHost, + adapter, + error: err, + }, + ); + this.previewConfigOptionsCache.delete(cacheKey); + return [] as SessionConfigOption[]; + }); + this.previewConfigOptionsCache.set(cacheKey, pending); + } + + const previewOptions = await pending; + const extras = previewOptions + .filter( + (opt) => opt.category === "model" || opt.category === "thought_level", + ) + .map((opt) => { + if ( + opt.category === "model" && + opt.type === "select" && + typeof initialModel === "string" + ) { + const flat = flattenSelectOptions(opt.options); + if (flat.some((o) => o.value === initialModel)) { + return { ...opt, currentValue: initialModel }; + } + } + return opt; + }); + + if (extras.length === 0) return; + + const session = this.d.store.getSessions()[taskRunId]; + if (!session) return; + + const existingOptions = session.configOptions ?? []; + const existingIds = new Set(existingOptions.map((o) => o.id)); + const newExtras = extras.filter((o) => !existingIds.has(o.id)); + if (newExtras.length === 0) return; + const merged = [...existingOptions, ...newExtras]; + + this.d.store.updateSession(taskRunId, { configOptions: merged }); + } + + /** + * Start watching a cloud task via main-process CloudTaskService. + * + * The watcher stays alive across navigation. A fresh watcher is created only + * on first visit or when the runId changes (new run started). Terminal + * status triggers full teardown from within handleCloudTaskUpdate via + * stopCloudTaskWatch(). + */ + watchCloudTask( + taskId: string, + runId: string, + apiHost: string, + teamId: number, + onStatusChange?: () => void, + logUrl?: string, + initialMode?: string, + adapter: Adapter = "claude", + initialModel?: string, + taskDescription?: string, + ): () => void { + const taskRunId = runId; + const existingWatcher = this.cloudTaskWatchers.get(taskId); + + // Resuming same run — reuse the existing watcher. + if ( + existingWatcher && + existingWatcher.runId === runId && + existingWatcher.apiHost === apiHost && + existingWatcher.teamId === teamId + ) { + if (onStatusChange) { + existingWatcher.onStatusChange = onStatusChange; + } + // Ensure configOptions is populated on revisit + const existing = this.d.store.getSessionByTaskId(taskId); + if (existing) { + const existingMode = getConfigOptionByCategory( + existing.configOptions, + "mode", + )?.currentValue; + const currentMode = + typeof existingMode === "string" ? existingMode : initialMode; + const shouldRefreshConfigOptions = + !existing.configOptions?.length || existing.adapter !== adapter; + if (shouldRefreshConfigOptions) { + this.d.store.updateSession(existing.taskRunId, { + adapter, + configOptions: buildCloudDefaultConfigOptions(currentMode, adapter), + }); + } + void this.fetchAndApplyCloudPreviewOptions( + existing.taskRunId, + apiHost, + adapter, + initialModel, + ); + } + return () => {}; + } + + // Different run — full cleanup of old watcher first + if (existingWatcher) { + this.stopCloudTaskWatch(taskId); + } + + const startToken = ++this.nextCloudTaskWatchToken; + + // Create session in the store + 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. + const shouldResetExistingSession = + existing?.taskRunId === taskRunId && + existing.events.length > 0 && + existing.processedLineCount === undefined; + const shouldHydrateSession = + !existing || + existing.taskRunId !== taskRunId || + shouldResetExistingSession || + existing.events.length === 0; + + if ( + !existing || + existing.taskRunId !== taskRunId || + shouldResetExistingSession + ) { + const taskTitle = existing?.taskTitle ?? "Cloud Task"; + const session = createBaseSession(taskRunId, taskId, taskTitle); + session.status = "disconnected"; + session.isCloud = true; + session.adapter = adapter; + session.configOptions = buildCloudDefaultConfigOptions( + initialMode, + adapter, + ); + 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 + // the description at top until hydration replaced it. + } else { + // Ensure cloud flag and configOptions are set on existing sessions + const updates: Partial<AgentSession> = {}; + if (!existing.isCloud) updates.isCloud = true; + if (existing.adapter !== adapter) updates.adapter = adapter; + if (!existing.configOptions?.length || existing.adapter !== adapter) { + const existingMode = getConfigOptionByCategory( + existing.configOptions, + "mode", + )?.currentValue; + const currentMode = + typeof existingMode === "string" ? existingMode : initialMode; + updates.configOptions = buildCloudDefaultConfigOptions( + currentMode, + adapter, + ); + } + if (Object.keys(updates).length > 0) { + this.d.store.updateSession(existing.taskRunId, updates); + } + } + + void this.fetchAndApplyCloudPreviewOptions( + taskRunId, + apiHost, + adapter, + initialModel, + ); + + if (shouldHydrateSession) { + this.hydrateCloudTaskSessionFromLogs( + taskId, + taskRunId, + logUrl, + taskDescription, + ); + } + + // Subscribe before starting the main-process watcher so the first replayed + // SSE/log burst cannot race ahead of the renderer subscription. + const subscription = this.d.trpc.cloudTask.onUpdate.subscribe( + { taskId, runId }, + { + onData: (update: CloudTaskUpdatePayload) => { + this.handleCloudTaskUpdate(taskRunId, update); + const watcher = this.cloudTaskWatchers.get(taskId); + if ( + (update.kind === "status" || + update.kind === "snapshot" || + update.kind === "error") && + watcher?.onStatusChange + ) { + watcher.onStatusChange(); + } + }, + onError: (err: unknown) => + this.d.log.error("Cloud task subscription error", { taskId, err }), + }, + ); + + this.cloudTaskWatchers.set(taskId, { + runId, + apiHost, + teamId, + startToken, + subscription, + onStatusChange, + }); + + // Start main-process watcher after the subscription is attached. + void (async () => { + try { + if (!this.isCurrentCloudTaskWatcher(taskId, runId, startToken)) { + return; + } + + await this.d.trpc.cloudTask.watch.mutate({ + taskId, + runId, + apiHost, + teamId, + }); + + // 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 this.d.trpc.cloudTask.unwatch.mutate({ taskId, runId }); + } + } catch (err: unknown) { + if (!this.isCurrentCloudTaskWatcher(taskId, runId, startToken)) { + return; + } + this.d.log.warn("Failed to start cloud task watcher", { taskId, err }); + } + })(); + + return () => {}; + } + + private hydrateCloudTaskSessionFromLogs( + taskId: string, + taskRunId: string, + logUrl?: string, + taskDescription?: string, + ): void { + void (async () => { + const { rawEntries, totalLineCount } = await this.fetchSessionLogs( + logUrl, + taskRunId, + ); + + const session = this.d.store.getSessionByTaskId(taskId); + if (!session || session.taskRunId !== taskRunId) { + return; + } + + const events = convertStoredEntriesToEvents(rawEntries); + const hasUserPrompt = events.some( + (e: AcpMessage) => + isJsonRpcRequest(e.message) && e.message.method === "session/prompt", + ); + + // Seed the optimistic user-message bubble whenever the agent has + // not yet recorded an initial `session/prompt` request — covers the + // brand-new task case as well as "agent has emitted lifecycle + // notifications but hasn't received its first prompt yet". + if (!hasUserPrompt && taskDescription?.trim()) { + this.d.store.appendOptimisticItem(taskRunId, { + type: "user_message", + content: taskDescription, + timestamp: Date.now(), + }); + } + + if (rawEntries.length === 0) { + return; + } + + // If live updates already populated a processed count, don't overwrite + // that newer state with the persisted baseline fetched during startup. + if ( + session.processedLineCount !== undefined && + session.processedLineCount > 0 + ) { + return; + } + + this.d.store.updateSession(taskRunId, { + events, + isCloud: true, + logUrl: logUrl ?? session.logUrl, + processedLineCount: totalLineCount, + }); + // Without this the "Galumphing…" indicator stays hidden when the hydrated + // baseline already contains an in-flight session/prompt — the live delta + // path otherwise sees delta <= 0 and never re-evaluates the tail. + this.updatePromptStateFromEvents(taskRunId, events); + })().catch((err: unknown) => { + this.d.log.warn("Failed to hydrate cloud task session from logs", { + taskId, + taskRunId, + err, + }); + }); + } + + private isCurrentCloudTaskWatcher( + taskId: string, + runId: string, + startToken: number, + ): boolean { + const watcher = this.cloudTaskWatchers.get(taskId); + return watcher?.runId === runId && watcher.startToken === startToken; + } + + /** + * Fully stop a cloud task watcher. The tRPC subscription unwatches from the + * main process in its finally handler; the in-flight watch path below sends a + * compensating unwatch if teardown wins before watch.mutate lands. + */ + stopCloudTaskWatch(taskId: string): void { + const watcher = this.cloudTaskWatchers.get(taskId); + if (!watcher) return; + + watcher.subscription.unsubscribe(); + this.cloudTaskWatchers.delete(taskId); + this.cloudLogGapReconciler.forgetDeficiency(watcher.runId); + } + + async preflightToLocal(taskId: string, repoPath: string) { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) + return { + canHandoff: false as const, + localTreeDirty: false as const, + reason: "No session found", + }; + + const auth = await this.getHandoffAuth(); + if (!auth) + return { + canHandoff: false as const, + localTreeDirty: false as const, + reason: "Authentication required", + }; + + const preflight = await this.d.trpc.handoff.preflight.query({ + taskId, + runId: session.taskRunId, + repoPath, + apiHost: auth.apiHost, + teamId: auth.projectId, + }); + + return { + canHandoff: preflight.canHandoff, + localTreeDirty: preflight.localTreeDirty, + localGitState: preflight.localGitState, + changedFiles: preflight.changedFiles, + reason: preflight.reason, + }; + } + + async handoffToLocal(taskId: string, repoPath: string): Promise<void> { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) { + this.d.log.warn("No session found for handoff", { taskId }); + return; + } + + const runId = session.taskRunId; + const auth = await this.getHandoffAuth(); + if (!auth) return; + + this.d.store.updateSession(runId, { handoffInProgress: true }); + + try { + const preflight = await this.runHandoffPreflight( + taskId, + runId, + repoPath, + auth, + ); + this.stopCloudTaskWatch(taskId); + this.d.store.updateSession(runId, { status: "connecting" }); + await this.executeHandoff( + taskId, + runId, + repoPath, + auth, + preflight.localGitState, + ); + this.transitionToLocalSession(runId); + this.subscribeToChannel(runId); + await Promise.all([ + this.d.queryClient.refetchQueries({ queryKey: ["tasks"] }), + this.d.queryClient.refetchQueries({ + queryKey: this.d.WORKSPACE_QUERY_KEY, + }), + ]); + this.d.store.updateSession(runId, { handoffInProgress: false }); + this.d.log.info("Cloud-to-local handoff complete", { taskId, runId }); + } catch (err) { + 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); + this.d.store.updateSession(runId, { + handoffInProgress: false, + status: "disconnected", + }); + } + } + + async handoffToCloud(taskId: string, repoPath: string): Promise<void> { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) { + this.d.log.warn("No session found for cloud handoff", { taskId }); + return; + } + + const runId = session.taskRunId; + const auth = await this.getHandoffAuth(); + if (!auth) return; + + this.d.store.updateSession(runId, { handoffInProgress: true }); + + try { + const preflight = await this.d.trpc.handoff.preflightToCloud.query({ + taskId, + runId, + repoPath, + }); + if (!preflight.canHandoff) { + this.d.store.updateSession(runId, { + handoffInProgress: false, + }); + throw new Error(preflight.reason ?? "Cannot hand off to cloud"); + } + + this.unsubscribeFromChannel(runId); + this.d.store.updateSession(runId, { status: "connecting" }); + + const result = await this.d.trpc.handoff.executeToCloud.mutate({ + taskId, + runId, + repoPath, + apiHost: auth.apiHost, + teamId: auth.projectId, + localGitState: preflight.localGitState, + }); + if (!result.success) { + if (result.code === GITHUB_AUTHORIZATION_REQUIRED_CODE) { + throw new GitHubAuthorizationRequiredForCloudHandoffError( + result.error, + ); + } + throw new Error(result.error ?? "Handoff to cloud failed"); + } + + this.d.store.updateSession(runId, { + isCloud: true, + cloudStatus: undefined, + cloudStage: undefined, + cloudOutput: undefined, + cloudErrorMessage: undefined, + cloudBranch: undefined, + status: "disconnected", + processedLineCount: result.logEntryCount ?? 0, + }); + + this.watchCloudTask(taskId, runId, auth.apiHost, auth.projectId); + await Promise.all([ + this.d.queryClient.refetchQueries({ queryKey: ["tasks"] }), + this.d.queryClient.refetchQueries({ + queryKey: this.d.WORKSPACE_QUERY_KEY, + }), + ]); + this.d.store.updateSession(runId, { handoffInProgress: false }); + this.d.log.info("Local-to-cloud handoff complete", { taskId, runId }); + } catch (err) { + this.d.log.error("Handoff to cloud failed", { taskId, err }); + if (err instanceof GitHubAuthorizationRequiredForCloudHandoffError) { + await this.startGithubReauthForCloudHandoff(auth.projectId); + } else { + this.d.toast.error( + err instanceof Error ? err.message : "Handoff to cloud failed", + ); + } + this.subscribeToChannel(runId); + this.d.store.updateSession(runId, { + handoffInProgress: false, + status: "disconnected", + }); + } + } + + private async startGithubReauthForCloudHandoff( + projectId: number, + ): Promise<void> { + const client = await this.d.getAuthenticatedClient(); + if (!client) { + this.d.toast.error("Sign in before connecting GitHub."); + return; + } + + try { + const { install_url: installUrl } = + await client.startGithubUserIntegrationConnect(projectId); + const url = installUrl?.trim(); + if (!url) { + this.d.toast.error( + "GitHub connection did not return a URL. Please try again.", + ); + return; + } + + 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) { + this.d.toast.error( + error instanceof Error + ? error.message + : "Failed to start GitHub connection", + ); + } + } + + private async getHandoffAuth(): Promise<{ + apiHost: string; + projectId: number; + } | null> { + let auth: Awaited<ReturnType<SessionServiceDeps["fetchAuthState"]>>; + try { + auth = await this.d.fetchAuthState(); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + this.d.toast.error(`Authentication required for handoff: ${message}`); + return null; + } + if (!auth.projectId || !auth.cloudRegion) { + this.d.toast.error("Missing project configuration for handoff"); + return null; + } + return { + apiHost: getCloudUrlFromRegion(auth.cloudRegion), + projectId: auth.projectId, + }; + } + + private async runHandoffPreflight( + taskId: string, + runId: string, + repoPath: string, + auth: { apiHost: string; projectId: number }, + ): Promise<Awaited<ReturnType<typeof this.d.trpc.handoff.preflight.query>>> { + const preflight = await this.d.trpc.handoff.preflight.query({ + taskId, + runId, + repoPath, + apiHost: auth.apiHost, + teamId: auth.projectId, + }); + if (!preflight.canHandoff) { + this.d.store.updateSession(runId, { + handoffInProgress: false, + }); + throw new Error(preflight.reason ?? "Cannot hand off to local"); + } + return preflight; + } + + private async executeHandoff( + taskId: string, + runId: string, + repoPath: string, + auth: { apiHost: string; projectId: number }, + localGitState?: Awaited< + ReturnType<typeof this.d.trpc.handoff.preflight.query> + >["localGitState"], + ): Promise<void> { + const result = await this.d.trpc.handoff.execute.mutate({ + taskId, + runId, + repoPath, + apiHost: auth.apiHost, + teamId: auth.projectId, + localGitState, + }); + if (!result.success) { + throw new Error(result.error ?? "Handoff failed"); + } + } + + private transitionToLocalSession(runId: string): void { + this.d.store.updateSession(runId, { + isCloud: false, + cloudStatus: undefined, + cloudStage: undefined, + cloudOutput: undefined, + cloudErrorMessage: undefined, + cloudBranch: undefined, + status: "connected", + }); + } + + async retryCloudTaskWatch(taskId: string): Promise<void> { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session?.isCloud) { + throw new Error("No active cloud session for task"); + } + + const previousErrorTitle = session.errorTitle; + const previousErrorMessage = session.errorMessage; + + this.d.store.updateSession(session.taskRunId, { + status: "disconnected", + errorTitle: undefined, + errorMessage: undefined, + isPromptPending: false, + }); + + try { + await this.d.trpc.cloudTask.retry.mutate({ + taskId, + runId: session.taskRunId, + }); + } catch (error) { + this.d.store.updateSession(session.taskRunId, { + status: "error", + errorTitle: previousErrorTitle, + errorMessage: previousErrorMessage, + }); + throw error; + } + + // The main-process retry of an already-bootstrapped + // watcher only reconnects SSE (`start=latest`) and emits no fresh + // status/snapshot for an idle run, so the update-driven trigger in + // `handleCloudTaskUpdate` would never fire, the queued message would + // stay stuck. Attempt the same guarded recovery here once the reconnect + // request has been accepted. No-ops unless a queue is stranded on an + // idle, provably-alive run. + this.tryRecoverIdleCloudQueue(session.taskRunId); + } + + /** + * Retries every cloud session whose stream is in the `error` state, i.e. the + * main process exhausted its SSE reconnect budget and surfaced the manual + * Retry button. Invoked on window focus so users coming back to the app + * after a Django deploy, laptop sleep, or network blip don't have to click + * Retry themselves. + */ + public retryUnhealthyCloudSessions(): void { + const sessions = this.d.store.getSessions(); + for (const session of Object.values(sessions)) { + if (!session.isCloud) continue; + if (session.status !== "error") continue; + this.d.log.info("Auto-retrying errored cloud session on focus", { + taskId: session.taskId, + }); + this.retryCloudTaskWatch(session.taskId).catch((error) => { + this.d.log.warn("Auto-retry of errored cloud session failed", { + taskId: session.taskId, + error, + }); + }); + } + } + + public updateSessionTaskTitle(taskId: string, taskTitle: string): void { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) return; + + if (session.taskTitle === taskTitle) return; + + 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<string, string>, + ): Promise<PermissionSelectionPlan> { + 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<void> { + 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"); + } + + /** + * Drain the cloud queue, the deferral breaks out of + * the synchronous store-update frame so the dispatcher reads committed + * state; `sendQueuedCloudMessages` is reentrancy-guarded so stacked + * schedules from multiple triggers collapse to one. + */ + private scheduleCloudQueueFlush(taskId: string, reason: string): void { + if ( + this.scheduledCloudQueueFlushes.has(taskId) || + this.dispatchingCloudQueues.has(taskId) + ) { + return; + } + + this.scheduledCloudQueueFlushes.add(taskId); + setTimeout(() => { + this.scheduledCloudQueueFlushes.delete(taskId); + this.sendQueuedCloudMessages(taskId).catch((err) => + this.d.log.error("cloud queue flush failed", { + taskId, + reason, + error: err, + }), + ); + }, 0); + } + + /** + * Guarded recovery for a queued cloud message stranded by a transport + * drop on an idle, already-bootstrapped run. + * + * `run_started` is normally the canonical "agent is ready" trigger and + * would race with `sendInitialTaskMessage` while still booting, so the + * safe default remains "drain only once status is connected". But an + * idle run stays `in_progress` on the server while emitting NO fresh + * `run_started`/`turn_complete` (those only fire on boot or a new turn). + * If an SSE transport drop or the `retryCloudTaskWatch` it triggers + * flipped the session to disconnected/error AFTER the agent already + * booted for this exact run, nothing flips it back to "connected" and + * the queued message is stranded forever. When the run is provably + * alive (`cloudStatus === "in_progress"`) and the agent provably idle + * for THIS run (`isAgentIdleForRun`), recover readiness and drain. + */ + private tryRecoverIdleCloudQueue(taskRunId: string): void { + const session = this.d.store.getSessions()[taskRunId]; + if (!session?.isCloud || session.messageQueue.length === 0) { + return; + } + if (session.cloudStatus !== "in_progress") { + return; + } + if ( + this.scheduledCloudQueueFlushes.has(session.taskId) || + this.dispatchingCloudQueues.has(session.taskId) + ) { + return; + } + + const recoverableAfterTransportDrop = + (session.status === "disconnected" || session.status === "error") && + !session.isPromptPending; + + if (session.status !== "connected" && !recoverableAfterTransportDrop) { + return; + } + + // A local prompt in flight means a queued follow-up would double-send. + // The idle scan below is still the real safety check after reconnect. + if (session.isPromptPending) { + return; + } + + // The agent must be provably idle for this run, the + // connected path included. `status: "connected"` alone is NOT proof of + // idleness: the `_posthog/run_started` handler flips status to + // "connected" before the initial/resume turn even starts, so a + // connected-but-not-idle session is mid-boot. Draining now would race + // with `sendInitialTaskMessage`/`sendResumeMessage` and one prompt + // would be cancelled. Only `_posthog/turn_complete` makes the agent + // idle for the run. + const idleResult = this.cloudRunIdleTracker.evaluateIdle(session); + if (!idleResult.idle) { + return; + } + if (idleResult.shouldCacheToStore) { + this.d.store.updateSession(taskRunId, { + agentIdleForRunId: taskRunId, + }); + } + + if (recoverableAfterTransportDrop) { + this.d.store.updateSession(taskRunId, { + status: "connected", + errorTitle: undefined, + errorMessage: undefined, + }); + this.d.log.info( + "Recovered cloud session readiness after transport drop", + { + taskId: session.taskId, + previousStatus: session.status, + }, + ); + } + + this.scheduleCloudQueueFlush(session.taskId, "idle-run-recovery"); + } + + private handleCloudTaskUpdate( + taskRunId: string, + update: CloudTaskUpdatePayload, + ): void { + if (update.kind === "error") { + this.d.store.updateSession(taskRunId, { + status: "error", + errorTitle: update.errorTitle, + errorMessage: + update.errorMessage ?? + "Lost connection to the cloud run. Retry to reconnect.", + isPromptPending: false, + }); + return; + } + + if (update.kind === "permission_request") { + this.handleCloudPermissionRequest(taskRunId, update); + return; + } + + // Append new log entries with dedup guard + if ( + (update.kind === "logs" || update.kind === "snapshot") && + update.newEntries.length > 0 + ) { + // Cloud streams deliver `session/update` notifications as regular log + // entries rather than live ACP messages. Without this, config changes + // made mid-run (e.g. plan-approval switching to bypassPermissions) never + // reach the session store and the footer mode selector stays stale. + const latestConfigOptions = extractLatestConfigOptionsFromEntries( + update.newEntries, + ); + if (latestConfigOptions) { + this.d.store.updateSession(taskRunId, { + configOptions: latestConfigOptions, + }); + this.d.setPersistedConfigOptions(taskRunId, latestConfigOptions); + } + + const session = this.d.store.getSessions()[taskRunId]; + const currentCount = session?.processedLineCount ?? 0; + const expectedCount = update.totalEntryCount; + const plan = classifyCloudLogAppend( + currentCount, + expectedCount, + update.newEntries.length, + ); + + if (plan.kind === "caught-up") { + // Already caught up — skip duplicate entries + } else if (plan.kind === "append-tail") { + const entriesToAppend = update.newEntries.slice(-plan.tailCount); + let newEvents = convertStoredEntriesToEvents(entriesToAppend); + newEvents = this.filterSkippedPromptEvents( + taskRunId, + session, + newEvents, + ); + if (hasSessionPromptEvent(newEvents)) { + this.d.store.clearTailOptimisticItems(taskRunId); + } + this.d.store.appendEvents(taskRunId, newEvents, expectedCount); + this.updatePromptStateFromEvents(taskRunId, newEvents, { + isLive: true, + }); + } else { + this.cloudLogGapReconciler.reconcile({ + taskId: update.taskId, + taskRunId, + expectedCount, + currentCount, + newEntries: update.newEntries, + logUrl: session?.logUrl, + }); + } + } + + // NOTE: Don't auto-flush on `!isPromptPending && queue.length > 0` here. + // Setup-phase log batches (`_posthog/progress`, `_posthog/console`) stream + // in BEFORE the agent emits its initial `session/prompt` request, so + // `isPromptPending` is still false during those batches — firing the + // dispatcher then races with the agent's initial `clientConnection.prompt`. + // The canonical "agent is idle" signal is `_posthog/turn_complete`, which + // is handled in `updatePromptStateFromEvents`. + + // Update cloud status fields if present + if (update.kind === "status" || update.kind === "snapshot") { + this.d.store.updateCloudStatus(taskRunId, { + status: update.status, + stage: update.stage, + output: update.output, + errorMessage: update.errorMessage, + branch: update.branch, + }); + + if (update.status === "in_progress") { + this.tryRecoverIdleCloudQueue(taskRunId); + } + + if (isTerminalStatus(update.status)) { + // Clean up any pending resume messages that couldn't be sent + const session = this.d.store.getSessions()[taskRunId]; + if ( + session && + (session.messageQueue.length > 0 || session.isPromptPending) + ) { + this.d.store.clearMessageQueue(session.taskId); + this.d.store.updateSession(taskRunId, { + isPromptPending: false, + }); + } + this.stopCloudTaskWatch(update.taskId); + } + } + } + + /** + * 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 + * logs would duplicate the optimistic user bubble we already added. + */ + // Note: `session` is a snapshot from the start of handleCloudTaskUpdate. + // The updateSession call below makes it stale, but this is safe because + // skipPolledPromptCount is only ever 1, so this method runs at most once. + private filterSkippedPromptEvents( + taskRunId: string, + session: AgentSession | undefined, + events: AcpMessage[], + ): AcpMessage[] { + const plan = planSkippedPromptFilter( + session?.skipPolledPromptCount, + events, + ); + if (!plan) { + return events; + } + + this.d.store.updateSession(taskRunId, { + skipPolledPromptCount: plan.remainingSkipCount, + }); + return plan.events; + } + + // --- Helper Methods --- + + private async getAuthCredentials(): Promise<AuthCredentials | null> { + const authState = await this.d.fetchAuthState(); + const apiHost = authState.cloudRegion + ? getCloudUrlFromRegion(authState.cloudRegion) + : null; + const projectId = authState.projectId; + const client = this.d.createAuthenticatedClient(authState); + + if (!apiHost || !projectId || !client) return null; + return { apiHost, projectId, client }; + } + + private parseLogContent(content: string): ParsedSessionLogs { + return parseSessionLogContent(content, { + onParseError: (line) => + this.d.log.warn("Failed to parse log entry", { line }), + }); + } + + private async fetchSessionLogs( + logUrl: string | undefined, + taskRunId?: string, + options: { minEntryCount?: number } = {}, + ): Promise<ParsedSessionLogs> { + const empty: ParsedSessionLogs = { + rawEntries: [], + totalLineCount: 0, + parseFailureCount: 0, + }; + if (!logUrl && !taskRunId) return empty; + let localResult: ParsedSessionLogs | undefined; + + if (taskRunId) { + try { + const localContent = await this.d.trpc.logs.readLocalLogs.query({ + taskRunId, + }); + if (localContent?.trim()) { + localResult = this.parseLogContent(localContent); + if ( + !options.minEntryCount || + localResult.totalLineCount >= options.minEntryCount + ) { + return localResult; + } + } + } catch { + this.d.log.warn("Failed to read local logs, falling back to S3", { + taskRunId, + }); + } + } + + if (!logUrl) return localResult ?? empty; + + try { + 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) { + this.d.trpc.logs.writeLocalLogs + .mutate({ taskRunId, content }) + .catch((err: unknown) => { + this.d.log.warn("Failed to cache S3 logs locally", { + taskRunId, + err, + }); + }); + } + + if ( + localResult && + localResult.rawEntries.length > result.rawEntries.length + ) { + return localResult; + } + + return result; + } catch { + return localResult ?? empty; + } + } + + 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.cloudRunIdleTracker.delete(taskRunId); + this.d.store.updateSession(taskRunId, { + events, + isCloud: true, + logUrl, + processedLineCount, + }); + this.updatePromptStateFromEvents(taskRunId, events); + } + + private getSessionByRunId(taskRunId: string): AgentSession | undefined { + const sessions = this.d.store.getSessions(); + return sessions[taskRunId]; + } + + private async appendAndPersist( + taskId: string, + session: AgentSession, + event: AcpMessage, + storedEntry: StoredLogEntry, + ): Promise<void> { + // Don't update processedLineCount - it tracks S3 log lines, not local events + this.d.store.appendEvents(session.taskRunId, [event]); + + const client = await this.d.getAuthenticatedClient(); + if (client) { + try { + await client.appendTaskRunLog(taskId, session.taskRunId, [storedEntry]); + } catch (error) { + this.d.log.warn("Failed to persist event to logs", { error }); + } + } + } +} diff --git a/packages/core/src/sessions/sessionViewState.ts b/packages/core/src/sessions/sessionViewState.ts new file mode 100644 index 0000000000..b166960fed --- /dev/null +++ b/packages/core/src/sessions/sessionViewState.ts @@ -0,0 +1,80 @@ +import type { AcpMessage, AgentSession, Workspace } from "@posthog/shared"; +import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; + +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 && + (!cloudStatus || cloudStatus === "queued" || cloudStatus === "in_progress"); + const isCloudRunTerminal = isCloud && !isCloudRunNotTerminal; + + const hasError = session?.status === "error" && !session?.idleKilled; + const handoffInProgress = session?.handoffInProgress ?? false; + + let isRunning = false; + if (!handoffInProgress) { + if (isCloud) { + isRunning = !hasError; + } else { + isRunning = session?.status === "connected"; + } + } + + const events = session?.events ?? []; + const isPromptPending = session?.isPromptPending ?? false; + const promptStartedAt = session?.promptStartedAt; + + const isNewSessionWithInitialPrompt = + !task.latest_run?.id && !!task.description; + const isResumingExistingSession = !!task.latest_run?.id; + const isInitializing = isCloud + ? !hasError && (!session || (events.length === 0 && isCloudRunNotTerminal)) + : !session || + (session.status === "connecting" && events.length === 0) || + (session.status === "connected" && + events.length === 0 && + (isPromptPending || + isNewSessionWithInitialPrompt || + isResumingExistingSession)); + + const cloudBranch = isCloud + ? (workspace?.baseBranch ?? task.latest_run?.branch ?? null) + : null; + + return { + isCloudRunNotTerminal, + isCloudRunTerminal, + cloudStatus, + isRunning: !!isRunning, + hasError, + events, + isPromptPending, + promptStartedAt, + isInitializing, + cloudBranch, + errorTitle: session?.errorTitle, + errorMessage: + session?.errorMessage ?? + (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<string | null>; +} + +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/packages/core/src/sessions/titleGeneratorService.test.ts b/packages/core/src/sessions/titleGeneratorService.test.ts new file mode 100644 index 0000000000..a91422b5aa --- /dev/null +++ b/packages/core/src/sessions/titleGeneratorService.test.ts @@ -0,0 +1,181 @@ +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 readAbsoluteFile = vi.fn<FileReadClient["readAbsoluteFile"]>(); +const prompt = vi.fn(); + +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(() => { + vi.clearAllMocks(); + }); + + it("returns description unchanged when it contains real text", async () => { + const description = "Fix the login bug"; + const result = + await makeService().enrichDescriptionWithFileContent(description); + expect(result).toBe(description); + expect(readAbsoluteFile).not.toHaveBeenCalled(); + }); + + it("reads text file content when description only has file tags", async () => { + readAbsoluteFile.mockResolvedValue("const x = 1;\nexport default x;"); + const description = '1. <file path="/tmp/code.ts" />'; + const result = + await makeService().enrichDescriptionWithFileContent(description); + expect(result).toBe("const x = 1;\nexport default x;"); + expect(readAbsoluteFile).toHaveBeenCalledWith("/tmp/code.ts"); + }); + + it("handles multiple file tags", async () => { + readAbsoluteFile + .mockResolvedValueOnce("file one") + .mockResolvedValueOnce("file two"); + + const description = + '1. <file path="/tmp/a.ts" />\n2. <file path="/tmp/b.ts" />'; + const result = + await makeService().enrichDescriptionWithFileContent(description); + expect(result).toBe("file one\n\nfile two"); + }); + + it("uses filePaths argument over parsed tags", async () => { + readAbsoluteFile.mockResolvedValue("from explicit path"); + const description = '1. <file path="/tmp/ignored.ts" />'; + const result = await makeService().enrichDescriptionWithFileContent( + description, + ["/tmp/explicit.ts"], + ); + expect(result).toBe("from explicit path"); + expect(readAbsoluteFile).toHaveBeenCalledWith("/tmp/explicit.ts"); + }); + + it.each([ + { + label: "binary file", + description: '1. <file path="/tmp/screenshot.png" />', + setup: () => {}, + }, + { + label: "read throws", + description: '1. <file path="/tmp/missing.ts" />', + setup: () => readAbsoluteFile.mockRejectedValue(new Error("ENOENT")), + }, + { + label: "read returns null", + description: '1. <file path="/tmp/empty.ts" />', + setup: () => readAbsoluteFile.mockResolvedValue(null), + }, + ])( + "falls back to filename hint -- $label", + async ({ description, setup }) => { + setup(); + const result = + await makeService().enrichDescriptionWithFileContent(description); + const filename = description.match(/path="[^"]*\/([^"]+)"/)?.[1]; + expect(result).toBe(`[Attached: ${filename}]`); + }, + ); + + it("truncates content longer than 500 chars", async () => { + const longContent = "x".repeat(600); + readAbsoluteFile.mockResolvedValue(longContent); + const description = '1. <file path="/tmp/big.ts" />'; + const result = + await makeService().enrichDescriptionWithFileContent(description); + expect(result).toBe("x".repeat(500)); + }); + + it("strips 'Attached files:' lines when checking for real text", async () => { + readAbsoluteFile.mockResolvedValue("content"); + const description = '1. <file path="/tmp/a.ts" />\nAttached files: a.ts'; + 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 makeService().enrichDescriptionWithFileContent(description); + expect(result).toBe(description); + }); + + it("mixes binary and text files", async () => { + readAbsoluteFile.mockResolvedValue("text content"); + const result = await makeService().enrichDescriptionWithFileContent("", [ + "/tmp/image.jpg", + "/tmp/code.ts", + ]); + expect(result).toBe("[Attached: image.jpg]\n\ntext content"); + }); + + it("returns description unchanged for folder-only input", async () => { + const description = '<folder path="src/components" />'; + const result = + await makeService().enrichDescriptionWithFileContent(description); + expect(result).toBe(description); + expect(readAbsoluteFile).not.toHaveBeenCalled(); + }); + + it("reads file and drops folder for mixed file+folder input", async () => { + readAbsoluteFile.mockResolvedValue("file body"); + const description = + '<file path="/tmp/a.ts" /><folder path="src/components" />'; + const result = + await makeService().enrichDescriptionWithFileContent(description); + expect(result).toBe("file body"); + expect(readAbsoluteFile).toHaveBeenCalledTimes(1); + expect(readAbsoluteFile).toHaveBeenCalledWith("/tmp/a.ts"); + }); + + it("treats non-chip XML-like text as real content", async () => { + const description = "<div>hello world</div>"; + const result = + await makeService().enrichDescriptionWithFileContent(description); + expect(result).toBe(description); + expect(readAbsoluteFile).not.toHaveBeenCalled(); + }); +}); + +describe("generateTitleAndSummary", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("truncates title to 255 chars", async () => { + const longTitle = "A".repeat(300); + prompt.mockResolvedValue({ + content: `TITLE: ${longTitle}\nSUMMARY: A summary`, + }); + + const result = await makeService().generateTitleAndSummary("some content"); + expect(result?.title).toHaveLength(255); + expect(result?.summary).toBe("A summary"); + }); + + it("strips surrounding quotes from title", async () => { + prompt.mockResolvedValue({ + content: 'TITLE: "Fix login bug"\nSUMMARY: Fixing auth', + }); + + const result = + await makeService().generateTitleAndSummary("fix the login bug"); + expect(result?.title).toBe("Fix login bug"); + }); + + it("returns null on error", async () => { + 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: <title here> +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/packages/core/src/setup/prompts.ts b/packages/core/src/setup/prompts.ts new file mode 100644 index 0000000000..bfe4aad109 --- /dev/null +++ b/packages/core/src/setup/prompts.ts @@ -0,0 +1,71 @@ +import { BASE_CATEGORY_ENUM } from "@posthog/core/setup/types"; + +export const WIZARD_PROMPT = `/instrument-integration + +After the integration is wired up, also instrument error tracking and session replay (run \`/instrument-error-tracking\`, then add session replay if the framework's posthog-js config supports it). + +Run autonomously with sensible defaults — do not ask the user questions. If the PostHog API key isn't already in the project's env files and you can't read it from the PostHog MCP server, leave a placeholder env var and note it in the PR body rather than blocking.`; + +const DISCOVERY_PROMPT_BASE = `You are analyzing this codebase to find the highest-value first tasks for the developer. + +Scan the codebase for issues in two tiers. Tier 1 applies to every repo. Tier 2 only applies when PostHog is already installed (look for posthog-js, posthog-node, posthog-react-native or similar PostHog SDK imports). + +## Tier 1 -- Code health (always) + +- **Dead code**: Unused exports, unreachable branches, orphaned files, stale imports. Category: dead_code +- **Duplication / KISS violations**: Copy-pasted logic that should be a shared function, over-abstracted code that could be simpler. Category: duplication +- **Security vulnerabilities**: XSS, SQL injection, command injection, hardcoded secrets, open redirects, missing auth checks, insecure deserialization. Category: security +- **Bugs**: Null dereferences, race conditions, unchecked array access, off-by-one errors, unhandled promise rejections around I/O. Category: bug +- **Performance anti-patterns**: N+1 queries, unbounded loops, synchronous blocking on hot paths, missing pagination. Category: performance + +## Tier 2 -- PostHog-specific (only when PostHog SDK is detected) + +- **Stale feature flags**: Flags that are always evaluated the same way, flags referenced in code but never toggled, flags guarding code that shipped long ago. Category: stale_feature_flag +- **Error tracking gaps**: Catch blocks that swallow errors without reporting, missing error boundaries, untracked 5xx responses. Category: error_tracking +- **Event tracking improvements**: Key user actions (signup, purchase, invite, upgrade) with no analytics event, events missing useful properties (plan, user role, page context). Category: event_tracking +- **Funnel weak spots**: Multi-step flows (onboarding, checkout, activation) where intermediate steps have no tracking, making drop-off invisible. Category: funnel`; + +const DISCOVERY_PROMPT_EXPERIMENT_TIER = ` + +## Tier 3 -- Experiment opportunities (only when PostHog SDK is detected) + +- **Experimentable surfaces**: User-facing surfaces where an A/B test would meaningfully inform a product decision — pricing pages, paywalls, primary CTAs, signup/onboarding flows, empty states, recommendation lists, upgrade prompts. Category: experiment + - Title: a one-line hypothesis ("Test 'Get started free' vs 'Sign up' on landing CTA") + - Description: state the hypothesis as a sentence — what you would change and why you think it would move the metric + - Impact: name the primary metric you would measure (e.g. "Sign-up conversion on /landing") and what a winning variant would look like + - Recommendation: describe the control and test variants concretely (exact copy, layout change, or behavior), and note any flag wiring required (\`posthog.getFeatureFlag\`) + - Only suggest experiments where: (a) the surface is in code you can point at, (b) the variant is implementable without backend changes you can't see, and (c) the metric is something a typical PostHog event would capture + +If you find at least one credible Tier 3 experiment opportunity, include at least one experiment-category task in your output — even if doing so displaces a lower-impact Tier 1/2 finding. Do not fabricate an experiment to fill the slot: if no credible candidate exists, omit the category entirely.`; + +function buildDiscoveryRules(includeExperiments: boolean): string { + const allowed = ( + includeExperiments + ? [...BASE_CATEGORY_ENUM, "experiment"] + : [...BASE_CATEGORY_ENUM] + ).join(", "); + return ` + +## Rules + +- Be concrete: reference exact file paths, function names and line numbers — but put paths/lines in the dedicated \`file\` and \`lineHint\` fields, not in the title or description. +- Title: short, action-oriented header (under 60 characters), no paths or line numbers. +- Description: a clear paragraph (2–4 sentences) explaining the problem and the conditions under which it manifests. +- Impact: 1–3 sentences on why it matters (concrete consequence, blast radius, or risk). +- Recommendation: 2–4 sentences pointing at the right shape of the fix without writing the patch. Reference specific functions, types, or files involved. +- Prioritize by impact. Lead with findings that save the most time or prevent the most damage. +- Do NOT suggest documentation, comment, or style/formatting changes. +- Maximum 4 tasks. Quality over quantity. +- Allowed \`category\` values: ${allowed}. Do NOT emit any other category. + +When you are done analyzing, call create_output with your findings.`; +} + +export function buildDiscoveryPrompt({ + includeExperiments, +}: { + includeExperiments: boolean; +}): string { + const middle = includeExperiments ? DISCOVERY_PROMPT_EXPERIMENT_TIER : ""; + return `${DISCOVERY_PROMPT_BASE}${middle}${buildDiscoveryRules(includeExperiments)}`; +} 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/packages/core/src/skill-buttons/catalog.ts b/packages/core/src/skill-buttons/catalog.ts new file mode 100644 index 0000000000..9079077a02 --- /dev/null +++ b/packages/core/src/skill-buttons/catalog.ts @@ -0,0 +1,104 @@ +import type { SkillButtonId } from "@posthog/shared/analytics-events"; + +export type { SkillButtonId }; + +export interface SkillButtonCatalogEntry { + id: SkillButtonId; + label: string; + prompt: string; + color: string; + actionTitle: string; + actionDescription: string; + tooltip: string; +} + +export const SKILL_BUTTON_CATALOG: Record< + SkillButtonId, + SkillButtonCatalogEntry +> = { + "add-analytics": { + id: "add-analytics", + label: "Track events", + prompt: "/instrument-product-analytics", + color: "#2F80FA", + actionTitle: "Adding analytics", + actionDescription: "to measure how this change performs in production.", + tooltip: + "Instrument PostHog events so you can measure this change in production", + }, + "create-feature-flags": { + id: "create-feature-flags", + label: "Add feature flag", + prompt: "/instrument-feature-flags", + color: "#30ABC6", + actionTitle: "Creating a feature flag", + actionDescription: + "to roll this out safely and toggle it without a redeploy.", + tooltip: + "Gate this change behind a PostHog feature flag for a safe rollout", + }, + "run-experiment": { + id: "run-experiment", + label: "Run experiment", + 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", + actionTitle: "Setting up an experiment", + actionDescription: + "with control and test variants tied to a primary metric, ready to launch once this ships.", + tooltip: + "Scaffold a PostHog A/B experiment with control and test variants tied to a primary metric", + }, + "add-error-tracking": { + id: "add-error-tracking", + label: "Track errors", + prompt: "/instrument-error-tracking", + color: "#BF8113", + actionTitle: "Adding error tracking", + actionDescription: + "so exceptions surface in PostHog with stack traces and source maps.", + tooltip: + "Capture exceptions in PostHog with stack traces so issues surface quickly in production", + }, + "instrument-llm-calls": { + id: "instrument-llm-calls", + label: "Trace LLM calls", + prompt: "/instrument-llm-analytics", + color: "#B029D2", + actionTitle: "Instrumenting LLM calls", + actionDescription: + "for visibility into prompts, tokens, latency, and costs.", + tooltip: + "Inspect traces, spans, latency, usage, and per-user costs for AI-powered features", + }, + "add-logging": { + id: "add-logging", + label: "Capture logs", + prompt: "/instrument-logs", + color: "#C92474", + actionTitle: "Adding logging", + actionDescription: + "so structured log events flow into PostHog for inspection and debugging.", + tooltip: + "Capture structured application logs in PostHog for inspection and debugging", + }, +}; + +export const SKILL_BUTTON_ORDER: SkillButtonId[] = [ + "add-analytics", + "add-logging", + "add-error-tracking", + "instrument-llm-calls", + "create-feature-flags", + "run-experiment", +]; + +export const SKILL_BUTTON_IDS: ReadonlySet<SkillButtonId> = new Set( + Object.keys(SKILL_BUTTON_CATALOG) as SkillButtonId[], +); + +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/packages/core/src/sleep/sleep.ts b/packages/core/src/sleep/sleep.ts new file mode 100644 index 0000000000..ec49c2a84e --- /dev/null +++ b/packages/core/src/sleep/sleep.ts @@ -0,0 +1,83 @@ +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"; + +@injectable() +export class SleepService { + private enabled: boolean; + private releaseBlocker: (() => void) | null = null; + private activeActivities = new Set<string>(); + private readonly log: ScopedLogger; + + constructor( + @inject(POWER_MANAGER_SERVICE) + private readonly powerManager: IPowerManager, + @inject(WORKSPACE_SETTINGS_SERVICE) + private readonly settings: IWorkspaceSettings, + @inject(WORKBENCH_LOGGER) + workbenchLogger: WorkbenchLogger, + ) { + this.log = workbenchLogger.scope("sleep"); + this.enabled = this.settings.getPreventSleepWhileRunning(); + } + + setEnabled(enabled: boolean): void { + this.log.info("setEnabled", { enabled }); + this.enabled = enabled; + this.settings.setPreventSleepWhileRunning(enabled); + this.updateBlocker(); + } + + getEnabled(): boolean { + return this.enabled; + } + + acquire(activityId: string): void { + this.activeActivities.add(activityId); + this.updateBlocker(); + } + + release(activityId: string): void { + this.activeActivities.delete(activityId); + this.updateBlocker(); + } + + @preDestroy() + cleanup(): void { + this.stopBlocker(); + } + + private updateBlocker(): void { + if (this.enabled && this.activeActivities.size > 0) { + this.startBlocker(); + } else { + this.stopBlocker(); + } + } + + private startBlocker(): void { + if (this.releaseBlocker) return; + this.releaseBlocker = this.powerManager.preventSleep( + "prevent-app-suspension", + ); + this.log.info("Started power save blocker"); + } + + private stopBlocker(): void { + if (!this.releaseBlocker) return; + 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/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts new file mode 100644 index 0000000000..11fa798cee --- /dev/null +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -0,0 +1,464 @@ +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"; +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(), +})); + +import { TaskCreationSaga } from "./taskCreationSaga"; + +const host = mockHost as unknown as ITaskCreationHost; + +const sessionService = { + connectToTask: vi.fn(), + disconnectFromTask: vi.fn(), +} as unknown as SessionService; + +const createTask = (overrides: Partial<Task> = {}): Task => ({ + id: "task-123", + task_number: 1, + slug: "task-123", + title: "Test task", + description: "Ship the fix", + origin_product: "user_created", + repository: "posthog/posthog", + created_at: "2026-04-03T00:00:00Z", + updated_at: "2026-04-03T00:00:00Z", + ...overrides, +}); + +const createRun = (overrides: Partial<TaskRun> = {}): TaskRun => ({ + id: "run-123", + task: "task-123", + team: 1, + branch: "release/remembered-branch", + environment: "cloud", + status: "queued", + log_url: "https://example.com/logs/run-123", + error_message: null, + output: null, + state: {}, + created_at: "2026-04-03T00:00:00Z", + updated_at: "2026-04-03T00:00:00Z", + completed_at: null, + ...overrides, +}); + +describe("TaskCreationSaga", () => { + beforeEach(() => { + vi.clearAllMocks(); + 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 () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + const sendRunCommandMock = vi.fn(); + const onTaskReady = vi.fn(); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: sendRunCommandMock, + updateTask: vi.fn(), + } as never, + host, + sessionService, + onTaskReady, + }); + + const result = await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "release/remembered-branch", + adapter: "codex", + model: "gpt-5.4", + reasoningLevel: "high", + }); + + expect(result.success).toBe(true); + if (!result.success) { + throw new Error("Expected task creation to succeed"); + } + + expect(createTaskRunMock).toHaveBeenCalledWith("task-123", { + environment: "cloud", + mode: "interactive", + branch: "release/remembered-branch", + adapter: "codex", + model: "gpt-5.4", + reasoningLevel: "high", + sandboxEnvironmentId: undefined, + prAuthorshipMode: "user", + runSource: "manual", + signalReportId: undefined, + initialPermissionMode: "auto", + }); + expect(startTaskRunMock).toHaveBeenCalledWith("task-123", "run-123", { + pendingUserMessage: "Ship the fix", + pendingUserArtifactIds: undefined, + }); + expect(sendRunCommandMock).not.toHaveBeenCalled(); + expect(onTaskReady).toHaveBeenCalledTimes(1); + expect(onTaskReady.mock.calls[0][0].task.latest_run?.branch).toBe( + "release/remembered-branch", + ); + expect(result.data.task.latest_run?.branch).toBe( + "release/remembered-branch", + ); + expect(startTaskRunMock.mock.invocationCallOrder[0]).toBeLessThan( + onTaskReady.mock.invocationCallOrder[0], + ); + }); + + it("uploads initial cloud attachments before starting the run", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + const sendRunCommandMock = vi.fn(); + const onTaskReady = vi.fn(); + + 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: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: sendRunCommandMock, + updateTask: vi.fn(), + } as never, + host, + sessionService, + onTaskReady, + }); + + const result = await saga.run({ + content: 'read this file <file path="/tmp/test.txt" />', + taskDescription: "read this file\n\nAttached files: test.txt", + filePaths: ["/tmp/test.txt"], + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "release/remembered-branch", + adapter: "codex", + model: "gpt-5.4", + reasoningLevel: "medium", + }); + + expect(result.success).toBe(true); + if (!result.success) { + throw new Error("Expected task creation to succeed"); + } + + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: "read this file\n\nAttached files: test.txt", + }), + ); + expect(createTaskRunMock).toHaveBeenCalledWith("task-123", { + environment: "cloud", + mode: "interactive", + branch: "release/remembered-branch", + adapter: "codex", + model: "gpt-5.4", + reasoningLevel: "medium", + sandboxEnvironmentId: undefined, + prAuthorshipMode: "user", + runSource: "manual", + 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( + mockHost.uploadRunAttachments.mock.invocationCallOrder[0], + ); + expect( + mockHost.uploadRunAttachments.mock.invocationCallOrder[0], + ).toBeLessThan(startTaskRunMock.mock.invocationCallOrder[0]); + expect(startTaskRunMock.mock.invocationCallOrder[0]).toBeLessThan( + onTaskReady.mock.invocationCallOrder[0], + ); + }); + + it("uses the selected user GitHub integration for cloud task creation", async () => { + const createdTask = createTask({ + github_user_integration: "user-integration-123", + }); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + } as never, + host, + sessionService, + }); + + const result = await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + githubUserIntegrationId: "user-integration-123", + }); + + expect(result.success).toBe(true); + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + repository: "posthog/posthog", + github_user_integration: "user-integration-123", + github_integration: undefined, + }), + ); + expect(createTaskRunMock).toHaveBeenCalledWith( + "task-123", + expect.objectContaining({ + prAuthorshipMode: "user", + runSource: "manual", + }), + ); + }); + + it("uses user authorship for signal report cloud task creation", async () => { + const createdTask = createTask({ origin_product: "signal_report" }); + const startedTask = createTask({ + origin_product: "signal_report", + latest_run: createRun(), + }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + } as never, + host, + sessionService, + }); + + const result = await saga.run({ + content: "Ship the report", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + cloudRunSource: "signal_report", + signalReportId: "report-123", + githubIntegrationId: 123, + }); + + expect(result.success).toBe(true); + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + github_integration: 123, + github_user_integration: undefined, + origin_product: "signal_report", + }), + ); + expect(createTaskRunMock).toHaveBeenCalledWith( + "task-123", + expect.objectContaining({ + prAuthorshipMode: "user", + runSource: "signal_report", + }), + ); + }); + + it("does not prefill a task title from the prompt", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + } as never, + host, + sessionService, + }); + + await saga.run({ + content: "Ship the fix", + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + }); + + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Ship the fix", + }), + ); + expect(createTaskMock.mock.calls[0]?.[0]).not.toHaveProperty("title"); + }); + + it("does not prefill a task title for attachment-only prompts", async () => { + const createdTask = createTask(); + const startedTask = createTask({ latest_run: createRun() }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + } as never, + host, + sessionService, + }); + + await saga.run({ + taskDescription: '<file path="/tmp/code.ts" />', + repository: "posthog/posthog", + workspaceMode: "cloud", + branch: "main", + }); + + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + description: '<file path="/tmp/code.ts" />', + }), + ); + expect(createTaskMock.mock.calls[0]?.[0]).not.toHaveProperty("title"); + }); + + it("uses user authorship for repo-less cloud tasks with a selected user GitHub integration", async () => { + const createdTask = createTask({ + repository: null, + github_user_integration: "user-integration-123", + }); + const startedTask = createTask({ + repository: null, + latest_run: createRun(), + }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + const createTaskRunMock = vi.fn().mockResolvedValue(createRun()); + const startTaskRunMock = vi.fn().mockResolvedValue(startedTask); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: createTaskRunMock, + startTaskRun: startTaskRunMock, + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + } as never, + host, + sessionService, + }); + + const result = await saga.run({ + content: "Clone the private repo", + workspaceMode: "cloud", + branch: "main", + githubUserIntegrationId: "user-integration-123", + }); + + expect(result.success).toBe(true); + expect(createTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + repository: undefined, + github_user_integration: "user-integration-123", + github_integration: undefined, + }), + ); + expect(createTaskRunMock).toHaveBeenCalledWith( + "task-123", + expect.objectContaining({ + prAuthorshipMode: "user", + runSource: "manual", + }), + ); + }); +}); diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts new file mode 100644 index 0000000000..a24a3d9924 --- /dev/null +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -0,0 +1,379 @@ +import { buildPromptBlocks } from "@posthog/core/editor/prompt-builder"; +import type { + ConnectParams, + SessionService, +} from "@posthog/core/sessions/sessionService"; +import { + getTaskRepository, + Saga, + type SagaLogger, + type TaskCreationInput, + type TaskCreationOutput, + type Workspace, +} from "@posthog/shared"; +import { + SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP, + type Task, +} from "@posthog/shared/domain-types"; +import type { TaskCreationApiClient } from "./taskCreationApiClient"; +import type { ITaskCreationHost } from "./taskCreationHost"; + +export interface TaskCreationDeps { + posthogClient: TaskCreationApiClient; + host: ITaskCreationHost; + sessionService: SessionService; + onTaskReady?: (output: TaskCreationOutput) => void; +} + +export class TaskCreationSaga extends Saga< + TaskCreationInput, + TaskCreationOutput +> { + readonly sagaName = "TaskCreationSaga"; + + constructor( + private deps: TaskCreationDeps, + logger?: SagaLogger, + ) { + super(logger); + } + + protected async execute( + input: TaskCreationInput, + ): Promise<TaskCreationOutput> { + const taskId = input.taskId; + const folderPromise = + !taskId && input.repoPath + ? this.resolveFolder(input.repoPath) + : undefined; + + let task = taskId + ? await this.readOnlyStep("fetch_task", () => + this.deps.posthogClient.getTask(taskId), + ) + : await this.createTask(input); + + const repoKey = getTaskRepository(task); + const repoPath = + input.repoPath ?? + (await this.readOnlyStep("resolve_repo_path", () => + this.deps.host.getTaskDirectory(task.id, repoKey ?? undefined), + )); + + const workspaceMode = + input.workspaceMode ?? + (task.latest_run?.environment === "cloud" ? "cloud" : "local"); + + let workspace: Workspace | null = null; + const branch = input.branch ?? task.latest_run?.branch ?? null; + const hasProvisioning = + workspaceMode === "worktree" && !!repoPath && !input.taskId; + + if (hasProvisioning) { + this.deps.host.setProvisioningActive(task.id); + if (this.deps.onTaskReady) { + this.deps.onTaskReady({ task, workspace }); + } + } + + if (repoPath) { + const folder = folderPromise + ? await this.readOnlyStep("folder_registration", () => folderPromise) + : await this.readOnlyStep("folder_registration", () => + this.resolveFolder(repoPath), + ); + + const workspaceInfo = await this.step({ + name: "workspace_creation", + execute: async () => { + return this.deps.host.createWorkspace({ + taskId: task.id, + mainRepoPath: repoPath, + folderId: folder.id, + folderPath: repoPath, + mode: workspaceMode, + branch: branch ?? undefined, + }); + }, + rollback: async () => { + this.log.info("Rolling back: deleting workspace", { + taskId: task.id, + }); + await this.deps.host.deleteWorkspace({ + taskId: task.id, + mainRepoPath: repoPath, + }); + }, + }); + + workspace = { + taskId: task.id, + folderId: folder.id, + folderPath: repoPath, + mode: workspaceMode, + worktreePath: workspaceInfo.worktree?.worktreePath ?? null, + worktreeName: workspaceInfo.worktree?.worktreeName ?? null, + branchName: workspaceInfo.worktree?.branchName ?? null, + baseBranch: workspaceInfo.worktree?.baseBranch ?? null, + linkedBranch: workspaceInfo.linkedBranch ?? null, + createdAt: + workspaceInfo.worktree?.createdAt ?? new Date().toISOString(), + }; + } else if (workspaceMode === "cloud") { + await this.step({ + name: "cloud_workspace_creation", + execute: async () => { + return this.deps.host.createWorkspace({ + taskId: task.id, + mainRepoPath: "", + folderId: "", + folderPath: "", + mode: "cloud", + branch: branch ?? undefined, + }); + }, + rollback: async () => { + this.log.info("Rolling back: deleting cloud workspace", { + taskId: task.id, + }); + await this.deps.host.deleteWorkspace({ + taskId: task.id, + mainRepoPath: "", + }); + }, + }); + + workspace = { + taskId: task.id, + folderId: "", + folderPath: "", + mode: "cloud", + worktreePath: null, + worktreeName: null, + branchName: null, + baseBranch: branch, + linkedBranch: null, + createdAt: new Date().toISOString(), + }; + } + + const shouldStartCloudRun = workspaceMode === "cloud" && !task.latest_run; + + if (!hasProvisioning && !shouldStartCloudRun && this.deps.onTaskReady) { + this.deps.onTaskReady({ task, workspace }); + } + + if (hasProvisioning) { + this.deps.host.clearProvisioning(task.id); + } + + if ( + input.environmentId && + workspace?.worktreePath && + repoPath && + !input.taskId + ) { + this.dispatchEnvironmentSetup( + task.id, + input.environmentId, + repoPath, + workspace.worktreePath, + ); + } + + if (shouldStartCloudRun) { + task = await this.step({ + name: "cloud_run", + execute: async () => { + const prAuthorshipMode = input.cloudPrAuthorshipMode ?? "user"; + + const transport = + (input.content || input.filePaths?.length) && + workspaceMode === "cloud" + ? this.deps.host.getCloudPromptTransport( + input.content ?? "", + input.filePaths, + ) + : null; + const taskRun = await this.deps.posthogClient.createTaskRun(task.id, { + environment: "cloud", + mode: "interactive", + branch, + adapter: input.adapter, + model: input.model, + reasoningLevel: input.reasoningLevel, + sandboxEnvironmentId: input.sandboxEnvironmentId, + prAuthorshipMode, + runSource: input.cloudRunSource ?? "manual", + signalReportId: input.signalReportId, + initialPermissionMode: input.adapter + ? (input.executionMode ?? + (input.adapter === "codex" ? "auto" : "plan")) + : input.executionMode, + }); + if (!taskRun?.id) { + throw new Error("Failed to create cloud run"); + } + + const pendingUserArtifactIds = transport + ? await this.deps.host.uploadRunAttachments( + this.deps.posthogClient, + task.id, + taskRun.id, + transport.filePaths, + ) + : []; + + return this.deps.posthogClient.startTaskRun(task.id, taskRun.id, { + pendingUserMessage: transport?.messageText, + pendingUserArtifactIds: + pendingUserArtifactIds.length > 0 + ? pendingUserArtifactIds + : undefined, + }); + }, + rollback: async () => { + this.log.info("Rolling back: cloud run (no-op)", { + taskId: task.id, + }); + }, + }); + + if (!hasProvisioning && this.deps.onTaskReady) { + this.deps.onTaskReady({ task, workspace }); + } + } + + const agentCwd = + workspace?.worktreePath ?? workspace?.folderPath ?? repoPath; + const isCloudCreate = !input.taskId && workspaceMode === "cloud"; + const shouldConnect = !isCloudCreate && (!!input.taskId || !!agentCwd); + + if (shouldConnect) { + const initialPrompt = + !input.taskId && input.content + ? await this.readOnlyStep("build_prompt_blocks", () => + buildPromptBlocks( + input.content ?? "", + input.filePaths ?? [], + agentCwd ?? "", + ), + ) + : undefined; + + await this.step({ + name: "agent_session", + execute: async () => { + const connectParams: ConnectParams = { + task, + repoPath: agentCwd ?? "", + }; + if (initialPrompt) connectParams.initialPrompt = initialPrompt; + if (input.executionMode) + connectParams.executionMode = input.executionMode; + if (input.adapter) connectParams.adapter = input.adapter; + if (input.model) connectParams.model = input.model; + if (input.reasoningLevel) + connectParams.reasoningLevel = input.reasoningLevel; + + this.deps.sessionService.connectToTask(connectParams); + return { taskId: task.id }; + }, + rollback: async ({ taskId }) => { + this.log.info("Rolling back: disconnecting agent session", { + taskId, + }); + await this.deps.sessionService.disconnectFromTask(taskId); + }, + }); + } + + return { task, workspace }; + } + + private async resolveFolder(repoPath: string) { + const folders = await this.deps.host.getFolders(); + let existingFolder = folders.find((f) => f.path === repoPath); + + if (!existingFolder) { + existingFolder = await this.deps.host.addFolder({ folderPath: repoPath }); + } + return existingFolder; + } + + private dispatchEnvironmentSetup( + taskId: string, + environmentId: string, + repoPath: string, + worktreePath: string, + ): void { + this.deps.host + .getEnvironment({ repoPath, id: environmentId }) + .then((env) => { + if (!env?.setup?.script) return; + + this.deps.host.dispatchSetupAction({ + taskId, + command: env.setup.script, + cwd: worktreePath, + label: `Setup: ${env.name}`, + }); + }) + .catch((error) => { + this.log.error("Failed to dispatch environment setup script", { + taskId, + environmentId, + error, + }); + }); + } + + private async createTask(input: TaskCreationInput): Promise<Task> { + let repository = input.repository; + + const repoPathForDetection = input.repoPath; + if (!repository && repoPathForDetection) { + const detected = await this.readOnlyStep("repo_detection", () => + this.deps.host.detectRepo({ directoryPath: repoPathForDetection }), + ); + if (detected) { + repository = `${detected.organization}/${detected.repository}`; + } + } + + return this.step({ + name: "task_creation", + execute: async () => { + const description = input.taskDescription ?? input.content ?? ""; + const result = await this.deps.posthogClient.createTask({ + description, + repository: repository ?? undefined, + github_integration: + input.workspaceMode === "cloud" && + input.cloudRunSource === "signal_report" + ? input.githubIntegrationId + : undefined, + github_user_integration: + input.workspaceMode === "cloud" && + input.cloudRunSource !== "signal_report" + ? input.githubUserIntegrationId + : undefined, + origin_product: input.signalReportId + ? "signal_report" + : "user_created", + signal_report: input.signalReportId ?? undefined, + signal_report_task_relationship: input.signalReportId + ? SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP + : undefined, + }); + return result as unknown as Task; + }, + rollback: async (createdTask) => { + 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..dc472a9faa --- /dev/null +++ b/packages/core/src/tasks/taskDeletionService.ts @@ -0,0 +1,100 @@ +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/packages/core/src/tour/types.ts b/packages/core/src/tour/types.ts new file mode 100644 index 0000000000..b2c2793c45 --- /dev/null +++ b/packages/core/src/tour/types.ts @@ -0,0 +1,18 @@ +export type TourStepAdvance = { type: "action" } | { type: "click" }; + +export type TooltipPlacement = "right" | "left" | "top" | "bottom"; + +export interface TourStep { + id: string; + target: string; + hogSrc: string; + message: string; + advanceOn: TourStepAdvance; + preferredPlacement?: TooltipPlacement; +} + +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/packages/core/src/ui/ui.ts b/packages/core/src/ui/ui.ts new file mode 100644 index 0000000000..1cfe5fe300 --- /dev/null +++ b/packages/core/src/ui/ui.ts @@ -0,0 +1,36 @@ +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable } from "inversify"; +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(UI_AUTH) + private readonly auth: UiAuth, + ) { + super(); + } + + openSettings(): void { + this.emit(UIServiceEvent.OpenSettings, true); + } + + newTask(): void { + this.emit(UIServiceEvent.NewTask, true); + } + + resetLayout(): void { + this.emit(UIServiceEvent.ResetLayout, true); + } + + clearStorage(): void { + this.emit(UIServiceEvent.ClearStorage, true); + } + + async invalidateToken(): Promise<void> { + 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/packages/core/src/updates/updates.test.ts b/packages/core/src/updates/updates.test.ts new file mode 100644 index 0000000000..247cb90291 --- /dev/null +++ b/packages/core/src/updates/updates.test.ts @@ -0,0 +1,1003 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { UpdatesEvent } from "./schemas"; + +// Use vi.hoisted to ensure mocks are available when vi.mock is hoisted +const { + mockUpdater, + mockAppLifecycle, + mockAppMeta, + mockMainWindow, + mockLifecycleService, + mockLog, + updaterHandlers, +} = vi.hoisted(() => { + const updaterHandlers: { + checkStart: (() => void) | null; + updateAvailable: (() => void) | null; + noUpdate: (() => void) | null; + updateDownloaded: ((version: string) => void) | null; + error: ((error: Error) => void) | null; + focus: (() => void) | null; + } = { + checkStart: null, + updateAvailable: null, + noUpdate: null, + updateDownloaded: null, + error: null, + focus: null, + }; + + return { + updaterHandlers, + mockUpdater: { + isSupported: vi.fn(() => true), + setFeedUrl: vi.fn(), + check: vi.fn(), + quitAndInstall: vi.fn(), + onCheckStart: vi.fn((h: () => void) => { + updaterHandlers.checkStart = h; + return () => {}; + }), + onUpdateAvailable: vi.fn((h: () => void) => { + updaterHandlers.updateAvailable = h; + return () => {}; + }), + onNoUpdate: vi.fn((h: () => void) => { + updaterHandlers.noUpdate = h; + return () => {}; + }), + onUpdateDownloaded: vi.fn((h: (version: string) => void) => { + updaterHandlers.updateDownloaded = h; + return () => {}; + }), + onError: vi.fn((h: (error: Error) => void) => { + updaterHandlers.error = h; + return () => {}; + }), + }, + mockAppLifecycle: { + whenReady: vi.fn(() => Promise.resolve()), + quit: vi.fn(), + exit: vi.fn(), + onQuit: vi.fn(() => () => {}), + registerDeepLinkScheme: vi.fn(), + }, + mockAppMeta: { + version: "1.0.0", + isProduction: true, + platform: "darwin", + arch: "arm64", + }, + mockMainWindow: { + focus: vi.fn(), + isFocused: vi.fn(() => false), + isMinimized: vi.fn(() => false), + restore: vi.fn(), + onFocus: vi.fn((h: () => void) => { + updaterHandlers.focus = h; + return () => {}; + }), + }, + mockLifecycleService: { + shutdown: vi.fn(() => Promise.resolve()), + shutdownWithoutContainer: vi.fn(() => Promise.resolve()), + setQuittingForUpdate: vi.fn(), + clearQuittingForUpdate: vi.fn(), + }, + mockLog: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, + }; +}); + +import { UpdatesService } from "./updates"; + +function injectPorts(service: UpdatesService): void { + const s = service as unknown as Record<string, unknown>; + s.lifecycle = mockLifecycleService; + s.workbenchLogger = { ...mockLog, scope: () => mockLog }; + s.updater = mockUpdater; + s.appLifecycle = mockAppLifecycle; + s.appMeta = mockAppMeta; + s.mainWindow = mockMainWindow; +} + +// Helper to initialize service and wait for setup without running the periodic interval infinitely +async function initializeService(service: UpdatesService): Promise<void> { + service.init(); + // Allow the whenReady promise microtask to resolve + await vi.advanceTimersByTimeAsync(0); +} + +describe("UpdatesService", () => { + let service: UpdatesService; + let originalPlatform: PropertyDescriptor | undefined; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + // Store original values + originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + originalEnv = { ...process.env }; + + // 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(() => + Promise.resolve(), + ); + mockAppLifecycle.whenReady.mockResolvedValue(undefined); + + // Set default platform to darwin (macOS) + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }); + + // Clear env flag + delete process.env.ELECTRON_DISABLE_AUTO_UPDATE; + + service = new UpdatesService(); + injectPorts(service); + }); + + afterEach(() => { + vi.useRealTimers(); + + // Restore original values + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + process.env = originalEnv; + }); + + describe("isEnabled", () => { + // 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); + + const newService = new UpdatesService(); + injectPorts(newService); + expect(newService.isEnabled).toBe(true); + }); + + it("returns false when the platform updater reports unsupported", () => { + mockUpdater.isSupported.mockReturnValue(false); + + const newService = new UpdatesService(); + injectPorts(newService); + expect(newService.isEnabled).toBe(false); + }); + }); + + describe("init", () => { + it("sets up auto updater when enabled", async () => { + await initializeService(service); + + expect(mockMainWindow.onFocus).toHaveBeenCalledWith(expect.any(Function)); + expect(mockAppLifecycle.whenReady).toHaveBeenCalled(); + }); + + it("does not set up auto updater when the host reports unsupported", () => { + mockUpdater.isSupported.mockReturnValue(false); + + const newService = new UpdatesService(); + injectPorts(newService); + newService.init(); + + expect(mockAppLifecycle.whenReady).not.toHaveBeenCalled(); + }); + + it("prevents multiple initializations", async () => { + await initializeService(service); + + const firstCallCount = mockUpdater.setFeedUrl.mock.calls.length; + + // Simulate whenReady resolving again (shouldn't happen, but testing guard) + await initializeService(service); + + // setFeedURL should not be called again + expect(mockUpdater.setFeedUrl.mock.calls.length).toBe(firstCallCount); + }); + }); + + describe("feedUrl", () => { + it("constructs correct feed URL with platform, arch, and version", async () => { + mockAppMeta.platform = "darwin"; + mockAppMeta.arch = "arm64"; + mockAppMeta.version = "2.0.0"; + + await initializeService(service); + + expect(mockUpdater.setFeedUrl).toHaveBeenCalledWith( + "https://update.electronjs.org/PostHog/code/darwin-arm64/2.0.0", + ); + }); + }); + + describe("checkForUpdates", () => { + it("returns success when updates are enabled", () => { + const result = service.checkForUpdates(); + expect(result).toEqual({ success: true }); + }); + + it("returns error when updates are disabled (not packaged)", () => { + mockUpdater.isSupported.mockReturnValue(false); + mockAppMeta.isProduction = false; + + const newService = new UpdatesService(); + injectPorts(newService); + const result = newService.checkForUpdates(); + + expect(result).toEqual({ + success: false, + errorMessage: "Updates only available in packaged builds", + errorCode: "disabled", + }); + }); + + it("returns error when updates are disabled (unsupported platform)", () => { + mockUpdater.isSupported.mockReturnValue(false); + mockAppMeta.isProduction = true; + + const newService = new UpdatesService(); + injectPorts(newService); + const result = newService.checkForUpdates(); + + expect(result).toEqual({ + success: false, + errorMessage: "Auto updates only supported on macOS and Windows", + errorCode: "disabled", + }); + }); + + it("returns error when already checking for updates", () => { + // First call starts the check + service.checkForUpdates(); + + // Second call should fail + const result = service.checkForUpdates(); + expect(result).toEqual({ + success: false, + errorMessage: "Already checking for updates", + errorCode: "already_checking", + }); + }); + + it("emits status event when checking starts", () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + service.checkForUpdates(); + + expect(statusHandler).toHaveBeenCalledWith({ checking: true }); + }); + + it("calls autoUpdater.checkForUpdates", async () => { + await initializeService(service); + + // Complete the initial check triggered by setupAutoUpdater + const notAvailableHandler = updaterHandlers.noUpdate; + if (notAvailableHandler) { + notAvailableHandler(); + } + + mockUpdater.check.mockClear(); + service.checkForUpdates(); + + expect(mockUpdater.check).toHaveBeenCalled(); + }); + + it("allows retry after previous check completes", async () => { + await initializeService(service); + + // Complete the initial check triggered by setupAutoUpdater + const notAvailableHandler = updaterHandlers.noUpdate; + + if (notAvailableHandler) { + notAvailableHandler(); + } + + // First explicit check + const result1 = service.checkForUpdates(); + expect(result1.success).toBe(true); + + // Simulate completion + if (notAvailableHandler) { + notAvailableHandler(); + } + + // Second check should succeed + const result2 = service.checkForUpdates(); + expect(result2.success).toBe(true); + }); + }); + + describe("hasUpdateReady", () => { + it("returns false initially", () => { + expect(service.hasUpdateReady).toBe(false); + }); + + it("returns true after an update is downloaded", async () => { + await initializeService(service); + + const downloadedHandler = updaterHandlers.updateDownloaded; + + if (downloadedHandler) { + downloadedHandler("v2.0.0"); + } + + expect(service.hasUpdateReady).toBe(true); + }); + }); + + describe("installUpdate", () => { + it("returns false when no update is ready", async () => { + const result = await service.installUpdate(); + expect(result).toEqual({ installed: false }); + }); + + it("calls quitAndInstall when update is ready", async () => { + await initializeService(service); + + // Simulate update downloaded + const updateDownloadedHandler = updaterHandlers.updateDownloaded; + + if (updateDownloadedHandler) { + updateDownloadedHandler("v2.0.0"); + } + + const resultPromise = service.installUpdate(); + await vi.runOnlyPendingTimersAsync(); + const result = await resultPromise; + expect(result).toEqual({ installed: true }); + + // Verify setQuittingForUpdate is called first + expect(mockLifecycleService.setQuittingForUpdate).toHaveBeenCalled(); + + expect(mockLifecycleService.shutdownWithoutContainer).toHaveBeenCalled(); + expect(mockLifecycleService.shutdown).not.toHaveBeenCalled(); + + expect(mockUpdater.quitAndInstall).toHaveBeenCalled(); + + // Verify order: setQuittingForUpdate -> shutdownWithoutContainer -> quitAndInstall + const setQuittingOrder = + mockLifecycleService.setQuittingForUpdate.mock.invocationCallOrder[0]; + const cleanupOrder = + mockLifecycleService.shutdownWithoutContainer.mock + .invocationCallOrder[0]; + const quitAndInstallOrder = + mockUpdater.quitAndInstall.mock.invocationCallOrder[0]; + + expect(setQuittingOrder).toBeLessThan(cleanupOrder); + expect(cleanupOrder).toBeLessThan(quitAndInstallOrder); + }); + + it("continues to quitAndInstall if partial shutdown times out", async () => { + await initializeService(service); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + + mockLifecycleService.shutdownWithoutContainer.mockReturnValue( + new Promise(() => {}), + ); + + const resultPromise = service.installUpdate(); + await vi.advanceTimersByTimeAsync(3000); + + await expect(resultPromise).resolves.toEqual({ installed: true }); + expect(mockUpdater.quitAndInstall).toHaveBeenCalled(); + expect(mockLog.warn).toHaveBeenCalledWith( + "Partial shutdown timed out before update install", + expect.objectContaining({ + timeoutMs: 3000, + downloadedVersion: "v2.0.0", + }), + ); + }); + + it("returns false if quitAndInstall throws", async () => { + await initializeService(service); + + // Simulate update downloaded + const updateDownloadedHandler = updaterHandlers.updateDownloaded; + + if (updateDownloadedHandler) { + updateDownloadedHandler("v2.0.0"); + } + + mockUpdater.quitAndInstall.mockImplementation(() => { + throw new Error("Failed to install"); + }); + + const resultPromise = service.installUpdate(); + await vi.runOnlyPendingTimersAsync(); + const result = await resultPromise; + expect(result).toEqual({ installed: false }); + }); + + it("clears the quitting-for-update lifecycle flag when install handoff fails", async () => { + await initializeService(service); + updaterHandlers.updateDownloaded?.("v2.0.0"); + + mockUpdater.quitAndInstall.mockImplementation(() => { + throw new Error("Failed to install"); + }); + + await service.installUpdate(); + + expect(mockLifecycleService.clearQuittingForUpdate).toHaveBeenCalled(); + const setOrder = + mockLifecycleService.setQuittingForUpdate.mock.invocationCallOrder[0]; + const clearOrder = + mockLifecycleService.clearQuittingForUpdate.mock.invocationCallOrder[0]; + expect(setOrder).toBeLessThan(clearOrder); + }); + + it("rolls back to a re-installable ready state when install handoff fails", async () => { + await initializeService(service); + updaterHandlers.updateDownloaded?.("v2.0.0"); + + mockUpdater.quitAndInstall.mockImplementation(() => { + throw new Error("Failed to install"); + }); + + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + const first = await service.installUpdate(); + expect(first).toEqual({ installed: false }); + expect(service.hasUpdateReady).toBe(true); + expect(statusHandler).toHaveBeenLastCalledWith({ + checking: false, + updateReady: true, + installing: false, + version: "v2.0.0", + }); + + mockUpdater.quitAndInstall.mockImplementationOnce(() => undefined); + const second = await service.installUpdate(); + expect(second).toEqual({ installed: true }); + }); + + it("is idempotent when install is already in progress", async () => { + await initializeService(service); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + + await expect(service.installUpdate()).resolves.toEqual({ + installed: true, + }); + expect(mockUpdater.quitAndInstall).toHaveBeenCalledTimes(1); + + await expect(service.installUpdate()).resolves.toEqual({ + installed: true, + }); + expect(mockUpdater.quitAndInstall).toHaveBeenCalledTimes(1); + expect(mockLog.warn).not.toHaveBeenCalledWith( + "installUpdate called but no update is ready", + expect.anything(), + ); + }); + }); + + describe("triggerMenuCheck", () => { + it("emits CheckFromMenu event", () => { + const handler = vi.fn(); + service.on(UpdatesEvent.CheckFromMenu, handler); + + service.triggerMenuCheck(); + + expect(handler).toHaveBeenCalledWith(true); + }); + }); + + describe("autoUpdater event handling", () => { + beforeEach(async () => { + await initializeService(service); + }); + + it("registers all required event handlers", () => { + expect(mockUpdater.onError).toHaveBeenCalled(); + expect(mockUpdater.onCheckStart).toHaveBeenCalled(); + expect(mockUpdater.onUpdateAvailable).toHaveBeenCalled(); + expect(mockUpdater.onNoUpdate).toHaveBeenCalled(); + expect(mockUpdater.onUpdateDownloaded).toHaveBeenCalled(); + }); + + it("handles update-not-available event", () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + // Start a check + service.checkForUpdates(); + statusHandler.mockClear(); + + // Simulate no update available + const notAvailableHandler = updaterHandlers.noUpdate; + + if (notAvailableHandler) { + notAvailableHandler(); + } + + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + upToDate: true, + version: "1.0.0", + }); + }); + + it("ignores later update events once an update is already downloaded", () => { + // Simulate update already downloaded + const downloadedHandler = updaterHandlers.updateDownloaded; + if (downloadedHandler) { + downloadedHandler("v2.0.0"); + } + + const statusHandler = vi.fn(); + const readyHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + service.on(UpdatesEvent.Ready, readyHandler); + + mockUpdater.check.mockClear(); + + // Periodic checks should be suppressed once an update is staged. + service.checkForUpdates("periodic"); + expect(mockUpdater.check).not.toHaveBeenCalled(); + + const notAvailableHandler = updaterHandlers.noUpdate; + if (notAvailableHandler) { + notAvailableHandler(); + } + + expect(statusHandler).not.toHaveBeenCalledWith({ checking: false }); + expect(statusHandler).not.toHaveBeenCalledWith( + expect.objectContaining({ upToDate: true }), + ); + expect(readyHandler).not.toHaveBeenCalled(); + }); + + it("handles update-downloaded event with version info", () => { + const readyHandler = vi.fn(); + service.on(UpdatesEvent.Ready, readyHandler); + + // Simulate update downloaded with version + const downloadedHandler = updaterHandlers.updateDownloaded; + + if (downloadedHandler) { + downloadedHandler("v2.0.0"); + } + + expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); + }); + + it("emits a complete staged payload when an update is downloaded", () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + updateReady: true, + installing: false, + version: "v2.0.0", + }); + }); + + it("handles error event and emits status with error", () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + // Start a check + service.checkForUpdates(); + statusHandler.mockClear(); + + // Simulate error + const errorHandler = updaterHandlers.error; + + if (errorHandler) { + errorHandler(new Error("Network error")); + } + + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + error: "Network error", + }); + }); + + it("handles error event gracefully when not checking", () => { + // Complete the initial check triggered by setupAutoUpdater so we're not in checking state + const notAvailableHandler = updaterHandlers.noUpdate; + if (notAvailableHandler) { + notAvailableHandler(); + } + + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + // Simulate error without starting a check + const errorHandler = updaterHandlers.error; + + expect(() => { + if (errorHandler) { + errorHandler(new Error("Test error")); + } + }).not.toThrow(); + + // Should not emit status since we weren't checking + expect(statusHandler).not.toHaveBeenCalled(); + }); + }); + + describe("status snapshots", () => { + it("returns update-ready status for a staged update", async () => { + await initializeService(service); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + + expect(service.getStatus()).toEqual({ + checking: false, + updateReady: true, + installing: false, + version: "v2.0.0", + }); + }); + + it("flags installing in the staged status payload while install is in flight", async () => { + await initializeService(service); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + mockLifecycleService.shutdownWithoutContainer.mockReturnValue( + new Promise(() => {}), + ); + + void service.installUpdate(); + // Allow the synchronous part of installUpdate to run. + await Promise.resolve(); + + expect(service.getStatus()).toEqual({ + checking: false, + updateReady: true, + installing: true, + version: "v2.0.0", + }); + }); + + it("returns downloading status while an update is downloading", async () => { + await initializeService(service); + + updaterHandlers.updateAvailable?.(); + + expect(service.getStatus()).toEqual({ + checking: true, + downloading: true, + }); + }); + }); + + describe("check timeout", () => { + beforeEach(async () => { + await initializeService(service); + }); + + it("times out after 60 seconds if no response", async () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + service.checkForUpdates(); + statusHandler.mockClear(); + + // Advance 60 seconds + await vi.advanceTimersByTimeAsync(60 * 1000); + + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + error: "Update check timed out. Please try again.", + }); + }); + + it("clears timeout when update-not-available fires", async () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + service.checkForUpdates(); + statusHandler.mockClear(); + + // Simulate response before timeout + const notAvailableHandler = updaterHandlers.noUpdate; + + if (notAvailableHandler) { + notAvailableHandler(); + } + + // Advance past the timeout + await vi.advanceTimersByTimeAsync(60 * 1000); + + // Should only have received the upToDate status, not a timeout + expect(statusHandler).toHaveBeenCalledTimes(1); + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + upToDate: true, + version: "1.0.0", + }); + }); + + it("clears timeout when error fires", async () => { + const statusHandler = vi.fn(); + service.on(UpdatesEvent.Status, statusHandler); + + service.checkForUpdates(); + statusHandler.mockClear(); + + // Simulate error before timeout + const errorHandler = updaterHandlers.error; + + if (errorHandler) { + errorHandler(new Error("Network error")); + } + + // Advance past the timeout + await vi.advanceTimersByTimeAsync(60 * 1000); + + // Should only have received the error status, not a timeout + expect(statusHandler).toHaveBeenCalledTimes(1); + expect(statusHandler).toHaveBeenCalledWith({ + checking: false, + error: "Network error", + }); + }); + }); + + describe("flushPendingNotification", () => { + it("emits Ready event on window focus when update is pending", async () => { + await initializeService(service); + + const readyHandler = vi.fn(); + service.on(UpdatesEvent.Ready, readyHandler); + + // Simulate update downloaded + const downloadedHandler = updaterHandlers.updateDownloaded; + + if (downloadedHandler) { + downloadedHandler("v2.0.0"); + } + + // First Ready event from handleUpdateDownloaded + expect(readyHandler).toHaveBeenCalledTimes(1); + + // Reset the handler count + readyHandler.mockClear(); + + // Pending notification should be false now, so no second emit + updaterHandlers.focus?.(); + + expect(readyHandler).not.toHaveBeenCalled(); + }); + }); + + describe("periodic update checks", () => { + it("performs initial check on setup", async () => { + await initializeService(service); + + expect(mockUpdater.check).toHaveBeenCalled(); + }); + + it("performs check every hour", async () => { + await initializeService(service); + + const initialCallCount = mockUpdater.check.mock.calls.length; + + // Advance 1 hour + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + + expect(mockUpdater.check.mock.calls.length).toBe(initialCallCount + 1); + + // Advance another hour + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + + expect(mockUpdater.check.mock.calls.length).toBe(initialCallCount + 2); + }); + + it("stops the periodic interval once an update is staged", async () => { + await initializeService(service); + + updaterHandlers.updateDownloaded?.("v2.0.0"); + + const baselineCallCount = mockUpdater.check.mock.calls.length; + + // The interval would normally fire every hour; with the update staged it + // should be cleared so no further wake-ups occur. + await vi.advanceTimersByTimeAsync(60 * 60 * 1000 * 3); + + expect(mockUpdater.check.mock.calls.length).toBe(baselineCallCount); + }); + }); + + describe("staged update guards", () => { + it("does not re-check on periodic checks when update is ready", async () => { + await initializeService(service); + + // Simulate update downloaded + const downloadedHandler = updaterHandlers.updateDownloaded; + if (downloadedHandler) { + downloadedHandler("v2.0.0"); + } + + // Clear the checkForUpdates calls from initialization + mockUpdater.check.mockClear(); + + // Periodic check should not overwrite or refresh the staged update. + const result = service.checkForUpdates("periodic"); + expect(result).toEqual({ success: true }); + expect(mockUpdater.check).not.toHaveBeenCalled(); + // Update should still be ready (state not reset) + expect(service.hasUpdateReady).toBe(true); + }); + + it("user check still shows existing notification when update is ready", async () => { + await initializeService(service); + + // Simulate update downloaded + const downloadedHandler = updaterHandlers.updateDownloaded; + if (downloadedHandler) { + downloadedHandler("v2.0.0"); + } + + const readyHandler = vi.fn(); + service.on(UpdatesEvent.Ready, readyHandler); + + // User check should show existing notification, not re-check + mockUpdater.check.mockClear(); + const result = service.checkForUpdates("user"); + expect(result).toEqual({ success: true }); + expect(mockUpdater.check).not.toHaveBeenCalled(); + expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); + }); + + it("preserves downloaded update when later updater errors fire", async () => { + await initializeService(service); + + // Simulate update downloaded + const downloadedHandler = updaterHandlers.updateDownloaded; + if (downloadedHandler) { + downloadedHandler("v2.0.0"); + } + + mockUpdater.check.mockClear(); + service.checkForUpdates("periodic"); + expect(mockUpdater.check).not.toHaveBeenCalled(); + + // Simulate a stale updater error after staging. + const errorHandler = updaterHandlers.error; + if (errorHandler) { + errorHandler(new Error("Network error")); + } + + // Update should still be ready + expect(service.hasUpdateReady).toBe(true); + }); + + it("does not re-notify when same version is re-downloaded after staging", async () => { + await initializeService(service); + + const readyHandler = vi.fn(); + service.on(UpdatesEvent.Ready, readyHandler); + + // First download of v2.0.0 + const downloadedHandler = updaterHandlers.updateDownloaded; + if (downloadedHandler) { + downloadedHandler("v2.0.0"); + } + expect(readyHandler).toHaveBeenCalledTimes(1); + + readyHandler.mockClear(); + + if (downloadedHandler) { + downloadedHandler("v2.0.0"); + } + + // Should NOT re-notify since same version + expect(readyHandler).not.toHaveBeenCalled(); + }); + + it("does not overwrite staged version when a later download event arrives", async () => { + await initializeService(service); + + const readyHandler = vi.fn(); + service.on(UpdatesEvent.Ready, readyHandler); + + // Simulate update downloaded + const downloadedHandler = updaterHandlers.updateDownloaded; + if (downloadedHandler) { + downloadedHandler("v2.0.0"); + } + expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); + + readyHandler.mockClear(); + + if (downloadedHandler) { + downloadedHandler("v3.0.0"); + } + + // User checks should still surface the originally staged update. + service.checkForUpdates("user"); + expect(readyHandler).toHaveBeenCalledWith({ version: "v2.0.0" }); + + // Update should still be ready (state not corrupted) + expect(service.hasUpdateReady).toBe(true); + }); + }); + + describe("transition logging", () => { + it("logs state transitions with source and state metadata", () => { + service.checkForUpdates("user"); + + expect(mockLog.info).toHaveBeenCalledWith( + "Update state transition", + expect.objectContaining({ + source: "user", + fromState: "idle", + toState: "checking", + downloadedVersion: null, + skippedBecauseUpdateStaged: false, + }), + ); + }); + + it("logs skipped checks after an update is staged", async () => { + await initializeService(service); + updaterHandlers.updateDownloaded?.("v2.0.0"); + + mockLog.info.mockClear(); + service.checkForUpdates("periodic"); + + expect(mockLog.info).toHaveBeenCalledWith( + "Update state transition", + expect.objectContaining({ + source: "periodic", + fromState: "ready", + toState: "ready", + downloadedVersion: "v2.0.0", + skippedBecauseUpdateStaged: true, + }), + ); + }); + }); + + describe("error handling", () => { + it("catches errors during checkForUpdates", async () => { + await initializeService(service); + + mockUpdater.check.mockImplementation(() => { + throw new Error("Network error"); + }); + + // Should not throw + expect(() => service.checkForUpdates()).not.toThrow(); + }); + + it("handles setFeedURL failure gracefully", async () => { + mockUpdater.setFeedUrl.mockImplementation(() => { + throw new Error("Invalid URL"); + }); + + // Should not throw + expect(() => { + const newService = new UpdatesService(); + injectPorts(newService); + newService.init(); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/core/src/updates/updates.ts b/packages/core/src/updates/updates.ts new file mode 100644 index 0000000000..cbfaf97049 --- /dev/null +++ b/packages/core/src/updates/updates.ts @@ -0,0 +1,480 @@ +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 { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { + type SagaLogger, + TypedEventEmitter, + withTimeout, +} from "@posthog/shared"; +import { inject, injectable, postConstruct, preDestroy } from "inversify"; +import { + type IUpdateLifecycle, + UPDATE_LIFECYCLE_SERVICE, +} from "./identifiers"; +import { + type CheckForUpdatesOutput, + type InstallUpdateOutput, + UpdatesEvent, + type UpdatesEvents, + type UpdatesStatusPayload, +} from "./schemas"; + +type CheckSource = "user" | "periodic"; +type UpdateState = + | "idle" + | "checking" + | "downloading" + | "ready" + | "installing" + | "error"; +type TransitionContext = { + source?: CheckSource; + skippedBecauseUpdateStaged?: boolean; + reason?: string; + incomingVersion?: string | null; + error?: string; +}; + +@injectable() +export class UpdatesService extends TypedEventEmitter<UpdatesEvents> { + private static readonly SERVER_HOST = "https://update.electronjs.org"; + private static readonly REPO_OWNER = "PostHog"; + private static readonly REPO_NAME = "code"; + 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; + + @inject(UPDATE_LIFECYCLE_SERVICE) + private lifecycle!: IUpdateLifecycle; + + @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(APP_LIFECYCLE_SERVICE) + private appLifecycle!: IAppLifecycle; + + @inject(APP_META_SERVICE) + private appMeta!: IAppMeta; + + @inject(MAIN_WINDOW_SERVICE) + private mainWindow!: IMainWindow; + + private state: UpdateState = "idle"; + private pendingNotification = false; + private checkTimeoutId: ReturnType<typeof setTimeout> | null = null; + private checkIntervalId: ReturnType<typeof setInterval> | null = null; + private downloadedVersion: string | null = null; + private notifiedVersion: string | null = null; + private lastError: string | null = null; + private initialized = false; + private unsubscribes: Array<() => void> = []; + + get hasUpdateReady(): boolean { + return this.isUpdateStaged(); + } + + private isUpdateStaged(): boolean { + return this.state === "ready" || this.state === "installing"; + } + + get isEnabled(): boolean { + 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}/${this.appMeta.platform}-${this.appMeta.arch}/${this.appMeta.version}`; + } + + @postConstruct() + init(): void { + if (!this.isEnabled) { + this.log.info("Auto updates not enabled for this host"); + return; + } + + this.unsubscribes.push( + this.mainWindow.onFocus(() => this.flushPendingNotification()), + ); + this.appLifecycle.whenReady().then(() => this.setupAutoUpdater()); + } + + triggerMenuCheck(): void { + this.emit(UpdatesEvent.CheckFromMenu, true); + } + + getStatus(): UpdatesStatusPayload { + if (this.state === "checking") { + return { checking: true }; + } + + if (this.state === "downloading") { + return { checking: true, downloading: true }; + } + + if (this.isUpdateStaged()) { + return this.stagedStatusPayload(); + } + + if (this.state === "error") { + return { + checking: false, + error: this.lastError ?? "Update check failed. Please try again.", + }; + } + + return { checking: false }; + } + + checkForUpdates(source: CheckSource = "user"): CheckForUpdatesOutput { + if (!this.isEnabled) { + 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" }; + } + + if (this.isUpdateStaged()) { + this.logStateTransition(this.state, { + source, + skippedBecauseUpdateStaged: true, + reason: "check skipped because update is already staged", + }); + + if (source === "user") { + this.pendingNotification = true; + this.flushPendingNotification(); + this.emitStatus(this.stagedStatusPayload()); + } + + return { success: true }; + } + + if (this.state === "checking" || this.state === "downloading") { + return { + success: false, + errorMessage: "Already checking for updates", + errorCode: "already_checking", + }; + } + + this.transitionTo("checking", { source }); + this.emitStatus({ checking: true }); + this.performCheck(); + + return { success: true }; + } + + async installUpdate(): Promise<InstallUpdateOutput> { + if (this.state === "installing") { + this.logStateTransition("installing", { + skippedBecauseUpdateStaged: true, + reason: "install already in progress", + }); + return { installed: true }; + } + + if (this.state !== "ready") { + this.log.warn("installUpdate called but no update is ready", { + state: this.state, + }); + return { installed: false }; + } + + this.log.info("Installing update and restarting...", { + downloadedVersion: this.downloadedVersion, + }); + + try { + this.transitionTo("installing", { reason: "install requested" }); + this.emitStatus(this.stagedStatusPayload()); + this.lifecycle.setQuittingForUpdate(); + const cleanupResult = await withTimeout( + this.lifecycle.shutdownWithoutContainer(), + UpdatesService.INSTALL_SHUTDOWN_TIMEOUT_MS, + ); + if (cleanupResult.result === "timeout") { + this.log.warn("Partial shutdown timed out before update install", { + timeoutMs: UpdatesService.INSTALL_SHUTDOWN_TIMEOUT_MS, + downloadedVersion: this.downloadedVersion, + }); + } + this.updater.quitAndInstall(); + return { installed: true }; + } catch (error) { + 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), + }); + this.emitStatus(this.stagedStatusPayload()); + return { installed: false }; + } + } + + private setupAutoUpdater(): void { + if (this.initialized) { + this.log.warn("setupAutoUpdater called multiple times, ignoring"); + return; + } + + this.initialized = true; + const feedUrl = this.feedUrl; + this.log.info("Setting up auto updater", { + feedUrl, + currentVersion: this.appMeta.version, + platform: this.appMeta.platform, + arch: this.appMeta.arch, + }); + + try { + this.updater.setFeedUrl(feedUrl); + } catch (error) { + this.log.error("Failed to set feed URL", { error }); + return; + } + + this.unsubscribes.push( + this.updater.onError((error) => this.handleError(error)), + this.updater.onCheckStart(() => this.log.info("Checking for updates...")), + this.updater.onUpdateAvailable(() => this.handleUpdateAvailable()), + this.updater.onNoUpdate(() => this.handleNoUpdate()), + this.updater.onUpdateDownloaded((releaseName) => + this.handleUpdateDownloaded(releaseName), + ), + ); + + this.checkForUpdates("periodic"); + + this.checkIntervalId = setInterval( + () => this.checkForUpdates("periodic"), + UpdatesService.CHECK_INTERVAL_MS, + ); + } + + private stagedStatusPayload(): UpdatesStatusPayload { + return { + checking: false, + updateReady: true, + installing: this.state === "installing", + version: this.downloadedVersion ?? undefined, + }; + } + + private handleError(error: Error): void { + this.clearCheckTimeout(); + this.log.error("Auto update error", { + message: error.message, + stack: error.stack, + feedUrl: this.feedUrl, + state: this.state, + }); + + if (this.isUpdateStaged()) { + this.logStateTransition(this.state, { + skippedBecauseUpdateStaged: true, + reason: "updater error ignored because update is staged", + error: error.message, + }); + return; + } + + if (this.state === "checking" || this.state === "downloading") { + this.lastError = error.message; + this.transitionTo("error", { error: error.message }); + this.emitStatus({ + checking: false, + error: error.message, + }); + } + } + + private handleUpdateAvailable(): void { + if (this.isUpdateStaged()) { + this.log.info( + "Ignoring update-available because an update is already staged", + { + downloadedVersion: this.downloadedVersion, + }, + ); + return; + } + + this.clearCheckTimeout(); + this.transitionTo("downloading", { reason: "update available" }); + this.log.info("Update available, downloading..."); + this.emitStatus({ checking: true, downloading: true }); + } + + private handleNoUpdate(): void { + this.clearCheckTimeout(); + + if (this.isUpdateStaged()) { + this.log.info("Ignoring update-not-available because update is staged", { + downloadedVersion: this.downloadedVersion, + }); + return; + } + + 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({ + checking: false, + upToDate: true, + version: this.appMeta.version, + }); + } + } + + private handleUpdateDownloaded(releaseName?: string): void { + this.clearCheckTimeout(); + + if (this.isUpdateStaged()) { + this.log.info("Ignoring duplicate update-downloaded event", { + existingVersion: this.downloadedVersion, + incomingVersion: releaseName, + }); + return; + } + + this.downloadedVersion = releaseName ?? null; + this.transitionTo("ready", { + reason: "update downloaded", + incomingVersion: releaseName ?? null, + }); + this.clearCheckInterval(); + this.emitStatus(this.stagedStatusPayload()); + + this.log.info("Update downloaded, awaiting user confirmation", { + currentVersion: this.appMeta.version, + downloadedVersion: this.downloadedVersion, + }); + + if (this.notifiedVersion !== this.downloadedVersion) { + this.pendingNotification = true; + this.flushPendingNotification(); + } else { + this.log.info("Skipping notification - same version already notified", { + version: this.downloadedVersion, + }); + } + } + + private flushPendingNotification(): void { + if (this.state === "ready" && this.pendingNotification) { + this.log.info("Notifying user that update is ready", { + downloadedVersion: this.downloadedVersion, + }); + this.emit(UpdatesEvent.Ready, { version: this.downloadedVersion }); + this.pendingNotification = false; + this.notifiedVersion = this.downloadedVersion; + } + } + + private emitStatus(status: UpdatesStatusPayload): void { + this.emit(UpdatesEvent.Status, status); + } + + private performCheck(): void { + this.clearCheckTimeout(); + + this.checkTimeoutId = setTimeout(() => { + if (this.state === "checking" || this.state === "downloading") { + const timeoutSeconds = UpdatesService.CHECK_TIMEOUT_MS / 1000; + const message = "Update check timed out. Please try again."; + this.log.warn(`Update check timed out after ${timeoutSeconds} seconds`); + this.lastError = message; + this.transitionTo("error", { error: message }); + this.emitStatus({ checking: false, error: message }); + } + }, UpdatesService.CHECK_TIMEOUT_MS); + + try { + this.updater.check(); + } catch (error) { + this.clearCheckTimeout(); + 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), + }); + this.emitStatus({ + checking: false, + error: "Failed to check for updates. Please try again.", + }); + } + } + + private transitionTo( + state: UpdateState, + context: TransitionContext = {}, + ): void { + this.logStateTransition(state, context); + this.state = state; + if (state !== "error") { + this.lastError = null; + } + } + + private logStateTransition( + toState: UpdateState, + context: TransitionContext = {}, + ): void { + this.log.info("Update state transition", { + source: context.source, + fromState: this.state, + toState, + downloadedVersion: this.downloadedVersion, + skippedBecauseUpdateStaged: context.skippedBecauseUpdateStaged ?? false, + reason: context.reason, + incomingVersion: context.incomingVersion, + error: context.error, + }); + } + + private clearCheckTimeout(): void { + if (this.checkTimeoutId) { + clearTimeout(this.checkTimeoutId); + this.checkTimeoutId = null; + } + } + + private clearCheckInterval(): void { + if (this.checkIntervalId) { + clearInterval(this.checkIntervalId); + this.checkIntervalId = null; + } + } + + @preDestroy() + shutdown(): void { + this.clearCheckTimeout(); + this.clearCheckInterval(); + for (const unsub of this.unsubscribes) unsub(); + this.unsubscribes = []; + } +} 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/packages/core/src/usage/monitor-schemas.ts b/packages/core/src/usage/monitor-schemas.ts new file mode 100644 index 0000000000..abbdb0f8a4 --- /dev/null +++ b/packages/core/src/usage/monitor-schemas.ts @@ -0,0 +1,34 @@ +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]; + +export const thresholdCrossedEvent = z.object({ + bucket: z.enum(["burst", "sustained"]), + threshold: z.union([ + z.literal(50), + z.literal(75), + z.literal(90), + z.literal(100), + ]), + usedPercent: z.number(), + resetAt: z.string().datetime(), + isPro: z.boolean(), + userIsActive: z.boolean(), +}); + +export type ThresholdCrossedEvent = z.infer<typeof thresholdCrossedEvent>; + +export const usageSnapshotOutput = usageOutput.nullable(); +export type UsageSnapshot = UsageOutput | null; + +export const UsageMonitorEvent = { + ThresholdCrossed: "threshold-crossed", + UsageUpdated: "usage-updated", +} as const; + +export interface UsageMonitorEvents { + [UsageMonitorEvent.ThresholdCrossed]: ThresholdCrossedEvent; + [UsageMonitorEvent.UsageUpdated]: UsageOutput; +} 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/packages/core/src/usage/usage-monitor.test.ts b/packages/core/src/usage/usage-monitor.test.ts new file mode 100644 index 0000000000..6e1c8dad70 --- /dev/null +++ b/packages/core/src/usage/usage-monitor.test.ts @@ -0,0 +1,309 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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?: { + burstPercent?: number; + sustainedPercent?: number; + billingPeriodEnd?: string | null; + burstResetAt?: string; + sustainedResetAt?: string; + isPro?: boolean; +}): UsageOutput { + return { + product: "posthog_code", + user_id: 42, + is_rate_limited: false, + is_pro: overrides?.isPro ?? false, + billing_period_end: + overrides?.billingPeriodEnd === undefined + ? null + : overrides.billingPeriodEnd, + burst: { + used_percent: overrides?.burstPercent ?? 0, + reset_at: overrides?.burstResetAt ?? "2026-05-25T16:00:00.000Z", + exceeded: false, + }, + sustained: { + used_percent: overrides?.sustainedPercent ?? 0, + reset_at: overrides?.sustainedResetAt ?? "2026-06-01T00:00:00.000Z", + exceeded: false, + }, + }; +} + +function mockGateway(usage: UsageOutput | null): GatewaySlice { + return { + fetchUsage: vi.fn().mockResolvedValue(usage), + } as unknown as GatewaySlice; +} + +describe("UsageMonitorService", () => { + let service: UsageMonitorService; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-25T12:00:00.000Z")); + persisted = {}; + }); + + afterEach(() => { + service?.stop(); + vi.useRealTimers(); + }); + + 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 = makeService(gateway, makeActivityMonitor()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + + await service.fetchOnce(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + bucket: "burst", + threshold: 75, + usedPercent: 78, + }); + + await service.fetchOnce(); + expect(events).toHaveLength(1); + }); + + it("only emits the highest threshold a bucket has crossed", async () => { + const events: unknown[] = []; + const gateway = mockGateway(makeUsage({ burstPercent: 95 })); + service = makeService(gateway, makeActivityMonitor()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + + await service.fetchOnce(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ threshold: 90 }); + }); + + it("doesn't re-emit after a relaunch with persisted dedupe", async () => { + const events: unknown[] = []; + const gateway = mockGateway(makeUsage({ burstPercent: 55 })); + service = makeService(gateway, makeActivityMonitor()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + await service.fetchOnce(); + expect(events).toHaveLength(1); + service.stop(); + + service = makeService(gateway, makeActivityMonitor()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + await service.fetchOnce(); + expect(events).toHaveLength(1); + }); + + it("tracks burst and sustained as independent buckets", async () => { + const events: unknown[] = []; + const gateway = mockGateway( + makeUsage({ + burstPercent: 55, + sustainedPercent: 80, + billingPeriodEnd: "2026-06-01T00:00:00.000Z", + }), + ); + service = makeService(gateway, makeActivityMonitor()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + + await service.fetchOnce(); + expect(events).toHaveLength(2); + expect(events.map((e) => (e as { bucket: string }).bucket).sort()).toEqual([ + "burst", + "sustained", + ]); + }); + + it("marks events with isPro from the gateway", async () => { + const events: { isPro: boolean }[] = []; + const gateway = mockGateway( + makeUsage({ + sustainedPercent: 60, + isPro: true, + billingPeriodEnd: "2026-06-01T00:00:00.000Z", + }), + ); + service = makeService(gateway, makeActivityMonitor()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => + events.push(e as { isPro: boolean }), + ); + + await service.fetchOnce(); + expect(events[0]?.isPro).toBe(true); + }); + + it("marks events with userIsActive from the agent service", async () => { + const events: { userIsActive: boolean }[] = []; + const gateway = mockGateway(makeUsage({ burstPercent: 78 })); + service = makeService( + gateway, + makeActivityMonitor({ hasActiveSessions: true }), + ); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => + events.push(e as { userIsActive: boolean }), + ); + + await service.fetchOnce(); + expect(events[0]?.userIsActive).toBe(true); + }); + + it("silently skips polls when the gateway throws", async () => { + const events: unknown[] = []; + const gateway = { + fetchUsage: vi.fn().mockRejectedValue(new Error("not authenticated")), + } as unknown as GatewaySlice; + service = makeService(gateway, makeActivityMonitor()); + service.on(UsageMonitorEvent.ThresholdCrossed, (e) => events.push(e)); + + await expect(service.fetchOnce()).resolves.toBeNull(); + expect(events).toHaveLength(0); + }); + + it("emits UsageUpdated only when the snapshot actually changes", async () => { + const updates: UsageOutput[] = []; + const gateway = { + fetchUsage: vi + .fn() + .mockResolvedValueOnce(makeUsage({ burstPercent: 20 })) + .mockResolvedValueOnce(makeUsage({ burstPercent: 20 })) + .mockResolvedValueOnce(makeUsage({ burstPercent: 35 })), + } as unknown as GatewaySlice; + service = makeService(gateway, makeActivityMonitor()); + service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); + + expect(service.getLatest()).toBeNull(); + await service.fetchOnce(); + expect(updates).toHaveLength(1); + expect(service.getLatest()?.burst.used_percent).toBe(20); + + await service.fetchOnce(); + expect(updates).toHaveLength(1); + + await service.fetchOnce(); + expect(updates).toHaveLength(2); + expect(updates[1].burst.used_percent).toBe(35); + }); + + it("does not emit UsageUpdated when the gateway throws", async () => { + const updates: UsageOutput[] = []; + const gateway = { + fetchUsage: vi.fn().mockRejectedValue(new Error("offline")), + } as unknown as GatewaySlice; + service = makeService(gateway, makeActivityMonitor()); + service.on(UsageMonitorEvent.UsageUpdated, (u) => updates.push(u)); + + await service.fetchOnce(); + expect(updates).toHaveLength(0); + expect(service.getLatest()).toBeNull(); + }); + + it("refreshNow triggers a fresh fetch and returns the snapshot", async () => { + const gateway = mockGateway(makeUsage({ burstPercent: 42 })); + service = makeService(gateway, makeActivityMonitor()); + + const result = await service.refreshNow(); + expect(result?.burst.used_percent).toBe(42); + expect(service.getLatest()?.burst.used_percent).toBe(42); + }); + + it("collapses bursts of LlmActivity into at most one trailing fetch", async () => { + const gateway = mockGateway(makeUsage({ burstPercent: 10 })); + const agent = makeActivityMonitor(); + service = makeService(gateway, agent); + service.init(); + await vi.advanceTimersByTimeAsync(0); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(1); + + agent.fireLlmActivity(); + agent.fireLlmActivity(); + agent.fireLlmActivity(); + agent.fireLlmActivity(); + await vi.advanceTimersByTimeAsync(0); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5_000); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(60_000); + 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 = 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.fireLlmActivity(); + await vi.advanceTimersByTimeAsync(10_000); + expect(gateway.fetchUsage).toHaveBeenCalledTimes(baseline); + }); +}); diff --git a/packages/core/src/usage/usage-monitor.ts b/packages/core/src/usage/usage-monitor.ts new file mode 100644 index 0000000000..bee75b7d49 --- /dev/null +++ b/packages/core/src/usage/usage-monitor.ts @@ -0,0 +1,259 @@ +import { WORKBENCH_LOGGER, type WorkbenchLogger } from "@posthog/di/logger"; +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable, postConstruct, preDestroy } from "inversify"; +import { USAGE_HOST, type UsageHost, type UsageLogger } from "./identifiers"; +import { + USAGE_THRESHOLDS, + UsageMonitorEvent, + type UsageMonitorEvents, + type UsageThreshold, +} 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 +// sits idle and no LlmActivity events fire. +const BACKSTOP_INTERVAL_MS = 30 * 60_000; + +type BucketName = "burst" | "sustained"; + +@injectable() +export class UsageMonitorService extends TypedEventEmitter<UsageMonitorEvents> { + private backstopTimeoutId: ReturnType<typeof setTimeout> | null = null; + private coalesceTimeoutId: ReturnType<typeof setTimeout> | null = null; + private lastFetchStartedAt = 0; + private isFetching = false; + private thresholdsSeen: Record<string, string>; + private latestUsage: UsageOutput | null = null; + + private readonly onLlmActivity = (): void => this.requestRefresh(); + + constructor( + @inject(USAGE_HOST) + private readonly host: UsageHost, + @inject(WORKBENCH_LOGGER) + logger: WorkbenchLogger, + ) { + super(); + this.log = logger.scope("usage-monitor"); + this.thresholdsSeen = { ...this.host.getThresholdsSeen() }; + } + + private readonly log: UsageLogger; + + getLatest(): UsageOutput | null { + return this.latestUsage; + } + + async refreshNow(): Promise<UsageOutput | null> { + return this.fetchOnce(); + } + + // Coalesces N parallel agents finishing turns into at most two fetches + // (leading + trailing) per `COALESCE_INTERVAL_MS` window. + requestRefresh(): void { + if (this.coalesceTimeoutId) return; + const now = Date.now(); + const delay = Math.max( + 0, + this.lastFetchStartedAt + COALESCE_INTERVAL_MS - now, + ); + this.coalesceTimeoutId = setTimeout(() => { + this.coalesceTimeoutId = null; + void this.fetchOnce(); + }, delay); + } + + @postConstruct() + init(): void { + this.pruneStaleEntries(); + this.host.onLlmActivity(this.onLlmActivity); + void this.fetchOnce(); + this.scheduleBackstop(); + } + + @preDestroy() + stop(): void { + this.host.offLlmActivity(this.onLlmActivity); + if (this.backstopTimeoutId) { + clearTimeout(this.backstopTimeoutId); + this.backstopTimeoutId = null; + } + if (this.coalesceTimeoutId) { + clearTimeout(this.coalesceTimeoutId); + this.coalesceTimeoutId = null; + } + } + + async fetchOnce(): Promise<UsageOutput | null> { + if (this.isFetching) return null; + this.isFetching = true; + this.lastFetchStartedAt = Date.now(); + if (this.coalesceTimeoutId) { + clearTimeout(this.coalesceTimeoutId); + this.coalesceTimeoutId = null; + } + try { + let usage: UsageOutput | null = null; + try { + usage = await this.host.fetchUsage(); + } catch (err) { + this.log.debug("Usage fetch skipped", { + error: err instanceof Error ? err.message : String(err), + }); + } + if (usage) { + const changed = !isSameUsage(this.latestUsage, usage); + this.latestUsage = usage; + if (changed) { + this.emit(UsageMonitorEvent.UsageUpdated, usage); + } + this.processUsage(usage); + } + return usage; + } finally { + this.isFetching = false; + } + } + + private scheduleBackstop(): void { + this.backstopTimeoutId = setTimeout(async () => { + this.backstopTimeoutId = null; + await this.fetchOnce(); + this.scheduleBackstop(); + }, BACKSTOP_INTERVAL_MS); + } + + private processUsage(usage: UsageOutput): void { + const userId = usage.user_id.toString(); + const product = usage.product; + this.maybeEmit(usage, "burst", usage.burst, userId, product, usage.is_pro); + this.maybeEmit( + usage, + "sustained", + usage.sustained, + userId, + product, + usage.is_pro, + ); + } + + private maybeEmit( + usage: UsageOutput, + bucket: BucketName, + status: UsageBucket, + userId: string, + product: string, + isPro: boolean, + ): void { + const anchor = this.anchorFor(bucket, status, usage); + if (!anchor) return; + + const threshold = highestThresholdCrossed(status.used_percent); + if (threshold === null) return; + + const key = makeKey(userId, product, bucket, anchor, threshold); + if (this.thresholdsSeen[key]) return; + + this.thresholdsSeen[key] = anchor; + this.host.setThresholdsSeen(this.thresholdsSeen); + + this.log.info("Usage threshold crossed", { + bucket, + threshold, + usedPercent: status.used_percent, + }); + + this.emit(UsageMonitorEvent.ThresholdCrossed, { + bucket, + threshold, + usedPercent: status.used_percent, + resetAt: status.reset_at, + isPro, + userIsActive: this.host.hasActiveSessions(), + }); + } + + // Rounded anchor so transient TTL jitter doesn't make every poll look like + // a fresh window. + private anchorFor( + bucket: BucketName, + status: UsageBucket, + usage: UsageOutput, + ): string | null { + if (bucket === "sustained") { + return usage.billing_period_end ?? sustainedFreeAnchor(status) ?? null; + } + return burstAnchor(status); + } + + private pruneStaleEntries(): void { + const now = Date.now(); + let dirty = false; + for (const [key, anchor] of Object.entries(this.thresholdsSeen)) { + const parsed = Date.parse(anchor); + if (Number.isNaN(parsed) || parsed < now) { + delete this.thresholdsSeen[key]; + dirty = true; + } + } + if (dirty) { + this.host.setThresholdsSeen(this.thresholdsSeen); + } + } +} + +function highestThresholdCrossed(usedPercent: number): UsageThreshold | null { + for (let i = USAGE_THRESHOLDS.length - 1; i >= 0; i--) { + const t = USAGE_THRESHOLDS[i]; + if (usedPercent >= t) return t; + } + return null; +} + +function burstAnchor(status: UsageBucket): string | null { + const resetMs = resetMillis(status); + if (resetMs === null) return null; + // Round to the nearest hour so 30s polling doesn't churn the anchor. + const rounded = Math.round(resetMs / 3_600_000) * 3_600_000; + return new Date(rounded).toISOString(); +} + +function sustainedFreeAnchor(status: UsageBucket): string | null { + const resetMs = resetMillis(status); + if (resetMs === null) return null; + return new Date(resetMs).toISOString().slice(0, 10); +} + +function resetMillis(status: UsageBucket): number | null { + const parsed = Date.parse(status.reset_at); + return Number.isNaN(parsed) ? null : parsed; +} + +function makeKey( + userId: string, + product: string, + bucket: BucketName, + anchor: string, + threshold: UsageThreshold, +): string { + return `${userId}:${product}:${bucket}:${anchor}:${threshold}`; +} + +function isSameUsage(a: UsageOutput | null, b: UsageOutput): boolean { + if (!a) return false; + return ( + a.is_rate_limited === b.is_rate_limited && + a.billing_period_end === b.billing_period_end && + isSameBucket(a.burst, b.burst) && + isSameBucket(a.sustained, b.sustained) + ); +} + +function isSameBucket(a: UsageBucket, b: UsageBucket): boolean { + return ( + a.used_percent === b.used_percent && + a.reset_at === b.reset_at && + a.exceeded === b.exceeded + ); +} 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/di/src/react.tsx b/packages/di/src/react.tsx new file mode 100644 index 0000000000..482556dc16 --- /dev/null +++ b/packages/di/src/react.tsx @@ -0,0 +1,48 @@ +import type { ServiceIdentifier } from "inversify"; +import type { ReactNode } from "react"; +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); + +export function ServiceProvider({ + children, + container, +}: { + children: ReactNode; + container: ServiceContainer; +}) { + const value = useMemo(() => container, [container]); + + return ( + <ServiceContext.Provider value={value}>{children}</ServiceContext.Provider> + ); +} + +export function useService<T>(serviceIdentifier: ServiceIdentifier<T>): T { + const container = useContext(ServiceContext); + if (!container) { + throw new Error("useService must be used within a ServiceProvider"); + } + + 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..034d7841fe 100644 --- a/packages/enricher/src/serialize.ts +++ b/packages/enricher/src/serialize.ts @@ -1,59 +1,22 @@ +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[]; -} +// PORT NOTE: the Serialized* enrichment boundary types now live in +// @posthog/shared/enrichment (renderer-safe). Re-exported here for enricher's +// own consumers (apps/code + ws-server) that import from @posthog/enricher. +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..89216c488d 100644 --- a/packages/enricher/src/types.ts +++ b/packages/enricher/src/types.ts @@ -176,13 +176,11 @@ 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"; +// PORT NOTE: FlagType + StalenessReason now live in @posthog/shared/enrichment +// (renderer-safe boundary types). Imported for enricher-internal use and +// re-exported here for enricher's own consumers. +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..0459738e28 --- /dev/null +++ b/packages/host-router/package.json @@ -0,0 +1,37 @@ +{ + "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-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/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..711f8cd554 --- /dev/null +++ b/packages/host-router/src/router.ts @@ -0,0 +1,74 @@ +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 { contextMenuRouter } from "./routers/context-menu.router"; +import { deepLinkRouter } from "./routers/deep-link.router"; +import { enrichmentRouter } from "./routers/enrichment.router"; +import { externalAppsRouter } from "./routers/external-apps.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 { linearIntegrationRouter } from "./routers/linear-integration.router"; +import { llmGatewayRouter } from "./routers/llm-gateway.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, + contextMenu: contextMenuRouter, + deepLink: deepLinkRouter, + enrichment: enrichmentRouter, + externalApps: externalAppsRouter, + focus: focusRouter, + folders: foldersRouter, + fs: fsRouter, + git: gitRouter, + githubIntegration: githubIntegrationRouter, + linearIntegration: linearIntegrationRouter, + llmGateway: llmGatewayRouter, + 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..888d2e805c --- /dev/null +++ b/packages/host-router/src/routers/agent.router.ts @@ -0,0 +1,228 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { SLEEP_SERVICE } from "@posthog/core/sleep/identifiers"; +import type { SleepService } from "@posthog/core/sleep/sleep"; +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..6338049a82 --- /dev/null +++ b/packages/host-router/src/routers/analytics.router.ts @@ -0,0 +1,30 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { ANALYTICS_SERVICE } from "@posthog/platform/analytics"; +import type { IAnalytics } 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..7be6cab505 --- /dev/null +++ b/packages/host-router/src/routers/auth.router.ts @@ -0,0 +1,76 @@ +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/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..0837d5f3bf --- /dev/null +++ b/packages/host-router/src/routers/deep-link.router.ts @@ -0,0 +1,79 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +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"; + +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/packages/host-router/src/routers/enrichment.router.ts b/packages/host-router/src/routers/enrichment.router.ts new file mode 100644 index 0000000000..eb1fa2d88e --- /dev/null +++ b/packages/host-router/src/routers/enrichment.router.ts @@ -0,0 +1,65 @@ +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"; + +const enrichFileInput = z.object({ + taskId: z.string(), + filePath: z.string(), + absolutePath: z.string().optional(), + content: z.string(), +}); + +const detectPosthogInstallStateInput = z.object({ + repoPath: z.string(), +}); + +const detectPosthogInstallStateOutput = z.enum([ + "not_installed", + "installed_no_init", + "initialized", +]); + +const findStaleFlagSuggestionsInput = z.object({ + repoPath: z.string(), +}); + +const staleFlagReference = z.object({ + file: z.string(), + line: z.number(), + method: z.string(), +}); + +const findStaleFlagSuggestionsOutput = z.array( + z.object({ + flagKey: z.string(), + references: z.array(staleFlagReference), + referenceCount: z.number(), + }), +); + +export const enrichmentRouter = router({ + enrichFile: publicProcedure + .input(enrichFileInput) + .query(({ ctx, input }) => + ctx.container + .get<EnrichmentService>(ENRICHMENT_SERVICE) + .enrichFile(input), + ), + detectPosthogInstallState: publicProcedure + .input(detectPosthogInstallStateInput) + .output(detectPosthogInstallStateOutput) + .query(({ ctx, input }) => + ctx.container + .get<EnrichmentService>(ENRICHMENT_SERVICE) + .detectPosthogInstallState(input.repoPath), + ), + findStaleFlagSuggestions: publicProcedure + .input(findStaleFlagSuggestionsInput) + .output(findStaleFlagSuggestionsOutput) + .query(({ ctx, input }) => + ctx.container + .get<EnrichmentService>(ENRICHMENT_SERVICE) + .findStaleFlagSuggestions(input.repoPath), + ), +}); 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/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..51211f7ff7 --- /dev/null +++ b/packages/host-router/src/routers/fs.router.ts @@ -0,0 +1,90 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { + type FsCapability, + FS_SERVICE, +} 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..99a3c78908 --- /dev/null +++ b/packages/host-router/src/routers/github-integration.router.ts @@ -0,0 +1,58 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +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"; + +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/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..efe7420365 --- /dev/null +++ b/packages/host-router/src/routers/linear-integration.router.ts @@ -0,0 +1,18 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +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"; + +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/mcp-apps.router.ts b/packages/host-router/src/routers/mcp-apps.router.ts new file mode 100644 index 0000000000..3afb86b3b1 --- /dev/null +++ b/packages/host-router/src/routers/mcp-apps.router.ts @@ -0,0 +1,118 @@ +import { MCP_APPS_SERVICE } from "@posthog/core/mcp-apps/identifiers"; +import type { McpAppsService } from "@posthog/core/mcp-apps/mcp-apps"; +import { + getToolDefinitionInput, + getUiResourceInput, + hasUiForToolInput, + McpAppsServiceEvent, + mcpAppsSubscriptionInput, + mcpUiResourceSchema, + openLinkInput, + proxyResourceReadInput, + proxyToolCallInput, +} 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(({ ctx, input }) => + ctx.container + .get<McpAppsService>(MCP_APPS_SERVICE) + .getUiResourceForTool(input.toolKey), + ), + + hasUiForTool: publicProcedure + .input(hasUiForToolInput) + .query(({ ctx, input }) => + ctx.container + .get<McpAppsService>(MCP_APPS_SERVICE) + .hasUiForTool(input.toolKey), + ), + + getToolDefinition: publicProcedure + .input(getToolDefinitionInput) + .query(({ ctx, input }) => + ctx.container + .get<McpAppsService>(MCP_APPS_SERVICE) + .getToolDefinition(input.toolKey), + ), + + proxyToolCall: publicProcedure + .input(proxyToolCallInput) + .mutation(({ ctx, input }) => + ctx.container + .get<McpAppsService>(MCP_APPS_SERVICE) + .proxyToolCall(input.serverName, input.toolName, input.args), + ), + + proxyResourceRead: publicProcedure + .input(proxyResourceReadInput) + .mutation(({ ctx, input }) => + ctx.container + .get<McpAppsService>(MCP_APPS_SERVICE) + .proxyResourceRead(input.serverName, input.uri), + ), + + openLink: publicProcedure + .input(openLinkInput) + .mutation(({ ctx, input }) => + ctx.container.get<McpAppsService>(MCP_APPS_SERVICE).openLink(input.url), + ), + + onToolInput: publicProcedure + .input(mcpAppsSubscriptionInput) + .subscription(async function* (opts) { + const service = opts.ctx.container.get<McpAppsService>(MCP_APPS_SERVICE); + const targetToolKey = opts.input.toolKey; + for await (const event of service.toIterable( + McpAppsServiceEvent.ToolInput, + { signal: opts.signal }, + )) { + if (event.toolKey === targetToolKey) { + yield event; + } + } + }), + + onToolResult: publicProcedure + .input(mcpAppsSubscriptionInput) + .subscription(async function* (opts) { + const service = opts.ctx.container.get<McpAppsService>(MCP_APPS_SERVICE); + const targetToolKey = opts.input.toolKey; + for await (const event of service.toIterable( + McpAppsServiceEvent.ToolResult, + { signal: opts.signal }, + )) { + if (event.toolKey === targetToolKey) { + yield event; + } + } + }), + + onToolCancelled: publicProcedure + .input(mcpAppsSubscriptionInput) + .subscription(async function* (opts) { + const service = opts.ctx.container.get<McpAppsService>(MCP_APPS_SERVICE); + const targetToolKey = opts.input.toolKey; + for await (const event of service.toIterable( + McpAppsServiceEvent.ToolCancelled, + { signal: opts.signal }, + )) { + if (event.toolKey === targetToolKey) { + yield event; + } + } + }), + + onDiscoveryComplete: publicProcedure.subscription(async function* (opts) { + const service = opts.ctx.container.get<McpAppsService>(MCP_APPS_SERVICE); + for await (const event of service.toIterable( + McpAppsServiceEvent.DiscoveryComplete, + { signal: opts.signal }, + )) { + yield event; + } + }), +}); diff --git a/packages/host-router/src/routers/mcp-callback.router.ts b/packages/host-router/src/routers/mcp-callback.router.ts new file mode 100644 index 0000000000..d6ba723c7f --- /dev/null +++ b/packages/host-router/src/routers/mcp-callback.router.ts @@ -0,0 +1,51 @@ +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 "@posthog/workspace-server/services/mcp-callback/schemas"; + +export const mcpCallbackRouter = router({ + /** + * Get the callback URL for MCP OAuth (dev: http://localhost:8238/..., prod: deep link via the app-registered URL scheme). + * Call this before making the install_custom API call to PostHog. + */ + getCallbackUrl: publicProcedure + .output(getCallbackUrlOutput) + .query(({ ctx }) => + ctx.container + .get<McpCallbackService>(MCP_CALLBACK_SERVICE) + .getCallbackUrl(), + ), + + /** + * Open the OAuth authorization URL in the browser and wait for the callback. + * Returns when the OAuth flow completes (success or error). + */ + openAndWaitForCallback: publicProcedure + .input(openAndWaitInput) + .output(openAndWaitOutput) + .mutation(({ ctx, input }) => + ctx.container + .get<McpCallbackService>(MCP_CALLBACK_SERVICE) + .openAndWaitForCallback(input.redirectUrl), + ), + + /** + * Subscribe to MCP OAuth completion events. + * Useful for refreshing the installations list when a flow completes. + */ + onOAuthComplete: publicProcedure.subscription(async function* (opts) { + const service = + opts.ctx.container.get<McpCallbackService>(MCP_CALLBACK_SERVICE); + for await (const data of service.toIterable( + McpCallbackEvent.OAuthComplete, + { signal: opts.signal }, + )) { + yield data; + } + }), +}); 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..527790ee39 --- /dev/null +++ b/packages/host-router/src/routers/process-tracking.router.ts @@ -0,0 +1,60 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import type { ProcessTrackingService } from "@posthog/workspace-server/services/process-tracking/process-tracking"; +import { PROCESS_TRACKING_SERVICE } from "@posthog/workspace-server/services/process-tracking/identifiers"; +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..d464243cba --- /dev/null +++ b/packages/host-router/src/routers/provisioning.router.ts @@ -0,0 +1,19 @@ +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..6b988ee27b --- /dev/null +++ b/packages/host-router/src/routers/slack-integration.router.ts @@ -0,0 +1,53 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { SLACK_INTEGRATION_SERVICE } from "@posthog/core/integrations/identifiers"; +import { + type SlackIntegrationCallback, + SlackIntegrationEvent, + type SlackIntegrationService, +} from "@posthog/core/integrations/slack"; +import { + startIntegrationFlowInput, + startIntegrationFlowOutput, +} from "@posthog/core/integrations/schemas"; + +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..aa6f52a314 --- /dev/null +++ b/packages/host-router/src/routers/sleep.router.ts @@ -0,0 +1,16 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { SLEEP_SERVICE } from "@posthog/core/sleep/identifiers"; +import type { SleepService } from "@posthog/core/sleep/sleep"; +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/packages/host-router/src/routers/ui.router.ts b/packages/host-router/src/routers/ui.router.ts new file mode 100644 index 0000000000..dd64512190 --- /dev/null +++ b/packages/host-router/src/routers/ui.router.ts @@ -0,0 +1,22 @@ +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 = opts.ctx.container.get<UIService>(UI_SERVICE); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const uiRouter = router({ + onOpenSettings: subscribeToUIEvent(UIServiceEvent.OpenSettings), + onNewTask: subscribeToUIEvent(UIServiceEvent.NewTask), + onResetLayout: subscribeToUIEvent(UIServiceEvent.ResetLayout), + onClearStorage: subscribeToUIEvent(UIServiceEvent.ClearStorage), + onInvalidateToken: subscribeToUIEvent(UIServiceEvent.InvalidateToken), +}); diff --git a/packages/host-router/src/routers/updates.router.test.ts b/packages/host-router/src/routers/updates.router.test.ts new file mode 100644 index 0000000000..df505071f6 --- /dev/null +++ b/packages/host-router/src/routers/updates.router.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; + +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({ container: resolver }); + + await expect(caller.getStatus()).resolves.toEqual({ + checking: false, + updateReady: true, + version: "v2.0.0", + }); + expect(mockUpdatesService.getStatus).toHaveBeenCalled(); + }); + + it("delegates menu/user checks to the updates service", async () => { + 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({ container: resolver }); + + await expect(caller.isEnabled()).resolves.toEqual({ enabled: true }); + }); + + it("delegates install to the updates service", async () => { + 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/packages/host-router/src/routers/usage-monitor.router.ts b/packages/host-router/src/routers/usage-monitor.router.ts new file mode 100644 index 0000000000..9f8a1a688d --- /dev/null +++ b/packages/host-router/src/routers/usage-monitor.router.ts @@ -0,0 +1,37 @@ +import { USAGE_MONITOR_SERVICE } from "@posthog/core/usage/identifiers"; +import { + UsageMonitorEvent, + type UsageMonitorEvents, + usageSnapshotOutput, +} 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 = opts.ctx.container.get<UsageMonitorService>( + USAGE_MONITOR_SERVICE, + ); + const iterable = service.toIterable(event, { signal: opts.signal }); + for await (const data of iterable) { + yield data; + } + }); +} + +export const usageMonitorRouter = router({ + onThresholdCrossed: subscribe(UsageMonitorEvent.ThresholdCrossed), + onUsageUpdated: subscribe(UsageMonitorEvent.UsageUpdated), + getLatest: publicProcedure + .output(usageSnapshotOutput) + .query(({ ctx }) => + ctx.container.get<UsageMonitorService>(USAGE_MONITOR_SERVICE).getLatest(), + ), + refresh: publicProcedure + .output(usageSnapshotOutput) + .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..0a332f70e1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,18 +7,28 @@ ".": { "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" } }, "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/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts new file mode 100644 index 0000000000..7e0e61b92c --- /dev/null +++ b/packages/shared/src/analytics-events.ts @@ -0,0 +1,896 @@ +// Analytics event types and properties + +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"; +type TaskCreatedFrom = "cli" | "command-menu"; +type RepositorySelectSource = "task-creation" | "task-detail"; +type GitActionType = + | "push" + | "pull" + | "sync" + | "publish" + | "commit" + | "commit-push" + | "create-pr" + | "view-pr" + | "update-pr" + | "branch-here"; +export type FeedbackType = "good" | "bad" | "general"; +type FileOpenSource = "sidebar" | "agent-suggestion" | "search" | "diff"; +export type FileChangeType = "added" | "modified" | "deleted"; +type StopReason = "user_cancelled" | "completed" | "error" | "timeout"; +export type SkillButtonId = + | "add-analytics" + | "create-feature-flags" + | "run-experiment" + | "add-error-tracking" + | "instrument-llm-calls" + | "add-logging"; +type SkillButtonSource = "primary" | "dropdown"; +export type CommandMenuAction = + | "home" + | "new-task" + | "settings" + | "logout" + | "toggle-theme" + | "toggle-left-sidebar" + | "open-review-panel" + | "open-task"; + +// Event property interfaces +export interface TaskListViewProperties { + filter_type?: string; + sort_field?: string; + view_mode?: string; +} + +export interface TaskCreateProperties { + auto_run: boolean; + created_from: TaskCreatedFrom; + repository_provider?: RepositoryProvider; + workspace_mode?: "local" | "worktree" | "cloud"; + has_branch?: boolean; + /** Worktree mode: a project environment with a setup script was selected */ + has_environment_setup?: boolean; + /** Cloud mode: a sandbox environment was selected */ + has_sandbox_environment?: boolean; + cloud_run_source?: "manual" | "signal_report"; + cloud_pr_authorship_mode?: "user" | "bot"; + signal_report_id?: string; + /** Worktree mode: repo has a non-empty .worktreelink file */ + uses_worktree_link?: boolean; + /** Worktree mode: repo has a non-empty .worktreeinclude file */ + uses_worktree_include?: boolean; + adapter?: "claude" | "codex"; +} + +export interface TaskViewProperties { + task_id: string; +} + +export interface TaskRunProperties { + task_id: string; + execution_type: ExecutionType; +} + +export interface RepositorySelectProperties { + repository_provider: RepositoryProvider; + source: RepositorySelectSource; +} + +export interface UserIdentifyProperties { + email?: string; + uuid?: string; + project_id?: string; + region?: string; +} +export interface TaskRunStartedProperties { + task_id: string; + execution_type: ExecutionType; + model?: string; + initial_mode?: string; + adapter?: string; +} + +export interface TaskRunCompletedProperties { + task_id: string; + execution_type: ExecutionType; + duration_seconds: number; + prompts_sent: number; + stop_reason: StopReason; +} + +export interface TaskRunCancelledProperties { + task_id: string; + execution_type: ExecutionType; + duration_seconds: number; + prompts_sent: number; +} + +export interface PromptSentProperties { + task_id: string; + is_initial: boolean; + execution_type: ExecutionType; + prompt_length_chars: number; +} + +// Git operations +export interface GitActionExecutedProperties { + action_type: GitActionType; + success: boolean; + task_id?: string; + /** Number of staged files at time of action */ + staged_file_count?: number; + /** Number of unstaged files at time of action */ + unstaged_file_count?: number; + /** Whether user chose to commit all changes (vs staged only) */ + commit_all?: boolean; + /** Whether stagedOnly mode was used for the commit */ + staged_only?: boolean; +} + +export interface PrCreatedProperties { + task_id?: string; + success: boolean; +} + +export interface AgentFileActivityProperties { + task_id: string; + branch_name: string | null; +} + +// Branch link events +type BranchLinkSource = "agent" | "user" | "unknown"; + +export interface BranchLinkedProperties { + task_id: string; + branch_name: string; + source: BranchLinkSource; +} + +export interface BranchUnlinkedProperties { + task_id: string; + source: BranchLinkSource; +} + +export interface BranchLinkDefaultBranchUnknownProperties { + task_id: string; + branch_name: string; +} + +// File interactions +export interface FileOpenedProperties { + file_extension: string; + source: FileOpenSource; + task_id?: string; +} + +export interface FileDiffViewedProperties { + file_extension: string; + change_type: FileChangeType; + task_id?: string; +} + +export interface ReviewPanelViewedProperties { + task_id: string; +} + +export interface DiffViewModeChangedProperties { + from_mode: "split" | "unified"; + to_mode: "split" | "unified"; +} + +// Workspace events +export interface WorkspaceCreatedProperties { + task_id: string; + mode: "cloud" | "worktree" | "local"; +} + +export interface WorkspaceScriptsStartedProperties { + task_id: string; + scripts_count: number; +} + +export interface FolderRegisteredProperties { + path_hash: string; +} + +// Navigation events +export interface CommandMenuActionProperties { + action_type: CommandMenuAction; +} + +export interface SkillButtonTriggeredProperties { + task_id: string; + button_id: SkillButtonId; + source: SkillButtonSource; +} + +// Settings events +export interface SettingChangedProperties { + setting_name: string; + new_value: string | boolean | number; + old_value?: string | boolean | number; +} + +// Error events +export interface TaskCreationFailedProperties { + error_type: string; + failed_step?: string; +} + +export interface AgentSessionErrorProperties { + task_id: string; + error_type: string; +} + +// Permission events +export interface PermissionRespondedProperties { + task_id: string; + tool_name?: string; + option_id?: string; + option_kind?: string; + custom_input?: string; +} + +export interface PermissionCancelledProperties { + task_id: string; + tool_name?: string; +} + +// Session config events +export interface SessionConfigChangedProperties { + task_id: string; + category: string; + from_value: string; + to_value: string; +} + +// Tour events +type TourAction = "started" | "step_advanced" | "dismissed" | "completed"; + +export interface TourEventProperties { + tour_id: string; + action: TourAction; + step_id?: string; + step_index?: number; + total_steps?: number; +} + +// Branch mismatch events +type BranchMismatchAction = "switch" | "continue" | "cancel"; + +export interface BranchMismatchWarningShownProperties { + task_id: string; + linked_branch: string; + current_branch: string; + has_uncommitted_changes: boolean; +} + +export interface BranchMismatchActionProperties { + task_id: string; + action: BranchMismatchAction; + linked_branch: string; + current_branch: string; +} + +// Deep link events +export interface DeepLinkNewTaskProperties { + has_prompt: boolean; + has_repo: boolean; + mode?: string; + model?: string; +} + +export interface DeepLinkPlanProperties { + has_repo: boolean; + mode?: string; + model?: string; + plan_length_chars: number; +} + +export interface DeepLinkIssueProperties { + owner: string; + repo: string; + issue_number: number; + mode?: string; + model?: string; +} + +export interface DeepLinkIssueFailedProperties { + owner: string; + repo: string; + issue_number: number; + reason: "not_found" | "fetch_failed"; + error_message?: string; +} + +// Feedback events +export interface TaskFeedbackProperties { + task_id: string; + task_run_id?: string; + log_url?: string; + event_count: number; + feedback_type: FeedbackType; + feedback_comment?: string; +} + +// Onboarding events +export type OnboardingStepId = + | "welcome" + | "project-select" + | "invite-code" + | "connect-github" + | "install-cli" + | "select-repo"; + +type OnboardingSkipReason = "no_repo_selected" | "dev_skip"; + +export interface OnboardingStepViewedProperties { + step_id: OnboardingStepId; + step_index: number; + total_steps: number; +} + +export interface OnboardingStepCompletedProperties { + step_id: OnboardingStepId; + step_index: number; + total_steps: number; + duration_seconds: number; + github_connected?: boolean; + git_installed?: boolean; + gh_installed?: boolean; + gh_authenticated?: boolean; +} + +export interface OnboardingStepSkippedProperties { + step_id: OnboardingStepId; + step_index: number; + reason: OnboardingSkipReason; +} + +export interface OnboardingSignInInitiatedProperties { + region: string; +} + +export interface OnboardingProjectSelectedProperties { + had_multiple_orgs: boolean; + had_multiple_projects: boolean; +} + +export interface OnboardingInviteCodeSubmittedProperties { + success: boolean; + error_type?: string; +} + +export interface OnboardingFolderSelectedProperties { + has_git_remote: boolean; + repository_provider: RepositoryProvider; +} + +export interface OnboardingCliCheckCompletedProperties { + git_installed: boolean; + gh_installed: boolean; + gh_authenticated: boolean; +} + +export interface OnboardingCompletedProperties { + duration_seconds: number; + github_connected: boolean; + repo_skipped: boolean; +} + +export type OnboardingGithubConnectFlow = + | "team_existing" + | "team_alternative" + | "user_new"; + +export interface OnboardingGithubConnectStartedProperties { + flow_type: OnboardingGithubConnectFlow; + is_retry: boolean; +} + +export interface OnboardingGithubConnectFailedProperties { + reason: "timeout" | "error"; + error_type?: string; +} + +export interface OnboardingAbandonedProperties { + last_step_id: OnboardingStepId; + duration_seconds: number; +} + +export interface AiConsentGateShownProperties { + is_org_admin: boolean; +} + +// Setup / onboarding events +type SetupDiscoveredTaskCategory = + | "bug" + | "security" + | "dead_code" + | "duplication" + | "performance" + | "stale_feature_flag" + | "error_tracking" + | "event_tracking" + | "funnel" + | "posthog_setup" + | "experiment"; + +export interface SetupDiscoveryStartedProperties { + discovery_task_id: string; + discovery_task_run_id: string; +} + +export interface SetupDiscoveryCompletedProperties { + discovery_task_id: string; + discovery_task_run_id: string; + task_count: number; + duration_seconds: number; + signal_source: "structured_output" | "terminal_status" | "missing_output"; +} + +export interface SetupDiscoveryFailedProperties { + discovery_task_id?: string; + discovery_task_run_id?: string; + reason: "failed" | "cancelled" | "timeout" | "startup_error"; + error_message?: string; +} + +export interface SetupTaskSelectedProperties { + discovered_task_id: string; + category: SetupDiscoveredTaskCategory; + position: number; + total_discovered: number; +} + +export interface SetupTaskDismissedProperties { + discovered_task_id: string; + category: SetupDiscoveredTaskCategory; + position: number; + total_discovered: number; +} + +// Inbox events +export type InboxReportOpenMethod = + | "click" + | "click_cmd" + | "click_shift" + | "keyboard" + | "deeplink" + | "unknown"; + +export type InboxReportCloseMethod = + | "next_report" + | "deselected" + | "navigated_away" + | "unmount"; + +export type InboxReportActionType = + | "dismiss" + | "snooze" + | "delete" + | "reingest" + | "create_pr" + | "open_pr" + | "copy_link" + | "discuss" + | "expand_signal" + | "collapse_signal" + | "expand_signal_section" + | "view_signal_external" + | "expand_why" + | "click_suggested_reviewer" + | "expand_task_section" + | "play_session_recording"; + +export type InboxReportActionSurface = + | "detail_pane" + | "toolbar" + | "keyboard" + | "list_row"; + +export interface InboxViewedProperties { + report_count: number; + total_count: number; + ready_count: number; + has_active_filters: boolean; + source_product_filter: string[]; + status_filter_count: number; + is_empty: boolean; + /** True when the inbox is scale-gated (GatedDueToScalePane shown, data not loaded). */ + is_gated_due_to_scale: boolean; + /** Breakdown of the visible report_count by priority (P0–P4, or "unknown"). */ + priority_p0_count: number; + priority_p1_count: number; + priority_p2_count: number; + priority_p3_count: number; + priority_p4_count: number; + priority_unknown_count: number; + /** Breakdown of the visible report_count by actionability. */ + actionability_immediately_actionable_count: number; + actionability_requires_human_input_count: number; + actionability_not_actionable_count: number; + actionability_unknown_count: number; +} + +export interface InboxReportOpenedProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + status: string | null; + priority: string | null; + actionability: string | null; + source_products: string[]; + rank: number; + list_size: number; + open_method: InboxReportOpenMethod; + previous_report_id: string | null; +} + +export interface InboxReportClosedProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + priority: string | null; + actionability: string | null; + time_spent_ms: number; + scrolled: boolean; + close_method: InboxReportCloseMethod; +} + +export interface InboxReportScrolledProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + priority: string | null; + actionability: string | null; + rank: number; + list_size: number; + time_since_open_ms: number; +} + +export interface SpendAnalysisTaskOpenedProperties { + /** Total LLM spend in USD across all products for the analysed window. */ + total_cost_usd: number; + /** PostHog Code spend in USD for the analysed window (subset of total). */ + scoped_cost_usd: number; + /** Number of `$ai_generation` events in the analysed window. */ + scoped_event_count: number; + /** Length of the analysed window in days. */ + window_days: number; + /** Number of tool rows the receiving agent will see (capped at 10 in the prompt). */ + tool_row_count: number; + /** Number of model rows the receiving agent will see. */ + model_row_count: number; +} + +export interface InboxReportActionProperties { + report_id: string; + report_title: string | null; + report_age_hours: number; + priority: string | null; + actionability: string | null; + action_type: InboxReportActionType; + surface: InboxReportActionSurface; + is_bulk: boolean; + bulk_size: number; + rank: number; + list_size: number; + dismissal_reason?: string; + dismissal_note?: string; + signal_id?: string; + signal_source_product?: string; + signal_source_type?: string; + signal_section?: "relevant_code" | "data_queried"; + why_field?: "priority" | "actionability"; + task_section?: "research" | "implementation"; + // True when the user submitted Discuss with a first question via the popover. + has_question?: boolean; + // The first question text the user typed before hitting Discuss. Truncated to + // 500 chars to keep event payloads bounded. + question_text?: string; +} + +export interface SignalSourceConnectedProperties { + source_product: + | "session_replay" + | "error_tracking" + | "github" + | "linear" + | "zendesk" + | "conversations" + | "pganalyze" + | "llm_analytics"; + /** True when this is a brand-new createSignalSourceConfig, false for re-enable of an existing config. */ + is_first_connection: boolean; + /** True when the connection went through the DataSourceSetup wizard (warehouse OAuth path). */ + via_setup_wizard: boolean; +} + +// Subscription / billing events + +export type UpgradePromptShownSurface = "usage_limit_modal" | "upgrade_dialog"; + +export type UpgradePromptClickedSurface = + | "usage_limit_modal" + | "sidebar" + | "plan_page_card" + | "upgrade_dialog"; + +export interface UpgradePromptShownProperties { + surface: UpgradePromptShownSurface; +} + +export interface UpgradePromptClickedProperties { + surface: UpgradePromptClickedSurface; +} + +export interface SubscriptionStartedProperties { + plan_key: string; + previous_plan_key?: string; +} + +export interface SubscriptionCancelledProperties { + plan_key: string; +} + +// Event names as constants +export const ANALYTICS_EVENTS = { + // App lifecycle + APP_STARTED: "App started", + APP_QUIT: "App quit", + + // Authentication + USER_LOGGED_IN: "User logged in", + USER_LOGGED_OUT: "User logged out", + + // Task management + TASK_LIST_VIEWED: "Task list viewed", + TASK_CREATED: "Task created", + TASK_VIEWED: "Task viewed", + TASK_RUN: "Task run", + TASK_RUN_STARTED: "Task run started", + TASK_RUN_COMPLETED: "Task run completed", + TASK_RUN_CANCELLED: "Task run cancelled", + PROMPT_SENT: "Prompt sent", + + // Repository + REPOSITORY_SELECTED: "Repository selected", + + // Git operations + GIT_ACTION_EXECUTED: "Git action executed", + PR_CREATED: "PR created", + AGENT_FILE_ACTIVITY: "Agent file activity", + BRANCH_LINKED: "Branch linked", + BRANCH_UNLINKED: "Branch unlinked", + BRANCH_LINK_DEFAULT_BRANCH_UNKNOWN: "Branch link default branch unknown", + + // File interactions + FILE_OPENED: "File opened", + FILE_DIFF_VIEWED: "File diff viewed", + REVIEW_PANEL_VIEWED: "Review panel viewed", + DIFF_VIEW_MODE_CHANGED: "Diff view mode changed", + + // Workspace events + WORKSPACE_CREATED: "Workspace created", + WORKSPACE_SCRIPTS_STARTED: "Workspace scripts started", + FOLDER_REGISTERED: "Folder registered", + + // Navigation events + SETTINGS_VIEWED: "Settings viewed", + COMMAND_MENU_OPENED: "Command menu opened", + COMMAND_MENU_ACTION: "Command menu action", + COMMAND_CENTER_VIEWED: "Command center viewed", + SKILL_BUTTON_TRIGGERED: "Skill button triggered", + + // Permission events + PERMISSION_RESPONDED: "Permission responded", + PERMISSION_CANCELLED: "Permission cancelled", + + // Session config events + SESSION_CONFIG_CHANGED: "Session config changed", + + // Settings events + SETTING_CHANGED: "Setting changed", + + // Feedback events + TASK_FEEDBACK: "Task feedback", + + // Branch mismatch events + BRANCH_MISMATCH_WARNING_SHOWN: "Branch mismatch warning shown", + BRANCH_MISMATCH_ACTION: "Branch mismatch action", + + // Tour events + TOUR_EVENT: "Tour event", + + // Onboarding events + ONBOARDING_STARTED: "Onboarding started", + ONBOARDING_STEP_VIEWED: "Onboarding step viewed", + ONBOARDING_STEP_COMPLETED: "Onboarding step completed", + ONBOARDING_STEP_SKIPPED: "Onboarding step skipped", + ONBOARDING_SIGN_IN_INITIATED: "Onboarding sign in initiated", + ONBOARDING_PROJECT_SELECTED: "Onboarding project selected", + ONBOARDING_INVITE_CODE_SUBMITTED: "Onboarding invite code submitted", + ONBOARDING_FOLDER_SELECTED: "Onboarding folder selected", + ONBOARDING_GITHUB_CONNECT_STARTED: "Onboarding github connect started", + ONBOARDING_GITHUB_CONNECT_FAILED: "Onboarding github connect failed", + ONBOARDING_GITHUB_CONNECTED: "Onboarding github connected", + ONBOARDING_CLI_CHECK_COMPLETED: "Onboarding cli check completed", + ONBOARDING_COMPLETED: "Onboarding completed", + ONBOARDING_ABANDONED: "Onboarding abandoned", + AI_CONSENT_GATE_SHOWN: "Ai consent gate shown", + AI_CONSENT_APPROVED: "Ai consent approved", + + // Setup / onboarding events + SETUP_DISCOVERY_STARTED: "Setup discovery started", + SETUP_DISCOVERY_COMPLETED: "Setup discovery completed", + SETUP_DISCOVERY_FAILED: "Setup discovery failed", + SETUP_TASK_SELECTED: "Setup task selected", + SETUP_TASK_DISMISSED: "Setup task dismissed", + + // Deep link events + DEEP_LINK_NEW_TASK: "Deep link new task", + DEEP_LINK_PLAN: "Deep link plan", + DEEP_LINK_ISSUE: "Deep link issue", + DEEP_LINK_ISSUE_FAILED: "Deep link issue failed", + + // Error events + TASK_CREATION_FAILED: "Task creation failed", + AGENT_SESSION_ERROR: "Agent session error", + + // Inbox events + INBOX_INTEREST_REGISTERED: "Inbox interest registered", + INBOX_VIEWED: "Inbox viewed", + INBOX_REPORT_OPENED: "Inbox report opened", + INBOX_REPORT_CLOSED: "Inbox report closed", + INBOX_REPORT_ACTION: "Inbox report action", + INBOX_REPORT_SCROLLED: "Inbox report scrolled", + SIGNAL_SOURCE_CONNECTED: "Signal source connected", + + // Spend analysis events + SPEND_ANALYSIS_TASK_OPENED: "Spend analysis task opened", + + // Prompt history events + PROMPT_HISTORY_OPENED: "Prompt history opened", + PROMPT_HISTORY_SELECTED: "Prompt history selected", + + // Subscription events + UPGRADE_PROMPT_SHOWN: "Upgrade prompt shown", + UPGRADE_PROMPT_CLICKED: "Upgrade prompt clicked", + SUBSCRIPTION_STARTED: "Subscription started", + SUBSCRIPTION_CANCELLED: "Subscription cancelled", +} as const; + +// Event property mapping +export type EventPropertyMap = { + [ANALYTICS_EVENTS.TASK_LIST_VIEWED]: TaskListViewProperties | undefined; + [ANALYTICS_EVENTS.TASK_CREATED]: TaskCreateProperties; + [ANALYTICS_EVENTS.TASK_VIEWED]: TaskViewProperties; + [ANALYTICS_EVENTS.TASK_RUN]: TaskRunProperties; + [ANALYTICS_EVENTS.REPOSITORY_SELECTED]: RepositorySelectProperties; + [ANALYTICS_EVENTS.USER_LOGGED_IN]: UserIdentifyProperties | undefined; + [ANALYTICS_EVENTS.USER_LOGGED_OUT]: never; + + // Task execution events + [ANALYTICS_EVENTS.TASK_RUN_STARTED]: TaskRunStartedProperties; + [ANALYTICS_EVENTS.TASK_RUN_COMPLETED]: TaskRunCompletedProperties; + [ANALYTICS_EVENTS.TASK_RUN_CANCELLED]: TaskRunCancelledProperties; + [ANALYTICS_EVENTS.PROMPT_SENT]: PromptSentProperties; + + // Git operations + [ANALYTICS_EVENTS.GIT_ACTION_EXECUTED]: GitActionExecutedProperties; + [ANALYTICS_EVENTS.PR_CREATED]: PrCreatedProperties; + [ANALYTICS_EVENTS.AGENT_FILE_ACTIVITY]: AgentFileActivityProperties; + [ANALYTICS_EVENTS.BRANCH_LINKED]: BranchLinkedProperties; + [ANALYTICS_EVENTS.BRANCH_UNLINKED]: BranchUnlinkedProperties; + [ANALYTICS_EVENTS.BRANCH_LINK_DEFAULT_BRANCH_UNKNOWN]: BranchLinkDefaultBranchUnknownProperties; + + // File interactions + [ANALYTICS_EVENTS.FILE_OPENED]: FileOpenedProperties; + [ANALYTICS_EVENTS.FILE_DIFF_VIEWED]: FileDiffViewedProperties; + [ANALYTICS_EVENTS.REVIEW_PANEL_VIEWED]: ReviewPanelViewedProperties; + [ANALYTICS_EVENTS.DIFF_VIEW_MODE_CHANGED]: DiffViewModeChangedProperties; + + // Workspace events + [ANALYTICS_EVENTS.WORKSPACE_CREATED]: WorkspaceCreatedProperties; + [ANALYTICS_EVENTS.WORKSPACE_SCRIPTS_STARTED]: WorkspaceScriptsStartedProperties; + [ANALYTICS_EVENTS.FOLDER_REGISTERED]: FolderRegisteredProperties; + + // Navigation events + [ANALYTICS_EVENTS.SETTINGS_VIEWED]: never; + [ANALYTICS_EVENTS.COMMAND_MENU_OPENED]: never; + [ANALYTICS_EVENTS.COMMAND_MENU_ACTION]: CommandMenuActionProperties; + [ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED]: never; + [ANALYTICS_EVENTS.SKILL_BUTTON_TRIGGERED]: SkillButtonTriggeredProperties; + + // Permission events + [ANALYTICS_EVENTS.PERMISSION_RESPONDED]: PermissionRespondedProperties; + [ANALYTICS_EVENTS.PERMISSION_CANCELLED]: PermissionCancelledProperties; + + // Session config events + [ANALYTICS_EVENTS.SESSION_CONFIG_CHANGED]: SessionConfigChangedProperties; + + // Settings events + [ANALYTICS_EVENTS.SETTING_CHANGED]: SettingChangedProperties; + + // Feedback events + [ANALYTICS_EVENTS.TASK_FEEDBACK]: TaskFeedbackProperties; + + // Branch mismatch events + [ANALYTICS_EVENTS.BRANCH_MISMATCH_WARNING_SHOWN]: BranchMismatchWarningShownProperties; + [ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION]: BranchMismatchActionProperties; + + // Tour events + [ANALYTICS_EVENTS.TOUR_EVENT]: TourEventProperties; + + // Onboarding events + [ANALYTICS_EVENTS.ONBOARDING_STARTED]: never; + [ANALYTICS_EVENTS.ONBOARDING_STEP_VIEWED]: OnboardingStepViewedProperties; + [ANALYTICS_EVENTS.ONBOARDING_STEP_COMPLETED]: OnboardingStepCompletedProperties; + [ANALYTICS_EVENTS.ONBOARDING_STEP_SKIPPED]: OnboardingStepSkippedProperties; + [ANALYTICS_EVENTS.ONBOARDING_SIGN_IN_INITIATED]: OnboardingSignInInitiatedProperties; + [ANALYTICS_EVENTS.ONBOARDING_PROJECT_SELECTED]: OnboardingProjectSelectedProperties; + [ANALYTICS_EVENTS.ONBOARDING_INVITE_CODE_SUBMITTED]: OnboardingInviteCodeSubmittedProperties; + [ANALYTICS_EVENTS.ONBOARDING_FOLDER_SELECTED]: OnboardingFolderSelectedProperties; + [ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECT_STARTED]: OnboardingGithubConnectStartedProperties; + [ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECT_FAILED]: OnboardingGithubConnectFailedProperties; + [ANALYTICS_EVENTS.ONBOARDING_GITHUB_CONNECTED]: never; + [ANALYTICS_EVENTS.ONBOARDING_CLI_CHECK_COMPLETED]: OnboardingCliCheckCompletedProperties; + [ANALYTICS_EVENTS.ONBOARDING_COMPLETED]: OnboardingCompletedProperties; + [ANALYTICS_EVENTS.ONBOARDING_ABANDONED]: OnboardingAbandonedProperties; + [ANALYTICS_EVENTS.AI_CONSENT_GATE_SHOWN]: AiConsentGateShownProperties; + [ANALYTICS_EVENTS.AI_CONSENT_APPROVED]: never; + + // Setup / onboarding events + [ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED]: SetupDiscoveryStartedProperties; + [ANALYTICS_EVENTS.SETUP_DISCOVERY_COMPLETED]: SetupDiscoveryCompletedProperties; + [ANALYTICS_EVENTS.SETUP_DISCOVERY_FAILED]: SetupDiscoveryFailedProperties; + [ANALYTICS_EVENTS.SETUP_TASK_SELECTED]: SetupTaskSelectedProperties; + [ANALYTICS_EVENTS.SETUP_TASK_DISMISSED]: SetupTaskDismissedProperties; + + // Deep link events + [ANALYTICS_EVENTS.DEEP_LINK_NEW_TASK]: DeepLinkNewTaskProperties; + [ANALYTICS_EVENTS.DEEP_LINK_PLAN]: DeepLinkPlanProperties; + [ANALYTICS_EVENTS.DEEP_LINK_ISSUE]: DeepLinkIssueProperties; + [ANALYTICS_EVENTS.DEEP_LINK_ISSUE_FAILED]: DeepLinkIssueFailedProperties; + + // Error events + [ANALYTICS_EVENTS.TASK_CREATION_FAILED]: TaskCreationFailedProperties; + [ANALYTICS_EVENTS.AGENT_SESSION_ERROR]: AgentSessionErrorProperties; + + // Inbox events + [ANALYTICS_EVENTS.INBOX_INTEREST_REGISTERED]: never; + [ANALYTICS_EVENTS.INBOX_VIEWED]: InboxViewedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_OPENED]: InboxReportOpenedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_CLOSED]: InboxReportClosedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_ACTION]: InboxReportActionProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED]: InboxReportScrolledProperties; + [ANALYTICS_EVENTS.SIGNAL_SOURCE_CONNECTED]: SignalSourceConnectedProperties; + + // Spend analysis events + [ANALYTICS_EVENTS.SPEND_ANALYSIS_TASK_OPENED]: SpendAnalysisTaskOpenedProperties; + + // Prompt history events + [ANALYTICS_EVENTS.PROMPT_HISTORY_OPENED]: PromptHistoryOpenedProperties; + [ANALYTICS_EVENTS.PROMPT_HISTORY_SELECTED]: PromptHistorySelectedProperties; + + // Subscription events + [ANALYTICS_EVENTS.UPGRADE_PROMPT_SHOWN]: UpgradePromptShownProperties; + [ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED]: UpgradePromptClickedProperties; + [ANALYTICS_EVENTS.SUBSCRIPTION_STARTED]: SubscriptionStartedProperties; + [ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED]: SubscriptionCancelledProperties; +}; diff --git a/packages/shared/src/archive-domain.ts b/packages/shared/src/archive-domain.ts new file mode 100644 index 0000000000..dd97947839 --- /dev/null +++ b/packages/shared/src/archive-domain.ts @@ -0,0 +1,17 @@ +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(), + 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>; 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/packages/shared/src/deep-links.test.ts b/packages/shared/src/deep-links.test.ts new file mode 100644 index 0000000000..ce26233329 --- /dev/null +++ b/packages/shared/src/deep-links.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +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", () => { + expect(buildInboxDeeplink("abc-123", null, { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + expect( + buildInboxDeeplink("abc-123", undefined, { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123"); + expect(buildInboxDeeplink("abc-123", "", { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + }); + + it("emits `--` for runs that mix a colon with other unsafe chars", () => { + expect( + buildInboxDeeplink("abc-123", "fix(inbox): Add foo", { + isDevBuild: false, + }), + ).toBe("posthog-code://inbox/abc-123/fix-inbox--Add-foo"); + }); + + it("emits a single `-` for a colon-only run", () => { + expect( + buildInboxDeeplink("abc-123", "feat:bar", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/feat-bar"); + }); + + it("omits the slug when the title slugifies to empty", () => { + expect(buildInboxDeeplink("abc-123", ":::", { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + expect(buildInboxDeeplink("abc-123", " ", { isDevBuild: false })).toBe( + "posthog-code://inbox/abc-123", + ); + }); + + it("uses the dev scheme when isDevBuild is true", () => { + expect( + buildInboxDeeplink("abc-123", "Hello World", { isDevBuild: true }), + ).toBe("posthog-code-dev://inbox/abc-123/Hello-World"); + }); + + it("preserves URL-unreserved punctuation (- _ . ~)", () => { + expect( + buildInboxDeeplink("abc-123", "v1.2.3_final~ish", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/v1.2.3_final~ish"); + }); + + it("collapses runs of unsafe punctuation into a single hyphen", () => { + expect( + buildInboxDeeplink("abc-123", "Cost $5, 50% off!", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/Cost-5-50-off"); + }); + + it("folds accented Latin letters to their ASCII base", () => { + expect( + buildInboxDeeplink("abc-123", "café résumé naïve", { isDevBuild: false }), + ).toBe("posthog-code://inbox/abc-123/cafe-resume-naive"); + }); + + it("hyphenizes non-Latin scripts that have no ASCII fold", () => { + expect( + buildInboxDeeplink("abc-123", "Hello Привет world", { + isDevBuild: false, + }), + ).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/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts new file mode 100644 index 0000000000..d2654516b7 --- /dev/null +++ b/packages/shared/src/domain-types.ts @@ -0,0 +1,556 @@ +import { z } from "zod"; +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([ + "default", + "acceptEdits", + "plan", + "bypassPermissions", + "auto", + "read-only", + "full-access", +]); +import type { ExecutionMode } from "./exec-types"; +export type { ExecutionMode }; + +// Effort level schema and type - shared between main and renderer +export const effortLevelSchema = z.enum([ + "low", + "medium", + "high", + "xhigh", + "max", +]); +export type EffortLevel = z.infer<typeof effortLevelSchema>; + +interface UserBasic { + id: number; + uuid: string; + distinct_id?: string | null; + first_name?: string; + last_name?: string; + email: string; + is_email_verified?: boolean | null; +} + +export interface Task { + id: string; + task_number: number | null; + slug: string; + title: string; + title_manually_set?: boolean; + description: string; + created_at: string; + updated_at: string; + created_by?: UserBasic | null; + origin_product: string; + repository?: string | null; // Format: "organization/repository" (e.g., "posthog/posthog-js") + github_integration?: number | null; + github_user_integration?: string | null; + json_schema?: Record<string, unknown> | null; + signal_report?: string | null; + internal?: boolean; + latest_run?: TaskRun; +} + +export type TaskRunStatus = + | "not_started" + | "queued" + | "in_progress" + | "completed" + | "failed" + | "cancelled"; + +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]) + ); +} + +export interface TaskRun { + id: string; + task: string; // Task ID + team: number; + branch: string | null; + runtime_adapter?: "claude" | "codex" | null; + model?: string | null; + reasoning_effort?: "low" | "medium" | "high" | "xhigh" | "max" | null; + stage?: string | null; // Current stage (e.g., 'research', 'plan', 'build') + environment?: "local" | "cloud"; + 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) + created_at: string; + updated_at: string; + completed_at: string | null; +} + +export type NetworkAccessLevel = "trusted" | "full" | "custom"; + +export interface SandboxEnvironment { + id: string; + name: string; + network_access_level: NetworkAccessLevel; + allowed_domains: string[]; + include_default_domains: boolean; + repositories: string[]; + has_environment_variables: boolean; + private: boolean; + effective_domains: string[]; + created_by?: UserBasic | null; + created_at: string; + updated_at: string; +} + +export interface SandboxEnvironmentInput { + name: string; + network_access_level: NetworkAccessLevel; + allowed_domains?: string[]; + include_default_domains?: boolean; + repositories?: string[]; + environment_variables?: Record<string, string>; + private?: boolean; +} + +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<string, unknown> | 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<string, unknown> | 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<string, unknown>; +} + +export interface CloudTaskPermissionRequestUpdate extends CloudTaskUpdateBase { + kind: "permission_request"; + requestId: string; + toolCall: { + toolCallId: string; + title: string; + kind: string; + content?: unknown[]; + rawInput?: Record<string, unknown>; + _meta?: Record<string, unknown>; + }; + options: CloudPermissionOption[]; +} + +export type CloudTaskUpdatePayload = + | CloudTaskLogsUpdate + | CloudTaskStatusUpdate + | CloudTaskSnapshotUpdate + | CloudTaskErrorUpdate + | CloudTaskPermissionRequestUpdate; + +// Mention types for editors +type MentionType = + | "file" + | "folder" + | "error" + | "experiment" + | "insight" + | "feature_flag" + | "generic"; + +export interface MentionItem { + // File items + path?: string; + name?: string; + kind?: "file" | "directory"; + // URL items + url?: string; + type?: MentionType; + label?: string; + id?: string; + urlId?: string; +} + +// Git file status types +import type { GitFileStatus } from "./git-types"; +export type { GitFileStatus }; + +export type GitBusyOperation = "rebase" | "merge" | "cherry-pick" | "revert"; + +export type GitBusyState = + | { busy: false } + | { busy: true; operation: GitBusyOperation }; + +export interface ChangedFile { + path: string; + status: GitFileStatus; + originalPath?: string; // For renames: the old path + linesAdded?: number; + linesRemoved?: number; + staged?: boolean; + patch?: string; // Unified diff patch from GitHub API +} + +// External apps detection types +export type ExternalAppType = + | "editor" + | "terminal" + | "file-manager" + | "git-client"; + +export interface DetectedApplication { + id: string; // "vscode", "cursor", "iterm" + name: string; // "Visual Studio Code" + type: ExternalAppType; + path: string; // "/Applications/Visual Studio Code.app" + command: string; // Launch command + icon?: string; // Base64 data URL +} + +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"; + +/** Actionability choice from the researched report. */ +export type SignalReportActionability = + | "immediately_actionable" + | "requires_human_input" + | "not_actionable"; + +/** + * One or more `SignalReportStatus` values joined by commas, e.g. `potential` or `potential,candidate,ready`. + * This looks horrendous but it's superb, trust me bro. + */ +export type CommaSeparatedSignalReportStatuses = + | SignalReportStatus + | `${SignalReportStatus},${SignalReportStatus}` + | `${SignalReportStatus},${SignalReportStatus},${SignalReportStatus}` + | `${SignalReportStatus},${SignalReportStatus},${SignalReportStatus},${SignalReportStatus}` + | `${SignalReportStatus},${SignalReportStatus},${SignalReportStatus},${SignalReportStatus},${SignalReportStatus}`; + +export interface SignalReport { + id: string; + title: string | null; + summary: string | null; + status: SignalReportStatus; + total_weight: number; + signal_count: number; + signals_at_run?: number; + created_at: string; + updated_at: string; + artefact_count: number; + /** P0–P4 from priority judgment when the report is researched */ + priority?: SignalReportPriority | null; + /** Actionability choice from the actionability judgment artefact. */ + actionability?: SignalReportActionability | null; + /** Whether the issue appears already fixed, from the actionability judgment artefact. */ + already_addressed?: boolean | null; + /** Whether the current user is a suggested reviewer for this report (server-annotated). */ + is_suggested_reviewer?: boolean; + /** Distinct source products contributing signals to this report. */ + source_products?: string[]; + /** PR URL from the latest implementation task run, if available. */ + implementation_pr_url?: string | null; +} + +export interface SignalReportArtefactContent { + session_id: string; + start_time: string; + end_time: string; + distinct_id: string; + content: string; + distance_to_centroid: number | null; +} + +export interface SignalReportArtefact { + id: string; + type: string; + content: SignalReportArtefactContent; + created_at: string; +} + +/** Artefact with `type: "priority_judgment"` — priority assessment from the agentic report. */ +export interface PriorityJudgmentArtefact { + id: string; + type: "priority_judgment"; + content: PriorityJudgmentContent; + created_at: string; +} + +export interface PriorityJudgmentContent { + explanation: string; + priority: SignalReportPriority; +} + +/** Artefact with `type: "actionability_judgment"` — actionability assessment from the agentic report. */ +export interface ActionabilityJudgmentArtefact { + id: string; + type: "actionability_judgment"; + content: ActionabilityJudgmentContent; + created_at: string; +} + +export interface ActionabilityJudgmentContent { + explanation: string; + actionability: SignalReportActionability; + already_addressed: boolean; +} + +/** Artefact with `type: "signal_finding"` — per-signal research finding from the agentic report. */ +export interface SignalFindingArtefact { + id: string; + type: "signal_finding"; + content: SignalFindingContent; + created_at: string; +} + +export interface SignalFindingContent { + signal_id: string; + relevant_code_paths: string[]; + relevant_commit_hashes: Record<string, string>; + data_queried: string; + verified: boolean; +} + +/** Artefact with `type: "suggested_reviewers"` — content is an enriched reviewer list. */ +export interface SuggestedReviewersArtefact { + id: string; + type: "suggested_reviewers"; + content: SuggestedReviewer[]; + created_at: string; +} + +/** Artefact with `type: "dismissal"` — captures the user's rationale when suppressing a report. */ +export interface DismissalArtefact { + id: string; + type: "dismissal"; + content: DismissalContent; + created_at: string; +} + +export interface DismissalContent { + reason: DismissalReasonOptionValue; + /** Optional free-form detail provided alongside the reason. */ + note: string; + /** PostHog numeric user id of the dismisser, when available. */ + user_id: number | null; + /** PostHog UUID of the dismisser, when available. */ + user_uuid: string | null; +} + +export interface SuggestedReviewerCommit { + sha: string; + url: string; + reason: string; +} + +export interface SuggestedReviewerUser { + id: number; + uuid: string; + email: string; + first_name: string; + last_name: string; +} + +import type { AvailableSuggestedReviewer } from "./inbox-types"; +export type { AvailableSuggestedReviewer }; + +export interface SuggestedReviewer { + github_login: string; + github_name: string | null; + relevant_commits: SuggestedReviewerCommit[]; + user: SuggestedReviewerUser | null; +} + +interface MatchedSignalMetadata { + parent_signal_id: string; + match_query: string; + reason: string; +} + +interface NoMatchSignalMetadata { + reason: string; + rejected_signal_ids: string[]; +} + +export type SignalMatchMetadata = MatchedSignalMetadata | NoMatchSignalMetadata; + +export interface Signal { + signal_id: string; + content: string; + source_product: string; + source_type: string; + source_id: string; + weight: number; + timestamp: string; + extra: Record<string, unknown>; + match_metadata?: SignalMatchMetadata | null; +} + +export interface SignalReportsResponse { + results: SignalReport[]; + count: number; +} + +export interface SignalProcessingStateResponse { + paused_until: string | null; +} + +export interface AvailableSuggestedReviewersResponse { + results: AvailableSuggestedReviewer[]; + count: number; +} + +export interface SignalReportSignalsResponse { + report: SignalReport | null; + signals: Signal[]; +} + +export interface SignalReportArtefactsResponse { + results: ( + | SignalReportArtefact + | PriorityJudgmentArtefact + | ActionabilityJudgmentArtefact + | SignalFindingArtefact + | SuggestedReviewersArtefact + | DismissalArtefact + )[]; + count: number; + unavailableReason?: + | "forbidden" + | "not_found" + | "invalid_payload" + | "request_failed"; +} + +import type { SignalReportOrderingField } from "./signal-types"; +export type { SignalReportOrderingField }; + +export interface SignalReportsQueryParams { + limit?: number; + offset?: number; + status?: CommaSeparatedSignalReportStatuses | string; + /** + * Comma-separated sort keys (prefix `-` for descending). `status` is semantic stage + * rank (not lexicographic `status` column order). Also: `signal_count`, `total_weight`, + * `created_at`, `updated_at`, `id`. Example: `status,-total_weight`. + */ + ordering?: string; + /** Comma-separated source products — only returns reports with signals from these sources. */ + source_product?: string; + /** Comma-separated PostHog user UUIDs — only returns reports with these suggested reviewers. */ + suggested_reviewers?: string; +} + +/** Values match `SignalReportTask.Relationship` on the PostHog API. */ +export const SIGNAL_REPORT_TASK_RELATIONSHIPS = [ + "repo_selection", + "research", + "implementation", +] as const; + +export type SignalReportTaskRelationship = + (typeof SIGNAL_REPORT_TASK_RELATIONSHIPS)[number]; + +/** Inbox / cloud PR tasks must use this when creating the `SignalReportTask` link. */ +export const SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP: SignalReportTaskRelationship = + "implementation"; + +export interface SignalReportTask { + id: string; + relationship: SignalReportTaskRelationship; + task_id: string; + created_at: string; +} + +export interface SignalTeamConfig { + id: string; + default_autostart_priority: SignalReportPriority; + created_at: string; + updated_at: string; +} + +export interface SignalUserAutonomyConfig { + id?: string; + autostart_priority: SignalReportPriority | null; + /** ID of the team-scoped Slack `Integration` row used to deliver inbox-item notifications. */ + slack_notification_integration_id?: number | null; + /** `channel_id|#channel-name` target — same convention used by Insight Alerts. */ + slack_notification_channel?: string | null; + /** Minimum priority that triggers a notification (P0 highest). `null` = every priority. */ + slack_notification_min_priority?: SignalReportPriority | null; + created_at?: string; + updated_at?: string; +} + +export interface SlackChannelOption { + id: string; + name: string; + is_private: boolean; + is_member: boolean; + is_ext_shared: boolean; + is_private_without_access: boolean; +} + +export interface SlackChannelsResponse { + channels: SlackChannelOption[]; + lastRefreshedAt?: string; + has_more?: boolean; +} + +export interface SlackChannelsQueryParams { + search?: string; + limit?: number; + offset?: number; + channelId?: string; +} + +// PORT NOTE: moved to @posthog/shared (deep-links slice); re-exported here so +// existing @shared/types importers keep working. Migrate them to import from +// @posthog/shared, then drop this re-export. +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/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/packages/shared/src/oauth.ts b/packages/shared/src/oauth.ts new file mode 100644 index 0000000000..447a002cf8 --- /dev/null +++ b/packages/shared/src/oauth.ts @@ -0,0 +1,25 @@ +import type { CloudRegion } from "./regions"; + +export const POSTHOG_US_CLIENT_ID = "HCWoE0aRFMYxIxFNTTwkOORn5LBjOt2GVDzwSw5W"; +export const POSTHOG_EU_CLIENT_ID = "AIvijgMS0dxKEmr5z6odvRd8Pkh5vts3nPTzgzU9"; +export const POSTHOG_DEV_CLIENT_ID = "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ"; + +// Bump OAUTH_SCOPE_VERSION below whenever OAUTH_SCOPES changes to force re-authentication +export const OAUTH_SCOPES = ["*"]; + +export const OAUTH_SCOPE_VERSION = 4; + +// Token refresh settings +export const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000; // 30 minutes before expiry +export const TOKEN_REFRESH_FORCE_MS = 60 * 1000; // Force refresh when <1 min to expiry, even with active sessions + +export function getOauthClientIdFromRegion(region: CloudRegion): string { + switch (region) { + case "us": + return POSTHOG_US_CLIENT_ID; + case "eu": + return POSTHOG_EU_CLIENT_ID; + case "dev": + return POSTHOG_DEV_CLIENT_ID; + } +} 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..b88736ef21 --- /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: void; +} + +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/packages/shared/src/urls.ts b/packages/shared/src/urls.ts new file mode 100644 index 0000000000..f41f6e58be --- /dev/null +++ b/packages/shared/src/urls.ts @@ -0,0 +1,12 @@ +import type { CloudRegion } from "./regions"; + +export function getCloudUrlFromRegion(region: CloudRegion): string { + switch (region) { + case "us": + return "https://us.posthog.com"; + case "eu": + return "https://eu.posthog.com"; + case "dev": + return "http://localhost:8010"; + } +} 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..0200726713 100644 --- a/packages/shared/tsup.config.ts +++ b/packages/shared/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/analytics-events.ts", "src/domain-types.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..b2b93368ee 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,7 +1,7 @@ { "name": "@posthog/ui", "version": "1.0.0", - "description": "React UI layer. Components, stores, hooks. Pure rendering and UI state — no I/O, no business logic. Built on @posthog/quill. Consumed by every host app (desktop, web, mobile-web).", + "description": "React UI layer. Components, stores, hooks. Pure rendering and UI state \u2014 no I/O, no business logic. Built on @posthog/quill. Consumed by every host app (desktop, web, mobile-web).", "private": true, "type": "module", "exports": { @@ -12,15 +12,78 @@ }, "scripts": { "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "node ../../scripts/rimraf.mjs dist .turbo" }, "dependencies": { + "@agentclientprotocol/sdk": "0.22.1", + "@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", + "@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-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", + "@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", + "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", + "react-hotkeys-hook": "^4.4.4", + "react-resizable-panels": "^3.0.6", + "reflect-metadata": "catalog:", + "semver": "^7.6.0", + "sonner": "^2.0.7", + "tippy.js": "^6.3.7", + "virtua": "^0.48.6", + "vscode-icons-js": "^11.6.1", + "zustand": "^4.5.0" }, "peerDependencies": { "@phosphor-icons/react": "catalog:", @@ -36,11 +99,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/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..4826cb5a58 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 { + type ShellClient, + SHELL_CLIENT, +} 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..de809cbdf0 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 { ArchivedTasksViewPresentation, type ArchivedTaskWithDetails, -} from "./ArchivedTasksView"; +} from "@posthog/ui/features/archive/ArchivedTasksView"; +import { Box } from "@radix-ui/themes"; +import type { Task } from "@posthog/shared/domain-types"; +import type { ArchivedTask } from "@posthog/shared/archive-domain"; +import type { Meta, StoryObj } from "@storybook/react-vite"; function createArchivedTask(id: string, daysAgo: number): ArchivedTask { return { diff --git a/packages/ui/src/features/archive/ArchivedTasksView.tsx b/packages/ui/src/features/archive/ArchivedTasksView.tsx new file mode 100644 index 0000000000..51b8ebfb08 --- /dev/null +++ b/packages/ui/src/features/archive/ArchivedTasksView.tsx @@ -0,0 +1,542 @@ +import { + CaretDown, + CaretUp, + Check, + Cloud as CloudIcon, + GitBranch as GitBranchIcon, + 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, + Button, + Dialog, + Flex, + Popover, + Table, + Text, + TextField, +} from "@radix-ui/themes"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +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; + +function ModeIcon({ mode }: { mode: WorkspaceMode }) { + if (mode === "cloud") { + return ( + <Tooltip content="Cloud"> + <span className="flex items-center justify-center"> + <CloudIcon size={ICON_SIZE} className="text-gray-10" /> + </span> + </Tooltip> + ); + } + if (mode === "worktree") { + return ( + <Tooltip content="Worktree"> + <span className="flex items-center justify-center"> + <GitBranchIcon size={ICON_SIZE} className="text-gray-10" /> + </span> + </Tooltip> + ); + } + return ( + <Tooltip content="Local"> + <span className="flex items-center justify-center"> + <LaptopIcon size={ICON_SIZE} className="text-gray-10" /> + </span> + </Tooltip> + ); +} + +function SortableColumnHeader({ + column, + label, + sort, + onSort, + width, +}: { + column: SortColumn; + label: string; + sort: SortState; + onSort: (column: SortColumn) => void; + width?: string; +}) { + const isActive = sort.column === column; + return ( + <Table.ColumnHeaderCell + className="font-normal text-[13px] text-gray-11" + style={width ? { width } : undefined} + > + <button + type="button" + className="inline-flex items-center gap-0.5 text-gray-11 transition-colors hover:text-gray-12" + onClick={() => onSort(column)} + > + {label} + {isActive && + (sort.direction === "asc" ? ( + <CaretUp size={10} weight="fill" /> + ) : ( + <CaretDown size={10} weight="fill" /> + ))} + </button> + </Table.ColumnHeaderCell> + ); +} + +const filterItemClassName = + "flex w-full items-center justify-between rounded-sm px-1.5 py-1 text-left text-[13px] text-gray-12 transition-colors hover:bg-gray-3"; + +function RepositoryFilterHeader({ + repos, + selectedRepo, + onSelect, +}: { + repos: string[]; + selectedRepo: string | null; + onSelect: (repo: string | null) => void; +}) { + return ( + <Table.ColumnHeaderCell className="w-[20%] font-normal text-[13px] text-gray-11"> + <Popover.Root> + <Popover.Trigger> + <button + type="button" + className="inline-flex items-center gap-1 text-gray-11 transition-colors hover:text-gray-12" + > + Repository + <CaretDown size={10} /> + {selectedRepo !== null && ( + <span className="inline-block h-1.5 w-1.5 rounded-full bg-accent-9" /> + )} + </button> + </Popover.Trigger> + <Popover.Content + align="start" + side="bottom" + sideOffset={4} + className="min-w-[180px] p-[6px]" + > + <Flex direction="column" gap="0"> + <button + type="button" + className={filterItemClassName} + onClick={() => onSelect(null)} + > + <span>All repositories</span> + {selectedRepo === null && ( + <Check size={12} className="text-gray-12" /> + )} + </button> + {repos.map((repo) => ( + <button + key={repo} + type="button" + className={filterItemClassName} + onClick={() => onSelect(repo)} + > + <span className="max-w-[200px] truncate">{repo}</span> + {selectedRepo === repo && ( + <Check size={12} className="text-gray-12" /> + )} + </button> + ))} + </Flex> + </Popover.Content> + </Popover.Root> + </Table.ColumnHeaderCell> + ); +} + +interface BranchNotFoundPrompt { + taskId: string; + branchName: string; +} + +export type { ArchivedTaskWithDetails }; + +export interface ArchivedTasksViewPresentationProps { + items: ArchivedTaskWithDetails[]; + isLoading: boolean; + branchNotFound: BranchNotFoundPrompt | null; + onUnarchive: (taskId: string) => void; + onDelete: (taskId: string) => void; + onContextMenu: (item: ArchivedTaskWithDetails, e: React.MouseEvent) => void; + onBranchNotFoundClose: () => void; + onRecreateBranch: () => void; +} + +export function ArchivedTasksViewPresentation({ + items, + isLoading, + branchNotFound, + onUnarchive, + onDelete, + onContextMenu, + onBranchNotFoundClose, + onRecreateBranch, +}: ArchivedTasksViewPresentationProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [sort, setSort] = useState<SortState>({ + column: "archived", + direction: "desc", + }); + const [repoFilter, setRepoFilter] = useState<string | null>(null); + const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); + + const handleSort = (column: SortColumn) => { + setSort((prev) => + prev.column === column + ? { column, direction: prev.direction === "asc" ? "desc" : "asc" } + : { column, direction: "desc" }, + ); + }; + + const itemsWithRepo = useMemo(() => withRepoNames(items), [items]); + + const uniqueRepos = useMemo( + () => deriveUniqueRepos(itemsWithRepo), + [itemsWithRepo], + ); + + const filteredItems = useMemo( + () => + filterAndSortArchivedTasks(itemsWithRepo, { + searchQuery, + repoFilter, + sort, + }), + [itemsWithRepo, searchQuery, repoFilter, sort], + ); + + return ( + <Flex direction="column" height="100%"> + <Box + className="flex-1 overflow-y-auto" + style={{ scrollbarGutter: "stable" }} + > + <Box px="3" pt="3" pb="2"> + <TextField.Root + size="2" + placeholder="Search archived tasks..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="text-[13px]" + > + <TextField.Slot> + <MagnifyingGlass size={14} /> + </TextField.Slot> + </TextField.Root> + </Box> + + {isLoading ? ( + <Flex align="center" justify="center" gap="2" py="8"> + <DotsCircleSpinner size={16} className="text-gray-10" /> + <Text className="text-[13px] text-gray-10"> + Loading archived tasks... + </Text> + </Flex> + ) : filteredItems.length === 0 ? ( + <Flex align="center" justify="center" py="8"> + <Text className="text-[13px] text-gray-10"> + {items.length === 0 ? "No archived tasks" : "No matching tasks"} + </Text> + </Flex> + ) : ( + <Table.Root + size="1" + className="[&_td]:!py-1.5 [&_th]:!py-1.5 [&_table]:w-full [&_table]:table-fixed [&_tbody_tr:hover]:bg-gray-4 [&_td]:overflow-hidden [&_td]:align-middle [&_th]:align-middle" + > + <Table.Header> + <Table.Row> + <Table.ColumnHeaderCell className="w-[40%] font-normal text-[13px] text-gray-11"> + Title + </Table.ColumnHeaderCell> + <SortableColumnHeader + column="created" + label="Created" + sort={sort} + onSort={handleSort} + width="15%" + /> + <SortableColumnHeader + column="archived" + label="Archived" + sort={sort} + onSort={handleSort} + width="15%" + /> + <RepositoryFilterHeader + repos={uniqueRepos} + selectedRepo={repoFilter} + onSelect={setRepoFilter} + /> + <Table.ColumnHeaderCell className="w-[160px]" /> + </Table.Row> + </Table.Header> + <Table.Body> + {filteredItems.map((item) => ( + <Table.Row + key={item.archived.taskId} + onContextMenu={(e) => onContextMenu(item, e)} + className="group" + > + <Table.Cell> + <Flex align="center" gap="2"> + <ModeIcon mode={item.archived.mode} /> + <Text className="block truncate text-[13px]"> + {item.task?.title ?? "Unknown task"} + </Text> + </Flex> + </Table.Cell> + <Table.Cell> + <Text className="block whitespace-nowrap text-[13px] text-gray-11"> + {formatRelativeDate(item.task?.created_at)} + </Text> + </Table.Cell> + <Table.Cell> + <Text className="block whitespace-nowrap text-[13px] text-gray-11"> + {formatRelativeDate(item.archived.archivedAt)} + </Text> + </Table.Cell> + <Table.Cell> + <Text className="block truncate text-[13px] text-gray-11"> + {item.repoName} + </Text> + </Table.Cell> + <Table.Cell className="overflow-visible"> + <Flex gap="2" className="invisible group-hover:visible"> + <Button + variant="outline" + color="gray" + size="1" + onClick={() => onUnarchive(item.archived.taskId)} + > + Unarchive + </Button> + <Button + variant="outline" + color="red" + size="1" + onClick={() => setDeleteTargetId(item.archived.taskId)} + > + Delete + </Button> + </Flex> + </Table.Cell> + </Table.Row> + ))} + </Table.Body> + </Table.Root> + )} + </Box> + + <Dialog.Root + open={branchNotFound !== null} + onOpenChange={(open) => { + if (!open) onBranchNotFoundClose(); + }} + > + <Dialog.Content maxWidth="420px" size="1"> + <Dialog.Title className="text-sm"> + Unarchive to new branch? + </Dialog.Title> + <Dialog.Description className="text-[13px]"> + <Text color="gray" className="text-[13px]"> + This workspace was last on{" "} + <Text className="font-medium text-[13px]"> + {branchNotFound?.branchName} + </Text> + , but that branch has been deleted or renamed. + </Text> + </Dialog.Description> + <Flex justify="end" gap="3" mt="3"> + <Dialog.Close> + <Button variant="soft" color="gray" size="1"> + Cancel + </Button> + </Dialog.Close> + <Button size="1" onClick={onRecreateBranch}> + Unarchive to new branch + </Button> + </Flex> + </Dialog.Content> + </Dialog.Root> + + <AlertDialog.Root + open={deleteTargetId !== null} + onOpenChange={(open) => { + if (!open) setDeleteTargetId(null); + }} + > + <AlertDialog.Content maxWidth="420px" size="1"> + <AlertDialog.Title className="text-sm"> + Delete archived task + </AlertDialog.Title> + <AlertDialog.Description className="text-[13px]"> + <Text color="gray" className="text-[13px]"> + Permanently delete{" "} + <Text className="font-medium text-[13px]"> + {items.find((i) => i.archived.taskId === deleteTargetId)?.task + ?.title ?? "Unknown task"} + </Text> + ? This cannot be undone. + </Text> + </AlertDialog.Description> + <Flex justify="end" gap="3" mt="3"> + <AlertDialog.Cancel> + <Button variant="soft" color="gray" size="1"> + Cancel + </Button> + </AlertDialog.Cancel> + <AlertDialog.Action> + <Button + variant="solid" + color="red" + size="1" + onClick={() => { + if (deleteTargetId) onDelete(deleteTargetId); + setDeleteTargetId(null); + }} + > + Delete + </Button> + </AlertDialog.Action> + </Flex> + </AlertDialog.Content> + </AlertDialog.Root> + </Flex> + ); +} + +export function ArchivedTasksView() { + const trpc = useHostTRPC(); + const { data: archivedTasks = [], isLoading: isLoadingArchived } = useQuery( + trpc.archive.list.queryOptions(), + ); + const { data: tasks = [], isLoading: isLoadingTasks } = useTasks(); + const { restore, remove, runContextMenuAction } = useUnarchiveTask(); + + useSetHeaderContent( + <Text className="font-medium text-[13px]">Archived tasks</Text>, + ); + + const [branchNotFound, setBranchNotFound] = + useState<BranchNotFoundPrompt | null>(null); + + const items = useMemo( + () => mergeArchivedWithTasks(archivedTasks, tasks), + [archivedTasks, tasks], + ); + + const isLoading = isLoadingArchived || isLoadingTasks; + + 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 + ? { + label: "View task", + onClick: () => useNavigationStore.getState().navigateToTask(task), + } + : undefined, + }); + } else if (outcome.kind === "branch-not-found") { + setBranchNotFound({ taskId, branchName: outcome.branchName }); + } else { + toast.error(`Failed to unarchive task: ${outcome.message}`); + } + }; + + const applyDeleteOutcome = (outcome: { kind: string; message?: string }) => { + if (outcome.kind === "deleted") { + toast.success("Task deleted"); + } 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, + ) => { + e.preventDefault(); + e.stopPropagation(); + + 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; + setBranchNotFound(null); + const hasTask = + items.find((i) => i.archived.taskId === taskId)?.task != null; + applyRestoreOutcome( + taskId, + await restore(taskId, hasTask, { recreateBranch: true }), + ); + }; + + return ( + <ArchivedTasksViewPresentation + items={items} + isLoading={isLoading} + branchNotFound={branchNotFound} + 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/packages/ui/src/features/archive/useArchivedTaskIds.ts b/packages/ui/src/features/archive/useArchivedTaskIds.ts new file mode 100644 index 0000000000..9e10d586bb --- /dev/null +++ b/packages/ui/src/features/archive/useArchivedTaskIds.ts @@ -0,0 +1,9 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +export function useArchivedTaskIds(): Set<string> { + 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/packages/ui/src/features/auth/SignInCard.tsx b/packages/ui/src/features/auth/SignInCard.tsx new file mode 100644 index 0000000000..b6820d07ae --- /dev/null +++ b/packages/ui/src/features/auth/SignInCard.tsx @@ -0,0 +1,36 @@ +import type { CloudRegion } from "@posthog/shared"; +import { OnboardingHogTip } from "@posthog/ui/primitives/OnboardingHogTip"; +import { Flex, Text } from "@radix-ui/themes"; +import { OAuthControls } from "./OAuthControls"; + +interface SignInCardProps { + hogSrc: string; + hogMessage: string; + subtitle: string; + onAuthInitiated?: (region: CloudRegion) => void; + includeDevRegion?: boolean; +} + +export function SignInCard({ + hogSrc, + hogMessage, + subtitle, + onAuthInitiated, + includeDevRegion = false, +}: SignInCardProps) { + return ( + <Flex direction="column" gap="4"> + <Flex direction="column" gap="2"> + <Text className="font-bold text-(--gray-12) text-2xl"> + Sign in / sign up with PostHog + </Text> + <Text className="text-(--gray-11) text-sm">{subtitle}</Text> + </Flex> + <OAuthControls + onAuthInitiated={onAuthInitiated} + includeDevRegion={includeDevRegion} + /> + <OnboardingHogTip hogSrc={hogSrc} message={hogMessage} /> + </Flex> + ); +} diff --git a/packages/ui/src/features/auth/assets/posthog-icon.svg b/packages/ui/src/features/auth/assets/posthog-icon.svg new file mode 100644 index 0000000000..dccc059ab8 --- /dev/null +++ b/packages/ui/src/features/auth/assets/posthog-icon.svg @@ -0,0 +1,18 @@ +<svg width="499" height="271" viewBox="0 0 499 271" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_822_682)"> +<path d="M482.64 225.225L479.381 224.832C469.662 223.596 460.617 219.101 453.763 212.023L321.235 74.7194V270.225H472.865C487.134 270.225 498.651 258.652 498.651 244.438V243.427C498.651 234.158 491.741 226.349 482.584 225.225H482.64ZM378.314 227.247C368.82 227.247 361.123 219.551 361.123 210.056C361.123 200.562 368.82 192.865 378.314 192.865C387.808 192.865 395.505 200.562 395.505 210.056C395.505 219.551 387.808 227.247 378.314 227.247Z" fill="#313131"/> +<path d="M212.022 73.8197V180.393L299.326 270.224H321.292V186.235L212.022 73.8197Z" fill="#F9AE2D"/> +<path d="M212.022 73.8201L147.921 7.865C131.797 -8.76421 103.651 2.69646 103.651 25.8425V68.8762L212.022 180.393V73.8201Z" fill="#F77133"/> +<path d="M321.292 186.236V74.7189L256.292 7.865C240.169 -8.76421 212.022 2.69646 212.022 25.8425V73.7639L321.292 186.18V186.236Z" fill="#FACA55"/> +<path d="M212.022 270.225H299.326L212.022 180.394V270.225Z" fill="#F0A82D"/> +<path d="M103.651 270.224H189.157L103.651 180.786V270.224Z" fill="#C64F2D"/> +<path d="M0 72.4709V244.437C0 249.774 1.62921 254.718 4.38202 258.875C6.23596 261.628 8.59551 263.988 11.3483 265.842C12.6966 266.797 14.2135 267.583 15.7303 268.201C18.8202 269.493 22.191 270.224 25.7865 270.224H103.596V180.786L0 72.4709Z" fill="#0B54E8"/> +<path d="M103.651 68.9325V180.786L189.157 270.225H212.022V180.393L103.651 68.9325Z" fill="#CD562E"/> +<path d="M103.652 68.9329L44.2697 7.86549C28.1461 -8.76372 0 2.69695 0 25.843V72.4722L103.652 180.787V68.9329Z" fill="#3271EC"/> +</g> +<defs> +<clipPath id="clip0_822_682"> +<rect width="498.708" height="270.225" fill="white"/> +</clipPath> +</defs> +</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/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/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/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/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/packages/ui/src/features/auth/useMeQuery.ts b/packages/ui/src/features/auth/useMeQuery.ts new file mode 100644 index 0000000000..9184f75a92 --- /dev/null +++ b/packages/ui/src/features/auth/useMeQuery.ts @@ -0,0 +1,12 @@ +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; + +export function useMeQuery() { + return useAuthenticatedQuery( + ["me"], + async (client) => { + const data = await client.getCurrentUser(); + return data; + }, + { staleTime: 5 * 60 * 1000 }, + ); +} 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/packages/ui/src/features/auth/useOrgRole.ts b/packages/ui/src/features/auth/useOrgRole.ts new file mode 100644 index 0000000000..67b7c17280 --- /dev/null +++ b/packages/ui/src/features/auth/useOrgRole.ts @@ -0,0 +1,12 @@ +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; + +export const ORGANIZATION_ADMIN_LEVEL = 8; + +export function useIsOrgAdmin(): { isAdmin: boolean | null } { + const client = useOptionalAuthenticatedClient(); + const { data, isLoading } = useCurrentUser({ client }); + const level = data?.organization?.membership_level ?? null; + if (isLoading || level === null) return { isAdmin: null }; + return { isAdmin: level >= ORGANIZATION_ADMIN_LEVEL }; +} 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/packages/ui/src/features/billing/billing.contribution.ts b/packages/ui/src/features/billing/billing.contribution.ts new file mode 100644 index 0000000000..6a714fb885 --- /dev/null +++ b/packages/ui/src/features/billing/billing.contribution.ts @@ -0,0 +1,64 @@ +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"); +}; + +@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); + + if (event.threshold === 100) { + if (event.userIsActive) { + useUsageLimitStore.getState().show({ + bucket: event.bucket, + resetAt: event.resetAt, + isPro: event.isPro, + }); + return; + } + toast.error("Usage limit reached", { + id: `usage-threshold-${event.bucket}-100`, + description: resetLabel, + }); + return; + } + + const limitName = + event.bucket === "burst" ? "daily limit" : "monthly limit"; + toast.warning( + `You've used ${Math.round(event.usedPercent)}% of your ${limitName}`, + { + id: `usage-threshold-${event.bucket}-${event.threshold}`, + description: resetLabel, + action: { label: "View usage", onClick: openPlanUsage }, + duration: 10_000, + }, + ); + }, + onError: (error) => { + this.logger.error("Usage threshold subscription error", { error }); + }, + }); + } +} 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..e765379b2a --- /dev/null +++ b/packages/ui/src/features/billing/billing.module.ts @@ -0,0 +1,7 @@ +import { WORKBENCH_CONTRIBUTION } from "@posthog/di/contribution"; +import { ContainerModule } from "inversify"; +import { BillingContribution } from "./billing.contribution"; + +export const billingUiModule = new ContainerModule(({ bind }) => { + bind(WORKBENCH_CONTRIBUTION).to(BillingContribution).inSingletonScope(); +}); 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/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx b/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx new file mode 100644 index 0000000000..dc71990e01 --- /dev/null +++ b/packages/ui/src/features/code-editor/components/CodeEditorPanel.tsx @@ -0,0 +1,300 @@ +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 { 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; + task: Task; + absolutePath: string; +} + +function FilePanelImagePreview({ + base64, + mimeType, + filePath, + absolutePath, +}: { + base64: string; + mimeType: string; + filePath: string; + absolutePath: string; +}) { + return ( + <Flex + align="center" + justify="center" + height="100%" + p="4" + className="overflow-auto" + > + <SafeImagePreview + base64={base64} + mimeType={mimeType} + alt={filePath} + className="max-h-[100%] max-w-[100%] object-contain" + fallback={ + <PanelMessage detail={absolutePath}> + Failed to render image + </PanelMessage> + } + /> + </Flex> + ); +} + +export function CodeEditorPanel({ + taskId, + task: _task, + absolutePath, +}: CodeEditorPanelProps) { + const repoPath = useCwd(taskId); + const isInsideRepo = !!repoPath && absolutePath.startsWith(repoPath); + const filePath = getRelativePath(absolutePath, repoPath); + const isImage = isRasterImageFile(absolutePath); + const isMarkdown = isMarkdownFile(absolutePath); + const openFileInSplit = usePanelLayoutStore((s) => s.openFileInSplit); + const expandToFile = useFileTreeStore((s) => s.expandToFile); + const [copied, setCopied] = useState(false); + + const handleMarkdownLinkClick = useCallback( + (e: React.MouseEvent<HTMLAnchorElement>, href: string) => { + e.preventDefault(); + const link = resolveMarkdownLink(href, filePath, repoPath); + if (link.kind === "external") { + openExternalUrl(link.href); + return; + } + if (link.absolutePath) { + expandToFile(taskId, link.absolutePath); + } + if (link.relativePath) { + openFileInSplit(taskId, link.relativePath); + } + }, + [filePath, taskId, repoPath, openFileInSplit, expandToFile], + ); + + const markdownComponents: Components = useMemo( + () => ({ + a: ({ href, children }) => ( + <Tooltip content={href ?? ""}> + <a + href={href ?? "#"} + onClick={(e) => handleMarkdownLinkClick(e, href ?? "")} + className="cursor-pointer text-(--accent-11) underline" + > + {children} + </a> + </Tooltip> + ), + }), + [handleMarkdownLinkClick], + ); + + const isCloudRun = useIsWorkspaceCloudRun(taskId); + const source = selectFileSource({ isInsideRepo, isCloudRun, isImage }); + + const cloudFile = useCloudFileContent(taskId, filePath, source.cloudEnabled); + const repoQuery = useRepoFileContent( + repoPath ?? "", + filePath, + source.repoEnabled, + ); + const absoluteQuery = useAbsoluteFileContent( + absolutePath, + source.absoluteEnabled, + ); + const imageQuery = useFileAsBase64(absolutePath, source.imageEnabled); + + const localQuery = isInsideRepo ? repoQuery : absoluteQuery; + 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, + filePath, + absolutePath: isInsideRepo ? absolutePath : undefined, + content: isImage ? null : fileContent, + }); + + const dataUrlImage = useMemo( + () => + isImage || fileContent == null ? null : parseImageDataUrl(fileContent), + [isImage, fileContent], + ); + + if (isImage) { + if (isCloudRun) { + return ( + <PanelMessage detail={filePath}> + Images not available for cloud runs + </PanelMessage> + ); + } + if (imageQuery.isLoading) { + return <PanelMessage>Loading image...</PanelMessage>; + } + if (imageQuery.error || !imageQuery.data) { + return ( + <PanelMessage detail={absolutePath}>Failed to load image</PanelMessage> + ); + } + return ( + <FilePanelImagePreview + base64={imageQuery.data} + mimeType={getImageMimeType(absolutePath)} + filePath={filePath} + absolutePath={absolutePath} + /> + ); + } + + if (isLoading) { + return <PanelMessage>Loading file...</PanelMessage>; + } + + if (isCloudRun && !cloudFile.touched) { + return ( + <PanelMessage detail={filePath}> + File content not available — the agent did not read or write this file + </PanelMessage> + ); + } + + if (isCloudRun && cloudFile.touched && cloudFile.content == null) { + return ( + <PanelMessage detail={filePath}> + This file was deleted by the agent + </PanelMessage> + ); + } + + if (error || fileContent == null) { + return ( + <PanelMessage detail={absolutePath}>Failed to load file</PanelMessage> + ); + } + + if (fileContent.length === 0) { + return <PanelMessage>File is empty</PanelMessage>; + } + + if (dataUrlImage) { + return ( + <FilePanelImagePreview + base64={dataUrlImage.base64} + mimeType={dataUrlImage.mimeType} + filePath={filePath} + absolutePath={absolutePath} + /> + ); + } + + if (isMarkdown) { + const handleCopySource = () => { + navigator.clipboard.writeText(fileContent); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + <Flex direction="column" height="100%" className="overflow-hidden"> + <Flex + px="3" + py="2" + align="center" + justify="between" + className="shrink-0 border-b border-b-(--gray-6)" + > + <Text + color="gray" + className="font-[var(--code-font-family)] text-[13px]" + > + {filePath} + </Text> + <Flex align="center" gap="1"> + <Tooltip content={copied ? "Copied" : "Copy source"}> + <IconButton + size="1" + variant="ghost" + color="gray" + className="cursor-pointer" + onClick={handleCopySource} + aria-label="Copy source" + > + {copied ? <Check size={14} /> : <Copy size={14} />} + </IconButton> + </Tooltip> + </Flex> + </Flex> + <Box className="flex-1 overflow-auto"> + <Box className="plan-markdown max-w-[750px]" p="5"> + <ReactMarkdown + remarkPlugins={[remarkGfm]} + components={markdownComponents} + > + {fileContent} + </ReactMarkdown> + </Box> + </Box> + </Flex> + ); + } + + return ( + <Box height="100%" className="relative overflow-hidden"> + <CodeMirrorEditor + content={fileContent} + filePath={absolutePath} + relativePath={filePath} + readOnly + enrichment={enrichment} + /> + <EnrichmentPopover /> + </Box> + ); +} 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/packages/ui/src/features/code-editor/extensions/postHogEnrichment.ts b/packages/ui/src/features/code-editor/extensions/postHogEnrichment.ts new file mode 100644 index 0000000000..58e58902f0 --- /dev/null +++ b/packages/ui/src/features/code-editor/extensions/postHogEnrichment.ts @@ -0,0 +1,139 @@ +import { + type Extension, + RangeSetBuilder, + StateEffect, + StateField, +} from "@codemirror/state"; +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, +} from "@codemirror/view"; +import { + 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>(); + +const enrichmentField = StateField.define<EnrichmentOccurrence[]>({ + create: () => [], + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(setEnrichmentEffect)) { + return buildEnrichmentOccurrences(effect.value); + } + } + return value; + }, +}); + +const pillStyles = EditorView.baseTheme({ + ".cm-posthog-pill": { + backgroundColor: + "color-mix(in srgb, var(--accent-9, #6b46c1) 18%, transparent)", + borderRadius: "3px", + padding: "0 3px", + margin: "0 -3px", + boxShadow: + "inset 0 0 0 1px color-mix(in srgb, var(--accent-9, #6b46c1) 40%, transparent)", + cursor: "pointer", + }, + ".cm-posthog-pill:hover": { + backgroundColor: + "color-mix(in srgb, var(--accent-9, #6b46c1) 30%, transparent)", + }, +}); + +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); + const start = view.coordsAtPos(from); + if (!start) return; + const end = view.coordsAtPos(to) ?? start; + useEnrichmentPopoverStore.getState().show( + { + top: start.top, + bottom: start.bottom, + left: start.left, + right: end.right, + }, + occurrence.entry, + ); +} + +const pillPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = this.build(view); + } + update(update: ViewUpdate) { + const prev = update.startState.field(enrichmentField, false); + const next = update.state.field(enrichmentField, false); + if (prev !== next || update.docChanged) { + this.decorations = this.build(update.view); + } + } + build(view: EditorView): DecorationSet { + const occurrences = view.state.field(enrichmentField, false) ?? []; + const builder = new RangeSetBuilder<Decoration>(); + const doc = view.state.doc; + for (const occ of occurrences) { + if (occ.line < 1 || occ.line > doc.lines) continue; + const line = doc.line(occ.line); + const from = line.from + Math.max(0, occ.startCol); + const to = line.from + Math.max(occ.startCol, occ.endCol); + if (to <= from || to > line.to) continue; + builder.add( + from, + to, + Decoration.mark({ + class: "cm-posthog-pill", + attributes: { + "data-posthog-pill": "1", + title: occ.summary, + }, + }), + ); + } + return builder.finish(); + } + }, + { + decorations: (v) => v.decorations, + eventHandlers: { + click(event, view) { + const target = event.target as HTMLElement | null; + if (!target) return false; + const pill = target.closest<HTMLElement>("[data-posthog-pill]"); + if (!pill) return false; + const pos = view.posAtDOM(pill); + const occurrences = view.state.field(enrichmentField, false) ?? []; + const line = view.state.doc.lineAt(pos).number; + const col = pos - view.state.doc.line(line).from; + const match = occurrences.find( + (o) => o.line === line && col >= o.startCol && col <= o.endCol, + ); + if (!match) return false; + event.preventDefault(); + event.stopPropagation(); + openPopoverFor(view, match); + return true; + }, + }, + }, +); + +export function postHogEnrichmentExtension(): Extension { + return [enrichmentField, pillPlugin, pillStyles]; +} 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/packages/ui/src/features/code-editor/hooks/useCodeMirror.ts b/packages/ui/src/features/code-editor/hooks/useCodeMirror.ts new file mode 100644 index 0000000000..13ce407f5b --- /dev/null +++ b/packages/ui/src/features/code-editor/hooks/useCodeMirror.ts @@ -0,0 +1,73 @@ +import { EditorState, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { useEffect, useRef } from "react"; +import { useFileContextMenu } from "../../sessions/components/useFileContextMenu"; + +interface UseCodeMirrorOptions { + doc: string; + extensions: Extension[]; + filePath?: string; +} + +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; + + instanceRef.current?.destroy(); + instanceRef.current = null; + + instanceRef.current = new EditorView({ + state: EditorState.create({ + doc: options.doc, + extensions: options.extensions, + }), + parent: containerRef.current, + }); + + return () => { + instanceRef.current?.destroy(); + instanceRef.current = null; + }; + }, [options]); + + useEffect(() => { + if (!instanceRef.current || !options.filePath) return; + + const filePath = options.filePath; + const domElement = instanceRef.current.dom; + + const handleContextMenu = async (e: MouseEvent) => { + e.preventDefault(); + + 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); + + return () => { + domElement.removeEventListener("contextmenu", handleContextMenu); + }; + }, [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/packages/ui/src/features/code-review/components/CloudReviewPage.tsx b/packages/ui/src/features/code-review/components/CloudReviewPage.tsx new file mode 100644 index 0000000000..5f92127f31 --- /dev/null +++ b/packages/ui/src/features/code-review/components/CloudReviewPage.tsx @@ -0,0 +1,137 @@ +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 { 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, + type ReviewListItem, + ReviewShell, + useReviewState, +} from "./ReviewShell"; + +interface CloudReviewPageProps { + task: Task; +} + +export function CloudReviewPage({ task }: CloudReviewPageProps) { + const taskId = task.id; + const isReviewOpen = useReviewNavigationStore( + (s) => (s.reviewModes[taskId] ?? "closed") !== "closed", + ); + const showReviewComments = useDiffViewerStore((s) => s.showReviewComments); + const { + effectiveBranch, + prUrl, + isRunActive, + remoteFiles, + reviewFiles, + toolCalls, + isLoading, + } = useCloudChangedFiles(taskId, task, isReviewOpen); + const { commentThreads } = usePrDetails(prUrl, { + includeComments: isReviewOpen && showReviewComments, + }); + + const allPaths = useMemo(() => reviewFiles.map((f) => f.path), [reviewFiles]); + + const { + diffOptions, + linesAdded, + linesRemoved, + collapsedFiles, + toggleFile, + expandAll, + collapseAll, + uncollapseFile, + } = useReviewState(reviewFiles, allPaths); + + 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 = buildGithubFileUrl(prUrl, file.path); + + return { + key: file.path, + scrollKey: file.path, + node: ( + <PatchedFileDiff + file={file} + taskId={taskId} + prUrl={prUrl} + options={diffOptions} + collapsed={isCollapsed} + onToggle={() => toggleFile(file.path)} + commentThreads={showReviewComments ? commentThreads : undefined} + fallback={toolCallFallbacks?.get(file.path) ?? null} + externalUrl={githubFileUrl} + /> + ), + }; + }); + }, [ + collapsedFiles, + commentThreads, + diffOptions, + prUrl, + reviewFiles, + showReviewComments, + taskId, + toggleFile, + toolCallFallbacks, + ]); + + const itemIndexByFilePath = useMemo(() => buildItemIndex(items), [items]); + + if (!prUrl && !effectiveBranch && reviewFiles.length === 0) { + if (isRunActive) { + return ( + <Flex + align="center" + justify="center" + height="100%" + className="text-gray-10" + > + <Flex direction="column" align="center" gap="2"> + <Spinner size="2" /> + <Text className="text-sm">Waiting for changes...</Text> + </Flex> + </Flex> + ); + } + return null; + } + + return ( + <ReviewShell + task={task} + fileCount={reviewFiles.length} + linesAdded={linesAdded} + linesRemoved={linesRemoved} + isLoading={isLoading && reviewFiles.length === 0} + isEmpty={reviewFiles.length === 0} + allExpanded={collapsedFiles.size === 0} + onExpandAll={expandAll} + onCollapseAll={collapseAll} + onUncollapseFile={uncollapseFile} + items={items} + itemIndexByFilePath={itemIndexByFilePath} + /> + ); +} 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/packages/ui/src/features/code-review/hooks/useEffectiveDiffSource.ts b/packages/ui/src/features/code-review/hooks/useEffectiveDiffSource.ts new file mode 100644 index 0000000000..9ba7458cc7 --- /dev/null +++ b/packages/ui/src/features/code-review/hooks/useEffectiveDiffSource.ts @@ -0,0 +1,89 @@ +import { + type ResolvedDiffSource, + 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; + prUrl: string | null; + linkedBranch: string | null; + defaultBranch: string | null; + repoSlug: string | null; + branchSourceAvailable: boolean; + prSourceAvailable: boolean; + diffStats: DiffStats; +} + +export function useEffectiveDiffSource(taskId: string): EffectiveDiffSource { + const trpc = useHostTRPC(); + const repoPath = useCwd(taskId); + const workspace = useWorkspace(taskId); + const linkedBranch = workspace?.linkedBranch ?? null; + + const configured = useDiffViewerStore((s) => s.diffSource[taskId] ?? null); + + const enabled = !!repoPath; + const emptyDiffStats: DiffStats = { + filesChanged: 0, + linesAdded: 0, + linesRemoved: 0, + }; + + const { data: syncStatus } = useQuery( + trpc.git.getGitSyncStatus.queryOptions( + { directoryPath: repoPath as string }, + { enabled, staleTime: 30_000 }, + ), + ); + + const { data: repoInfo } = useQuery( + trpc.git.getGitRepoInfo.queryOptions( + { directoryPath: repoPath as string }, + { enabled, staleTime: 60_000 }, + ), + ); + + const { data: diffStats = emptyDiffStats } = useDiffStats(repoPath ?? null); + + const aheadOfDefault = syncStatus?.aheadOfDefault ?? 0; + const defaultBranch = repoInfo?.defaultBranch ?? null; + const hasLocalChanges = diffStats.filesChanged > 0; + const branchSourceAvailable = !!linkedBranch && aheadOfDefault > 0; + + const prUrl = useLinkedBranchPrUrl({ + linkedBranch, + folderPath: workspace?.folderPath ?? null, + }); + const prSourceAvailable = !!prUrl; + + const repoSlug = repoInfo + ? `${repoInfo.organization}/${repoInfo.repository}` + : null; + + const effectiveSource = resolveDiffSource({ + configured, + hasLocalChanges, + linkedBranch, + aheadOfDefault, + prSourceAvailable, + }); + + return { + effectiveSource, + prUrl, + linkedBranch, + defaultBranch, + repoSlug, + branchSourceAvailable, + prSourceAvailable, + diffStats, + }; +} 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/packages/ui/src/features/code-review/hooks/usePrCommentActions.ts b/packages/ui/src/features/code-review/hooks/usePrCommentActions.ts new file mode 100644 index 0000000000..c5e9f6b565 --- /dev/null +++ b/packages/ui/src/features/code-review/hooks/usePrCommentActions.ts @@ -0,0 +1,71 @@ +import { useService } from "@posthog/di/react"; +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { useCallback } from "react"; +import { toast } from "../../../primitives/toast"; +import { + IMPERATIVE_QUERY_CLIENT, + type ImperativeQueryClient, +} from "../../../workbench/queryClient"; + +export function usePrCommentActions(prUrl: string | null) { + 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 client.git.replyToPrComment.mutate({ + prUrl, + commentId, + body, + }); + if (!result.success) { + toast.error("Failed to post reply"); + return false; + } + await queryClient.invalidateQueries( + trpc.git.getPrReviewComments.queryFilter({ prUrl }), + ); + return true; + } catch { + toast.error("Failed to post reply"); + return false; + } + }, + [prUrl, client, trpc, queryClient], + ); + + const resolve = useCallback( + async (threadNodeId: string, resolved: boolean): Promise<boolean> => { + if (!prUrl) return false; + const errorMessage = resolved + ? "Failed to resolve thread" + : "Failed to unresolve thread"; + try { + const result = await client.git.resolveReviewThread.mutate({ + prUrl, + threadNodeId, + resolved, + }); + if (!result.success) { + toast.error(errorMessage); + return false; + } + await queryClient.invalidateQueries( + trpc.git.getPrReviewComments.queryFilter({ prUrl }), + ); + return true; + } catch { + toast.error(errorMessage); + return false; + } + }, + [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/packages/ui/src/features/code-review/reviewShellParts.test.tsx b/packages/ui/src/features/code-review/reviewShellParts.test.tsx new file mode 100644 index 0000000000..f4dc68fd55 --- /dev/null +++ b/packages/ui/src/features/code-review/reviewShellParts.test.tsx @@ -0,0 +1,99 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../code-editor/diffViewerStore", () => ({ + useDiffViewerStore: vi.fn(), +})); +vi.mock("../git-interaction/utils/diffStats", () => ({ + computeDiffStats: () => ({ linesAdded: 0, linesRemoved: 0 }), +})); +vi.mock("../../workbench/themeStore", () => ({ + useThemeStore: vi.fn(() => ({ isDarkMode: false })), +})); +vi.mock("../../primitives/FileIcon", () => ({ + FileIcon: () => <span data-testid="file-icon" />, +})); + +import { DeferredDiffPlaceholder, DiffFileHeader } from "./reviewShellParts"; + +type FileDiffMetadata = import("@pierre/diffs/react").FileDiffMetadata; + +function makeFileDiff(name: string): FileDiffMetadata { + return { + name, + prevName: null, + hunks: [{ additionLines: 3, deletionLines: 1 }], + } as unknown as FileDiffMetadata; +} + +function findSpan( + container: HTMLElement, + match: (s: HTMLSpanElement) => boolean, +): HTMLSpanElement { + const spans = Array.from(container.querySelectorAll<HTMLSpanElement>("span")); + const found = spans.find(match); + if (!found) throw new Error("span not found"); + return found; +} + +function renderHeader(path: string) { + const diff = render( + <DiffFileHeader + fileDiff={makeFileDiff(path)} + collapsed={false} + onToggle={() => {}} + />, + ); + const deferred = render( + <DeferredDiffPlaceholder + filePath={path} + linesAdded={10} + linesRemoved={2} + reason="line-limit" + collapsed={false} + onToggle={() => {}} + />, + ); + return { diff, deferred }; +} + +describe.each([ + ["DiffFileHeader", "diff" as const], + ["DeferredDiffPlaceholder", "deferred" as const], +])("%s", (_name, which) => { + it("renders the directory path and filename", () => { + const rendered = renderHeader( + "src/renderer/features/code-review/components/ReviewShell.tsx", + )[which]; + + const text = rendered.container.querySelector("button")?.textContent ?? ""; + expect(text).toContain("src/renderer/features/code-review/components/"); + expect(text).toContain("ReviewShell.tsx"); + }); + + it("truncates the directory path and keeps the filename intact", () => { + const rendered = renderHeader( + "src/a/very/deeply/nested/structure/ReviewShell.tsx", + )[which]; + + // Inline styles were migrated to Tailwind utility classes; check classes + // instead. The dir span gets the muted color + truncation utilities, the + // file span gets bold weight + a non-shrinking flex behavior. + const dirSpan = findSpan(rendered.container, (s) => + s.classList.contains("text-(--gray-9)"), + ); + const fileSpan = findSpan(rendered.container, (s) => + s.classList.contains("font-semibold"), + ); + + expect(dirSpan.classList.contains("overflow-hidden")).toBe(true); + expect(dirSpan.classList.contains("text-ellipsis")).toBe(true); + expect(dirSpan.classList.contains("whitespace-nowrap")).toBe(true); + + expect(fileSpan.classList.contains("whitespace-nowrap")).toBe(true); + expect(fileSpan.classList.contains("shrink-0")).toBe(true); + + expect(dirSpan.parentElement).toBe(fileSpan.parentElement); + expect(dirSpan.parentElement?.classList.contains("flex")).toBe(true); + }); +}); 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/packages/ui/src/features/command-center/components/CommandCenterPRButton.tsx b/packages/ui/src/features/command-center/components/CommandCenterPRButton.tsx new file mode 100644 index 0000000000..a1d8452909 --- /dev/null +++ b/packages/ui/src/features/command-center/components/CommandCenterPRButton.tsx @@ -0,0 +1,38 @@ +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; + workspaceMode: WorkspaceMode | null; +} + +/** + * PR badge for a task cell in the command center. Same resolution rules as + * `TaskActionsMenu` via `useTaskPrUrl`, gated by `usePrDetails` returning a + * real PR state. + */ +export function CommandCenterPRButton({ + taskId, + workspaceMode, +}: CommandCenterPRButtonProps) { + const isCloud = workspaceMode === "cloud"; + const prUrl = useTaskPrUrl(taskId, isCloud); + + const { + meta: { state, merged, draft }, + } = usePrDetails(prUrl); + + if (!prUrl || state === null) return null; + + return ( + <PRBadgeLink + prUrl={prUrl} + prState={state} + merged={merged} + draft={draft} + compact + /> + ); +} 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/packages/ui/src/features/command-center/hooks/useAutofillCommandCenter.ts b/packages/ui/src/features/command-center/hooks/useAutofillCommandCenter.ts new file mode 100644 index 0000000000..5215817685 --- /dev/null +++ b/packages/ui/src/features/command-center/hooks/useAutofillCommandCenter.ts @@ -0,0 +1,55 @@ +import { selectAutofillCandidates } from "@posthog/core/command-center/autofill"; +import { workspaceIdSet } from "@posthog/core/command-center/eligibility"; +import { useEffect, useRef } from "react"; +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(); + const { data: workspaces, isFetched: workspacesFetched } = useWorkspaces(); + const archivedTaskIds = useArchivedTaskIds(); + + const cells = useCommandCenterStore((s) => s.cells); + const autofillCells = useCommandCenterStore((s) => s.autofillCells); + + // Fires at most once per mount so clearing cells in-place doesn't + // immediately re-populate them. Navigating away and back remounts the + // view and lets autofill run again with the latest recent tasks. + const hasRunRef = useRef(false); + + useEffect(() => { + if (hasRunRef.current) return; + if (!workspacesFetched || !workspaces) return; + if (!tasksFetched) return; + + const emptySlots = cells.filter((id) => id == null).length; + if (emptySlots === 0) { + hasRunRef.current = true; + return; + } + + const assignedIds = new Set(cells.filter((id): id is string => id != null)); + const candidates = selectAutofillCandidates(tasks, { + assignedIds, + archivedIds: archivedTaskIds, + workspaceIds: workspaceIdSet(workspaces), + emptySlots, + nowMs: Date.now(), + }); + + if (candidates.length > 0) { + autofillCells(candidates); + } + hasRunRef.current = true; + }, [ + cells, + workspaces, + workspacesFetched, + tasks, + tasksFetched, + archivedTaskIds, + autofillCells, + ]); +} 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/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/packages/ui/src/features/deep-links/useTaskDeepLink.ts b/packages/ui/src/features/deep-links/useTaskDeepLink.ts new file mode 100644 index 0000000000..39ef26b50d --- /dev/null +++ b/packages/ui/src/features/deep-links/useTaskDeepLink.ts @@ -0,0 +1,119 @@ +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 { useCallback, useEffect, useRef } from "react"; + +const log = logger.scope("task-deep-link"); + +/** + * 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 client = useHostTRPCClient(); + const taskService = useService<TaskService>(TASK_SERVICE); + const navigateToTask = useNavigationStore((state) => state.navigateToTask); + const { markAsViewed } = useTaskViewed(); + const queryClient = useQueryClient(); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + const hasFetchedPending = useRef(false); + + const handleOpenTask = useCallback( + async (taskId: string, taskRunId?: string) => { + log.info( + `Opening task from deep link: ${taskId}${taskRunId ? `, run: ${taskRunId}` : ""}`, + ); + + try { + const result = await taskService.openTask(taskId, taskRunId); + + if (!result.success) { + log.error("Failed to open task from deep link", { + taskId, + taskRunId, + error: result.error, + failedStep: result.failedStep, + }); + toast.error(`Failed to open task: ${result.error}`); + return; + } + + const { task } = result.data; + + queryClient.setQueryData<Task[]>(taskKeys.list(), (old) => { + if (!old) return [task]; + const existingIndex = old.findIndex((t) => t.id === task.id); + if (existingIndex >= 0) { + const updated = [...old]; + updated[existingIndex] = task; + return updated; + } + return [task, ...old]; + }); + + queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); + + markAsViewed(taskId); + navigateToTask(task); + + log.info( + `Successfully opened task from deep link: ${taskId}${taskRunId ? `, run: ${taskRunId}` : ""}`, + ); + } catch (error) { + log.error("Unexpected error opening task from deep link:", error); + toast.error("Failed to open task"); + } + }, + [navigateToTask, markAsViewed, queryClient, taskService], + ); + + // Check for pending deep link on mount (for cold start via deep link) + useEffect(() => { + if (!isAuthenticated || hasFetchedPending.current) return; + + const fetchPending = async () => { + hasFetchedPending.current = true; + try { + const pending = await client.deepLink.getPendingDeepLink.query(); + if (pending) { + log.info( + `Found pending deep link: taskId=${pending.taskId}, taskRunId=${pending.taskRunId ?? "none"}`, + ); + handleOpenTask(pending.taskId, pending.taskRunId); + } + } catch (error) { + log.error("Failed to check for pending deep link:", error); + } + }; + + fetchPending(); + }, [isAuthenticated, handleOpenTask, client]); + + // Subscribe to deep link events (for warm start via deep link) + useEffect(() => { + const subscription = client.deepLink.onOpenTask.subscribe(undefined, { + onData: (data) => { + log.info( + `Received deep link event: taskId=${data.taskId}, taskRunId=${data.taskRunId ?? "none"}`, + ); + 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/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..bd278f8df5 --- /dev/null +++ b/packages/ui/src/features/focus/focusStore.ts @@ -0,0 +1,90 @@ +import { resolveService } from "@posthog/di/container"; +import { + type EnableFocusParams, + FocusController, + type FocusSagaResult, +} from "@posthog/core/focus/service"; +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/packages/ui/src/features/git-interaction/cloudPrUrl.test.ts b/packages/ui/src/features/git-interaction/cloudPrUrl.test.ts new file mode 100644 index 0000000000..d65a745cf9 --- /dev/null +++ b/packages/ui/src/features/git-interaction/cloudPrUrl.test.ts @@ -0,0 +1,60 @@ +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 { + id: "task-1", + latest_run: { output: { pr_url: prUrl } }, + } as unknown as Task; +} + +function makeSession(prUrl?: unknown): AgentSession { + return { cloudOutput: { pr_url: prUrl } } as unknown as AgentSession; +} + +describe("resolveCloudPrUrl", () => { + it("returns null when both task and session are undefined", () => { + expect(resolveCloudPrUrl(undefined, undefined)).toBeNull(); + }); + + it("returns task PR URL when available", () => { + const task = makeTask("https://github.com/org/repo/pull/1"); + expect(resolveCloudPrUrl(task, undefined)).toBe( + "https://github.com/org/repo/pull/1", + ); + }); + + it("returns session PR URL when task has none", () => { + const task = makeTask(undefined); + const session = makeSession("https://github.com/org/repo/pull/2"); + expect(resolveCloudPrUrl(task, session)).toBe( + "https://github.com/org/repo/pull/2", + ); + }); + + it("prefers task PR URL over session", () => { + const task = makeTask("https://github.com/org/repo/pull/1"); + const session = makeSession("https://github.com/org/repo/pull/2"); + expect(resolveCloudPrUrl(task, session)).toBe( + "https://github.com/org/repo/pull/1", + ); + }); + + it("ignores non-string pr_url values", () => { + expect(resolveCloudPrUrl(makeTask(123), makeSession(true))).toBeNull(); + expect(resolveCloudPrUrl(makeTask(null), makeSession(null))).toBeNull(); + }); + + it("ignores empty string pr_url", () => { + expect(resolveCloudPrUrl(makeTask(""), makeSession(""))).toBeNull(); + }); + + it("falls back to session when task pr_url is empty", () => { + const session = makeSession("https://github.com/org/repo/pull/3"); + expect(resolveCloudPrUrl(makeTask(""), session)).toBe( + "https://github.com/org/repo/pull/3", + ); + }); +}); diff --git a/packages/ui/src/features/git-interaction/cloudPrUrl.ts b/packages/ui/src/features/git-interaction/cloudPrUrl.ts new file mode 100644 index 0000000000..11e659a921 --- /dev/null +++ b/packages/ui/src/features/git-interaction/cloudPrUrl.ts @@ -0,0 +1,19 @@ +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 + * persisted TaskRun output or the live session's cloudOutput (pushed over SSE + * while the run is active), so both sources are consulted. + */ +export function resolveCloudPrUrl( + task: Task | undefined, + session: AgentSession | undefined, +): string | null { + const taskPrUrl = task?.latest_run?.output?.pr_url; + const sessionPrUrl = session?.cloudOutput?.pr_url; + + if (typeof taskPrUrl === "string" && taskPrUrl) return taskPrUrl; + if (typeof sessionPrUrl === "string" && sessionPrUrl) return sessionPrUrl; + return null; +} 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/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx b/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx new file mode 100644 index 0000000000..8c252dc00d --- /dev/null +++ b/packages/ui/src/features/git-interaction/components/CloudGitInteractionHeader.tsx @@ -0,0 +1,206 @@ +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 "@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"; + +interface CloudGitInteractionHeaderProps { + taskId: string; + task: Task; +} + +export function CloudGitInteractionHeader({ + taskId, + task, +}: CloudGitInteractionHeaderProps) { + const session = useSessionForTask(taskId); + 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; + + const confirmOpen = useHandoffDialogStore((s) => s.confirmOpen); + const direction = useHandoffDialogStore((s) => s.direction); + const branchName = useHandoffDialogStore((s) => s.branchName); + const dirtyTreeOpen = useHandoffDialogStore((s) => s.dirtyTreeOpen); + const changedFiles = useHandoffDialogStore((s) => s.changedFiles); + const closeConfirm = useHandoffDialogStore((s) => s.closeConfirm); + const pendingAfterCommit = useHandoffDialogStore((s) => s.pendingAfterCommit); + + const commitRepoPath = pendingAfterCommit?.repoPath; + const git = useGitInteraction(taskId, commitRepoPath); + + const [isPreflighting, setIsPreflighting] = useState(false); + const [preflightError, setPreflightError] = useState<string | null>(null); + + const handleConfirm = async () => { + setPreflightError(null); + setIsPreflighting(true); + try { + await localHandoff.start(taskId, task); + } catch (err) { + setPreflightError( + err instanceof Error ? err.message : "Preflight failed", + ); + } finally { + setIsPreflighting(false); + } + }; + + const applyStep = (step: ContinueAfterDirtyTreeStep) => { + const actions = useGitInteractionStore.getState().actions; + if (step.step === "open-commit") { + actions.openCommit("commit"); + return; + } + actions.openBranch(step.suggestedName); + }; + + 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; + applyStep(localHandoff.afterBranchCreated()); + }; + + const handleCommitConfirm = async () => { + const committed = await git.actions.runCommit(); + if (!committed) return; + await localHandoff.afterCommit(); + }; + + if (!cloudHandoffEnabled) return null; + + const inProgress = session?.handoffInProgress ?? false; + + return ( + <> + <div className="no-drag flex items-center"> + <QuillButton + variant="outline" + size="sm" + disabled={inProgress} + onClick={() => + localHandoff.openConfirm(taskId, session?.cloudBranch ?? null) + } + > + {inProgress ? ( + <Spinner size={14} className="shrink-0 animate-spin" /> + ) : ( + <Laptop size={14} weight="regular" className="shrink-0" /> + )} + {inProgress ? "Transferring..." : "Continue locally"} + </QuillButton> + </div> + {confirmOpen && direction === "to-local" && ( + <HandoffConfirmDialog + open={confirmOpen} + onOpenChange={(open) => { + if (!open) { + closeConfirm(); + setPreflightError(null); + } + }} + direction="to-local" + branchName={branchName} + onConfirm={handleConfirm} + isSubmitting={isPreflighting} + error={preflightError} + /> + )} + {dirtyTreeOpen && ( + <DirtyTreeDialog + open={dirtyTreeOpen} + onOpenChange={(open) => { + if (!open) localHandoff.cancelPendingFlow(); + }} + changedFiles={changedFiles} + onCommitAndContinue={handleCommitAndContinue} + /> + )} + {pendingAfterCommit && ( + <GitCommitDialog + open={git.modals.commitOpen} + onOpenChange={(open) => { + if (!open) { + git.actions.closeCommit(); + localHandoff.cancelPendingFlow(); + } + }} + branchName={git.state.currentBranch ?? pendingAfterCommit.branchName} + diffStats={git.state.diffStats} + commitMessage={git.modals.commitMessage} + onCommitMessageChange={git.actions.setCommitMessage} + nextStep={git.modals.commitNextStep} + onNextStepChange={git.actions.setCommitNextStep} + pushDisabledReason={git.state.pushDisabledReason} + onContinue={handleCommitConfirm} + isSubmitting={git.modals.isSubmitting} + error={git.modals.commitError} + onGenerateMessage={git.actions.generateCommitMessage} + isGeneratingMessage={git.modals.isGeneratingCommitMessage} + showCommitAllToggle={ + git.state.stagedFiles.length > 0 && + git.state.unstagedFiles.length > 0 + } + commitAll={git.modals.commitAll} + onCommitAllChange={git.actions.setCommitAll} + stagedFileCount={git.state.stagedFiles.length} + /> + )} + {pendingAfterCommit && ( + <GitBranchDialog + open={git.modals.branchOpen} + onOpenChange={(open) => { + if (!open) { + git.actions.closeBranch(); + localHandoff.cancelPendingFlow(); + } + }} + branchName={git.modals.branchName} + onBranchNameChange={git.actions.setBranchName} + onConfirm={handleBranchConfirm} + isSubmitting={git.modals.isSubmitting} + error={git.modals.branchError} + /> + )} + </> + ); +} 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..1c6929d091 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 { Flex } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { GitCommitDialog, GitPushDialog } from "./GitInteractionDialogs"; +import { + GitCommitDialog, + GitPushDialog, +} from "@posthog/ui/features/git-interaction/components/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/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/packages/ui/src/features/git-interaction/useGitQueries.ts b/packages/ui/src/features/git-interaction/useGitQueries.ts new file mode 100644 index 0000000000..067f626fe8 --- /dev/null +++ b/packages/ui/src/features/git-interaction/useGitQueries.ts @@ -0,0 +1,199 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; + +const EMPTY_DIFF_STATS = { filesChanged: 0, linesAdded: 0, linesRemoved: 0 }; +const EMPTY_CHANGED_FILES: never[] = []; + +const GIT_QUERY_DEFAULTS = { + staleTime: 30_000, +} as const; + +interface UseGitQueriesOptions { + enabled?: boolean; +} + +export function useGitQueries( + repoPath?: string, + options?: UseGitQueriesOptions, +) { + 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(input, { + enabled, + ...GIT_QUERY_DEFAULTS, + }), + ); + + const repoEnabled = enabled && isRepo; + + const { data: changedFiles = EMPTY_CHANGED_FILES, isLoading: changesLoading } = + useQuery( + 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(input, { + enabled: repoEnabled, + ...GIT_QUERY_DEFAULTS, + placeholderData: (prev) => prev ?? EMPTY_DIFF_STATS, + }), + ); + + const { data: currentBranchData, isLoading: branchLoading } = useQuery( + trpc.git.getCurrentBranch.queryOptions(input, { + enabled: repoEnabled, + staleTime: 10_000, + placeholderData: (prev) => prev, + }), + ); + + const { data: busyState } = useQuery( + 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(input, { + enabled: repoEnabled, + ...GIT_QUERY_DEFAULTS, + refetchInterval: 60_000, + }), + ); + + const { data: repoInfo } = useQuery( + trpc.git.getGitRepoInfo.queryOptions(input, { + enabled: repoEnabled, + ...GIT_QUERY_DEFAULTS, + staleTime: 60_000, + }), + ); + + const { data: ghStatus } = useQuery( + trpc.git.getGhStatus.queryOptions(undefined, { + enabled, + ...GIT_QUERY_DEFAULTS, + staleTime: 60_000, + }), + ); + + const currentBranch = currentBranchData ?? syncStatus?.currentBranch ?? null; + + const { data: prStatus } = useQuery( + trpc.git.getPrStatus.queryOptions(input, { + enabled: repoEnabled && !!ghStatus?.installed && !!currentBranch, + ...GIT_QUERY_DEFAULTS, + }), + ); + + const { data: latestCommit } = useQuery( + trpc.git.getLatestCommit.queryOptions(input, { + enabled: repoEnabled, + ...GIT_QUERY_DEFAULTS, + }), + ); + + useQuery( + trpc.git.getAllBranches.queryOptions(input, { + enabled: repoEnabled, + ...GIT_QUERY_DEFAULTS, + staleTime: 60_000, + }), + ); + + const hasChanges = changedFiles.length > 0; + const aheadOfRemote = syncStatus?.aheadOfRemote ?? 0; + const behind = syncStatus?.behind ?? 0; + const aheadOfDefault = syncStatus?.aheadOfDefault ?? 0; + const hasRemote = syncStatus?.hasRemote ?? true; + const isFeatureBranch = syncStatus?.isFeatureBranch ?? false; + const defaultBranch = repoInfo?.defaultBranch ?? null; + + return { + isRepo, + isRepoLoading, + changedFiles, + changesLoading, + diffStats, + syncStatus, + syncLoading, + repoInfo, + ghStatus, + prStatus, + latestCommit, + hasChanges, + aheadOfRemote, + behind, + aheadOfDefault, + hasRemote, + isFeatureBranch, + currentBranch, + branchLoading, + defaultBranch, + busyState, + isLoading: isRepoLoading || changesLoading || syncLoading, + }; +} + +export function usePrChangedFiles(prUrl: string | null, pollFast?: boolean) { + const trpc = useHostTRPC(); + return useQuery( + trpc.git.getPrChangedFiles.queryOptions( + { prUrl: prUrl as string }, + { + enabled: !!prUrl, + staleTime: pollFast ? 10_000 : 5 * 60_000, + refetchInterval: pollFast ? 10_000 : false, + retry: 1, + }, + ), + ); +} + +export function useBranchChangedFiles( + repo: string | null, + branch: string | null, + pollFast?: boolean, +) { + const trpc = useHostTRPC(); + return useQuery( + trpc.git.getBranchChangedFiles.queryOptions( + { repo: repo as string, branch: branch as string }, + { + enabled: !!repo && !!branch, + staleTime: pollFast ? 10_000 : 5 * 60_000, + refetchInterval: pollFast ? 10_000 : false, + retry: 1, + }, + ), + ); +} + +export function useLocalBranchChangedFiles( + directoryPath: string | null, + branch: string | null, +) { + const trpc = useHostTRPC(); + return useQuery( + trpc.git.getLocalBranchChangedFiles.queryOptions( + { directoryPath: directoryPath as string, branch: branch as string }, + { + enabled: !!directoryPath && !!branch, + staleTime: 30_000, + refetchOnMount: "always", + retry: 1, + }, + ), + ); +} diff --git a/packages/ui/src/features/git-interaction/useLinkedBranchPrUrl.ts b/packages/ui/src/features/git-interaction/useLinkedBranchPrUrl.ts new file mode 100644 index 0000000000..5904420162 --- /dev/null +++ b/packages/ui/src/features/git-interaction/useLinkedBranchPrUrl.ts @@ -0,0 +1,31 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; + +interface UseLinkedBranchPrUrlArgs { + linkedBranch: string | null; + folderPath: string | null; +} + +/** + * Resolves the PR URL for a local task's linked branch by looking it up via + * `gh pr list --head`. Returns `null` when the task has no linked branch, no + * folder path, or the branch has no associated PR on GitHub. + */ +export function useLinkedBranchPrUrl({ + linkedBranch, + folderPath, +}: UseLinkedBranchPrUrlArgs): string | null { + 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/packages/ui/src/features/git-interaction/usePrDetails.ts b/packages/ui/src/features/git-interaction/usePrDetails.ts new file mode 100644 index 0000000000..760b41e612 --- /dev/null +++ b/packages/ui/src/features/git-interaction/usePrDetails.ts @@ -0,0 +1,62 @@ +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; +} + +function threadsToMap(threads: PrReviewThread[]): Map<number, PrCommentThread> { + const map = new Map<number, PrCommentThread>(); + for (const thread of threads) { + map.set(thread.rootId, { + rootId: thread.rootId, + nodeId: thread.nodeId, + isResolved: thread.isResolved, + comments: thread.comments, + filePath: thread.filePath, + }); + } + return map; +} + +export function usePrDetails( + prUrl: string | null, + options?: UsePrDetailsOptions, +) { + const { includeComments = false } = options ?? {}; + const trpc = useHostTRPC(); + + 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 commentThreads = useMemo( + () => threadsToMap(commentsQuery.data ?? []), + [commentsQuery.data], + ); + + return { + meta: { + state: metaQuery.data?.state ?? null, + merged: metaQuery.data?.merged ?? false, + draft: metaQuery.data?.draft ?? false, + isLoading: metaQuery.isLoading, + }, + commentThreads, + }; +} diff --git a/packages/ui/src/features/git-interaction/useTaskPrUrl.ts b/packages/ui/src/features/git-interaction/useTaskPrUrl.ts new file mode 100644 index 0000000000..c57dcf3fb3 --- /dev/null +++ b/packages/ui/src/features/git-interaction/useTaskPrUrl.ts @@ -0,0 +1,38 @@ +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: + * - cloud: the cloud run's `pr_url` + * - local: the linked-branch lookup, falling back to `getPrStatus` on the + * active repo path + * + * Shared by the task header (`TaskActionsMenu`) and the command center cell + * header (`CommandCenterPRButton`) so they always agree on what PR a task + * points at. + */ +export function useTaskPrUrl(taskId: string, isCloud: boolean): string | null { + const cloudPrUrl = useCloudPrUrl(taskId); + const workspace = useWorkspace(taskId); + const linkedPrUrl = useLinkedBranchPrUrl({ + linkedBranch: workspace?.linkedBranch ?? null, + folderPath: workspace?.folderPath ?? null, + }); + const localRepoPath = useLocalRepoPath(taskId); + + 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/packages/ui/src/features/git-interaction/utils/updateGitCache.ts b/packages/ui/src/features/git-interaction/utils/updateGitCache.ts new file mode 100644 index 0000000000..96f4067f9f --- /dev/null +++ b/packages/ui/src/features/git-interaction/utils/updateGitCache.ts @@ -0,0 +1,57 @@ +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( + provider.gitQueryKey("getChangedFilesHead", input), + snapshot.changedFiles, + ); + } + + if (snapshot.diffStats !== undefined) { + queryClient.setQueryData( + provider.gitQueryKey("getDiffStats", input), + snapshot.diffStats, + ); + } + + if (snapshot.syncStatus !== undefined) { + queryClient.setQueryData( + provider.gitQueryKey("getGitSyncStatus", input), + snapshot.syncStatus, + ); + if (snapshot.syncStatus.currentBranch !== undefined) { + queryClient.setQueryData( + provider.gitQueryKey("getCurrentBranch", input), + snapshot.syncStatus.currentBranch, + ); + } + } + + if (snapshot.latestCommit !== undefined) { + queryClient.setQueryData( + provider.gitQueryKey("getLatestCommit", input), + snapshot.latestCommit, + ); + } + + if (snapshot.prStatus !== undefined) { + queryClient.setQueryData( + 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/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/packages/ui/src/features/inbox/hooks/useEvaluations.ts b/packages/ui/src/features/inbox/hooks/useEvaluations.ts new file mode 100644 index 0000000000..f1c1900b44 --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useEvaluations.ts @@ -0,0 +1,19 @@ +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 = useAuthStateValue((s) => s.projectId); + return useAuthenticatedQuery<Evaluation[]>( + ["evaluations", projectId], + (client) => + projectId ? client.listEvaluations(projectId) : Promise.resolve([]), + { + enabled: !!projectId, + staleTime: POLL_INTERVAL_MS, + refetchInterval: POLL_INTERVAL_MS, + }, + ); +} diff --git a/packages/ui/src/features/inbox/hooks/useExternalDataSources.ts b/packages/ui/src/features/inbox/hooks/useExternalDataSources.ts new file mode 100644 index 0000000000..92da10af99 --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useExternalDataSources.ts @@ -0,0 +1,15 @@ +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); + return useAuthenticatedQuery<ExternalDataSource[]>( + ["external-data-sources", projectId], + (client) => + projectId + ? client.listExternalDataSources(projectId) + : Promise.resolve([]), + { enabled: !!projectId, staleTime: 60_000 }, + ); +} 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/packages/ui/src/features/inbox/hooks/useInboxEngagementTracker.ts b/packages/ui/src/features/inbox/hooks/useInboxEngagementTracker.ts new file mode 100644 index 0000000000..ea3f79afe5 --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useInboxEngagementTracker.ts @@ -0,0 +1,217 @@ +import { + reportAgeHours, + resolveActionProperties, +} from "@posthog/core/inbox/engagement"; +import { + ANALYTICS_EVENTS, + type InboxReportActionProperties, + type InboxReportCloseMethod, +} 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; + reportTitle: string | null; + reportCreatedAt: string | null; + reportPriority: string | null; + reportActionability: string | null; + openedAt: number; + rank: number; + listSize: number; + hasScrolled: boolean; +} + +export interface InboxEngagementTracker { + /** Fires INBOX_REPORT_SCROLLED once per open on the first scroll inside the detail pane. */ + signalScroll(): void; + /** + * Fires INBOX_REPORT_ACTION for the current open or an explicit report id. + * + * `rank`, `list_size`, `priority`, and `actionability` default to the live tracker + * state (or a lookup in the visible list for non-current reports). Callers that fire + * after an async mutation (bulk dismiss/delete/snooze/reingest, single-report dismiss + * confirm) should snapshot the pre-mutation values and pass them through — by the + * time the promise resolves the visible list has usually been re-queried without the + * affected report. + */ + signalAction( + action: Omit< + InboxReportActionProperties, + "rank" | "list_size" | "priority" | "actionability" + > & { + rank?: number; + list_size?: number; + priority?: string | null; + actionability?: string | null; + }, + ): void; +} + +export interface UseInboxEngagementTrackerOptions { + currentReportId: string | null; + currentReport: SignalReport | null; + reports: SignalReport[]; + isInboxView: boolean; +} + +export function useInboxEngagementTracker( + options: UseInboxEngagementTrackerOptions, +): InboxEngagementTracker { + const { currentReportId, currentReport, reports, isInboxView } = options; + + const openInfoRef = useRef<OpenInfo | null>(null); + const previousReportIdRef = useRef<string | null>(null); + + // Keep reports/currentReport accessible to callbacks without retriggering effects. + const reportsRef = useRef(reports); + reportsRef.current = reports; + const currentReportRef = useRef(currentReport); + currentReportRef.current = currentReport; + + const fireClose = useCallback((closeMethod: InboxReportCloseMethod) => { + const info = openInfoRef.current; + if (!info) return; + track(ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, { + report_id: info.reportId, + report_title: info.reportTitle, + report_age_hours: reportAgeHours(info.reportCreatedAt), + priority: info.reportPriority, + actionability: info.reportActionability, + time_spent_ms: Date.now() - info.openedAt, + scrolled: info.hasScrolled, + close_method: closeMethod, + }); + openInfoRef.current = null; + }, []); + + // Drive OPENED / CLOSED transitions on selection change. + useEffect(() => { + const prevInfo = openInfoRef.current; + const prevId = prevInfo?.reportId ?? null; + + if (currentReportId === prevId) return; + + if (prevInfo) { + fireClose(currentReportId == null ? "deselected" : "next_report"); + } + + if (currentReportId != null) { + const visibleReports = reportsRef.current; + const rank = visibleReports.findIndex((r) => r.id === currentReportId); + const listSize = visibleReports.length; + const openMethod = consumePendingInboxOpenMethod(); + const report = currentReportRef.current; + + const info: OpenInfo = { + reportId: currentReportId, + reportTitle: report?.title ?? null, + reportCreatedAt: report?.created_at ?? null, + reportPriority: report?.priority ?? null, + reportActionability: report?.actionability ?? null, + openedAt: Date.now(), + rank, + listSize, + hasScrolled: false, + }; + openInfoRef.current = info; + + track(ANALYTICS_EVENTS.INBOX_REPORT_OPENED, { + report_id: currentReportId, + report_title: info.reportTitle, + report_age_hours: reportAgeHours(info.reportCreatedAt), + status: report?.status ?? null, + priority: info.reportPriority, + actionability: info.reportActionability, + source_products: report?.source_products ?? [], + rank, + list_size: listSize, + open_method: openMethod, + previous_report_id: previousReportIdRef.current, + }); + } + + previousReportIdRef.current = currentReportId; + }, [currentReportId, fireClose]); + + // Close on inbox-view exit. + useEffect(() => { + if (isInboxView) return; + if (openInfoRef.current) { + fireClose("navigated_away"); + } + }, [isInboxView, fireClose]); + + // Close on unmount (covers app quit / hard navigation). + useEffect(() => { + return () => { + if (openInfoRef.current) { + fireClose("unmount"); + } + }; + }, [fireClose]); + + const signalScroll = useCallback(() => { + const info = openInfoRef.current; + if (!info || info.hasScrolled) return; + info.hasScrolled = true; + track(ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED, { + report_id: info.reportId, + report_title: info.reportTitle, + report_age_hours: reportAgeHours(info.reportCreatedAt), + priority: info.reportPriority, + actionability: info.reportActionability, + rank: info.rank, + list_size: info.listSize, + time_since_open_ms: Date.now() - info.openedAt, + }); + }, []); + + const signalAction = useCallback( + ( + action: Omit< + InboxReportActionProperties, + "rank" | "list_size" | "priority" | "actionability" + > & { + rank?: number; + list_size?: number; + priority?: string | null; + actionability?: string | null; + }, + ) => { + const info = openInfoRef.current; + const { + rank: rankOverride, + list_size: listSizeOverride, + priority: priorityOverride, + actionability: actionabilityOverride, + ...rest + } = action; + 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, + ...resolved, + }); + }, + [], + ); + + return { signalScroll, signalAction }; +} 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/packages/ui/src/features/inbox/hooks/useSignalSourceConfigs.ts b/packages/ui/src/features/inbox/hooks/useSignalSourceConfigs.ts new file mode 100644 index 0000000000..b1277bb521 --- /dev/null +++ b/packages/ui/src/features/inbox/hooks/useSignalSourceConfigs.ts @@ -0,0 +1,15 @@ +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); + return useAuthenticatedQuery<SignalSourceConfig[]>( + ["signals", "source-configs", projectId], + (client) => + projectId + ? client.listSignalSourceConfigs(projectId) + : Promise.resolve([]), + { enabled: !!projectId, staleTime: 30_000 }, + ); +} 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/packages/ui/src/features/inbox/stores/inboxSignalsSidebarStore.ts b/packages/ui/src/features/inbox/stores/inboxSignalsSidebarStore.ts new file mode 100644 index 0000000000..07d38ee55f --- /dev/null +++ b/packages/ui/src/features/inbox/stores/inboxSignalsSidebarStore.ts @@ -0,0 +1,7 @@ +import { createSidebarStore } from "@posthog/ui/workbench/createSidebarStore"; + +export const useInboxSignalsSidebarStore = createSidebarStore({ + name: "inbox-signals-sidebar-storage", + defaultWidth: 380, + defaultOpen: false, +}); 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/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/packages/ui/src/features/integrations/useGitHubIntegrationCallback.ts b/packages/ui/src/features/integrations/useGitHubIntegrationCallback.ts new file mode 100644 index 0000000000..c42d2a1ab4 --- /dev/null +++ b/packages/ui/src/features/integrations/useGitHubIntegrationCallback.ts @@ -0,0 +1,90 @@ +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"); + +const DEFAULT_ERROR_MESSAGE = + "GitHub install failed. Please try connecting again."; + +export interface IntegrationCallbackError { + message: string; + code: string | null; +} + +interface Options { + onSuccess: (projectId: number | null) => void; + onError: (error: IntegrationCallbackError) => void; + onTimedOut?: () => void; +} + +/** + * Subscribes to GitHub integration deep link callbacks and drains any pending + * callback that arrived before the subscription was established (cold-start). + */ +export function useGitHubIntegrationCallback({ + onSuccess, + onError, + onTimedOut, +}: Options): void { + const client = useHostTRPCClient(); + const hasConsumedPendingRef = useRef(false); + + const optsRef = useRef({ onSuccess, onError, onTimedOut }); + optsRef.current = { onSuccess, onError, onTimedOut }; + + 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); + }, + }, + ); + + 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; + hasConsumedPendingRef.current = true; + void (async () => { + try { + const pending = + await client.githubIntegration.consumePendingCallback.query(); + if (!pending) return; + log.info("Consumed pending integration callback on mount", pending); + if (pending.status === "error") { + optsRef.current.onError({ + message: pending.errorMessage ?? DEFAULT_ERROR_MESSAGE, + code: pending.errorCode, + }); + return; + } + optsRef.current.onSuccess(pending.projectId); + } catch (error) { + 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/packages/ui/src/features/integrations/useGithubUserConnect.ts b/packages/ui/src/features/integrations/useGithubUserConnect.ts new file mode 100644 index 0000000000..8bc177c25c --- /dev/null +++ b/packages/ui/src/features/integrations/useGithubUserConnect.ts @@ -0,0 +1,273 @@ +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 { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import { useGitHubIntegrationCallback } from "./useGitHubIntegrationCallback"; + +export { describeGithubConnectError } from "@posthog/core/integrations/connectErrors"; + +const IS_DEV = import.meta.env.DEV; + +const POLL_INTERVAL_MS = 3_000; +const POLL_TIMEOUT_MS = 300_000; + +export type GithubUserConnectState = ConnectState; +export type GithubUserConnectError = ConnectError; + +interface Options { + projectId: number | null; +} + +interface Result { + state: GithubUserConnectState; + error: GithubUserConnectError | null; + isConnecting: boolean; + isTimedOut: boolean; + hasError: boolean; + connect: () => Promise<void>; + reset: () => void; +} + +export function invalidateGithubQueries( + queryClient: QueryClient, + projectId: number | null = null, +): void { + for (const queryKey of githubInvalidationKeys(projectId)) { + void queryClient.invalidateQueries({ queryKey: [...queryKey] }); + } +} + +interface StateMachine { + state: GithubUserConnectState; + error: GithubUserConnectError | null; + stateRef: React.MutableRefObject<GithubUserConnectState>; + beginConnecting: () => void; + finishWithError: (error: GithubUserConnectError) => void; + reset: () => void; + scheduleUserFlowTimeout: () => void; + scheduleDevPolling: () => void; +} + +function useConnectStateMachine( + projectId: number | null, + onConnected?: () => void, +): StateMachine { + const queryClient = useQueryClient(); + 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); + const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } + }, []); + + const invalidate = useCallback( + (pid: number | null) => invalidateGithubQueries(queryClient, pid), + [queryClient], + ); + + useEffect(() => stopPolling, [stopPolling]); + + // 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 (status.state !== "connecting") return; + const onFocus = () => invalidate(projectId); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [status.state, projectId, invalidate]); + + useGitHubIntegrationCallback({ + onSuccess: (callbackProjectId) => { + stopPolling(); + dispatch({ type: "succeed" }); + invalidate(callbackProjectId ?? projectId); + onConnectedRef.current?.(); + }, + onError: (cbError) => { + stopPolling(); + dispatch({ type: "fail", error: cbError }); + }, + onTimedOut: () => { + stopPolling(); + dispatch({ type: "timeout" }); + invalidate(projectId); + }, + }); + + const beginConnecting = useCallback(() => { + stopPolling(); + dispatch({ type: "begin" }); + }, [stopPolling]); + + const finishWithError = useCallback( + (e: GithubUserConnectError) => { + stopPolling(); + dispatch({ type: "fail", error: e }); + }, + [stopPolling], + ); + + const reset = useCallback(() => { + stopPolling(); + dispatch({ type: "reset" }); + }, [stopPolling]); + + const scheduleUserFlowTimeout = useCallback(() => { + pollTimeoutRef.current = setTimeout(() => { + stopPolling(); + dispatch({ type: "timeout" }); + }, POLL_TIMEOUT_MS); + }, [stopPolling]); + + const scheduleDevPolling = useCallback(() => { + if (!IS_DEV) return; + pollTimerRef.current = setInterval( + () => invalidate(projectId), + POLL_INTERVAL_MS, + ); + }, [invalidate, projectId]); + + return useMemo( + () => ({ + state: status.state, + error: status.error, + stateRef, + beginConnecting, + finishWithError, + reset, + scheduleUserFlowTimeout, + scheduleDevPolling, + }), + [ + status.state, + status.error, + beginConnecting, + finishWithError, + reset, + scheduleUserFlowTimeout, + scheduleDevPolling, + ], + ); +} + +function machineToResult( + machine: StateMachine, + connect: () => Promise<void>, +): Result { + return { + state: machine.state, + error: machine.error, + ...deriveConnectFlags(machine.state), + connect, + reset: machine.reset, + }; +} + +export function useGithubUserConnect({ projectId }: Options): Result { + const connectService = useService<GithubConnectService>( + GITHUB_CONNECT_SERVICE, + ); + const machine = useConnectStateMachine(projectId); + + const connect = useCallback(async () => { + if (machine.stateRef.current === "connecting") return; + if (projectId === null) return; + machine.beginConnecting(); + try { + await connectService.connectUser(projectId); + machine.scheduleDevPolling(); + machine.scheduleUserFlowTimeout(); + } catch (e) { + machine.finishWithError( + toConnectError(e, "Failed to start GitHub connection"), + ); + } + }, [connectService, projectId, machine]); + + return machineToResult(machine, connect); +} + +interface ConnectOptions extends Options { + /** Whether `projectId` already has a team-level GitHub Integration. Required + * because the relevant project is not always the auth project (e.g. + * onboarding picks a project from a list). Admins on projects where this + * is `false` get the team-level OAuth flow (Cloud also seeds their + * `UserIntegration` in the same round-trip). */ + projectHasTeamIntegration: boolean | null; + onConnected?: () => void; +} + +/** + * Single "Connect GitHub" button for surfaces that should respect the + * team-vs-user distinction. Picks the team-level flow only for admins on + * projects with no team integration yet; everyone else gets the user-level + * flow. For purely user-scoped surfaces ("Add another GitHub org") use + * `useGithubUserConnect` directly. + */ +export function useGithubConnect({ + projectId, + projectHasTeamIntegration, + onConnected, +}: ConnectOptions): Result { + const connectService = useService<GithubConnectService>( + GITHUB_CONNECT_SERVICE, + ); + const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + const { isAdmin } = useIsOrgAdmin(); + const machine = useConnectStateMachine(projectId, onConnected); + + const connect = useCallback(async () => { + if (machine.stateRef.current === "connecting") return; + if (projectId === null) return; + machine.beginConnecting(); + try { + const outcome = await connectService.connect({ + projectId, + isAdmin, + projectHasTeamIntegration, + cloudRegion, + }); + if (outcome.flow === "user") { + machine.scheduleDevPolling(); + machine.scheduleUserFlowTimeout(); + } + } catch (e) { + machine.finishWithError( + toConnectError(e, "Failed to start GitHub connection"), + ); + } + }, [ + connectService, + projectId, + isAdmin, + projectHasTeamIntegration, + cloudRegion, + machine, + ]); + + return machineToResult(machine, connect); +} diff --git a/packages/ui/src/features/integrations/useIntegrations.ts b/packages/ui/src/features/integrations/useIntegrations.ts new file mode 100644 index 0000000000..6627748e9e --- /dev/null +++ b/packages/ui/src/features/integrations/useIntegrations.ts @@ -0,0 +1,506 @@ +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 "@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, + useEffect, + useMemo, + useState, +} from "react"; + +// 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 +// skip the debounce so closing the picker (which resets search to "") clears +// stale results immediately. +const BRANCH_SEARCH_DEBOUNCE_MS = 300; + +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() { + const setIntegrations = useIntegrationStore((state) => state.setIntegrations); + + const query = useAuthenticatedQuery( + integrationKeys.list(), + (client) => client.getIntegrations() as Promise<Integration[]>, + ); + + useEffect(() => { + if (query.data) { + setIntegrations(query.data); + } + }, [query.data, setIntegrations]); + + return query; +} + +function useAllGithubRepositories(githubIntegrations: Integration[]) { + const client = useOptionalAuthenticatedClient(); + + return useQueries({ + queries: githubIntegrations.map((integration) => ({ + queryKey: integrationKeys.repositories(integration.id), + queryFn: async () => { + if (!client) throw new Error("Not authenticated"); + const repos = await client.getGithubRepositories(integration.id); + return { integrationId: integration.id, repos }; + }, + enabled: !!client, + staleTime: 5 * 60 * 1000, + meta: AUTH_SCOPED_QUERY_META, + })), + combine: combineGithubRepositories, + }); +} + +export function useUserGithubIntegrations() { + return useAuthenticatedQuery(userGithubIntegrationKeys.list(), (client) => + client.getGithubUserIntegrations(), + ); +} + +function useAllUserGithubRepositories( + githubIntegrations: UserGitHubIntegration[], +) { + const client = useOptionalAuthenticatedClient(); + + return useQueries({ + queries: githubIntegrations.map((integration) => ({ + queryKey: userGithubIntegrationKeys.repositories( + integration.installation_id, + ), + queryFn: async () => { + if (!client) throw new Error("Not authenticated"); + const repos = await client.getGithubUserRepositories( + integration.installation_id, + ); + return { + userIntegrationId: integration.id, + installationId: integration.installation_id, + repos, + }; + }, + enabled: !!client, + staleTime: 5 * 60 * 1000, + meta: AUTH_SCOPED_QUERY_META, + })), + combine: (results) => + combineUserGithubRepositories( + results, + githubIntegrations.map((i) => i.installation_id), + ), + }); +} + +const REPOSITORIES_PAGE_SIZE = 50; + +export function useGithubRepositories( + search?: string, + enabled: boolean = true, +) { + const client = useOptionalAuthenticatedClient(); + const { githubIntegrations } = useIntegrationSelectors(); + const deferredSearch = useDeferredValue(search?.trim() ?? ""); + const [requestedLimit, setRequestedLimit] = useState(REPOSITORIES_PAGE_SIZE); + const queryEnabled = enabled && !!client && githubIntegrations.length > 0; + + useEffect(() => { + setRequestedLimit(REPOSITORIES_PAGE_SIZE); + }, []); + + const { repositoryMap, isPending, isRefreshing, hasMore } = useQueries({ + queries: githubIntegrations.map((integration) => ({ + queryKey: integrationKeys.repositoryPicker( + integration.id, + deferredSearch, + requestedLimit, + ), + queryFn: async () => { + if (!client) throw new Error("Not authenticated"); + + const page = await client.getGithubRepositoriesPage( + integration.id, + 0, + requestedLimit, + deferredSearch, + ); + + return { ref: integration.id, ...page }; + }, + enabled: queryEnabled, + staleTime: 5 * 60 * 1000, + placeholderData: (prev: unknown) => prev, + meta: AUTH_SCOPED_QUERY_META, + })), + combine: combineRepositoryPicker<number>, + }); + + const loadMore = useCallback(() => { + setRequestedLimit((currentLimit) => currentLimit + REPOSITORIES_PAGE_SIZE); + }, []); + + return { + repositories: Object.keys(repositoryMap), + isPending: queryEnabled ? isPending : false, + isRefreshing: queryEnabled ? isRefreshing : false, + hasMore, + loadMore, + }; +} + +export function useUserGithubRepositories( + search?: string, + enabled: boolean = true, +) { + const client = useOptionalAuthenticatedClient(); + const { data: githubIntegrations = [] } = useUserGithubIntegrations(); + const deferredSearch = useDeferredValue(search?.trim() ?? ""); + const [requestedLimit, setRequestedLimit] = useState(REPOSITORIES_PAGE_SIZE); + const queryEnabled = enabled && !!client && githubIntegrations.length > 0; + + useEffect(() => { + setRequestedLimit(REPOSITORIES_PAGE_SIZE); + }, []); + + const { repositoryMap, isPending, isRefreshing, hasMore } = useQueries({ + queries: githubIntegrations.map((integration) => ({ + queryKey: userGithubIntegrationKeys.repositoryPicker( + integration.installation_id, + deferredSearch, + requestedLimit, + ), + queryFn: async () => { + if (!client) throw new Error("Not authenticated"); + + const page = await client.getGithubUserRepositoriesPage( + integration.installation_id, + 0, + requestedLimit, + deferredSearch, + ); + + return { + ref: { + userIntegrationId: integration.id, + installationId: integration.installation_id, + }, + ...page, + }; + }, + enabled: queryEnabled, + staleTime: 5 * 60 * 1000, + meta: AUTH_SCOPED_QUERY_META, + })), + combine: combineRepositoryPicker<UserRepositoryIntegrationRef>, + }); + + const loadMore = useCallback(() => { + setRequestedLimit((currentLimit) => currentLimit + REPOSITORIES_PAGE_SIZE); + }, []); + + return { + repositories: Object.keys(repositoryMap), + isPending: queryEnabled ? isPending : false, + isRefreshing: queryEnabled ? isRefreshing : false, + hasMore, + loadMore, + }; +} + +export function useGithubBranches( + integrationId?: number, + repo?: string | null, + search?: string, + enabled: boolean = true, +) { + const trimmedSearch = search?.trim() ?? ""; + const debouncedSearch = useDebounce( + trimmedSearch, + trimmedSearch ? BRANCH_SEARCH_DEBOUNCE_MS : 0, + ); + const queryEnabled = enabled && !!integrationId && !!repo; + + const query = useAuthenticatedInfiniteQuery<GithubBranchesPage, number>( + integrationKeys.branches(integrationId, repo, debouncedSearch), + async (client, offset) => { + if (!integrationId || !repo) { + return { branches: [], defaultBranch: null, hasMore: false }; + } + return await client.getGithubBranchesPage( + integrationId, + repo, + offset, + branchPageSizeForOffset(offset), + debouncedSearch, + ); + }, + { + enabled: queryEnabled, + initialPageParam: 0, + getNextPageParam: computeNextBranchOffset, + staleTime: 5 * 60 * 1000, + }, + ); + + const data = useMemo( + () => flattenBranchPages(query.data?.pages), + [query.data?.pages], + ); + + const loadMore = useCallback(() => { + if (!query.hasNextPage || query.isFetchingNextPage) { + return; + } + + void query.fetchNextPage(); + }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]); + + const refresh = useCallback(async () => { + await query.refetch(); + }, [query.refetch]); + + return { + data, + isPending: queryEnabled ? query.isPending : false, + isRefreshing: queryEnabled ? query.isRefetching : false, + isFetchingMore: query.isFetchingNextPage, + hasMore: query.hasNextPage ?? false, + loadMore, + refresh, + }; +} + +export function useUserGithubBranches( + installationId?: string, + repo?: string | null, + search?: string, + enabled: boolean = true, +) { + const trimmedSearch = search?.trim() ?? ""; + const debouncedSearch = useDebounce( + trimmedSearch, + trimmedSearch ? BRANCH_SEARCH_DEBOUNCE_MS : 0, + ); + const queryEnabled = enabled && !!installationId && !!repo; + + const query = useAuthenticatedInfiniteQuery<GithubBranchesPage, number>( + userGithubIntegrationKeys.branches(installationId, repo, debouncedSearch), + async (client, offset) => { + if (!installationId || !repo) { + return { branches: [], defaultBranch: null, hasMore: false }; + } + return await client.getGithubUserBranchesPage( + installationId, + repo, + offset, + branchPageSizeForOffset(offset), + debouncedSearch, + ); + }, + { + enabled: queryEnabled, + initialPageParam: 0, + getNextPageParam: computeNextBranchOffset, + staleTime: 5 * 60 * 1000, + }, + ); + + const data = useMemo( + () => flattenBranchPages(query.data?.pages), + [query.data?.pages], + ); + + const loadMore = useCallback(() => { + if (!query.hasNextPage || query.isFetchingNextPage) { + return; + } + + void query.fetchNextPage(); + }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]); + + const refresh = useCallback(async () => { + await query.refetch(); + }, [query.refetch]); + + return { + data, + isPending: queryEnabled ? query.isPending : false, + isRefreshing: queryEnabled ? query.isRefetching : false, + isFetchingMore: query.isFetchingNextPage, + hasMore: query.hasNextPage ?? false, + loadMore, + refresh, + }; +} + +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); + + const { + repositoryMap, + reposByInstallationId, + isPending: reposPending, + failedInstallationIds, + } = useAllUserGithubRepositories(githubIntegrations); + + const repositories = useMemo( + () => Object.keys(repositoryMap), + [repositoryMap], + ); + + const getUserIntegrationIdForRepo = useCallback( + (repoKey: string) => + getRepoEntry(repositoryMap, repoKey)?.userIntegrationId, + [repositoryMap], + ); + + const getInstallationIdForRepo = useCallback( + (repoKey: string) => getRepoEntry(repositoryMap, repoKey)?.installationId, + [repositoryMap], + ); + + const repoInIntegration = useCallback( + (repoKey: string) => isRepoInIntegration(repositoryMap, repoKey), + [repositoryMap], + ); + + const refreshRepositories = useCallback(async () => { + if (!githubIntegrations.length || !client) { + return; + } + + setIsRefreshingRepos(true); + + try { + const refetchKeys = + await repositoriesService.refreshUserRepositoriesAndKeys( + githubIntegrations.map((integration) => integration.installation_id), + ); + await refetchRepositoryKeys(queryClient, refetchKeys); + } finally { + setIsRefreshingRepos(false); + } + }, [client, githubIntegrations, queryClient, repositoriesService]); + + return { + repositories, + getUserIntegrationIdForRepo, + getInstallationIdForRepo, + isRepoInIntegration: repoInIntegration, + isLoadingRepos: integrationsPending || reposPending, + isRefreshingRepos, + refreshRepositories, + hasGithubIntegration: githubIntegrations.length > 0, + failedInstallationIds, + reposByInstallationId, + }; +} + +export function useRepositoryIntegration() { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + const repositoriesService = + useService<RepositoriesService>(REPOSITORIES_SERVICE); + const { isPending: integrationsPending } = useIntegrations(); + const { githubIntegrations, hasGithubIntegration } = + useIntegrationSelectors(); + const [isRefreshingRepos, setIsRefreshingRepos] = useState(false); + + const { repositoryMap, isPending: reposPending } = + useAllGithubRepositories(githubIntegrations); + + const repositories = useMemo( + () => Object.keys(repositoryMap), + [repositoryMap], + ); + + const getIntegrationIdForRepoFn = useCallback( + (repoKey: string) => getIntegrationIdForRepo(repositoryMap, repoKey), + [repositoryMap], + ); + + const repoInIntegration = useCallback( + (repoKey: string) => isRepoInIntegration(repositoryMap, repoKey), + [repositoryMap], + ); + + const refreshRepositories = useCallback(async () => { + if (!githubIntegrations.length || !client) { + return; + } + + setIsRefreshingRepos(true); + + try { + const refetchKeys = + await repositoriesService.refreshTeamRepositoriesAndKeys( + githubIntegrations.map((integration) => integration.id), + ); + await refetchRepositoryKeys(queryClient, refetchKeys); + } finally { + setIsRefreshingRepos(false); + } + }, [client, githubIntegrations, queryClient, repositoriesService]); + + return { + repositories, + getIntegrationIdForRepo: getIntegrationIdForRepoFn, + isRepoInIntegration: repoInIntegration, + isLoadingIntegrations: integrationsPending, + isLoadingRepos: integrationsPending || reposPending, + isRefreshingRepos, + refreshRepositories, + hasGithubIntegration, + }; +} diff --git a/packages/ui/src/features/integrations/useSlackConnect.ts b/packages/ui/src/features/integrations/useSlackConnect.ts new file mode 100644 index 0000000000..9941490a49 --- /dev/null +++ b/packages/ui/src/features/integrations/useSlackConnect.ts @@ -0,0 +1,133 @@ +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, useReducer, useRef } from "react"; +import { useSlackIntegrationCallback } from "./useSlackIntegrationCallback"; + +const POLL_TIMEOUT_MS = 300_000; + +export type SlackConnectState = ConnectState; +export type SlackConnectError = ConnectError; + +interface Result { + state: SlackConnectState; + error: SlackConnectError | null; + isConnecting: boolean; + isTimedOut: boolean; + hasError: boolean; + connect: () => Promise<void>; + reset: () => void; +} + +function invalidateIntegrationQueries(queryClient: QueryClient): void { + for (const queryKey of slackInvalidationKeys()) { + void queryClient.invalidateQueries({ queryKey: [...queryKey] }); + } +} + +/** + * Drives the "Connect Slack workspace" button: + * - kicks off the main-process flow via `slackIntegration.startFlow`, + * - listens for the deep-link callback via `useSlackIntegrationCallback`, + * - refetches integration queries on success so the rest of the UI updates, + * - times out after 5 minutes and refetches as a fallback (a Slack admin who + * 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 [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(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + useEffect(() => clearLocalTimeout, [clearLocalTimeout]); + + // Window-focus fallback — the deep link can occasionally miss (browser + // setting, OS prompt dismissed), so refetch when the user returns to the + // app while a connect is in flight. + useEffect(() => { + if (status.state !== "connecting") return; + const onFocus = () => invalidateIntegrationQueries(queryClient); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [status.state, queryClient]); + + useSlackIntegrationCallback({ + onSuccess: () => { + clearLocalTimeout(); + dispatch({ type: "succeed" }); + invalidateIntegrationQueries(queryClient); + }, + onError: (cbError) => { + clearLocalTimeout(); + dispatch({ type: "fail", error: cbError }); + }, + onTimedOut: () => { + clearLocalTimeout(); + dispatch({ type: "timeout" }); + invalidateIntegrationQueries(queryClient); + }, + }); + + const reset = useCallback(() => { + clearLocalTimeout(); + dispatch({ type: "reset" }); + }, [clearLocalTimeout]); + + const connect = useCallback(async () => { + if (stateRef.current === "connecting") return; + if (projectId === null || cloudRegion === null) return; + clearLocalTimeout(); + dispatch({ type: "begin" }); + try { + const res = await client.slackIntegration.startFlow.mutate({ + region: cloudRegion, + projectId, + }); + if (!res.success) { + throw new Error(res.error ?? "Failed to start Slack connection"); + } + timeoutRef.current = setTimeout(() => { + dispatch({ type: "timeout" }); + invalidateIntegrationQueries(queryClient); + }, POLL_TIMEOUT_MS); + } catch (e) { + clearLocalTimeout(); + dispatch({ + type: "fail", + error: toConnectError(e, "Failed to start Slack connection"), + }); + } + }, [client, cloudRegion, projectId, clearLocalTimeout, queryClient]); + + return useMemo( + () => ({ + state: status.state, + error: status.error, + ...deriveConnectFlags(status.state), + connect, + reset, + }), + [status.state, status.error, connect, reset], + ); +} diff --git a/packages/ui/src/features/integrations/useSlackIntegrationCallback.ts b/packages/ui/src/features/integrations/useSlackIntegrationCallback.ts new file mode 100644 index 0000000000..01ae638710 --- /dev/null +++ b/packages/ui/src/features/integrations/useSlackIntegrationCallback.ts @@ -0,0 +1,96 @@ +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"); + +const DEFAULT_ERROR_MESSAGE = + "Slack connection failed. Please try connecting again."; + +export interface SlackCallbackError { + message: string; + code: string | null; +} + +interface Options { + onSuccess: (projectId: number | null, integrationId: number | null) => void; + onError: (error: SlackCallbackError) => void; + onTimedOut?: () => void; +} + +/** + * Subscribes to Slack integration deep link callbacks and drains any pending + * callback that arrived before the subscription was established (cold-start). + */ +export function useSlackIntegrationCallback({ + onSuccess, + onError, + onTimedOut, +}: Options): void { + const client = useHostTRPCClient(); + const hasConsumedPendingRef = useRef(false); + + const optsRef = useRef({ onSuccess, onError, onTimedOut }); + optsRef.current = { onSuccess, onError, onTimedOut }; + + 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); + }, + }, + ); + + 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; + hasConsumedPendingRef.current = true; + void (async () => { + try { + const pending = + await client.slackIntegration.consumePendingCallback.query(); + if (!pending) return; + log.info( + "Consumed pending Slack integration callback on mount", + pending, + ); + if (pending.status === "error") { + optsRef.current.onError({ + message: pending.errorMessage ?? DEFAULT_ERROR_MESSAGE, + code: pending.errorCode, + }); + return; + } + optsRef.current.onSuccess(pending.projectId, pending.integrationId); + } catch (error) { + log.error( + "Failed to consume pending Slack integration callback", + error, + ); + } + })(); + }, [client]); +} 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/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/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts b/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts new file mode 100644 index 0000000000..4caa73d3b7 --- /dev/null +++ b/packages/ui/src/features/mcp-servers/hooks/useMcpInstallationTools.ts @@ -0,0 +1,182 @@ +import type { + McpApprovalState, + McpInstallationTool, +} 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 { mcpKeys } from "./useMcpServers"; + +interface UseMcpInstallationToolsOptions { + includeRemoved?: boolean; + autoRefreshIfEmpty?: boolean; +} + +// Module-scoped on purpose: state must survive remounts of this hook so a +// detail-page revisit doesn't re-fire the auto-refresh. Tests that exercise +// auto-refresh need to clear this in beforeEach. +const autoRefreshedInstallations = new Set<string>(); + +export function useMcpInstallationTools( + installationId: string | null, + options: UseMcpInstallationToolsOptions = {}, +) { + const trpc = useHostTRPC(); + const queryClient = useQueryClient(); + + const queryKey = [ + ...mcpKeys.tools(installationId ?? ""), + { includeRemoved: !!options.includeRemoved }, + ] as const; + + const { data: tools, isLoading } = useAuthenticatedQuery( + queryKey, + (client) => + installationId + ? client.getMcpInstallationTools(installationId, { + includeRemoved: options.includeRemoved, + }) + : Promise.resolve([] as McpInstallationTool[]), + { + enabled: !!installationId, + refetchOnMount: "always", + }, + ); + + const invalidate = useCallback(() => { + if (!installationId) return; + queryClient.invalidateQueries({ + queryKey: mcpKeys.tools(installationId), + }); + }, [installationId, queryClient]); + + const setToolApprovalMutation = useAuthenticatedMutation( + (client, vars: { toolName: string; approval_state: McpApprovalState }) => { + if (!installationId) { + return Promise.reject(new Error("No installation selected")); + } + return client.updateMcpToolApproval( + installationId, + vars.toolName, + vars.approval_state, + ); + }, + { + onSuccess: () => { + invalidate(); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update tool approval"); + }, + }, + ); + + const setBulkApprovalMutation = useAuthenticatedMutation( + ( + client, + vars: { + approval_state: McpApprovalState; + targetTools?: McpInstallationTool[]; + }, + ) => { + if (!installationId) { + return Promise.reject(new Error("No installation selected")); + } + return dispatchBulkApproval( + client, + installationId, + vars.targetTools ?? tools ?? [], + vars.approval_state, + ); + }, + { + onSuccess: () => { + invalidate(); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update tool approvals"); + }, + }, + ); + + const silentRefreshRef = useRef(false); + + const refreshMutation = useAuthenticatedMutation( + (client) => { + if (!installationId) { + return Promise.reject(new Error("No installation selected")); + } + return client.refreshMcpInstallationTools(installationId); + }, + { + onSuccess: () => { + const silent = silentRefreshRef.current; + silentRefreshRef.current = false; + if (!silent) toast.success("Tools refreshed"); + invalidate(); + queryClient.invalidateQueries({ queryKey: mcpKeys.installations }); + }, + onError: (error: Error) => { + const silent = silentRefreshRef.current; + silentRefreshRef.current = false; + if (!silent) toast.error(error.message || "Failed to refresh tools"); + }, + }, + ); + + const toolsLength = (tools ?? []).length; + const refreshIsPending = refreshMutation.isPending; + const refreshMutate = refreshMutation.mutate; + + useEffect(() => { + if (!installationId) 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); + }, [ + options.autoRefreshIfEmpty, + installationId, + isLoading, + toolsLength, + refreshIsPending, + refreshMutate, + ]); + + useSubscription( + trpc.mcpCallback.onOAuthComplete.subscriptionOptions(undefined, { + onData: (data) => { + if (data.status === "success") { + invalidate(); + } + }, + }), + ); + + return { + tools: tools ?? [], + isLoading, + setToolApproval: setToolApprovalMutation.mutate, + setBulkApproval: ( + approval_state: McpApprovalState, + targetTools?: McpInstallationTool[], + ) => setBulkApprovalMutation.mutate({ approval_state, targetTools }), + bulkPending: setBulkApprovalMutation.isPending, + refresh: () => refreshMutation.mutate(undefined), + refreshPending: refreshMutation.isPending, + }; +} diff --git a/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts new file mode 100644 index 0000000000..50ca0dac80 --- /dev/null +++ b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts @@ -0,0 +1,216 @@ +import type { + McpAuthType, + McpRecommendedServer, + McpServerInstallation, +} 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"; +import { toast } from "sonner"; + +export const mcpKeys = { + servers: ["mcp", "servers"] as const, + installations: ["mcp", "installations"] as const, + tools: (installationId: string) => + ["mcp", "installations", installationId, "tools"] as const, +}; + +type HostTRPCClient = ReturnType<typeof useHostTRPCClient>; + +function createOAuthCallback(trpcClient: HostTRPCClient): IOAuthCallback { + return { + getCallbackUrl: () => trpcClient.mcpCallback.getCallbackUrl.query(), + openAndWaitForCallback: (args) => + trpcClient.mcpCallback.openAndWaitForCallback.mutate(args), + }; +} + +export function useMcpServers() { + const trpc = useHostTRPC(); + const trpcClient = useHostTRPCClient(); + const oauth = useMemo(() => createOAuthCallback(trpcClient), [trpcClient]); + const [installingId, setInstallingId] = useState<string | null>(null); + const queryClient = useQueryClient(); + + const { data: installations, isLoading: installationsLoading } = + useAuthenticatedQuery(mcpKeys.installations, (client) => + client.getMcpServerInstallations(), + ); + + const { data: servers, isLoading: serversLoading } = useAuthenticatedQuery( + mcpKeys.servers, + (client) => client.getMcpServers(), + ); + + const installedTemplateIds = useMemo( + () => + new Set( + (installations ?? []) + .map((i) => i.template_id) + .filter((id): id is string => !!id), + ), + [installations], + ); + + const installedUrls = useMemo( + () => + new Set( + (installations ?? []).map((i) => i.url).filter((u): u is string => !!u), + ), + [installations], + ); + + const invalidateInstallations = useCallback(() => { + queryClient.invalidateQueries({ queryKey: mcpKeys.installations }); + }, [queryClient]); + + const uninstallMutation = useAuthenticatedMutation( + (client, installationId: string) => + client.uninstallMcpServer(installationId), + { + onSuccess: () => { + toast.success("Server uninstalled"); + invalidateInstallations(); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to uninstall server"); + }, + }, + ); + + const toggleEnabledMutation = useAuthenticatedMutation( + (client, vars: { id: string; is_enabled: boolean }) => + client.updateMcpServerInstallation(vars.id, { + is_enabled: vars.is_enabled, + }), + { + onSuccess: () => { + invalidateInstallations(); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update server"); + }, + }, + ); + + const toggleEnabled = useCallback( + (installationId: string, enabled: boolean) => { + toggleEnabledMutation.mutate({ id: installationId, is_enabled: enabled }); + }, + [toggleEnabledMutation], + ); + + const installTemplateMutation = useAuthenticatedMutation( + (client, vars: { template_id: string; api_key?: string }) => + installTemplateWithOAuth(client, oauth, vars), + { + onSuccess: (data) => { + if (data && "success" in data && data.success) { + toast.success("Server connected"); + } else if (data && "error" in data && data.error) { + toast.error(data.error); + } + invalidateInstallations(); + setInstallingId(null); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to connect server"); + setInstallingId(null); + }, + }, + ); + + const installTemplate = useCallback( + (template: McpRecommendedServer, opts?: { api_key?: string }) => { + setInstallingId(template.id); + installTemplateMutation.mutate({ + template_id: template.id, + api_key: opts?.api_key, + }); + }, + [installTemplateMutation], + ); + + const installCustomMutation = useAuthenticatedMutation( + ( + client, + vars: { + name: string; + url: string; + description: string; + auth_type: McpAuthType; + api_key?: string; + client_id?: string; + client_secret?: string; + }, + ) => installCustomWithOAuth(client, oauth, vars), + { + onSuccess: (data) => { + if (data && "success" in data && data.success) { + toast.success("Server added"); + } else if (data && "error" in data && data.error) { + toast.error(data.error); + } + invalidateInstallations(); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to add server"); + }, + }, + ); + + const reauthorizeMutation = useAuthenticatedMutation( + (client, installationId: string) => + reauthorizeWithOAuth(client, oauth, installationId), + { + onSuccess: (data) => { + if (data && "success" in data && data.success) { + toast.success("Server reconnected"); + } else if (data && "error" in data && data.error) { + toast.error(data.error); + } + invalidateInstallations(); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to reconnect server"); + }, + }, + ); + + useSubscription( + trpc.mcpCallback.onOAuthComplete.subscriptionOptions(undefined, { + onData: (data) => { + if (data.status === "success") { + invalidateInstallations(); + } + }, + }), + ); + + return { + installations: installations as McpServerInstallation[] | undefined, + installationsLoading, + servers: servers as McpRecommendedServer[] | undefined, + serversLoading, + installedTemplateIds, + installedUrls, + installingId, + uninstallMutation, + toggleEnabled, + installTemplate, + installCustom: installCustomMutation.mutate, + installCustomPending: installCustomMutation.isPending, + reauthorize: reauthorizeMutation.mutate, + reauthorizePending: reauthorizeMutation.isPending, + 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/packages/ui/src/features/message-editor/commands.ts b/packages/ui/src/features/message-editor/commands.ts new file mode 100644 index 0000000000..f37845942d --- /dev/null +++ b/packages/ui/src/features/message-editor/commands.ts @@ -0,0 +1,140 @@ +import type { AvailableCommand } from "@agentclientprotocol/sdk"; +import { + basename, + buildFeedbackEventPayload, + parseCommandLine, +} from "@posthog/core/message-editor/commands"; +import { resolveService } from "@posthog/di/container"; +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 "../../workbench/analytics"; +import { MESSAGE_EDITOR_HOST, type MessageEditorHost } from "./identifiers"; +import type { MentionChipAttrs } from "./tiptap/MentionChipNode"; + +interface CommandContext { + taskId: string; + repoPath: string | null | undefined; + session: { + taskRunId?: string; + logUrl?: string; + events: unknown[]; + } | null; + taskRun: { id?: string; log_url?: string } | null; +} + +export interface CodeCommandInsertContext { + editor: Editor; + chipId: string; + sessionId: string; +} + +interface CodeCommand { + name: string; + description: string; + input?: { hint: string }; + /** Optional override for the chip attrs inserted when this command is committed. */ + placeholderChip?: Partial<MentionChipAttrs>; + /** Fires immediately after the chip is inserted into the editor. */ + onInsert?: (ctx: CodeCommandInsertContext) => void; + /** Runs at submission time when the message is sent. Optional. */ + execute?: ( + args: string | undefined, + context: CommandContext, + ) => Promise<void> | void; +} + +function makeFeedbackCommand( + name: string, + feedbackType: FeedbackType, + label: string, +): CodeCommand { + return { + name, + description: `Capture ${label.toLowerCase()} feedback`, + input: { hint: "optional comment" }, + execute(args, ctx) { + 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`); + }, + }; +} + +const addDirCommand: CodeCommand = { + name: "add-dir", + description: "Add a folder the agent can access in this task", + async onInsert(ctx) { + const taskId = ctx.sessionId; + try { + const path = + await resolveService<MessageEditorHost>( + MESSAGE_EDITOR_HOST, + ).selectDirectory(); + if (!path) { + ctx.editor.commands.removeMentionChipById(ctx.chipId); + return; + } + ctx.editor.commands.replaceMentionChipById(ctx.chipId, { + id: path, + label: `add-dir - ${basename(path)}`, + }); + useAddDirectoryDialogStore.getState().show({ + taskId, + path, + onCancel: () => ctx.editor.commands.removeMentionChipById(ctx.chipId), + }); + } catch (err) { + ctx.editor.commands.removeMentionChipById(ctx.chipId); + toast.error("Failed to open folder picker", { + description: err instanceof Error ? err.message : String(err), + }); + } + }, +}; + +const commands: CodeCommand[] = [ + addDirCommand, + makeFeedbackCommand("good", "good", "Positive"), + makeFeedbackCommand("bad", "bad", "Negative"), + makeFeedbackCommand("feedback", "general", "General"), +]; + +export const CODE_COMMANDS: AvailableCommand[] = commands.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + input: cmd.input, +})); + +const commandMap = new Map(commands.map((cmd) => [cmd.name, cmd])); + +export function getCodeCommand(name: string): CodeCommand | undefined { + return commandMap.get(name); +} + +export async function tryExecuteCodeCommand( + text: string, + context: CommandContext, +): Promise<boolean> { + const parsed = parseCommandLine(text); + if (!parsed) return false; + + const cmd = commandMap.get(parsed.name); + if (!cmd?.execute) return false; + + 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..1ce46a7644 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,26 @@ vi.mock("@posthog/quill", () => ({ ComboboxList: () => null, })); -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - os: { - selectAttachments: { - query: mockSelectAttachments, - }, - downscaleImageFile: { - mutate: mockDownscaleImageFile, - }, - }, - }, - useTRPC: () => ({ - git: { - getGhStatus: { - queryOptions: () => ({}), - }, - searchGithubRefs: { - queryOptions: () => ({}), - }, - }, - }), +const mockMessageEditorHost = vi.hoisted(() => ({ + selectAttachments: mockSelectAttachments, + downscaleImageFile: mockDownscaleImageFile, + getGhStatus: vi.fn(), + searchGithubRefs: vi.fn(), +})); + +vi.mock("@posthog/di/react", () => ({ + useService: () => mockMessageEditorHost, +})); + +vi.mock("@posthog/di/container", () => ({ + resolveService: () => mockMessageEditorHost, })); 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 90% 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..e73ce4a735 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,15 @@ -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 { useService } from "@posthog/di/react"; import { Button, DropdownMenu, @@ -13,15 +18,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 { MESSAGE_EDITOR_HOST, type MessageEditorHost } from "../identifiers"; import { persistBrowserFile, persistImageFilePath, @@ -69,13 +70,13 @@ export function AttachmentMenu({ const fileInputRef = useRef<HTMLInputElement>(null); const paperclipRef = useRef<HTMLButtonElement>(null); const showAddDirectoryDialog = useAddDirectoryDialogStore((s) => s.show); + const messageEditorHost = useService<MessageEditorHost>(MESSAGE_EDITOR_HOST); - const trpc = useTRPC(); - const { data: ghStatus } = useQuery( - trpc.git.getGhStatus.queryOptions(undefined, { - staleTime: 60_000, - }), - ); + const { data: ghStatus } = useQuery({ + queryKey: ["git", "getGhStatus"], + queryFn: () => messageEditorHost.getGhStatus(), + staleTime: 60_000, + }); const issueDisabledReason = getIssueDisabledReason(ghStatus, repoPath); @@ -121,7 +122,7 @@ export function AttachmentMenu({ setMenuOpen(false); try { - const results = await trpcClient.os.selectAttachments.query({ mode }); + const results = await messageEditorHost.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 89% 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..55a4e94678 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,16 @@ -import { SafeImagePreview } from "@components/ui/SafeImagePreview"; import { File, X } from "@phosphor-icons/react"; +import type { FileAttachment } from "@posthog/core/message-editor/content"; +import { useService } from "@posthog/di/react"; 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 { MESSAGE_EDITOR_HOST, type MessageEditorHost } from "../identifiers"; function FrozenGifThumbnail({ src, alt }: { src: string; alt: string }) { const canvasRef = useRef<HTMLCanvasElement>(null); @@ -44,13 +45,13 @@ function ImageThumbnail({ attachment: FileAttachment; onRemove: () => void; }) { - const trpcReact = useTRPC(); - const { data: dataUrl } = useQuery( - trpcReact.os.readFileAsDataUrl.queryOptions( - { filePath: attachment.id }, - { staleTime: Infinity }, - ), - ); + const messageEditorHost = useService<MessageEditorHost>(MESSAGE_EDITOR_HOST); + const { data: dataUrl } = useQuery({ + queryKey: ["os", "readFileAsDataUrl", attachment.id], + queryFn: () => + messageEditorHost.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 76% 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..7239cce02c 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,9 @@ -import { useDebounce } from "@hooks/useDebounce"; +import type { MentionChip } from "@posthog/core/message-editor/content"; +import { + githubIssueToMentionChip, + githubPullRequestToMentionChip, +} from "@posthog/core/message-editor/githubIssueChip"; +import { useService } from "@posthog/di/react"; import { Combobox, ComboboxContent, @@ -7,17 +12,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 { MESSAGE_EDITOR_HOST, type MessageEditorHost } from "../identifiers"; 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,24 +46,25 @@ export function IssuePicker({ onSelect, anchor, }: IssuePickerProps) { - const trpc = useTRPC(); const [query, setQuery] = useState(""); const debouncedQuery = useDebounce(query, open ? 300 : 0); + const messageEditorHost = useService<MessageEditorHost>(MESSAGE_EDITOR_HOST); useEffect(() => { if (!open) setQuery(""); }, [open]); - const { data: refs = [], isFetching } = useQuery( - trpc.git.searchGithubRefs.queryOptions( - { + const { data: refs = [], isFetching } = useQuery({ + queryKey: ["git", "searchGithubRefs", repoPath, debouncedQuery || ""], + queryFn: () => + messageEditorHost.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..e746bad5df 100644 --- a/apps/code/src/renderer/features/message-editor/components/IssueRow.tsx +++ b/packages/ui/src/features/message-editor/components/IssueRow.tsx @@ -5,8 +5,8 @@ import { ItemMedia, ItemTitle, } from "@posthog/quill"; +import { githubIssueStateColor } from "@posthog/core/message-editor/githubIssueChip"; 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.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/identifiers.ts b/packages/ui/src/features/message-editor/identifiers.ts new file mode 100644 index 0000000000..335da12e9d --- /dev/null +++ b/packages/ui/src/features/message-editor/identifiers.ts @@ -0,0 +1,63 @@ +import type { GithubRef } from "@posthog/shared"; +import type { Fzf } from "fzf"; +import type { FileItem } from "../repo-files/useRepoFiles"; + +export interface GhStatus { + installed: boolean; + version: string | null; + authenticated: boolean; + username: string | null; + error: string | null; +} + +export interface SelectedAttachment { + path: string; + kind: "file" | "directory"; +} + +export interface MessageEditorHost { + searchGithubRefs(input: { + directoryPath: string; + query?: string; + limit?: number; + }): Promise<GithubRef[]>; + fetchRepoFiles( + repoPath: string, + options?: { includeDirectories?: boolean }, + ): Promise<{ files: FileItem[]; fzf: Fzf<FileItem[]> }>; + readAbsoluteFile(input: { filePath: string }): Promise<string | null>; + selectDirectory(): Promise<string | null>; + 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 }>; + getGithubPullRequest(input: { + owner: string; + repo: string; + number: number; + }): Promise<GithubRef | null>; + getGithubIssue(input: { + owner: string; + repo: string; + number: number; + }): Promise<GithubRef | null>; + getGhStatus(): Promise<GhStatus>; + selectAttachments(input: { + mode: "files" | "directories" | "both"; + }): Promise<SelectedAttachment[]>; + readFileAsDataUrl(input: { filePath: string }): Promise<string | null>; +} + +export const MESSAGE_EDITOR_HOST = Symbol.for("posthog.ui.MessageEditorHost"); 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..f9a0b86ea6 --- /dev/null +++ b/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts @@ -0,0 +1,98 @@ +import { + githubIssueToMentionChip, + githubPullRequestToMentionChip, +} from "@posthog/core/message-editor/githubIssueChip"; +import { + getAbsolutePathSuggestion, + mergeCommands, + searchCommands, + shapeCommandSuggestions, + shapeFileSuggestions, +} from "@posthog/core/message-editor/suggestions"; +import { resolveService } from "@posthog/di/container"; +import { getAvailableCommandsForTask } from "@posthog/ui/features/sessions/sessionStore"; +import { searchFiles } from "../../repo-files/useRepoFiles"; +import { CODE_COMMANDS } from "../commands"; +import { useDraftStore } from "../draftStore"; +import { MESSAGE_EDITOR_HOST, type MessageEditorHost } from "../identifiers"; +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 resolveService<MessageEditorHost>( + MESSAGE_EDITOR_HOST, + ).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 resolveService<MessageEditorHost>( + MESSAGE_EDITOR_HOST, + ).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 91% 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..3d03953b66 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, @@ -12,11 +10,14 @@ import { WarningIcon, XIcon, } from "@phosphor-icons/react"; +import { useService } from "@posthog/di/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 { MESSAGE_EDITOR_HOST, type MessageEditorHost } from "../identifiers"; import type { ChipType, MentionChipAttrs } from "./MentionChipNode"; const chipBase = "group/chip relative top-px active:translate-y-0 pl-1"; @@ -124,10 +125,11 @@ function PastedTextChip({ selected: boolean; onRemove: () => void; }) { + const messageEditorHost = useService<MessageEditorHost>(MESSAGE_EDITOR_HOST); const handleClick = async () => { useFeatureSettingsStore.getState().markHintLearned("paste-as-file"); - const content = await trpcClient.fs.readAbsoluteFile.query({ + const content = await messageEditorHost.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..249052edb9 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 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 { createSuggestionLoader } from "@posthog/core/message-editor/suggestionLoader"; 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..0fb87e4311 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,40 @@ -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 { resolveService } from "@posthog/di/container"; +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 { MESSAGE_EDITOR_HOST, type MessageEditorHost } from "../identifiers"; +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 +94,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, @@ -119,18 +115,13 @@ async function fetchGithubRefTitle( repo: parsed.repo, number: parsed.number, }; + const host = resolveService<MessageEditorHost>(MESSAGE_EDITOR_HOST); try { if (parsed.kind === "pr") { - const pr = await queryClient.fetchQuery({ - ...trpc.git.getGithubPullRequest.queryOptions(input), - staleTime: 60_000, - }); + const pr = await host.getGithubPullRequest(input); return pr?.title ?? null; } - const issue = await queryClient.fetchQuery({ - ...trpc.git.getGithubIssue.queryOptions(input), - staleTime: 60_000, - }); + const issue = await host.getGithubIssue(input); return issue?.title ?? null; } catch { return null; @@ -390,11 +381,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 +450,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { useFeatureSettingsStore.getState().autoConvertLongText; if ( clipboardText && - autoConvertThreshold !== "off" && - clipboardText.length > Number(autoConvertThreshold) + shouldAutoConvertLongText(clipboardText, autoConvertThreshold) ) { event.preventDefault(); @@ -496,7 +489,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 +570,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 +578,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 +682,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/packages/ui/src/features/message-editor/types.ts b/packages/ui/src/features/message-editor/types.ts new file mode 100644 index 0000000000..8420137e3d --- /dev/null +++ b/packages/ui/src/features/message-editor/types.ts @@ -0,0 +1,63 @@ +import type { AvailableCommand } from "@agentclientprotocol/sdk"; +import type { + EditorContent, + FileAttachment, + MentionChip, +} from "@posthog/core/message-editor/content"; +import type { GithubRefKind, GithubRefState } from "@posthog/shared"; + +export type GithubIssueState = GithubRefState; +export type { GithubRefKind, GithubRefState }; + +export interface EditorHandle { + focus: () => void; + blur: () => void; + clear: () => void; + isEmpty: () => boolean; + getContent: () => EditorContent; + getText: () => string; + setContent: (text: string) => void; + insertChip: (chip: MentionChip) => void; + removeChipById: (chipId: string) => void; + replaceChipAttrs: ( + chipId: string, + attrs: Partial<{ id: string; label: string; type: MentionChip["type"] }>, + ) => void; + addAttachment: (attachment: FileAttachment) => void; + removeAttachment: (id: string) => void; +} + +export interface SuggestionItem { + id: string; + label: string; + description?: string; + filename?: string; + chipType?: MentionChip["type"]; +} + +export interface FileSuggestionItem extends SuggestionItem { + path: string; + kind?: "file" | "directory"; +} + +export interface CommandSuggestionItem extends SuggestionItem { + command: AvailableCommand; +} + +export interface IssueSuggestionItem extends SuggestionItem { + kind: GithubRefKind; + number: number; + title: string; + url: string; + repo: string; + state: GithubRefState; + labels: string[]; + isDraft?: boolean; +} + +export type SuggestionLoadingState = "idle" | "loading" | "error" | "success"; + +export interface SuggestionPosition { + x: number; + y: number; +} 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..93ba439e8d --- /dev/null +++ b/packages/ui/src/features/message-editor/utils/persistFile.ts @@ -0,0 +1,67 @@ +import type { FileAttachment } from "@posthog/core/message-editor/content"; +import { + type FilePersistHost, + 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 { resolveService } from "@posthog/di/container"; +import { toast } from "@posthog/ui/primitives/toast"; +import { getFilePath } from "@posthog/ui/utils/getFilePath"; +import { MESSAGE_EDITOR_HOST, type MessageEditorHost } from "../identifiers"; + +export type { PersistedFile }; + +function host(): FilePersistHost { + return resolveService<MessageEditorHost>(MESSAGE_EDITOR_HOST); +} + +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/packages/ui/src/features/navigation/store.test.ts b/packages/ui/src/features/navigation/store.test.ts new file mode 100644 index 0000000000..2aaec72228 --- /dev/null +++ b/packages/ui/src/features/navigation/store.test.ts @@ -0,0 +1,270 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getItem, setItem, removeItem } = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), +})); + +vi.mock("@posthog/di/container", () => ({ + resolveService: () => ({ getItem, setItem, removeItem }), + resolveServiceOptional: () => undefined, +})); + +vi.mock("@posthog/ui/workbench/analytics", () => ({ + track: vi.fn(), + setActiveTaskContext: vi.fn(), +})); + +import { useNavigationStore } from "./store"; + +const mockTask: Task = { + id: "task-123", + task_number: 1, + slug: "test-task", + title: "Test task", + description: "Test task description", + origin_product: "twig", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", +}; + +const getStore = () => useNavigationStore.getState(); +const getView = () => getStore().view; + +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" }], + historyIndex: 0, + }); + }); + + it("starts with task-input view", () => { + expect(getView().type).toBe("task-input"); + }); + + describe("navigation", () => { + it("navigates to task detail with taskId", async () => { + await getStore().navigateToTask(mockTask); + expect(getView()).toMatchObject({ + type: "task-detail", + data: mockTask, + taskId: "task-123", + }); + }); + + it("navigates to folder settings", () => { + getStore().navigateToFolderSettings("folder-123"); + expect(getView()).toMatchObject({ + type: "folder-settings", + folderId: "folder-123", + }); + }); + + it("navigates to task input with folderId", () => { + getStore().navigateToTaskInput("folder-123"); + expect(getView()).toMatchObject({ + type: "task-input", + folderId: "folder-123", + }); + }); + + it("navigates to task input with report association", () => { + getStore().navigateToTaskInput({ + initialPrompt: "Fix this report", + reportAssociation: { reportId: "report-123", title: "Broken signup" }, + }); + + expect(getView()).toMatchObject({ + type: "task-input", + initialPrompt: "Fix this report", + reportAssociation: { reportId: "report-123", title: "Broken signup" }, + }); + expect(getView().taskInputRequestId).toBeTruthy(); + }); + + it("mints a fresh taskInputRequestId on each navigation with transient state", () => { + getStore().navigateToTaskInput({ + initialPrompt: "Discuss this", + reportAssociation: { reportId: "report-456", title: "Slow checkout" }, + }); + const firstRequestId = getView().taskInputRequestId; + expect(firstRequestId).toBeTruthy(); + + getStore().navigateToInbox(); + getStore().navigateToTaskInput({ + initialPrompt: "Discuss this", + reportAssociation: { reportId: "report-456", title: "Slow checkout" }, + }); + expect(getView().taskInputRequestId).not.toBe(firstRequestId); + }); + + it("clears task input report association", () => { + getStore().navigateToTaskInput({ + initialPrompt: "Fix this report", + initialCloudRepository: "posthog/code", + reportAssociation: { reportId: "report-123", title: "Broken signup" }, + }); + + getStore().clearTaskInputReportAssociation(); + + expect(getView().reportAssociation).toBeUndefined(); + expect(getView().initialCloudRepository).toBeUndefined(); + expect( + getStore().history[getStore().historyIndex].reportAssociation, + ).toBeUndefined(); + expect( + getStore().history[getStore().historyIndex].initialCloudRepository, + ).toBeUndefined(); + expect(getStore().taskInputReportAssociation).toBeUndefined(); + }); + + it("clears cloud-only task input state without report association", () => { + getStore().navigateToTaskInput({ + initialCloudRepository: "posthog/code", + }); + + getStore().clearTaskInputReportAssociation(); + + expect(getView().initialCloudRepository).toBeUndefined(); + expect(getStore().taskInputCloudRepository).toBeUndefined(); + expect( + getStore().history[getStore().historyIndex].initialCloudRepository, + ).toBeUndefined(); + }); + + it("clears persisted task input report association after returning to task input", () => { + getStore().navigateToTaskInput({ + initialPrompt: "Fix this report", + initialCloudRepository: "posthog/code", + reportAssociation: { reportId: "report-123", title: "Broken signup" }, + }); + getStore().navigateToInbox(); + getStore().navigateToTaskInput(); + + getStore().clearTaskInputReportAssociation(); + + expect(getStore().taskInputReportAssociation).toBeUndefined(); + expect(getStore().taskInputCloudRepository).toBeUndefined(); + expect(getView().initialCloudRepository).toBeUndefined(); + }); + + it("keeps task input report association after leaving task input", () => { + getStore().navigateToTaskInput({ + initialPrompt: "Fix this report", + initialCloudRepository: "posthog/code", + reportAssociation: { reportId: "report-123", title: "Broken signup" }, + }); + + getStore().navigateToInbox(); + getStore().navigateToTaskInput(); + + expect(getStore().taskInputReportAssociation).toEqual({ + reportId: "report-123", + title: "Broken signup", + }); + expect(getStore().taskInputCloudRepository).toBe("posthog/code"); + }); + + it("navigates to inbox", () => { + getStore().navigateToInbox(); + expect(getView()).toMatchObject({ + type: "inbox", + }); + }); + + it("navigates to pending task with key", () => { + getStore().navigateToPendingTask("pending-key-123"); + expect(getView()).toMatchObject({ + type: "task-pending", + pendingTaskKey: "pending-key-123", + }); + }); + + it("replaces task-pending in history when navigating to real task", async () => { + getStore().navigateToTaskInput(); + getStore().navigateToPendingTask("pending-key-123"); + const indexBeforeReal = getStore().history.length - 1; + expect(getStore().history[indexBeforeReal].type).toBe("task-pending"); + + await getStore().navigateToTask(mockTask); + + const finalHistory = getStore().history; + expect(finalHistory[finalHistory.length - 1].type).toBe("task-detail"); + expect(finalHistory.some((v) => v.type === "task-pending")).toBe(false); + }); + }); + + describe("history", () => { + it("tracks history and supports back/forward", async () => { + await getStore().navigateToTask(mockTask); + getStore().navigateToFolderSettings("folder-123"); + + expect(getStore().history).toHaveLength(3); + expect(getStore().canGoBack()).toBe(true); + + getStore().goBack(); + expect(getView().type).toBe("task-detail"); + + expect(getStore().canGoForward()).toBe(true); + getStore().goForward(); + expect(getView().type).toBe("folder-settings"); + }); + }); + + describe("persistence", () => { + it("persists view type and taskId but not full task data", async () => { + await getStore().navigateToTask(mockTask); + + await vi.waitFor(() => { + expect(setItem).toHaveBeenCalled(); + }); + + const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; + const persisted = JSON.parse(lastCall[1]); + expect(persisted.state.view).toEqual({ + type: "task-detail", + taskId: "task-123", + folderId: undefined, + }); + }); + + it("restores view from electronStorage without task data", async () => { + const storedState = JSON.stringify({ + state: { + view: { + type: "task-detail", + taskId: "task-123", + folderId: undefined, + }, + }, + version: 0, + }); + + getItem.mockResolvedValue(storedState); + + useNavigationStore.setState({ + view: { type: "task-input" }, + history: [{ type: "task-input" }], + historyIndex: 0, + }); + + await useNavigationStore.persist.rehydrate(); + + expect(getView()).toMatchObject({ + type: "task-detail", + taskId: "task-123", + }); + expect(getView().data).toBeUndefined(); + }); + }); +}); diff --git a/packages/ui/src/features/navigation/store.ts b/packages/ui/src/features/navigation/store.ts new file mode 100644 index 0000000000..52be5102ac --- /dev/null +++ b/packages/ui/src/features/navigation/store.ts @@ -0,0 +1,329 @@ +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"; +import { + NAVIGATION_TASK_BINDER, + type NavigationTaskBinder, +} from "./taskBinder"; + +type ViewType = + | "task-detail" + | "task-pending" + | "task-input" + | "folder-settings" + | "inbox" + | "archived" + | "command-center" + | "skills" + | "mcp-servers"; + +export interface TaskInputReportAssociation { + reportId: string; + title: string; +} + +export interface TaskInputNavigationOptions { + folderId?: string; + initialPrompt?: string; + initialCloudRepository?: string; + initialModel?: string; + initialMode?: string; + reportAssociation?: TaskInputReportAssociation; +} + +interface ViewState { + type: ViewType; + data?: Task; + taskId?: string; + folderId?: string; + taskInputRequestId?: string; + initialPrompt?: string; + initialCloudRepository?: string; + initialModel?: string; + initialMode?: string; + reportAssociation?: TaskInputReportAssociation; + pendingTaskKey?: string; +} + +interface NavigationStore { + view: ViewState; + history: ViewState[]; + historyIndex: number; + taskInputReportAssociation?: TaskInputReportAssociation; + taskInputCloudRepository?: string; + navigateToTask: (task: Task) => void; + navigateToPendingTask: (pendingTaskKey: string) => void; + navigateToTaskInput: ( + folderIdOrOptions?: string | TaskInputNavigationOptions, + ) => void; + clearTaskInputReportAssociation: () => void; + navigateToFolderSettings: (folderId: string) => void; + navigateToInbox: () => void; + navigateToArchived: () => void; + navigateToCommandCenter: () => void; + navigateToSkills: () => void; + navigateToMcpServers: () => void; + goBack: () => void; + goForward: () => void; + canGoBack: () => boolean; + canGoForward: () => boolean; + hydrateTask: (tasks: Task[]) => void; +} + +const isSameView = (view1: ViewState, view2: ViewState): boolean => { + if (view1.type !== view2.type) return false; + if (view1.type === "task-detail" && view2.type === "task-detail") { + return view1.data?.id === view2.data?.id; + } + if (view1.type === "task-pending" && view2.type === "task-pending") { + return view1.pendingTaskKey === view2.pendingTaskKey; + } + if (view1.type === "task-input" && view2.type === "task-input") { + return ( + view1.folderId === view2.folderId && + view1.taskInputRequestId === view2.taskInputRequestId + ); + } + if (view1.type === "folder-settings" && view2.type === "folder-settings") { + return view1.folderId === view2.folderId; + } + if (view1.type === "inbox" && view2.type === "inbox") { + return true; + } + if (view1.type === "archived" && view2.type === "archived") { + return true; + } + if (view1.type === "command-center" && view2.type === "command-center") { + return true; + } + if (view1.type === "skills" && view2.type === "skills") { + return true; + } + if (view1.type === "mcp-servers" && view2.type === "mcp-servers") { + return true; + } + return false; +}; + +export const useNavigationStore = create<NavigationStore>()( + persist( + (set, get) => { + const navigate = (newView: ViewState) => { + const { view, history, historyIndex } = get(); + if (isSameView(view, newView)) { + return; + } + // Replace transient task-pending entries instead of stacking them in + // history — going back to a pending view after the real task lands + // would render an empty placeholder. + const baseHistory = + view.type === "task-pending" + ? history.slice(0, historyIndex) + : history.slice(0, historyIndex + 1); + const newHistory = [...baseHistory, newView]; + set({ + view: newView, + history: newHistory, + historyIndex: newHistory.length - 1, + }); + setActiveTaskContext( + newView.type === "task-detail" ? (newView.data ?? null) : null, + ); + }; + + return { + view: { type: "task-input" }, + history: [{ type: "task-input" }], + historyIndex: 0, + taskInputReportAssociation: undefined, + taskInputCloudRepository: undefined, + + navigateToTask: async (task: Task) => { + navigate({ type: "task-detail", data: task, taskId: task.id }); + track(ANALYTICS_EVENTS.TASK_VIEWED, { + task_id: task.id, + }); + + const result = await resolveServiceOptional<NavigationTaskBinder>( + NAVIGATION_TASK_BINDER, + )?.ensureWorkspaceForTask(task); + if (result?.staleFolderId) { + navigate({ + type: "folder-settings", + folderId: result.staleFolderId, + }); + } + }, + + navigateToPendingTask: (pendingTaskKey: string) => { + navigate({ type: "task-pending", pendingTaskKey }); + }, + + navigateToTaskInput: (folderIdOrOptions) => { + const options = + typeof folderIdOrOptions === "string" + ? { folderId: folderIdOrOptions } + : (folderIdOrOptions ?? {}); + const hasTransientState = + !!options.initialPrompt || + !!options.initialCloudRepository || + !!options.initialModel || + !!options.initialMode || + !!options.reportAssociation; + if (options.reportAssociation || options.initialCloudRepository) { + set({ + taskInputReportAssociation: options.reportAssociation, + taskInputCloudRepository: options.initialCloudRepository, + }); + } + navigate({ + type: "task-input", + folderId: options.folderId, + initialPrompt: options.initialPrompt, + initialCloudRepository: options.initialCloudRepository, + initialModel: options.initialModel, + initialMode: options.initialMode, + reportAssociation: options.reportAssociation, + taskInputRequestId: hasTransientState + ? (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`) + : undefined, + }); + }, + + clearTaskInputReportAssociation: () => { + const { + view, + history, + historyIndex, + taskInputReportAssociation, + taskInputCloudRepository, + } = get(); + if ( + !taskInputReportAssociation && + !view.reportAssociation && + !taskInputCloudRepository && + !view.initialCloudRepository + ) { + return; + } + + const updatedView = { + ...view, + reportAssociation: undefined, + initialCloudRepository: undefined, + }; + const updatedHistory = [...history]; + if (updatedHistory[historyIndex]?.type === "task-input") { + updatedHistory[historyIndex] = { + ...updatedHistory[historyIndex], + reportAssociation: undefined, + initialCloudRepository: undefined, + }; + } + + set({ + view: updatedView, + history: updatedHistory, + taskInputReportAssociation: undefined, + taskInputCloudRepository: undefined, + }); + }, + + navigateToFolderSettings: (folderId: string) => { + navigate({ type: "folder-settings", folderId }); + }, + + navigateToInbox: () => { + navigate({ type: "inbox" }); + }, + + navigateToArchived: () => { + navigate({ type: "archived" }); + }, + + navigateToCommandCenter: () => { + navigate({ type: "command-center" }); + track(ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED); + }, + + navigateToSkills: () => { + navigate({ type: "skills" }); + }, + + navigateToMcpServers: () => { + navigate({ type: "mcp-servers" }); + }, + + goBack: () => { + const { history, historyIndex } = get(); + if (historyIndex > 0) { + const newIndex = historyIndex - 1; + const newView = history[newIndex]; + set({ + view: newView, + historyIndex: newIndex, + }); + setActiveTaskContext( + newView.type === "task-detail" ? (newView.data ?? null) : null, + ); + } + }, + + goForward: () => { + const { history, historyIndex } = get(); + if (historyIndex < history.length - 1) { + const newIndex = historyIndex + 1; + const newView = history[newIndex]; + set({ + view: newView, + historyIndex: newIndex, + }); + setActiveTaskContext( + newView.type === "task-detail" ? (newView.data ?? null) : null, + ); + } + }, + + canGoBack: () => { + const { historyIndex } = get(); + return historyIndex > 0; + }, + + canGoForward: () => { + const { history, historyIndex } = get(); + return historyIndex < history.length - 1; + }, + + hydrateTask: (tasks: Task[]) => { + const { view, navigateToTask, navigateToTaskInput } = get(); + if (view.type !== "task-detail" || !view.taskId || view.data) return; + + const task = tasks.find((t) => t.id === view.taskId); + if (task) { + navigateToTask(task); + } else { + navigateToTaskInput(); + } + }, + }; + }, + { + name: "navigation-storage", + storage: electronStorage, + partialize: (state) => ({ + view: + state.view.type === "task-pending" + ? { type: "task-input" as const } + : { + type: state.view.type, + taskId: state.view.taskId, + folderId: state.view.folderId, + }, + }), + }, + ), +); diff --git a/packages/ui/src/features/navigation/taskBinder.ts b/packages/ui/src/features/navigation/taskBinder.ts new file mode 100644 index 0000000000..9ebbcea8e8 --- /dev/null +++ b/packages/ui/src/features/navigation/taskBinder.ts @@ -0,0 +1,13 @@ +import type { Task } from "@posthog/shared/domain-types"; + +export interface EnsureWorkspaceResult { + staleFolderId?: string; +} + +export interface NavigationTaskBinder { + ensureWorkspaceForTask(task: Task): Promise<EnsureWorkspaceResult | void>; +} + +export const NAVIGATION_TASK_BINDER = Symbol.for( + "posthog.ui.NavigationTaskBinder", +); 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/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/packages/ui/src/features/onboarding/hooks/useProjectsWithIntegrations.ts b/packages/ui/src/features/onboarding/hooks/useProjectsWithIntegrations.ts new file mode 100644 index 0000000000..e5fd6076a4 --- /dev/null +++ b/packages/ui/src/features/onboarding/hooks/useProjectsWithIntegrations.ts @@ -0,0 +1,53 @@ +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; + name: string; + organization: { id: string; name: string }; + integrations: Integration[]; + hasGithubIntegration: boolean; +} + +export function useProjectsWithIntegrations() { + const { projects, isLoading: projectsLoading } = useProjects(); + const client = useOptionalAuthenticatedClient(); + + const integrationQueries = useQueries({ + queries: projects.map((project) => ({ + queryKey: ["integrations", project.id], + queryFn: async () => { + if (!client) throw new Error("Not authenticated"); + return client.getIntegrationsForProject(project.id); + }, + enabled: !!client && projects.length > 0, + staleTime: 60 * 1000, + meta: AUTH_SCOPED_QUERY_META, + })), + }); + + const isLoading = + projectsLoading || integrationQueries.some((q) => q.isLoading); + const isFetching = integrationQueries.some((q) => q.isFetching); + + const { projects: projectsWithIntegrations, projectsWithGithub } = useMemo( + () => + deriveProjectsWithIntegrations( + projects, + integrationQueries.map((q) => q.data as Integration[] | undefined), + ), + [projects, integrationQueries], + ); + + return { + projects: projectsWithIntegrations, + projectsWithGithub, + isLoading, + isFetching, + }; +} 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..4582417c29 100644 --- a/apps/code/src/renderer/components/permissions/PermissionSelector.stories.tsx +++ b/packages/ui/src/features/permissions/PermissionSelector.stories.tsx @@ -9,7 +9,7 @@ import { type QuestionItem, } from "@posthog/agent/adapters/claude/questions/utils"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PermissionSelector } from "./PermissionSelector"; +import { PermissionSelector } from "@posthog/ui/features/permissions/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/packages/ui/src/features/permissions/types.ts b/packages/ui/src/features/permissions/types.ts new file mode 100644 index 0000000000..1505da5062 --- /dev/null +++ b/packages/ui/src/features/permissions/types.ts @@ -0,0 +1,64 @@ +import type { + PermissionOption, + RequestPermissionRequest, + ToolCallContent, +} from "@agentclientprotocol/sdk"; +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"> & { + kind?: CodeToolKind | null; +}; + +export interface BasePermissionProps { + toolCall: PermissionToolCall; + options: PermissionOption[]; + onSelect: ( + optionId: string, + customInput?: string, + answers?: Record<string, string>, + ) => void; + onCancel: () => void; +} + +export function toSelectorOptions( + options: PermissionOption[], +): SelectorOption[] { + return options.map((opt) => { + const meta = opt._meta as + | { description?: string; customInput?: boolean } + | undefined; + return { + id: opt.optionId, + label: opt.name, + description: meta?.description, + customInput: meta?.customInput, + }; + }); +} + +export { + type DiffContent, + findDiffContent, +} from "@posthog/ui/features/sessions/components/session-update/toolCallUtils"; +export type TerminalContent = Extract<ToolCallContent, { type: "terminal" }>; +export type StandardContent = Extract<ToolCallContent, { type: "content" }>; + +export function findTerminalContent( + content: ToolCallContent[] | null | undefined, +): TerminalContent | undefined { + return content?.find((c): c is TerminalContent => c.type === "terminal"); +} + +export function findTextContent( + content: ToolCallContent[] | null | undefined, +): string | undefined { + const stdContent = content?.find( + (c): c is StandardContent => c.type === "content", + ); + if (stdContent?.content.type === "text") { + return stdContent.content.text; + } + return undefined; +} 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/packages/ui/src/features/projects/useProjectQuery.ts b/packages/ui/src/features/projects/useProjectQuery.ts new file mode 100644 index 0000000000..da16b9240a --- /dev/null +++ b/packages/ui/src/features/projects/useProjectQuery.ts @@ -0,0 +1,21 @@ +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; + +export function useProjectQuery() { + const projectId = useAuthStateValue((state) => state.projectId); + + return useAuthenticatedQuery( + ["project", projectId], + async (client) => { + if (!projectId) { + throw new Error("No project ID available"); + } + const data = await client.getProject(projectId); + return data; + }, + { + staleTime: 5 * 60 * 1000, + enabled: !!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..044b578bf9 --- /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; + } + }, [text]); + + 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/packages/ui/src/features/provisioning/store.ts b/packages/ui/src/features/provisioning/store.ts new file mode 100644 index 0000000000..bd2279bc9a --- /dev/null +++ b/packages/ui/src/features/provisioning/store.ts @@ -0,0 +1,46 @@ +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) => { + const next = new Set(state.activeTasks); + next.add(taskId); + return { activeTasks: next }; + }), + + clear: (taskId) => + set((state) => { + const next = new Set(state.activeTasks); + next.delete(taskId); + 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/packages/ui/src/features/repo-files/useDetectedCloudRepository.ts b/packages/ui/src/features/repo-files/useDetectedCloudRepository.ts new file mode 100644 index 0000000000..e2017973ba --- /dev/null +++ b/packages/ui/src/features/repo-files/useDetectedCloudRepository.ts @@ -0,0 +1,16 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; + +export function useDetectedCloudRepository( + folderPath: string | null | undefined, +): string | null { + 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/packages/ui/src/features/repo-files/useRepoFiles.ts b/packages/ui/src/features/repo-files/useRepoFiles.ts new file mode 100644 index 0000000000..ae9e0ca895 --- /dev/null +++ b/packages/ui/src/features/repo-files/useRepoFiles.ts @@ -0,0 +1,87 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import type { MentionItem } from "@posthog/shared/domain-types"; +import { useQuery } from "@tanstack/react-query"; +import { byLengthAsc, Fzf } from "fzf"; +import { useMemo } from "react"; + +export interface FileItem { + path: string; + name: string; + dir: string; + kind: "file" | "directory"; +} + +const MENTION_DISPLAY_LIMIT = 20; + +export function pathToFileItem(path: string): FileItem { + const parts = path.split("/"); + const name = parts.pop() ?? path; + const dir = parts.join("/"); + return { path, name, dir, kind: "file" }; +} + +function pathToFolderItem(path: string): FileItem { + const parts = path.split("/"); + const name = parts.pop() ?? path; + const dir = parts.join("/"); + return { path, name, dir, kind: "directory" }; +} + +export function transformRawFiles( + rawFiles: MentionItem[], + includeDirectories: boolean, +): FileItem[] { + return rawFiles + .filter((file): file is MentionItem & { path: string } => !!file.path) + .filter((file) => includeDirectories || file.kind !== "directory") + .map((file) => + file.kind === "directory" + ? pathToFolderItem(file.path) + : pathToFileItem(file.path), + ); +} + +export function createFzf(files: FileItem[]): Fzf<FileItem[]> { + return new Fzf(files, { + selector: (item) => + item.kind === "directory" + ? `${item.name}/ ${item.path}/` + : `${item.name} ${item.path}`, + limit: MENTION_DISPLAY_LIMIT, + tiebreakers: [byLengthAsc], + }); +} + +export function useRepoFiles( + repoPath: string | undefined, + enabled = true, + options: { includeDirectories?: boolean } = {}, +) { + const { includeDirectories = false } = options; + const trpc = useHostTRPC(); + const { data: rawFiles, isLoading } = useQuery({ + ...trpc.fs.listRepoFiles.queryOptions({ repoPath: repoPath ?? "" }), + enabled: enabled && !!repoPath, + }); + + const files: FileItem[] = useMemo(() => { + if (!rawFiles) return []; + return transformRawFiles(rawFiles, includeDirectories); + }, [rawFiles, includeDirectories]); + + const fzf = useMemo(() => createFzf(files), [files]); + + return { files, fzf, isLoading }; +} + +export function searchFiles( + fzf: Fzf<FileItem[]>, + files: FileItem[], + query: string, +): FileItem[] { + if (!query.trim()) { + return files.slice(0, MENTION_DISPLAY_LIMIT); + } + const results = fzf.find(query); + return results.map((result) => result.item); +} 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..32b9ce9b98 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 type { Meta, StoryObj } from "@storybook/react-vite"; -import { ConversationView } from "./ConversationView"; +import { ConversationView } from "@posthog/ui/features/sessions/components/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..6dcdbcc869 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 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 { useService } from "@posthog/di/react"; +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.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/packages/ui/src/features/sessions/components/PendingChatView.tsx b/packages/ui/src/features/sessions/components/PendingChatView.tsx new file mode 100644 index 0000000000..01409dabe0 --- /dev/null +++ b/packages/ui/src/features/sessions/components/PendingChatView.tsx @@ -0,0 +1,43 @@ +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"; + +interface PendingChatViewProps { + promptText: string; + attachments?: UserMessageAttachment[]; +} + +export function PendingChatView({ + promptText, + attachments, +}: PendingChatViewProps) { + return ( + <Flex direction="column" className="absolute inset-0 bg-background"> + <Box className="min-h-0 flex-1 overflow-y-auto"> + <Box + className="mx-auto flex flex-col gap-3 px-2 py-1.5" + style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }} + > + <UserMessage + content={promptText} + attachments={attachments} + animate={false} + /> + <Flex align="center" gap="2" className="pl-3"> + <Brain size={12} className="ph-pulse text-accent-11" /> + <Text className="text-[13px] text-accent-11">Starting task...</Text> + </Flex> + </Box> + </Box> + <Box + className="mx-auto w-full p-2" + style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }} + > + <PendingInputPlaceholder /> + </Box> + </Flex> + ); +} 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/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx new file mode 100644 index 0000000000..57eba2f25b --- /dev/null +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -0,0 +1,605 @@ +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 "@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 "@posthog/ui/features/sessions/sessionStore"; +import { + 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 "@posthog/ui/workbench/pendingTaskPromptStore"; +import { Box, Button, ContextMenu, Flex, Text } from "@radix-ui/themes"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +interface SessionViewProps { + events: AcpMessage[]; + taskId?: string; + task?: Task; + isRunning: boolean; + isPromptPending?: boolean | null; + promptStartedAt?: number | null; + onBeforeSubmit?: (text: string, clearEditor: () => void) => boolean; + onSendPrompt: (text: string) => void; + onBashCommand?: (command: string) => void; + onCancelPrompt: () => void; + repoPath?: string | null; + cloudBranch?: string | null; + isSuspended?: boolean; + onRestoreWorktree?: () => void; + isRestoring?: boolean; + hasError?: boolean; + errorTitle?: string; + errorMessage?: string; + onRetry?: () => void; + onNewSession?: () => void; + isInitializing?: boolean; + isCloud?: boolean; + cloudStatus?: TaskRunStatus | null; + slackThreadUrl?: string; + compact?: boolean; + isActiveSession?: boolean; + /** Hide the message input and permission UI — log-only view. */ + hideInput?: boolean; +} + +const DEFAULT_ERROR_MESSAGE = + "Failed to resume this session. The working directory may have been deleted. Please start a new session."; + +export function SessionView({ + events, + taskId, + task, + isRunning, + isPromptPending = false, + promptStartedAt, + onBeforeSubmit, + onSendPrompt, + onBashCommand, + onCancelPrompt, + repoPath, + cloudBranch, + isSuspended = false, + onRestoreWorktree, + isRestoring = false, + hasError = false, + errorTitle, + errorMessage = DEFAULT_ERROR_MESSAGE, + onRetry, + onNewSession, + isInitializing = false, + isCloud = false, + cloudStatus = null, + slackThreadUrl, + compact = false, + isActiveSession = true, + hideInput = false, +}: SessionViewProps) { + const sessionService = useService<SessionService>(SESSION_SERVICE); + const showRawLogs = useShowRawLogs(); + const { setShowRawLogs } = useSessionViewActions(); + const pendingTaskPrompt = usePendingTaskPrompt(taskId); + const pendingPermissions = usePendingPermissionsForTask(taskId); + const modeOption = useModeConfigOptionForTask(taskId); + const thoughtOption = useThoughtLevelConfigOptionForTask(taskId); + const adapter = useAdapterForTask(taskId); + const { allowBypassPermissions } = useSettingsStore(); + const { isOnline } = useConnectivity(); + const currentModeId = modeOption?.currentValue; + const handoffInProgress = + useSessionForTask(taskId)?.handoffInProgress ?? false; + + useEffect(() => { + if (!taskId) return; + if (isInitializing) return; + pendingTaskPromptStoreApi.clear(taskId); + }, [taskId, isInitializing]); + + useEffect(() => { + sessionService.maybeRevertBypassMode(taskId, { + isCloud, + allowBypassPermissions, + currentModeId, + }); + }, [allowBypassPermissions, currentModeId, taskId, isCloud, sessionService]); + + const handleModeChange = useCallback( + (nextMode: string) => { + if (!taskId) return; + sessionService.setSessionConfigOptionByCategory(taskId, "mode", nextMode); + }, + [taskId, sessionService], + ); + + const handleThoughtChange = useCallback( + (value: string) => { + if (!taskId || !thoughtOption) return; + sessionService.setSessionConfigOption(taskId, thoughtOption.id, value); + }, + [taskId, thoughtOption, sessionService], + ); + + const sessionId = taskId ?? "default"; + const setContext = useDraftStore((s) => s.actions.setContext); + const requestFocus = useDraftStore((s) => s.actions.requestFocus); + + useEffect(() => { + setContext(sessionId, { + taskId, + repoPath, + cloudBranch, + disabled: !isRunning, + isLoading: !!isPromptPending, + }); + }, [ + setContext, + sessionId, + taskId, + repoPath, + cloudBranch, + isRunning, + isPromptPending, + ]); + + const isCloudRun = useIsWorkspaceCloudRun(taskId); + + const latestPlan = useMemo( + (): Plan | null => sessionService.selectLatestPlan(events) as Plan | null, + [events, sessionService], + ); + + const handleSubmit = useCallback( + (text: string) => { + if (text.trim()) { + onSendPrompt(text); + } + }, + [onSendPrompt], + ); + + const handleBeforeSubmit = useCallback( + (text: string, clearEditor: () => void): boolean => { + if (!isOnline) { + showOfflineToast(); + return false; + } + return onBeforeSubmit ? onBeforeSubmit(text, clearEditor) : true; + }, + [isOnline, onBeforeSubmit], + ); + + const [isDraggingFile, setIsDraggingFile] = useState(false); + const editorRef = useRef<PromptInputHandle>(null); + const dragCounterRef = useRef(0); + + const firstPendingPermission = useMemo(() => { + const entries = Array.from(pendingPermissions.entries()); + if (entries.length === 0) return null; + const [toolCallId, permission] = entries[0]; + return { ...permission, toolCallId }; + }, [pendingPermissions]); + + const handlePermissionSelect = useCallback( + async ( + optionId: string, + customInput?: string, + answers?: Record<string, string>, + ) => { + if (!firstPendingPermission || !taskId) return; + + const plan = await sessionService.resolvePermissionSelection( + taskId, + firstPendingPermission, + optionId, + modeOption, + customInput, + answers, + ); + + if (plan.resendPromptText) { + onSendPrompt(plan.resendPromptText); + } + + requestFocus(sessionId); + }, + [ + firstPendingPermission, + taskId, + onSendPrompt, + requestFocus, + sessionId, + modeOption, + sessionService, + ], + ); + + const handlePermissionCancel = useCallback(async () => { + if (!firstPendingPermission || !taskId) return; + await sessionService.cancelPermissionAndPrompt( + taskId, + firstPendingPermission.toolCallId, + ); + requestFocus(sessionId); + }, [firstPendingPermission, taskId, requestFocus, sessionId, sessionService]); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current++; + if (e.dataTransfer.types.includes("Files")) { + setIsDraggingFile(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current--; + if (dragCounterRef.current === 0) { + setIsDraggingFile(false); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current = 0; + setIsDraggingFile(false); + + // If dropped on the editor, Tiptap's handleDrop already handled it + if ((e.target as HTMLElement).closest(".ProseMirror")) return; + + const files = e.dataTransfer.files; + if (!files || files.length === 0) return; + + resolveAndAttachDroppedFiles(files, (a) => + editorRef.current?.addAttachment(a), + ) + .then(() => editorRef.current?.focus()) + .catch(() => toast.error("Failed to attach files")); + }, []); + + const handlePaneClick = useCallback((e: React.MouseEvent) => { + const target = e.target as HTMLElement; + + const interactiveSelector = + 'button, a, input, textarea, select, [role="button"], [role="link"], [contenteditable="true"], [data-interactive]'; + if (target.closest(interactiveSelector)) { + return; + } + + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) { + return; + } + + editorRef.current?.focus(); + }, []); + + useAutoFocusOnTyping(editorRef, !isActiveSession); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if ( + target.closest('input, textarea, [contenteditable="true"], .ProseMirror') + ) { + e.stopPropagation(); + } + }, []); + + return ( + <ContextMenu.Root> + <ContextMenu.Trigger> + {showRawLogs ? ( + <Flex + direction="column" + height="100%" + className="relative bg-background" + onContextMenu={handleContextMenu} + > + <RawLogsView + events={events} + onClose={() => setShowRawLogs(false)} + /> + </Flex> + ) : ( + <Flex + direction="column" + height="100%" + className="relative bg-background" + onClick={handlePaneClick} + onContextMenu={handleContextMenu} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDrop={handleDrop} + > + {isSuspended ? ( + <> + <ConversationView + events={events} + isPromptPending={isPromptPending} + promptStartedAt={promptStartedAt} + repoPath={repoPath} + taskId={taskId} + task={task} + slackThreadUrl={slackThreadUrl} + /> + <Box className="border-gray-4 border-t"> + <Box + className="mx-auto p-2" + style={{ maxWidth: CHAT_CONTENT_MAX_WIDTH }} + > + <Flex + align="center" + justify="between" + gap="3" + py="2" + px="3" + className="rounded-2 bg-gray-3" + > + <Flex align="center" gap="2"> + <Pause + size={14} + weight="duotone" + color="var(--gray-11)" + /> + <Text className="font-medium text-[13px]"> + Worktree suspended + </Text> + <Text color="gray" className="text-[13px]"> + Worktree was removed to save disk space + </Text> + </Flex> + {onRestoreWorktree && ( + <Button + variant="outline" + size="1" + onClick={onRestoreWorktree} + disabled={isRestoring} + > + {isRestoring ? ( + <> + <Spinner size={14} className="animate-spin" /> + Restoring... + </> + ) : ( + "Restore worktree" + )} + </Button> + )} + </Flex> + </Box> + </Box> + </> + ) : isInitializing ? ( + isCloud ? ( + <CloudInitializingView cloudStatus={cloudStatus} /> + ) : pendingTaskPrompt?.promptText ? ( + <PendingChatView + promptText={pendingTaskPrompt.promptText} + attachments={pendingTaskPrompt.attachments} + /> + ) : ( + <Flex + align="center" + justify="center" + className="absolute inset-0 bg-background" + > + <Spinner size={32} className="animate-spin text-gray-9" /> + </Flex> + ) + ) : ( + <> + <DropZoneOverlay isVisible={isDraggingFile} /> + <ConversationView + events={events} + isPromptPending={isPromptPending} + promptStartedAt={promptStartedAt} + repoPath={repoPath} + taskId={taskId} + task={task} + slackThreadUrl={slackThreadUrl} + compact={compact} + /> + + <PlanStatusBar plan={latestPlan} /> + + {hasError ? ( + <Flex + align="center" + justify="center" + direction="column" + gap="2" + className="absolute inset-0 bg-background" + > + <Warning size={32} weight="duotone" color="var(--red-9)" /> + {errorTitle && ( + <Text + align="center" + color="red" + className="font-bold text-base" + > + {errorTitle} + </Text> + )} + <Text + align="center" + color={errorTitle ? "gray" : "red"} + className={`max-w-md px-4 ${errorTitle ? "text-sm" : "font-medium text-base"}`} + > + {errorMessage} + </Text> + <Flex gap="2" mt="2"> + {onRetry && ( + <Button variant="soft" size="2" onClick={onRetry}> + Retry + </Button> + )} + {onNewSession && ( + <Button + variant="soft" + size="2" + color="green" + onClick={onNewSession} + > + New Session + </Button> + )} + </Flex> + </Flex> + ) : hideInput ? null : firstPendingPermission ? ( + <Box className="max-h-1/2 min-h-0 overflow-y-auto border-gray-4 border-t"> + <Box + className={compact ? "p-1" : "mx-auto p-2"} + style={ + compact + ? undefined + : { maxWidth: CHAT_CONTENT_MAX_WIDTH } + } + > + <PermissionSelector + toolCall={firstPendingPermission.toolCall} + options={firstPendingPermission.options} + onSelect={handlePermissionSelect} + onCancel={handlePermissionCancel} + /> + </Box> + </Box> + ) : ( + <Box className="relative border-gray-4 border-t"> + <Box + className={`absolute inset-0 flex min-h-[66px] items-center justify-center gap-2 transition-opacity duration-200 ${ + isRunning + ? "pointer-events-none opacity-0" + : "opacity-100" + }`} + > + <Spinner size={28} className="animate-spin text-gray-9" /> + <Text color="gray" className="text-base"> + Connecting to agent... + </Text> + </Box> + <Box + className={`transition-all duration-300 ease-out ${ + isRunning + ? "translate-y-0 opacity-100" + : "pointer-events-none translate-y-4 opacity-0" + }`} + > + <Box + className={compact ? "p-1" : "mx-auto p-2"} + style={ + compact + ? undefined + : { maxWidth: CHAT_CONTENT_MAX_WIDTH } + } + > + <PromptInput + ref={editorRef} + sessionId={sessionId} + placeholder="Type a message... @ to mention files, ! for bash mode, / for skills" + disabled={!isRunning && !handoffInProgress} + submitDisabledExternal={ + handoffInProgress || !isOnline + } + submitTooltipOverride={ + !isOnline ? "No internet connection" : undefined + } + isLoading={!!isPromptPending} + isActiveSession={isActiveSession} + taskId={taskId} + repoPath={repoPath} + modeOption={modeOption} + onModeChange={ + modeOption ? handleModeChange : undefined + } + allowBypassPermissions={allowBypassPermissions} + enableBashMode={!isCloudRun} + modelSelector={ + <ModelSelector + taskId={taskId} + disabled={!isRunning} + /> + } + reasoningSelector={ + thoughtOption ? ( + <ReasoningLevelSelector + thoughtOption={thoughtOption} + adapter={adapter} + onChange={handleThoughtChange} + disabled={!isRunning} + /> + ) : null + } + onBeforeSubmit={handleBeforeSubmit} + onSubmit={handleSubmit} + onBashCommand={onBashCommand} + onCancel={onCancelPrompt} + /> + </Box> + </Box> + </Box> + )} + </> + )} + </Flex> + )} + </ContextMenu.Trigger> + <ContextMenu.Content size="1"> + <ContextMenu.Item + onSelect={() => { + const text = window.getSelection()?.toString(); + if (text) { + navigator.clipboard.writeText(text); + } + }} + > + Copy + </ContextMenu.Item> + <ContextMenu.Separator /> + <ContextMenu.Item onSelect={() => setShowRawLogs(!showRawLogs)}> + {showRawLogs ? "Back to conversation" : "Show raw logs"} + </ContextMenu.Item> + </ContextMenu.Content> + </ContextMenu.Root> + ); +} 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..06a15492a7 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 type { AcpMessage } from "@posthog/shared"; +import { makeAttachmentUri } from "@posthog/core/sessions/promptContent"; 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/packages/ui/src/features/sessions/components/session-update/FileMentionChip.tsx b/packages/ui/src/features/sessions/components/session-update/FileMentionChip.tsx new file mode 100644 index 0000000000..98fcb927a0 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/FileMentionChip.tsx @@ -0,0 +1,100 @@ +import { isAbsolutePath } from "@posthog/shared"; +import { Flex, Text } from "@radix-ui/themes"; +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 { + filePath: string; +} + +function toRelativePath(absolutePath: string, repoPath: string | null): string { + if (!absolutePath) return absolutePath; + if (!repoPath) return absolutePath; + const normalizedRepo = repoPath.endsWith("/") + ? repoPath.slice(0, -1) + : repoPath; + if (absolutePath.startsWith(`${normalizedRepo}/`)) { + return absolutePath.slice(normalizedRepo.length + 1); + } + if (absolutePath === normalizedRepo) { + return ""; + } + return absolutePath; +} + +export const FileMentionChip = memo(function FileMentionChip({ + filePath, +}: FileMentionChipProps) { + const taskId = useSessionTaskId(); + 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; + + const handleClick = useCallback(() => { + if (!taskId) return; + const relativePath = toRelativePath(filePath, repoPath ?? null); + openFileInSplit(taskId, relativePath, true); + }, [taskId, filePath, repoPath, openFileInSplit]); + + const handleContextMenu = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + const absolutePath = isAbsolutePath(filePath) + ? filePath + : repoPath + ? `${repoPath}/${filePath}` + : filePath; + + await openForFile({ + absolutePath, + filename, + workspace, + mainRepoPath, + }); + }, + [filePath, repoPath, filename, workspace, mainRepoPath, openForFile], + ); + + const isClickable = !!taskId; + + const relativePath = toRelativePath(filePath, repoPath ?? null); + const directory = + relativePath && relativePath !== filename + ? relativePath.replace(`/${filename}`, "") + : null; + + return ( + <Flex + align="center" + gap="1" + asChild + onClick={isClickable ? handleClick : undefined} + onContextMenu={handleContextMenu} + className={`relative top-[1px] inline-flex min-w-0 max-w-full ${isClickable ? "cursor-pointer hover:underline" : ""}`} + > + <Text className="text-[13px]"> + <FileIcon filename={filename} size={12} /> + <span className="flex min-w-0 flex-1 items-baseline gap-1 overflow-hidden font-mono text-[13px] leading-none"> + <span className="flex-shrink-0 whitespace-nowrap font-semibold"> + {filename} + </span> + {directory && ( + <span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-gray-9"> + {directory} + </span> + )} + </span> + </Text> + </Flex> + ); +}); 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/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx new file mode 100644 index 0000000000..73c1256049 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx @@ -0,0 +1,146 @@ +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 type { ConversationItem } from "../buildConversationItems"; +import { ToolCallBlock } from "./ToolCallBlock"; + +export type RenderItem = + | SessionUpdate + | { + sessionUpdate: "console"; + level: string; + message: string; + timestamp?: string; + } + | { + sessionUpdate: "compact_boundary"; + trigger: "manual" | "auto"; + preTokens: number; + contextSize?: number; + } + | { + sessionUpdate: "status"; + status: string; + isComplete?: boolean; + } + | { + sessionUpdate: "error"; + errorType: string; + message: string; + } + | { + sessionUpdate: "task_notification"; + taskId: string; + status: "completed" | "failed" | "stopped"; + summary: string; + outputFile: string; + } + | { + sessionUpdate: "progress_group"; + steps: Step[]; + isActive: boolean; + }; + +interface SessionUpdateViewProps { + item: RenderItem; + toolCalls?: Map<string, ToolCall>; + childItems?: Map<string, ConversationItem[]>; + turnCancelled?: boolean; + turnComplete?: boolean; + thoughtComplete?: boolean; +} + +export const SessionUpdateView = memo(function SessionUpdateView({ + item, + toolCalls, + childItems, + turnCancelled, + turnComplete, + thoughtComplete, +}: SessionUpdateViewProps) { + switch (item.sessionUpdate) { + case "user_message_chunk": + return null; + case "agent_message_chunk": + return item.content.type === "text" ? ( + <AgentMessage content={item.content.text} /> + ) : null; + case "agent_thought_chunk": + return item.content.type === "text" ? ( + <ThoughtView content={item.content.text} isLoading={!thoughtComplete} /> + ) : null; + case "tool_call": + return ( + <ToolCallBlock + toolCall={toolCalls?.get(item.toolCallId) ?? item} + turnCancelled={turnCancelled} + turnComplete={turnComplete} + childItems={childItems?.get(item.toolCallId)} + childItemsMap={childItems} + /> + ); + case "tool_call_update": + return null; + case "plan": + return null; + case "available_commands_update": + return null; + case "config_option_update": + return null; + case "console": + return ( + <ConsoleMessage + level={item.level as "info" | "debug" | "warn" | "error"} + message={item.message} + timestamp={item.timestamp} + /> + ); + case "compact_boundary": + return ( + <CompactBoundaryView + trigger={item.trigger} + preTokens={item.preTokens} + contextSize={item.contextSize} + /> + ); + case "status": + return ( + <StatusNotificationView + status={item.status} + isComplete={item.isComplete} + /> + ); + case "error": + return ( + <ErrorNotificationView + errorType={item.errorType} + message={item.message} + /> + ); + case "task_notification": + return ( + <TaskNotificationView status={item.status} summary={item.summary} /> + ); + case "progress_group": + return ( + <ProgressGroupView + steps={item.steps} + isActive={item.isActive} + turnComplete={turnComplete} + /> + ); + default: + return null; + } +}); 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..3bcb176c79 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,7 @@ -import type { CodeToolKind, ToolCall } from "@features/sessions/types"; +import type { CodeToolKind, ToolCall } from "@posthog/ui/features/sessions/types"; import { toolInfoFromToolUse } from "@posthog/agent/adapters/claude/conversion/tool-use-to-acp"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ToolCallBlock } from "./ToolCallBlock"; +import { ToolCallBlock } from "@posthog/ui/features/sessions/components/session-update/ToolCallBlock"; function buildToolCallData( toolName: string, diff --git a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx new file mode 100644 index 0000000000..877d445f80 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx @@ -0,0 +1,129 @@ +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 type { ConversationItem, TurnContext } from "../buildConversationItems"; +import { + MCP_TOOL_BLOCK_COMPONENT, + type McpToolBlockComponent, +} from "./identifiers"; +import { SubagentToolView } from "./SubagentToolView"; + +interface ToolCallBlockProps extends ToolViewProps { + childItems?: ConversationItem[]; + childItemsMap?: Map<string, ConversationItem[]>; +} + +export function ToolCallBlock({ + toolCall, + turnCancelled, + turnComplete, + childItems, + childItemsMap, +}: ToolCallBlockProps) { + const McpToolBlock = useServiceOptional<McpToolBlockComponent>( + MCP_TOOL_BLOCK_COMPONENT, + ); + const meta = toolCall._meta as + | { claudeCode?: { toolName?: string } } + | undefined; + const toolName = meta?.claudeCode?.toolName; + + if (toolName === "EnterPlanMode") { + return null; + } + + const props = { toolCall, turnCancelled, turnComplete }; + + if ( + (toolName === "Task" || toolName === "Agent") && + childItems && + childItems.length > 0 + ) { + const turnContext: TurnContext = { + toolCalls: buildChildToolCallsMap(childItems), + childItems: childItemsMap ?? new Map(), + turnCancelled: turnCancelled ?? false, + turnComplete: turnComplete ?? false, + }; + return ( + <Box className="pl-3"> + <SubagentToolView + {...props} + childItems={childItems} + turnContext={turnContext} + /> + </Box> + ); + } + + if (toolName?.startsWith("mcp__")) { + return ( + <Box className="pl-3"> + {McpToolBlock ? ( + <McpToolBlock {...props} mcpToolName={toolName} /> + ) : ( + <ToolCallView {...props} agentToolName={toolName} /> + )} + </Box> + ); + } + + const content = (() => { + switch (toolCall.kind) { + case "switch_mode": + return <PlanApprovalView {...props} />; + case "execute": + return <ExecuteToolView {...props} />; + case "read": + return <ReadToolView {...props} />; + case "edit": + return <EditToolView {...props} />; + case "delete": + return <DeleteToolView {...props} />; + case "move": + return <MoveToolView {...props} />; + case "search": + return <SearchToolView {...props} />; + case "think": + return <ThinkToolView {...props} />; + case "fetch": + return <FetchToolView {...props} />; + case "question": + return <QuestionToolView {...props} />; + default: + return <ToolCallView {...props} agentToolName={toolName} />; + } + })(); + + return <Box className="pl-3">{content}</Box>; +} + +function buildChildToolCallsMap( + childItems: ConversationItem[], +): Map<string, ToolCall> { + const map = new Map<string, ToolCall>(); + for (const item of childItems) { + if ( + item.type === "session_update" && + item.update.sessionUpdate === "tool_call" + ) { + const tc = item.update as unknown as ToolCall; + if (tc.toolCallId) { + map.set(tc.toolCallId, tc); + } + } + } + return map; +} 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..a0f31baa31 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 { isAgentVersion } from "@posthog/ui/utils/agentVersion"; +import { useSessionStore } from "@posthog/ui/features/sessions/sessionStore"; /** * 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/packages/ui/src/features/sessions/hooks/useChatTitleGenerator.ts b/packages/ui/src/features/sessions/hooks/useChatTitleGenerator.ts new file mode 100644 index 0000000000..724e81e05a --- /dev/null +++ b/packages/ui/src/features/sessions/hooks/useChatTitleGenerator.ts @@ -0,0 +1,172 @@ +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 "@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"); + +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); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated" && !!state.cloudRegion, + ); + + const promptCount = useSessionStore((state) => { + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return 0; + const session = state.sessions[taskRunId]; + if (!session?.events) return 0; + return extractUserPromptsFromEvents(session.events).length; + }); + + useEffect(() => { + if (!isAuthenticated) return; + if (isGenerating.current) return; + + const { shouldGenerateFromPrompts, shouldGenerateFromTaskDescription } = + decideTitleGeneration({ + promptCount, + lastGeneratedAtCount: lastGeneratedAtCount.current, + initialDescriptionHandled: initialDescriptionHandled.current, + task, + }); + + if (!shouldGenerateFromPrompts && !shouldGenerateFromTaskDescription) { + return; + } + + isGenerating.current = true; + + const state = useSessionStore.getState(); + const taskRunId = state.taskIdIndex[taskId]; + const session = taskRunId ? state.sessions[taskRunId] : undefined; + let rawContent = task.description; + + if (shouldGenerateFromPrompts) { + if (!session?.events) { + isGenerating.current = false; + return; + } + + const allPrompts = extractUserPromptsFromEvents(session.events); + const promptsForTitle = selectPromptsForTitle(allPrompts, promptCount); + + rawContent = formatPromptsForTitleInput(promptsForTitle); + } + + const run = async () => { + try { + const content = + await titleGenerator.enrichDescriptionWithFileContent(rawContent); + const result = await titleGenerator.generateTitleAndSummary(content); + if (result) { + const { title, summary } = result; + const titleLocked = isAutoTitleLocked( + getCachedTask(queryClient, taskId) ?? task, + ); + + if (title && titleLocked) { + log.debug("Skipping auto-title, user renamed task", { taskId }); + } else if (title) { + if (client) { + await client.updateTask(taskId, { title }); + queryClient.setQueriesData<Task[]>( + { queryKey: taskKeys.lists() }, + (old) => + old?.map((task) => + task.id === taskId ? { ...task, title } : task, + ), + ); + queryClient.setQueriesData<Schemas.TaskSummary[]>( + { queryKey: taskKeys.allSummaries() }, + (old) => + old?.map((task) => + task.id === taskId ? { ...task, title } : task, + ), + ); + queryClient.setQueryData<Task>(taskKeys.detail(taskId), (old) => + old ? { ...old, title } : old, + ); + sessionService.updateSessionTaskTitle(taskId, title); + log.debug("Updated task title from conversation", { + taskId, + promptCount, + }); + } + } + + if (summary && taskRunId) { + sessionStoreSetters.updateSession(taskRunId, { + conversationSummary: result.summary, + }); + + log.debug("Updated task summary from conversation", { + taskId, + promptCount, + }); + } + } + } catch (error) { + log.error("Failed to update task title", { taskId, error }); + } finally { + if (shouldGenerateFromPrompts) { + lastGeneratedAtCount.current = promptCount; + } + if (shouldGenerateFromTaskDescription) { + initialDescriptionHandled.current = true; + } + isGenerating.current = false; + } + }; + + run(); + }, [ + 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/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts new file mode 100644 index 0000000000..eb127e1b20 --- /dev/null +++ b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts @@ -0,0 +1,208 @@ +import { + combineQueuedCloudPrompts, + promptToQueuedEditorContent, +} 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"); + +interface UseSessionCallbacksOptions { + taskId: string; + task: Task; + session: AgentSession | undefined; + repoPath: string | null; +} + +export function useSessionCallbacks({ + taskId, + task, + 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); + + const sessionRef = useRef(session); + sessionRef.current = session; + + const handleSendPrompt = useCallback( + async (text: string) => { + const currentSession = sessionRef.current; + const currentEvents = currentSession?.events ?? []; + const handled = await tryExecuteCodeCommand(text, { + taskId, + repoPath, + session: currentSession + ? { + taskRunId: currentSession.taskRunId, + logUrl: currentSession.logUrl, + events: currentEvents, + } + : null, + taskRun: task.latest_run ?? null, + }); + if (handled) return; + + try { + markAsViewed(taskId); + markActivity(taskId); + await sessionService.sendPrompt(taskId, text); + + const view = useNavigationStore.getState().view; + const isViewingTask = + view?.type === "task-detail" && view?.data?.id === taskId; + if (isViewingTask) { + markAsViewed(taskId); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to send message"; + toast.error(message); + log.error("Failed to send prompt", error); + } + }, + [ + taskId, + repoPath, + markActivity, + markAsViewed, + task.latest_run, + sessionService, + ], + ); + + const handleCancelPrompt = useCallback(async () => { + const queuedMessages = sessionStoreSetters.dequeueMessages(taskId); + const result = await sessionService.cancelPrompt(taskId); + log.info("Prompt cancelled", { success: result }); + + const queuedPrompt = sessionRef.current?.isCloud + ? combineQueuedCloudPrompts(queuedMessages) + : queuedMessages.map((message) => message.content).join("\n\n"); + + if (queuedPrompt) { + const pendingContent = sessionRef.current?.isCloud + ? promptToQueuedEditorContent(queuedPrompt) + : { + segments: [ + { + type: "text" as const, + text: typeof queuedPrompt === "string" ? queuedPrompt : "", + }, + ], + }; + + setPendingContent(taskId, pendingContent); + } + requestFocus(taskId); + }, [taskId, setPendingContent, requestFocus, sessionService]); + + const handleRetry = useCallback(async () => { + try { + if (sessionRef.current?.isCloud) { + await sessionService.retryCloudTaskWatch(taskId); + return; + } + + if (!repoPath) return; + 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, sessionService]); + + const handleNewSession = useCallback(async () => { + if (!repoPath) return; + try { + 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, sessionService]); + + const handleBashCommand = useCallback( + async (command: string) => { + if (!repoPath) return; + + const execId = `user-shell-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + await sessionService.startUserShellExecute( + taskId, + execId, + command, + repoPath, + ); + + try { + const result = await shellClient.execute({ + cwd: repoPath, + command, + }); + await sessionService.completeUserShellExecute( + taskId, + execId, + command, + repoPath, + result, + ); + } catch (error) { + log.error("Failed to execute shell command", error); + await sessionService.completeUserShellExecute( + taskId, + execId, + command, + repoPath, + { + stdout: "", + stderr: error instanceof Error ? error.message : "Command failed", + exitCode: 1, + }, + ); + } + }, + [taskId, repoPath, sessionService, shellClient], + ); + + const initiateHandoffToCloud = useCallback(async () => { + if (!repoPath) return; + try { + 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, sessionService]); + + return { + handleSendPrompt, + handleCancelPrompt, + handleRetry, + handleNewSession, + handleBashCommand, + initiateHandoffToCloud, + }; +} 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/packages/ui/src/features/sessions/sendPromptToAgent.ts b/packages/ui/src/features/sessions/sendPromptToAgent.ts new file mode 100644 index 0000000000..03c8b8560e --- /dev/null +++ b/packages/ui/src/features/sessions/sendPromptToAgent.ts @@ -0,0 +1,35 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +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 + * panel to split mode if expanded, and switches to the logs/chat tab. + */ +export function sendPromptToAgent( + taskId: string, + prompt: string | ContentBlock[], +): void { + resolveService<AgentPromptSender>(AGENT_PROMPT_SENDER)(taskId, prompt); + + const { getReviewMode, setReviewMode } = useReviewNavigationStore.getState(); + if (getReviewMode(taskId) === "expanded") { + setReviewMode(taskId, "split"); + } + + const { taskLayouts, setActiveTab } = usePanelLayoutStore.getState(); + const layout = taskLayouts[taskId]; + if (layout) { + const result = findTabInTree(layout.panelTree, DEFAULT_TAB_IDS.LOGS); + if (result) { + setActiveTab(taskId, result.panelId, DEFAULT_TAB_IDS.LOGS); + } + } +} 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/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/packages/ui/src/features/sessions/sessionStore.ts b/packages/ui/src/features/sessions/sessionStore.ts new file mode 100644 index 0000000000..9c6f840083 --- /dev/null +++ b/packages/ui/src/features/sessions/sessionStore.ts @@ -0,0 +1,339 @@ +import type { + ContentBlock, + SessionConfigOption, +} from "@agentclientprotocol/sdk"; +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"; + +// --- Types --- + +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 */ + sessions: Record<string, AgentSession>; + /** Index mapping taskId -> taskRunId for O(1) lookups */ + taskIdIndex: Record<string, string>; +} + +// --- Store --- + +export const useSessionStore = create<SessionState>()( + immer(() => ({ + sessions: {}, + taskIdIndex: {}, + })), +); + +// --- Re-exports --- + +export { + getAvailableCommandsForTask, + getPendingPermissionsForTask, + getUserPromptsForTask, + useAdapterForTask, + useAvailableCommandsForTask, + useConfigOptionForTask, + useModeConfigOptionForTask, + useModelConfigOptionForTask, + useOptimisticItemsForTask, + usePendingPermissionsForTask, + useQueuedMessagesForTask, + useSessionForTask, + useSessions, + useThoughtLevelConfigOptionForTask, +} from "./useSession"; + +// --- Setters --- + +export const sessionStoreSetters = { + setSession: (session: AgentSession) => { + useSessionStore.setState((state) => { + // Clean up old session if taskId already has a different taskRunId + const existingTaskRunId = state.taskIdIndex[session.taskId]; + if (existingTaskRunId && existingTaskRunId !== session.taskRunId) { + delete state.sessions[existingTaskRunId]; + } + + state.sessions[session.taskRunId] = session; + state.taskIdIndex[session.taskId] = session.taskRunId; + }); + }, + + removeSession: (taskRunId: string) => { + useSessionStore.setState((state) => { + const session = state.sessions[taskRunId]; + if (session) { + delete state.taskIdIndex[session.taskId]; + } + delete state.sessions[taskRunId]; + }); + }, + + updateSession: (taskRunId: string, updates: Partial<AgentSession>) => { + useSessionStore.setState((state) => { + if (state.sessions[taskRunId]) { + Object.assign(state.sessions[taskRunId], updates); + } + }); + }, + + appendEvents: ( + taskRunId: string, + events: AcpMessage[], + newLineCount?: number, + ) => { + useSessionStore.setState((state) => { + const session = state.sessions[taskRunId]; + if (session) { + session.events.push(...events); + if (newLineCount !== undefined) { + session.processedLineCount = newLineCount; + } + } + }); + }, + + updateCloudStatus: ( + taskRunId: string, + fields: { + status?: TaskRunStatus; + stage?: string | null; + output?: Record<string, unknown> | null; + errorMessage?: string | null; + branch?: string | null; + }, + ) => { + useSessionStore.setState((state) => { + const session = state.sessions[taskRunId]; + if (!session) return; + if (fields.status !== undefined) session.cloudStatus = fields.status; + if (fields.stage !== undefined) session.cloudStage = fields.stage; + if (fields.output !== undefined) session.cloudOutput = fields.output; + if (fields.errorMessage !== undefined) + session.cloudErrorMessage = fields.errorMessage; + if (fields.branch !== undefined) session.cloudBranch = fields.branch; + }); + }, + + setPendingPermissions: ( + taskRunId: string, + permissions: Map<string, PermissionRequest>, + ) => { + useSessionStore.setState((state) => { + if (state.sessions[taskRunId]) { + state.sessions[taskRunId].pendingPermissions = permissions; + } + }); + }, + + enqueueMessage: ( + taskId: string, + content: string, + rawPrompt?: string | ContentBlock[], + ) => { + const id = `queue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + useSessionStore.setState((state) => { + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return; + + const session = state.sessions[taskRunId]; + if (session) { + session.messageQueue.push({ + id, + content, + rawPrompt, + queuedAt: Date.now(), + }); + } + }); + }, + + removeQueuedMessage: (taskId: string, messageId: string) => { + useSessionStore.setState((state) => { + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return; + const session = state.sessions[taskRunId]; + if (session) { + session.messageQueue = session.messageQueue.filter( + (msg) => msg.id !== messageId, + ); + } + }); + }, + + clearMessageQueue: (taskId: string) => { + useSessionStore.setState((state) => { + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return; + + const session = state.sessions[taskRunId]; + if (session) { + session.messageQueue = []; + } + }); + }, + + dequeueMessagesAsText: (taskId: string): string | null => { + // Read the queue from the frozen committed state BEFORE entering the + // immer draft — same rationale as `dequeueMessages`: anything captured + // through a draft proxy can be revoked when setState exits. + const state = useSessionStore.getState(); + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return null; + const session = state.sessions[taskRunId]; + if (!session || session.messageQueue.length === 0) return null; + + const combined = session.messageQueue + .map((msg) => msg.content) + .join("\n\n"); + useSessionStore.setState((draft) => { + const trid = draft.taskIdIndex[taskId]; + if (!trid) return; + const draftSession = draft.sessions[trid]; + if (draftSession) draftSession.messageQueue = []; + }); + return combined; + }, + + dequeueMessages: (taskId: string): QueuedMessage[] => { + // Read the queue from the frozen committed state BEFORE entering the + // immer draft, otherwise the items returned are proxies that get + // revoked when setState exits and any later access throws + // "Cannot perform 'get' on a proxy that has been revoked". + const state = useSessionStore.getState(); + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return []; + const session = state.sessions[taskRunId]; + if (!session || session.messageQueue.length === 0) return []; + + const queuedMessages = [...session.messageQueue]; + + useSessionStore.setState((draft) => { + const trid = draft.taskIdIndex[taskId]; + if (!trid) return; + const draftSession = draft.sessions[trid]; + if (draftSession) { + draftSession.messageQueue = []; + } + }); + + return queuedMessages; + }, + + /** + * Splice messages back at the head of the queue. Used to roll back a + * dispatch attempt that drained the queue but failed before delivery. + */ + prependQueuedMessages: (taskId: string, messages: QueuedMessage[]) => { + if (messages.length === 0) return; + useSessionStore.setState((state) => { + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return; + const session = state.sessions[taskRunId]; + if (!session) return; + session.messageQueue = [...messages, ...session.messageQueue]; + }); + }, + + appendOptimisticItem: ( + taskRunId: string, + item: OptimisticItem extends infer T + ? T extends { id: string } + ? Omit<T, "id"> + : never + : never, + ): void => { + const id = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + useSessionStore.setState((state) => { + const session = state.sessions[taskRunId]; + if (session) { + session.optimisticItems.push({ ...item, id } as OptimisticItem); + } + }); + }, + + clearOptimisticItems: (taskRunId: string): void => { + useSessionStore.setState((state) => { + const session = state.sessions[taskRunId]; + if (session) { + session.optimisticItems = []; + } + }); + }, + + clearTailOptimisticItems: (taskRunId: string): void => { + useSessionStore.setState((state) => { + const session = state.sessions[taskRunId]; + if (session) { + session.optimisticItems = session.optimisticItems.filter( + (item) => item.type !== "user_message" || item.pinToTop !== false, + ); + } + }); + }, + + replaceOptimisticWithEvent: (taskRunId: string, event: AcpMessage): void => { + useSessionStore.setState((state) => { + const session = state.sessions[taskRunId]; + if (session) { + session.events.push(event); + session.optimisticItems = []; + } + }); + }, + + /** O(1) lookup using taskIdIndex */ + getSessionByTaskId: (taskId: string): AgentSession | undefined => { + const state = useSessionStore.getState(); + const taskRunId = state.taskIdIndex[taskId]; + if (!taskRunId) return undefined; + return state.sessions[taskRunId]; + }, + + getSessions: (): Record<string, AgentSession> => { + return useSessionStore.getState().sessions; + }, + + clearAll: () => { + useSessionStore.setState((state) => { + state.sessions = {}; + state.taskIdIndex = {}; + }); + }, +}; 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..964046cb4a 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 { describe, expect, it } from "vitest"; -import { extractSearchableText } from "./extractSearchableText"; +import { extractSearchableText } from "@posthog/ui/features/sessions/utils/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/packages/ui/src/features/settings/sections/AdvancedSettings.tsx b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx new file mode 100644 index 0000000000..632fdd180f --- /dev/null +++ b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx @@ -0,0 +1,67 @@ +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"; + +export function AdvancedSettings() { + const showDebugLogsToggle = + useFeatureFlag("posthog-code-background-agent-logs") || import.meta.env.DEV; + const debugLogsCloudRuns = useSettingsStore((s) => s.debugLogsCloudRuns); + const setDebugLogsCloudRuns = useSettingsStore( + (s) => s.setDebugLogsCloudRuns, + ); + + return ( + <Flex direction="column"> + <SettingRow + label="Reset onboarding and tours" + description="Re-run the onboarding tutorial and product tours on next app restart" + > + <Button + variant="soft" + size="1" + onClick={() => { + useSettingsDialogStore.getState().close(); + useOnboardingStore.getState().resetOnboarding(); + useSetupStore.getState().resetSetup(); + useTourStore.getState().resetTours(); + }} + > + Reset + </Button> + </SettingRow> + <SettingRow + label="Clear application storage" + description="This will remove all locally stored application data" + noBorder={!showDebugLogsToggle} + > + <Button + variant="soft" + color="red" + size="1" + onClick={clearApplicationStorage} + > + Clear all data + </Button> + </SettingRow> + {showDebugLogsToggle && ( + <SettingRow + label="Debug logs for cloud runs" + description="Show debug-level console output in the conversation view for cloud-executed runs" + noBorder + > + <Switch + checked={debugLogsCloudRuns} + onCheckedChange={setDebugLogsCloudRuns} + size="1" + /> + </SettingRow> + )} + </Flex> + ); +} 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/packages/ui/src/features/settings/sections/WorkspacesSettings.tsx b/packages/ui/src/features/settings/sections/WorkspacesSettings.tsx new file mode 100644 index 0000000000..bb52bdf303 --- /dev/null +++ b/packages/ui/src/features/settings/sections/WorkspacesSettings.tsx @@ -0,0 +1,143 @@ +import { Folder, X } from "@phosphor-icons/react"; +import { useHostTRPCClient } from "@posthog/host-router/react"; +import { Button } from "@posthog/quill"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +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 hostClient = useHostTRPCClient(); + const queryClient = useQueryClient(); + const [localWorktreeLocation, setLocalWorktreeLocation] = + useState<string>(""); + + const { data: worktreeLocation } = useQuery({ + queryKey: ["settings", "worktreeLocation"], + queryFn: async () => + (await hostClient.secureStore.getItem.query({ + key: "worktreeLocation", + })) ?? null, + }); + + useEffect(() => { + if (worktreeLocation) { + setLocalWorktreeLocation(worktreeLocation); + } + }, [worktreeLocation]); + + const handleWorktreeLocationChange = async (newLocation: string) => { + setLocalWorktreeLocation(newLocation); + try { + await hostClient.secureStore.setItem.query({ + key: "worktreeLocation", + value: newLocation, + }); + } catch (error) { + log.error("Failed to set worktree location:", error); + } + }; + + const defaultsQuery = useQuery({ + queryKey: DEFAULT_DIRECTORIES_QUERY_KEY, + queryFn: () => hostClient.additionalDirectories.listDefaults.query(), + }); + const defaults = defaultsQuery.data ?? []; + + const invalidateDefaults = () => + queryClient.invalidateQueries({ + queryKey: DEFAULT_DIRECTORIES_QUERY_KEY, + }); + + 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 hostClient.os.selectDirectory.query(); + if (path) { + await addMutation.mutateAsync(path); + } + } catch (err) { + log.error("Failed to add default directory", err); + toast.error("Failed to open folder picker"); + } + }; + + return ( + <div className="flex flex-col"> + <SettingRow + label="Workspace location" + description="Directory where isolated workspaces are created for each task" + > + <div className="min-w-[200px]"> + <FolderPicker + value={localWorktreeLocation} + onChange={handleWorktreeLocationChange} + placeholder="~/.posthog-code" + /> + </div> + </SettingRow> + <div className="flex flex-col gap-2 py-4"> + <p className="font-medium text-sm">Default folders for new chats</p> + <p className="text-(--gray-11) text-[13px]"> + Folders the agent can access in every new chat on your device. + </p> + <div className="mt-1 flex flex-col gap-2"> + {defaults.length === 0 && ( + <p className="text-(--gray-11) text-[12px]">No default folders.</p> + )} + {defaults.map((path) => ( + <div + key={path} + className="flex min-w-0 items-center gap-2 rounded-(--radius-2) border border-(--gray-5) bg-(--gray-2) px-2 py-1" + > + <Folder size={12} className="shrink-0 text-(--gray-11)" /> + <span + className="min-w-0 flex-1 truncate text-[12px]" + title={path} + > + {path} + </span> + <button + type="button" + aria-label={`Remove ${path}`} + className="cursor-pointer p-0 opacity-60 hover:opacity-100" + onClick={() => removeMutation.mutate(path)} + > + <X size={12} /> + </button> + </div> + ))} + <div> + <Button + size="sm" + variant="outline" + onClick={handleAddDefaultDirectory} + > + Add folder… + </Button> + </div> + </div> + </div> + </div> + ); +} 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/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx b/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx new file mode 100644 index 0000000000..fad3dfdda7 --- /dev/null +++ b/packages/ui/src/features/settings/sections/worktrees/WorktreesSettings.tsx @@ -0,0 +1,210 @@ +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 { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo, useState } from "react"; +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 queryClient = useQueryClient(); + const trpc = useHostTRPC(); + const hostClient = useHostTRPCClient(); + const { settings, updateSettings } = useSuspensionSettings(); + const { mutateAsync: deleteTask } = useDeleteTask(); + const [deletingWorktrees, setDeletingWorktrees] = useState<Set<string>>( + new Set(), + ); + + const { folders } = useFolders(); + const { data: tasks } = useTasks(); + + const worktreeQueries = useQueries({ + 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( + () => + groupWorktrees( + folders, + worktreeQueries.map((q) => q?.data), + ), + [folders, worktreeQueries], + ); + + const taskMap = useMemo(() => buildTaskMap(tasks), [tasks]); + + const handleDeleteWorktree = useCallback( + async ( + worktreePath: string, + allTaskIds: string[], + existingTaskIds: string[], + folderPath: string, + ) => { + setDeletingWorktrees((prev) => new Set(prev).add(worktreePath)); + + try { + 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 { + setDeletingWorktrees((prev) => { + const next = new Set(prev); + next.delete(worktreePath); + return next; + }); + } + }, + [hostClient, trpc, deleteTask, queryClient], + ); + + const commitNumericField = useCallback( + ( + e: + | React.FocusEvent<HTMLInputElement> + | React.KeyboardEvent<HTMLInputElement>, + field: "maxActiveWorktrees" | "autoSuspendAfterDays", + fallback: number, + ) => { + const input = e.currentTarget; + const val = parseWorktreeLimit(input.value); + const labels: Record<string, string> = { + maxActiveWorktrees: "Max active worktrees", + autoSuspendAfterDays: "Auto-suspend days", + }; + if (val !== null) { + updateSettings({ [field]: val }); + toast.success(`${labels[field]} updated to ${val}`); + } else { + input.value = String(settings?.[field] ?? fallback); + } + }, + [settings, updateSettings], + ); + + const isLoading = worktreeQueries.some((q) => q.isLoading); + + return ( + <Flex direction="column" gap="5"> + <Flex direction="column"> + <SettingRow + label="Automatically suspend stale worktrees" + description="Suspend stale worktrees to save disk space. Suspended worktrees can be restored at any time. Only disable if you prefer to manage worktrees manually." + > + <Switch + checked={settings.autoSuspendEnabled} + onCheckedChange={(checked) => + updateSettings({ autoSuspendEnabled: checked }) + } + size="1" + /> + </SettingRow> + <SettingRow + label="Max active worktrees" + description="When this limit is reached, the least recently active worktree will be automatically suspended" + > + <TextField.Root + key={`max-${settings.maxActiveWorktrees}`} + type="number" + size="1" + min={1} + disabled={!settings.autoSuspendEnabled} + defaultValue={settings.maxActiveWorktrees} + onBlur={(e) => commitNumericField(e, "maxActiveWorktrees", 5)} + onKeyDown={(e) => { + if (e.key === "Enter") + commitNumericField(e, "maxActiveWorktrees", 5); + }} + className="w-[64px]" + /> + </SettingRow> + <SettingRow + label="Auto-suspend after inactivity" + description="Suspend worktrees with no activity for this many days" + noBorder + > + <TextField.Root + key={`days-${settings.autoSuspendAfterDays}`} + type="number" + size="1" + min={1} + disabled={!settings.autoSuspendEnabled} + defaultValue={settings.autoSuspendAfterDays} + onBlur={(e) => commitNumericField(e, "autoSuspendAfterDays", 7)} + onKeyDown={(e) => { + if (e.key === "Enter") + commitNumericField(e, "autoSuspendAfterDays", 7); + }} + className="w-[64px]" + /> + </SettingRow> + </Flex> + + {isLoading ? ( + <Text color="gray" className="text-sm"> + Loading worktrees... + </Text> + ) : worktreeGroups.length === 0 ? ( + <Text color="gray" className="text-[13px]"> + Tasks that are run in a worktree will show up here. + </Text> + ) : ( + worktreeGroups.map((group) => ( + <WorktreeGroupSection + key={group.folderPath} + group={group} + taskMap={taskMap} + deletingWorktrees={deletingWorktrees} + onDelete={handleDeleteWorktree} + /> + )) + )} + </Flex> + ); +} 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/packages/ui/src/features/settings/settingsStore.test.ts b/packages/ui/src/features/settings/settingsStore.test.ts new file mode 100644 index 0000000000..a220df98cd --- /dev/null +++ b/packages/ui/src/features/settings/settingsStore.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getItem, setItem, removeItem } = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), +})); + +vi.mock("@posthog/di/container", () => ({ + resolveService: () => ({ getItem, setItem, removeItem }), +})); + +import { useSettingsStore } from "./settingsStore"; + +describe("feature settingsStore cloud selections", () => { + beforeEach(() => { + getItem.mockReset(); + setItem.mockReset(); + removeItem.mockReset(); + getItem.mockResolvedValue(null); + setItem.mockResolvedValue(undefined); + removeItem.mockResolvedValue(undefined); + + useSettingsStore.setState({ + allowBypassPermissions: false, + lastUsedCloudRepository: null, + }); + }); + + it("persists the last used cloud repository", async () => { + useSettingsStore.getState().setLastUsedCloudRepository("posthog/posthog"); + + await vi.waitFor(() => { + expect(setItem).toHaveBeenCalled(); + }); + + const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; + const persisted = JSON.parse(lastCall[1]); + + expect(persisted.state.lastUsedCloudRepository).toBe("posthog/posthog"); + }); + + it("rehydrates the last used cloud repository", async () => { + getItem.mockResolvedValue( + JSON.stringify({ + state: { + lastUsedCloudRepository: "posthog/posthog", + }, + version: 0, + }), + ); + + useSettingsStore.setState({ + lastUsedCloudRepository: null, + }); + + await useSettingsStore.persist.rehydrate(); + + expect(useSettingsStore.getState().lastUsedCloudRepository).toBe( + "posthog/posthog", + ); + }); + + it("rehydrates the unsafe mode toggle", async () => { + getItem.mockResolvedValue( + JSON.stringify({ + state: { + allowBypassPermissions: true, + }, + version: 0, + }), + ); + + await useSettingsStore.persist.rehydrate(); + + expect(useSettingsStore.getState().allowBypassPermissions).toBe(true); + }); +}); + +describe("feature settingsStore terminal font", () => { + beforeEach(() => { + getItem.mockReset(); + setItem.mockReset(); + removeItem.mockReset(); + getItem.mockResolvedValue(null); + setItem.mockResolvedValue(undefined); + removeItem.mockResolvedValue(undefined); + + useSettingsStore.setState({ + terminalFont: "berkeley-mono", + terminalCustomFontFamily: "", + }); + }); + + it("defaults to berkeley-mono with no custom override", () => { + expect(useSettingsStore.getState().terminalFont).toBe("berkeley-mono"); + expect(useSettingsStore.getState().terminalCustomFontFamily).toBe(""); + }); + + it("persists terminal font selection and custom family", async () => { + useSettingsStore.getState().setTerminalFont("custom"); + useSettingsStore.getState().setTerminalCustomFontFamily("Fira Code"); + + await vi.waitFor(() => { + expect(setItem).toHaveBeenCalled(); + }); + + const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; + const persisted = JSON.parse(lastCall[1]); + + expect(persisted.state.terminalFont).toBe("custom"); + expect(persisted.state.terminalCustomFontFamily).toBe("Fira Code"); + }); + + it("rehydrates terminal font selection and custom family", async () => { + getItem.mockResolvedValue( + JSON.stringify({ + state: { + terminalFont: "jetbrains-mono", + terminalCustomFontFamily: "Cascadia Code", + }, + version: 0, + }), + ); + + await useSettingsStore.persist.rehydrate(); + + expect(useSettingsStore.getState().terminalFont).toBe("jetbrains-mono"); + expect(useSettingsStore.getState().terminalCustomFontFamily).toBe( + "Cascadia Code", + ); + }); +}); diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts new file mode 100644 index 0000000000..d0894b82ae --- /dev/null +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -0,0 +1,328 @@ +import type { ExecutionMode, WorkspaceMode } from "@posthog/shared"; +import { electronStorage } from "@posthog/ui/workbench/rendererStorage"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +// ---------- Types ---------- + +export type DefaultRunMode = "local" | "cloud" | "last_used"; +export type LocalWorkspaceMode = "worktree" | "local"; +export type AgentAdapter = "claude" | "codex"; +export type DefaultInitialTaskMode = "plan" | "last_used"; +export type DefaultReasoningEffort = + | "low" + | "medium" + | "high" + | "xhigh" + | "max" + | "last_used"; + +export type SendMessagesWith = "enter" | "cmd+enter"; +export type AutoConvertLongText = "off" | "1000" | "2500" | "5000" | "10000"; +export type DiffOpenMode = "auto" | "split" | "same-pane" | "last-active-pane"; + +export type CompletionSound = + | "none" + | "guitar" + | "danilo" + | "revi" + | "meep" + | "meep-smol" + | "bubbles" + | "drop" + | "knock" + | "ring" + | "shoot" + | "slide" + | "switch" + | "wilhelm"; + +export type TerminalFont = + | "berkeley-mono" + | "jetbrains-mono" + | "system" + | "custom"; + +export interface HintState { + count: number; + learned: boolean; +} + +// ---------- Store shape ---------- + +interface SettingsStore { + // Run mode + last-used flow defaults + defaultRunMode: DefaultRunMode; + lastUsedRunMode: "local" | "cloud"; + lastUsedLocalWorkspaceMode: LocalWorkspaceMode; + lastUsedWorkspaceMode: WorkspaceMode; + lastUsedAdapter: AgentAdapter; + lastUsedModel: string | null; + lastUsedReasoningEffort: string | null; + lastUsedCloudRepository: string | null; + lastUsedEnvironments: Record<string, string>; + defaultInitialTaskMode: DefaultInitialTaskMode; + lastUsedInitialTaskMode: ExecutionMode; + defaultReasoningEffort: DefaultReasoningEffort; + setDefaultRunMode: (mode: DefaultRunMode) => void; + setLastUsedRunMode: (mode: "local" | "cloud") => void; + setLastUsedLocalWorkspaceMode: (mode: LocalWorkspaceMode) => void; + setLastUsedWorkspaceMode: (mode: WorkspaceMode) => void; + setLastUsedAdapter: (adapter: AgentAdapter) => void; + setLastUsedModel: (model: string) => void; + setLastUsedReasoningEffort: (effort: string) => void; + setLastUsedCloudRepository: (repo: string | null) => void; + setLastUsedEnvironment: ( + repoPath: string, + environmentId: string | null, + ) => void; + getLastUsedEnvironment: (repoPath: string) => string | null; + setDefaultInitialTaskMode: (mode: DefaultInitialTaskMode) => void; + setLastUsedInitialTaskMode: (mode: ExecutionMode) => void; + setDefaultReasoningEffort: (effort: DefaultReasoningEffort) => void; + + // Notifications + desktopNotifications: boolean; + dockBadgeNotifications: boolean; + dockBounceNotifications: boolean; + completionSound: CompletionSound; + completionVolume: number; + setDesktopNotifications: (enabled: boolean) => void; + setDockBadgeNotifications: (enabled: boolean) => void; + setDockBounceNotifications: (enabled: boolean) => void; + setCompletionSound: (sound: CompletionSound) => void; + setCompletionVolume: (volume: number) => void; + + // Composer / chat + autoConvertLongText: AutoConvertLongText; + sendMessagesWith: SendMessagesWith; + customInstructions: string; + setAutoConvertLongText: (value: AutoConvertLongText) => void; + setSendMessagesWith: (mode: SendMessagesWith) => void; + setCustomInstructions: (instructions: string) => void; + + // Diff viewer + diffOpenMode: DiffOpenMode; + setDiffOpenMode: (mode: DiffOpenMode) => void; + + // System / power / permissions + allowBypassPermissions: boolean; + preventSleepWhileRunning: boolean; + debugLogsCloudRuns: boolean; + setAllowBypassPermissions: (enabled: boolean) => void; + setPreventSleepWhileRunning: (enabled: boolean) => void; + setDebugLogsCloudRuns: (enabled: boolean) => void; + + // Terminal + terminalFont: TerminalFont; + terminalCustomFontFamily: string; + setTerminalFont: (font: TerminalFont) => void; + setTerminalCustomFontFamily: (value: string) => void; + + // Experimental / misc + hedgehogMode: boolean; + mcpAppsDisabledServers: string[]; + setHedgehogMode: (enabled: boolean) => void; + setMcpAppsDisabledServers: (servers: string[]) => void; + + // Onboarding hints + hints: Record<string, HintState>; + shouldShowHint: (key: string, max?: number) => boolean; + recordHintShown: (key: string) => void; + markHintLearned: (key: string) => void; +} + +// ---------- Store ---------- + +export const useSettingsStore = create<SettingsStore>()( + persist( + (set, get) => ({ + // Run mode + last-used flow defaults + defaultRunMode: "last_used", + lastUsedRunMode: "local", + lastUsedLocalWorkspaceMode: "local", + lastUsedWorkspaceMode: "local", + lastUsedAdapter: "claude", + lastUsedModel: null, + lastUsedReasoningEffort: null, + lastUsedCloudRepository: null, + lastUsedEnvironments: {}, + defaultInitialTaskMode: "plan", + lastUsedInitialTaskMode: "plan", + defaultReasoningEffort: "last_used", + setDefaultRunMode: (mode) => set({ defaultRunMode: mode }), + setLastUsedRunMode: (mode) => set({ lastUsedRunMode: mode }), + setLastUsedLocalWorkspaceMode: (mode) => + set({ lastUsedLocalWorkspaceMode: mode }), + setLastUsedWorkspaceMode: (mode) => set({ lastUsedWorkspaceMode: mode }), + setLastUsedAdapter: (adapter) => set({ lastUsedAdapter: adapter }), + setLastUsedModel: (model) => set({ lastUsedModel: model }), + setLastUsedReasoningEffort: (effort) => + set({ lastUsedReasoningEffort: effort }), + setLastUsedCloudRepository: (repo) => + set({ lastUsedCloudRepository: repo }), + setLastUsedEnvironment: (repoPath, environmentId) => + set((state) => { + const next = { ...state.lastUsedEnvironments }; + if (environmentId) { + next[repoPath] = environmentId; + } else { + delete next[repoPath]; + } + return { lastUsedEnvironments: next }; + }), + getLastUsedEnvironment: (repoPath) => + get().lastUsedEnvironments[repoPath] ?? null, + setDefaultInitialTaskMode: (mode) => + set({ defaultInitialTaskMode: mode }), + setLastUsedInitialTaskMode: (mode) => + set({ lastUsedInitialTaskMode: mode }), + setDefaultReasoningEffort: (effort) => + set({ defaultReasoningEffort: effort }), + + // Notifications + desktopNotifications: true, + dockBadgeNotifications: true, + dockBounceNotifications: false, + completionSound: "none", + completionVolume: 80, + setDesktopNotifications: (enabled) => + set({ desktopNotifications: enabled }), + setDockBadgeNotifications: (enabled) => + set({ dockBadgeNotifications: enabled }), + setDockBounceNotifications: (enabled) => + set({ dockBounceNotifications: enabled }), + setCompletionSound: (sound) => set({ completionSound: sound }), + setCompletionVolume: (volume) => set({ completionVolume: volume }), + + // Composer / chat + autoConvertLongText: "2500", + sendMessagesWith: "enter", + customInstructions: "", + setAutoConvertLongText: (value) => set({ autoConvertLongText: value }), + setSendMessagesWith: (mode) => set({ sendMessagesWith: mode }), + setCustomInstructions: (instructions) => + set({ customInstructions: instructions }), + + // Diff viewer + diffOpenMode: "auto", + setDiffOpenMode: (mode) => set({ diffOpenMode: mode }), + + // System / power / permissions + allowBypassPermissions: false, + preventSleepWhileRunning: false, + debugLogsCloudRuns: false, + setAllowBypassPermissions: (enabled) => + set({ allowBypassPermissions: enabled }), + setPreventSleepWhileRunning: (enabled) => + set({ preventSleepWhileRunning: enabled }), + setDebugLogsCloudRuns: (enabled) => set({ debugLogsCloudRuns: enabled }), + + // Terminal + terminalFont: "berkeley-mono", + terminalCustomFontFamily: "", + setTerminalFont: (font) => set({ terminalFont: font }), + setTerminalCustomFontFamily: (value) => + set({ terminalCustomFontFamily: value }), + + // Experimental / misc + hedgehogMode: false, + mcpAppsDisabledServers: [], + setHedgehogMode: (enabled) => set({ hedgehogMode: enabled }), + setMcpAppsDisabledServers: (servers) => + set({ mcpAppsDisabledServers: servers }), + + // Onboarding hints + hints: {}, + shouldShowHint: (key, max = 3) => { + const hint = get().hints[key]; + if (!hint) return true; + return !hint.learned && hint.count < max; + }, + recordHintShown: (key) => + set((state) => { + const current = state.hints[key] ?? { count: 0, learned: false }; + return { + hints: { + ...state.hints, + [key]: { ...current, count: current.count + 1 }, + }, + }; + }), + markHintLearned: (key) => + set((state) => { + const current = state.hints[key] ?? { count: 0, learned: false }; + return { + hints: { + ...state.hints, + [key]: { ...current, learned: true }, + }, + }; + }), + }), + { + name: "settings-storage", + storage: electronStorage, + partialize: (state) => ({ + // Run mode + last-used flow defaults + defaultRunMode: state.defaultRunMode, + lastUsedRunMode: state.lastUsedRunMode, + lastUsedLocalWorkspaceMode: state.lastUsedLocalWorkspaceMode, + lastUsedWorkspaceMode: state.lastUsedWorkspaceMode, + lastUsedAdapter: state.lastUsedAdapter, + lastUsedModel: state.lastUsedModel, + lastUsedReasoningEffort: state.lastUsedReasoningEffort, + lastUsedCloudRepository: state.lastUsedCloudRepository, + lastUsedEnvironments: state.lastUsedEnvironments, + defaultInitialTaskMode: state.defaultInitialTaskMode, + lastUsedInitialTaskMode: state.lastUsedInitialTaskMode, + defaultReasoningEffort: state.defaultReasoningEffort, + + // Notifications + desktopNotifications: state.desktopNotifications, + dockBadgeNotifications: state.dockBadgeNotifications, + dockBounceNotifications: state.dockBounceNotifications, + completionSound: state.completionSound, + completionVolume: state.completionVolume, + + // Composer / chat + autoConvertLongText: state.autoConvertLongText, + sendMessagesWith: state.sendMessagesWith, + customInstructions: state.customInstructions, + + // Diff viewer + diffOpenMode: state.diffOpenMode, + + // System / power / permissions + allowBypassPermissions: state.allowBypassPermissions, + preventSleepWhileRunning: state.preventSleepWhileRunning, + debugLogsCloudRuns: state.debugLogsCloudRuns, + + // Terminal + terminalFont: state.terminalFont, + terminalCustomFontFamily: state.terminalCustomFontFamily, + + // Experimental / misc + hedgehogMode: state.hedgehogMode, + mcpAppsDisabledServers: state.mcpAppsDisabledServers, + + // Onboarding hints + hints: state.hints, + }), + merge: (persisted, current) => { + const merged = { + ...current, + ...(persisted as Partial<SettingsStore>), + }; + if (typeof merged.autoConvertLongText === "boolean") { + (merged as Record<string, unknown>).autoConvertLongText = + merged.autoConvertLongText ? "1000" : "off"; + } + if ((merged.autoConvertLongText as string) === "500") { + (merged as Record<string, unknown>).autoConvertLongText = "1000"; + } + return merged; + }, + }, + ), +); 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/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/packages/ui/src/features/sidebar/components/MainSidebar.tsx b/packages/ui/src/features/sidebar/components/MainSidebar.tsx new file mode 100644 index 0000000000..08a453433e --- /dev/null +++ b/packages/ui/src/features/sidebar/components/MainSidebar.tsx @@ -0,0 +1,51 @@ +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"; + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + +export function MainSidebar() { + const { data: workspaces = {}, isFetched } = useWorkspaces(); + const hasCompletedOnboarding = useOnboardingStore( + (state) => state.hasCompletedOnboarding, + ); + const setOpenAuto = useSidebarStore((state) => state.setOpenAuto); + + useEffect(() => { + if (isFetched) { + const workspaceCount = Object.keys(workspaces).length; + setOpenAuto(hasCompletedOnboarding || workspaceCount > 0); + } + }, [isFetched, workspaces, hasCompletedOnboarding, setOpenAuto]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (isEditableTarget(e.target)) return; + const { selectedTaskIds, clearSelection } = + useTaskSelectionStore.getState(); + if (selectedTaskIds.length === 0) return; + clearSelection(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + return ( + <Box flexShrink="0" className="shrink-0"> + <Sidebar> + <SidebarContent /> + </Sidebar> + </Box> + ); +} 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/packages/ui/src/features/sidebar/components/SidebarContent.tsx b/packages/ui/src/features/sidebar/components/SidebarContent.tsx new file mode 100644 index 0000000000..7f4d031085 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/SidebarContent.tsx @@ -0,0 +1,42 @@ +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 type React from "react"; + +export const SidebarContent: React.FC = () => { + const archivedTaskIds = useArchivedTaskIds(); + const navigateToArchived = useNavigationStore( + (state) => state.navigateToArchived, + ); + return ( + <Flex direction="column" height="100%"> + <Box flexGrow="1" overflow="hidden"> + <SidebarMenu /> + </Box> + <UpdateBanner /> + <SidebarUsageBar /> + {archivedTaskIds.size > 0 && ( + <Box className="shrink-0 border-gray-6 border-t"> + <button + type="button" + className="flex w-full items-center gap-1 bg-transparent px-2 py-1.5 text-left text-[13px] text-gray-11 transition-colors hover:bg-gray-3" + onClick={navigateToArchived} + > + <span className="flex h-[18px] w-[18px] shrink-0 items-center justify-center text-gray-10"> + <ArchiveIcon size={14} /> + </span> + <span className="text-gray-11">Archived</span> + </button> + </Box> + )} + <Box p="2" className="shrink-0 border-gray-6 border-t"> + <ProjectSwitcher /> + </Box> + </Flex> + ); +}; 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/packages/ui/src/features/sidebar/components/SidebarMenu.tsx b/packages/ui/src/features/sidebar/components/SidebarMenu.tsx new file mode 100644 index 0000000000..d7dc501ff4 --- /dev/null +++ b/packages/ui/src/features/sidebar/components/SidebarMenu.tsx @@ -0,0 +1,441 @@ +import { isReportUpForReview } from "@posthog/core/inbox/reportFilters"; +import { + 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 "@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 { useQueryClient } from "@tanstack/react-query"; +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; + +const log = logger.scope("sidebar-menu"); + +function SidebarMenuComponent() { + const { + view, + navigateToTask, + navigateToTaskInput, + navigateToInbox, + navigateToCommandCenter, + navigateToSkills, + navigateToMcpServers, + } = useNavigationStore(); + + // Must mirror useSidebarData's filters so taskMap covers every rendered + // task — otherwise handleTaskClick silently bails for tasks not in the map. + const showAllUsers = useSidebarStore((s) => s.showAllUsers); + const showInternal = useSidebarStore((s) => s.showInternal); + const { data: allTasks = [] } = useTasks({ showAllUsers, showInternal }); + + 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(); + + const sidebarData = useSidebarData({ + activeView: view, + }); + const inboxPollingActive = useRendererWindowFocusStore((s) => s.focused); + const { data: inboxProbe } = useInboxReports( + { status: INBOX_PIPELINE_STATUS_FILTER }, + { + refetchInterval: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : false, + refetchIntervalInBackground: false, + staleTime: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : 15_000, + }, + ); + const inboxResults = inboxProbe?.results ?? []; + const inboxSignalCount = inboxResults.filter(isReportUpForReview).length; + + const taskMap = new Map<string, Task>(); + for (const task of allTasks) { + taskMap.set(task.id, task); + } + + const commandCenterCells = useCommandCenterStore((s) => s.cells); + const assignTaskToCommandCenter = useCommandCenterStore((s) => s.assignTask); + const commandCenterActiveCount = commandCenterCells.filter( + (taskId) => taskId != null && taskMap.has(taskId), + ).length; + + const previousTaskIdRef = useRef<string | null>(null); + + useEffect(() => { + const currentTaskId = + view.type === "task-detail" && view.data ? view.data.id : null; + + if ( + previousTaskIdRef.current && + previousTaskIdRef.current !== currentTaskId + ) { + markAsViewed(previousTaskIdRef.current); + } + + if (currentTaskId) { + markAsViewed(currentTaskId); + } + + previousTaskIdRef.current = currentTaskId; + }, [view, markAsViewed]); + + const handleNewTaskClick = () => { + navigateToTaskInput(); + }; + + const handleInboxClick = () => { + navigateToInbox(); + }; + + const handleCommandCenterClick = () => { + navigateToCommandCenter(); + }; + + const handleSkillsClick = () => { + navigateToSkills(); + }; + + const handleMcpServersClick = () => { + navigateToMcpServers(); + }; + + const openCommandMenu = useCommandMenuStore((s) => s.open); + const handleSearchClick = () => { + openCommandMenu(); + }; + + const queryClient = useQueryClient(); + + const selectedTaskIds = useTaskSelectionStore((s) => s.selectedTaskIds); + const toggleTaskSelection = useTaskSelectionStore( + (s) => s.toggleTaskSelection, + ); + const selectRange = useTaskSelectionStore((s) => s.selectRange); + const clearSelection = useTaskSelectionStore((s) => s.clearSelection); + const pruneSelection = useTaskSelectionStore((s) => s.pruneSelection); + + const organizeMode = useSidebarStore((s) => s.organizeMode); + const collapsedSections = useSidebarStore((s) => s.collapsedSections); + + const allSidebarTasks = useMemo( + () => [...sidebarData.pinnedTasks, ...sidebarData.flatTasks], + [sidebarData.pinnedTasks, sidebarData.flatTasks], + ); + + const allSidebarTaskIds = useMemo( + () => allSidebarTasks.map((t) => t.id), + [allSidebarTasks], + ); + + // Ordered list of currently visible task IDs in display order. Used as the + // 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( + () => + computeOrderedVisibleTaskIds( + sidebarData, + organizeMode, + collapsedSections, + ), + [sidebarData, organizeMode, collapsedSections], + ); + + useEffect(() => { + pruneSelection(allSidebarTaskIds); + }, [allSidebarTaskIds, pruneSelection]); + + // 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( + () => computeEffectiveBulkIds(selectedTaskIds, activeTaskId), + [activeTaskId, selectedTaskIds], + ); + + const handleTaskClick = (taskId: string, e: React.MouseEvent) => { + if (e.shiftKey) { + e.preventDefault(); + selectRange(taskId, orderedVisibleTaskIds, activeTaskId); + return; + } + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + toggleTaskSelection(taskId); + return; + } + + clearSelection(); + const task = taskMap.get(taskId); + if (task) { + navigateToTask(task); + } + }; + + const handleBulkContextMenu = useCallback( + async (e: React.MouseEvent, taskIds: string[]) => { + e.preventDefault(); + e.stopPropagation(); + try { + const result = + await hostClient.contextMenu.showBulkTaskContextMenu.mutate({ + taskCount: taskIds.length, + }); + if (!result.action) return; + if (result.action.type === "archive") { + const outcome = await archiveTasksImperative( + taskIds, + queryClient, + archiveCacheKeys, + ); + clearSelection(); + const { kind, message } = formatArchiveResult(outcome); + if (kind === "success") { + toast.success(message); + } else { + toast.error(message); + } + } + } catch (error) { + log.error("Failed to show bulk context menu", error); + } + }, + [queryClient, clearSelection, hostClient, archiveCacheKeys], + ); + + const handleTaskContextMenu = ( + taskId: string, + e: React.MouseEvent, + isPinned: boolean, + ) => { + // Bulk menu when 2+ tasks are in the effective selection (active + cmd/shift-clicked) + // and the right-clicked task is one of them. Otherwise clear and fall through. + if (effectiveBulkIds.length > 1) { + if (effectiveBulkIds.includes(taskId)) { + handleBulkContextMenu(e, effectiveBulkIds); + return; + } + clearSelection(); + } + + const task = taskMap.get(taskId); + if (task) { + const workspace = workspaces[taskId]; + const taskData = allSidebarTasks.find((t) => t.id === taskId); + const isInCommandCenter = commandCenterCells.some( + (id) => id === taskId && taskMap.has(id), + ); + const hasEmptyCommandCenterCell = commandCenterCells.some( + (id) => id == null || !taskMap.has(id), + ); + + showContextMenu(task, e, { + worktreePath: workspace?.worktreePath ?? undefined, + folderPath: workspace?.folderPath ?? undefined, + isPinned, + isSuspended: taskData?.isSuspended, + isInCommandCenter, + hasEmptyCommandCenterCell, + onTogglePin: () => togglePin(taskId), + onArchivePrior: handleArchivePrior, + onAddToCommandCenter: () => { + const cells = useCommandCenterStore.getState().cells; + const idx = cells.findIndex((id) => id == null || !taskMap.has(id)); + if (idx !== -1) { + assignTaskToCommandCenter(idx, taskId); + navigateToCommandCenter(); + } else { + toast.info("Command center is full"); + } + }, + }); + } + }; + + const handleTaskArchive = async (taskId: string) => { + await archiveTask({ taskId }); + }; + + const handleArchivePrior = useCallback( + async (taskId: string) => { + const allVisible = [...sidebarData.pinnedTasks, ...sidebarData.flatTasks]; + const priorTaskIds = computePriorTaskIds(allVisible, taskId); + + if (priorTaskIds.length === 0) { + toast.info("No older tasks to archive"); + return; + } + + const outcome = await archiveTasksImperative( + priorTaskIds, + queryClient, + archiveCacheKeys, + ); + + const { kind, message } = formatArchiveResult(outcome); + if (kind === "success") { + toast.success(message); + } else { + toast.error(message); + } + }, + [ + sidebarData.pinnedTasks, + sidebarData.flatTasks, + queryClient, + archiveCacheKeys, + ], + ); + const handleTaskDoubleClick = useCallback( + (taskId: string) => { + setEditingTaskId(taskId); + }, + [setEditingTaskId], + ); + + const handleTaskEditSubmit = useCallback( + async (taskId: string, currentTitle: string, newTitle: string) => { + setEditingTaskId(null); + + try { + await renameTask({ + taskId, + currentTitle, + newTitle, + }); + } catch (error) { + log.error("Failed to rename task", error); + } + }, + [renameTask, setEditingTaskId], + ); + + const handleTaskEditCancel = useCallback(() => { + setEditingTaskId(null); + }, [setEditingTaskId]); + + return ( + <Box height="100%" position="relative" id="side-bar-menu"> + <ScrollArea className="h-full overflow-y-auto overflow-x-hidden"> + <Flex direction="column" py="2" px="2" gap="1px"> + <Box mb="2"> + <NewTaskItem + isActive={sidebarData.isHomeActive} + onClick={handleNewTaskClick} + variant="primary" + /> + </Box> + + <Box> + <SearchItem onClick={handleSearchClick} /> + </Box> + + <Box> + <InboxItem + isActive={sidebarData.isInboxActive} + onClick={handleInboxClick} + signalCount={inboxSignalCount} + /> + </Box> + + <Box> + <SkillsItem + isActive={sidebarData.isSkillsActive} + onClick={handleSkillsClick} + /> + </Box> + + <Box> + <McpServersItem + isActive={sidebarData.isMcpServersActive} + onClick={handleMcpServersClick} + /> + </Box> + + <Box mb="2"> + <CommandCenterItem + isActive={sidebarData.isCommandCenterActive} + onClick={handleCommandCenterClick} + activeCount={commandCenterActiveCount} + /> + </Box> + + <Separator className="mx-2 my-2" /> + + {sidebarData.isLoading ? ( + <SidebarItem + depth={0} + icon={<DotsCircleSpinner size={12} className="text-gray-10" />} + label="Loading tasks..." + disabled + /> + ) : ( + <TaskListView + pinnedTasks={sidebarData.pinnedTasks} + flatTasks={sidebarData.flatTasks} + groupedTasks={sidebarData.groupedTasks} + activeTaskId={sidebarData.activeTaskId} + editingTaskId={editingTaskId} + selectedTaskIds={effectiveBulkIds} + onTaskClick={handleTaskClick} + onTaskDoubleClick={handleTaskDoubleClick} + onTaskContextMenu={handleTaskContextMenu} + onTaskArchive={handleTaskArchive} + onTaskTogglePin={togglePin} + onTaskEditSubmit={handleTaskEditSubmit} + onTaskEditCancel={handleTaskEditCancel} + hasMore={sidebarData.hasMore} + /> + )} + </Flex> + </ScrollArea> + </Box> + ); +} + +export const SidebarMenu = memo(SidebarMenuComponent); 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/packages/ui/src/features/sidebar/taskSelectionStore.ts b/packages/ui/src/features/sidebar/taskSelectionStore.ts new file mode 100644 index 0000000000..4387199a16 --- /dev/null +++ b/packages/ui/src/features/sidebar/taskSelectionStore.ts @@ -0,0 +1,80 @@ +import { + computeRangeSelection, + dedupeTaskIds, + pruneToVisible, +} from "@posthog/core/sidebar/selection"; +import { create } from "zustand"; + +interface TaskSelectionState { + selectedTaskIds: string[]; + /** The last task ID that was clicked — used as the anchor for shift-click range selection. */ + lastClickedId: string | null; +} + +interface TaskSelectionActions { + /** Replace the entire selection (plain click). */ + setSelectedTaskIds: (taskIds: string[]) => void; + /** Toggle a single task in/out of the selection (cmd-click). */ + toggleTaskSelection: (taskId: string) => void; + /** Select a contiguous range from the last-clicked task to `toId` within the given ordered list. + * Existing selection outside the range is preserved (shift-click behavior). + * If there is no last-clicked anchor (e.g. the user just navigated via a plain click), + * `fallbackAnchorId` is used — typically the currently active/routed task. */ + selectRange: ( + toId: string, + orderedIds: string[], + fallbackAnchorId?: string | null, + ) => void; + isTaskSelected: (taskId: string) => boolean; + clearSelection: () => void; + pruneSelection: (visibleTaskIds: string[]) => void; +} + +type TaskSelectionStore = TaskSelectionState & TaskSelectionActions; + +export const useTaskSelectionStore = create<TaskSelectionStore>()( + (set, get) => ({ + selectedTaskIds: [], + lastClickedId: null, + + setSelectedTaskIds: (taskIds) => + set({ + selectedTaskIds: dedupeTaskIds(taskIds), + lastClickedId: taskIds.length === 1 ? taskIds[0] : get().lastClickedId, + }), + + toggleTaskSelection: (taskId) => + set((state) => { + const isRemoving = state.selectedTaskIds.includes(taskId); + return { + selectedTaskIds: isRemoving + ? state.selectedTaskIds.filter((id) => id !== taskId) + : [...state.selectedTaskIds, taskId], + lastClickedId: taskId, + }; + }), + + selectRange: (toId, orderedIds, fallbackAnchorId) => + set((state) => + computeRangeSelection( + state.lastClickedId ?? fallbackAnchorId ?? null, + toId, + orderedIds, + state.selectedTaskIds, + ), + ), + + isTaskSelected: (taskId) => get().selectedTaskIds.includes(taskId), + + clearSelection: () => set({ selectedTaskIds: [], lastClickedId: null }), + + pruneSelection: (visibleTaskIds) => + set((state) => { + 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/packages/ui/src/features/sidebar/useCwd.ts b/packages/ui/src/features/sidebar/useCwd.ts new file mode 100644 index 0000000000..ccc9765e0d --- /dev/null +++ b/packages/ui/src/features/sidebar/useCwd.ts @@ -0,0 +1,12 @@ +import { useSuspendedTaskIds } from "../suspension/useSuspendedTaskIds"; +import { useWorkspace } from "../workspace/useWorkspace"; + +export function useCwd(taskId: string): string | undefined { + const workspace = useWorkspace(taskId); + const suspendedIds = useSuspendedTaskIds(); + + if (!workspace) return undefined; + if (suspendedIds.has(taskId)) return undefined; + + return workspace.worktreePath ?? workspace.folderPath; +} diff --git a/packages/ui/src/features/sidebar/usePinnedTasks.ts b/packages/ui/src/features/sidebar/usePinnedTasks.ts new file mode 100644 index 0000000000..04c8cf0e60 --- /dev/null +++ b/packages/ui/src/features/sidebar/usePinnedTasks.ts @@ -0,0 +1,81 @@ +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..dde5cb356a 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 type { Meta, StoryObj } from "@storybook/react-vite"; -import { SkillButtonActionMessage } from "./SkillButtonActionMessage"; +import { SkillButtonActionMessage } from "@posthog/ui/features/skill-buttons/components/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..17b1779d5b 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 type { Meta, StoryObj } from "@storybook/react-vite"; -import { SkillButtonsMenu } from "./SkillButtonsMenu"; +import { SkillButtonsMenu } from "@posthog/ui/features/skill-buttons/components/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/packages/ui/src/features/skill-buttons/skillButtonsStore.ts b/packages/ui/src/features/skill-buttons/skillButtonsStore.ts new file mode 100644 index 0000000000..932c1f8b77 --- /dev/null +++ b/packages/ui/src/features/skill-buttons/skillButtonsStore.ts @@ -0,0 +1,43 @@ +import { + isSkillButtonId, + SKILL_BUTTON_ORDER, + type SkillButtonId, +} from "@posthog/core/skill-buttons/catalog"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface SkillButtonsStoreState { + lastSelectedId: SkillButtonId; +} + +interface SkillButtonsStoreActions { + setLastSelectedId: (id: SkillButtonId) => void; +} + +type SkillButtonsStore = SkillButtonsStoreState & SkillButtonsStoreActions; + +const DEFAULT_PRIMARY: SkillButtonId = SKILL_BUTTON_ORDER[0]; + +export const useSkillButtonsStore = create<SkillButtonsStore>()( + persist( + (set) => ({ + lastSelectedId: DEFAULT_PRIMARY, + setLastSelectedId: (lastSelectedId) => set({ lastSelectedId }), + }), + { + name: "skill-buttons-storage", + merge: (persisted, current) => { + const persistedState = persisted as { + lastSelectedId?: string; + }; + 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/packages/ui/src/features/skills/skillsSidebarStore.ts b/packages/ui/src/features/skills/skillsSidebarStore.ts new file mode 100644 index 0000000000..83681abada --- /dev/null +++ b/packages/ui/src/features/skills/skillsSidebarStore.ts @@ -0,0 +1,6 @@ +import { createSidebarStore } from "@posthog/ui/workbench/createSidebarStore"; + +export const useSkillsSidebarStore = createSidebarStore({ + name: "skills-sidebar", + defaultWidth: 380, +}); 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/packages/ui/src/features/suspension/useRestoreTask.ts b/packages/ui/src/features/suspension/useRestoreTask.ts new file mode 100644 index 0000000000..7ec62aff34 --- /dev/null +++ b/packages/ui/src/features/suspension/useRestoreTask.ts @@ -0,0 +1,72 @@ +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { + invalidateGitBranchQueries, + invalidateGitWorkingTreeQueries, +} 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 restoreMutation.mutateAsync({ + taskId, + recreateBranch, + }); + + queryClient.invalidateQueries({ queryKey: suspensionPathKey }); + queryClient.invalidateQueries({ queryKey: WORKSPACE_QUERY_KEY }); + + const workspaces = await hostClient.workspace.getAll.query(); + const workspace = workspaces[taskId] ?? null; + const repoPath = workspace?.worktreePath ?? workspace?.folderPath; + if (repoPath) { + invalidateGitWorkingTreeQueries(repoPath); + invalidateGitBranchQueries(repoPath); + } + + log.info("Task restored", { + taskId, + worktreeName: result.worktreeName, + }); + + return result; + } catch (error) { + log.error("Failed to restore task", error); + + const message = + error instanceof Error ? error.message : "Failed to restore worktree"; + + if (message.includes("is already used by worktree")) { + toast.error( + "Branch is in use by another worktree. Try restoring with a new branch.", + ); + } else { + toast.error(message); + } + + throw error; + } finally { + setIsRestoring(false); + } + }; + + return { restoreTask, isRestoring }; +} 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/packages/ui/src/features/suspension/useSuspendedTaskIds.ts b/packages/ui/src/features/suspension/useSuspendedTaskIds.ts new file mode 100644 index 0000000000..ddd52bb328 --- /dev/null +++ b/packages/ui/src/features/suspension/useSuspendedTaskIds.ts @@ -0,0 +1,9 @@ +import { useHostTRPC } from "@posthog/host-router/react"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +export function useSuspendedTaskIds(): Set<string> { + 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/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx new file mode 100644 index 0000000000..e460bfe500 --- /dev/null +++ b/packages/ui/src/features/task-detail/components/TabContentRenderer.tsx @@ -0,0 +1,76 @@ +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; + taskId: string; + task: Task; +} + +export function TabContentRenderer({ + tab, + taskId, + task, +}: TabContentRendererProps) { + const isCloud = useIsWorkspaceCloudRun(taskId); + const { data } = tab; + + switch (data.type) { + case "logs": + return <TaskLogsPanel taskId={taskId} task={task} />; + + case "terminal": + return ( + <TaskShellPanel taskId={taskId} task={task} shellId={data.terminalId} /> + ); + + case "file": + return ( + <CodeEditorPanel + taskId={taskId} + task={task} + absolutePath={data.absolutePath} + /> + ); + + case "review": { + return isCloud ? ( + <CloudReviewPage task={task} /> + ) : ( + <ReviewPage task={task} /> + ); + } + + case "action": + return ( + <ActionPanel + taskId={taskId} + actionId={data.actionId} + command={data.command} + cwd={data.cwd} + /> + ); + + case "other": + switch (tab.id) { + case "files": + return <FileTreePanel taskId={taskId} task={task} />; + case "changes": + return <ChangesPanel taskId={taskId} task={task} />; + default: + return <div>Unknown tab: {tab.id}</div>; + } + + default: + return <div>Unknown tab type</div>; + } +} 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/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx b/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx new file mode 100644 index 0000000000..f84cca7de6 --- /dev/null +++ b/packages/ui/src/features/task-detail/components/TaskLogsPanel.tsx @@ -0,0 +1,161 @@ +import { getTaskRepository } from "@posthog/shared"; +import type { Task } from "@posthog/shared/domain-types"; +import { Box, Flex } from "@radix-ui/themes"; +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; + task: Task; + /** Hide the message input — log-only view. */ + hideInput?: boolean; +} + +export function TaskLogsPanel({ taskId, task, hideInput }: TaskLogsPanelProps) { + const isWorkspaceLoaded = useWorkspaceLoaded(); + const { isPending: isCreatingWorkspace } = useCreateWorkspace(); + const repoKey = getTaskRepository(task); + const { folders } = useFolders(); + const hasDirectoryMapping = repoKey + ? folders.some((f) => f.remoteUrl === repoKey) + : false; + + const suspendedTaskIds = useSuspendedTaskIds(); + const isSuspended = suspendedTaskIds.has(taskId); + const { restoreTask, isRestoring } = useRestoreTask(); + + const isProvisioning = useProvisioningStore((s) => s.activeTasks.has(taskId)); + + const { requestFocus } = useDraftStore((s) => s.actions); + + const { + session, + repoPath, + isCloud, + isRunning, + hasError, + events, + isPromptPending, + promptStartedAt, + isInitializing, + cloudBranch, + cloudStatus, + errorTitle, + errorMessage, + } = useSessionViewState(taskId, task); + + useSessionConnection({ + taskId, + task, + session, + repoPath, + isCloud, + isSuspended, + }); + + const { + handleSendPrompt, + handleCancelPrompt, + handleRetry, + handleNewSession, + handleBashCommand, + } = useSessionCallbacks({ taskId, task, session, repoPath }); + + const { handleBeforeSubmit, dialogProps } = useBranchMismatchDialog({ + taskId, + repoPath, + onSendPrompt: handleSendPrompt, + }); + + const slackThreadUrl = + typeof task.latest_run?.state?.slack_thread_url === "string" + ? task.latest_run.state.slack_thread_url + : undefined; + + useEffect(() => { + requestFocus(taskId); + }, [taskId, requestFocus]); + + const handleRestoreWorktree = useCallback(async () => { + await restoreTask(taskId); + }, [taskId, restoreTask]); + + if (isProvisioning) { + return <ProvisioningView taskId={taskId} />; + } + + if ( + !repoPath && + !isCloud && + !isSuspended && + isWorkspaceLoaded && + !hasDirectoryMapping && + !isCreatingWorkspace + ) { + return ( + <BackgroundWrapper> + <Box height="100%" width="100%"> + <WorkspaceSetupPrompt taskId={taskId} task={task} /> + </Box> + </BackgroundWrapper> + ); + } + + return ( + <BackgroundWrapper> + <Flex direction="column" height="100%" width="100%"> + <Box className="min-h-0 flex-1"> + <ErrorBoundary name="SessionView"> + <SessionView + events={events} + taskId={taskId} + task={task} + isRunning={isRunning} + isSuspended={isSuspended} + onRestoreWorktree={ + isSuspended ? handleRestoreWorktree : undefined + } + isRestoring={isRestoring} + isPromptPending={isPromptPending} + promptStartedAt={promptStartedAt} + onBeforeSubmit={handleBeforeSubmit} + onSendPrompt={handleSendPrompt} + onBashCommand={isCloud ? undefined : handleBashCommand} + onCancelPrompt={handleCancelPrompt} + repoPath={repoPath} + cloudBranch={cloudBranch} + hasError={hasError} + errorTitle={errorTitle} + errorMessage={errorMessage ?? undefined} + hideInput={hideInput} + onRetry={handleRetry} + onNewSession={isCloud ? undefined : handleNewSession} + isInitializing={isInitializing} + isCloud={isCloud} + cloudStatus={cloudStatus} + slackThreadUrl={slackThreadUrl} + /> + </ErrorBoundary> + </Box> + </Flex> + + {dialogProps && <BranchMismatchDialog {...dialogProps} />} + </BackgroundWrapper> + ); +} diff --git a/packages/ui/src/features/task-detail/components/TaskPendingView.tsx b/packages/ui/src/features/task-detail/components/TaskPendingView.tsx new file mode 100644 index 0000000000..464eb656ef --- /dev/null +++ b/packages/ui/src/features/task-detail/components/TaskPendingView.tsx @@ -0,0 +1,20 @@ +import { Box } from "@radix-ui/themes"; +import { usePendingTaskPrompt } from "../../../workbench/pendingTaskPromptStore"; +import { PendingChatView } from "../../sessions/components/PendingChatView"; + +interface TaskPendingViewProps { + pendingTaskKey: string; +} + +export function TaskPendingView({ pendingTaskKey }: TaskPendingViewProps) { + const pending = usePendingTaskPrompt(pendingTaskKey); + + return ( + <Box className="relative h-full w-full bg-background"> + <PendingChatView + promptText={pending?.promptText ?? ""} + attachments={pending?.attachments} + /> + </Box> + ); +} diff --git a/packages/ui/src/features/task-detail/components/TaskShellPanel.tsx b/packages/ui/src/features/task-detail/components/TaskShellPanel.tsx new file mode 100644 index 0000000000..dc9eb30c36 --- /dev/null +++ b/packages/ui/src/features/task-detail/components/TaskShellPanel.tsx @@ -0,0 +1,51 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { Box } from "@radix-ui/themes"; +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; + task: Task; + shellId?: string; +} + +export function TaskShellPanel({ + taskId, + task: _task, + shellId, +}: TaskShellPanelProps) { + const stateKey = shellId ? `${taskId}-${shellId}` : taskId; + const tabId = shellId || "shell"; + + const session = useSessionForTask(taskId); + const workspace = useWorkspace(taskId); + const workspacePath = workspace?.worktreePath ?? workspace?.folderPath; + + const processName = useTerminalStore( + (state) => state.terminalStates[stateKey]?.processName, + ); + const updateTabLabel = usePanelLayoutStore((state) => state.updateTabLabel); + + useShellProcessPoller(stateKey); + + useEffect(() => { + if (processName) { + updateTabLabel(taskId, tabId, processName); + } + }, [processName, taskId, tabId, updateTabLabel]); + + if (!workspacePath || !session || session.status === "connecting") { + return null; + } + + return ( + <Box height="100%"> + <ShellTerminal cwd={workspacePath} stateKey={stateKey} taskId={taskId} /> + </Box> + ); +} 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/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx b/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx new file mode 100644 index 0000000000..31901d482b --- /dev/null +++ b/packages/ui/src/features/task-detail/components/WorkspaceSetupPrompt.tsx @@ -0,0 +1,144 @@ +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 { 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; + task: Task; +} + +export function WorkspaceSetupPrompt({ + taskId, + task, +}: WorkspaceSetupPromptProps) { + const [isSettingUp, setIsSettingUp] = useState(false); + const [selectedPath, setSelectedPath] = useState(""); + const [pendingPath, setPendingPath] = useState<string | null>(null); + 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) => { + setPendingPath(null); + setDetectedRepo(null); + setSelectedPath(path); + setIsSettingUp(true); + + const result = await setupSaga.setupWorkspace(executor, taskId, path); + if (!result.success) { + toast.error("Failed to set up workspace. Please try again."); + } + + setSelectedPath(""); + setIsSettingUp(false); + }, + [taskId, executor, setupSaga], + ); + + const handleFolderSelect = useCallback( + async (path: string) => { + const evaluation = await setupService.evaluateFolderSelection( + repository, + path, + ); + if (evaluation.kind === "mismatch") { + setPendingPath(path); + setDetectedRepo(evaluation.detectedRepo); + return; + } + + await proceedWithSetup(path); + }, + [repository, proceedWithSetup, setupService], + ); + + const handleConfirm = useCallback(async () => { + if (pendingPath) { + await proceedWithSetup(pendingPath); + } + }, [pendingPath, proceedWithSetup]); + + const handleBack = useCallback(() => { + setPendingPath(null); + setDetectedRepo(null); + }, []); + + return ( + <Flex + align="center" + justify="center" + direction="column" + gap="3" + className="absolute inset-0" + > + {isSettingUp ? ( + <> + <Spinner size="3" /> + <Text className="text-gray-11 text-sm">Setting up workspace...</Text> + </> + ) : pendingPath ? ( + <> + <Warning size={32} weight="duotone" className="text-amber-9" /> + <Text className="font-medium text-base text-gray-12"> + Repository mismatch + </Text> + <Text align="center" className="max-w-xs text-gray-11 text-sm"> + This task is linked to <Code>{repository}</Code> but the selected + folder belongs to <Code>{detectedRepo}</Code>. + </Text> + <Flex gap="2" mt="1"> + <Button variant="soft" color="gray" onClick={handleBack}> + Go back + </Button> + <Button variant="solid" onClick={handleConfirm}> + Continue anyway + </Button> + </Flex> + </> + ) : ( + <> + <Folder size={32} weight="duotone" className="text-gray-9" /> + <Text className="font-medium text-base text-gray-12"> + Select a repository folder + </Text> + {repository && ( + <Text className="text-gray-11 text-sm"> + This task is linked to <Code>{repository}</Code> + </Text> + )} + <Box mt="1"> + <FolderPicker + value={selectedPath} + onChange={handleFolderSelect} + placeholder="Select folder..." + /> + </Box> + </> + )} + </Flex> + ); +} 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/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts new file mode 100644 index 0000000000..527ce0ad80 --- /dev/null +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -0,0 +1,298 @@ +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 "../../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"); + +interface UseTaskCreationOptions { + editorRef: React.RefObject<EditorHandle | null>; + selectedDirectory: string; + selectedRepository?: string | null; + githubIntegrationId?: number; + githubUserIntegrationId?: string; + workspaceMode: WorkspaceMode; + branch?: string | null; + editorIsEmpty: boolean; + executionMode?: ExecutionMode; + adapter?: "claude" | "codex"; + model?: string; + reasoningLevel?: string; + environmentId?: string | null; + sandboxEnvironmentId?: string; + signalReportId?: string; + onTaskCreated?: (task: Task) => void; +} + +interface UseTaskCreationReturn { + isCreatingTask: boolean; + canSubmit: boolean; + handleSubmit: (contentOverride?: EditorContent) => Promise<boolean>; +} + +async function trackTaskCreated( + input: TaskCreationInput, + selectedDirectory: string, + hostClient: HostTrpcClient, +): Promise<void> { + try { + const workspaceMode = input.workspaceMode ?? "local"; + + let usesWorktreeLink: boolean | undefined; + let usesWorktreeInclude: boolean | undefined; + if (workspaceMode === "worktree" && selectedDirectory) { + try { + const usage = await hostClient.workspace.getWorktreeFileUsage.query({ + mainRepoPath: selectedDirectory, + }); + usesWorktreeLink = usage.usesWorktreeLink; + usesWorktreeInclude = usage.usesWorktreeInclude; + } catch (error) { + log.warn("Failed to read worktree file usage for analytics", { + error, + }); + } + } + + track(ANALYTICS_EVENTS.TASK_CREATED, { + auto_run: !!input.executionMode, + created_from: "command-menu", + repository_provider: input.repository ? "github" : "none", + workspace_mode: workspaceMode, + has_branch: !!input.branch, + has_environment_setup: + workspaceMode === "worktree" ? !!input.environmentId : undefined, + has_sandbox_environment: + workspaceMode === "cloud" ? !!input.sandboxEnvironmentId : undefined, + cloud_run_source: + workspaceMode === "cloud" + ? (input.cloudRunSource ?? "manual") + : undefined, + cloud_pr_authorship_mode: + workspaceMode === "cloud" ? input.cloudPrAuthorshipMode : undefined, + signal_report_id: input.signalReportId, + uses_worktree_link: usesWorktreeLink, + uses_worktree_include: usesWorktreeInclude, + adapter: input.adapter, + }); + } catch (error) { + log.warn("Failed to track Task created event", { error }); + } +} + +export function useTaskCreation({ + editorRef, + selectedDirectory, + selectedRepository, + githubIntegrationId, + githubUserIntegrationId, + workspaceMode, + branch, + editorIsEmpty, + executionMode, + adapter, + model, + reasoningLevel, + environmentId, + sandboxEnvironmentId, + signalReportId, + onTaskCreated, +}: UseTaskCreationOptions): UseTaskCreationReturn { + const [isCreatingTask, setIsCreatingTask] = useState(false); + const hostClient = useHostTRPCClient(); + const taskService = useService<TaskService>(TASK_SERVICE); + const { + clearTaskInputReportAssociation, + navigateToTask, + navigateToPendingTask, + navigateToTaskInput, + } = useNavigationStore(); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + const { invalidateTasks } = useCreateTask(); + const { isOnline } = useConnectivity(); + + const hasRequiredPath = + workspaceMode === "cloud" ? !!selectedRepository : !!selectedDirectory; + const canSubmitBase = + isAuthenticated && isOnline && hasRequiredPath && !isCreatingTask; + const canSubmit = !!editorRef.current && canSubmitBase && !editorIsEmpty; + + const handleSubmit = useCallback( + async (contentOverride?: EditorContent): Promise<boolean> => { + const editor = editorRef.current; + if (!editor) return false; + const allowSubmit = contentOverride ? canSubmitBase : canSubmit; + if (!allowSubmit) return false; + + setIsCreatingTask(true); + + const content = contentOverride ?? editor.getContent(); + const plainPromptText = contentToPlainText(content).trim(); + const shouldShowPendingView = !onTaskCreated && !!plainPromptText; + const pendingTaskKey = shouldShowPendingView + ? (globalThis.crypto?.randomUUID?.() ?? `pending-${Date.now()}`) + : null; + + if (pendingTaskKey) { + pendingTaskPromptStoreApi.set(pendingTaskKey, { + promptText: plainPromptText, + attachments: (content.attachments ?? []).map((a) => ({ + id: a.id, + label: a.label, + })), + }); + navigateToPendingTask(pendingTaskKey); + if (!contentOverride) { + editor.clear(); + } + } + + try { + if (!contentOverride) { + const plainText = editor.getText()?.trim() ?? plainPromptText; + if (plainText) { + useTaskInputHistoryStore.getState().addPrompt(plainText); + } + } + + const serializedContent = contentToXml(content).trim(); + const filePaths = extractFilePaths(content); + const input = prepareTaskInput(serializedContent, filePaths, { + selectedDirectory, + selectedRepository, + githubIntegrationId, + githubUserIntegrationId, + workspaceMode, + branch, + executionMode, + adapter, + model, + reasoningLevel, + environmentId, + sandboxEnvironmentId, + signalReportId, + }); + + if (executionMode) { + useSettingsStore.getState().setLastUsedInitialTaskMode(executionMode); + } + + const result = await taskService.createTask(input, (output) => { + invalidateTasks(output.task); + if (signalReportId) { + clearTaskInputReportAssociation(); + } + if (pendingTaskKey) { + pendingTaskPromptStoreApi.move(pendingTaskKey, output.task.id); + } + if (onTaskCreated) { + onTaskCreated(output.task); + } else { + navigateToTask(output.task); + } + useTourStore.getState().completeTour(createFirstTaskTour.id); + if (!pendingTaskKey && !contentOverride) { + editor.clear(); + } + }); + + if (result.success) { + void trackTaskCreated(input, selectedDirectory, hostClient); + } + + if (!result.success) { + const title = getErrorTitle(result.failedStep); + toast.error(title, { description: result.error }); + log.error("Task creation failed", { + failedStep: result.failedStep, + error: result.error, + }); + if (pendingTaskKey) { + pendingTaskPromptStoreApi.clear(pendingTaskKey); + navigateToTaskInput({ initialPrompt: plainPromptText }); + } + } + return result.success; + } catch (error) { + const description = + error instanceof Error ? error.message : "Unknown error"; + toast.error("Failed to create task", { description }); + log.error("Unexpected error during task creation", { error }); + if (pendingTaskKey) { + pendingTaskPromptStoreApi.clear(pendingTaskKey); + navigateToTaskInput({ initialPrompt: plainPromptText }); + } + return false; + } finally { + setIsCreatingTask(false); + } + }, + [ + canSubmit, + canSubmitBase, + editorRef, + selectedDirectory, + selectedRepository, + githubIntegrationId, + githubUserIntegrationId, + workspaceMode, + branch, + executionMode, + adapter, + model, + reasoningLevel, + environmentId, + sandboxEnvironmentId, + signalReportId, + clearTaskInputReportAssociation, + invalidateTasks, + navigateToTask, + navigateToPendingTask, + navigateToTaskInput, + onTaskCreated, + hostClient, + taskService, + ], + ); + + return { + isCreatingTask, + canSubmit, + handleSubmit, + }; +} diff --git a/packages/ui/src/features/task-detail/hooks/useTaskData.ts b/packages/ui/src/features/task-detail/hooks/useTaskData.ts new file mode 100644 index 0000000000..ae278ba306 --- /dev/null +++ b/packages/ui/src/features/task-detail/hooks/useTaskData.ts @@ -0,0 +1,60 @@ +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 { useMemo } from "react"; +import { cloneStore } from "../../clone/cloneStore"; +import { useTasks } from "../../tasks/useTasks"; +import { useWorkspace } from "../../workspace/useWorkspace"; + +interface UseTaskDataParams { + taskId: string; + initialTask: Task; +} + +export function useTaskData({ taskId, initialTask }: UseTaskDataParams) { + const trpcReact = useWorkspaceTRPC(); + const { data: tasks = [] } = useTasks(); + + const task = useMemo( + () => tasks.find((t) => t.id === taskId) || initialTask, + [tasks, taskId, initialTask], + ); + + const workspace = useWorkspace(taskId); + const repoPath = workspace?.folderPath ?? null; + + const { data: repoExists } = useQuery( + trpcReact.git.validateRepo.queryOptions( + { directoryPath: repoPath ?? "" }, + { enabled: !!repoPath }, + ), + ); + + const repository = getTaskRepository(task); + + const isCloning = cloneStore((state) => + repository ? isRepoCloning(state.operations, repository) : false, + ); + + const cloneProgress = cloneStore( + (state) => + repository + ? parseCloneProgress(findCloneForRepo(state.operations, repository)) + : null, + (a, b) => a?.message === b?.message && a?.percent === b?.percent, + ); + + return { + task, + repoPath, + repoExists: repoExists ?? null, + isCloning, + cloneProgress, + }; +} 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/packages/ui/src/features/tasks/taskStore.types.ts b/packages/ui/src/features/tasks/taskStore.types.ts new file mode 100644 index 0000000000..3038bbc056 --- /dev/null +++ b/packages/ui/src/features/tasks/taskStore.types.ts @@ -0,0 +1,70 @@ +import type { + ActiveFilters, + FilterCategory, + FilterMatchMode, + FilterOperator, + GroupByField, + OrderByField, + OrderDirection, +} from "@posthog/core/tasks/filters"; + +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; + hoveredIndex: number | null; + contextMenuIndex: number | null; + filter: string; + orderBy: OrderByField; + orderDirection: OrderDirection; + groupBy: GroupByField; + expandedGroups: Record<string, boolean>; + activeFilters: ActiveFilters; + filterMatchMode: FilterMatchMode; + filterSearchQuery: string; + filterMenuSelectedIndex: number; + isFilterDropdownOpen: boolean; + editingFilterBadgeKey: string | null; + + setSelectedIndex: (index: number | null) => void; + setHoveredIndex: (index: number | null) => void; + setContextMenuIndex: (index: number | null) => void; + setFilter: (filter: string) => void; + setOrderBy: (orderBy: OrderByField) => void; + setOrderDirection: (orderDirection: OrderDirection) => void; + setGroupBy: (groupBy: GroupByField) => void; + toggleGroupExpanded: (groupName: string) => void; + setActiveFilters: (filters: ActiveFilters) => void; + clearActiveFilters: () => void; + toggleFilter: ( + category: FilterCategory, + value: string, + operator?: FilterOperator, + ) => void; + addFilter: ( + category: FilterCategory, + value: string, + operator?: FilterOperator, + ) => void; + updateFilter: ( + category: FilterCategory, + oldValue: string, + newValue: string, + ) => void; + toggleFilterOperator: (category: FilterCategory, value: string) => void; + setFilterMatchMode: (mode: FilterMatchMode) => void; + setFilterSearchQuery: (query: string) => void; + setFilterMenuSelectedIndex: (index: number) => void; + setIsFilterDropdownOpen: (open: boolean) => void; + setEditingFilterBadgeKey: (key: string | null) => void; +} diff --git a/packages/ui/src/features/tasks/useTaskContextMenu.ts b/packages/ui/src/features/tasks/useTaskContextMenu.ts new file mode 100644 index 0000000000..9f527ed79d --- /dev/null +++ b/packages/ui/src/features/tasks/useTaskContextMenu.ts @@ -0,0 +1,138 @@ +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(); + const { restoreTask } = useRestoreTask(); + + const showContextMenu = useCallback( + async ( + task: Task, + event: React.MouseEvent, + options?: { + worktreePath?: string; + folderPath?: string; + isPinned?: boolean; + isSuspended?: boolean; + isInCommandCenter?: boolean; + hasEmptyCommandCenterCell?: boolean; + onTogglePin?: () => void; + onArchivePrior?: (taskId: string) => void; + onAddToCommandCenter?: () => void; + }, + ) => { + event.preventDefault(); + event.stopPropagation(); + + const { + worktreePath, + folderPath, + isPinned, + isSuspended, + isInCommandCenter, + hasEmptyCommandCenterCell, + onTogglePin, + onArchivePrior, + onAddToCommandCenter, + } = options ?? {}; + + try { + const result = await hostClient.contextMenu.showTaskContextMenu.mutate({ + taskTitle: task.title, + worktreePath, + folderPath, + isPinned, + isSuspended, + isInCommandCenter, + hasEmptyCommandCenterCell, + }); + + if (!result.action) return; + + const intent = resolveTaskContextMenuIntent(result.action, { + isSuspended, + }); + + switch (intent.type) { + case "rename": + setEditingTaskId(task.id); + break; + case "pin": + onTogglePin?.(); + break; + case "suspend": + await suspendTask({ taskId: task.id, reason: "manual" }); + break; + case "restore": + await restoreTask(task.id); + break; + case "archive": + await archiveTask({ taskId: task.id }); + break; + case "archive-prior": + await onArchivePrior?.(task.id); + break; + case "delete": + await deleteWithConfirm({ + taskId: task.id, + taskTitle: task.title, + hasWorktree: !!worktreePath, + }); + break; + case "add-to-command-center": + onAddToCommandCenter?.(); + break; + case "external-app": { + const effectivePath = resolveExternalAppPath( + worktreePath, + folderPath, + ); + if (effectivePath) { + 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; + } + } + } catch (error) { + log.error("Failed to show context menu", error); + } + }, + [ + archiveTask, + deleteWithConfirm, + restoreTask, + suspendTask, + hostClient, + openExternalApp, + ], + ); + + return { + showContextMenu, + editingTaskId, + setEditingTaskId, + }; +} 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/packages/ui/src/features/tasks/useTaskMutations.test.tsx b/packages/ui/src/features/tasks/useTaskMutations.test.tsx new file mode 100644 index 0000000000..450447d7fe --- /dev/null +++ b/packages/ui/src/features/tasks/useTaskMutations.test.tsx @@ -0,0 +1,247 @@ +import type { Schemas } from "@posthog/api-client"; +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"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateTask = vi.hoisted(() => vi.fn()); +const mockClient = vi.hoisted(() => ({ updateTask: mockUpdateTask })); +const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn()); + +vi.mock("@posthog/ui/features/auth/authClient", () => ({ + useOptionalAuthenticatedClient: () => mockClient, +})); + +vi.mock("@posthog/di/react", () => ({ + useService: () => ({ + updateSessionTaskTitle: mockUpdateSessionTaskTitle, + }), +})); + +import { taskKeys } from "./taskKeys"; +import { useRenameTask } from "./useTaskMutations"; + +const TASK_ID = "task-1"; +const OTHER_TASK_ID = "task-2"; + +function createTask(overrides: Partial<Task> = {}): Task { + return { + id: TASK_ID, + task_number: 1, + slug: "task-1", + title: "Original title", + description: "Original description", + created_at: "2026-05-28T00:00:00.000Z", + updated_at: "2026-05-28T00:00:00.000Z", + origin_product: "user_created", + ...overrides, + }; +} + +function createSummary(overrides: Partial<Schemas.TaskSummary> = {}) { + return { + id: TASK_ID, + title: "Original title", + ...overrides, + } as Schemas.TaskSummary; +} + +function renderRenameHook() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> + ); + const result = renderHook(() => useRenameTask(), { wrapper }); + return { ...result, queryClient }; +} + +describe("useRenameTask", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("applies the new title optimistically to list, summaries, and detail caches", async () => { + mockUpdateTask.mockResolvedValue(undefined); + const { result, queryClient } = renderRenameHook(); + + const listKey = taskKeys.list(); + const summaryKey = taskKeys.summaries([TASK_ID]); + const detailKey = taskKeys.detail(TASK_ID); + queryClient.setQueryData<Task[]>(listKey, [ + createTask(), + createTask({ id: OTHER_TASK_ID, title: "Other" }), + ]); + queryClient.setQueryData<Schemas.TaskSummary[]>(summaryKey, [ + createSummary(), + createSummary({ id: OTHER_TASK_ID, title: "Other" }), + ]); + queryClient.setQueryData<Task>(detailKey, createTask()); + + await act(async () => { + await result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "Renamed", + }); + }); + + const list = queryClient.getQueryData<Task[]>(listKey); + expect(list?.find((t) => t.id === TASK_ID)).toMatchObject({ + title: "Renamed", + title_manually_set: true, + }); + expect(list?.find((t) => t.id === OTHER_TASK_ID)).toMatchObject({ + title: "Other", + }); + + const summaries = + queryClient.getQueryData<Schemas.TaskSummary[]>(summaryKey); + expect(summaries?.find((t) => t.id === TASK_ID)?.title).toBe("Renamed"); + expect(summaries?.find((t) => t.id === OTHER_TASK_ID)?.title).toBe("Other"); + + const detail = queryClient.getQueryData<Task>(detailKey); + expect(detail).toMatchObject({ + title: "Renamed", + title_manually_set: true, + }); + + expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, { + title: "Renamed", + title_manually_set: true, + }); + expect(mockUpdateSessionTaskTitle).toHaveBeenCalledWith(TASK_ID, "Renamed"); + }); + + it("rolls back all caches and notifies the session service with the original title on failure", async () => { + const failure = new Error("network down"); + mockUpdateTask.mockRejectedValue(failure); + const { result, queryClient } = renderRenameHook(); + + const listKey = taskKeys.list(); + const summaryKey = taskKeys.summaries([TASK_ID]); + const detailKey = taskKeys.detail(TASK_ID); + queryClient.setQueryData<Task[]>(listKey, [createTask()]); + queryClient.setQueryData<Schemas.TaskSummary[]>(summaryKey, [ + createSummary(), + ]); + queryClient.setQueryData<Task>(detailKey, createTask()); + + let caught: unknown; + await act(async () => { + try { + await result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "Renamed", + }); + } catch (error) { + caught = error; + } + }); + expect(caught).toBe(failure); + + expect(queryClient.getQueryData<Task[]>(listKey)?.[0].title).toBe( + "Original title", + ); + expect( + queryClient.getQueryData<Task[]>(listKey)?.[0].title_manually_set, + ).toBeUndefined(); + expect( + queryClient.getQueryData<Schemas.TaskSummary[]>(summaryKey)?.[0].title, + ).toBe("Original title"); + expect(queryClient.getQueryData<Task>(detailKey)?.title).toBe( + "Original title", + ); + + expect(mockUpdateSessionTaskTitle).toHaveBeenNthCalledWith( + 1, + TASK_ID, + "Renamed", + ); + expect(mockUpdateSessionTaskTitle).toHaveBeenNthCalledWith( + 2, + TASK_ID, + "Original title", + ); + }); + + it("skips rollback when a newer rename has advanced the title past ours", async () => { + const failure = new Error("network down"); + mockUpdateTask.mockRejectedValue(failure); + const { result, queryClient } = renderRenameHook(); + + const listKey = taskKeys.list(); + const summaryKey = taskKeys.summaries([TASK_ID]); + const detailKey = taskKeys.detail(TASK_ID); + queryClient.setQueryData<Task[]>(listKey, [createTask()]); + queryClient.setQueryData<Schemas.TaskSummary[]>(summaryKey, [ + createSummary(), + ]); + queryClient.setQueryData<Task>(detailKey, createTask()); + + const renamePromise = result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "First rename", + }); + + queryClient.setQueryData<Task[]>(listKey, [ + createTask({ title: "Second rename", title_manually_set: true }), + ]); + queryClient.setQueryData<Schemas.TaskSummary[]>(summaryKey, [ + createSummary({ title: "Second rename" }), + ]); + queryClient.setQueryData<Task>( + detailKey, + createTask({ title: "Second rename", title_manually_set: true }), + ); + + let caught: unknown; + await act(async () => { + try { + await renamePromise; + } catch (error) { + caught = error; + } + }); + expect(caught).toBe(failure); + + expect(queryClient.getQueryData<Task[]>(listKey)?.[0].title).toBe( + "Second rename", + ); + expect( + queryClient.getQueryData<Schemas.TaskSummary[]>(summaryKey)?.[0].title, + ).toBe("Second rename"); + expect(queryClient.getQueryData<Task>(detailKey)?.title).toBe( + "Second rename", + ); + + expect(mockUpdateSessionTaskTitle).not.toHaveBeenCalledWith( + TASK_ID, + "Original title", + ); + }); + + it("does not write to the detail cache when no detail entry exists", async () => { + mockUpdateTask.mockResolvedValue(undefined); + const { result, queryClient } = renderRenameHook(); + + queryClient.setQueryData<Task[]>(taskKeys.list(), [createTask()]); + + await act(async () => { + await result.current.renameTask({ + taskId: TASK_ID, + currentTitle: "Original title", + newTitle: "Renamed", + }); + }); + + expect(queryClient.getQueryData(taskKeys.detail(TASK_ID))).toBeUndefined(); + expect(queryClient.getQueryData<Task[]>(taskKeys.list())?.[0].title).toBe( + "Renamed", + ); + }); +}); 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/packages/ui/src/features/terminal/Terminal.tsx b/packages/ui/src/features/terminal/Terminal.tsx new file mode 100644 index 0000000000..683e2d79a6 --- /dev/null +++ b/packages/ui/src/features/terminal/Terminal.tsx @@ -0,0 +1,146 @@ +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; +import { Box } from "@radix-ui/themes"; +import "@xterm/xterm/css/xterm.css"; + +import { useService } from "@posthog/di/react"; +import { resolveTerminalFontFamily } from "@posthog/core/terminal/resolveTerminalFontFamily"; +import { + type ShellClient, + SHELL_CLIENT, +} from "@posthog/ui/features/terminal/shellClient"; +import { terminalManager } from "@posthog/ui/features/terminal/TerminalManager"; +import { useCallback, useEffect, useRef } from "react"; + +export interface TerminalProps { + sessionId: string; + persistenceKey: string; + cwd?: string; + initialState?: string; + taskId?: string; + command?: string; + onReady?: () => void; + onExit?: (exitCode?: number) => void; +} + +export function Terminal({ + sessionId, + persistenceKey, + cwd, + initialState, + taskId, + command, + onReady, + onExit, +}: TerminalProps) { + 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( + (s) => s.terminalCustomFontFamily, + ); + + // Create instance (idempotent) + useEffect(() => { + if (!terminalManager.has(sessionId)) { + terminalManager.create({ + sessionId, + persistenceKey, + cwd, + initialState, + taskId, + command, + }); + } + }, [sessionId, persistenceKey, cwd, initialState, taskId, command]); + + // Attach/detach from DOM + useEffect(() => { + if (!terminalRef.current) return; + + terminalManager.attach(sessionId, terminalRef.current); + terminalManager.focus(sessionId); + + return () => { + terminalManager.detach(sessionId); + }; + }, [sessionId]); + + // Theme sync + useEffect(() => { + terminalManager.setTheme(isDarkMode); + }, [isDarkMode]); + + // Font sync + useEffect(() => { + terminalManager.setFontFamily( + resolveTerminalFontFamily(terminalFont, terminalCustomFontFamily), + ); + }, [terminalFont, terminalCustomFontFamily]); + + // 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(() => { + const offReady = terminalManager.on("ready", ({ sessionId: id }) => { + if (id === sessionId) { + onReady?.(); + } + }); + + const offExit = terminalManager.on( + "exit", + ({ sessionId: id, exitCode }) => { + if (id === sessionId) { + onExit?.(exitCode); + } + }, + ); + + return () => { + offReady(); + offExit(); + }; + }, [sessionId, onReady, onExit]); + + // mousedown so the xterm textarea is focused before the browser's native focus shift, not after. + const handleMouseDown = useCallback(() => { + terminalManager.focus(sessionId); + }, [sessionId]); + + return ( + <Box onMouseDown={handleMouseDown} className="relative h-full p-3"> + <div ref={terminalRef} className="h-full w-full" /> + <style> + {` + .xterm { + background-color: transparent !important; + } + .xterm .xterm-viewport { + background-color: transparent !important; + } + .xterm .xterm-viewport::-webkit-scrollbar { + display: none; + } + .xterm .xterm-viewport { + scrollbar-width: none; + } + `} + </style> + </Box> + ); +} 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..181c301285 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 { resolveService } from "@posthog/di/container"; +import { + type ShellClient, + SHELL_CLIENT, +} 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"; +import { DEFAULT_TERMINAL_FONT_FAMILY } from "@posthog/core/terminal/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/packages/ui/src/features/terminal/terminalStore.ts b/packages/ui/src/features/terminal/terminalStore.ts new file mode 100644 index 0000000000..219660555e --- /dev/null +++ b/packages/ui/src/features/terminal/terminalStore.ts @@ -0,0 +1,110 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { terminalManager } from "./TerminalManager"; + +export interface TerminalState { + serializedState: string | null; + sessionId: string | null; + processName: string | null; +} + +interface TerminalStoreState { + terminalStates: Record<string, TerminalState>; + 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; +} + +const DEFAULT_TERMINAL_STATE: TerminalState = { + serializedState: null, + sessionId: null, + processName: null, +}; + +export const useTerminalStore = create<TerminalStoreState>()( + persist( + (set, get) => ({ + terminalStates: {}, + + getTerminalState: (key: string) => { + return get().terminalStates[key] || DEFAULT_TERMINAL_STATE; + }, + + setSerializedState: (key: string, state: string) => { + set((prev) => ({ + terminalStates: { + ...prev.terminalStates, + [key]: { + ...prev.terminalStates[key], + serializedState: state, + }, + }, + })); + }, + + setSessionId: (key: string, sessionId: string) => { + set((prev) => ({ + terminalStates: { + ...prev.terminalStates, + [key]: { + ...prev.terminalStates[key], + sessionId, + }, + }, + })); + }, + + setProcessName: (key: string, processName: string | null) => { + set((prev) => ({ + terminalStates: { + ...prev.terminalStates, + [key]: { + ...prev.terminalStates[key], + processName, + }, + }, + })); + }, + + clearTerminalState: (key: string) => { + set((prev) => { + const newStates = { ...prev.terminalStates }; + delete newStates[key]; + return { terminalStates: newStates }; + }); + }, + + clearTerminalStatesForTask: (taskId: string) => { + set((prev) => { + const newStates = { ...prev.terminalStates }; + for (const key of Object.keys(newStates)) { + if (key === taskId || key.startsWith(`${taskId}-`)) { + delete newStates[key]; + } + } + return { terminalStates: newStates }; + }); + }, + }), + { + name: "terminal-store", + partialize: (state) => ({ + terminalStates: Object.fromEntries( + Object.entries(state.terminalStates).map(([k, v]) => [ + k, + { serializedState: v.serializedState, sessionId: v.sessionId }, + ]), + ), + }), + }, + ), +); + +terminalManager.on("stateChange", ({ persistenceKey, serializedState }) => { + useTerminalStore + .getState() + .setSerializedState(persistenceKey, serializedState); +}); 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/packages/ui/src/features/tour/tours/createFirstTaskTour.ts b/packages/ui/src/features/tour/tours/createFirstTaskTour.ts new file mode 100644 index 0000000000..c1abecbbcd --- /dev/null +++ b/packages/ui/src/features/tour/tours/createFirstTaskTour.ts @@ -0,0 +1,35 @@ +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", + target: "folder-picker", + hogSrc: explorerHog, + message: "Pick a repo to work with. This tells me where your code lives!", + advanceOn: { type: "action" }, + }, + { + id: "task-editor", + target: "task-input-editor", + hogSrc: builderHog, + message: + "Describe what you want to build or fix. Be as specific as you like!", + advanceOn: { type: "action" }, + }, + { + id: "submit-button", + target: "task-input-submit", + hogSrc: happyHog, + message: "Hit send or press Enter to launch your first agent!", + advanceOn: { type: "click" }, + }, + ], +}; 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/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/packages/ui/src/features/workspace/useBranchMismatchDialog.ts b/packages/ui/src/features/workspace/useBranchMismatchDialog.ts new file mode 100644 index 0000000000..1ebaf54f93 --- /dev/null +++ b/packages/ui/src/features/workspace/useBranchMismatchDialog.ts @@ -0,0 +1,143 @@ +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 { 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"); + +interface UseBranchMismatchDialogOptions { + taskId: string; + repoPath: string | null; + onSendPrompt: (text: string) => void; +} + +export function useBranchMismatchDialog({ + taskId, + repoPath, + onSendPrompt, +}: UseBranchMismatchDialogOptions) { + const { shouldWarn, linkedBranch, currentBranch, dismissWarning } = + useBranchMismatchGuard(taskId); + + // State drives dialog visibility (`open`), refs avoid stale closures in + // mutation callbacks (onSuccess / handleContinue) that capture at mount time. + const [pendingMessage, setPendingMessage] = useState<string | null>(null); + const pendingMessageRef = useRef<string | null>(null); + const pendingClearRef = useRef<(() => void) | null>(null); + const onSendPromptRef = useRef(onSendPrompt); + onSendPromptRef.current = onSendPrompt; + const [switchError, setSwitchError] = useState<string | null>(null); + + const { hasChanges: hasUncommittedChanges } = useGitQueries( + repoPath ?? undefined, + ); + + 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: () => { + if (repoPath) invalidateGitBranchQueries(repoPath); + dismissWarning(); + pendingClearRef.current?.(); + pendingClearRef.current = null; + const message = pendingMessageRef.current; + if (message) onSendPromptRef.current(message); + setPendingMessage(null); + pendingMessageRef.current = null; + }, + onError: (error) => { + log.error("Failed to switch branch", error); + setSwitchError(resolveSwitchErrorMessage(error)); + }, + }), + ); + + const handleBeforeSubmit = useCallback( + (text: string, clearEditor: () => void): boolean => { + if (!decideBeforeSubmit(shouldWarn)) { + setPendingMessage(text); + pendingMessageRef.current = text; + pendingClearRef.current = clearEditor; + emitAction("shown"); + return false; + } + return true; + }, + [shouldWarn, emitAction], + ); + + const handleSwitch = useCallback(() => { + const request = buildCheckoutBranchRequest(repoPath, linkedBranch); + if (!request) return; + setSwitchError(null); + emitAction("switch"); + checkoutBranch(request); + }, [linkedBranch, repoPath, emitAction, checkoutBranch]); + + const handleContinue = useCallback(() => { + emitAction("continue"); + dismissWarning(); + pendingClearRef.current?.(); + pendingClearRef.current = null; + const message = pendingMessageRef.current; + if (message) onSendPromptRef.current(message); + setPendingMessage(null); + pendingMessageRef.current = null; + setSwitchError(null); + }, [dismissWarning, emitAction]); + + const handleCancel = useCallback(() => { + emitAction("cancel"); + setPendingMessage(null); + pendingMessageRef.current = null; + pendingClearRef.current = null; + setSwitchError(null); + }, [emitAction]); + + const dialogProps = + linkedBranch && currentBranch + ? { + open: pendingMessage !== null, + linkedBranch, + currentBranch, + hasUncommittedChanges, + switchError, + onSwitch: handleSwitch, + onContinue: handleContinue, + onCancel: handleCancel, + isSwitching, + } + : null; + + return { handleBeforeSubmit, dialogProps }; +} 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/packages/ui/src/features/workspace/useWorkspaceEvents.ts b/packages/ui/src/features/workspace/useWorkspaceEvents.ts new file mode 100644 index 0000000000..7099376873 --- /dev/null +++ b/packages/ui/src/features/workspace/useWorkspaceEvents.ts @@ -0,0 +1,25 @@ +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 = client.workspace.onWarning.subscribe( + undefined, + { + onData: (data) => { + if (data.taskId !== taskId) return; + toast.warning(data.title, { + description: data.message, + duration: 10000, + }); + }, + }, + ); + + return () => { + warningSubscription.unsubscribe(); + }; + }, [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..d795f3a596 --- /dev/null +++ b/packages/ui/src/features/workspace/workspace-events.contribution.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +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(); + // biome-ignore lint/suspicious/noExplicitAny: test double + new WorkspaceEventsContribution( + client as any, + { invalidateQueries } as any, + ).start(); + expect(Object.keys(client.handlers).sort()).toEqual([ + "onBranchChanged", + "onError", + "onLinkedBranchChanged", + "onPromoted", + ]); + }); + + it("onPromoted invalidates the workspace query and toasts", () => { + const client = makeClient(); + // biome-ignore lint/suspicious/noExplicitAny: test double + new WorkspaceEventsContribution( + client as any, + { invalidateQueries } as any, + ).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(); + // biome-ignore lint/suspicious/noExplicitAny: test double + new WorkspaceEventsContribution( + client as any, + { invalidateQueries } as any, + ).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(); + // biome-ignore lint/suspicious/noExplicitAny: test double + new WorkspaceEventsContribution( + client as any, + { invalidateQueries } as any, + ).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..26923061b3 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 { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; 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..bf53f88f2c 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 { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; 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..bd92f7785e 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 { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; 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..263aa3134d 100644 --- a/apps/code/src/renderer/components/ui/Button.tsx +++ b/packages/ui/src/primitives/Button.tsx @@ -1,4 +1,4 @@ -import { Tooltip } from "@components/ui/Tooltip"; +import { Tooltip } from "./Tooltip"; import { Flex, Button as RadixButton, Text } from "@radix-ui/themes"; import { type ComponentPropsWithoutRef, 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/packages/ui/src/primitives/ErrorBoundary.tsx b/packages/ui/src/primitives/ErrorBoundary.tsx new file mode 100644 index 0000000000..27e0772679 --- /dev/null +++ b/packages/ui/src/primitives/ErrorBoundary.tsx @@ -0,0 +1,91 @@ +import { Warning } from "@phosphor-icons/react"; +import { Box, Button, Callout, Flex, Text } from "@radix-ui/themes"; +import { Component, type ErrorInfo, type ReactNode } from "react"; + +export interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; + /** Optional name to identify which boundary caught the error */ + name?: string; + /** When this value changes, the boundary clears its error state. */ + resetKey?: unknown; + /** + * If returns true for a caught error, the boundary renders nothing, + * 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 { + error: Error | null; + lastResetKey: unknown; +} + +export class ErrorBoundary extends Component<ErrorBoundaryProps, State> { + state: State = { error: null, lastResetKey: this.props.resetKey }; + + static getDerivedStateFromError(error: Error): Partial<State> { + return { error }; + } + + static getDerivedStateFromProps( + props: ErrorBoundaryProps, + state: State, + ): Partial<State> | null { + if (props.resetKey === state.lastResetKey) return null; + return { error: null, lastResetKey: props.resetKey }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + const suppressed = this.props.shouldSuppress?.(error) ?? false; + this.props.onError?.(error, { + componentStack: errorInfo.componentStack, + suppressed, + }); + } + + handleRetry = () => { + this.setState({ error: null }); + }; + + render() { + const { error } = this.state; + if (!error) return this.props.children; + if (this.props.shouldSuppress?.(error)) return null; + if (this.props.fallback) return this.props.fallback; + + return ( + <Box p="4"> + <Callout.Root color="red" size="2"> + <Callout.Icon> + <Warning weight="fill" /> + </Callout.Icon> + <Callout.Text> + <Flex direction="column" gap="2"> + <Text className="font-medium">Something went wrong</Text> + <Text className="text-[13px] text-gray-11"> + {error.message || "An unexpected error occurred"} + </Text> + <Flex gap="2" mt="2"> + <Button size="1" variant="soft" onClick={this.handleRetry}> + Try again + </Button> + </Flex> + </Flex> + </Callout.Text> + </Callout.Root> + </Box> + ); + } +} 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/packages/ui/src/primitives/FullScreenLayout.tsx b/packages/ui/src/primitives/FullScreenLayout.tsx new file mode 100644 index 0000000000..10e047f2e5 --- /dev/null +++ b/packages/ui/src/primitives/FullScreenLayout.tsx @@ -0,0 +1,82 @@ +import { DotPatternBackground } from "@posthog/ui/primitives/DotPatternBackground"; +import { useThemeStore } from "@posthog/ui/workbench/themeStore"; +import { Lifebuoy } from "@phosphor-icons/react"; +import { Button, Flex, Theme } from "@radix-ui/themes"; +import type { ReactNode } from "react"; +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); + + return ( + <Theme + appearance={isDarkMode ? "dark" : "light"} + accentColor={isDarkMode ? "yellow" : "orange"} + radius="medium" + > + <Flex + direction="column" + height="100vh" + className="relative overflow-hidden" + > + <DraggableTitleBar /> + + <div className="absolute inset-0 bg-(--color-background)" /> + <DotPatternBackground /> + + <Flex + direction="column" + flexGrow="1" + className="relative z-[1] min-h-0 w-full" + > + <Flex + direction="column" + flexGrow="1" + overflow="hidden" + className="min-h-0" + > + {children} + </Flex> + + <Flex + justify="between" + className="absolute right-[32px] bottom-[20px] left-[32px] z-[2]" + > + {footerLeft ?? ( + <Flex align="center" gap="3"> + <Button + size="1" + variant="ghost" + color="gray" + onClick={onOpenSupport} + className="opacity-50" + > + <Lifebuoy size={14} /> + Get support + </Button> + {banner} + </Flex> + )} + {footerRight ?? <div />} + </Flex> + </Flex> + </Flex> + </Theme> + ); +} 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..3481325082 100644 --- a/apps/code/src/renderer/components/HighlightedCode.tsx +++ b/packages/ui/src/primitives/HighlightedCode.tsx @@ -1,5 +1,5 @@ -import { useThemeStore } from "@stores/themeStore"; -import { highlightSyntax } from "@utils/syntax-highlight"; +import { useThemeStore } from "../workbench/themeStore"; +import { highlightSyntax } from "../utils/syntax-highlight"; import { useMemo } from "react"; interface HighlightedCodeProps { 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/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx new file mode 100644 index 0000000000..651fb65d76 --- /dev/null +++ b/packages/ui/src/primitives/KeyboardShortcutsSheet.tsx @@ -0,0 +1,201 @@ +import { Box, Dialog, Flex, Text } from "@radix-ui/themes"; +import { + CATEGORY_LABELS, + formatHotkeyParts, + getShortcutsByCategory, + type ShortcutCategory, +} from "../features/command/keyboard-shortcuts"; +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/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 100% rename from apps/code/src/renderer/assets/logo.tsx rename to packages/ui/src/primitives/Logo.tsx 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..906bc3c4ca 100644 --- a/apps/code/src/renderer/components/ui/SafeImagePreview.tsx +++ b/packages/ui/src/primitives/SafeImagePreview.tsx @@ -1,4 +1,4 @@ -import { useImagePanAndZoom } from "@hooks/useImagePanAndZoom"; +import { useImagePanAndZoom } from "./hooks/useImagePanAndZoom"; import { buildImageDataUrl, isAllowedImageMimeType, 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..9cc14c851b 100644 --- a/apps/code/src/renderer/components/ThemeWrapper.tsx +++ b/packages/ui/src/primitives/ThemeWrapper.tsx @@ -1,5 +1,5 @@ import { Theme } from "@radix-ui/themes"; -import { useThemeStore } from "@stores/themeStore"; +import { useThemeStore } from "@posthog/ui/workbench/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..56098fa196 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 { Box, Flex, Text } from "@radix-ui/themes"; -import { compactHomePath } from "@utils/path"; +import { compactHomePath } from "@posthog/shared"; 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..9b56ec9664 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 { Box, Checkbox, Flex, Radio, Text } from "@radix-ui/themes"; -import { compactHomePath } from "@utils/path"; +import { compactHomePath } from "@posthog/shared"; 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..22e713b208 100644 --- a/apps/code/src/renderer/components/ui/combobox/Combobox.stories.tsx +++ b/packages/ui/src/primitives/combobox/Combobox.stories.tsx @@ -2,7 +2,7 @@ import { Plus } from "@phosphor-icons/react"; import { Button, Text } from "@radix-ui/themes"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; -import { Combobox } from "./Combobox"; +import { Combobox } from "@posthog/ui/primitives/combobox/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.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..cbe9e2f09e 100644 --- a/apps/code/src/renderer/components/ui/combobox/useComboboxFilter.ts +++ b/packages/ui/src/primitives/combobox/useComboboxFilter.ts @@ -1,4 +1,4 @@ -import { useDebounce } from "@hooks/useDebounce"; +import { useDebounce } from "../hooks/useDebounce"; import { defaultFilter } from "cmdk"; import { useCallback, useEffect, useMemo, useState } from "react"; 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..3730d6d318 100644 --- a/apps/code/src/renderer/hooks/useDebounce.test.ts +++ b/packages/ui/src/primitives/hooks/useDebounce.test.ts @@ -1,6 +1,6 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useDebounce } from "./useDebounce"; +import { useDebounce } from "@posthog/ui/primitives/hooks/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..90f79e9f23 100644 --- a/apps/code/src/renderer/hooks/useImagePanAndZoom.test.tsx +++ b/packages/ui/src/primitives/hooks/useImagePanAndZoom.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render } from "@testing-library/react"; import { describe, expect, it } from "vitest"; -import { useImagePanAndZoom } from "./useImagePanAndZoom"; +import { useImagePanAndZoom } from "@posthog/ui/primitives/hooks/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/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/packages/ui/src/utils/browser.ts b/packages/ui/src/utils/browser.ts new file mode 100644 index 0000000000..26d0e8e80e --- /dev/null +++ b/packages/ui/src/utils/browser.ts @@ -0,0 +1,9 @@ +import { openExternalUrl } from "@posthog/ui/workbench/openExternal"; + +export async function openUrlInBrowser(url: string): Promise<void> { + try { + 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/packages/ui/src/utils/dialog.ts b/packages/ui/src/utils/dialog.ts new file mode 100644 index 0000000000..f6ce62098a --- /dev/null +++ b/packages/ui/src/utils/dialog.ts @@ -0,0 +1,27 @@ +import { resolveService } from "@posthog/di/container"; +import { + HOST_TRPC_CLIENT, + type HostTrpcClient, +} from "@posthog/host-router/client"; + +export interface MessageBoxOptions { + type?: "none" | "info" | "error" | "question" | "warning"; + title?: string; + message?: string; + detail?: string; + buttons?: string[]; + defaultId?: number; + cancelId?: number; +} + +export async function showMessageBox( + options: MessageBoxOptions, +): Promise<{ response: number }> { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + + 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..cc50ce4a73 --- /dev/null +++ b/packages/ui/src/utils/getFilePath.ts @@ -0,0 +1,15 @@ +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/packages/ui/src/utils/sounds.ts b/packages/ui/src/utils/sounds.ts new file mode 100644 index 0000000000..ea17f2199a --- /dev/null +++ b/packages/ui/src/utils/sounds.ts @@ -0,0 +1,56 @@ +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, + danilo: daniloUrl, + revi: reviUrl, + meep: meepUrl, + "meep-smol": meepSmolUrl, + bubbles: bubblesUrl, + drop: dropUrl, + knock: knockUrl, + ring: ringUrl, + shoot: shootUrl, + slide: slideUrl, + switch: switchUrl, + wilhelm: wilhelmUrl, +}; + +let currentAudio: HTMLAudioElement | null = null; + +export function playCompletionSound(sound: CompletionSound, volume = 80): void { + if (sound === "none") return; + + const url = SOUND_URLS[sound]; + if (!url) return; + + if (currentAudio) { + currentAudio.pause(); + currentAudio = null; + } + + const audio = new Audio(url); + audio.volume = Math.max(0, Math.min(100, volume)) / 100; + currentAudio = audio; + audio.play().catch(() => { + // Audio play can fail if user hasn't interacted with the page yet + }); + audio.addEventListener("ended", () => { + if (currentAudio === audio) { + currentAudio = null; + } + }); +} 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/packages/ui/src/utils/urls.ts b/packages/ui/src/utils/urls.ts new file mode 100644 index 0000000000..669e2940b4 --- /dev/null +++ b/packages/ui/src/utils/urls.ts @@ -0,0 +1,20 @@ +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 ?? useAuthStore.getState().authState.cloudRegion; + if (!region) return null; + const base = getCloudUrlFromRegion(region); + return `${base}${pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`}`; +} + +export function getBillingUrl( + regionOverride?: CloudRegion | null, +): string | null { + return getPostHogUrl("/organization/billing/overview", regionOverride); +} 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/packages/ui/src/workbench/MainLayout.tsx b/packages/ui/src/workbench/MainLayout.tsx new file mode 100644 index 0000000000..d3f7baa8a6 --- /dev/null +++ b/packages/ui/src/workbench/MainLayout.tsx @@ -0,0 +1,188 @@ +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 { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; + +const log = logger.scope("main-layout"); + +export function MainLayout() { + const { + view, + hydrateTask, + navigateToTaskInput, + navigateToTask, + taskInputReportAssociation, + taskInputCloudRepository, + } = useNavigationStore(); + const { isOpen: commandMenuOpen, setOpen: setCommandMenuOpen } = + useCommandMenuStore(); + const { isOpen: shortcutsSheetOpen, close: closeShortcutsSheet } = + useShortcutsSheetStore(); + const { data: tasks } = useTasks(); + const { data: workspaces, isFetched: workspacesFetched } = useWorkspaces(); + const trpc = useHostTRPC(); + const hostClient = useHostTRPCClient(); + const queryClient = useQueryClient(); + const reconcilingTaskIds = useRef<Set<string>>(new Set()); + const billingEnabled = useFeatureFlag(BILLING_FLAG); + const syncCloudTasksEnabled = useFeatureFlag(SYNC_CLOUD_TASKS_FLAG); + + // Space switcher data + const sidebarData = useSidebarData({ activeView: view }); + const visualTaskOrder = useVisualTaskOrder(sidebarData); + const activeTaskId = + view.type === "task-detail" && view.data ? view.data.id : null; + + useIntegrations(); + useTaskDeepLink(); + useInboxDeepLink(); + useSetupDiscovery(); + useNewTaskDeepLink(); + + useEffect(() => { + if (tasks) { + hydrateTask(tasks); + } + }, [tasks, hydrateTask]); + + useEffect(() => { + if (!syncCloudTasksEnabled) return; + if (!tasks || !workspaces || !workspacesFetched) return; + const missing = tasks.filter( + (t) => + t.latest_run?.environment === "cloud" && + !workspaces[t.id] && + !reconcilingTaskIds.current.has(t.id), + ); + if (missing.length === 0) return; + const missingIds = missing.map((t) => t.id); + 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. + 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({ + queryKey: trpc.workspace.getAll.queryKey(), + }); + } + }) + .catch((err) => { + for (const id of missingIds) reconcilingTaskIds.current.delete(id); + log.warn("Failed to reconcile cloud workspaces", err); + }); + }, [ + syncCloudTasksEnabled, + tasks, + workspaces, + workspacesFetched, + queryClient, + hostClient, + trpc, + ]); + + useEffect(() => { + if (view.type === "task-detail" && !view.data && !view.taskId) { + navigateToTaskInput(); + } + }, [view, navigateToTaskInput]); + + return ( + <Flex direction="column" height="100vh"> + <HeaderRow /> + <Flex flexGrow="1" overflow="hidden"> + <MainSidebar /> + + <Box flexGrow="1" overflow="hidden"> + {view.type === "task-input" && ( + <TaskInput + initialPrompt={view.initialPrompt} + initialPromptKey={view.taskInputRequestId} + initialCloudRepository={ + view.initialCloudRepository ?? taskInputCloudRepository + } + initialModel={view.initialModel} + initialMode={view.initialMode} + reportAssociation={ + view.reportAssociation ?? taskInputReportAssociation + } + /> + )} + + {view.type === "task-detail" && view.data && ( + <TaskDetail key={view.data.id} task={view.data} /> + )} + + {view.type === "task-pending" && view.pendingTaskKey && ( + <TaskPendingView pendingTaskKey={view.pendingTaskKey} /> + )} + + {view.type === "folder-settings" && <FolderSettingsView />} + + {view.type === "inbox" && <InboxView />} + + {view.type === "archived" && <ArchivedTasksView />} + + {view.type === "command-center" && <CommandCenterView />} + + {view.type === "skills" && <SkillsView />} + + {view.type === "mcp-servers" && <McpServersView />} + </Box> + </Flex> + + <SpaceSwitcher + tasks={visualTaskOrder} + activeTaskId={activeTaskId} + allTasks={tasks ?? []} + isOnNewTask={view.type === "task-input" || view.type === "task-pending"} + onNavigateToTask={navigateToTask} + onNewTask={navigateToTaskInput} + /> + <CommandMenu open={commandMenuOpen} onOpenChange={setCommandMenuOpen} /> + <KeyboardShortcutsSheet + open={shortcutsSheetOpen} + onOpenChange={(open) => (open ? null : closeShortcutsSheet())} + /> + <SettingsDialog /> + <TourOverlay /> + {billingEnabled && <UsageLimitModal />} + <HedgehogMode /> + </Flex> + ); +} 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..e804d2c717 --- /dev/null +++ b/packages/ui/src/workbench/analytics.ts @@ -0,0 +1,31 @@ +import { resolveService } from "@posthog/di/container"; +import type { EventPropertyMap } 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 AnalyticsTracker { + track<K extends keyof EventPropertyMap>( + eventName: K, + ...args: TrackArgs<K> + ): void; + setActiveTaskContext(task: Task | null): 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); +} 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..7d1cfb0d40 --- /dev/null +++ b/packages/ui/src/workbench/diffWorkerHost.ts @@ -0,0 +1,7 @@ +export interface 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..f87031c365 --- /dev/null +++ b/packages/ui/src/workbench/logger.ts @@ -0,0 +1,39 @@ +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/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/packages/ui/src/workbench/service-context.tsx b/packages/ui/src/workbench/service-context.tsx deleted file mode 100644 index b484a4b7e5..0000000000 --- a/packages/ui/src/workbench/service-context.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { ServiceIdentifier } from "inversify"; -import type { ReactNode } from "react"; -import { createContext, useContext, useMemo } from "react"; - -interface ServiceContainer { - get<T>(serviceIdentifier: ServiceIdentifier<T>): T; -} - -const ServiceContext = createContext<ServiceContainer | null>(null); - -export function ServiceProvider({ - children, - container, -}: { - children: ReactNode; - container: ServiceContainer; -}) { - const value = useMemo(() => container, [container]); - - return ( - <ServiceContext.Provider value={value}>{children}</ServiceContext.Provider> - ); -} - -export function useService<T>(serviceIdentifier: ServiceIdentifier<T>): T { - const container = useContext(ServiceContext); - if (!container) { - throw new Error("useService must be used within a ServiceProvider"); - } - - return container.get(serviceIdentifier); -} 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/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/packages/workspace-server/package.json b/packages/workspace-server/package.json index 6363e7e9d7..f41948e80a 100644 --- a/packages/workspace-server/package.json +++ b/packages/workspace-server/package.json @@ -12,24 +12,39 @@ }, "scripts": { "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "node ../../scripts/rimraf.mjs .turbo" }, "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:" + "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/packages/workspace-server/src/db/migrations/0000_red_jigsaw.sql b/packages/workspace-server/src/db/migrations/0000_red_jigsaw.sql new file mode 100644 index 0000000000..962cf05b70 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0000_red_jigsaw.sql @@ -0,0 +1,47 @@ +CREATE TABLE `archives` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text NOT NULL, + `branch_name` text, + `checkpoint_id` text, + `archived_at` text NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `archives_workspaceId_unique` ON `archives` (`workspace_id`);--> statement-breakpoint +CREATE TABLE `repositories` ( + `id` text PRIMARY KEY NOT NULL, + `path` text NOT NULL, + `remote_url` text, + `last_accessed_at` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `repositories_path_unique` ON `repositories` (`path`);--> statement-breakpoint +CREATE TABLE `workspaces` ( + `id` text PRIMARY KEY NOT NULL, + `task_id` text NOT NULL, + `repository_id` text, + `mode` text NOT NULL, + `pinned_at` text, + `last_viewed_at` text, + `last_activity_at` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`repository_id`) REFERENCES `repositories`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `workspaces_taskId_unique` ON `workspaces` (`task_id`);--> statement-breakpoint +CREATE TABLE `worktrees` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text NOT NULL, + `name` text NOT NULL, + `path` text NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `worktrees_workspaceId_unique` ON `worktrees` (`workspace_id`); \ No newline at end of file diff --git a/packages/workspace-server/src/db/migrations/0001_tan_lifeguard.sql b/packages/workspace-server/src/db/migrations/0001_tan_lifeguard.sql new file mode 100644 index 0000000000..336afd3b78 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0001_tan_lifeguard.sql @@ -0,0 +1 @@ +CREATE INDEX `workspaces_repository_id_idx` ON `workspaces` (`repository_id`); \ No newline at end of file diff --git a/packages/workspace-server/src/db/migrations/0002_massive_bishop.sql b/packages/workspace-server/src/db/migrations/0002_massive_bishop.sql new file mode 100644 index 0000000000..aa19ba1e39 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0002_massive_bishop.sql @@ -0,0 +1,13 @@ +CREATE TABLE `suspensions` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text NOT NULL, + `branch_name` text, + `checkpoint_id` text, + `suspended_at` text NOT NULL, + `reason` text NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `suspensions_workspaceId_unique` ON `suspensions` (`workspace_id`); \ No newline at end of file diff --git a/packages/workspace-server/src/db/migrations/0003_fair_whiplash.sql b/packages/workspace-server/src/db/migrations/0003_fair_whiplash.sql new file mode 100644 index 0000000000..9fa93d5e56 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0003_fair_whiplash.sql @@ -0,0 +1,9 @@ +CREATE TABLE `auth_sessions` ( + `id` integer PRIMARY KEY NOT NULL CHECK (`id` = 1), + `refresh_token_encrypted` text NOT NULL, + `cloud_region` text NOT NULL, + `selected_project_id` integer, + `scope_version` integer NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); diff --git a/packages/workspace-server/src/db/migrations/0004_auth_preferences.sql b/packages/workspace-server/src/db/migrations/0004_auth_preferences.sql new file mode 100644 index 0000000000..d1b5c0d2d0 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0004_auth_preferences.sql @@ -0,0 +1,9 @@ +CREATE TABLE `auth_preferences` ( + `account_key` text NOT NULL, + `cloud_region` text NOT NULL, + `last_selected_project_id` integer, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `auth_preferences_account_region_idx` ON `auth_preferences` (`account_key`,`cloud_region`); diff --git a/packages/workspace-server/src/db/migrations/0005_youthful_scarlet_spider.sql b/packages/workspace-server/src/db/migrations/0005_youthful_scarlet_spider.sql new file mode 100644 index 0000000000..a4f59743a7 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0005_youthful_scarlet_spider.sql @@ -0,0 +1 @@ +ALTER TABLE `workspaces` ADD `linked_branch` text; diff --git a/packages/workspace-server/src/db/migrations/0006_youthful_warstar.sql b/packages/workspace-server/src/db/migrations/0006_youthful_warstar.sql new file mode 100644 index 0000000000..cfc3cbb081 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0006_youthful_warstar.sql @@ -0,0 +1,6 @@ +CREATE TABLE `default_additional_directories` ( + `path` text PRIMARY KEY NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); +--> statement-breakpoint +ALTER TABLE `workspaces` ADD `additional_directories` text DEFAULT '[]' NOT NULL; \ No newline at end of file diff --git a/packages/workspace-server/src/db/migrations/meta/0000_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000000..7cc31c22d3 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,316 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3ea9a080-30c3-4303-8dfd-e87e428f9ffc", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/0001_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000000..db11e00875 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,321 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bbdcad43-d134-48d3-9c6a-da01cde0e419", + "prevId": "3ea9a080-30c3-4303-8dfd-e87e428f9ffc", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/0002_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000000..6aeb20303e --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,405 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f5d77788-5c4e-4bfa-a114-096b8d377332", + "prevId": "bbdcad43-d134-48d3-9c6a-da01cde0e419", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/0003_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000000..b142ca13e6 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,466 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "68e7d461-1a53-41eb-a131-babc4db7329b", + "prevId": "f5d77788-5c4e-4bfa-a114-096b8d377332", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/0004_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000000..dc09e6076e --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,519 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c5ddb764-2a46-47c0-82b7-59658c60d306", + "prevId": "68e7d461-1a53-41eb-a131-babc4db7329b", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/0005_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000000..22e3e1018f --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,526 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b530fcd1-77cc-4df0-ad3c-148ee9d5c46b", + "prevId": "c5ddb764-2a46-47c0-82b7-59658c60d306", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/0006_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000000..ee3bb09afa --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,559 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "805d2ed3-331d-4ba6-8379-30f926268064", + "prevId": "b530fcd1-77cc-4df0-ad3c-148ee9d5c46b", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "default_additional_directories": { + "name": "default_additional_directories", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additional_directories": { + "name": "additional_directories", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/_journal.json b/packages/workspace-server/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000000..98745d4e45 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/_journal.json @@ -0,0 +1,55 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1772809171049, + "tag": "0000_red_jigsaw", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1773063486685, + "tag": "0001_tan_lifeguard", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1773335630838, + "tag": "0002_massive_bishop", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1774890000000, + "tag": "0003_fair_whiplash", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1774891000000, + "tag": "0004_auth_preferences", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1775755977659, + "tag": "0005_youthful_scarlet_spider", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1777639303535, + "tag": "0006_youthful_warstar", + "breakpoints": true + } + ] +} 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..a1b87f1938 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,7 @@ -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 +28,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..5b07f39388 --- /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 { createTestDb, type TestDatabase } from "../test-helpers"; +import type { DatabaseService } from "../service"; +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/packages/workspace-server/src/db/service.ts b/packages/workspace-server/src/db/service.ts new file mode 100644 index 0000000000..dd1a0dfe55 --- /dev/null +++ b/packages/workspace-server/src/db/service.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import { + type IStoragePaths, + STORAGE_PATHS_SERVICE, +} from "@posthog/platform/storage-paths"; +import Database from "better-sqlite3"; +import { + type BetterSQLite3Database, + drizzle, +} from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { inject, injectable, postConstruct, preDestroy } from "inversify"; + +import * as schema from "./schema"; + +const MIGRATIONS_FOLDER = path.join(__dirname, "db-migrations"); + +@injectable() +export class DatabaseService { + private _db: BetterSQLite3Database<typeof schema> | null = null; + private _sqlite: InstanceType<typeof Database> | null = null; + + constructor( + @inject(STORAGE_PATHS_SERVICE) + private readonly storagePaths: IStoragePaths, + ) {} + + get db(): BetterSQLite3Database<typeof schema> { + if (!this._db) { + throw new Error("Database not initialized — call initialize() first"); + } + return this._db; + } + + @postConstruct() + initialize(): void { + const dbPath = path.join(this.storagePaths.appDataPath, "posthog-code.db"); + 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) { + 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 100% rename from apps/code/src/main/db/test-helpers.ts rename to packages/workspace-server/src/db/test-helpers.ts diff --git a/packages/workspace-server/src/di/container.ts b/packages/workspace-server/src/di/container.ts index b54ae5947e..acfc07af86 100644 --- a/packages/workspace-server/src/di/container.ts +++ b/packages/workspace-server/src/di/container.ts @@ -1,9 +1,12 @@ 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 { LocalLogsService } from "../services/local-logs/service"; import { WatcherService } from "../services/watcher/service"; import { TOKENS } from "./tokens"; @@ -13,3 +16,12 @@ 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(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/packages/workspace-server/src/services/agent/agent.test.ts b/packages/workspace-server/src/services/agent/agent.test.ts new file mode 100644 index 0000000000..c1fa410fa0 --- /dev/null +++ b/packages/workspace-server/src/services/agent/agent.test.ts @@ -0,0 +1,509 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// --- Hoisted mocks --- + +const mockApp = vi.hoisted(() => ({ + getAppPath: vi.fn(() => "/mock/appPath"), + isPackaged: false, + getVersion: vi.fn(() => "0.0.0-test"), + getPath: vi.fn(() => "/mock/home"), +})); + +const mockNewSession = vi.hoisted(() => + vi.fn().mockResolvedValue({ + sessionId: "test-session-id", + configOptions: [], + }), +); + +const mockClientSideConnection = vi.hoisted(() => + vi.fn().mockImplementation(function (this: Record<string, unknown>) { + this.initialize = vi.fn().mockResolvedValue({}); + this.newSession = mockNewSession; + this.loadSession = vi.fn().mockResolvedValue({ configOptions: [] }); + this.resumeSession = vi.fn().mockResolvedValue({ configOptions: [] }); + }), +); + +const mockAgentRun = vi.hoisted(() => + vi.fn().mockImplementation(() => + Promise.resolve({ + clientStreams: { + readable: new ReadableStream(), + writable: new WritableStream(), + }, + }), + ), +); + +const mockAgentConstructor = vi.hoisted(() => + vi.fn().mockImplementation(function (this: Record<string, unknown>) { + this.run = mockAgentRun; + this.cleanup = vi.fn().mockResolvedValue(undefined); + this.getPosthogAPI = vi.fn(); + this.flushAllLogs = vi.fn().mockResolvedValue(undefined); + }), +); + +// --- Module mocks --- + +vi.mock("electron", () => ({ + app: mockApp, +})); + +vi.mock("@posthog/agent/agent", () => ({ + Agent: mockAgentConstructor, +})); + +vi.mock("@agentclientprotocol/sdk", () => ({ + ClientSideConnection: mockClientSideConnection, + ndJsonStream: vi.fn(), + PROTOCOL_VERSION: 1, +})); + +vi.mock("@posthog/agent", () => ({ + isMcpToolReadOnly: vi.fn(() => false), +})); + +vi.mock("@posthog/agent/posthog-api", () => ({ + getLlmGatewayUrl: vi.fn(() => "https://gateway.example.com"), +})); + +vi.mock("@posthog/agent/gateway-models", () => ({ + DEFAULT_GATEWAY_MODEL: "claude-opus-4-8", + DEFAULT_CODEX_MODEL: "gpt-5.5", + fetchGatewayModels: vi.fn().mockResolvedValue([]), + formatGatewayModelName: vi.fn(), + getProviderName: vi.fn(), + isBlockedModelId: vi.fn().mockReturnValue(false), +})); + +vi.mock("@posthog/agent/adapters/claude/session/jsonl-hydration", () => ({ + hydrateSessionJsonl: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("node:fs", async (importOriginal) => { + const original = await importOriginal<typeof import("node:fs")>(); + return { + ...original, + default: { + ...original, + existsSync: vi.fn(() => false), + realpathSync: vi.fn((p: string) => p), + }, + existsSync: vi.fn(() => false), + mkdirSync: vi.fn(), + symlinkSync: vi.fn(), + realpathSync: vi.fn((p: string) => p), + }; +}); + +// --- Import after mocks --- +import { AgentService, buildAutoApproveOutcome } from "./agent"; + +// --- Test helpers --- + +function createMockDependencies() { + return { + processTracking: { + register: vi.fn(), + unregister: vi.fn(), + killByTaskId: vi.fn(), + getByTaskId: vi.fn(() => []), + kill: vi.fn(), + }, + sleepService: { + acquire: vi.fn(), + release: vi.fn(), + }, + fsService: { + readRepoFile: vi.fn(), + writeRepoFile: vi.fn(), + }, + posthogPluginService: { + getPluginPath: vi.fn(() => "/mock/plugin"), + }, + agentAuthAdapter: { + ensureGatewayProxy: vi.fn().mockResolvedValue("http://127.0.0.1:9999"), + configureProcessEnv: vi.fn().mockResolvedValue(undefined), + createPosthogConfig: vi.fn((credentials) => ({ + apiUrl: credentials.apiHost, + getApiKey: vi.fn().mockResolvedValue("test-access-token"), + refreshApiKey: vi.fn().mockResolvedValue("fresh-access-token"), + projectId: credentials.projectId, + })), + buildMcpServers: vi.fn().mockResolvedValue({ + servers: [ + { + name: "posthog", + type: "http", + url: "https://mcp.posthog.com/mcp", + headers: [], + }, + ], + toolApprovals: {}, + toolInstallations: {}, + }), + }, + mcpAppsService: { + setServerConfigs: vi.fn(), + handleDiscovery: vi.fn().mockResolvedValue(undefined), + cleanup: vi.fn().mockResolvedValue(undefined), + notifyToolInput: vi.fn(), + notifyToolResult: vi.fn(), + notifyToolCancelled: vi.fn(), + }, + powerManager: { + onResume: vi.fn(() => () => {}), + preventSleep: vi.fn(() => () => {}), + }, + bundledResources: { + resolve: vi.fn((rel: string) => `/mock/appPath/${rel}`), + }, + appMeta: { + version: "0.0.0-test", + isProduction: false, + }, + storagePaths: { + appDataPath: "/mock/userData", + logsPath: "/mock/logs", + }, + defaultAdditionalDirectoryRepository: { + list: vi.fn(() => [] as string[]), + add: vi.fn(), + remove: vi.fn(), + }, + workspaceRepository: { + getAdditionalDirectories: vi.fn(() => [] as string[]), + addAdditionalDirectory: vi.fn(), + removeAdditionalDirectory: vi.fn(), + }, + loggerFactory: { + scope: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, + }; +} + +const baseSessionParams = { + taskId: "task-1", + taskRunId: "run-1", + repoPath: "/mock/repo", + apiHost: "https://app.posthog.com", + projectId: 1, +}; + +describe("AgentService", () => { + let service: AgentService; + let deps: ReturnType<typeof createMockDependencies>; + + beforeEach(() => { + vi.clearAllMocks(); + + deps = createMockDependencies(); + service = new AgentService( + deps.processTracking as never, + deps.sleepService as never, + deps.fsService as never, + deps.posthogPluginService as never, + deps.agentAuthAdapter as never, + deps.mcpAppsService as never, + deps.powerManager as never, + deps.bundledResources as never, + deps.appMeta as never, + deps.storagePaths as never, + deps.defaultAdditionalDirectoryRepository as never, + deps.workspaceRepository as never, + deps.loggerFactory as never, + ); + vi.spyOn(service, "emit"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("MCP servers", () => { + it("marks desktop sessions as local even though they have a taskRunId", async () => { + await service.startSession({ + ...baseSessionParams, + adapter: "codex", + }); + + expect(mockNewSession).toHaveBeenCalledTimes(1); + expect(mockNewSession.mock.calls[0][0]._meta).toMatchObject({ + taskRunId: "run-1", + environment: "local", + }); + }); + + it("passes MCP servers to newSession for codex adapter", async () => { + await service.startSession({ + ...baseSessionParams, + adapter: "codex", + }); + + expect(mockNewSession).toHaveBeenCalledTimes(1); + const mcpServers = mockNewSession.mock.calls[0][0].mcpServers; + expect(mcpServers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "posthog", + type: "http", + url: "https://mcp.posthog.com/mcp", + }), + ]), + ); + }); + + it("passes MCP servers to newSession for claude adapter", async () => { + await service.startSession({ + ...baseSessionParams, + adapter: "claude", + }); + + expect(mockNewSession).toHaveBeenCalledTimes(1); + const mcpServers = mockNewSession.mock.calls[0][0].mcpServers; + expect(mcpServers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "posthog", + type: "http", + url: "https://mcp.posthog.com/mcp", + }), + ]), + ); + }); + + it("passes identical MCP servers regardless of adapter", async () => { + await service.startSession({ + ...baseSessionParams, + taskRunId: "run-claude", + adapter: "claude", + }); + + await service.startSession({ + ...baseSessionParams, + taskRunId: "run-codex", + adapter: "codex", + }); + + const claudeMcp = mockNewSession.mock.calls[0][0].mcpServers; + const codexMcp = mockNewSession.mock.calls[1][0].mcpServers; + expect(codexMcp).toEqual(claudeMcp); + }); + }); + + describe("idle timeout", () => { + function injectSession( + svc: AgentService, + taskRunId: string, + overrides: Record<string, unknown> = {}, + ) { + const sessions = (svc as unknown as { sessions: Map<string, unknown> }) + .sessions; + sessions.set(taskRunId, { + taskRunId, + taskId: `task-for-${taskRunId}`, + repoPath: "/mock/repo", + agent: { cleanup: vi.fn().mockResolvedValue(undefined) }, + clientSideConnection: {}, + channel: `ch-${taskRunId}`, + createdAt: Date.now(), + lastActivityAt: Date.now(), + config: {}, + promptPending: false, + inFlightMcpToolCalls: new Map(), + mcpToolApprovals: {}, + toolInstallations: {}, + ...overrides, + }); + } + + function getIdleTimeouts(svc: AgentService) { + return ( + svc as unknown as { + idleTimeouts: Map< + string, + { handle: ReturnType<typeof setTimeout>; deadline: number } + >; + } + ).idleTimeouts; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("recordActivity is a no-op for unknown sessions", () => { + service.recordActivity("unknown-run"); + expect(getIdleTimeouts(service).size).toBe(0); + }); + + it("recordActivity sets a timeout for a known session", () => { + injectSession(service, "run-1"); + service.recordActivity("run-1"); + expect(getIdleTimeouts(service).has("run-1")).toBe(true); + }); + + it("recordActivity resets the timeout on subsequent calls", () => { + injectSession(service, "run-1"); + service.recordActivity("run-1"); + const firstDeadline = getIdleTimeouts(service).get("run-1")?.deadline; + if (firstDeadline === undefined) + throw new Error("Expected firstDeadline to be defined"); + + vi.advanceTimersByTime(5 * 60 * 1000); + service.recordActivity("run-1"); + const secondDeadline = getIdleTimeouts(service).get("run-1") + ?.deadline as number; + if (secondDeadline === undefined) + throw new Error("Expected secondDeadline to be defined"); + + expect(secondDeadline).toBeGreaterThan(firstDeadline); + }); + + it("kills idle session after timeout expires", () => { + injectSession(service, "run-1"); + service.recordActivity("run-1"); + + vi.advanceTimersByTime(15 * 60 * 1000); + + expect(service.emit).toHaveBeenCalledWith( + "session-idle-killed", + expect.objectContaining({ taskRunId: "run-1" }), + ); + }); + + it("does not kill session if activity is recorded before timeout", () => { + injectSession(service, "run-1"); + service.recordActivity("run-1"); + + vi.advanceTimersByTime(14 * 60 * 1000); + service.recordActivity("run-1"); + vi.advanceTimersByTime(14 * 60 * 1000); + + expect(service.emit).not.toHaveBeenCalledWith( + "session-idle-killed", + expect.anything(), + ); + }); + + it("reschedules when promptPending is true at timeout", () => { + injectSession(service, "run-1", { promptPending: true }); + service.recordActivity("run-1"); + + vi.advanceTimersByTime(15 * 60 * 1000); + + expect(service.emit).not.toHaveBeenCalledWith( + "session-idle-killed", + expect.anything(), + ); + expect(getIdleTimeouts(service).has("run-1")).toBe(true); + }); + + it("reschedules when inFlightMcpToolCalls is non-empty at timeout", () => { + const toolCalls = new Map([["tool-1", "some-mcp-tool"]]); + injectSession(service, "run-1", { inFlightMcpToolCalls: toolCalls }); + service.recordActivity("run-1"); + + vi.advanceTimersByTime(15 * 60 * 1000); + + expect(service.emit).not.toHaveBeenCalledWith( + "session-idle-killed", + expect.anything(), + ); + expect(getIdleTimeouts(service).has("run-1")).toBe(true); + }); + + it("kills session when inFlightMcpToolCalls is empty", () => { + injectSession(service, "run-1", { + inFlightMcpToolCalls: new Map(), + }); + service.recordActivity("run-1"); + + vi.advanceTimersByTime(15 * 60 * 1000); + + expect(service.emit).toHaveBeenCalledWith( + "session-idle-killed", + expect.objectContaining({ taskRunId: "run-1" }), + ); + }); + + it("checkIdleDeadlines kills expired sessions on resume", () => { + injectSession(service, "run-1"); + service.recordActivity("run-1"); + + const resumeHandler = ( + deps.powerManager.onResume.mock.calls[0] as unknown as [() => void] + )[0]; + expect(resumeHandler).toBeDefined(); + + vi.advanceTimersByTime(20 * 60 * 1000); + resumeHandler(); + + expect(service.emit).toHaveBeenCalledWith( + "session-idle-killed", + expect.objectContaining({ taskRunId: "run-1" }), + ); + }); + + it("checkIdleDeadlines does not kill non-expired sessions", () => { + injectSession(service, "run-1"); + service.recordActivity("run-1"); + + const resumeHandler = ( + deps.powerManager.onResume.mock.calls[0] as unknown as [() => void] + )[0]; + + vi.advanceTimersByTime(5 * 60 * 1000); + resumeHandler(); + + expect(service.emit).not.toHaveBeenCalledWith( + "session-idle-killed", + expect.anything(), + ); + }); + }); +}); + +describe("buildAutoApproveOutcome", () => { + it("prefers an allow_once option", () => { + expect( + buildAutoApproveOutcome([ + { optionId: "reject", kind: "reject_once", name: "Reject" }, + { optionId: "allow", kind: "allow_once", name: "Allow" }, + ]), + ).toEqual({ outcome: "selected", optionId: "allow" }); + }); + + it("prefers an allow_always option", () => { + expect( + buildAutoApproveOutcome([ + { optionId: "reject", kind: "reject_once", name: "Reject" }, + { optionId: "allow_always", kind: "allow_always", name: "Always" }, + ]), + ).toEqual({ outcome: "selected", optionId: "allow_always" }); + }); + + it("falls back to the first option when no allow option exists", () => { + expect( + buildAutoApproveOutcome([ + { optionId: "first", kind: "reject_once", name: "First" }, + { optionId: "second", kind: "reject_always", name: "Second" }, + ]), + ).toEqual({ outcome: "selected", optionId: "first" }); + }); + + it("returns a cancelled outcome when options is empty", () => { + expect(buildAutoApproveOutcome([])).toEqual({ outcome: "cancelled" }); + }); +}); diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts new file mode 100644 index 0000000000..1763b90ebf --- /dev/null +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -0,0 +1,1904 @@ +import fs, { mkdirSync, symlinkSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + type Client, + ClientSideConnection, + type ContentBlock, + ndJsonStream, + PROTOCOL_VERSION, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionConfigOption, + type SessionNotification, +} from "@agentclientprotocol/sdk"; +import { + isMcpToolReadOnly, + isNotification, + POSTHOG_NOTIFICATIONS, +} from "@posthog/agent"; +import type { McpToolApprovals } from "@posthog/agent/adapters/claude/mcp/tool-metadata"; +import { hydrateSessionJsonl } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; +import { getReasoningEffortOptions } from "@posthog/agent/adapters/reasoning-effort"; +import { Agent } from "@posthog/agent/agent"; +import { + getAvailableCodexModes, + getAvailableModes, +} from "@posthog/agent/execution-mode"; +import { + DEFAULT_CODEX_MODEL, + DEFAULT_GATEWAY_MODEL, + fetchGatewayModels, + formatGatewayModelName, + getProviderName, + isAnthropicModel, + isOpenAIModel, +} from "@posthog/agent/gateway-models"; +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 { 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 { + STORAGE_PATHS_SERVICE, + type IStoragePaths, +} 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 { 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 { 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, + type Credentials, + type EffortLevel, + type InterruptReason, + type PromptOutput, + type ReconnectSessionInput, + type SessionResponse, + type StartSessionInput, +} from "./schemas"; + +export type { InterruptReason }; + +function isDevBuild(): boolean { + return process.env.POSTHOG_CODE_IS_DEV === "true"; +} + +const MOCK_NODE_DIR_PREFIX = "agent-node"; + +function getMockNodeDir(): string { + const suffix = isDevBuild() ? "dev" : "prod"; + return join(tmpdir(), `${MOCK_NODE_DIR_PREFIX}-${suffix}`); +} + +/** Mark all content blocks as hidden so the renderer doesn't show a duplicate user message on retry */ +type MessageCallback = (message: unknown) => void; + +/** Shape of the `_meta.claudeCode` extension field on tool call updates. */ +interface ClaudeCodeToolMeta { + claudeCode?: { toolName?: string }; +} + +class NdJsonTap { + private decoder = new TextDecoder(); + private buffer = ""; + + constructor(private onMessage: MessageCallback) {} + + process(chunk: Uint8Array): void { + this.buffer += this.decoder.decode(chunk, { stream: true }); + const lines = this.buffer.split("\n"); + this.buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + this.onMessage(JSON.parse(line)); + } catch { + // Not valid JSON, skip + } + } + } +} + +function createTappedReadableStream( + underlying: ReadableStream<Uint8Array>, + onMessage: MessageCallback, + log: AgentScopedLogger, +): ReadableStream<Uint8Array> { + const reader = underlying.getReader(); + const tap = new NdJsonTap(onMessage); + + return new ReadableStream<Uint8Array>({ + async pull(controller) { + try { + const { value, done } = await reader.read(); + if (done) { + controller.close(); + return; + } + tap.process(value); + controller.enqueue(value); + } catch (err) { + // Stream may be closed if subprocess crashed - close gracefully + log.warn("Stream read failed (subprocess may have crashed)", { + error: err, + }); + controller.close(); + } + }, + cancel() { + // Release the reader when stream is cancelled + reader.releaseLock(); + }, + }); +} + +function createTappedWritableStream( + underlying: WritableStream<Uint8Array>, + onMessage: MessageCallback, + log: AgentScopedLogger, +): WritableStream<Uint8Array> { + const tap = new NdJsonTap(onMessage); + + return new WritableStream<Uint8Array>({ + async write(chunk) { + tap.process(chunk); + try { + const writer = underlying.getWriter(); + await writer.write(chunk); + writer.releaseLock(); + } catch (err) { + // Stream may be closed if subprocess crashed - log but don't throw + log.warn("Stream write failed (subprocess may have crashed)", { + error: err, + }); + } + }, + async close() { + try { + const writer = underlying.getWriter(); + await writer.close(); + writer.releaseLock(); + } catch { + // Stream may already be closed + } + }, + async abort(reason) { + try { + const writer = underlying.getWriter(); + await writer.abort(reason); + writer.releaseLock(); + } catch { + // Stream may already be closed + } + }, + }); +} + +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[]; + effort?: EffortLevel; + plugins: { type: "local"; path: string }[]; +}) { + return { + ...(args.additionalDirectories?.length && { + additionalDirectories: args.additionalDirectories, + }), + ...(args.effort && { effort: args.effort }), + plugins: args.plugins, + }; +} + +interface SessionConfig { + taskId: string; + taskRunId: string; + repoPath: string; + credentials: Credentials; + logUrl?: string; + /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ + sessionId?: string; + adapter?: "claude" | "codex"; + /** Permission mode to use for the session */ + permissionMode?: string; + /** Custom instructions injected into the system prompt */ + customInstructions?: string; + /** Effort level for Claude sessions */ + effort?: EffortLevel; + /** Model to use for the session (e.g. "claude-sonnet-4-6") */ + model?: string; + /** JSON Schema for structured task output — when set, the agent gets a create_output tool */ + jsonSchema?: Record<string, unknown> | null; +} + +interface ManagedSession { + taskRunId: string; + taskId: string; + repoPath: string; + agent: Agent; + clientSideConnection: ClientSideConnection; + channel: string; + createdAt: number; + lastActivityAt: number; + config: SessionConfig; + interruptReason?: InterruptReason; + promptPending: boolean; + pendingContext?: string; + configOptions?: SessionConfigOption[]; + /** Tracks in-flight MCP tool calls (toolCallId → toolKey) for cancellation */ + inFlightMcpToolCalls: Map<string, string>; + /** MCP tool approval states fetched at session start */ + mcpToolApprovals: McpToolApprovals; + /** Maps tool keys to their installation for backend approval updates */ + toolInstallations: McpToolInstallations; +} + +/** Get the agent session ID from a managed session, throwing if not set. */ +function getAgentSessionId(session: ManagedSession): string { + const { sessionId } = session.config; + if (!sessionId) { + throw new Error(`Session ${session.taskRunId} has no agent session ID`); + } + return sessionId; +} + +export function buildAutoApproveOutcome( + options: RequestPermissionRequest["options"], +): RequestPermissionResponse["outcome"] { + const allowOption = options.find( + (o) => o.kind === "allow_once" || o.kind === "allow_always", + ); + const optionId = allowOption?.optionId ?? options[0]?.optionId; + if (!optionId) { + return { outcome: "cancelled" }; + } + return { outcome: "selected", optionId }; +} + +interface PendingPermission { + resolve: (response: RequestPermissionResponse) => void; + reject: (error: Error) => void; + taskRunId: string; + toolCallId: string; +} + +@injectable() +export class AgentService extends TypedEventEmitter<AgentServiceEvents> { + private static readonly IDLE_TIMEOUT_MS = 15 * 60 * 1000; + + private sessions = new Map<string, ManagedSession>(); + private pendingPermissions = new Map<string, PendingPermission>(); + private mockNodeReady = false; + private idleTimeouts = new Map< + string, + { handle: ReturnType<typeof setTimeout>; deadline: number } + >(); + private processTracking: ProcessTrackingService; + private sleepService: AgentSleepCoordinator; + private fsService: AgentRepoFiles; + private posthogPluginService: PosthogPluginService; + private agentAuthAdapter: AgentAuthAdapter; + private mcpAppsService: AgentMcpApps; + private readonly log: AgentScopedLogger; + private readonly onAgentLog: AgentTypes.OnLogCallback; + + constructor( + @inject(PROCESS_TRACKING_SERVICE) + processTracking: ProcessTrackingService, + @inject(AGENT_SLEEP_COORDINATOR) + sleepService: AgentSleepCoordinator, + @inject(AGENT_REPO_FILES) + fsService: AgentRepoFiles, + @inject(POSTHOG_PLUGIN_SERVICE) + posthogPluginService: PosthogPluginService, + @inject(AGENT_AUTH_ADAPTER) + agentAuthAdapter: AgentAuthAdapter, + @inject(AGENT_MCP_APPS) + mcpAppsService: AgentMcpApps, + @inject(POWER_MANAGER_SERVICE) + powerManager: IPowerManager, + @inject(BUNDLED_RESOURCES_SERVICE) + private readonly bundledResources: IBundledResources, + @inject(APP_META_SERVICE) + private readonly appMeta: IAppMeta, + @inject(STORAGE_PATHS_SERVICE) + private readonly storagePaths: IStoragePaths, + @inject(DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY) + private readonly defaultAdditionalDirectoryRepository: IDefaultAdditionalDirectoryRepository, + @inject(WORKSPACE_REPOSITORY) + private readonly workspaceRepository: IWorkspaceRepository, + @inject(AGENT_LOGGER) + loggerFactory: AgentLogger, + ) { + super(); + this.processTracking = processTracking; + this.sleepService = sleepService; + this.fsService = fsService; + this.posthogPluginService = posthogPluginService; + this.agentAuthAdapter = agentAuthAdapter; + this.mcpAppsService = mcpAppsService; + this.log = loggerFactory.scope("agent-service"); + this.onAgentLog = makeOnAgentLog(loggerFactory); + + powerManager.onResume(() => this.checkIdleDeadlines()); + } + + private getClaudeCliPath(): string { + // Keep in sync with the destDir in apps/code/vite.main.config.mts + // (copyClaudeExecutable plugin). + const binary = process.platform === "win32" ? "claude.exe" : "claude"; + return this.bundledResources.resolve(`.vite/build/claude-cli/${binary}`); + } + + private getCodexBinaryPath(): string { + return this.bundledResources.resolve(".vite/build/codex-acp/codex-acp"); + } + + /** + * Respond to a pending permission request from the UI. + * This resolves the promise that the agent is waiting on. + */ + public respondToPermission( + taskRunId: string, + toolCallId: string, + optionId: string, + customInput?: string, + answers?: Record<string, string>, + ): void { + const key = `${taskRunId}:${toolCallId}`; + const pending = this.pendingPermissions.get(key); + + if (!pending) { + this.log.warn("No pending permission found", { taskRunId, toolCallId }); + return; + } + + this.log.info("Permission response received", { + taskRunId, + toolCallId, + optionId, + hasCustomInput: !!customInput, + hasAnswers: !!answers, + }); + + const meta: Record<string, unknown> = {}; + if (customInput) meta.customInput = customInput; + if (answers) meta.answers = answers; + + pending.resolve({ + outcome: { + outcome: "selected", + optionId, + }, + ...(Object.keys(meta).length > 0 && { _meta: meta }), + }); + + this.pendingPermissions.delete(key); + this.recordActivity(taskRunId); + } + + /** + * Cancel a pending permission request. + * This resolves the promise with a "cancelled" outcome per ACP spec. + */ + public cancelPermission(taskRunId: string, toolCallId: string): void { + const key = `${taskRunId}:${toolCallId}`; + const pending = this.pendingPermissions.get(key); + + if (!pending) { + this.log.warn("No pending permission found to cancel", { + taskRunId, + toolCallId, + }); + return; + } + + this.log.info("Permission cancelled", { taskRunId, toolCallId }); + + pending.resolve({ + outcome: { + outcome: "cancelled", + }, + }); + + this.pendingPermissions.delete(key); + this.recordActivity(taskRunId); + } + + /** + * Check if any sessions are currently active (i.e. have a prompt pending). + */ + public hasActiveSessions(): boolean { + for (const session of this.sessions.values()) { + if (session.promptPending || session.inFlightMcpToolCalls.size > 0) { + return true; + } + } + return false; + } + + public recordActivity(taskRunId: string): void { + if (!this.sessions.has(taskRunId)) return; + + const existing = this.idleTimeouts.get(taskRunId); + if (existing) clearTimeout(existing.handle); + + const deadline = Date.now() + AgentService.IDLE_TIMEOUT_MS; + const handle = setTimeout(() => { + this.killIdleSession(taskRunId); + }, AgentService.IDLE_TIMEOUT_MS); + + this.idleTimeouts.set(taskRunId, { handle, deadline }); + } + + private killIdleSession(taskRunId: string): void { + const session = this.sessions.get(taskRunId); + if (!session) return; + if (session.promptPending || session.inFlightMcpToolCalls.size > 0) { + this.recordActivity(taskRunId); + return; + } + this.log.info("Killing idle session", { + taskRunId, + taskId: session.taskId, + }); + this.emit(AgentServiceEvent.SessionIdleKilled, { + taskRunId, + taskId: session.taskId, + }); + this.cleanupSession(taskRunId).catch((err) => { + this.log.error("Failed to cleanup idle session", { taskRunId, err }); + }); + } + + private checkIdleDeadlines(): void { + const now = Date.now(); + const expired = [...this.idleTimeouts.entries()].filter( + ([, { deadline }]) => now >= deadline, + ); + for (const [taskRunId, { handle }] of expired) { + clearTimeout(handle); + this.killIdleSession(taskRunId); + } + } + + private buildSystemPrompt( + credentials: Credentials, + taskId: string, + customInstructions?: string, + additionalDirectories?: string[], + ): { + append: string; + } { + let prompt = `PostHog context: use project ${credentials.projectId} on ${credentials.apiHost}. When using PostHog MCP tools, operate only on this project.`; + + prompt += ` + +## Attribution +Do NOT use Claude Code's default attribution (no "Co-Authored-By" trailers, no "Generated with [Claude Code]" lines). + +Instead, add the following trailers to EVERY commit message (after a blank line at the end): + Generated-By: PostHog Code + Task-Id: ${taskId} + +Example: +\`\`\` +git commit -m "$(cat <<'EOF' +fix: resolve login redirect loop + +Generated-By: PostHog Code +Task-Id: ${taskId} +EOF +)" +\`\`\` + +When creating new branches, prefix them with \`posthog-code/\` (e.g. \`posthog-code/fix-login-redirect\`). + +When creating pull requests, add the following footer at the end of the PR description: +\`\`\` +--- +*Created with [PostHog Code](https://posthog.com/code?ref=pr)* +\`\`\``; + + if (customInstructions) { + prompt += `\n\nUser custom instructions:\n${customInstructions}`; + } + + if (additionalDirectories?.length) { + const escapeXml = (s: string) => + s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + const dirs = additionalDirectories + .map((d) => ` <directory>${escapeXml(d)}</directory>`) + .join("\n"); + prompt += `\n\nThe user has granted you access to additional directories outside the working directory. You may read and edit files in these paths just like the working directory:\n<additional_directories>\n${dirs}\n</additional_directories>`; + } + + return { append: prompt }; + } + + private resolveAdditionalDirectories(taskId: string): string[] { + const defaults = this.defaultAdditionalDirectoryRepository.list(); + const taskScoped = + this.workspaceRepository.getAdditionalDirectories(taskId); + const seen = new Set<string>(); + const merged: string[] = []; + for (const path of [...defaults, ...taskScoped]) { + if (!path || seen.has(path)) continue; + seen.add(path); + merged.push(path); + } + return merged; + } + + async startSession(params: StartSessionInput): Promise<SessionResponse> { + this.validateSessionParams(params); + const config = this.toSessionConfig(params); + const session = await this.getOrCreateSession(config, false); + if (!session) { + throw new Error("Failed to create session"); + } + return this.toSessionResponse(session); + } + + async reconnectSession( + params: ReconnectSessionInput, + ): Promise<SessionResponse | null> { + try { + this.validateSessionParams(params); + } catch (err) { + this.log.error("Invalid reconnect params", err); + return null; + } + + const config = this.toSessionConfig(params); + const session = await this.getOrCreateSession(config, true); + return session ? this.toSessionResponse(session) : null; + } + + private async getOrCreateSession( + config: SessionConfig, + isReconnect: boolean, + isRetry = false, + ): Promise<ManagedSession | null> { + const { + taskId, + taskRunId, + repoPath: rawRepoPath, + credentials, + logUrl, + adapter, + permissionMode, + customInstructions, + effort, + model, + jsonSchema, + } = config; + + // Preview config doesn't need a real repo — use a temp directory + const repoPath = taskId === "__preview__" ? tmpdir() : rawRepoPath; + + const additionalDirectories = + taskId === "__preview__" ? [] : this.resolveAdditionalDirectories(taskId); + + if (!isRetry) { + const existing = this.sessions.get(taskRunId); + if (existing) { + return existing; + } + + for (const proc of this.processTracking.getByTaskId(taskId)) { + if ( + (proc.category === "agent" || proc.category === "child") && + proc.metadata?.taskRunId === taskRunId + ) { + this.processTracking.kill(proc.pid); + } + } + + // Clean up any prior session for this taskRunId before creating a new one + await this.cleanupSession(taskRunId); + } + + const channel = `agent-event:${taskRunId}`; + const mockNodeDir = this.setupMockNodeEnvironment(); + const proxyUrl = await this.agentAuthAdapter.ensureGatewayProxy( + credentials.apiHost, + ); + await this.agentAuthAdapter.configureProcessEnv({ + credentials, + mockNodeDir, + proxyUrl, + claudeCliPath: this.getClaudeCliPath(), + }); + + const isPreview = taskId === "__preview__"; + + const agent = new Agent({ + posthog: { + ...this.agentAuthAdapter.createPosthogConfig(credentials), + userAgent: `posthog/desktop.hog.dev; version: ${this.appMeta.version}`, + }, + skipLogPersistence: isPreview, + localCachePath: join(homedir(), ".posthog-code"), + debug: isDevBuild(), + onLog: this.onAgentLog, + }); + + try { + const systemPrompt = this.buildSystemPrompt( + credentials, + taskId, + customInstructions, + additionalDirectories, + ); + + const acpConnection = await agent.run(taskId, taskRunId, { + adapter, + gatewayUrl: proxyUrl, + codexBinaryPath: + adapter === "codex" ? this.getCodexBinaryPath() : undefined, + model, + instructions: adapter === "codex" ? systemPrompt.append : undefined, + additionalDirectories: + adapter === "codex" ? additionalDirectories : undefined, + onStructuredOutput: jsonSchema + ? async (output) => { + const posthogAPI = agent.getPosthogAPI(); + if (posthogAPI) { + await posthogAPI.updateTaskRun(taskId, taskRunId, { output }); + } + } + : undefined, + processCallbacks: { + onProcessSpawned: (info) => { + this.processTracking.register( + info.pid, + "agent", + `agent:${taskRunId}`, + { + taskRunId, + taskId, + command: info.command, + }, + taskId, + ); + }, + onProcessExited: (pid) => { + this.processTracking.unregister(pid, "agent-exited"); + }, + onMcpServersReady: (serverNames) => { + this.mcpAppsService.handleDiscovery(serverNames).catch((err) => { + this.log.warn("MCP Apps discovery failed", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }, + }, + }); + const { clientStreams } = acpConnection; + + const connection = this.createClientConnection( + taskRunId, + channel, + clientStreams, + ); + + await connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { + readTextFile: true, + writeTextFile: true, + }, + terminal: true, + }, + }); + + const { + servers: mcpServers, + toolApprovals, + toolInstallations, + } = await this.agentAuthAdapter.buildMcpServers(credentials); + + // Store server configs for lazy MCP connections — actual connections + // are created on-demand when UI resources are first requested. + this.mcpAppsService.setServerConfigs( + mcpServers.map((s) => ({ + name: s.name, + url: s.url, + headers: Object.fromEntries(s.headers.map((h) => [h.name, h.value])), + })), + ); + + let externalPlugins: Awaited<ReturnType<typeof discoverExternalPlugins>> = + []; + try { + externalPlugins = await discoverExternalPlugins( + { + userDataDir: this.storagePaths.appDataPath, + repoPath, + }, + this.log, + ); + } catch (err) { + this.log.warn("Failed to discover external plugins", { + error: err instanceof Error ? err.message : String(err), + }); + } + const plugins = [ + { + type: "local" as const, + path: this.posthogPluginService.getPluginPath(), + }, + ...externalPlugins, + ]; + const claudeCodeOptions = buildClaudeCodeOptions({ + additionalDirectories, + effort, + plugins, + }); + + let configOptions: SessionConfigOption[] | undefined; + let agentSessionId: string; + + // Claude-specific: hydrate session JSONL from PostHog before resuming. + // If hydration finds no conversation to restore, skip the resume and + // fall through to creating a new session. This avoids a doomed + // resumeSession that would fail with "Resource not found" + if (isReconnect && config.sessionId) { + const existingSessionId = config.sessionId; + + if (adapter !== "codex") { + const posthogAPI = agent.getPosthogAPI(); + if (posthogAPI) { + const hasSession = await hydrateSessionJsonl({ + sessionId: existingSessionId, + cwd: repoPath, + taskId, + runId: taskRunId, + permissionMode: config.permissionMode, + posthogAPI, + log: this.log, + }); + if (!hasSession) { + this.log.info( + "No session JSONL to resume, creating new session instead", + { taskId, taskRunId }, + ); + config.sessionId = undefined; + } + } + } + } + + if (isReconnect && config.sessionId) { + const existingSessionId = config.sessionId; + + // Both adapters implement resumeSession: + // - Claude: delegates to SDK's resumeSession with JSONL hydration + // - Codex: delegates to codex-acp's loadSession internally + const resumeResponse = await connection.resumeSession({ + sessionId: existingSessionId, + cwd: repoPath, + mcpServers, + _meta: { + ...(logUrl && { + persistence: { taskId, runId: taskRunId, logUrl }, + }), + taskRunId, + environment: "local", + sessionId: existingSessionId, + systemPrompt, + mcpToolApprovals: toolApprovals, + ...(permissionMode && { permissionMode }), + ...(model != null && { model }), + ...(jsonSchema && { jsonSchema }), + claudeCode: { + options: claudeCodeOptions, + }, + }, + }); + configOptions = resumeResponse?.configOptions ?? undefined; + agentSessionId = existingSessionId; + } else { + if (isReconnect) { + this.log.info("No sessionId for reconnect, creating new session", { + taskId, + taskRunId, + }); + } + const newSessionResponse = await connection.newSession({ + cwd: repoPath, + mcpServers, + _meta: { + taskRunId, + environment: "local", + systemPrompt, + mcpToolApprovals: toolApprovals, + ...(permissionMode && { permissionMode }), + ...(model != null && { model }), + ...(jsonSchema && { jsonSchema }), + claudeCode: { + options: claudeCodeOptions, + }, + }, + }); + configOptions = newSessionResponse.configOptions ?? undefined; + agentSessionId = newSessionResponse.sessionId; + } + + config.sessionId = agentSessionId; + + const session: ManagedSession = { + taskRunId, + taskId, + repoPath, + agent, + clientSideConnection: connection, + channel, + createdAt: Date.now(), + lastActivityAt: Date.now(), + config, + promptPending: false, + configOptions, + inFlightMcpToolCalls: new Map(), + mcpToolApprovals: toolApprovals, + toolInstallations, + }; + + this.sessions.set(taskRunId, session); + this.recordActivity(taskRunId); + + if (isRetry) { + this.log.info("Session created after auth retry", { taskRunId }); + } + return session; + } catch (err) { + try { + await agent.cleanup(); + } catch { + this.log.debug("Agent cleanup failed during error handling", { + taskRunId, + }); + } + + if (!isRetry && isAuthError(err)) { + this.log.warn( + `Auth error during ${isReconnect ? "reconnect" : "create"}, retrying`, + { taskRunId }, + ); + return this.getOrCreateSession(config, isReconnect, true); + } + this.log.error( + `Failed to ${isReconnect ? "reconnect" : "create"} session${ + isRetry ? " after retry" : "" + }`, + err, + ); + // Non-auth reconnect failure on first attempt: fall back to a fresh session. + // If this was already an auth retry (isRetry=true), we've exhausted retries + // and return null to avoid infinite loops. + if (isReconnect && !isRetry) { + this.log.warn("Reconnect failed, falling back to new session", { + taskRunId, + }); + config.sessionId = undefined; + return this.getOrCreateSession(config, false, false); + } + if (isReconnect) return null; + throw err; + } + } + + async prompt( + sessionId: string, + prompt: ContentBlock[], + ): Promise<PromptOutput> { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + // Prepend pending context if present + let finalPrompt = prompt; + if (session.pendingContext) { + this.log.info("Prepending context to prompt", { sessionId }); + finalPrompt = [ + { + type: "text", + text: `_${session.pendingContext}_\n\n`, + _meta: { ui: { hidden: true } }, + }, + ...prompt, + ]; + session.pendingContext = undefined; + } + + session.lastActivityAt = Date.now(); + session.promptPending = true; + this.recordActivity(sessionId); + this.sleepService.acquire(sessionId); + + try { + const result = await session.clientSideConnection.prompt({ + sessionId: getAgentSessionId(session), + prompt: finalPrompt, + }); + return { + stopReason: result.stopReason, + _meta: result._meta as PromptOutput["_meta"], + }; + } finally { + session.promptPending = false; + session.lastActivityAt = Date.now(); + this.recordActivity(sessionId); + this.sleepService.release(sessionId); + + if (!this.hasActiveSessions()) { + this.emit(AgentServiceEvent.SessionsIdle, undefined); + } + } + } + + async cancelSession(sessionId: string): Promise<boolean> { + const session = this.sessions.get(sessionId); + if (!session) return false; + + try { + await this.cleanupSession(sessionId); + return true; + } catch (_err) { + return false; + } + } + + async cancelSessionsByTaskId(taskId: string): Promise<void> { + for (const [taskRunId, session] of this.sessions) { + if (session.taskId === taskId) { + await this.cleanupSession(taskRunId); + } + } + } + + async cancelPrompt( + sessionId: string, + reason?: InterruptReason, + ): Promise<boolean> { + const session = this.sessions.get(sessionId); + if (!session) return false; + + try { + this.cancelInFlightMcpToolCalls(session); + await session.clientSideConnection.cancel({ + sessionId: getAgentSessionId(session), + _meta: reason ? { interruptReason: reason } : undefined, + }); + if (reason) { + session.interruptReason = reason; + this.log.info("Session interrupted", { sessionId, reason }); + } + return true; + } catch (err) { + this.log.error("Failed to cancel prompt", { sessionId, err }); + return false; + } + } + + getSession(taskRunId: string): ManagedSession | undefined { + return this.sessions.get(taskRunId); + } + + async setSessionConfigOption( + sessionId: string, + configId: string, + value: string, + ): Promise<void> { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + + try { + const result = await session.clientSideConnection.setSessionConfigOption({ + sessionId: getAgentSessionId(session), + configId, + value, + }); + session.configOptions = result.configOptions ?? session.configOptions; + + const updatedModeOption = session.configOptions?.find( + (opt) => opt.category === "mode", + ); + if ( + updatedModeOption && + typeof updatedModeOption.currentValue === "string" + ) { + session.config.permissionMode = updatedModeOption.currentValue; + } + } catch (err) { + this.log.error("Failed to set session config option", { + sessionId, + configId, + value, + err, + }); + throw err; + } + } + + listSessions(taskId?: string): ManagedSession[] { + const all = Array.from(this.sessions.values()); + return taskId ? all.filter((s) => s.taskId === taskId) : all; + } + + /** + * Resolve env-var overrides set by the SessionStart-style hooks of the most + * recently active agent session for `taskId`. + * + * Used by git/gh operations triggered from the UI (Commit, Create PR) so + * they pick up the same hook env the agent itself sees — most importantly + * the SSH_AUTH_SOCK that Secretive's hook re-points at the Secretive agent + * for commit signing. Returns an empty object when there is no session for + * the task or when no hook output is available. + */ + public async getSessionEnvForTask( + taskId: string, + ): Promise<Record<string, string>> { + const candidates = this.listSessions(taskId) + .filter((s) => !!s.config.sessionId) + .sort((a, b) => b.lastActivityAt - a.lastActivityAt); + const session = candidates[0]; + if (!session?.config.sessionId) return {}; + return loadSessionEnvOverrides(session.config.sessionId); + } + + /** + * Get sessions that were interrupted for a specific reason. + * Optionally filter by repoPath to get only sessions for a specific repo. + */ + getInterruptedSessions( + reason: InterruptReason, + repoPath?: string, + ): ManagedSession[] { + return Array.from(this.sessions.values()).filter( + (s) => + s.interruptReason === reason && + (repoPath === undefined || s.repoPath === repoPath), + ); + } + + /** + * Resume an interrupted session by clearing the interrupt reason + * and sending a continue prompt. + */ + async resumeInterruptedSession(sessionId: string): Promise<PromptOutput> { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Session not found: ${sessionId}`); + } + if (!session.interruptReason) { + throw new Error(`Session ${sessionId} was not interrupted`); + } + this.log.info("Resuming interrupted session", { + sessionId, + reason: session.interruptReason, + }); + // Clear the interrupt reason + session.interruptReason = undefined; + // Send a continue prompt + return this.prompt(sessionId, [ + { type: "text", text: "Continue where you left off." }, + ]); + } + + setPendingContext(taskRunId: string, context: string): void { + const session = this.sessions.get(taskRunId); + if (!session) { + this.log.warn("Session not found for setPendingContext", { taskRunId }); + return; + } + session.pendingContext = context; + this.log.info("Set pending context on session", { + taskRunId, + contextLength: context.length, + }); + } + + /** + * Notify a session of a context change (CWD moved, detached HEAD, etc). + * Used when focusing/unfocusing worktrees - the agent doesn't need to respawn + * because it has additionalDirectories configured, but it should know about the change. + */ + async notifySessionContext( + sessionId: string, + context: import("./schemas.js").SessionContextChange, + ): Promise<void> { + const session = this.sessions.get(sessionId); + if (!session) { + this.log.warn("Session not found for context notification", { + sessionId, + }); + return; + } + + const contextMessage = this.buildContextMessage(context); + + // Check if session is currently busy + if (session.promptPending) { + // Active session: send immediately with continue instruction + this.prompt(sessionId, [ + { + type: "text", + text: `${contextMessage} Continue where you left off.`, + _meta: { ui: { hidden: true } }, + }, + ]); + } else { + // Idle session: store for prepending to next user message + session.pendingContext = contextMessage; + } + + this.log.info("Notified session of context change", { + sessionId, + context, + wasPromptPending: session.promptPending, + }); + } + + private buildContextMessage( + context: import("./schemas.js").SessionContextChange, + ): string { + if (context.isDetached) { + return `Your worktree is now on detached HEAD while the user edits in their main repo. The branch is \`${context.branchName}\`. + +For git operations while detached: +- Commit: works normally +- Push: \`git push origin HEAD:refs/heads/${context.branchName}\` +- Pull: \`git fetch origin ${context.branchName} && git merge FETCH_HEAD\``; + } + return `Your worktree is back on branch \`${context.branchName}\`. Normal git commands work again.`; + } + + @preDestroy() + async cleanupAll(): Promise<void> { + for (const { handle } of this.idleTimeouts.values()) clearTimeout(handle); + this.idleTimeouts.clear(); + const sessionIds = Array.from(this.sessions.keys()); + this.log.info("Cleaning up all agent sessions", { + sessionCount: sessionIds.length, + }); + + for (const session of this.sessions.values()) { + try { + await session.agent.flushAllLogs(); + } catch { + this.log.debug("Failed to flush session logs during shutdown"); + } + } + + for (const taskRunId of sessionIds) { + await this.cleanupSession(taskRunId); + } + + this.log.info("All agent sessions cleaned up"); + } + + private setupMockNodeEnvironment(): string { + const mockNodeDir = getMockNodeDir(); + if (!this.mockNodeReady) { + try { + mkdirSync(mockNodeDir, { recursive: true }); + const nodeSymlinkPath = join(mockNodeDir, "node"); + try { + symlinkSync(process.execPath, nodeSymlinkPath); + } catch (err) { + if ( + !(err instanceof Error) || + !("code" in err) || + err.code !== "EEXIST" + ) { + throw err; + } + } + this.mockNodeReady = true; + } catch (err) { + this.log.warn("Failed to setup mock node environment", err); + } + } + return mockNodeDir; + } + + private cancelInFlightMcpToolCalls(session: ManagedSession): void { + for (const [toolCallId, toolKey] of session.inFlightMcpToolCalls) { + this.mcpAppsService.notifyToolCancelled(toolKey, toolCallId); + } + + session.inFlightMcpToolCalls.clear(); + } + + private async cleanupSession(taskRunId: string): Promise<void> { + const session = this.sessions.get(taskRunId); + if (session) { + this.cancelInFlightMcpToolCalls(session); + this.sleepService.release(taskRunId); + try { + await session.agent.cleanup(); + } catch { + this.log.debug("Agent cleanup failed", { taskRunId }); + } + + this.sessions.delete(taskRunId); + + const timeout = this.idleTimeouts.get(taskRunId); + if (timeout) { + clearTimeout(timeout.handle); + this.idleTimeouts.delete(taskRunId); + } + + // When no sessions remain, tear down MCP Apps connections and cached resources + if (this.sessions.size === 0) { + this.mcpAppsService.cleanup().catch(() => { + this.log.debug("MCP Apps cleanup failed"); + }); + } + } + } + + private createClientConnection( + taskRunId: string, + _channel: string, + clientStreams: { readable: ReadableStream; writable: WritableStream }, + ): ClientSideConnection { + // Capture service reference for use in client callbacks + const service = this; + + const emitToRenderer = (payload: unknown) => { + // Emit event via TypedEventEmitter for tRPC subscription + this.emit(AgentServiceEvent.SessionEvent, { + taskRunId, + payload, + }); + }; + + const onAcpMessage = (message: unknown) => { + const acpMessage: AcpMessage = { + type: "acp_message", + ts: Date.now(), + message: message as AcpMessage["message"], + }; + emitToRenderer(acpMessage); + + // Inspect tool call updates for PR URLs and file activity + this.handleToolCallUpdate(taskRunId, message as AcpMessage["message"]); + }; + + 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 = { + async requestPermission( + params: RequestPermissionRequest, + ): Promise<RequestPermissionResponse> { + const toolName = + (params.toolCall?.rawInput as { toolName?: string } | undefined) + ?.toolName || ""; + const toolCallId = params.toolCall?.toolCallId || ""; + + service.log.info("requestPermission called", { + taskRunId, + toolCallId, + toolName, + title: params.toolCall?.title, + optionCount: params.options.length, + }); + + if (toolName && isMcpToolReadOnly(toolName)) { + const session = service.sessions.get(taskRunId); + const approvalState = session?.mcpToolApprovals?.[toolName]; + if (approvalState === "approved") { + service.log.info("Auto-approving read-only MCP tool", { + taskRunId, + toolName, + }); + return { outcome: buildAutoApproveOutcome(params.options) }; + } + } + + // If we have a toolCallId, always prompt the user for permission. + // The claude.ts adapter only calls requestPermission when user input is needed. + // (It handles auto-approve internally for acceptEdits/bypassPermissions modes) + if (toolCallId) { + service.sleepService.release(taskRunId); + try { + const response = await new Promise<RequestPermissionResponse>( + (resolve, reject) => { + const key = `${taskRunId}:${toolCallId}`; + service.pendingPermissions.set(key, { + resolve, + reject, + taskRunId, + toolCallId, + }); + + service.log.info("Emitting permission request to renderer", { + taskRunId, + toolCallId, + }); + const { sessionId: _agentSessionId, ...rest } = params; + service.emit(AgentServiceEvent.PermissionRequest, { + ...rest, + taskRunId, + }); + }, + ); + + const approved = + response.outcome?.outcome === "selected" && + (response.outcome.optionId === "allow" || + response.outcome.optionId === "allow_always"); + if (approved && toolName) { + const session = service.sessions.get(taskRunId); + if ( + session?.mcpToolApprovals?.[toolName] === "needs_approval" && + session.toolInstallations[toolName] + ) { + const { installationId, toolName: rawToolName } = + session.toolInstallations[toolName]; + try { + await service.agentAuthAdapter.updateMcpToolApproval( + session.config.credentials, + installationId, + rawToolName, + "approved", + ); + session.mcpToolApprovals[toolName] = "approved"; + } catch (err) { + service.log.warn( + "Failed to update tool approval on backend", + { + toolName, + error: err instanceof Error ? err.message : String(err), + }, + ); + } + } + } + + return response; + } finally { + // Only re-acquire if session wasn't cleaned up while waiting + if (service.sessions.has(taskRunId)) { + service.sleepService.acquire(taskRunId); + } + } + } + + // Fallback: no toolCallId means we can't track the response, auto-approve + service.log.warn( + "No toolCallId in permission request, auto-approving", + { + taskRunId, + toolName, + }, + ); + return { outcome: buildAutoApproveOutcome(params.options) }; + }, + + async readTextFile(params) { + const session = service.sessions.get(taskRunId); + if (!session) { + throw new Error(`No active session for taskRunId=${taskRunId}`); + } + const repoPath = session.config.repoPath; + const relativePath = service.toRepoRelativePath(repoPath, params.path); + const content = await service.fsService.readRepoFile( + repoPath, + relativePath, + ); + if (content === null) { + throw new Error(`File not found: ${params.path}`); + } + return { content }; + }, + + async writeTextFile(params) { + const session = service.sessions.get(taskRunId); + if (!session) { + throw new Error(`No active session for taskRunId=${taskRunId}`); + } + const repoPath = session.config.repoPath; + const relativePath = service.toRepoRelativePath(repoPath, params.path); + await service.fsService.writeRepoFile( + repoPath, + relativePath, + params.content, + ); + return {}; + }, + + async sessionUpdate(params: SessionNotification) { + // Forward MCP tool events to McpAppsService using the SDK's + // typed discriminated union instead of parsing raw JSON. + const { update } = params; + if ( + update.sessionUpdate !== "tool_call" && + update.sessionUpdate !== "tool_call_update" + ) { + return; + } + + const toolName = (update._meta as ClaudeCodeToolMeta | undefined) + ?.claudeCode?.toolName; + if (!toolName?.startsWith("mcp__")) return; + + const session = service.sessions.get(taskRunId); + if (update.sessionUpdate === "tool_call") { + session?.inFlightMcpToolCalls.set(update.toolCallId, toolName); + service.mcpAppsService.notifyToolInput( + toolName, + update.toolCallId, + update.rawInput, + ); + } else if ( + update.status === "completed" || + update.status === "failed" + ) { + session?.inFlightMcpToolCalls.delete(update.toolCallId); + service.mcpAppsService.notifyToolResult( + toolName, + update.toolCallId, + update.rawOutput, + update.status === "failed", + ); + } + }, + + extNotification: async ( + method: string, + params: Record<string, unknown>, + ): Promise<void> => { + if (isNotification(method, POSTHOG_NOTIFICATIONS.SDK_SESSION)) { + const { + taskRunId: notifTaskRunId, + sessionId, + adapter: notifAdapter, + } = params as { + taskRunId: string; + sessionId: string; + adapter: "claude" | "codex"; + }; + const session = this.sessions.get(notifTaskRunId); + if (session) { + session.config.sessionId = sessionId; + if (notifAdapter) { + session.config.adapter = notifAdapter; + } + service.log.info("Session ID captured", { + taskRunId: notifTaskRunId, + sessionId, + adapter: notifAdapter, + }); + } + } + + if (isNotification(method, POSTHOG_NOTIFICATIONS.USAGE_UPDATE)) { + this.emit(AgentServiceEvent.LlmActivity, undefined); + } + + // Extension notifications already flow through the tapped stream + // (same pattern as sessionUpdate). No need to re-emit here. + }, + }; + + const clientStream = ndJsonStream(tappedWritable, tappedReadable); + + return new ClientSideConnection((_agent) => client, clientStream); + } + + private validateSessionParams( + params: StartSessionInput | ReconnectSessionInput, + ): void { + if (!params.taskId || !params.repoPath) { + throw new Error("taskId and repoPath are required"); + } + if (!params.apiHost) { + throw new Error("PostHog API host is required"); + } + } + + private toRepoRelativePath(repoPath: string, filePath: string): string { + const normalize = (inputPath: string): string => { + try { + return fs.realpathSync(inputPath); + } catch { + return resolve(inputPath); + } + }; + + const resolvedRepo = normalize(repoPath); + const resolvedFile = isAbsolute(filePath) + ? resolve(filePath) + : resolve(repoPath, filePath); + const resolvedFileForCheck = fs.existsSync(resolvedFile) + ? normalize(resolvedFile) + : resolve(resolvedFile); + const repoPrefix = resolvedRepo.endsWith(sep) + ? resolvedRepo + : `${resolvedRepo}${sep}`; + + if ( + resolvedFileForCheck === resolvedRepo || + !resolvedFileForCheck.startsWith(repoPrefix) + ) { + throw new Error(`Access denied: path outside repository (${filePath})`); + } + + return relative(resolvedRepo, resolvedFileForCheck); + } + + private toSessionConfig( + params: StartSessionInput | ReconnectSessionInput, + ): SessionConfig { + return { + taskId: params.taskId, + taskRunId: params.taskRunId, + repoPath: params.repoPath, + credentials: { + apiHost: params.apiHost, + projectId: params.projectId, + }, + logUrl: "logUrl" in params ? params.logUrl : undefined, + sessionId: "sessionId" in params ? params.sessionId : undefined, + adapter: "adapter" in params ? params.adapter : undefined, + permissionMode: + "permissionMode" in params ? params.permissionMode : undefined, + customInstructions: + "customInstructions" in params ? params.customInstructions : undefined, + effort: "effort" in params ? params.effort : undefined, + model: "model" in params ? params.model : undefined, + jsonSchema: "jsonSchema" in params ? params.jsonSchema : undefined, + }; + } + + private toSessionResponse(session: ManagedSession): SessionResponse { + return { + sessionId: session.taskRunId, + channel: session.channel, + configOptions: session.configOptions, + }; + } + + private handleToolCallUpdate(taskRunId: string, message: unknown): void { + try { + const msg = message as { + method?: string; + params?: { + update?: { + sessionUpdate?: string; + _meta?: { + claudeCode?: { + toolName?: string; + toolResponse?: unknown; + bashCommand?: string; + }; + }; + content?: Array<{ type?: string; text?: string }>; + }; + }; + }; + + // Only process session/update notifications for tool_call_update + if (msg.method !== "session/update") return; + if (msg.params?.update?.sessionUpdate !== "tool_call_update") return; + + const update = msg.params.update; + const toolMeta = update._meta?.claudeCode; + const toolName = toolMeta?.toolName; + if (!toolName) return; + + const session = this.sessions.get(taskRunId); + + this.detectAndAttachPrUrl(taskRunId, session, toolMeta, update.content); + + this.trackAgentFileActivity(taskRunId, session, toolName); + } catch (err) { + this.log.debug("Error in tool call update handling", { + taskRunId, + error: err, + }); + } + } + + /** + * Detect GitHub PR URLs in `gh pr create` output and attach to task. + * Gated on the originating bash command so that unrelated PR URLs (e.g. + * `gh pr view`, `gh search prs`) don't get latched onto the run. + */ + private detectAndAttachPrUrl( + taskRunId: string, + session: ManagedSession | undefined, + toolMeta: + | { + toolName?: string; + toolResponse?: unknown; + bashCommand?: string; + } + | undefined, + content?: Array<{ type?: string; text?: string }>, + ): void { + const prUrl = extractCreatedPrUrl({ + toolName: toolMeta?.toolName, + bashCommand: toolMeta?.bashCommand, + toolResponse: toolMeta?.toolResponse, + content, + }); + if (!prUrl) return; + + this.log.info("Detected PR URL from gh pr create", { taskRunId, prUrl }); + + if (!session) { + this.log.warn("Session not found for PR attachment", { taskRunId }); + return; + } + + session.agent + .attachPullRequestToTask(session.taskId, prUrl) + .then(() => { + this.log.info("PR URL attached to task", { + taskRunId, + taskId: session.taskId, + prUrl, + }); + }) + .catch((err) => { + this.log.error("Failed to attach PR URL to task", { + taskRunId, + taskId: session.taskId, + prUrl, + error: err, + }); + }); + + // The user-initiated PR-creation flow links the current branch to the + // workspace atomically (see GitService.createPr). PRs created via bash — + // e.g. an agent running a `/commit-and-pr` skill — never go through that + // flow, so `workspace.linkedBranch` would otherwise stay unset and + // PR-aware UI (the unified PR badge, branch mismatch warning, diff + // source) would have no anchor. Emit AgentFileActivity here too so + // WorkspaceService.handleAgentFileActivity links the current feature + // branch the moment we observe a PR for it. + this.emitAgentFileActivityForCurrentBranch(taskRunId, session, { + reason: "pr-detected", + }); + } + + /** + * Track agent file activity for branch association observability. + */ + private static readonly FILE_MODIFYING_TOOLS = new Set([ + "Edit", + "Write", + "FileEditTool", + "FileWriteTool", + "MultiEdit", + "NotebookEdit", + ]); + + private trackAgentFileActivity( + taskRunId: string, + session: ManagedSession | undefined, + toolName: string, + ): void { + if (!session) return; + if (!AgentService.FILE_MODIFYING_TOOLS.has(toolName)) return; + + this.emitAgentFileActivityForCurrentBranch(taskRunId, session, { + reason: "file-edit", + toolName, + }); + } + + /** + * Resolve the current branch in the session's repo and emit AgentFileActivity + * so WorkspaceService can link the branch to the task. Best-effort — branch + * resolution failures are logged but never thrown. + */ + private emitAgentFileActivityForCurrentBranch( + taskRunId: string, + session: ManagedSession, + context: { reason: "file-edit" | "pr-detected"; toolName?: string }, + ): void { + getCurrentBranch(session.repoPath) + .then((branchName) => { + this.emit(AgentServiceEvent.AgentFileActivity, { + taskId: session.taskId, + branchName, + }); + }) + .catch((err) => { + this.log.warn("Failed to emit agent file activity event", { + taskRunId, + taskId: session.taskId, + ...context, + error: err, + }); + }); + } + + async getGatewayModels(apiHost: string) { + const gatewayUrl = getLlmGatewayUrl(apiHost); + const models = await fetchGatewayModels({ gatewayUrl }); + + const mapped = models.map((model) => ({ + modelId: model.id, + name: formatGatewayModelName(model), + description: `Context: ${model.context_window.toLocaleString()} tokens`, + provider: getProviderName(model.owned_by), + })); + + const CLAUDE_TIER_ORDER = ["opus", "sonnet", "haiku"]; + const getModelTier = (modelId: string): number => { + const lowerId = modelId.toLowerCase(); + for (let i = 0; i < CLAUDE_TIER_ORDER.length; i++) { + if (lowerId.includes(CLAUDE_TIER_ORDER[i])) return i; + } + return CLAUDE_TIER_ORDER.length; + }; + + return mapped.sort((a, b) => { + const providerOrder = ["Anthropic", "OpenAI", "Gemini"]; + const aProviderIdx = providerOrder.indexOf(a.provider ?? ""); + const bProviderIdx = providerOrder.indexOf(b.provider ?? ""); + if (aProviderIdx !== bProviderIdx) { + const aIdx = aProviderIdx === -1 ? 999 : aProviderIdx; + const bIdx = bProviderIdx === -1 ? 999 : bProviderIdx; + return aIdx - bIdx; + } + return getModelTier(a.modelId) - getModelTier(b.modelId); + }); + } + + async getPreviewConfigOptions( + apiHost: string, + adapter: "claude" | "codex" = "claude", + ): Promise<SessionConfigOption[]> { + const gatewayUrl = getLlmGatewayUrl(apiHost); + const gatewayModels = await fetchGatewayModels({ gatewayUrl }); + + const modelFilter = adapter === "codex" ? isOpenAIModel : isAnthropicModel; + + const modelOptions = gatewayModels + .filter((model) => modelFilter(model)) + .map((model) => ({ + value: model.id, + name: formatGatewayModelName(model), + description: `Context: ${model.context_window.toLocaleString()} tokens`, + })); + + const defaultModel = + adapter === "codex" + ? (modelOptions.find((o) => o.value === DEFAULT_CODEX_MODEL)?.value ?? + modelOptions[0]?.value ?? + "") + : DEFAULT_GATEWAY_MODEL; + + const resolvedModelId = modelOptions.some((o) => o.value === defaultModel) + ? defaultModel + : (modelOptions[0]?.value ?? defaultModel); + + if (!modelOptions.some((o) => o.value === resolvedModelId)) { + modelOptions.unshift({ + value: resolvedModelId, + name: resolvedModelId, + description: "Custom model", + }); + } + + const modes = + adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); + const modeOptions = modes.map((mode) => ({ + value: mode.id, + name: mode.name, + description: mode.description ?? undefined, + })); + const defaultMode = adapter === "codex" ? "auto" : "plan"; + + const configOptions: SessionConfigOption[] = [ + { + id: "mode", + name: "Approval Preset", + type: "select", + currentValue: defaultMode, + options: modeOptions, + category: "mode", + description: + "Choose an approval and sandboxing preset for your session", + }, + { + id: "model", + name: "Model", + type: "select", + currentValue: resolvedModelId, + options: modelOptions, + category: "model", + description: "Choose which model Claude should use", + }, + ]; + + const effortOpts = getReasoningEffortOptions(adapter, resolvedModelId); + if (effortOpts) { + configOptions.push({ + id: adapter === "codex" ? "reasoning_effort" : "effort", + name: adapter === "codex" ? "Reasoning Level" : "Effort", + type: "select", + currentValue: "high", + 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", + }); + } + + return configOptions; + } +} 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..2c83eeb0b9 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 { AUTH_PROXY_SERVICE } from "../auth-proxy/identifiers"; +import type { AuthProxyService } from "../auth-proxy/auth-proxy"; +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/packages/workspace-server/src/services/agent/discover-plugins.ts b/packages/workspace-server/src/services/agent/discover-plugins.ts new file mode 100644 index 0000000000..749948b4b5 --- /dev/null +++ b/packages/workspace-server/src/services/agent/discover-plugins.ts @@ -0,0 +1,137 @@ +import * as crypto from "node:crypto"; +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 { + findSkillDirs, + getMarketplaceInstallPaths, +} from "../skills/skill-discovery"; +import type { AgentScopedLogger } from "./ports"; + +interface DiscoverPluginsOptions { + userDataDir: string; + repoPath?: string; +} + +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, log), + discoverMarketplacePlugins(), + options.repoPath + ? discoverRepoSkills(options.userDataDir, options.repoPath, log) + : Promise.resolve([]), + ]); + + return [...globalSkills, ...marketplacePlugins, ...repoSkills]; +} + +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, + ); +} + +async function discoverMarketplacePlugins(): Promise<SdkPluginConfig[]> { + const paths = await getMarketplaceInstallPaths(); + return paths.map((p) => ({ type: "local" as const, path: p })); +} + +async function discoverRepoSkills( + userDataDir: string, + repoPath: string, + log: AgentScopedLogger, +): Promise<SdkPluginConfig[]> { + const skillsDir = path.join(repoPath, ".claude", "skills"); + const hash = crypto + .createHash("md5") + .update(repoPath) + .digest("hex") + .slice(0, 8); + + return buildSyntheticPlugin( + skillsDir, + path.join(userDataDir, "plugins", `repo-skills-${hash}`), + `repo-skills-${hash}`, + `Repo skills for ${path.basename(repoPath)}`, + log, + ); +} + +async function buildSyntheticPlugin( + sourceSkillsDir: string, + pluginDir: string, + name: string, + description: string, + log: AgentScopedLogger, +): Promise<SdkPluginConfig[]> { + try { + const skillDirs = await findSkillDirs(sourceSkillsDir); + if (skillDirs.length === 0) { + return []; + } + + const syntheticSkillsDir = path.join(pluginDir, "skills"); + await fs.promises.mkdir(syntheticSkillsDir, { recursive: true }); + + await fs.promises.writeFile( + path.join(pluginDir, "plugin.json"), + JSON.stringify({ name, description, version: "1.0.0" }), + ); + + try { + const existing = await fs.promises.readdir(syntheticSkillsDir); + await Promise.all( + existing.map((e) => + fs.promises.rm(path.join(syntheticSkillsDir, e), { + recursive: true, + force: true, + }), + ), + ); + } catch { + // ignore + } + + await Promise.all( + skillDirs.map(async (skillName) => { + const src = path.join(sourceSkillsDir, skillName); + const dest = path.join(syntheticSkillsDir, skillName); + try { + const realSrc = await fs.promises.realpath(src); + await fs.promises.symlink(realSrc, dest); + } catch (err) { + log.warn("Failed to symlink skill", { + skillName, + error: err instanceof Error ? err.message : String(err), + }); + } + }), + ); + + return [{ type: "local", path: pluginDir }]; + } catch (err) { + log.warn("Failed to discover skills", { + source: sourceSkillsDir, + error: err instanceof Error ? err.message : String(err), + }); + return []; + } +} 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/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts new file mode 100644 index 0000000000..d070f167f4 --- /dev/null +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -0,0 +1,303 @@ +import type { + RequestPermissionRequest, + PermissionOption as SdkPermissionOption, +} from "@agentclientprotocol/sdk"; +import { effortLevelSchema } from "@posthog/shared/domain-types"; +import { z } from "zod"; + +export { effortLevelSchema }; +export type { EffortLevel } from "@posthog/shared/domain-types"; + +// Session credentials schema +export const credentialsSchema = z.object({ + apiHost: z.string(), + projectId: z.number(), +}); + +export type Credentials = z.infer<typeof credentialsSchema>; + +// Session config schema +export const sessionConfigSchema = z.object({ + taskId: z.string(), + taskRunId: z.string(), + repoPath: z.string(), + credentials: credentialsSchema, + logUrl: z.string().optional(), + /** The agent's session ID (for resume - SDK session ID for Claude, Codex's session ID for Codex) */ + sessionId: z.string().optional(), + adapter: z.enum(["claude", "codex"]).optional(), + /** Additional directories Claude can access beyond cwd (for worktree support) */ + additionalDirectories: z.array(z.string()).optional(), + /** Permission mode to use for the session (e.g. "default", "acceptEdits", "plan", "bypassPermissions") */ + permissionMode: z.string().optional(), +}); + +export type SessionConfig = z.infer<typeof sessionConfigSchema>; + +// Start session input/output + +export const startSessionInput = z.object({ + taskId: z.string(), + taskRunId: z.string(), + repoPath: z.string(), + apiHost: z.string(), + projectId: z.number(), + permissionMode: z.string().optional(), + autoProgress: z.boolean().optional(), + runMode: z.enum(["local", "cloud"]).optional(), + adapter: z.enum(["claude", "codex"]).optional(), + additionalDirectories: z.array(z.string()).optional(), + customInstructions: z.string().max(2000).optional(), + effort: effortLevelSchema.optional(), + model: z.string().optional(), + jsonSchema: z.record(z.string(), z.unknown()).nullish(), +}); + +export type StartSessionInput = z.infer<typeof startSessionInput>; + +export const modelOptionSchema = z.object({ + modelId: z.string(), + name: z.string(), + description: z.string().nullish(), + provider: z.string().optional(), +}); + +export type ModelOption = z.infer<typeof modelOptionSchema>; + +const sessionConfigSelectOptionSchema = z.looseObject({ + value: z.string(), + name: z.string(), + description: z.string().nullish(), + _meta: z.record(z.string(), z.unknown()).nullish(), +}); + +const sessionConfigSelectGroupSchema = z.looseObject({ + group: z.string(), + name: z.string(), + options: z.array(sessionConfigSelectOptionSchema), + _meta: z.record(z.string(), z.unknown()).nullish(), +}); + +const sessionConfigSelectSchema = z.looseObject({ + id: z.string(), + name: z.string(), + type: z.literal("select"), + currentValue: z.string(), + options: z + .array(sessionConfigSelectOptionSchema) + .or(z.array(sessionConfigSelectGroupSchema)), + category: z.string().nullish(), + description: z.string().nullish(), + _meta: z.record(z.string(), z.unknown()).nullish(), +}); + +const sessionConfigBooleanSchema = z.looseObject({ + id: z.string(), + name: z.string(), + type: z.literal("boolean"), + currentValue: z.boolean(), + category: z.string().nullish(), + description: z.string().nullish(), + _meta: z.record(z.string(), z.unknown()).nullish(), +}); + +export const sessionConfigOptionSchema = z.union([ + sessionConfigSelectSchema, + sessionConfigBooleanSchema, +]); + +export type SessionConfigOption = z.infer<typeof sessionConfigOptionSchema>; + +export const sessionResponseSchema = z.object({ + sessionId: z.string(), + channel: z.string(), + configOptions: z.array(sessionConfigOptionSchema).optional(), +}); + +export type SessionResponse = z.infer<typeof sessionResponseSchema>; + +// Prompt input/output +export const contentBlockSchema = z.looseObject({ + type: z.string(), + text: z.string().optional(), + _meta: z.record(z.string(), z.unknown()).nullish(), +}); + +export const promptInput = z.object({ + sessionId: z.string(), + prompt: z.array(contentBlockSchema), +}); + +export type PromptInput = z.infer<typeof promptInput>; + +export const promptOutput = z.object({ + stopReason: z.string(), + _meta: z + .object({ + interruptReason: z.string().optional(), + }) + .optional(), +}); + +export type PromptOutput = z.infer<typeof promptOutput>; + +// Cancel session input +export const cancelSessionInput = z.object({ + sessionId: z.string(), +}); + +// Interrupt reason schema +export const interruptReasonSchema = z.enum([ + "user_request", + "moving_to_worktree", +]); +export type InterruptReason = z.infer<typeof interruptReasonSchema>; + +// Cancel prompt input +export const cancelPromptInput = z.object({ + sessionId: z.string(), + reason: interruptReasonSchema.optional(), +}); + +// Reconnect session input +export const reconnectSessionInput = z.object({ + taskId: z.string(), + taskRunId: z.string(), + repoPath: z.string(), + apiHost: z.string(), + projectId: z.number(), + logUrl: z.string().optional(), + sessionId: z.string().optional(), + adapter: z.enum(["claude", "codex"]).optional(), + /** Additional directories Claude can access beyond cwd (for worktree support) */ + additionalDirectories: z.array(z.string()).optional(), + permissionMode: z.string().optional(), + customInstructions: z.string().max(2000).optional(), + effort: effortLevelSchema.optional(), + jsonSchema: z.record(z.string(), z.unknown()).nullish(), +}); + +export type ReconnectSessionInput = z.infer<typeof reconnectSessionInput>; + +// Set config option input (for Codex reasoning level, etc.) +export const setConfigOptionInput = z.object({ + sessionId: z.string(), + configId: z.string(), + value: z.string(), +}); + +// Subscribe to session events input +export const subscribeSessionInput = z.object({ + taskRunId: z.string(), +}); + +// Record activity input — resets the idle timeout for the given session +export const recordActivityInput = z.object({ + taskRunId: z.string(), +}); + +// Agent events +export const AgentServiceEvent = { + SessionEvent: "session-event", + PermissionRequest: "permission-request", + SessionsIdle: "sessions-idle", + SessionIdleKilled: "session-idle-killed", + AgentFileActivity: "agent-file-activity", + LlmActivity: "llm-activity", +} as const; + +export interface AgentSessionEventPayload { + taskRunId: string; + payload: unknown; +} + +export type PermissionOption = SdkPermissionOption; +export type PermissionRequestPayload = Omit< + RequestPermissionRequest, + "sessionId" +> & { + taskRunId: string; +}; + +export interface SessionIdleKilledPayload { + taskRunId: string; + taskId: string; +} + +export interface AgentFileActivityPayload { + taskId: string; + branchName: string | null; +} + +export interface AgentServiceEvents { + [AgentServiceEvent.SessionEvent]: AgentSessionEventPayload; + [AgentServiceEvent.PermissionRequest]: PermissionRequestPayload; + [AgentServiceEvent.SessionsIdle]: undefined; + [AgentServiceEvent.SessionIdleKilled]: SessionIdleKilledPayload; + [AgentServiceEvent.AgentFileActivity]: AgentFileActivityPayload; + [AgentServiceEvent.LlmActivity]: undefined; +} + +// Permission response input for tRPC +export const respondToPermissionInput = z.object({ + taskRunId: z.string(), + toolCallId: z.string(), + optionId: z.string(), + // For "Other" option: custom text input from user (ACP extension via _meta) + customInput: z.string().optional(), + // For multi-question flows: all answers keyed by question text + answers: z.record(z.string(), z.string()).optional(), +}); + +export type RespondToPermissionInput = z.infer<typeof respondToPermissionInput>; + +// Permission cancellation input for tRPC +export const cancelPermissionInput = z.object({ + taskRunId: z.string(), + toolCallId: z.string(), +}); + +export type CancelPermissionInput = z.infer<typeof cancelPermissionInput>; + +export const listSessionsInput = z.object({ + taskId: z.string(), +}); + +export const detachedHeadContext = z.object({ + type: z.literal("detached_head"), + branchName: z.string(), + isDetached: z.boolean(), +}); + +export const sessionContextChangeSchema = detachedHeadContext; + +export type SessionContextChange = z.infer<typeof sessionContextChangeSchema>; + +export const notifySessionContextInput = z.object({ + sessionId: z.string(), + context: sessionContextChangeSchema, +}); + +export type NotifySessionContextInput = z.infer< + typeof notifySessionContextInput +>; + +export const sessionInfoSchema = z.object({ + taskRunId: z.string(), + repoPath: z.string(), +}); + +export const listSessionsOutput = z.array(sessionInfoSchema); + +export const getGatewayModelsInput = z.object({ + apiHost: z.string(), +}); + +export const getGatewayModelsOutput = z.array(modelOptionSchema); + +export const getPreviewConfigOptionsInput = z.object({ + apiHost: z.string(), + adapter: z.enum(["claude", "codex"]), +}); + +export const getPreviewConfigOptionsOutput = z.array(sessionConfigOptionSchema); diff --git a/packages/workspace-server/src/services/archive/archive.integration.test.ts b/packages/workspace-server/src/services/archive/archive.integration.test.ts new file mode 100644 index 0000000000..78cb79d647 --- /dev/null +++ b/packages/workspace-server/src/services/archive/archive.integration.test.ts @@ -0,0 +1,596 @@ +import { execSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { WorktreeManager } from "@posthog/git/worktree"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getPath: (name: string) => { + if (name === "home") return os.homedir(); + if (name === "userData") return os.tmpdir(); + return os.tmpdir(); + }, + }, +})); + +let testWorktreeBasePath = ""; + +import { + createMockArchiveRepository, + type MockArchiveRepository, +} 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 "@posthog/workspace-server/db/repositories/workspace-repository.mock"; +import { + createMockWorktreeRepository, + type MockWorktreeRepository, +} 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-")); + execSync("git init", { cwd: dir, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { + cwd: dir, + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd: dir, stdio: "pipe" }); + execSync("git config commit.gpgsign false", { cwd: dir, stdio: "pipe" }); + await fs.writeFile(path.join(dir, "README.md"), "# Test Repo"); + execSync("git add . && git commit -m 'Initial commit'", { + cwd: dir, + stdio: "pipe", + }); + return dir; +} + +async function pathExists(p: string): Promise<boolean> { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +const TASK_ID = "task-1"; + +interface TestContext { + service: ArchiveService; + repositoryRepo: IRepositoryRepository; + workspaceRepo: MockWorkspaceRepository; + worktreeRepo: MockWorktreeRepository; + archiveRepo: MockArchiveRepository; + repoPath: string; + repoId: string; + worktreeBasePath: string; + archiveInput: () => { taskId: string }; + setupWorktree: ( + method: "detached" | "branch", + branchName?: string, + ) => Promise<{ worktreePath: string; worktreeName: string }>; + git: (cmd: string) => string; +} + +interface CreateTestContextOpts { + mode?: "local" | "cloud" | "worktree"; + hasWorkspace?: boolean; + isArchived?: boolean; + failOnArchiveCreate?: boolean; + failOnArchiveDelete?: boolean; + failOnWorktreeCreate?: boolean; + failOnWorktreeDelete?: boolean; +} + +async function withTestContext( + opts: CreateTestContextOpts, + fn: (ctx: TestContext) => Promise<void>, +): Promise<void> { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "archive-int-")); + const repoPath = await createTempGitRepo(); + const worktreeBasePath = path.join(tempDir, "worktrees"); + await fs.mkdir(worktreeBasePath, { recursive: true }); + + testWorktreeBasePath = worktreeBasePath; + + const repositoryRepo = createMockRepositoryRepository(); + const workspaceRepo = createMockWorkspaceRepository(); + const worktreeRepo = createMockWorktreeRepository({ + failOnCreate: opts.failOnWorktreeCreate, + failOnDelete: opts.failOnWorktreeDelete, + }); + const archiveRepo = createMockArchiveRepository({ + failOnCreate: opts.failOnArchiveCreate, + failOnDelete: opts.failOnArchiveDelete, + }); + + const repo = repositoryRepo.create({ path: repoPath }); + const repoId = repo.id; + + const mocks = { + 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.sessionCanceller as never, + mocks.processTracking as never, + mocks.fileWatcher as never, + repositoryRepo as never, + workspaceRepo as never, + worktreeRepo as never, + archiveRepo as never, + suspensionRepo as never, + workspaceSettings as never, + archiveLogger as never, + ); + + const git = (cmd: string) => + execSync(`git ${cmd}`, { + cwd: repoPath, + encoding: "utf8", + stdio: "pipe", + }).trim(); + + const archiveInput = () => ({ taskId: TASK_ID }); + + const setupWorktree = async ( + method: "detached" | "branch", + branchName?: string, + ) => { + const manager = new WorktreeManager({ + mainRepoPath: repoPath, + worktreeBasePath, + }); + const result = + method === "detached" + ? await manager.createDetachedWorktreeAtCommit("HEAD", "test-wt") + : await manager.createWorktreeForExistingBranch( + branchName ?? "", + "test-wt", + ); + + const workspace = workspaceRepo.create({ + taskId: TASK_ID, + repositoryId: repoId, + mode: "worktree", + }); + + worktreeRepo.create({ + workspaceId: workspace.id, + name: result.worktreeName, + path: result.worktreePath, + }); + + return result; + }; + + if (opts.hasWorkspace !== false && opts.mode && opts.mode !== "worktree") { + const workspace = workspaceRepo.create({ + taskId: TASK_ID, + repositoryId: repoId, + mode: opts.mode, + }); + + if (opts.isArchived) { + archiveRepo.create({ + workspaceId: workspace.id, + branchName: null, + checkpointId: null, + }); + } + } + + const ctx: TestContext = { + service, + repositoryRepo, + workspaceRepo, + worktreeRepo, + archiveRepo, + repoPath, + repoId, + worktreeBasePath, + archiveInput, + setupWorktree, + git, + }; + + try { + await fn(ctx); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + await fs.rm(repoPath, { recursive: true, force: true }); + } +} + +describe("ArchiveService integration", () => { + describe("worktree mode", () => { + it("archive and unarchive preserves uncommitted changes", () => + withTestContext({}, async (ctx) => { + const { worktreePath, worktreeName } = + await ctx.setupWorktree("detached"); + await fs.writeFile( + path.join(worktreePath, "work.txt"), + "my precious work", + ); + + const archived = await ctx.service.archiveTask(ctx.archiveInput()); + + expect(await pathExists(worktreePath)).toBe(false); + expect(ctx.archiveRepo.findAll()).toHaveLength(1); + expect(archived.checkpointId).toBeTruthy(); + + const result = await ctx.service.unarchiveTask(TASK_ID); + + expect(result.worktreeName).toBe(worktreeName); + const repoName = path.basename(ctx.repoPath); + const newWorktreePath = path.join( + ctx.worktreeBasePath, + result.worktreeName ?? "", + repoName, + ); + expect(await pathExists(newWorktreePath)).toBe(true); + + const content = await fs.readFile( + path.join(newWorktreePath, "work.txt"), + "utf8", + ); + expect(content).toBe("my precious work"); + + expect(ctx.archiveRepo.findAll()).toHaveLength(0); + })); + + it("archive and unarchive preserves branch name", () => + withTestContext({}, async (ctx) => { + const branchName = "feature/my-branch"; + ctx.git(`checkout -b ${branchName}`); + ctx.git("checkout -"); + + const { worktreePath } = await ctx.setupWorktree("branch", branchName); + + const archived = await ctx.service.archiveTask(ctx.archiveInput()); + + expect(archived.branchName).toBe(branchName); + expect(await pathExists(worktreePath)).toBe(false); + + await ctx.service.unarchiveTask(TASK_ID); + + expect(ctx.archiveRepo.findAll()).toHaveLength(0); + })); + + it("unarchive with recreateBranch creates new branch", () => + withTestContext({}, async (ctx) => { + const branchName = "feature/old-branch"; + ctx.git(`checkout -b ${branchName}`); + ctx.git("checkout -"); + + const { worktreePath } = await ctx.setupWorktree("branch", branchName); + await fs.writeFile(path.join(worktreePath, "work.txt"), "my work"); + + await ctx.service.archiveTask(ctx.archiveInput()); + ctx.git(`branch -D ${branchName}`); + + const result = await ctx.service.unarchiveTask(TASK_ID, true); + + const repoName = path.basename(ctx.repoPath); + const newWorktreePath = path.join( + ctx.worktreeBasePath, + result.worktreeName ?? "", + repoName, + ); + + const currentBranch = execSync("git branch --show-current", { + cwd: newWorktreePath, + encoding: "utf8", + stdio: "pipe", + }).trim(); + expect(currentBranch).toBe(branchName); + + const content = await fs.readFile( + path.join(newWorktreePath, "work.txt"), + "utf8", + ); + expect(content).toBe("my work"); + })); + + it("archive does not save branch name for detached HEAD", () => + withTestContext({}, async (ctx) => { + const { worktreePath } = await ctx.setupWorktree("detached"); + + const archived = await ctx.service.archiveTask(ctx.archiveInput()); + + expect(archived.branchName).toBeNull(); + expect(await pathExists(worktreePath)).toBe(false); + })); + + it("throws when trying to archive already archived task", () => + withTestContext({}, async (ctx) => { + await ctx.setupWorktree("detached"); + + await ctx.service.archiveTask(ctx.archiveInput()); + + await expect( + ctx.service.archiveTask(ctx.archiveInput()), + ).rejects.toThrow("already archived"); + })); + + it("archive finds worktree at legacy path format", () => + withTestContext({}, async (ctx) => { + const repoName = path.basename(ctx.repoPath); + const worktreeName = "legacy-wt"; + const legacyPath = path.join( + ctx.worktreeBasePath, + repoName, + worktreeName, + ); + + await fs.mkdir(legacyPath, { recursive: true }); + ctx.git(`worktree add "${legacyPath}" HEAD --detach`); + await fs.writeFile( + path.join(legacyPath, "legacy.txt"), + "legacy content", + ); + + const workspace = ctx.workspaceRepo.create({ + taskId: TASK_ID, + repositoryId: ctx.repoId, + mode: "worktree", + }); + + ctx.worktreeRepo.create({ + workspaceId: workspace.id, + name: worktreeName, + path: legacyPath, + }); + + const archived = await ctx.service.archiveTask(ctx.archiveInput()); + + expect(archived.checkpointId).toBeTruthy(); + expect(await pathExists(legacyPath)).toBe(false); + })); + + it("archive succeeds when worktree was deleted externally", () => + withTestContext({}, async (ctx) => { + const { worktreePath } = await ctx.setupWorktree("detached"); + + await fs.rm(worktreePath, { recursive: true, force: true }); + expect(await pathExists(worktreePath)).toBe(false); + + const archived = await ctx.service.archiveTask(ctx.archiveInput()); + + expect(archived.checkpointId).toBeNull(); + expect(archived.branchName).toBeNull(); + expect(ctx.archiveRepo.findAll()).toHaveLength(1); + })); + }); + + describe("local/cloud mode", () => { + it.each(["local", "cloud"] as const)( + "archive and unarchive %s mode restores correct workspace", + (mode) => + withTestContext({ mode }, async (ctx) => { + await ctx.service.archiveTask(ctx.archiveInput()); + + expect(ctx.archiveRepo.findAll()).toHaveLength(1); + + const result = await ctx.service.unarchiveTask(TASK_ID); + + expect(result.worktreeName).toBeNull(); + expect(ctx.archiveRepo.findAll()).toHaveLength(0); + }), + ); + }); + + describe("error handling", () => { + it("archives task without workspace association", () => + withTestContext({ hasWorkspace: false }, async (ctx) => { + const result = await ctx.service.archiveTask({ + taskId: "nonexistent", + }); + expect(result).toMatchObject({ + taskId: "nonexistent", + folderId: "", + mode: "cloud", + worktreeName: null, + branchName: null, + checkpointId: null, + }); + })); + + it("unarchives task without repository association", () => + withTestContext({}, async (ctx) => { + const workspace = ctx.workspaceRepo.create({ + taskId: TASK_ID, + repositoryId: null, + mode: "cloud", + }); + ctx.archiveRepo.create({ + workspaceId: workspace.id, + branchName: null, + checkpointId: null, + }); + + const result = await ctx.service.unarchiveTask(TASK_ID); + + expect(result).toEqual({ taskId: TASK_ID, worktreeName: null }); + expect(ctx.archiveRepo.findAll()).toHaveLength(0); + })); + + it("throws when workspace not found for unarchive", () => + withTestContext({}, async (ctx) => { + await expect(ctx.service.unarchiveTask("nonexistent")).rejects.toThrow( + "Workspace not found", + ); + })); + + it("throws when archived task not found for unarchive", () => + withTestContext({ mode: "local", isArchived: false }, async (ctx) => { + await expect(ctx.service.unarchiveTask(TASK_ID)).rejects.toThrow( + "Archived task not found", + ); + })); + + it("throws when repository not found for archive", () => + withTestContext({}, async (ctx) => { + ctx.workspaceRepo.create({ + taskId: TASK_ID, + repositoryId: "missing-repo-id", + mode: "local", + }); + + await expect( + ctx.service.archiveTask(ctx.archiveInput()), + ).rejects.toThrow("Repository not found"); + })); + + it("throws when repository not found for unarchive", () => + withTestContext({}, async (ctx) => { + const workspace = ctx.workspaceRepo.create({ + taskId: TASK_ID, + repositoryId: "missing-repo-id", + mode: "worktree", + }); + ctx.worktreeRepo.create({ + workspaceId: workspace.id, + name: "test-wt", + path: "/some/path", + }); + ctx.archiveRepo.create({ + workspaceId: workspace.id, + branchName: null, + checkpointId: "worktree-test-wt", + }); + + await expect(ctx.service.unarchiveTask(TASK_ID)).rejects.toThrow( + "Repository not found", + ); + })); + }); + + describe("getters", () => { + it("getArchivedTasks returns tasks from repository", () => + withTestContext({ mode: "local", isArchived: true }, async (ctx) => { + const tasks = ctx.service.getArchivedTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0].taskId).toBe(TASK_ID); + + expect(ctx.service.getArchivedTaskIds()).toEqual([TASK_ID]); + expect(ctx.service.isArchived(TASK_ID)).toBe(true); + expect(ctx.service.isArchived("task-2")).toBe(false); + })); + }); + + describe("deleteArchivedTask", () => { + it("deletes archived task without checkpoint", () => + withTestContext({ mode: "local", isArchived: true }, async (ctx) => { + await ctx.service.deleteArchivedTask(TASK_ID); + expect(ctx.archiveRepo.findAll()).toHaveLength(0); + expect(ctx.workspaceRepo.findByTaskId(TASK_ID)).toBeNull(); + })); + + it("deletes archived task with checkpoint", () => + withTestContext({}, async (ctx) => { + const { worktreePath } = await ctx.setupWorktree("detached"); + await fs.writeFile(path.join(worktreePath, "file.txt"), "content"); + + const archived = await ctx.service.archiveTask(ctx.archiveInput()); + expect(archived.checkpointId).toBeTruthy(); + expect(ctx.archiveRepo.findAll()).toHaveLength(1); + + const refs = ctx.git("for-each-ref --format='%(refname)'"); + expect(refs).toContain(archived.checkpointId); + + await ctx.service.deleteArchivedTask(TASK_ID); + + expect(ctx.archiveRepo.findAll()).toHaveLength(0); + const refsAfter = ctx.git("for-each-ref --format='%(refname)'"); + expect(refsAfter).not.toContain(archived.checkpointId); + })); + + it("throws when workspace not found for delete", () => + withTestContext({}, async (ctx) => { + await expect( + ctx.service.deleteArchivedTask("nonexistent"), + ).rejects.toThrow("Workspace not found"); + })); + + it("throws when archived task not found for delete", () => + withTestContext({ mode: "local", isArchived: false }, async (ctx) => { + await expect(ctx.service.deleteArchivedTask(TASK_ID)).rejects.toThrow( + "Archived task", + ); + })); + + it("still removes from repository if checkpoint deletion fails", () => + withTestContext({}, async (ctx) => { + const workspace = ctx.workspaceRepo.create({ + taskId: TASK_ID, + repositoryId: ctx.repoId, + mode: "worktree", + }); + ctx.worktreeRepo.create({ + workspaceId: workspace.id, + name: "nonexistent", + path: "/some/path", + }); + ctx.archiveRepo.create({ + workspaceId: workspace.id, + branchName: null, + checkpointId: "worktree-nonexistent", + }); + + await ctx.service.deleteArchivedTask(TASK_ID); + expect(ctx.archiveRepo.findAll()).toHaveLength(0); + })); + }); + + describe("rollback behavior", () => { + it("archive rolls back if archive create fails", () => + withTestContext( + { mode: "local", failOnArchiveCreate: true }, + async (ctx) => { + await expect( + ctx.service.archiveTask(ctx.archiveInput()), + ).rejects.toThrow("Injected failure"); + + expect(ctx.archiveRepo.findAll()).toHaveLength(0); + }, + )); + + it("unarchive rolls back if archive delete fails", () => + withTestContext( + { mode: "local", isArchived: true, failOnArchiveDelete: true }, + async (ctx) => { + await expect(ctx.service.unarchiveTask(TASK_ID)).rejects.toThrow( + "Injected failure", + ); + + expect(ctx.archiveRepo.findAll()).toHaveLength(1); + }, + )); + }); +}); 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/packages/workspace-server/src/services/archive/archive.ts b/packages/workspace-server/src/services/archive/archive.ts new file mode 100644 index 0000000000..8b3d48a91b --- /dev/null +++ b/packages/workspace-server/src/services/archive/archive.ts @@ -0,0 +1,549 @@ +import path from "node:path"; +import { + WORKBENCH_LOGGER, + type ScopedLogger, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { resolveWorktreePathByProbe } from "../worktree-path/worktree-path"; +import { + captureWorktreeCheckpoint, + restoreWorktreeFromCheckpoint, +} from "../worktree-checkpoint/worktree-checkpoint"; +import { getCurrentBranchName } from "../worktree-query/worktree-query"; +import { createGitClient } from "@posthog/git/client"; +import { isGitRepository } from "@posthog/git/queries"; +import { + deleteCheckpoint, +} from "@posthog/git/sagas/checkpoint"; +import { forceRemove } from "@posthog/git/utils"; +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, +} from "../../db/repositories/archive-repository"; +import type { RepositoryRepository } from "../../db/repositories/repository-repository"; +import type { + SuspensionReason, + SuspensionRepository, +} from "../../db/repositories/suspension-repository"; +import type { + Workspace, + WorkspaceRepository, +} from "../../db/repositories/workspace-repository"; +import type { WorktreeRepository } from "../../db/repositories/worktree-repository"; +import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; +import type { ProcessTrackingService } from "../process-tracking/process-tracking"; +import { + ARCHIVE_FILE_WATCHER, + ARCHIVE_SESSION_CANCELLER, +} from "./identifiers"; +import type { ArchiveFileWatcher, SessionCanceller } from "./ports"; +import type { ArchivedTask, ArchiveTaskInput } from "./schemas"; + +type RollbackFn = () => Promise<void>; + +@injectable() +export class ArchiveService { + constructor( + @inject(ARCHIVE_SESSION_CANCELLER) + private readonly sessionCanceller: SessionCanceller, + @inject(PROCESS_TRACKING_SERVICE) + private readonly processTracking: ProcessTrackingService, + @inject(ARCHIVE_FILE_WATCHER) + private readonly fileWatcher: ArchiveFileWatcher, + @inject(REPOSITORY_REPOSITORY) + private readonly repositoryRepo: RepositoryRepository, + @inject(WORKSPACE_REPOSITORY) + private readonly workspaceRepo: WorkspaceRepository, + @inject(WORKTREE_REPOSITORY) + private readonly worktreeRepo: WorktreeRepository, + @inject(ARCHIVE_REPOSITORY) + private readonly archiveRepo: ArchiveRepository, + @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> { + this.log.info(`Archiving task ${input.taskId}`); + + const rollbacks: RollbackFn[] = []; + const runWithRollback = async ( + execute: () => Promise<void>, + rollback: RollbackFn, + ) => { + await execute(); + rollbacks.push(rollback); + }; + + try { + const result = await this.executeArchive(input, runWithRollback); + this.log.info(`Task ${input.taskId} archived successfully`); + return result; + } catch (error) { + for (const rollback of rollbacks.reverse()) { + try { + await rollback(); + } catch (rollbackError) { + this.log.error("Rollback failed:", rollbackError); + } + } + throw error; + } + } + + private async executeArchive( + input: ArchiveTaskInput, + step: (execute: () => Promise<void>, rollback: RollbackFn) => Promise<void>, + ): Promise<ArchivedTask> { + const { taskId } = input; + + const workspace = this.workspaceRepo.findByTaskId(taskId); + if (!workspace) { + return { + taskId, + archivedAt: new Date().toISOString(), + folderId: "", + mode: "cloud", + worktreeName: null, + branchName: null, + checkpointId: null, + }; + } + + const existingArchive = this.archiveRepo.findByWorkspaceId(workspace.id); + if (existingArchive) { + throw new Error(`Task ${taskId} is already archived`); + } + + const suspension = this.suspensionRepo.findByWorkspaceId(workspace.id); + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + + if (suspension) { + const archivedTask: ArchivedTask = { + taskId, + archivedAt: new Date().toISOString(), + folderId: workspace.repositoryId ?? "", + mode: workspace.mode, + worktreeName: worktree?.name ?? null, + branchName: suspension.branchName, + checkpointId: suspension.checkpointId, + }; + + await step( + async () => { + this.archiveRepo.create({ + workspaceId: workspace.id, + branchName: archivedTask.branchName, + checkpointId: archivedTask.checkpointId, + }); + }, + async () => { + this.archiveRepo.deleteByWorkspaceId(workspace.id); + }, + ); + + await step( + async () => { + this.suspensionRepo.deleteByWorkspaceId(workspace.id); + }, + async () => { + this.suspensionRepo.create({ + workspaceId: workspace.id, + branchName: suspension.branchName, + checkpointId: suspension.checkpointId, + reason: suspension.reason as SuspensionReason, + }); + }, + ); + + return archivedTask; + } + + const archivedTask: ArchivedTask = { + taskId, + archivedAt: new Date().toISOString(), + folderId: workspace.repositoryId ?? "", + mode: workspace.mode, + worktreeName: worktree?.name ?? null, + branchName: null, + checkpointId: + workspace.mode === "worktree" && worktree + ? `worktree-${worktree.name}` + : null, + }; + + if (workspace.repositoryId) { + const repo = this.repositoryRepo.findById(workspace.repositoryId); + if (!repo) { + throw new Error(`Repository not found for task ${taskId}`); + } + const folderPath = repo.path; + + if (workspace.mode === "worktree" && worktree) { + const worktreePath = worktree.path; + const worktreeIsValid = await isGitRepository(worktreePath).catch( + (error) => { + this.log.warn( + `Failed to check worktree at ${worktreePath}; treating as invalid`, + { error }, + ); + return false; + }, + ); + + if (!worktreeIsValid) { + this.log.warn( + `Worktree at ${worktreePath} is missing or not a git repository; skipping checkpoint capture`, + ); + archivedTask.checkpointId = null; + } else { + const actualBranch = await this.getCurrentBranchName(worktreePath); + if (actualBranch && actualBranch !== "HEAD") { + archivedTask.branchName = actualBranch; + } + + await step( + async () => { + if (!archivedTask.checkpointId) { + throw new Error("checkpointId must be set for worktree mode"); + } + await this.captureWorktreeCheckpoint( + folderPath, + worktreePath, + archivedTask.checkpointId, + ); + }, + async () => { + if (archivedTask.checkpointId) { + const git = createGitClient(folderPath); + await deleteCheckpoint(git, archivedTask.checkpointId); + } + }, + ); + } + + await step( + async () => { + await this.sessionCanceller.cancelSessionsByTaskId(taskId); + this.processTracking.killByTaskId(taskId); + await this.fileWatcher.stopWatching(worktreePath); + }, + async () => {}, + ); + + await step( + async () => { + const manager = new WorktreeManager({ + mainRepoPath: folderPath, + worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), + }); + await manager.deleteWorktree(worktreePath); + const parentDir = path.dirname(worktreePath); + await forceRemove(parentDir); + }, + async () => {}, + ); + } + } + + if (workspace.mode !== "worktree") { + await step( + async () => { + await this.sessionCanceller.cancelSessionsByTaskId(taskId); + this.processTracking.killByTaskId(taskId); + }, + async () => {}, + ); + } + + await step( + async () => { + this.archiveRepo.create({ + workspaceId: workspace.id, + branchName: archivedTask.branchName, + checkpointId: archivedTask.checkpointId, + }); + }, + async () => { + this.archiveRepo.deleteByWorkspaceId(workspace.id); + }, + ); + + return archivedTask; + } + + async unarchiveTask( + taskId: string, + recreateBranch?: boolean, + ): Promise<{ taskId: string; worktreeName: string | null }> { + this.log.info( + `Unarchiving task ${taskId}${recreateBranch ? " (recreate branch)" : ""}`, + ); + + const rollbacks: RollbackFn[] = []; + const runWithRollback = async ( + execute: () => Promise<void>, + rollback: RollbackFn, + ) => { + await execute(); + rollbacks.push(rollback); + }; + + try { + const result = await this.executeUnarchive( + taskId, + recreateBranch, + runWithRollback, + ); + this.log.info(`Task ${taskId} unarchived successfully`); + return result; + } catch (error) { + for (const rollback of rollbacks.reverse()) { + try { + await rollback(); + } catch (rollbackError) { + this.log.error("Rollback failed:", rollbackError); + } + } + throw error; + } + } + + private async executeUnarchive( + taskId: string, + recreateBranch: boolean | undefined, + step: (execute: () => Promise<void>, rollback: RollbackFn) => Promise<void>, + ): Promise<{ taskId: string; worktreeName: string | null }> { + const workspace = this.workspaceRepo.findByTaskId(taskId); + if (!workspace) { + throw new Error(`Workspace not found: ${taskId}`); + } + + const archive = this.archiveRepo.findByWorkspaceId(workspace.id); + if (!archive) { + throw new Error(`Archived task not found: ${taskId}`); + } + + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + let restoredWorktreeName: string | null = worktree?.name ?? null; + + if (workspace.repositoryId) { + const repo = this.repositoryRepo.findById(workspace.repositoryId); + if (!repo) { + throw new Error(`Repository not found for task ${taskId}`); + } + const folderPath = repo.path; + + const shouldRestoreWorktree = + workspace.mode === "worktree" && archive.checkpointId; + + if (shouldRestoreWorktree) { + await step( + async () => { + restoredWorktreeName = await this.restoreWorktreeFromCheckpoint( + folderPath, + workspace, + archive, + recreateBranch, + ); + }, + async () => { + if (restoredWorktreeName) { + const manager = new WorktreeManager({ + mainRepoPath: folderPath, + worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), + }); + const worktreePath = await this.deriveWorktreePath( + folderPath, + restoredWorktreeName, + ); + await manager.deleteWorktree(worktreePath); + const parentDir = path.dirname(worktreePath); + await forceRemove(parentDir); + } + }, + ); + + await step( + async () => { + if (!restoredWorktreeName) { + throw new Error("Failed to restore worktree"); + } + const worktreePath = await this.deriveWorktreePath( + folderPath, + restoredWorktreeName, + ); + this.worktreeRepo.create({ + workspaceId: workspace.id, + name: restoredWorktreeName, + path: worktreePath, + }); + }, + async () => { + this.worktreeRepo.deleteByWorkspaceId(workspace.id); + }, + ); + } + } + + await step( + async () => { + this.archiveRepo.deleteByWorkspaceId(workspace.id); + }, + async () => { + this.archiveRepo.create({ + workspaceId: workspace.id, + branchName: archive.branchName, + checkpointId: archive.checkpointId, + }); + }, + ); + + return { taskId, worktreeName: restoredWorktreeName }; + } + + getArchivedTasks(): ArchivedTask[] { + const archives = this.archiveRepo.findAll(); + return archives.map((archive) => { + const workspace = this.workspaceRepo.findById( + archive.workspaceId, + ) as Workspace; + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + return this.toArchivedTask(workspace, archive, worktree?.name ?? null); + }); + } + + getArchivedTaskIds(): string[] { + const archives = this.archiveRepo.findAll(); + return archives + .map((archive) => { + const workspace = this.workspaceRepo.findById(archive.workspaceId); + return workspace?.taskId; + }) + .filter((id): id is string => id !== undefined); + } + + isArchived(taskId: string): boolean { + const workspace = this.workspaceRepo.findByTaskId(taskId); + if (!workspace) return false; + return this.archiveRepo.findByWorkspaceId(workspace.id) !== null; + } + + async deleteArchivedTask(taskId: string): Promise<void> { + this.log.info(`Deleting archived task ${taskId}`); + + const workspace = this.workspaceRepo.findByTaskId(taskId); + if (!workspace) { + throw new Error(`Workspace not found: ${taskId}`); + } + + const archive = this.archiveRepo.findByWorkspaceId(workspace.id); + if (!archive) { + throw new Error(`Archived task ${taskId} not found`); + } + + if (archive.checkpointId && workspace.repositoryId) { + const repo = this.repositoryRepo.findById(workspace.repositoryId); + if (repo) { + try { + const git = createGitClient(repo.path); + await deleteCheckpoint(git, archive.checkpointId); + } catch (error) { + this.log.warn(`Failed to delete checkpoint ${archive.checkpointId}`, { + error, + }); + } + } + } + + this.archiveRepo.deleteByWorkspaceId(workspace.id); + this.workspaceRepo.deleteByTaskId(taskId); + this.log.info(`Deleted archived task ${taskId}`); + } + + private toArchivedTask( + workspace: Workspace, + archive: Archive, + worktreeName: string | null, + ): ArchivedTask { + return { + taskId: workspace.taskId, + archivedAt: archive.archivedAt, + folderId: workspace.repositoryId ?? "", + mode: workspace.mode, + worktreeName, + branchName: archive.branchName, + checkpointId: archive.checkpointId, + }; + } + + private deriveWorktreePath( + folderPath: string, + worktreeName: string, + ): Promise<string> { + return resolveWorktreePathByProbe( + this.workspaceSettings.getWorktreeLocation(), + folderPath, + worktreeName, + ); + } + + private getCurrentBranchName(worktreePath: string): Promise<string> { + return getCurrentBranchName(worktreePath); + } + + private captureWorktreeCheckpoint( + folderPath: string, + worktreePath: string, + checkpointId: string, + ): Promise<void> { + return captureWorktreeCheckpoint(folderPath, worktreePath, checkpointId); + } + + private async restoreWorktreeFromCheckpoint( + folderPath: string, + workspace: Workspace, + archive: Archive, + recreateBranch?: boolean, + ): Promise<string> { + if (!archive.checkpointId) { + throw new Error("checkpointId is required for restoring worktree"); + } + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + + const newWorktree = await restoreWorktreeFromCheckpoint({ + mainRepoPath: folderPath, + worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), + preferredName: worktree?.name ?? undefined, + branchName: archive.branchName, + checkpointId: archive.checkpointId, + recreateBranch, + }); + + if (worktree) { + this.worktreeRepo.deleteByWorkspaceId(workspace.id); + } + + return newWorktree.worktreeName; + } +} 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/packages/workspace-server/src/services/archive/schemas.ts b/packages/workspace-server/src/services/archive/schemas.ts new file mode 100644 index 0000000000..b7447c9ef6 --- /dev/null +++ b/packages/workspace-server/src/services/archive/schemas.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +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(), +}); + +export type ArchiveTaskInput = z.infer<typeof archiveTaskInput>; + +export const unarchiveTaskInput = z.object({ + taskId: z.string(), + recreateBranch: z.boolean().optional(), +}); + +export type UnarchiveTaskInput = z.infer<typeof unarchiveTaskInput>; + +export const archiveTaskOutput = archivedTaskSchema; + +export const unarchiveTaskOutput = z.object({ + taskId: z.string(), + worktreeName: z.string().nullable(), +}); + +export const listArchivedTasksOutput = z.array(archivedTaskSchema); + +export const archivedTaskIdsOutput = z.array(z.string()); + +export const deleteArchivedTaskInput = z.object({ + taskId: z.string(), +}); + +export const deleteArchivedTaskOutput = z.void(); 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/packages/workspace-server/src/services/auth-proxy/auth-proxy.ts b/packages/workspace-server/src/services/auth-proxy/auth-proxy.ts new file mode 100644 index 0000000000..a69fbe7d16 --- /dev/null +++ b/packages/workspace-server/src/services/auth-proxy/auth-proxy.ts @@ -0,0 +1,213 @@ +import http from "node:http"; +import { + WORKBENCH_LOGGER, + type ScopedLogger, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; +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(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) { + this.gatewayUrl = gatewayUrl; + return this.getProxyUrl(); + } + + this.gatewayUrl = gatewayUrl; + + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + + return new Promise<string>((resolve, reject) => { + this.server?.listen(0, "127.0.0.1", () => { + const addr = this.server?.address(); + if (typeof addr === "object" && addr) { + this.port = addr.port; + resolve(this.getProxyUrl()); + } else { + reject(new Error("Failed to get proxy address")); + } + }); + + this.server?.on("error", (err) => { + this.log.error("Auth proxy server error", err); + reject(err); + }); + }); + } + + getProxyUrl(): string { + if (!this.port) { + throw new Error("Auth proxy not started"); + } + return `http://127.0.0.1:${this.port}`; + } + + isRunning(): boolean { + return this.server !== null && this.port !== null; + } + + async stop(): Promise<void> { + if (!this.server) return; + + return new Promise<void>((resolve) => { + this.server?.close(() => { + this.log.info("Auth proxy stopped"); + this.server = null; + this.port = null; + resolve(); + }); + }); + } + + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + ): void { + if (!this.gatewayUrl) { + res.writeHead(503); + res.end("Proxy not configured"); + return; + } + + const base = this.gatewayUrl.endsWith("/") + ? this.gatewayUrl + : `${this.gatewayUrl}/`; + const incoming = (req.url ?? "/").replace(/^\//, ""); + const targetUrl = new URL(incoming, base); + + // Validate that the resolved URL stays within the configured gateway origin + const gatewayBase = new URL(base); + const normalizePort = (u: URL): string => { + if (u.port) return u.port; + if (u.protocol === "https:") return "443"; + if (u.protocol === "http:") return "80"; + return ""; + }; + + const targetPort = normalizePort(targetUrl); + const gatewayPort = normalizePort(gatewayBase); + + const sameOrigin = + targetUrl.protocol === gatewayBase.protocol && + targetUrl.hostname === gatewayBase.hostname && + targetPort === gatewayPort; + + const hasPathTraversal = targetUrl.pathname.includes(".."); + + if (!sameOrigin || hasPathTraversal) { + this.log.warn("Rejected proxy request with invalid target URL", { + method: req.method, + incoming: req.url, + target: targetUrl.toString(), + }); + res.writeHead(403); + res.end("Forbidden"); + return; + } + + const strippedAuthHeaders = new Set([ + "authorization", + "x-api-key", + "api-key", + "anthropic-auth-token", + "proxy-authorization", + ]); + const headers: Record<string, string> = {}; + for (const [key, value] of Object.entries(req.headers)) { + if ( + key === "host" || + key === "connection" || + strippedAuthHeaders.has(key) + ) { + continue; + } + if (typeof value === "string") { + headers[key] = value; + } + } + const fetchOptions: RequestInit = { + method: req.method ?? "GET", + headers, + }; + + if (req.method !== "GET" && req.method !== "HEAD") { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + fetchOptions.body = Buffer.concat(chunks); + this.forwardRequest(targetUrl.toString(), fetchOptions, res); + }); + } else { + this.forwardRequest(targetUrl.toString(), fetchOptions, res); + } + } + + private async forwardRequest( + url: string, + options: RequestInit, + res: http.ServerResponse, + ): Promise<void> { + try { + const response = await this.auth.authenticatedFetch(url, options); + + const responseHeaders: Record<string, string> = {}; + const stripHeaders = new Set([ + "transfer-encoding", + "content-encoding", + "content-length", + ]); + response.headers.forEach((value: string, key: string) => { + if (stripHeaders.has(key)) return; + responseHeaders[key] = value; + }); + + res.writeHead(response.status, responseHeaders); + + if (!response.body) { + res.end(); + return; + } + + const reader = response.body.getReader(); + const pump = async (): Promise<void> => { + const { done, value } = await reader.read(); + if (done) { + res.end(); + return; + } + const canContinue = res.write(value); + if (canContinue) { + return pump(); + } + res.once("drain", () => pump()); + }; + + await pump(); + } catch (err) { + this.log.error("Proxy forward error", { url, err }); + if (!res.headersSent) { + res.writeHead(502); + } + res.end("Proxy error"); + } + } +} 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/packages/workspace-server/src/services/connectivity/service.test.ts b/packages/workspace-server/src/services/connectivity/service.test.ts new file mode 100644 index 0000000000..fa9ce859fb --- /dev/null +++ b/packages/workspace-server/src/services/connectivity/service.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ConnectivityEvent } from "./schemas"; +import { ConnectivityService } from "./service"; + +const mockFetch = vi.hoisted(() => vi.fn()); + +const ok = (status = 200) => ({ ok: true, status }); +const notOk = (status = 500) => ({ ok: false, status }); + +describe("ConnectivityService", () => { + let service: ConnectivityService | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + mockFetch.mockResolvedValue(ok()); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + service?.stop(); + service = undefined; + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + describe("initial check", () => { + it("goes online after a successful HEAD check", async () => { + mockFetch.mockResolvedValue(ok(204)); + + service = new ConnectivityService(); + await vi.advanceTimersByTimeAsync(0); + + expect(service.getStatus()).toEqual({ isOnline: true }); + expect(mockFetch).toHaveBeenCalledWith( + "https://www.google.com/generate_204", + expect.objectContaining({ method: "HEAD" }), + ); + }); + + it("goes offline when the HEAD check throws", async () => { + mockFetch.mockImplementation(() => { + throw new Error("offline"); + }); + + service = new ConnectivityService(); + await vi.advanceTimersByTimeAsync(0); + + expect(service.getStatus()).toEqual({ isOnline: false }); + }); + }); + + describe("checkNow", () => { + it("returns online when HEAD succeeds", async () => { + mockFetch.mockResolvedValue(ok(204)); + service = new ConnectivityService(); + await vi.advanceTimersByTimeAsync(0); + + const result = await service.checkNow(); + expect(result).toEqual({ isOnline: true }); + }); + + it("returns offline when HEAD rejects", async () => { + mockFetch.mockRejectedValue(new Error("Network error")); + service = new ConnectivityService(); + await vi.advanceTimersByTimeAsync(0); + + const result = await service.checkNow(); + expect(result).toEqual({ isOnline: false }); + }); + + it("returns offline when HEAD returns a non-ok non-204 response", async () => { + mockFetch.mockResolvedValue(notOk(500)); + service = new ConnectivityService(); + await vi.advanceTimersByTimeAsync(0); + + const result = await service.checkNow(); + expect(result).toEqual({ isOnline: false }); + }); + }); + + describe("status change events", () => { + it("emits when going offline", async () => { + mockFetch.mockResolvedValue(ok(204)); + service = new ConnectivityService(); + await vi.advanceTimersByTimeAsync(0); + + const handler = vi.fn(); + service.on(ConnectivityEvent.StatusChange, handler); + + mockFetch.mockRejectedValue(new Error("offline")); + await vi.advanceTimersByTimeAsync(3000); + + expect(handler).toHaveBeenCalledWith({ isOnline: false }); + }); + + it("emits when coming back online", async () => { + mockFetch.mockRejectedValue(new Error("offline")); + service = new ConnectivityService(); + await vi.advanceTimersByTimeAsync(0); + + const handler = vi.fn(); + service.on(ConnectivityEvent.StatusChange, handler); + + mockFetch.mockResolvedValue(ok(204)); + await vi.advanceTimersByTimeAsync(3000); + + expect(handler).toHaveBeenCalledWith({ isOnline: true }); + }); + + it("does not emit when status is unchanged", async () => { + mockFetch.mockResolvedValue(ok(204)); + service = new ConnectivityService(); + await vi.advanceTimersByTimeAsync(0); + + const handler = vi.fn(); + service.on(ConnectivityEvent.StatusChange, handler); + + await vi.advanceTimersByTimeAsync(3000); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe("HTTP verification", () => { + it("accepts 204 status as success", async () => { + mockFetch.mockResolvedValue({ ok: false, status: 204 }); + service = new ConnectivityService(); + await vi.advanceTimersByTimeAsync(0); + + const result = await service.checkNow(); + expect(result).toEqual({ isOnline: true }); + }); + + it("accepts 200 status as success", async () => { + mockFetch.mockResolvedValue(ok(200)); + service = new ConnectivityService(); + await vi.advanceTimersByTimeAsync(0); + + const result = await service.checkNow(); + expect(result).toEqual({ isOnline: true }); + }); + }); + + describe("polling", () => { + it("polls periodically after construction", async () => { + mockFetch.mockResolvedValue(ok(204)); + service = new ConnectivityService(); + await vi.advanceTimersByTimeAsync(0); + + const callsAfterInit = mockFetch.mock.calls.length; + + await vi.advanceTimersByTimeAsync(3000); + expect(mockFetch.mock.calls.length).toBeGreaterThan(callsAfterInit); + }); + }); +}); diff --git a/packages/workspace-server/src/services/connectivity/service.ts b/packages/workspace-server/src/services/connectivity/service.ts new file mode 100644 index 0000000000..cbad79459d --- /dev/null +++ b/packages/workspace-server/src/services/connectivity/service.ts @@ -0,0 +1,100 @@ +import { TypedEventEmitter } from "@posthog/shared"; +import { injectable } from "inversify"; +import { + ConnectivityEvent, + type ConnectivityEvents, + type ConnectivityStatusOutput, +} from "./schemas"; + +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 = true; + private pollTimeoutId: ReturnType<typeof setTimeout> | null = null; + private offlinePollAttempt = 0; + + constructor() { + super(); + this.setMaxListeners(0); + void this.checkConnectivity(); + this.startPolling(); + } + + getStatus(): ConnectivityStatusOutput { + return { isOnline: this.isOnline }; + } + + async checkNow(): Promise<ConnectivityStatusOutput> { + await this.checkConnectivity(); + 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; + this.emit(ConnectivityEvent.StatusChange, { isOnline: online }); + this.offlinePollAttempt = 0; + } + + private async checkConnectivity(): Promise<void> { + this.setOnline(await this.verifyWithHttp()); + } + + private async verifyWithHttp(): Promise<boolean> { + try { + const response = await fetch(CHECK_URL, { + method: "HEAD", + signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), + }); + return response.ok || response.status === 204; + } catch { + return false; + } + } + + private startPolling(): void { + if (this.pollTimeoutId) return; + this.offlinePollAttempt = 0; + this.schedulePoll(); + } + + private schedulePoll(): void { + const interval = this.isOnline + ? ONLINE_POLL_INTERVAL_MS + : 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); + 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..2d70afa54f 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 { 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 type { WorkbenchLogger } from "@posthog/di/logger"; +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/packages/workspace-server/src/services/enrichment/enrichment.ts b/packages/workspace-server/src/services/enrichment/enrichment.ts new file mode 100644 index 0000000000..e8fee9880e --- /dev/null +++ b/packages/workspace-server/src/services/enrichment/enrichment.ts @@ -0,0 +1,427 @@ +import { createHash } from "node:crypto"; +import * as path from "node:path"; +import { + EXT_TO_LANG_ID, + enrichSource, + type ParseResult, + PostHogApi, + PostHogEnricher, + type SerializedEnrichment, + setLogger as setEnricherLogger, + toSerializable, +} from "@posthog/enricher"; +import { inject, injectable } from "inversify"; +import { ENRICHMENT_AUTH, ENRICHMENT_FILE_READER } from "./identifiers"; +import { + WORKBENCH_LOGGER, + type ScopedLogger, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import type { EnrichmentAuth, EnrichmentFileReader } from "./ports"; + +export type PosthogInstallState = + | "not_installed" + | "installed_no_init" + | "initialized"; + +const MAX_CACHE_ENTRIES = 200; +const CACHE_TTL_MS = 10 * 60 * 1000; + +interface CacheEntry { + value: SerializedEnrichment | null; + expiresAt: number; +} + +export interface EnrichFileInput { + taskId: string; + filePath: string; + absolutePath?: string; + content: string; +} + +const MANIFEST_BASENAMES = new Set([ + "package.json", + "requirements.txt", + "pyproject.toml", + "Gemfile", + "Podfile", + "build.gradle", + "build.gradle.kts", + "pubspec.yaml", + "pubspec.yml", + "go.mod", + "composer.json", +]); +const MANIFEST_EXTENSIONS = new Set([".csproj"]); + +const SKIP_PATH_SEGMENTS = new Set([ + "node_modules", + "dist", + "build", + "out", + ".next", + ".nuxt", + ".svelte-kit", + ".turbo", + ".cache", + "vendor", + "target", + "coverage", + ".git", + "__pycache__", + ".venv", + "venv", + "env", + ".tox", +]); + +export interface StaleFlagSuggestion { + flagKey: string; + references: { file: string; line: number; method: string }[]; + referenceCount: number; +} + +const STALE_FLAG_SUGGESTION_CAP = 4; +const STALE_FLAG_REFERENCES_PER_FLAG = 5; +const STALE_LOOKBACK_DAYS = 30; + +const MAX_FILE_BYTES = 256 * 1024; +const MAX_FILES_TO_PARSE = 500; + +interface ParsedRepoEntry { + langId: string; + result: ParseResult | null; +} + +interface ParsedRepoCacheEntry { + files: Map<string, ParsedRepoEntry>; + manifestHit: boolean; +} + +function yieldToEventLoop(): Promise<void> { + return new Promise<void>((resolve) => setImmediate(resolve)); +} + +function shouldSkipPath(relPath: string): boolean { + const parts = relPath.split(/[\\/]/); + return parts.some((segment) => SKIP_PATH_SEGMENTS.has(segment)); +} + +function isManifestPath(relPath: string): boolean { + const base = path.basename(relPath); + if (MANIFEST_BASENAMES.has(base)) return true; + const ext = path.extname(relPath).toLowerCase(); + return MANIFEST_EXTENSIONS.has(ext); +} + +function isUsageProbeCandidate(relPath: string): boolean { + if (shouldSkipPath(relPath)) return false; + const ext = path.extname(relPath).toLowerCase(); + if (!ext) return false; + return ext in EXT_TO_LANG_ID; +} + +@injectable() +export class EnrichmentService { + private enricher: PostHogEnricher | null = null; + private readonly cache = new Map<string, CacheEntry>(); + private readonly repoScanCache = new Map<string, ParsedRepoCacheEntry>(); + private readonly repoScanInflight = new Map< + string, + Promise<ParsedRepoCacheEntry | null> + >(); + private readonly log: ScopedLogger; + + constructor( + @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, + ): Promise<SerializedEnrichment | null> { + const { taskId, filePath, absolutePath, content } = input; + const cacheKey = this.buildCacheKey(taskId, filePath, content); + + const cached = this.cache.get(cacheKey); + const now = Date.now(); + if (cached && cached.expiresAt > now) { + this.cache.delete(cacheKey); + this.cache.set(cacheKey, cached); + return cached.value; + } + if (cached) { + this.cache.delete(cacheKey); + } + + const result = await this.runEnrichment(filePath, absolutePath, content); + this.setCache(cacheKey, result); + return result; + } + + private async runEnrichment( + filePath: string, + absolutePath: string | undefined, + content: string, + ): Promise<SerializedEnrichment | null> { + const apiConfig = await this.resolveApiConfig(); + if (!apiConfig) return null; + + const enricher = this.getEnricher(); + const enriched = await enrichSource({ + enricher, + apiConfig, + filePath, + absolutePath, + content, + onDebug: (message: string, data?: Record<string, unknown>) => { + this.log.debug(message, { filePath, ...(data ?? {}) }); + }, + }); + + if (!enriched) return null; + return toSerializable(enriched); + } + + private getEnricher(): PostHogEnricher { + if (!this.enricher) { + this.enricher = new PostHogEnricher(); + } + return this.enricher; + } + + private async resolveApiConfig(): Promise<{ + apiKey: string; + host: string; + projectId: number; + } | null> { + const state = this.authService.getState(); + if ( + state.status !== "authenticated" || + !state.projectId || + !state.cloudRegion + ) { + return null; + } + try { + const auth = await this.authService.getValidAccessToken(); + return { + apiKey: auth.accessToken, + host: auth.apiHost, + projectId: state.projectId, + }; + } catch (err) { + this.log.debug("Failed to resolve access token", { + message: err instanceof Error ? err.message : String(err), + }); + return null; + } + } + + async detectPosthogInstallState( + repoPath: string, + ): Promise<PosthogInstallState> { + if (!repoPath) return "not_installed"; + + const scan = await this.scanRepo(repoPath); + if (!scan) return "not_installed"; + + let usageFound = false; + for (const entry of scan.files.values()) { + if (!entry.result) continue; + if (entry.result.calls.length > 0 || entry.result.initCalls.length > 0) { + usageFound = true; + break; + } + } + + if (usageFound) return "initialized"; + if (scan.manifestHit) return "installed_no_init"; + return "not_installed"; + } + + async findStaleFlagSuggestions( + repoPath: string, + ): Promise<StaleFlagSuggestion[]> { + if (!repoPath) return []; + + const apiConfig = await this.resolveApiConfig(); + if (!apiConfig) return []; + + const scan = await this.scanRepo(repoPath); + if (!scan) return []; + + const referencesByKey = new Map< + string, + { file: string; line: number; method: string }[] + >(); + for (const [relPath, entry] of scan.files) { + if (!entry.result) continue; + for (const check of entry.result.flagChecks) { + const list = referencesByKey.get(check.flagKey) ?? []; + list.push({ file: relPath, line: check.line, method: check.method }); + referencesByKey.set(check.flagKey, list); + } + } + + if (referencesByKey.size === 0) return []; + + const flagKeys = [...referencesByKey.keys()]; + let lastCalled: Map<string, string>; + try { + const api = new PostHogApi(apiConfig); + lastCalled = await api.getFlagLastCalled(flagKeys, STALE_LOOKBACK_DAYS); + } catch (err) { + this.log.debug("Failed to fetch flag-call timestamps", { + error: err instanceof Error ? err.message : String(err), + }); + return []; + } + + const staleKeys = flagKeys.filter((key) => !lastCalled.has(key)).sort(); + + const suggestions: StaleFlagSuggestion[] = []; + for (const key of staleKeys) { + const refs = referencesByKey.get(key); + if (!refs || refs.length === 0) continue; + suggestions.push({ + flagKey: key, + references: refs.slice(0, STALE_FLAG_REFERENCES_PER_FLAG), + referenceCount: refs.length, + }); + if (suggestions.length >= STALE_FLAG_SUGGESTION_CAP) break; + } + return suggestions; + } + + // Memoized per repoPath; concurrent callers wait on the same in-flight + // promise. Cleared by `dispose()`. + private async scanRepo( + repoPath: string, + ): Promise<ParsedRepoCacheEntry | null> { + const cached = this.repoScanCache.get(repoPath); + if (cached) return cached; + + const inflight = this.repoScanInflight.get(repoPath); + if (inflight) return inflight; + + const promise = this.runScan(repoPath).finally(() => { + this.repoScanInflight.delete(repoPath); + }); + this.repoScanInflight.set(repoPath, promise); + return promise; + } + + private async runScan( + repoPath: string, + ): Promise<ParsedRepoCacheEntry | null> { + let posthogFiles: string[]; + try { + posthogFiles = await this.files.listFilesContainingText( + repoPath, + "posthog", + ); + } catch (err) { + this.log.debug("git grep failed during repo scan", { + repoPath, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + const enricher = this.getEnricher(); + const langIdMap = EXT_TO_LANG_ID as Record<string, string | undefined>; + + const manifestHit = posthogFiles.some(isManifestPath); + + const toParse: { relPath: string; langId: string }[] = []; + for (const relPath of posthogFiles) { + if (!isUsageProbeCandidate(relPath)) continue; + const ext = path.extname(relPath).toLowerCase(); + const langId = langIdMap[ext]; + if (!langId || !enricher.isSupported(langId)) continue; + toParse.push({ relPath, langId }); + if (toParse.length >= MAX_FILES_TO_PARSE) { + this.log.info("Capping repo parse to keep main process responsive", { + repoPath, + totalCandidates: posthogFiles.length, + parseLimit: MAX_FILES_TO_PARSE, + }); + break; + } + } + + const files = new Map<string, ParsedRepoEntry>(); + for (const candidate of toParse) { + const absPath = path.join(repoPath, candidate.relPath); + let content: string; + try { + const stat = await this.files.stat(absPath); + if (stat.size > MAX_FILE_BYTES) { + files.set(candidate.relPath, { + langId: candidate.langId, + result: null, + }); + continue; + } + content = await this.files.readFile(absPath); + } catch { + continue; + } + try { + const result = await enricher.parse(content, candidate.langId); + files.set(candidate.relPath, { langId: candidate.langId, result }); + } catch (err) { + this.log.debug("enricher.parse threw during repo scan, skipping file", { + file: candidate.relPath, + error: err instanceof Error ? err.message : String(err), + }); + files.set(candidate.relPath, { + langId: candidate.langId, + result: null, + }); + } + await yieldToEventLoop(); + } + + const entry: ParsedRepoCacheEntry = { files, manifestHit }; + this.repoScanCache.set(repoPath, entry); + return entry; + } + + private buildCacheKey( + taskId: string, + filePath: string, + content: string, + ): string { + const hash = createHash("sha1").update(content).digest("hex"); + return `${taskId}::${filePath}::${hash}`; + } + + private setCache(key: string, value: SerializedEnrichment | null): void { + this.cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS }); + while (this.cache.size > MAX_CACHE_ENTRIES) { + const oldest = this.cache.keys().next().value; + if (oldest === undefined) break; + this.cache.delete(oldest); + } + } + + dispose(): void { + this.enricher?.dispose(); + this.enricher = null; + this.cache.clear(); + this.repoScanCache.clear(); + this.repoScanInflight.clear(); + } +} 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..d1e1bc7d33 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 { listFilesContainingText } from "@posthog/git/queries"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("../../utils/logger.js", () => makeLoggerMock()); +import type { WorkbenchLogger } from "@posthog/di/logger"; +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/packages/workspace-server/src/services/external-apps/external-apps.ts b/packages/workspace-server/src/services/external-apps/external-apps.ts new file mode 100644 index 0000000000..ef3ad25b4a --- /dev/null +++ b/packages/workspace-server/src/services/external-apps/external-apps.ts @@ -0,0 +1,661 @@ +import { exec } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { + CLIPBOARD_SERVICE, + type IClipboard, +} from "@posthog/platform/clipboard"; +import { FILE_ICON_SERVICE, type IFileIcon } from "@posthog/platform/file-icon"; +import type { DetectedApplication } from "./schemas"; +import { inject, injectable } from "inversify"; +import { EXTERNAL_APPS_STORE } from "./identifiers"; +import type { ExternalAppsStore } from "./ports"; +import type { AppDefinition } from "./types"; + +const execAsync = promisify(exec); + +const LOCALAPPDATA = process.env.LOCALAPPDATA ?? ""; +const PROGRAMFILES = process.env.PROGRAMFILES ?? "C:\\Program Files"; + +@injectable() +export class ExternalAppsService { + private readonly APP_DEFINITIONS: Record<string, AppDefinition> = { + // Cross-platform editors + vscode: { + type: "editor", + darwin: { path: "/Applications/Visual Studio Code.app" }, + win32: { + paths: [ + path.join(LOCALAPPDATA, "Programs", "Microsoft VS Code", "Code.exe"), + ], + exeName: "code", + }, + }, + cursor: { + type: "editor", + darwin: { path: "/Applications/Cursor.app" }, + win32: { + paths: [path.join(LOCALAPPDATA, "Programs", "cursor", "Cursor.exe")], + exeName: "cursor", + }, + }, + windsurf: { + type: "editor", + darwin: { path: "/Applications/Windsurf.app" }, + win32: { + paths: [ + path.join(LOCALAPPDATA, "Programs", "Windsurf", "Windsurf.exe"), + ], + exeName: "windsurf", + }, + }, + zed: { + type: "editor", + darwin: { path: "/Applications/Zed.app" }, + win32: { + paths: [path.join(LOCALAPPDATA, "Programs", "Zed", "Zed.exe")], + exeName: "zed", + }, + }, + sublime: { + type: "editor", + darwin: { path: "/Applications/Sublime Text.app" }, + win32: { + paths: [path.join(PROGRAMFILES, "Sublime Text", "sublime_text.exe")], + exeName: "subl", + }, + }, + lapce: { + type: "editor", + darwin: { path: "/Applications/Lapce.app" }, + win32: { + paths: [path.join(PROGRAMFILES, "Lapce", "lapce.exe")], + exeName: "lapce", + }, + }, + emacs: { + type: "editor", + darwin: { path: "/Applications/Emacs.app" }, + win32: { + paths: [path.join(PROGRAMFILES, "Emacs", "bin", "emacs.exe")], + exeName: "emacs", + }, + }, + androidstudio: { + type: "editor", + darwin: { path: "/Applications/Android Studio.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "Android", + "Android Studio", + "bin", + "studio64.exe", + ), + ], + }, + }, + fleet: { + type: "editor", + darwin: { path: "/Applications/Fleet.app" }, + win32: { + paths: [path.join(LOCALAPPDATA, "JetBrains", "Fleet", "fleet.exe")], + }, + }, + intellij: { + type: "editor", + darwin: { path: "/Applications/IntelliJ IDEA.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "IntelliJ IDEA", + "bin", + "idea64.exe", + ), + ], + }, + }, + intellijce: { + type: "editor", + darwin: { path: "/Applications/IntelliJ IDEA CE.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "IntelliJ IDEA Community Edition", + "bin", + "idea64.exe", + ), + ], + }, + }, + intellijultimate: { + type: "editor", + darwin: { path: "/Applications/IntelliJ IDEA Ultimate.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "IntelliJ IDEA", + "bin", + "idea64.exe", + ), + ], + }, + }, + webstorm: { + type: "editor", + darwin: { path: "/Applications/WebStorm.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "WebStorm", + "bin", + "webstorm64.exe", + ), + ], + }, + }, + pycharm: { + type: "editor", + darwin: { path: "/Applications/PyCharm.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "PyCharm", + "bin", + "pycharm64.exe", + ), + ], + }, + }, + pycharmce: { + type: "editor", + darwin: { path: "/Applications/PyCharm CE.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "PyCharm Community Edition", + "bin", + "pycharm64.exe", + ), + ], + }, + }, + pycharmpro: { + type: "editor", + darwin: { path: "/Applications/PyCharm Professional Edition.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "PyCharm Professional", + "bin", + "pycharm64.exe", + ), + ], + }, + }, + phpstorm: { + type: "editor", + darwin: { path: "/Applications/PhpStorm.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "PhpStorm", + "bin", + "phpstorm64.exe", + ), + ], + }, + }, + rubymine: { + type: "editor", + darwin: { path: "/Applications/RubyMine.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "RubyMine", + "bin", + "rubymine64.exe", + ), + ], + }, + }, + goland: { + type: "editor", + darwin: { path: "/Applications/GoLand.app" }, + win32: { + paths: [ + path.join(PROGRAMFILES, "JetBrains", "GoLand", "bin", "goland64.exe"), + ], + }, + }, + clion: { + type: "editor", + darwin: { path: "/Applications/CLion.app" }, + win32: { + paths: [ + path.join(PROGRAMFILES, "JetBrains", "CLion", "bin", "clion64.exe"), + ], + }, + }, + rider: { + type: "editor", + darwin: { path: "/Applications/Rider.app" }, + win32: { + paths: [ + path.join(PROGRAMFILES, "JetBrains", "Rider", "bin", "rider64.exe"), + ], + }, + }, + datagrip: { + type: "editor", + darwin: { path: "/Applications/DataGrip.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "DataGrip", + "bin", + "datagrip64.exe", + ), + ], + }, + }, + dataspell: { + type: "editor", + darwin: { path: "/Applications/DataSpell.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "DataSpell", + "bin", + "dataspell64.exe", + ), + ], + }, + }, + rustrover: { + type: "editor", + darwin: { path: "/Applications/RustRover.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "RustRover", + "bin", + "rustrover64.exe", + ), + ], + }, + }, + aqua: { + type: "editor", + darwin: { path: "/Applications/Aqua.app" }, + win32: { + paths: [ + path.join(PROGRAMFILES, "JetBrains", "Aqua", "bin", "aqua64.exe"), + ], + }, + }, + writerside: { + type: "editor", + darwin: { path: "/Applications/Writerside.app" }, + win32: { + paths: [ + path.join( + PROGRAMFILES, + "JetBrains", + "Writerside", + "bin", + "writerside64.exe", + ), + ], + }, + }, + eclipse: { + type: "editor", + darwin: { path: "/Applications/Eclipse.app" }, + win32: { + paths: [path.join(PROGRAMFILES, "Eclipse", "eclipse.exe")], + exeName: "eclipse", + }, + }, + netbeans: { + type: "editor", + darwin: { path: "/Applications/NetBeans.app" }, + win32: { + paths: [path.join(PROGRAMFILES, "NetBeans", "bin", "netbeans64.exe")], + }, + }, + netbeansapache: { + type: "editor", + darwin: { path: "/Applications/Apache NetBeans.app" }, + win32: { + paths: [path.join(PROGRAMFILES, "NetBeans", "bin", "netbeans64.exe")], + }, + }, + // macOS-only editors + nova: { type: "editor", darwin: { path: "/Applications/Nova.app" } }, + bbedit: { type: "editor", darwin: { path: "/Applications/BBEdit.app" } }, + textmate: { + type: "editor", + darwin: { path: "/Applications/TextMate.app" }, + }, + xcode: { type: "editor", darwin: { path: "/Applications/Xcode.app" } }, + appcode: { + type: "editor", + darwin: { path: "/Applications/AppCode.app" }, + }, + // macOS-only terminals + iterm: { type: "terminal", darwin: { path: "/Applications/iTerm.app" } }, + warp: { type: "terminal", darwin: { path: "/Applications/Warp.app" } }, + terminal: { + type: "terminal", + darwin: { path: "/System/Applications/Utilities/Terminal.app" }, + }, + ghostty: { + type: "terminal", + darwin: { path: "/Applications/Ghostty.app" }, + }, + kitty: { + type: "terminal", + darwin: { path: "/Applications/kitty.app" }, + }, + rio: { type: "terminal", darwin: { path: "/Applications/Rio.app" } }, + // Cross-platform terminals + alacritty: { + type: "terminal", + darwin: { path: "/Applications/Alacritty.app" }, + win32: { + paths: [path.join(PROGRAMFILES, "Alacritty", "alacritty.exe")], + exeName: "alacritty", + }, + }, + hyper: { + type: "terminal", + darwin: { path: "/Applications/Hyper.app" }, + win32: { + paths: [path.join(LOCALAPPDATA, "Programs", "Hyper", "Hyper.exe")], + }, + }, + tabby: { + type: "terminal", + darwin: { path: "/Applications/Tabby.app" }, + win32: { + paths: [path.join(LOCALAPPDATA, "Programs", "Tabby", "Tabby.exe")], + }, + }, + // Windows-only terminals + windowsterminal: { + type: "terminal", + win32: { + paths: [path.join(LOCALAPPDATA, "Microsoft", "WindowsApps", "wt.exe")], + exeName: "wt", + }, + }, + // Git clients + gitkraken: { + type: "git-client", + darwin: { path: "/Applications/GitKraken.app" }, + }, + // File managers + finder: { + type: "file-manager", + darwin: { path: "/System/Library/CoreServices/Finder.app" }, + }, + explorer: { + type: "file-manager", + win32: { + paths: [ + path.join(process.env.SYSTEMROOT ?? "C:\\Windows", "explorer.exe"), + ], + }, + }, + }; + + private readonly DISPLAY_NAMES: Record<string, string> = { + vscode: "VS Code", + cursor: "Cursor", + windsurf: "Windsurf", + zed: "Zed", + sublime: "Sublime Text", + nova: "Nova", + bbedit: "BBEdit", + textmate: "TextMate", + lapce: "Lapce", + emacs: "Emacs", + xcode: "Xcode", + androidstudio: "Android Studio", + fleet: "Fleet", + intellij: "IntelliJ IDEA", + intellijce: "IntelliJ IDEA CE", + intellijultimate: "IntelliJ IDEA Ultimate", + webstorm: "WebStorm", + pycharm: "PyCharm", + pycharmce: "PyCharm CE", + pycharmpro: "PyCharm Professional", + phpstorm: "PhpStorm", + rubymine: "RubyMine", + goland: "GoLand", + clion: "CLion", + rider: "Rider", + datagrip: "DataGrip", + dataspell: "DataSpell", + rustrover: "RustRover", + aqua: "Aqua", + writerside: "Writerside", + appcode: "AppCode", + eclipse: "Eclipse", + netbeans: "NetBeans", + netbeansapache: "Apache NetBeans", + iterm: "iTerm", + warp: "Warp", + terminal: "Terminal", + alacritty: "Alacritty", + kitty: "Kitty", + ghostty: "Ghostty", + hyper: "Hyper", + tabby: "Tabby", + rio: "Rio", + finder: "Finder", + windowsterminal: "Windows Terminal", + explorer: "Explorer", + gitkraken: "GitKraken", + }; + + private cachedApps: DetectedApplication[] | null = null; + private detectionPromise: Promise<DetectedApplication[]> | null = null; + + constructor( + @inject(CLIPBOARD_SERVICE) + private readonly clipboard: IClipboard, + @inject(FILE_ICON_SERVICE) + private readonly fileIcon: IFileIcon, + @inject(EXTERNAL_APPS_STORE) + private readonly store: ExternalAppsStore, + ) {} + + private async extractIcon(appPath: string): Promise<string | undefined> { + const dataUrl = await this.fileIcon.getAsDataUrl(appPath); + return dataUrl ?? undefined; + } + + private async findWin32Executable( + definition: NonNullable<AppDefinition["win32"]>, + ): Promise<string | null> { + for (const p of definition.paths) { + try { + await fs.access(p); + return p; + } catch { + // path not found, try next + } + } + + if (definition.exeName) { + try { + const { stdout } = await execAsync(`where.exe ${definition.exeName}`); + const firstLine = stdout.trim().split("\n")[0]?.trim(); + if (firstLine) { + return firstLine; + } + } catch { + // not found in PATH + } + } + + return null; + } + + private async checkApplication( + id: string, + definition: AppDefinition, + ): Promise<DetectedApplication | null> { + try { + let appPath: string; + let command: string; + + if (process.platform === "darwin") { + const darwinDef = definition.darwin; + if (!darwinDef) return null; + + await fs.access(darwinDef.path); + appPath = darwinDef.path; + command = `open -a "${appPath}"`; + } else if (process.platform === "win32") { + const win32Def = definition.win32; + if (!win32Def) return null; + + const exePath = await this.findWin32Executable(win32Def); + if (!exePath) return null; + + appPath = exePath; + command = `"${appPath}"`; + } else { + return null; + } + + const icon = await this.extractIcon(appPath); + const name = this.DISPLAY_NAMES[id] || id; + return { id, name, type: definition.type, path: appPath, command, icon }; + } catch { + return null; + } + } + + private async detectExternalApps(): Promise<DetectedApplication[]> { + const apps: DetectedApplication[] = []; + for (const [id, definition] of Object.entries(this.APP_DEFINITIONS)) { + const detected = await this.checkApplication(id, definition); + if (detected) { + apps.push(detected); + } + } + return apps; + } + + async getDetectedApps(): Promise<DetectedApplication[]> { + if (this.cachedApps) { + return this.cachedApps; + } + + if (this.detectionPromise) { + return this.detectionPromise; + } + + this.detectionPromise = this.detectExternalApps().then((apps) => { + this.cachedApps = apps; + this.detectionPromise = null; + return apps; + }); + + return this.detectionPromise; + } + + async openInApp( + appId: string, + targetPath: string, + ): Promise<{ success: boolean; error?: string }> { + try { + const apps = await this.getDetectedApps(); + const appToOpen = apps.find((a) => a.id === appId); + + if (!appToOpen) { + return { success: false, error: "Application not found" }; + } + + let isFile = false; + try { + const stat = await fs.stat(targetPath); + isFile = stat.isFile(); + } catch { + isFile = false; + } + + let command: string; + + if (process.platform === "darwin") { + if (appToOpen.id === "finder" && isFile) { + command = `open -R "${targetPath}"`; + } else if (appToOpen.id === "gitkraken") { + // GitKraken ignores positional args; it needs `--args -p <path>`. + command = `open -na "${appToOpen.path}" --args -p "${targetPath}"`; + } else { + command = `open -a "${appToOpen.path}" "${targetPath}"`; + } + } else if (process.platform === "win32") { + command = + appToOpen.id === "explorer" && isFile + ? `explorer.exe /select,"${targetPath}"` + : `"${appToOpen.path}" "${targetPath}"`; + } else { + return { success: false, error: "Unsupported platform" }; + } + + await execAsync(command); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + async setLastUsed(appId: string): Promise<void> { + const prefs = this.store.getPrefs(); + this.store.setPrefs({ ...prefs, lastUsedApp: appId }); + } + + async getLastUsed(): Promise<{ lastUsedApp?: string }> { + const prefs = this.store.getPrefs(); + return { lastUsedApp: prefs.lastUsedApp }; + } + + async copyPath(targetPath: string): Promise<void> { + await this.clipboard.writeText(targetPath); + } +} 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/packages/workspace-server/src/services/external-apps/schemas.ts b/packages/workspace-server/src/services/external-apps/schemas.ts new file mode 100644 index 0000000000..42f56cb84d --- /dev/null +++ b/packages/workspace-server/src/services/external-apps/schemas.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +export const openInAppInput = z.object({ + appId: z.string(), + targetPath: z.string(), +}); + +export const setLastUsedInput = z.object({ + appId: z.string(), +}); + +export const copyPathInput = z.object({ + targetPath: z.string(), +}); + +export const externalAppType = z.enum([ + "editor", + "terminal", + "file-manager", + "git-client", +]); + +const detectedApplication = z.object({ + id: z.string(), + name: z.string(), + type: externalAppType, + path: z.string(), + command: z.string(), + icon: z.string().optional(), +}); + +export const getDetectedAppsOutput = z.array(detectedApplication); +export const openInAppOutput = z.object({ + success: z.boolean(), + error: z.string().optional(), +}); +export const getLastUsedOutput = z.object({ + lastUsedApp: z.string().optional(), +}); + +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/packages/workspace-server/src/services/external-apps/types.ts b/packages/workspace-server/src/services/external-apps/types.ts new file mode 100644 index 0000000000..609e32c3b6 --- /dev/null +++ b/packages/workspace-server/src/services/external-apps/types.ts @@ -0,0 +1,15 @@ +import type { ExternalAppType } from "./schemas"; + +export interface AppDefinition { + type: ExternalAppType; + darwin?: { path: string }; + win32?: { paths: string[]; exeName?: string }; +} + +export interface ExternalAppsPreferences { + lastUsedApp?: string; +} + +export interface ExternalAppsSchema { + externalAppsPrefs: ExternalAppsPreferences; +} 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/packages/workspace-server/src/services/folders/folders.test.ts b/packages/workspace-server/src/services/folders/folders.test.ts new file mode 100644 index 0000000000..441ebd983d --- /dev/null +++ b/packages/workspace-server/src/services/folders/folders.test.ts @@ -0,0 +1,610 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockExistsSync = vi.hoisted(() => vi.fn(() => true)); +const mockDialog = vi.hoisted(() => ({ + confirm: vi.fn(), + pickFile: vi.fn(), +})); +const mockRepositoryRepo = vi.hoisted(() => ({ + findAll: vi.fn(), + findById: vi.fn(), + findByPath: vi.fn(), + findByRemoteUrl: vi.fn(), + findMostRecentlyAccessed: vi.fn(), + create: vi.fn(), + upsertByPath: vi.fn(), + updateLastAccessed: vi.fn(), + updateRemoteUrl: vi.fn(), + delete: vi.fn(), +})); +const mockWorkspaceRepo = vi.hoisted(() => ({ + findAllByRepositoryId: vi.fn(), + findAll: vi.fn(), +})); +const mockWorktreeRepo = vi.hoisted(() => ({ + findByWorkspaceId: vi.fn(), + findAll: vi.fn(), +})); +const mockWorktreeManager = vi.hoisted(() => ({ + deleteWorktree: vi.fn(), + cleanupOrphanedWorktrees: vi.fn(), +})); +const mockInitRepositorySaga = vi.hoisted(() => ({ + run: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + existsSync: mockExistsSync, + promises: { + readdir: vi.fn(), + readFile: vi.fn(), + }, + default: { + existsSync: mockExistsSync, + promises: { + readdir: vi.fn(), + readFile: vi.fn(), + }, + }, +})); + +vi.mock("@posthog/git/worktree", () => ({ + WorktreeManager: class MockWorktreeManager { + deleteWorktree = mockWorktreeManager.deleteWorktree; + cleanupOrphanedWorktrees = mockWorktreeManager.cleanupOrphanedWorktrees; + }, +})); + +vi.mock("@posthog/git/queries", () => ({ + isGitRepository: vi.fn(() => Promise.resolve(true)), + getRemoteUrl: vi.fn(() => Promise.resolve(null)), +})); + +vi.mock("@posthog/git/sagas/init", () => ({ + InitRepositorySaga: class { + run = mockInitRepositorySaga.run; + }, +})); + +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 type { WorkbenchLogger } from "@posthog/di/logger"; +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; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRepositoryRepo.findAll.mockReturnValue([]); + mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([]); + mockWorkspaceRepo.findAll.mockReturnValue([]); + mockWorktreeRepo.findAll.mockReturnValue([]); + + service = createService(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("initialize", () => { + it("removes folders that no longer exist on disk", async () => { + mockRepositoryRepo.findAll.mockReturnValue([ + { + id: "folder-1", + path: "/gone/project", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]); + mockExistsSync.mockReturnValue(false); + mockRepositoryRepo.findById.mockReturnValue({ + id: "folder-1", + path: "/gone/project", + }); + mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([]); + + createService(); + await vi.waitFor(() => { + expect(mockRepositoryRepo.delete).toHaveBeenCalledWith("folder-1"); + }); + }); + + it("cleans up orphaned worktrees for each existing folder", async () => { + mockRepositoryRepo.findAll.mockReturnValue([ + { + id: "folder-1", + path: "/home/user/project-a", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + { + id: "folder-2", + path: "/home/user/project-b", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]); + mockExistsSync.mockReturnValue(true); + mockWorktreeRepo.findAll.mockReturnValue([]); + mockWorktreeManager.cleanupOrphanedWorktrees.mockResolvedValue({ + deleted: [], + errors: [], + }); + + createService(); + await vi.waitFor(() => { + expect( + mockWorktreeManager.cleanupOrphanedWorktrees, + ).toHaveBeenCalledTimes(2); + }); + }); + + it("continues if one folder removal fails", async () => { + mockRepositoryRepo.findAll.mockReturnValue([ + { + id: "folder-1", + path: "/gone/a", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + { + id: "folder-2", + path: "/gone/b", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]); + mockExistsSync.mockReturnValue(false); + mockRepositoryRepo.findById + .mockReturnValueOnce({ id: "folder-1", path: "/gone/a" }) + .mockReturnValueOnce({ id: "folder-2", path: "/gone/b" }); + mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([]); + mockRepositoryRepo.delete + .mockImplementationOnce(() => { + throw new Error("db error"); + }) + .mockImplementationOnce(() => undefined); + + createService(); + await vi.waitFor(() => { + expect(mockRepositoryRepo.delete).toHaveBeenCalledTimes(2); + expect(mockRepositoryRepo.delete).toHaveBeenCalledWith("folder-2"); + }); + }); + + it("continues if one worktree cleanup fails", async () => { + mockRepositoryRepo.findAll.mockReturnValue([ + { + id: "folder-1", + path: "/home/user/project-a", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + { + id: "folder-2", + path: "/home/user/project-b", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]); + mockExistsSync.mockReturnValue(true); + mockWorktreeRepo.findAll.mockReturnValue([]); + mockWorktreeManager.cleanupOrphanedWorktrees + .mockRejectedValueOnce(new Error("cleanup error")) + .mockResolvedValueOnce({ deleted: [], errors: [] }); + + createService(); + await vi.waitFor(() => { + expect( + mockWorktreeManager.cleanupOrphanedWorktrees, + ).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe("getFolders", () => { + it("returns empty array when no folders registered", async () => { + mockRepositoryRepo.findAll.mockReturnValue([]); + + const result = await service.getFolders(); + + expect(result).toEqual([]); + }); + + it("returns folders with exists property", async () => { + const repos = [ + { + id: "folder-1", + path: "/home/user/project", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]; + mockRepositoryRepo.findAll.mockReturnValue(repos); + mockExistsSync.mockReturnValue(true); + + const result = await service.getFolders(); + + expect(result).toEqual([ + { + id: "folder-1", + path: "/home/user/project", + name: "project", + remoteUrl: null, + lastAccessed: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + exists: true, + }, + ]); + }); + + it("strips .git suffix from remote repo name in display name (defensive against legacy data)", async () => { + const repos = [ + { + id: "folder-1", + path: "/home/user/my-billing-fork", + remoteUrl: "PostHog/billing.git", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]; + mockRepositoryRepo.findAll.mockReturnValue(repos); + mockExistsSync.mockReturnValue(true); + + const result = await service.getFolders(); + + expect(result[0].name).toBe("my-billing-fork (billing)"); + }); + + it("uses remote repo name in display name when it differs from local dir", async () => { + const repos = [ + { + id: "folder-1", + path: "/home/user/ph-tour-demo", + remoteUrl: "PostHog/hogotchi", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]; + mockRepositoryRepo.findAll.mockReturnValue(repos); + mockExistsSync.mockReturnValue(true); + + const result = await service.getFolders(); + + expect(result[0].name).toBe("ph-tour-demo (hogotchi)"); + }); + + it("uses local dir name when it matches remote repo name", async () => { + const repos = [ + { + id: "folder-1", + path: "/home/user/hogotchi", + remoteUrl: "PostHog/hogotchi", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]; + mockRepositoryRepo.findAll.mockReturnValue(repos); + mockExistsSync.mockReturnValue(true); + + const result = await service.getFolders(); + + expect(result[0].name).toBe("hogotchi"); + }); + + it("uses local dir name when it matches remote repo name case-insensitively", async () => { + const repos = [ + { + id: "folder-1", + path: "/home/user/Hogotchi", + remoteUrl: "PostHog/hogotchi", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]; + mockRepositoryRepo.findAll.mockReturnValue(repos); + mockExistsSync.mockReturnValue(true); + + const result = await service.getFolders(); + + expect(result[0].name).toBe("Hogotchi"); + }); + + it("marks non-existent folders", async () => { + const repos = [ + { + id: "folder-1", + path: "/nonexistent/path", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }, + ]; + mockRepositoryRepo.findAll.mockReturnValue(repos); + mockExistsSync.mockReturnValue(false); + + const result = await service.getFolders(); + + expect(result[0].exists).toBe(false); + }); + }); + + describe("addFolder", () => { + it("adds a new folder when it is a git repository", async () => { + vi.mocked(isGitRepository).mockResolvedValue(true); + mockRepositoryRepo.findByPath.mockReturnValue(null); + mockRepositoryRepo.create.mockReturnValue({ + id: "folder-new", + path: "/home/user/my-project", + remoteUrl: null, + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }); + + const result = await service.addFolder("/home/user/my-project"); + + expect(result.name).toBe("my-project"); + expect(result.path).toBe("/home/user/my-project"); + expect(result.exists).toBe(true); + expect(mockRepositoryRepo.create).toHaveBeenCalledWith({ + path: "/home/user/my-project", + remoteUrl: undefined, + }); + }); + + it("throws error for invalid folder path", async () => { + await expect(service.addFolder("")).rejects.toThrow( + "Invalid folder path", + ); + }); + + it("prompts to initialize git for non-git folder", async () => { + vi.mocked(isGitRepository).mockResolvedValue(false); + mockDialog.confirm.mockResolvedValue(0); + mockInitRepositorySaga.run.mockResolvedValue({ + success: true, + data: { initialized: true }, + }); + mockRepositoryRepo.findByPath.mockReturnValue(null); + mockRepositoryRepo.create.mockReturnValue({ + id: "folder-new", + path: "/home/user/project", + remoteUrl: null, + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }); + + const result = await service.addFolder("/home/user/project"); + + expect(mockDialog.confirm).toHaveBeenCalled(); + expect(mockInitRepositorySaga.run).toHaveBeenCalledWith({ + baseDir: "/home/user/project", + initialCommit: true, + commitMessage: "Initial commit", + }); + expect(result.name).toBe("project"); + }); + + it("tags a new folder with the supplied remoteUrl override", async () => { + vi.mocked(isGitRepository).mockResolvedValue(true); + mockRepositoryRepo.findByPath.mockReturnValue(null); + mockRepositoryRepo.create.mockReturnValue({ + id: "folder-new", + path: "/home/user/fork", + remoteUrl: "PostHog/posthog", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }); + + await service.addFolder("/home/user/fork", { + remoteUrl: "https://github.com/PostHog/posthog", + }); + + expect(mockRepositoryRepo.create).toHaveBeenCalledWith({ + path: "/home/user/fork", + remoteUrl: "PostHog/posthog", + }); + }); + + it("normalizes a non-GitHub override and skips the local remote lookup", async () => { + vi.mocked(isGitRepository).mockResolvedValue(true); + vi.mocked(getRemoteUrl).mockResolvedValue( + "https://github.com/SomeoneElse/wrong", + ); + mockRepositoryRepo.findByPath.mockReturnValue(null); + mockRepositoryRepo.create.mockReturnValue({ + id: "folder-new", + path: "/home/user/fork", + remoteUrl: "https://gitlab.com/PostHog/posthog", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }); + + await service.addFolder("/home/user/fork", { + remoteUrl: "https://gitlab.com/PostHog/posthog.git", + }); + + expect(mockRepositoryRepo.create).toHaveBeenCalledWith({ + path: "/home/user/fork", + remoteUrl: "https://gitlab.com/PostHog/posthog", + }); + expect(getRemoteUrl).not.toHaveBeenCalled(); + }); + + it("backfills remoteUrl on an existing folder when override is supplied", async () => { + vi.mocked(isGitRepository).mockResolvedValue(true); + const existing = { + id: "folder-existing", + path: "/home/user/project", + remoteUrl: null, + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + mockRepositoryRepo.findByPath.mockReturnValue(existing); + mockRepositoryRepo.findById.mockReturnValue(existing); + + await service.addFolder("/home/user/project", { + remoteUrl: "https://github.com/PostHog/posthog", + }); + + expect(mockRepositoryRepo.updateRemoteUrl).toHaveBeenCalledWith( + "folder-existing", + "PostHog/posthog", + ); + }); + + it("throws error when user cancels git init", async () => { + vi.mocked(isGitRepository).mockResolvedValue(false); + mockDialog.confirm.mockResolvedValue(1); + + await expect(service.addFolder("/home/user/project")).rejects.toThrow( + "Folder must be a git repository", + ); + }); + }); + + describe("removeFolder", () => { + it("removes folder from database", async () => { + mockRepositoryRepo.findById.mockReturnValue({ + id: "folder-1", + path: "/home/user/project", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }); + mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([]); + + await service.removeFolder("folder-1"); + + expect(mockRepositoryRepo.delete).toHaveBeenCalledWith("folder-1"); + }); + + it("removes associated worktrees", async () => { + mockRepositoryRepo.findById.mockReturnValue({ + id: "folder-1", + path: "/home/user/project", + lastAccessedAt: "2024-01-01T00:00:00.000Z", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }); + mockWorkspaceRepo.findAllByRepositoryId.mockReturnValue([ + { + id: "workspace-1", + taskId: "task-1", + repositoryId: "folder-1", + mode: "worktree", + state: "active", + }, + ]); + mockWorktreeRepo.findByWorkspaceId.mockReturnValue({ + id: "worktree-1", + workspaceId: "workspace-1", + name: "code-task-1", + path: "/tmp/worktrees/project/code-task-1", + branch: "main", + }); + mockWorktreeManager.deleteWorktree.mockResolvedValue(undefined); + + await service.removeFolder("folder-1"); + + expect(mockWorktreeManager.deleteWorktree).toHaveBeenCalled(); + }); + }); + + describe("updateFolderAccessed", () => { + it("updates lastAccessed timestamp", async () => { + await service.updateFolderAccessed("folder-1"); + + expect(mockRepositoryRepo.updateLastAccessed).toHaveBeenCalledWith( + "folder-1", + ); + }); + }); + + describe("cleanupOrphanedWorktrees", () => { + it("delegates to WorktreeManager", async () => { + mockWorktreeRepo.findAll.mockReturnValue([]); + mockWorktreeManager.cleanupOrphanedWorktrees.mockResolvedValue({ + deleted: ["/tmp/worktrees/project/orphan-1"], + errors: [], + }); + + await service.cleanupOrphanedWorktrees("/home/user/project"); + + expect(mockWorktreeManager.cleanupOrphanedWorktrees).toHaveBeenCalledWith( + [], + ); + }); + + it("excludes associated worktrees from cleanup", async () => { + mockWorktreeRepo.findAll.mockReturnValue([ + { + id: "worktree-1", + workspaceId: "workspace-1", + name: "code-task-1", + path: "/tmp/worktrees/project/code-task-1", + branch: "main", + }, + ]); + mockWorktreeManager.cleanupOrphanedWorktrees.mockResolvedValue({ + deleted: [], + errors: [], + }); + + await service.cleanupOrphanedWorktrees("/home/user/project"); + + expect(mockWorktreeManager.cleanupOrphanedWorktrees).toHaveBeenCalledWith( + ["/tmp/worktrees/project/code-task-1"], + ); + }); + }); +}); diff --git a/packages/workspace-server/src/services/folders/folders.ts b/packages/workspace-server/src/services/folders/folders.ts new file mode 100644 index 0000000000..47a8d8a756 --- /dev/null +++ b/packages/workspace-server/src/services/folders/folders.ts @@ -0,0 +1,318 @@ +import fs from "node:fs"; +import path from "node:path"; +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 { + WORKBENCH_LOGGER, + type ScopedLogger, + type WorkbenchLogger, +} from "@posthog/di/logger"; +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 type { RegisteredFolder } from "./schemas"; + +function normalizeRepoKey(key: string): string { + return key.trim().replace(/\.git$/, ""); +} + +@injectable() +export class FoldersService { + constructor( + @inject(REPOSITORY_REPOSITORY) + private readonly repositoryRepo: IRepositoryRepository, + @inject(WORKSPACE_REPOSITORY) + private readonly workspaceRepo: IWorkspaceRepository, + @inject(WORKTREE_REPOSITORY) + private readonly worktreeRepo: IWorktreeRepository, + @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) => { + this.log.error("Folders initialization failed", err); + }); + } + + private readonly log: ScopedLogger; + + private async initialize(): Promise<void> { + const folders = await this.getFolders(); + + const deletedFolders = folders.filter((f) => !f.exists); + if (deletedFolders.length > 0) { + let removed = 0; + for (const folder of deletedFolders) { + try { + await this.removeFolder(folder.id); + removed++; + } catch (err) { + this.log.error( + `Failed to remove deleted folder ${folder.path}:`, + err, + ); + } + } + if (removed > 0) { + this.log.info(`Removed ${removed} deleted folder(s)`); + } + } + + const existingFolders = folders.filter((f) => f.exists); + const results = await Promise.allSettled( + existingFolders.map((folder) => + this.cleanupOrphanedWorktrees(folder.path), + ), + ); + for (const [i, result] of results.entries()) { + if (result.status === "rejected") { + this.log.error( + `Failed to cleanup orphaned worktrees for ${existingFolders[i].path}:`, + result.reason, + ); + } + } + } + + private getDisplayName( + repoPath: string, + remoteUrl: string | null | undefined, + ): string { + const localName = path.basename(repoPath); + if (remoteUrl) { + const repoName = normalizeRepoKey(remoteUrl).split("/").pop(); + if (repoName && repoName.toLowerCase() !== localName.toLowerCase()) { + return `${localName} (${repoName})`; + } + } + return localName; + } + + async getFolders(): Promise<(RegisteredFolder & { exists: boolean })[]> { + const repos = this.repositoryRepo.findAll(); + return repos + .filter((r) => r.path) + .map((r) => ({ + id: r.id, + path: r.path, + name: this.getDisplayName(r.path, r.remoteUrl), + remoteUrl: r.remoteUrl ?? null, + lastAccessed: r.lastAccessedAt ?? r.createdAt, + createdAt: r.createdAt, + exists: fs.existsSync(r.path), + })); + } + + async addFolder( + folderPath: string, + options: { remoteUrl?: string } = {}, + ): Promise<RegisteredFolder & { exists: boolean }> { + const folderName = path.basename(folderPath); + if (!folderPath || !folderName) { + throw new Error( + `Invalid folder path: "${folderPath}" - path must have a valid directory name`, + ); + } + + const isRepo = await isGitRepository(folderPath); + + if (!isRepo) { + const response = await this.dialog.confirm({ + severity: "question", + title: "Initialize Git Repository", + message: "This folder is not a git repository", + detail: `Would you like to initialize git in "${path.basename(folderPath)}"?`, + options: ["Initialize Git", "Cancel"], + defaultIndex: 0, + cancelIndex: 1, + }); + + if (response === 1) { + throw new Error("Folder must be a git repository"); + } + + const saga = new InitRepositorySaga(); + const initResult = await saga.run({ + baseDir: folderPath, + initialCommit: true, + commitMessage: "Initial commit", + }); + if (!initResult.success) { + throw new Error( + `Failed to initialize git repository: ${initResult.error}`, + ); + } + } + + const repoKey = await this.resolveRepoKey(folderPath, options.remoteUrl); + const existingRepo = this.repositoryRepo.findByPath(folderPath); + let repo: Repository; + + if (existingRepo) { + this.repositoryRepo.updateLastAccessed(existingRepo.id); + const updated = this.repositoryRepo.findById(existingRepo.id); + if (!updated) { + throw new Error(`Repository ${existingRepo.id} not found after update`); + } + repo = updated; + + if (repoKey && repo.remoteUrl !== repoKey) { + this.repositoryRepo.updateRemoteUrl(repo.id, repoKey); + const refreshed = this.repositoryRepo.findById(repo.id); + if (!refreshed) { + throw new Error( + `Repository ${repo.id} not found after remote URL update`, + ); + } + repo = refreshed; + } + } else { + repo = this.repositoryRepo.create({ + path: folderPath, + remoteUrl: repoKey ?? undefined, + }); + } + + return { + id: repo.id, + path: repo.path, + name: this.getDisplayName(repo.path, repo.remoteUrl), + remoteUrl: repo.remoteUrl ?? null, + lastAccessed: repo.lastAccessedAt ?? repo.createdAt, + createdAt: repo.createdAt, + exists: true, + }; + } + + async removeFolder(folderId: string): Promise<void> { + const repo = this.repositoryRepo.findById(folderId); + if (!repo) { + this.log.debug(`Folder not found: ${folderId}`); + return; + } + + const workspaces = this.workspaceRepo.findAllByRepositoryId(folderId); + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); + const repoName = path.basename(repo.path); + + for (const workspace of workspaces) { + if (workspace.mode === "worktree") { + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + if (worktree) { + const worktreePath = path.join( + worktreeBasePath, + repoName, + worktree.name, + ); + try { + const manager = new WorktreeManager({ + mainRepoPath: repo.path, + worktreeBasePath, + }); + await manager.deleteWorktree(worktreePath); + } catch (error) { + this.log.error(`Failed to delete worktree ${worktreePath}:`, error); + } + } + } + } + + this.repositoryRepo.delete(folderId); + this.log.debug(`Removed folder with ID: ${folderId}`); + } + + async updateFolderAccessed(folderId: string): Promise<void> { + this.repositoryRepo.updateLastAccessed(folderId); + } + + async cleanupOrphanedWorktrees(mainRepoPath: string): Promise<void> { + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); + const manager = new WorktreeManager({ mainRepoPath, worktreeBasePath }); + + const allWorktrees = this.worktreeRepo.findAll(); + const associatedWorktreePaths = allWorktrees.map((wt) => wt.path); + + await manager.cleanupOrphanedWorktrees(associatedWorktreePaths); + } + + private async resolveRepoKey( + folderPath: string, + overrideRemoteUrl: string | undefined, + ): Promise<string | null> { + const slug = (url: string | null | undefined) => { + const parsed = parseGithubUrl(url); + return parsed ? `${parsed.owner}/${parsed.repo}` : null; + }; + if (overrideRemoteUrl) { + return slug(overrideRemoteUrl) ?? normalizeRepoKey(overrideRemoteUrl); + } + const localRemoteUrl = await getRemoteUrl(folderPath); + return slug(localRemoteUrl); + } + + getRepositoryByRemoteUrl( + remoteUrl: string, + ): { id: string; path: string } | null { + const repo = this.repositoryRepo.findByRemoteUrl(remoteUrl); + if (!repo) return null; + return { id: repo.id, path: repo.path }; + } + + getMostRecentlyAccessedRepository(): { id: string; path: string } | null { + const repo = this.repositoryRepo.findMostRecentlyAccessed(); + if (!repo) return null; + return { id: repo.id, path: repo.path }; + } + + async clearAllData(): Promise<void> { + const workspaces = this.workspaceRepo.findAll(); + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); + + for (const workspace of workspaces) { + if (workspace.mode === "worktree" && workspace.repositoryId) { + const repo = this.repositoryRepo.findById(workspace.repositoryId); + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + if (repo && worktree) { + try { + const manager = new WorktreeManager({ + mainRepoPath: repo.path, + worktreeBasePath, + }); + await manager.deleteWorktree(worktree.path); + } catch (error) { + this.log.error( + `Failed to delete worktree ${worktree.path}:`, + error, + ); + } + } + } + } + + this.worktreeRepo.deleteAll(); + this.workspaceRepo.deleteAll(); + this.repositoryRepo.deleteAll(); + + 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/schemas.ts b/packages/workspace-server/src/services/local-logs/schemas.ts new file mode 100644 index 0000000000..024350ec48 --- /dev/null +++ b/packages/workspace-server/src/services/local-logs/schemas.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +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/packages/workspace-server/src/services/local-logs/service.test.ts b/packages/workspace-server/src/services/local-logs/service.test.ts new file mode 100644 index 0000000000..c6d93d8493 --- /dev/null +++ b/packages/workspace-server/src/services/local-logs/service.test.ts @@ -0,0 +1,282 @@ +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockMkdir, mockWriteFile, mockReadFile, mockRm } = vi.hoisted(() => ({ + mockMkdir: vi.fn(), + mockWriteFile: vi.fn(), + mockReadFile: vi.fn(), + mockRm: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + default: { + promises: { + mkdir: mockMkdir, + writeFile: mockWriteFile, + readFile: mockReadFile, + rm: mockRm, + }, + }, +})); + +import { LocalLogsService } from "./service"; + +const RUN_ID = "run-abc"; +const expectedPath = path.join( + os.homedir(), + ".posthog-code", + "sessions", + RUN_ID, + "logs.ndjson", +); + +function deferred<T = void>(): { + promise: Promise<T>; + resolve: (value: T) => void; + reject: (err: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (err: unknown) => void; + const promise = new Promise<T>((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks(): Promise<void> { + for (let i = 0; i < 5; i++) await Promise.resolve(); +} + +describe("LocalLogsService", () => { + beforeEach(() => { + mockMkdir.mockReset().mockResolvedValue(undefined); + mockWriteFile.mockReset().mockResolvedValue(undefined); + mockReadFile.mockReset(); + mockRm.mockReset().mockResolvedValue(undefined); + }); + + describe("readLocalLogs", () => { + it("returns file contents", async () => { + mockReadFile.mockResolvedValue("hello"); + const service = new LocalLogsService(); + await expect(service.readLocalLogs(RUN_ID)).resolves.toBe("hello"); + expect(mockReadFile).toHaveBeenCalledWith(expectedPath, "utf-8"); + }); + + it.each([ + ["file is missing", Object.assign(new Error("nope"), { code: "ENOENT" })], + ["other read errors", new Error("boom")], + ])("returns null when %s", async (_label, err) => { + mockReadFile.mockRejectedValue(err); + const service = new LocalLogsService(); + await expect(service.readLocalLogs(RUN_ID)).resolves.toBeNull(); + }); + }); + + describe("writeLocalLogs", () => { + it("writes content to the run's NDJSON path", async () => { + const service = new LocalLogsService(); + await service.writeLocalLogs(RUN_ID, "line1\n"); + expect(mockMkdir).toHaveBeenCalledWith(path.dirname(expectedPath), { + recursive: true, + }); + expect(mockWriteFile).toHaveBeenCalledWith( + expectedPath, + "line1\n", + "utf-8", + ); + }); + + it("collapses many concurrent writes to one in-flight + one queued", async () => { + const firstWrite = deferred(); + mockWriteFile.mockImplementationOnce(() => firstWrite.promise); + + const service = new LocalLogsService(); + + const a = service.writeLocalLogs(RUN_ID, "A"); + const b = service.writeLocalLogs(RUN_ID, "B"); + const c = service.writeLocalLogs(RUN_ID, "C"); + const d = service.writeLocalLogs(RUN_ID, "D"); + + await flushMicrotasks(); + expect(mockWriteFile).toHaveBeenCalledTimes(1); + expect(mockWriteFile).toHaveBeenCalledWith(expectedPath, "A", "utf-8"); + + firstWrite.resolve(); + await Promise.all([a, b, c, d]); + + expect(mockWriteFile).toHaveBeenCalledTimes(2); + expect(mockWriteFile).toHaveBeenNthCalledWith( + 2, + expectedPath, + "D", + "utf-8", + ); + }); + + it("all coalesced callers see resolution when drain completes", async () => { + const firstWrite = deferred(); + mockWriteFile.mockImplementationOnce(() => firstWrite.promise); + + const service = new LocalLogsService(); + const a = service.writeLocalLogs(RUN_ID, "A"); + const b = service.writeLocalLogs(RUN_ID, "B"); + + let aResolved = false; + let bResolved = false; + void a.then(() => { + aResolved = true; + }); + void b.then(() => { + bResolved = true; + }); + + await Promise.resolve(); + expect(aResolved).toBe(false); + expect(bResolved).toBe(false); + + firstWrite.resolve(); + await Promise.all([a, b]); + expect(aResolved).toBe(true); + expect(bResolved).toBe(true); + }); + + it("keeps writes for different taskRunIds independent", async () => { + const writeA = deferred(); + const writeB = deferred(); + mockWriteFile + .mockImplementationOnce(() => writeA.promise) + .mockImplementationOnce(() => writeB.promise); + + const service = new LocalLogsService(); + const a = service.writeLocalLogs("run-a", "AAA"); + const b = service.writeLocalLogs("run-b", "BBB"); + + await flushMicrotasks(); + expect(mockWriteFile).toHaveBeenCalledTimes(2); + writeA.resolve(); + writeB.resolve(); + await Promise.all([a, b]); + }); + + it("starts fresh after the queue drains", async () => { + const service = new LocalLogsService(); + await service.writeLocalLogs(RUN_ID, "first"); + await service.writeLocalLogs(RUN_ID, "second"); + expect(mockWriteFile).toHaveBeenCalledTimes(2); + expect(mockWriteFile).toHaveBeenNthCalledWith( + 2, + expectedPath, + "second", + "utf-8", + ); + }); + + it("continues draining queued content even if a write rejects", async () => { + const firstWrite = deferred(); + mockWriteFile.mockImplementationOnce(() => firstWrite.promise); + + const service = new LocalLogsService(); + const a = service.writeLocalLogs(RUN_ID, "A"); + const b = service.writeLocalLogs(RUN_ID, "B"); + + firstWrite.reject(new Error("disk full")); + await Promise.all([a, b]); + + expect(mockWriteFile).toHaveBeenCalledTimes(2); + expect(mockWriteFile).toHaveBeenNthCalledWith( + 2, + expectedPath, + "B", + "utf-8", + ); + }); + + it("skips writeFile when coalesced content matches the last write", async () => { + const firstWrite = deferred(); + mockWriteFile.mockImplementationOnce(() => firstWrite.promise); + + const service = new LocalLogsService(); + const a = service.writeLocalLogs(RUN_ID, "SAME"); + const b = service.writeLocalLogs(RUN_ID, "SAME"); + + firstWrite.resolve(); + await Promise.all([a, b]); + + expect(mockWriteFile).toHaveBeenCalledTimes(1); + }); + + it("only mkdirs once per drain", async () => { + const firstWrite = deferred(); + mockWriteFile.mockImplementationOnce(() => firstWrite.promise); + + const service = new LocalLogsService(); + const a = service.writeLocalLogs(RUN_ID, "A"); + const b = service.writeLocalLogs(RUN_ID, "B"); + + firstWrite.resolve(); + await Promise.all([a, b]); + + expect(mockWriteFile).toHaveBeenCalledTimes(2); + 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/packages/workspace-server/src/services/local-logs/service.ts b/packages/workspace-server/src/services/local-logs/service.ts new file mode 100644 index 0000000000..6e01ec1a6b --- /dev/null +++ b/packages/workspace-server/src/services/local-logs/service.ts @@ -0,0 +1,131 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { injectable } from "inversify"; + +const DATA_DIR = ".posthog-code"; + +interface WriteState { + pending: string | undefined; + lastWritten: string | undefined; + dirReady: boolean; +} + +/** + * Single-flight per `taskRunId` with latest-wins coalescing. Prevents the + * gap-reconcile loop from spawning parallel writeFile of the same NDJSON. + */ +@injectable() +export class LocalLogsService { + private writes = new Map< + string, + { state: WriteState; inFlight: Promise<void> } + >(); + + async readLocalLogs(taskRunId: string): Promise<string | null> { + const logPath = this.getLocalLogPath(taskRunId); + try { + return await fs.promises.readFile(logPath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + return null; + } + } + + writeLocalLogs(taskRunId: string, content: string): Promise<void> { + const existing = this.writes.get(taskRunId); + if (existing) { + existing.state.pending = content; + return existing.inFlight; + } + + const state: WriteState = { + pending: undefined, + lastWritten: undefined, + dirReady: false, + }; + const inFlight = this.drain(taskRunId, content, state); + this.writes.set(taskRunId, { state, inFlight }); + 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, + state: WriteState, + ): Promise<void> { + try { + let next: string | undefined = initialContent; + while (next !== undefined) { + const current = next; + next = undefined; + if (current !== state.lastWritten) { + await this.doWrite(taskRunId, current, state); + state.lastWritten = current; + } + if (state.pending !== undefined) { + next = state.pending; + state.pending = undefined; + } + } + } finally { + this.writes.delete(taskRunId); + } + } + + private async doWrite( + taskRunId: string, + content: string, + state: WriteState, + ): Promise<void> { + const logPath = this.getLocalLogPath(taskRunId); + try { + if (!state.dirReady) { + await fs.promises.mkdir(path.dirname(logPath), { recursive: true }); + state.dirReady = true; + } + await fs.promises.writeFile(logPath, content, "utf-8"); + } catch {} + } + + private getLocalLogPath(taskRunId: string): string { + return path.join( + os.homedir(), + DATA_DIR, + "sessions", + taskRunId, + "logs.ndjson", + ); + } +} 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..8d331548a4 --- /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 { McpCallbackServer } from "./mcp-callback-server"; +import { McpCallbackService } from "./mcp-callback"; + +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..fbf4900ba6 --- /dev/null +++ b/packages/workspace-server/src/services/mcp-callback/mcp-callback.ts @@ -0,0 +1,212 @@ +import { APP_META_SERVICE, type IAppMeta } from "@posthog/platform/app-meta"; +import { + DEEP_LINK_SERVICE, + type IDeepLinkRegistry, +} from "@posthog/platform/deep-link"; +import { + URL_LAUNCHER_SERVICE, + type IUrlLauncher, +} from "@posthog/platform/url-launcher"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +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/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.test.ts b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.test.ts new file mode 100644 index 0000000000..0b273b7ffb --- /dev/null +++ b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.test.ts @@ -0,0 +1,273 @@ +import type { WorkbenchLogger } from "@posthog/di/logger"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { McpProxyService } from "./mcp-proxy"; +import type { McpProxyAuth } from "./ports"; + +type AuthServiceMock = { + authenticatedFetch: ReturnType; + refreshAccessToken: ReturnType; + getValidAccessToken: ReturnType; +}; + +function createAuthServiceMock(): AuthServiceMock { + return { + authenticatedFetch: vi.fn(), + refreshAccessToken: vi.fn().mockResolvedValue({ + accessToken: "refreshed-token", + apiHost: "https://app.posthog.com", + }), + getValidAccessToken: vi.fn().mockResolvedValue({ + accessToken: "access-token", + apiHost: "https://app.posthog.com", + }), + }; +} + +describe("McpProxyService", () => { + let authServiceMock: AuthServiceMock; + let service: McpProxyService; + + beforeEach(() => { + authServiceMock = createAuthServiceMock(); + 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 () => { + await service.stop(); + vi.restoreAllMocks(); + }); + + describe("lifecycle", () => { + it("starts on a loopback port and returns a URL for register()", async () => { + await service.start(); + const url = service.register("alpha", "https://upstream.example/path"); + expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/alpha$/); + }); + + it("throws from register() before start()", () => { + expect(() => + service.register("alpha", "https://upstream.example"), + ).toThrowError(/not started/); + }); + + it("handles concurrent start() calls without races", async () => { + await Promise.all([service.start(), service.start(), service.start()]); + const url = service.register("alpha", "https://upstream.example"); + expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/alpha$/); + }); + + it("stop() closes the server and clears registered targets", async () => { + await service.start(); + service.register("alpha", "https://upstream.example"); + await service.stop(); + expect(() => + service.register("alpha", "https://upstream.example"), + ).toThrowError(/not started/); + }); + }); + + describe("request forwarding", () => { + it("returns 404 for unknown targets", async () => { + await service.start(); + const proxyUrl = service.register("alpha", "https://upstream.example"); + const unknownUrl = proxyUrl.replace("/alpha", "/bravo"); + + const res = await fetch(unknownUrl); + + expect(res.status).toBe(404); + expect(await res.text()).toBe("Unknown target"); + expect(authServiceMock.authenticatedFetch).not.toHaveBeenCalled(); + }); + + it("forwards GET requests and returns the upstream body and status", async () => { + authServiceMock.authenticatedFetch.mockResolvedValue( + new Response('{"ok":true}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await service.start(); + const proxyUrl = service.register("alpha", "https://upstream.example"); + + const res = await fetch(proxyUrl); + + expect(res.status).toBe(200); + expect(await res.text()).toBe('{"ok":true}'); + expect(authServiceMock.authenticatedFetch).toHaveBeenCalledTimes(1); + const [url] = authServiceMock.authenticatedFetch.mock.calls[0]; + expect(url).toBe("https://upstream.example"); + }); + + it("forwards POST body bytes to the upstream URL", async () => { + authServiceMock.authenticatedFetch.mockResolvedValue( + new Response('{"ok":true}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await service.start(); + const proxyUrl = service.register("alpha", "https://upstream.example"); + + await fetch(proxyUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"hello":"world"}', + }); + + expect(authServiceMock.authenticatedFetch).toHaveBeenCalledTimes(1); + const [, options] = authServiceMock.authenticatedFetch.mock.calls[0]; + expect(options.method).toBe("POST"); + expect(Buffer.from(options.body).toString("utf8")).toBe( + '{"hello":"world"}', + ); + }); + + it("strips Authorization and Host headers before forwarding", async () => { + authServiceMock.authenticatedFetch.mockResolvedValue( + new Response('{"ok":true}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await service.start(); + const proxyUrl = service.register("alpha", "https://upstream.example"); + + await fetch(proxyUrl, { + headers: { + Authorization: "Bearer leaked", + "X-Custom": "keep-me", + }, + }); + + const [, options] = authServiceMock.authenticatedFetch.mock.calls[0]; + const forwardedHeaderKeys = Object.keys(options.headers).map((k) => + k.toLowerCase(), + ); + expect(forwardedHeaderKeys).not.toContain("authorization"); + expect(forwardedHeaderKeys).not.toContain("host"); + expect(forwardedHeaderKeys).not.toContain("connection"); + expect(options.headers["x-custom"]).toBe("keep-me"); + }); + + it("joins path suffix without producing a double slash for trailing-slash targets", async () => { + authServiceMock.authenticatedFetch.mockResolvedValue( + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await service.start(); + service.register("alpha", "https://upstream.example/inst-2/"); + const port = new URL( + service.register("alpha", "https://upstream.example/inst-2/"), + ).port; + + await fetch(`http://127.0.0.1:${port}/alpha/tools/list`); + + const [url] = authServiceMock.authenticatedFetch.mock.calls.at(-1) ?? []; + expect(url).toBe("https://upstream.example/inst-2/tools/list"); + }); + + it("preserves the incoming query string on the upstream URL", async () => { + authServiceMock.authenticatedFetch.mockResolvedValue( + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await service.start(); + const proxyUrl = service.register("alpha", "https://upstream.example"); + + await fetch(`${proxyUrl}?token=abc&foo=bar`); + + const [url] = authServiceMock.authenticatedFetch.mock.calls[0]; + expect(url).toBe("https://upstream.example?token=abc&foo=bar"); + }); + }); + + describe("auth error retry", () => { + it("refreshes the token and retries once when the body contains authentication_failed", async () => { + authServiceMock.authenticatedFetch + .mockResolvedValueOnce( + new Response( + JSON.stringify({ error: { code: "authentication_failed" } }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + .mockResolvedValueOnce( + new Response('{"ok":true}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await service.start(); + const proxyUrl = service.register("alpha", "https://upstream.example"); + + const res = await fetch(proxyUrl, { method: "POST", body: "payload" }); + + expect(res.status).toBe(200); + expect(await res.text()).toBe('{"ok":true}'); + expect(authServiceMock.refreshAccessToken).toHaveBeenCalledTimes(1); + expect(authServiceMock.authenticatedFetch).toHaveBeenCalledTimes(2); + }); + + it("does not retry when the body looks healthy", async () => { + authServiceMock.authenticatedFetch.mockResolvedValue( + new Response('{"ok":true}', { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await service.start(); + const proxyUrl = service.register("alpha", "https://upstream.example"); + + await fetch(proxyUrl); + + expect(authServiceMock.refreshAccessToken).not.toHaveBeenCalled(); + expect(authServiceMock.authenticatedFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe("SSE streaming", () => { + it("streams event-stream responses through to the client", async () => { + const sseBody = "data: one\n\ndata: two\n\n"; + authServiceMock.authenticatedFetch.mockResolvedValue( + new Response(sseBody, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + + await service.start(); + const proxyUrl = service.register("alpha", "https://upstream.example"); + + const res = await fetch(proxyUrl); + + expect(res.headers.get("content-type")).toContain("text/event-stream"); + expect(await res.text()).toBe(sseBody); + expect(authServiceMock.refreshAccessToken).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.ts b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.ts new file mode 100644 index 0000000000..426ca9e8ae --- /dev/null +++ b/packages/workspace-server/src/services/mcp-proxy/mcp-proxy.ts @@ -0,0 +1,303 @@ +import http from "node:http"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { inject, injectable, preDestroy } from "inversify"; +import { MCP_PROXY_AUTH } from "./identifiers"; +import type { McpProxyAuth } from "./ports"; + +function truncateRequestBody(body: RequestInit["body"]): string | undefined { + if (body == null) return undefined; + if (typeof body === "string") return body.slice(0, 2000); + if (body instanceof Buffer) return body.toString("utf8").slice(0, 2000); + if (body instanceof Uint8Array) { + return Buffer.from(body).toString("utf8").slice(0, 2000); + } + return `[${body.constructor.name}]`; +} + +/** + * Local HTTP proxy for MCP servers. Allows routing MCP requests through a + * stable loopback URL while injecting a fresh access token on every forwarded + * request. MCP transports bake their headers at construction time, so without + * this proxy we would either need to tear the transport down on every token + * rotation (expensive, racy) or leave it serving stale tokens. + * + * The proxy only listens on 127.0.0.1 and strips inbound Authorization headers + * before forwarding, but any local process can still use it to issue requests + * on the user's behalf — acceptable for a single-user desktop app. + */ +@injectable() +export class McpProxyService { + private server: http.Server | null = null; + private port: number | null = null; + private startPromise: Promise | null = null; + private targets = new Map(); + + private readonly log: ScopedLogger; + + constructor( + @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; + if (this.startPromise) return this.startPromise; + this.startPromise = this.doStart().catch((err) => { + this.startPromise = null; + throw err; + }); + return this.startPromise; + } + + private async doStart(): Promise { + const server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + this.server = server; + + await new Promise((resolve, reject) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (typeof addr === "object" && addr) { + this.port = addr.port; + this.log.info("MCP proxy started", { port: this.port }); + resolve(); + } else { + reject(new Error("Failed to get proxy address")); + } + }); + + server.on("error", (err) => { + this.log.error("MCP proxy server error", err); + reject(err); + }); + }); + } + + /** + * Register a target URL under a stable ID. Returns the loopback URL that + * should be passed to the MCP transport. Subsequent registrations with the + * same ID overwrite the target. + */ + register(id: string, targetUrl: string): string { + if (!this.port) { + throw new Error("MCP proxy not started"); + } + this.targets.set(id, targetUrl); + return `http://127.0.0.1:${this.port}/${encodeURIComponent(id)}`; + } + + @preDestroy() + async stop(): Promise { + if (!this.server) return; + const server = this.server; + await new Promise((resolve) => { + server.close(() => { + this.log.info("MCP proxy stopped"); + resolve(); + }); + }); + this.server = null; + this.port = null; + this.startPromise = null; + this.targets.clear(); + } + + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + ): void { + const incoming = new URL(req.url ?? "/", "http://placeholder"); + const segments = incoming.pathname.split("/").filter(Boolean); + const [rawId, ...rest] = segments; + const id = rawId ? decodeURIComponent(rawId) : ""; + const target = this.targets.get(id); + + if (!target) { + this.log.warn("Unknown MCP proxy target", { id, url: req.url }); + res.writeHead(404); + res.end("Unknown target"); + return; + } + + const suffix = rest.join("/"); + const targetBase = target.replace(/\/+$/, ""); + const targetUrl = + (suffix ? `${targetBase}/${suffix}` : targetBase) + incoming.search; + + const strippedAuthHeaders = new Set([ + "authorization", + "proxy-authorization", + ]); + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if ( + key === "host" || + key === "connection" || + strippedAuthHeaders.has(key) + ) { + continue; + } + if (typeof value === "string") { + headers[key] = value; + } + } + + const fetchOptions: RequestInit = { + method: req.method ?? "GET", + headers, + }; + + if (req.method !== "GET" && req.method !== "HEAD") { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + fetchOptions.body = Buffer.concat(chunks); + this.forwardRequest(id, targetUrl, fetchOptions, res); + }); + } else { + this.forwardRequest(id, targetUrl, fetchOptions, res); + } + } + + private async forwardRequest( + id: string, + url: string, + options: RequestInit, + res: http.ServerResponse, + ): Promise { + try { + 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 + // known error shape and retry once with a force-refreshed token. + const contentType = response.headers.get("content-type") ?? ""; + const isSse = contentType.includes("text/event-stream"); + + if (!isSse) { + const buf = Buffer.from(await response.arrayBuffer()); + const bodyText = buf.toString("utf8"); + + if (this.isAuthErrorBody(bodyText, response.status)) { + this.log.warn("MCP auth failure — refreshing token and retrying", { + id, + url, + status: response.status, + }); + 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()); + this.writeBufferedResponse(response, retryBuf, res); + return; + } + this.writeStreamingResponse(response, res); + return; + } + + if (/"isError"\s*:\s*true/.test(bodyText) || response.status >= 400) { + const details = { + id, + url, + method: options.method, + status: response.status, + requestBody: truncateRequestBody(options.body), + responseHeaders: Object.fromEntries(response.headers.entries()), + body: bodyText.slice(0, 2000), + }; + if (response.status >= 500) { + this.log.error("MCP proxy server error", details); + } else { + this.log.warn("MCP proxy non-OK body", details); + } + } + + this.writeBufferedResponse(response, buf, res); + return; + } + + this.writeStreamingResponse(response, res); + } catch (err) { + this.log.error("MCP proxy forward error", { id, url, err }); + if (!res.headersSent) { + res.writeHead(502); + } + res.end("Proxy error"); + } + } + + private isAuthErrorBody(bodyText: string, status: number): boolean { + if ( + bodyText.includes('"authentication_failed"') || + bodyText.includes('"authentication_error"') + ) { + return true; + } + if (status < 400) return false; + return ( + bodyText.includes("Invalid API key") || + bodyText.includes("Authentication failed") + ); + } + + private buildResponseHeaders(response: Response): Record { + const stripHeaders = new Set([ + "transfer-encoding", + "content-encoding", + "content-length", + ]); + const headers: Record = {}; + response.headers.forEach((value: string, key: string) => { + if (stripHeaders.has(key)) return; + headers[key] = value; + }); + return headers; + } + + private writeBufferedResponse( + response: Response, + buf: Buffer, + res: http.ServerResponse, + ): void { + res.writeHead(response.status, this.buildResponseHeaders(response)); + res.end(buf); + } + + private async writeStreamingResponse( + response: Response, + res: http.ServerResponse, + ): Promise { + res.writeHead(response.status, this.buildResponseHeaders(response)); + if (!response.body) { + res.end(); + return; + } + const reader = response.body.getReader(); + res.on("close", () => { + void reader.cancel().catch(() => {}); + }); + const pump = async (): Promise => { + const { done, value } = await reader.read(); + if (done) { + res.end(); + return; + } + const canContinue = res.write(value); + if (canContinue) { + return pump(); + } + res.once("drain", () => pump()); + }; + await pump(); + } +} 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..305756be5a --- /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 { + IMAGE_PROCESSOR_SERVICE, + type IImageProcessor, +} 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/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.test.ts b/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.test.ts new file mode 100644 index 0000000000..53c3324fc3 --- /dev/null +++ b/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.test.ts @@ -0,0 +1,536 @@ +import { vol } from "memfs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Set env before module loads (SKILLS_ZIP_URL / CONTEXT_MILL_ZIP_URL are captured at module level) +vi.hoisted(() => { + process.env.SKILLS_ZIP_URL = "https://example.com/skills.zip"; + process.env.CONTEXT_MILL_ZIP_URL = "https://example.com/context-mill.zip"; +}); + +const mockStoragePaths = vi.hoisted(() => ({ + appDataPath: "/mock/userData", + logsPath: "/mock/logs", +})); + +const mockBundledResources = vi.hoisted(() => ({ + resolve: vi.fn((rel: string) => `/mock/appPath/${rel}`), + _setPackaged: (packaged: boolean) => { + mockBundledResources.resolve.mockImplementation((rel: string) => + packaged ? `/mock/appPath.unpacked/${rel}` : `/mock/appPath/${rel}`, + ); + }, +})); + +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(() => + vi.fn<(zipPath: string, extractDir: string) => Promise>(async () => {}), +); + +vi.mock("node:fs", async () => { + const { fs } = await import("memfs"); + return { ...fs, default: fs }; +}); + +vi.mock("node:fs/promises", async () => { + const { fs } = await import("memfs"); + return { ...fs.promises, default: fs.promises }; +}); + +const mockFflateUnzip = vi.hoisted(() => vi.fn()); +vi.mock("fflate", () => ({ + unzip: mockFflateUnzip, +})); + +vi.mock("./extract-zip", async () => { + const actual = + await vi.importActual("./extract-zip"); + return { + ...actual, + extractZip: mockExtractZip, + }; +}); + +vi.mock("node:os", () => ({ + homedir: () => "/mock/home", + tmpdir: () => "/mock/tmp", + default: { homedir: () => "/mock/home", tmpdir: () => "/mock/tmp" }, +})); + +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 type { WorkbenchLogger } from "@posthog/di/logger"; +import { PosthogPluginService } from "./posthog-plugin"; +import { syncCodexSkills } from "./update-skills-saga"; + +/** Expose private members for testing without `as any`. */ +interface TestablePluginService { + initialize(): Promise; + copyBundledPlugin(): Promise; + intervalId: ReturnType | null; +} + +// Paths based on mock values +const RUNTIME_PLUGIN_DIR = "/mock/userData/plugins/posthog"; +const RUNTIME_SKILLS_DIR = "/mock/userData/skills"; +const BUNDLED_PLUGIN_DIR = "/mock/appPath/.vite/build/plugins/posthog"; +const BUNDLED_PLUGIN_DIR_PACKAGED = + "/mock/appPath.unpacked/.vite/build/plugins/posthog"; +const CODEX_SKILLS_DIR = "/mock/home/.agents/skills"; + +function mockFetchResponse(ok: boolean, status = 200) { + return { + ok, + status, + statusText: ok ? "OK" : "Not Found", + arrayBuffer: vi.fn(async () => new ArrayBuffer(8)), + }; +} + +/** Simulate zip extraction by creating skill files in the extracted dir */ +function simulateExtractZip() { + mockExtractZip.mockImplementation( + async (zipPath: string, extractDir: string) => { + if (zipPath.includes("context-mill")) { + // Inner zip bytes are dummy — fflate.unzip is mocked below. + vol.mkdirSync(extractDir, { recursive: true }); + vol.writeFileSync(`${extractDir}/omnibus-test-skill.zip`, "dummy"); + vol.writeFileSync(`${extractDir}/manifest.json`, "{}"); + // Non-omnibus zip should be ignored + vol.writeFileSync(`${extractDir}/other-skill.zip`, "dummy"); + } else { + // Primary skills zip + vol.mkdirSync(`${extractDir}/skills/remote-skill`, { + recursive: true, + }); + vol.writeFileSync( + `${extractDir}/skills/remote-skill/SKILL.md`, + "# Remote", + ); + } + }, + ); + + mockFflateUnzip.mockImplementation( + ( + _data: Uint8Array, + cb: (err: Error | null, data: Record) => void, + ) => { + cb(null, { + "SKILL.md": new TextEncoder().encode( + "---\nname: omnibus-test-skill\n---\n# Test Skill", + ), + }); + }, + ); +} + +/** Create the bundled plugin directory in memfs */ +function setupBundledPlugin(dir = BUNDLED_PLUGIN_DIR) { + vol.mkdirSync(`${dir}/skills/shipped-skill`, { recursive: true }); + vol.writeFileSync(`${dir}/plugin.json`, '{"name":"posthog"}'); + vol.writeFileSync(`${dir}/skills/shipped-skill/SKILL.md`, "# Shipped"); +} + +describe("PosthogPluginService", () => { + let service: PosthogPluginService; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vol.reset(); + + mockBundledResources._setPackaged(false); + mockAppMeta.isProduction = false; + mockFetch.mockResolvedValue(mockFetchResponse(true)); + vi.stubGlobal("fetch", mockFetch); + mockExtractZip.mockResolvedValue(undefined); + + 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, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + afterEach(() => { + service.cleanup(); + vi.useRealTimers(); + }); + + describe("getPluginPath", () => { + it("returns bundled path in dev mode", () => { + mockAppMeta.isProduction = false; + mockBundledResources._setPackaged(false); + expect(service.getPluginPath()).toBe(BUNDLED_PLUGIN_DIR); + }); + + it("returns runtime path in prod when plugin.json exists", () => { + mockAppMeta.isProduction = true; + mockBundledResources._setPackaged(true); + vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); + vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); + + expect(service.getPluginPath()).toBe(RUNTIME_PLUGIN_DIR); + }); + + it("returns bundled path as fallback in prod", () => { + mockAppMeta.isProduction = true; + mockBundledResources._setPackaged(true); + expect(service.getPluginPath()).toBe(BUNDLED_PLUGIN_DIR_PACKAGED); + }); + }); + + describe("initialize", () => { + it("copies bundled plugin on first run when plugin.json is missing", async () => { + setupBundledPlugin(); + + await (service as unknown as TestablePluginService).initialize(); + + // Entire bundled dir should be copied to runtime + expect(vol.existsSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`)).toBe(true); + expect( + vol.existsSync(`${RUNTIME_PLUGIN_DIR}/skills/shipped-skill/SKILL.md`), + ).toBe(true); + }); + + it("skips bundled copy when plugin.json already exists in runtime", async () => { + setupBundledPlugin(); + // Pre-populate runtime dir (simulating previous run) + vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); + vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, '{"old":true}'); + + await (service as unknown as TestablePluginService).initialize(); + + // Should keep the existing runtime plugin.json, not overwrite + expect( + vol.readFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "utf-8"), + ).toBe('{"old":true}'); + }); + + it("overlays downloaded skills from cache on top of runtime dir", async () => { + setupBundledPlugin(); + // Pre-populate runtime dir + vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); + vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); + // Pre-populate skills cache (as if downloaded previously) + vol.mkdirSync(`${RUNTIME_SKILLS_DIR}/cached-skill`, { recursive: true }); + vol.writeFileSync( + `${RUNTIME_SKILLS_DIR}/cached-skill/SKILL.md`, + "# Cached", + ); + + await (service as unknown as TestablePluginService).initialize(); + + expect( + vol.readFileSync( + `${RUNTIME_PLUGIN_DIR}/skills/cached-skill/SKILL.md`, + "utf-8", + ), + ).toBe("# Cached"); + }); + + it("starts periodic update interval", async () => { + await (service as unknown as TestablePluginService).initialize(); + expect( + (service as unknown as TestablePluginService).intervalId, + ).not.toBeNull(); + }); + }); + + describe("updateSkills", () => { + it("downloads, extracts, and installs skills", async () => { + setupBundledPlugin(); + simulateExtractZip(); + + await service.updateSkills(); + + // Skills should be in the runtime cache + expect( + vol.existsSync(`${RUNTIME_SKILLS_DIR}/remote-skill/SKILL.md`), + ).toBe(true); + expect(mockFetch).toHaveBeenCalledWith("https://example.com/skills.zip"); + expect(mockExtractZip).toHaveBeenCalled(); + }); + + it("performs atomic swap of skills directory", async () => { + setupBundledPlugin(); + // Pre-populate existing cache with old skill + vol.mkdirSync(`${RUNTIME_SKILLS_DIR}/old-skill`, { recursive: true }); + vol.writeFileSync(`${RUNTIME_SKILLS_DIR}/old-skill/SKILL.md`, "# Old"); + + simulateExtractZip(); + await service.updateSkills(); + + // New skill should be present, old skill should be gone + expect( + vol.existsSync(`${RUNTIME_SKILLS_DIR}/remote-skill/SKILL.md`), + ).toBe(true); + expect(vol.existsSync(`${RUNTIME_SKILLS_DIR}/old-skill`)).toBe(false); + // Temp dirs should be cleaned up + expect(vol.existsSync(`${RUNTIME_SKILLS_DIR}.new`)).toBe(false); + expect(vol.existsSync(`${RUNTIME_SKILLS_DIR}.old`)).toBe(false); + }); + + it("overlays new skills into runtime plugin dir", async () => { + setupBundledPlugin(); + vol.mkdirSync(RUNTIME_PLUGIN_DIR, { recursive: true }); + vol.writeFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "{}"); + + simulateExtractZip(); + await service.updateSkills(); + + expect( + vol.existsSync(`${RUNTIME_PLUGIN_DIR}/skills/remote-skill/SKILL.md`), + ).toBe(true); + }); + + it("emits 'updated' event on success", async () => { + simulateExtractZip(); + const handler = vi.fn(); + service.on("skillsUpdated", handler); + + await service.updateSkills(); + + expect(handler).toHaveBeenCalledWith(true); + }); + + it("throttles: skips if called within 30 minutes", async () => { + simulateExtractZip(); + await service.updateSkills(); + mockFetch.mockClear(); + + await service.updateSkills(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("allows update after throttle period expires", async () => { + simulateExtractZip(); + await service.updateSkills(); + mockFetch.mockClear(); + + vi.advanceTimersByTime(31 * 60 * 1000); + await service.updateSkills(); + + expect(mockFetch).toHaveBeenCalled(); + }); + + it("skips if already updating (reentrance guard)", async () => { + let resolveDownload!: (value: unknown) => void; + mockFetch.mockReturnValue( + new Promise((resolve) => { + resolveDownload = resolve; + }), + ); + + // Start first update (hangs on fetch) + const first = service.updateSkills(); + + // Advance past throttle so second call reaches the `updating` check + vi.advanceTimersByTime(31 * 60 * 1000); + mockFetch.mockClear(); + await service.updateSkills(); + + // Second call should not have triggered another fetch + expect(mockFetch).not.toHaveBeenCalled(); + + // Clean up hanging promise + resolveDownload(mockFetchResponse(true)); + await first.catch(() => {}); + }); + + it("downloads and merges context-mill omnibus skills with prefix stripped", async () => { + setupBundledPlugin(); + simulateExtractZip(); + + await service.updateSkills(); + + // Omnibus skill should exist with prefix stripped + expect(vol.existsSync(`${RUNTIME_SKILLS_DIR}/test-skill/SKILL.md`)).toBe( + true, + ); + + // SKILL.md should have "omnibus-" stripped from name field + const content = vol.readFileSync( + `${RUNTIME_SKILLS_DIR}/test-skill/SKILL.md`, + "utf-8", + ); + expect(content).toContain("name: test-skill"); + expect(content).not.toContain("omnibus-"); + }); + + it("context-mill failure is non-fatal", async () => { + setupBundledPlugin(); + // Primary skills succeed + mockExtractZip.mockImplementation( + async (zipPath: string, extractDir: string) => { + if (zipPath.includes("context-mill")) { + throw new Error("context-mill download failed"); + } + vol.mkdirSync(`${extractDir}/skills/remote-skill`, { + recursive: true, + }); + vol.writeFileSync( + `${extractDir}/skills/remote-skill/SKILL.md`, + "# Remote", + ); + }, + ); + + const handler = vi.fn(); + service.on("skillsUpdated", handler); + await service.updateSkills(); + + // Primary skills should still be installed + expect( + vol.existsSync(`${RUNTIME_SKILLS_DIR}/remote-skill/SKILL.md`), + ).toBe(true); + // Update should still succeed + expect(handler).toHaveBeenCalledWith(true); + }); + + it("handles download failure gracefully", async () => { + mockFetch.mockRejectedValue(new Error("Network error")); + await expect(service.updateSkills()).resolves.toBeUndefined(); + }); + + it("handles non-ok response gracefully", async () => { + mockFetch.mockResolvedValue(mockFetchResponse(false, 404)); + await expect(service.updateSkills()).resolves.toBeUndefined(); + }); + + it("handles missing skills dir in archive", async () => { + // Extraction creates no skills directory + mockExtractZip.mockImplementation( + async (_zipPath: string, extractDir: string) => { + vol.mkdirSync(`${extractDir}/random-dir`, { recursive: true }); + vol.writeFileSync(`${extractDir}/random-dir/README.md`, "nope"); + }, + ); + + const handler = vi.fn(); + service.on("skillsUpdated", handler); + await service.updateSkills(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("cleans up temp dir even on error", async () => { + mockExtractZip.mockRejectedValue(new Error("extraction failed")); + + await service.updateSkills(); + + // Temp dir under /mock/tmp should be cleaned up + const tmpEntries = vol.existsSync("/mock/tmp") + ? vol.readdirSync("/mock/tmp") + : []; + expect(tmpEntries).toHaveLength(0); + }); + }); + + describe("syncCodexSkills", () => { + it("copies skill directories to Codex dir", async () => { + setupBundledPlugin(); + + await syncCodexSkills(BUNDLED_PLUGIN_DIR, CODEX_SKILLS_DIR); + + expect( + vol.readFileSync(`${CODEX_SKILLS_DIR}/shipped-skill/SKILL.md`, "utf-8"), + ).toBe("# Shipped"); + }); + + it("skips if effective skills dir does not exist", async () => { + // No skills dir anywhere + await syncCodexSkills("/nonexistent", CODEX_SKILLS_DIR); + + expect(vol.existsSync(CODEX_SKILLS_DIR)).toBe(false); + }); + }); + + describe("copyBundledPlugin", () => { + it("copies entire bundled dir to runtime dir", async () => { + setupBundledPlugin(); + + await (service as unknown as TestablePluginService).copyBundledPlugin(); + + expect( + vol.readFileSync(`${RUNTIME_PLUGIN_DIR}/plugin.json`, "utf-8"), + ).toBe('{"name":"posthog"}'); + expect( + vol.readFileSync( + `${RUNTIME_PLUGIN_DIR}/skills/shipped-skill/SKILL.md`, + "utf-8", + ), + ).toBe("# Shipped"); + }); + + it("skips if bundled dir does not exist", async () => { + await (service as unknown as TestablePluginService).copyBundledPlugin(); + expect(vol.existsSync(RUNTIME_PLUGIN_DIR)).toBe(false); + }); + + it("handles copy failure gracefully", async () => { + // Bundled dir exists but is not a directory (will cause cp to fail or behave oddly) + // Just verify no exception propagates + setupBundledPlugin(); + await expect( + (service as unknown as TestablePluginService).copyBundledPlugin(), + ).resolves.toBeUndefined(); + }); + }); + + describe("cleanup", () => { + it("clears interval timer", async () => { + await (service as unknown as TestablePluginService).initialize(); + expect( + (service as unknown as TestablePluginService).intervalId, + ).not.toBeNull(); + + service.cleanup(); + expect( + (service as unknown as TestablePluginService).intervalId, + ).toBeNull(); + }); + + it("is safe to call multiple times", () => { + service.cleanup(); + service.cleanup(); + }); + }); +}); diff --git a/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.ts b/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.ts new file mode 100644 index 0000000000..f34b196d79 --- /dev/null +++ b/packages/workspace-server/src/services/posthog-plugin/posthog-plugin.ts @@ -0,0 +1,232 @@ +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 { + 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 { + STORAGE_PATHS_SERVICE, + type IStoragePaths, +} from "@posthog/platform/storage-paths"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { TypedEventEmitter } from "@posthog/shared"; +import { inject, injectable, postConstruct, preDestroy } from "inversify"; +import { + overlayDownloadedSkills, + syncCodexSkills, + UpdateSkillsSaga, +} from "./update-skills-saga"; + +const SKILLS_ZIP_URL = process.env.SKILLS_ZIP_URL ?? ""; +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"); + +interface PosthogPluginEvents { + skillsUpdated: true; +} + +@injectable() +export class PosthogPluginService extends TypedEventEmitter { + private intervalId: ReturnType | null = null; + private lastCheckAt = 0; + private updating = false; + private readonly log: ScopedLogger; + + constructor( + @inject(STORAGE_PATHS_SERVICE) + private readonly storagePaths: IStoragePaths, + @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 */ + private get runtimePluginDir(): string { + return join(this.storagePaths.appDataPath, "plugins", "posthog"); + } + + /** Runtime skills cache (downloaded zips extracted here) */ + private get runtimeSkillsDir(): string { + return join(this.storagePaths.appDataPath, "skills"); + } + + /** Bundled plugin path inside the .vite build output */ + private get bundledPluginDir(): string { + return this.bundledResources.resolve(".vite/build/plugins/posthog"); + } + + @postConstruct() + init(): void { + this.initialize().catch((err) => { + this.log.error("Skills initialization failed", { error: err }); + this.analytics.captureException(err, { + source: "posthog-plugin", + operation: "initialize", + }); + }); + } + + private async initialize(): Promise { + // On first run (or after app update), copy the entire bundled plugin to the runtime dir. + // On subsequent starts the runtime dir already exists — just overlay any cached downloaded skills. + if (!existsSync(join(this.runtimePluginDir, "plugin.json"))) { + await this.copyBundledPlugin(); + } + + // Overlay any previously-downloaded skills on top of the runtime plugin + await overlayDownloadedSkills(this.runtimeSkillsDir, this.runtimePluginDir); + + await syncCodexSkills(this.getPluginPath(), CODEX_SKILLS_DIR); + + // Start periodic updates + this.intervalId = setInterval(() => { + this.updateSkills().catch((err) => { + this.log.warn("Periodic skills update failed", { error: err }); + }); + }, UPDATE_INTERVAL_MS); + + // Kick off first download + await this.updateSkills(); + } + + /** + * Returns the path to the plugin directory that should be used for agent sessions. + * + * - In dev mode: Vite already merged shipped + remote + local-dev skills, so use bundled path. + * - In prod: use the runtime plugin dir (with downloaded updates). + * - Fallback: bundled plugin path. + */ + getPluginPath(): string { + if (!this.appMeta.isProduction) { + return this.bundledPluginDir; + } + + if (existsSync(join(this.runtimePluginDir, "plugin.json"))) { + return this.runtimePluginDir; + } + + return this.bundledPluginDir; + } + + async updateSkills(): Promise { + const now = Date.now(); + if (now - this.lastCheckAt < UPDATE_INTERVAL_MS) { + return; + } + + if (this.updating) { + return; + } + + this.updating = true; + this.lastCheckAt = now; + + const tempDir = join(tmpdir(), `posthog-code-skills-${Date.now()}`); + + try { + await mkdir(tempDir, { recursive: true }); + + const saga = new UpdateSkillsSaga(this.log); + const result = await saga.run({ + runtimeSkillsDir: this.runtimeSkillsDir, + runtimePluginDir: this.runtimePluginDir, + pluginPath: this.getPluginPath(), + codexSkillsDir: CODEX_SKILLS_DIR, + tempDir, + skillsZipUrl: SKILLS_ZIP_URL, + contextMillZipUrl: CONTEXT_MILL_ZIP_URL, + downloadFile: (url, destPath) => this.downloadFile(url, destPath), + }); + + if (result.success) { + this.emit("skillsUpdated", true); + } else { + this.log.warn("Skills update failed", { + error: result.error, + failedStep: result.failedStep, + }); + this.analytics.captureException(new Error(result.error), { + source: "posthog-plugin", + operation: "updateSkills", + failedStep: result.failedStep, + }); + } + } catch (err) { + this.log.warn("Failed to update skills, will retry next interval", { + error: err, + }); + this.analytics.captureException(err, { + source: "posthog-plugin", + operation: "updateSkills", + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + this.updating = false; + } + } + + /** + * Copies the entire bundled plugin directory to the runtime location. + * Called once on first run or after an app update. + */ + private async copyBundledPlugin(): Promise { + try { + if (!existsSync(this.bundledPluginDir)) { + this.log.warn("Bundled plugin dir not found", { + path: this.bundledPluginDir, + }); + return; + } + await rm(this.runtimePluginDir, { recursive: true, force: true }); + await cp(this.bundledPluginDir, this.runtimePluginDir, { + recursive: true, + }); + } catch (err) { + this.log.warn("Failed to copy bundled plugin", { error: err }); + this.analytics.captureException(err, { + source: "posthog-plugin", + operation: "copyBundledPlugin", + }); + } + } + + private async downloadFile(url: string, destPath: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Download failed: ${response.status} ${response.statusText}`, + ); + } + + const buffer = await response.arrayBuffer(); + await writeFile(destPath, Buffer.from(buffer)); + } + + @preDestroy() + cleanup(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } +} 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..847622ae99 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,7 +9,7 @@ import { writeFile, } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; -import { extractZip, unzipAsync } from "@main/utils/extract-zip"; +import { extractZip, unzipAsync } from "./extract-zip"; import { Saga } from "@posthog/shared"; /** 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/packages/workspace-server/src/services/process-tracking/process-tracking.test.ts b/packages/workspace-server/src/services/process-tracking/process-tracking.test.ts new file mode 100644 index 0000000000..c7b8eb8e6d --- /dev/null +++ b/packages/workspace-server/src/services/process-tracking/process-tracking.test.ts @@ -0,0 +1,441 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockPlatform = vi.hoisted(() => vi.fn(() => "darwin")); +const mockIsProcessAlive = vi.hoisted(() => vi.fn((_pid: number) => true)); +const mockKillProcessTree = vi.hoisted(() => vi.fn()); +const mockExecAsync = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + exec: vi.fn(), + default: { exec: vi.fn() }, +})); + +vi.mock("node:util", () => ({ + promisify: () => mockExecAsync, + default: { promisify: () => mockExecAsync }, +})); + +vi.mock("node:os", () => ({ + platform: mockPlatform, + default: { platform: mockPlatform }, +})); + +vi.mock("./process-utils", () => ({ + isProcessAlive: mockIsProcessAlive, + killProcessTree: mockKillProcessTree, +})); + +import { ProcessTrackingService } from "./process-tracking"; + +function mockExecResolves(stdout: string): void { + mockExecAsync.mockResolvedValueOnce({ stdout, stderr: "" }); +} + +function mockExecRejects(error: Error): void { + mockExecAsync.mockRejectedValueOnce(error); +} + +describe("ProcessTrackingService", () => { + let service: ProcessTrackingService; + + beforeEach(() => { + vi.clearAllMocks(); + mockPlatform.mockReturnValue("darwin"); + mockIsProcessAlive.mockReturnValue(true); + service = new ProcessTrackingService(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("register", () => { + it("tracks a process", () => { + service.register(1234, "shell", "shell:session-1"); + + const all = service.getAll(); + expect(all).toHaveLength(1); + expect(all[0]).toMatchObject({ + pid: 1234, + category: "shell", + label: "shell:session-1", + }); + }); + + it("stores metadata when provided", () => { + service.register(1234, "agent", "agent:run-1", { + taskId: "task-abc", + }); + + const all = service.getAll(); + expect(all[0].metadata).toEqual({ taskId: "task-abc" }); + }); + + it("sets registeredAt timestamp", () => { + const before = Date.now(); + service.register(1234, "shell", "test"); + const after = Date.now(); + + const proc = service.getAll()[0]; + expect(proc.registeredAt).toBeGreaterThanOrEqual(before); + expect(proc.registeredAt).toBeLessThanOrEqual(after); + }); + + it("overwrites an existing entry for the same PID", () => { + service.register(1234, "shell", "first"); + service.register(1234, "agent", "second"); + + const all = service.getAll(); + expect(all).toHaveLength(1); + expect(all[0].category).toBe("agent"); + expect(all[0].label).toBe("second"); + }); + }); + + describe("unregister", () => { + it("removes a tracked process", () => { + service.register(1234, "shell", "test"); + service.unregister(1234, "exited"); + + expect(service.getAll()).toHaveLength(0); + }); + + it("does nothing for an unknown PID", () => { + service.register(1234, "shell", "test"); + service.unregister(9999, "unknown"); + + expect(service.getAll()).toHaveLength(1); + }); + }); + + describe("getAll", () => { + it("returns empty array when nothing is tracked", () => { + expect(service.getAll()).toEqual([]); + }); + + it("returns all tracked processes", () => { + service.register(1, "shell", "s1"); + service.register(2, "agent", "a1"); + service.register(3, "child", "c1"); + + expect(service.getAll()).toHaveLength(3); + }); + }); + + describe("getByCategory", () => { + beforeEach(() => { + service.register(1, "shell", "s1"); + service.register(2, "shell", "s2"); + service.register(3, "agent", "a1"); + service.register(4, "child", "c1"); + }); + + it("filters by shell", () => { + const shells = service.getByCategory("shell"); + expect(shells).toHaveLength(2); + expect(shells.map((p) => p.pid)).toEqual([1, 2]); + }); + + it("filters by agent", () => { + const agents = service.getByCategory("agent"); + expect(agents).toHaveLength(1); + expect(agents[0].pid).toBe(3); + }); + + it("returns empty for category with no entries", () => { + service.unregister(4, "gone"); + expect(service.getByCategory("child")).toEqual([]); + }); + }); + + describe("getSnapshot", () => { + it("groups tracked processes by category", async () => { + service.register(1, "shell", "s1"); + service.register(2, "agent", "a1"); + service.register(3, "child", "c1"); + + const snapshot = await service.getSnapshot(); + + expect(snapshot.tracked.shell).toHaveLength(1); + expect(snapshot.tracked.agent).toHaveLength(1); + expect(snapshot.tracked.child).toHaveLength(1); + expect(snapshot.timestamp).toBeGreaterThan(0); + expect(snapshot.discovered).toBeUndefined(); + }); + + it("prunes dead PIDs before returning", async () => { + service.register(1, "shell", "alive"); + service.register(2, "shell", "dead"); + + mockIsProcessAlive.mockImplementation((pid: number) => pid === 1); + + const snapshot = await service.getSnapshot(); + + expect(snapshot.tracked.shell).toHaveLength(1); + expect(snapshot.tracked.shell[0].pid).toBe(1); + expect(service.getAll()).toHaveLength(1); + }); + + it("includes discovered processes when requested", async () => { + mockExecResolves( + ` 100 ${process.pid} /bin/bash\n 200 100 node server.js\n`, + ); + + service.register(100, "shell", "tracked-shell"); + + const snapshot = await service.getSnapshot(true); + + expect(snapshot.discovered).toBeDefined(); + expect(snapshot.discovered?.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe("discoverChildren", () => { + it("returns empty on Windows", async () => { + mockPlatform.mockReturnValue("win32"); + + const result = await service.discoverChildren(); + + expect(result).toEqual([]); + expect(mockExecAsync).not.toHaveBeenCalled(); + }); + + it("finds direct children of the app", async () => { + const appPid = process.pid; + mockExecResolves( + [ + ` ${appPid + 1} ${appPid} /bin/bash`, + ` ${appPid + 2} ${appPid} node agent.js`, + ` 9999 1 /sbin/launchd`, + ].join("\n"), + ); + + const result = await service.discoverChildren(); + + expect(result).toHaveLength(2); + expect(result.map((p) => p.pid)).toContain(appPid + 1); + expect(result.map((p) => p.pid)).toContain(appPid + 2); + }); + + it("finds nested descendants recursively", async () => { + const appPid = process.pid; + const child = appPid + 1; + const grandchild = appPid + 2; + + mockExecResolves( + [ + ` ${child} ${appPid} /bin/bash`, + ` ${grandchild} ${child} node server.js`, + ].join("\n"), + ); + + const result = await service.discoverChildren(); + + expect(result).toHaveLength(2); + expect(result.find((p) => p.pid === grandchild)).toBeDefined(); + }); + + it("marks tracked PIDs as tracked", async () => { + const appPid = process.pid; + const childPid = appPid + 1; + + mockExecResolves(` ${childPid} ${appPid} /bin/bash\n`); + + service.register(childPid, "shell", "known"); + + const result = await service.discoverChildren(); + + expect(result).toHaveLength(1); + expect(result[0].tracked).toBe(true); + }); + + it("marks untracked PIDs as not tracked", async () => { + const appPid = process.pid; + + mockExecResolves(` ${appPid + 1} ${appPid} mystery-process\n`); + + const result = await service.discoverChildren(); + + expect(result).toHaveLength(1); + expect(result[0].tracked).toBe(false); + }); + + it("returns empty when exec fails", async () => { + mockExecRejects(new Error("ps failed")); + + const result = await service.discoverChildren(); + + expect(result).toEqual([]); + }); + + it("does not include processes that are not descendants", async () => { + mockExecResolves(` 9999 1 /sbin/launchd\n 8888 9999 some-other\n`); + + const result = await service.discoverChildren(); + + expect(result).toEqual([]); + }); + }); + + describe("isAlive", () => { + it("delegates to isProcessAlive", () => { + mockIsProcessAlive.mockReturnValue(true); + expect(service.isAlive(1234)).toBe(true); + + mockIsProcessAlive.mockReturnValue(false); + expect(service.isAlive(1234)).toBe(false); + + expect(mockIsProcessAlive).toHaveBeenCalledWith(1234); + }); + }); + + describe("kill", () => { + it("kills the process tree and unregisters", () => { + service.register(1234, "shell", "test"); + + service.kill(1234); + + expect(mockKillProcessTree).toHaveBeenCalledWith(1234); + expect(service.getAll()).toHaveLength(0); + }); + + it("still calls killProcessTree for untracked PIDs", () => { + service.kill(9999); + + expect(mockKillProcessTree).toHaveBeenCalledWith(9999); + }); + }); + + describe("killByCategory", () => { + it("kills all processes in the given category", () => { + service.register(1, "shell", "s1"); + service.register(2, "shell", "s2"); + service.register(3, "agent", "a1"); + + service.killByCategory("shell"); + + expect(mockKillProcessTree).toHaveBeenCalledWith(1); + expect(mockKillProcessTree).toHaveBeenCalledWith(2); + expect(mockKillProcessTree).not.toHaveBeenCalledWith(3); + expect(service.getByCategory("shell")).toHaveLength(0); + expect(service.getByCategory("agent")).toHaveLength(1); + }); + + it("does nothing when no processes in category", () => { + service.register(1, "agent", "a1"); + + service.killByCategory("shell"); + + expect(mockKillProcessTree).not.toHaveBeenCalled(); + expect(service.getAll()).toHaveLength(1); + }); + }); + + describe("getByTaskId", () => { + it("returns processes for a given taskId", () => { + service.register(1, "agent", "a1", undefined, "task-1"); + service.register(2, "agent", "a2", undefined, "task-1"); + service.register(3, "agent", "a3", undefined, "task-2"); + + const result = service.getByTaskId("task-1"); + expect(result).toHaveLength(2); + expect(result.map((p) => p.pid)).toEqual([1, 2]); + }); + + it("returns empty for unknown taskId", () => { + service.register(1, "agent", "a1", undefined, "task-1"); + + expect(service.getByTaskId("task-999")).toEqual([]); + }); + + it("returns empty for processes without taskId", () => { + service.register(1, "shell", "s1"); + + expect(service.getByTaskId("task-1")).toEqual([]); + }); + }); + + describe("killByTaskId", () => { + it("kills all processes for a given taskId", () => { + service.register(1, "agent", "a1", undefined, "task-1"); + service.register(2, "agent", "a2", undefined, "task-1"); + service.register(3, "agent", "a3", undefined, "task-2"); + + service.killByTaskId("task-1"); + + expect(mockKillProcessTree).toHaveBeenCalledWith(1); + expect(mockKillProcessTree).toHaveBeenCalledWith(2); + expect(mockKillProcessTree).not.toHaveBeenCalledWith(3); + expect(service.getByTaskId("task-1")).toEqual([]); + expect(service.getByTaskId("task-2")).toHaveLength(1); + }); + + it("does nothing for unknown taskId", () => { + service.register(1, "agent", "a1", undefined, "task-1"); + + service.killByTaskId("task-999"); + + expect(mockKillProcessTree).not.toHaveBeenCalled(); + expect(service.getAll()).toHaveLength(1); + }); + }); + + describe("taskId index cleanup", () => { + it("cleans up task index on unregister", () => { + service.register(1, "agent", "a1", undefined, "task-1"); + service.register(2, "agent", "a2", undefined, "task-1"); + + service.unregister(1, "exited"); + + expect(service.getByTaskId("task-1")).toHaveLength(1); + expect(service.getByTaskId("task-1")[0].pid).toBe(2); + }); + + it("cleans up task index on kill", () => { + service.register(1, "agent", "a1", undefined, "task-1"); + + service.kill(1); + + expect(service.getByTaskId("task-1")).toEqual([]); + }); + + it("updates task index when PID is re-registered under different task", () => { + service.register(1, "agent", "a1", undefined, "task-1"); + service.register(1, "agent", "a1-new", undefined, "task-2"); + + expect(service.getByTaskId("task-1")).toEqual([]); + expect(service.getByTaskId("task-2")).toHaveLength(1); + }); + + it("clears task index on killAll", () => { + service.register(1, "agent", "a1", undefined, "task-1"); + service.register(2, "agent", "a2", undefined, "task-2"); + + service.killAll(); + + expect(service.getByTaskId("task-1")).toEqual([]); + expect(service.getByTaskId("task-2")).toEqual([]); + }); + }); + + describe("killAll", () => { + it("kills all tracked processes and clears the map", () => { + service.register(1, "shell", "s1"); + service.register(2, "agent", "a1"); + service.register(3, "child", "c1"); + + service.killAll(); + + expect(mockKillProcessTree).toHaveBeenCalledWith(1); + expect(mockKillProcessTree).toHaveBeenCalledWith(2); + expect(mockKillProcessTree).toHaveBeenCalledWith(3); + expect(service.getAll()).toHaveLength(0); + }); + + it("does nothing when no processes are tracked", () => { + service.killAll(); + + expect(mockKillProcessTree).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/workspace-server/src/services/process-tracking/process-tracking.ts b/packages/workspace-server/src/services/process-tracking/process-tracking.ts new file mode 100644 index 0000000000..809ee079aa --- /dev/null +++ b/packages/workspace-server/src/services/process-tracking/process-tracking.ts @@ -0,0 +1,220 @@ +import { exec } from "node:child_process"; +import { platform } from "node:os"; +import { promisify } from "node:util"; +import { injectable, preDestroy } from "inversify"; +import { isProcessAlive, killProcessTree } from "./process-utils"; +import type { + DiscoveredProcess, + ProcessCategory, + ProcessSnapshot, + TrackedProcess, +} from "./schemas"; + +const execAsync = promisify(exec); + +export type { + DiscoveredProcess, + ProcessCategory, + ProcessSnapshot, + TrackedProcess, +}; + +@injectable() +export class ProcessTrackingService { + private _isShuttingDown = false; + + get isShuttingDown(): boolean { + return this._isShuttingDown; + } + + private processes = new Map(); + private taskProcesses = new Map>(); + + register( + pid: number, + category: ProcessCategory, + label: string, + metadata?: Record, + taskId?: string, + ): void { + this.removeFromTaskIndex(pid); + + this.processes.set(pid, { + pid, + category, + label, + registeredAt: Date.now(), + taskId, + metadata, + }); + + if (taskId) { + let pids = this.taskProcesses.get(taskId); + if (!pids) { + pids = new Set(); + this.taskProcesses.set(taskId, pids); + } + pids.add(pid); + } + } + + unregister(pid: number, _reason: string): void { + const proc = this.processes.get(pid); + if (proc) { + this.removeFromTaskIndex(pid); + this.processes.delete(pid); + } + } + + private removeFromTaskIndex(pid: number): void { + const proc = this.processes.get(pid); + if (proc?.taskId) { + const pids = this.taskProcesses.get(proc.taskId); + if (pids) { + pids.delete(pid); + if (pids.size === 0) { + this.taskProcesses.delete(proc.taskId); + } + } + } + } + + getAll(): TrackedProcess[] { + return Array.from(this.processes.values()); + } + + getByCategory(category: ProcessCategory): TrackedProcess[] { + return this.getAll().filter((p) => p.category === category); + } + + async getSnapshot(includeDiscovered = false): Promise { + for (const [pid] of this.processes) { + if (!isProcessAlive(pid)) { + this.unregister(pid, "pruned-dead"); + } + } + + const tracked: Record = { + shell: [], + agent: [], + child: [], + }; + + for (const proc of this.processes.values()) { + tracked[proc.category].push(proc); + } + + const snapshot: ProcessSnapshot = { + tracked, + timestamp: Date.now(), + }; + + if (includeDiscovered) { + snapshot.discovered = await this.discoverChildren(); + } + + return snapshot; + } + + async discoverChildren(): Promise { + if (platform() === "win32") { + return []; + } + + const appPid = process.pid; + + let stdout: string; + try { + const result = await execAsync( + `ps -eo pid,ppid,comm --no-headers 2>/dev/null || ps -eo pid,ppid,comm`, + ); + stdout = result.stdout; + } catch { + return []; + } + + const allProcesses: { pid: number; ppid: number; command: string }[] = []; + + for (const line of stdout.trim().split("\n")) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 3) { + const pid = Number.parseInt(parts[0], 10); + const ppid = Number.parseInt(parts[1], 10); + const command = parts.slice(2).join(" "); + if (!Number.isNaN(pid) && !Number.isNaN(ppid)) { + allProcesses.push({ pid, ppid, command }); + } + } + } + + const descendants = new Set(); + const findDescendants = (parentPid: number): void => { + for (const p of allProcesses) { + if (p.ppid === parentPid && !descendants.has(p.pid)) { + descendants.add(p.pid); + findDescendants(p.pid); + } + } + }; + + findDescendants(appPid); + + const trackedPids = new Set(this.processes.keys()); + const discovered: DiscoveredProcess[] = []; + + for (const p of allProcesses) { + if (descendants.has(p.pid)) { + discovered.push({ + pid: p.pid, + ppid: p.ppid, + command: p.command, + tracked: trackedPids.has(p.pid), + }); + } + } + + return discovered; + } + + isAlive(pid: number): boolean { + return isProcessAlive(pid); + } + + kill(pid: number): void { + killProcessTree(pid); + this.unregister(pid, "killed"); + } + + getByTaskId(taskId: string): TrackedProcess[] { + const pids = this.taskProcesses.get(taskId); + if (!pids) return []; + return Array.from(pids) + .map((pid) => this.processes.get(pid)) + .filter((p): p is TrackedProcess => p !== undefined); + } + + killByCategory(category: ProcessCategory): void { + const procs = this.getByCategory(category); + for (const proc of procs) { + this.kill(proc.pid); + } + } + + killByTaskId(taskId: string): void { + const procs = this.getByTaskId(taskId); + for (const proc of procs) { + this.kill(proc.pid); + } + } + + @preDestroy() + killAll(): void { + this._isShuttingDown = true; + + for (const proc of this.processes.values()) { + killProcessTree(proc.pid); + } + this.processes.clear(); + this.taskProcesses.clear(); + } +} 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/packages/workspace-server/src/services/shell/shell.ts b/packages/workspace-server/src/services/shell/shell.ts new file mode 100644 index 0000000000..2951460ee2 --- /dev/null +++ b/packages/workspace-server/src/services/shell/shell.ts @@ -0,0 +1,437 @@ +import { exec } from "node:child_process"; +import { existsSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { + type IWorkspaceSettings, + WORKSPACE_SETTINGS_SERVICE, +} from "@posthog/platform/workspace-settings"; +import { inject, injectable, preDestroy } from "inversify"; +import { resolveWorktreePathByProbe } from "../worktree-path/worktree-path"; +import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; +import { + REPOSITORY_REPOSITORY, + WORKSPACE_REPOSITORY, + WORKTREE_REPOSITORY, +} from "../../db/identifiers"; +import { + WORKBENCH_LOGGER, + type ScopedLogger, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import * as pty from "node-pty"; +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 { TypedEventEmitter } from "@posthog/shared"; +import type { ProcessTrackingService } from "../process-tracking/process-tracking"; +import { buildWorkspaceEnv } from "../../workspace-env"; +import { type ExecuteOutput, ShellEvent, type ShellEvents } from "./schemas"; + +// node-pty exposes destroy() at runtime but it's missing from type definitions +declare module "node-pty" { + interface IPty { + destroy(): void; + } +} + +const PTY_ENCODING = "utf8"; + +export interface ShellSession { + pty: pty.IPty; + exitPromise: Promise<{ exitCode: number }>; + command?: string; + disposables: pty.IDisposable[]; +} + +function getDefaultShell(): string { + if (platform() === "win32") { + return process.env.COMSPEC || "cmd.exe"; + } + return process.env.SHELL || "/bin/bash"; +} + +function getShellArgs(shell: string): string[] { + if (platform() === "win32") { + const lower = shell.toLowerCase(); + if (lower.includes("powershell") || lower.includes("pwsh")) { + return ["-NoLogo"]; + } + return []; + } + return ["-l"]; +} + +function buildShellEnv( + additionalEnv?: Record, +): Record { + const env = { ...process.env } as Record; + + if (platform() === "darwin" && !process.env.LC_ALL) { + const locale = process.env.LC_CTYPE || "en_US.UTF-8"; + Object.assign(env, { + LANG: locale, + LC_ALL: locale, + LC_MESSAGES: locale, + LC_NUMERIC: locale, + LC_COLLATE: locale, + LC_MONETARY: locale, + }); + } + + Object.assign(env, { + TERM_PROGRAM: "PostHog Code", + COLORTERM: "truecolor", + FORCE_COLOR: "3", + ...additionalEnv, + }); + + return env; +} + +export interface CreateSessionOptions { + sessionId: string; + cwd?: string; + taskId?: string; + initialCommand?: string; + additionalEnv?: Record; +} + +@injectable() +export class ShellService extends TypedEventEmitter { + private sessions = new Map(); + private processTracking: ProcessTrackingService; + private repositoryRepo: RepositoryRepository; + private workspaceRepo: WorkspaceRepository; + private worktreeRepo: WorktreeRepository; + private readonly log: ScopedLogger; + + constructor( + @inject(PROCESS_TRACKING_SERVICE) + processTracking: ProcessTrackingService, + @inject(REPOSITORY_REPOSITORY) + repositoryRepo: RepositoryRepository, + @inject(WORKSPACE_REPOSITORY) + workspaceRepo: WorkspaceRepository, + @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( + sessionId: string, + cwd?: string, + taskId?: string, + ): Promise { + await this.createSession({ sessionId, cwd, taskId }); + } + + async createSession(options: CreateSessionOptions): Promise { + const { sessionId, cwd, taskId, initialCommand, additionalEnv } = options; + + const existing = this.sessions.get(sessionId); + if (existing) { + return existing; + } + + const taskEnv = await this.getTaskEnv(taskId); + const mergedEnv = { ...taskEnv, ...additionalEnv }; + const workingDir = this.resolveWorkingDir(sessionId, cwd); + const shell = getDefaultShell(); + + const ptyProcess = pty.spawn(shell, getShellArgs(shell), { + name: "xterm-256color", + cols: 80, + rows: 24, + cwd: workingDir, + env: buildShellEnv(mergedEnv), + encoding: PTY_ENCODING, + }); + + this.processTracking.register( + ptyProcess.pid, + "shell", + `shell:${sessionId}`, + { sessionId, cwd: workingDir }, + taskId, + ); + + let resolveExit: (result: { exitCode: number }) => void; + const exitPromise = new Promise<{ exitCode: number }>((resolve) => { + resolveExit = resolve; + }); + + const disposables: pty.IDisposable[] = []; + + disposables.push( + ptyProcess.onData((data: string) => { + this.emit(ShellEvent.Data, { sessionId, data }); + }), + ); + + disposables.push( + ptyProcess.onExit(({ exitCode }) => { + this.processTracking.unregister(ptyProcess.pid, "exited"); + const session = this.sessions.get(sessionId); + if (session) { + for (const d of session.disposables) { + d.dispose(); + } + session.pty.destroy(); + this.sessions.delete(sessionId); + } + this.emit(ShellEvent.Exit, { sessionId, exitCode }); + resolveExit({ exitCode }); + }), + ); + + if (initialCommand) { + setTimeout(() => { + ptyProcess.write(`${initialCommand}\n`); + }, 100); + } + + const session: ShellSession = { + pty: ptyProcess, + exitPromise, + command: initialCommand, + disposables, + }; + + this.sessions.set(sessionId, session); + return session; + } + + async createCommandSession(options: { + sessionId: string; + command: string; + cwd: string; + taskId?: string; + }): Promise { + const { sessionId, command, cwd, taskId } = options; + + const existing = this.sessions.get(sessionId); + if (existing) { + return; + } + + const taskEnv = await this.getTaskEnv(taskId); + const workingDir = this.resolveWorkingDir(sessionId, cwd); + const shell = getDefaultShell(); + + const ptyProcess = pty.spawn(shell, ["-c", command], { + name: "xterm-256color", + cols: 80, + rows: 24, + cwd: workingDir, + env: buildShellEnv(taskEnv), + encoding: PTY_ENCODING, + }); + + this.processTracking.register( + ptyProcess.pid, + "shell", + `shell:${sessionId}`, + { sessionId, cwd: workingDir, command }, + taskId, + ); + + let resolveExit: (result: { exitCode: number }) => void; + const exitPromise = new Promise<{ exitCode: number }>((resolve) => { + resolveExit = resolve; + }); + + const disposables: pty.IDisposable[] = []; + + disposables.push( + ptyProcess.onData((data: string) => { + this.emit(ShellEvent.Data, { sessionId, data }); + }), + ); + + disposables.push( + ptyProcess.onExit(({ exitCode }) => { + this.processTracking.unregister(ptyProcess.pid, "exited"); + const session = this.sessions.get(sessionId); + if (session) { + for (const d of session.disposables) { + d.dispose(); + } + session.pty.destroy(); + this.sessions.delete(sessionId); + } + this.emit(ShellEvent.Exit, { sessionId, exitCode }); + resolveExit({ exitCode }); + }), + ); + + const session: ShellSession = { + pty: ptyProcess, + exitPromise, + command, + disposables, + }; + + this.sessions.set(sessionId, session); + } + + write(sessionId: string, data: string): void { + this.getSessionOrThrow(sessionId).pty.write(data); + } + + resize(sessionId: string, cols: number, rows: number): void { + this.getSessionOrThrow(sessionId).pty.resize(cols, rows); + } + + check(sessionId: string): boolean { + return this.sessions.has(sessionId); + } + + hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId); + } + + getSession(sessionId: string): ShellSession | undefined { + return this.sessions.get(sessionId); + } + + getSessionsByPrefix(prefix: string): string[] { + const result: string[] = []; + for (const sessionId of this.sessions.keys()) { + if (sessionId.startsWith(prefix)) { + result.push(sessionId); + } + } + return result; + } + + destroyByPrefix(prefix: string): void { + for (const sessionId of this.sessions.keys()) { + if (sessionId.startsWith(prefix)) { + this.destroy(sessionId); + } + } + } + + destroy(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (session) { + const pid = session.pty.pid; + this.processTracking.kill(pid); + for (const disposable of session.disposables) { + disposable.dispose(); + } + session.pty.destroy(); + this.sessions.delete(sessionId); + } + } + + /** + * Destroy all active shell sessions. + * Used during application shutdown to ensure all child processes are cleaned up. + */ + @preDestroy() + destroyAll(): void { + for (const sessionId of this.sessions.keys()) { + this.destroy(sessionId); + } + } + + /** + * Get the count of active sessions. + */ + getSessionCount(): number { + return this.sessions.size; + } + + getProcess(sessionId: string): string | null { + return this.sessions.get(sessionId)?.pty.process ?? null; + } + + execute(cwd: string, command: string): Promise { + return new Promise((resolve) => { + exec(command, { cwd, timeout: 60000 }, (error, stdout, stderr) => { + resolve({ + stdout: stdout || "", + stderr: stderr || "", + exitCode: error?.code ?? 0, + }); + }); + }); + } + + private getSessionOrThrow(sessionId: string): ShellSession { + const session = this.sessions.get(sessionId); + if (!session) { + throw new Error(`Shell session ${sessionId} not found`); + } + return session; + } + + private resolveWorkingDir(sessionId: string, cwd?: string): string { + const home = homedir(); + const workingDir = cwd || home; + + if (!existsSync(workingDir)) { + this.log.warn( + `Shell session ${sessionId}: cwd "${workingDir}" does not exist, falling back to home`, + ); + return home; + } + + return workingDir; + } + + private async getTaskEnv( + taskId?: string, + ): Promise | undefined> { + if (!taskId) return undefined; + + const workspace = this.workspaceRepo.findByTaskId(taskId); + if (!workspace || workspace.mode === "cloud" || !workspace.repositoryId) { + return undefined; + } + + const repo = this.repositoryRepo.findById(workspace.repositoryId); + if (!repo) return undefined; + + let worktreePath: string | null = null; + let worktreeName: string | null = null; + + if (workspace.mode === "worktree") { + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + if (worktree) { + worktreeName = worktree.name; + worktreePath = await this.deriveWorktreePath(repo.path, worktreeName); + } + } + + return buildWorkspaceEnv({ + taskId, + folderPath: repo.path, + worktreePath, + worktreeName, + mode: workspace.mode, + }); + } +} 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/packages/workspace-server/src/services/skills/schemas.ts b/packages/workspace-server/src/services/skills/schemas.ts new file mode 100644 index 0000000000..76f459c7a7 --- /dev/null +++ b/packages/workspace-server/src/services/skills/schemas.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const skillSource = z.enum(["bundled", "user", "repo", "marketplace"]); + +export const skillInfo = z.object({ + name: z.string(), + description: z.string(), + source: skillSource, + path: z.string(), + repoName: z.string().optional(), +}); + +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/packages/workspace-server/src/services/suspension/schemas.ts b/packages/workspace-server/src/services/suspension/schemas.ts new file mode 100644 index 0000000000..c67e7dcfb1 --- /dev/null +++ b/packages/workspace-server/src/services/suspension/schemas.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +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(), + reason: suspensionReasonSchema.optional().default("manual"), +}); + +export type SuspendTaskInput = z.infer; + +export const restoreTaskInput = z.object({ + taskId: z.string(), + recreateBranch: z.boolean().optional(), +}); + +export type RestoreTaskInput = z.infer; + +export const suspendTaskOutput = suspendedTaskSchema; + +export const restoreTaskOutput = z.object({ + taskId: z.string(), + worktreeName: z.string().nullable(), +}); + +export const listSuspendedTasksOutput = z.array(suspendedTaskSchema); + +export const suspendedTaskIdsOutput = z.array(z.string()); + +export const suspensionSettingsOutput = suspensionSettingsSchema; + +export const updateSuspensionSettingsInput = suspensionSettingsSchema.partial(); 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/packages/workspace-server/src/services/suspension/suspension.test.ts b/packages/workspace-server/src/services/suspension/suspension.test.ts new file mode 100644 index 0000000000..a529cb23df --- /dev/null +++ b/packages/workspace-server/src/services/suspension/suspension.test.ts @@ -0,0 +1,306 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGetAutoSuspendEnabled = vi.hoisted(() => vi.fn(() => true)); +const mockGetMaxActiveWorktrees = vi.hoisted(() => vi.fn(() => 5)); +const mockGetAutoSuspendAfterDays = vi.hoisted(() => vi.fn(() => 7)); +const mockCaptureRun = vi.hoisted(() => vi.fn(() => ({ success: true }))); +const mockDeleteCheckpoint = vi.hoisted(() => vi.fn()); +const mockCreateGitClient = vi.hoisted(() => + vi.fn(() => ({ revparse: vi.fn(() => "feat/test\n") })), +); +const mockWorktreeManagerProto = vi.hoisted(() => ({ + deleteWorktree: vi.fn(), + createWorktreeForExistingBranch: vi.fn(), + createDetachedWorktreeAtCommit: vi.fn(), +})); + +vi.mock("@posthog/git/client", () => ({ + createGitClient: mockCreateGitClient, +})); +vi.mock("@posthog/git/sagas/checkpoint", () => ({ + CaptureCheckpointSaga: class { + run = mockCaptureRun; + }, + RevertCheckpointSaga: class { + run = vi.fn(() => ({ success: true })); + }, + deleteCheckpoint: mockDeleteCheckpoint, +})); +vi.mock("@posthog/git/worktree", () => ({ + WorktreeManager: class { + deleteWorktree = mockWorktreeManagerProto.deleteWorktree; + createWorktreeForExistingBranch = + mockWorktreeManagerProto.createWorktreeForExistingBranch; + createDetachedWorktreeAtCommit = + mockWorktreeManagerProto.createDetachedWorktreeAtCommit; + }, +})); +vi.mock("node:fs/promises", () => { + const fns = { + rm: vi.fn(), + access: vi.fn(), + lstat: vi + .fn() + .mockRejectedValue( + Object.assign(new Error("ENOENT"), { code: "ENOENT" }), + ), + chmod: vi.fn(), + readdir: vi.fn().mockResolvedValue([]), + }; + return { default: fns, ...fns }; +}); + +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 { IWorkspaceSettings } from "@posthog/platform/workspace-settings"; +import type { SessionCanceller, SuspensionFileWatcher } from "./ports"; +import { SuspensionService } from "./suspension"; + +function createMocks() { + const agentService = { + cancelSessionsByTaskId: vi.fn(), + } as unknown as SessionCanceller; + const processTracking = { + killByTaskId: vi.fn(), + } as unknown as ProcessTrackingService; + const fileWatcher = { + stopWatching: vi.fn(), + } 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" }); + + return { + agentService, + processTracking, + fileWatcher, + repositoryRepo, + workspaceRepo, + worktreeRepo, + suspensionRepo, + archiveRepo, + workspaceSettings, + logger, + }; +} + +function makeService(mocks: ReturnType) { + return new SuspensionService( + mocks.agentService, + mocks.processTracking, + mocks.fileWatcher, + mocks.repositoryRepo, + mocks.workspaceRepo, + mocks.worktreeRepo, + mocks.suspensionRepo, + mocks.archiveRepo, + mocks.workspaceSettings, + mocks.logger, + ); +} + +function seedWorktreeWorkspace( + mocks: ReturnType, + overrides: Partial = {}, +) { + const ws = mocks.workspaceRepo.create({ + taskId: overrides.taskId ?? "task-1", + repositoryId: overrides.repositoryId ?? "repo-1", + mode: overrides.mode ?? "worktree", + }); + const stored = mocks.workspaceRepo._workspaces.get(ws.id); + if (!stored) throw new Error(`Workspace not found: ${ws.id}`); + if (overrides.lastActivityAt !== undefined) + stored.lastActivityAt = overrides.lastActivityAt; + if (overrides.createdAt !== undefined) stored.createdAt = overrides.createdAt; + const resolved = mocks.workspaceRepo.findById(ws.id); + if (!resolved) throw new Error(`Workspace not found: ${ws.id}`); + mocks.worktreeRepo.create({ + workspaceId: resolved.id, + name: `wt-${resolved.taskId}`, + path: `/tmp/worktrees/wt-${resolved.taskId}/repo`, + }); + return resolved; +} + +describe("SuspensionService", () => { + let mocks: ReturnType; + let service: SuspensionService; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetAutoSuspendEnabled.mockImplementation(() => true); + mockGetMaxActiveWorktrees.mockImplementation(() => 5); + mockGetAutoSuspendAfterDays.mockImplementation(() => 7); + mocks = createMocks(); + service = makeService(mocks); + }); + + afterEach(() => { + service.stopInactivityChecker(); + }); + + describe("getActiveWorktreeWorkspaces filtering", () => { + beforeEach(() => mockGetMaxActiveWorktrees.mockReturnValue(1)); + + it.each([ + [ + "non-worktree mode", + (m: ReturnType) => + seedWorktreeWorkspace(m, { mode: "local" }), + ], + [ + "already-suspended", + (m: ReturnType) => { + const ws = seedWorktreeWorkspace(m); + m.suspensionRepo.create({ + workspaceId: ws.id, + branchName: null, + checkpointId: null, + reason: "manual", + }); + }, + ], + [ + "archived", + (m: ReturnType) => { + const ws = seedWorktreeWorkspace(m); + m.archiveRepo.create({ + workspaceId: ws.id, + branchName: null, + checkpointId: null, + }); + }, + ], + ])("excludes %s workspaces", async (_label, setup) => { + setup(mocks); + await service.suspendLeastRecentIfOverLimit(); + expect( + mocks.suspensionRepo + .findAll() + .filter((s) => s.reason === "max_worktrees"), + ).toHaveLength(0); + }); + }); + + describe("suspendLeastRecentIfOverLimit", () => { + it("does nothing when autoSuspendEnabled is false", async () => { + mockGetAutoSuspendEnabled.mockReturnValue(false); + seedWorktreeWorkspace(mocks); + await service.suspendLeastRecentIfOverLimit(); + expect(mocks.suspensionRepo.findAll()).toHaveLength(0); + }); + + it("does nothing when active count is below the limit", async () => { + seedWorktreeWorkspace(mocks); + await service.suspendLeastRecentIfOverLimit(); + expect(mocks.suspensionRepo.findAll()).toHaveLength(0); + }); + + it.each([ + [ + "lastActivityAt", + "2024-01-01T00:00:00.000Z", + "2024-06-01T00:00:00.000Z", + ], + ["createdAt fallback", null, null], + ])( + "suspends the oldest workspace by %s", + async (_label, oldActivity, newActivity) => { + const older = seedWorktreeWorkspace(mocks, { + taskId: "task-old", + lastActivityAt: oldActivity, + createdAt: "2024-01-01T00:00:00.000Z", + }); + seedWorktreeWorkspace(mocks, { + taskId: "task-new", + lastActivityAt: newActivity, + createdAt: "2024-06-01T00:00:00.000Z", + }); + mockGetMaxActiveWorktrees.mockReturnValue(1); + + await service.suspendLeastRecentIfOverLimit(); + + const suspended = mocks.suspensionRepo.findAll(); + expect(suspended).toHaveLength(1); + expect(suspended[0].workspaceId).toBe(older.id); + }, + ); + }); + + describe("suspendInactiveWorktrees", () => { + it("does not suspend recently active worktrees", async () => { + seedWorktreeWorkspace(mocks, { + lastActivityAt: new Date().toISOString(), + }); + await service.suspendInactiveWorktrees(); + expect(mocks.suspensionRepo.findAll()).toHaveLength(0); + }); + + it.each([ + ["lastActivityAt", "2020-01-01T00:00:00.000Z", undefined], + ["createdAt fallback", null, "2020-01-01T00:00:00.000Z"], + ])( + "suspends stale worktrees using %s", + async (_label, lastActivityAt, createdAt) => { + seedWorktreeWorkspace(mocks, { + lastActivityAt, + ...(createdAt ? { createdAt } : {}), + }); + + await service.suspendInactiveWorktrees(); + + const suspended = mocks.suspensionRepo.findAll(); + expect(suspended).toHaveLength(1); + expect(suspended[0].reason).toBe("inactivity"); + }, + ); + }); + + describe("withRollback", () => { + it("propagates the error and does not persist suspension", async () => { + seedWorktreeWorkspace(mocks); + mocks.suspensionRepo = createMockSuspensionRepository({ + failOnCreate: true, + }); + service = makeService(mocks); + + await expect(service.suspendTask("task-1", "manual")).rejects.toThrow( + "Injected failure on suspension create", + ); + expect(mocks.suspensionRepo.findAll()).toHaveLength(0); + }); + }); +}); diff --git a/packages/workspace-server/src/services/suspension/suspension.ts b/packages/workspace-server/src/services/suspension/suspension.ts new file mode 100644 index 0000000000..a6ba1a43f7 --- /dev/null +++ b/packages/workspace-server/src/services/suspension/suspension.ts @@ -0,0 +1,503 @@ +import path from "node:path"; +import { TypedEventEmitter } from "@posthog/shared"; +import { resolveWorktreePathByProbe } from "../worktree-path/worktree-path"; +import { + captureWorktreeCheckpoint, + restoreWorktreeFromCheckpoint, +} from "../worktree-checkpoint/worktree-checkpoint"; +import { getCurrentBranchName } from "../worktree-query/worktree-query"; +import { + 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 { 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 { IArchiveRepository } from "../../db/repositories/archive-repository"; +import type { IRepositoryRepository } from "../../db/repositories/repository-repository"; +import type { + SuspensionReason, + SuspensionRepository, +} from "../../db/repositories/suspension-repository"; +import type { + IWorkspaceRepository, + Workspace, +} 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 { + SUSPENSION_FILE_WATCHER, + SUSPENSION_SESSION_CANCELLER, +} from "./identifiers"; +import type { SessionCanceller, SuspensionFileWatcher } from "./ports"; +import type { SuspendedTask } from "./schemas"; + +type RollbackFn = () => Promise; +type StepFn = ( + execute: () => Promise, + rollback?: RollbackFn, +) => Promise; + +export const SuspensionServiceEvent = { + Suspended: "suspended", + Restored: "restored", +} as const; + +export interface SuspensionServiceEvents { + [SuspensionServiceEvent.Suspended]: { taskId: string; reason: string }; + [SuspensionServiceEvent.Restored]: { taskId: string }; +} + +@injectable() +export class SuspensionService extends TypedEventEmitter { + private inactivityTimerId: ReturnType | null = null; + private readonly log: ScopedLogger; + + constructor( + @inject(SUSPENSION_SESSION_CANCELLER) + private readonly sessionCanceller: SessionCanceller, + @inject(PROCESS_TRACKING_SERVICE) + private readonly processTracking: ProcessTrackingService, + @inject(SUSPENSION_FILE_WATCHER) + private readonly fileWatcher: SuspensionFileWatcher, + @inject(REPOSITORY_REPOSITORY) + private readonly repositoryRepo: IRepositoryRepository, + @inject(WORKSPACE_REPOSITORY) + private readonly workspaceRepo: IWorkspaceRepository, + @inject(WORKTREE_REPOSITORY) + private readonly worktreeRepo: IWorktreeRepository, + @inject(SUSPENSION_REPOSITORY) + private readonly suspensionRepo: SuspensionRepository, + @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 { + this.log.info(`Suspending task ${taskId} (reason: ${reason})`); + const result = await this.withRollback((step) => + this.executeSuspend(taskId, reason, step), + ); + this.emit(SuspensionServiceEvent.Suspended, { taskId, reason }); + return result; + } + + async restoreTask( + taskId: string, + recreateBranch?: boolean, + ): Promise<{ taskId: string; worktreeName: string | null }> { + this.log.info( + `Restoring suspended task ${taskId}${recreateBranch ? " (recreate branch)" : ""}`, + ); + const result = await this.withRollback((step) => + this.executeRestore(taskId, recreateBranch, step), + ); + this.emit(SuspensionServiceEvent.Restored, { taskId }); + return result; + } + + getSuspendedTasks(): SuspendedTask[] { + return this.suspensionRepo.findAll().map((suspension) => { + const workspace = this.workspaceRepo.findById( + suspension.workspaceId, + ) as Workspace; + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + return { + taskId: workspace.taskId, + suspendedAt: suspension.suspendedAt, + reason: suspension.reason as SuspendedTask["reason"], + folderId: workspace.repositoryId ?? "", + mode: workspace.mode as SuspendedTask["mode"], + worktreeName: worktree?.name ?? null, + branchName: suspension.branchName, + checkpointId: suspension.checkpointId, + }; + }); + } + + getSuspendedTaskIds(): string[] { + return this.getSuspendedTasks().map((t) => t.taskId); + } + + isSuspended(taskId: string): boolean { + const workspace = this.workspaceRepo.findByTaskId(taskId); + if (!workspace) return false; + return this.suspensionRepo.findByWorkspaceId(workspace.id) !== null; + } + + async suspendLeastRecentIfOverLimit(): Promise { + if (!this.workspaceSettings.getAutoSuspendEnabled()) return; + const maxActive = this.workspaceSettings.getMaxActiveWorktrees(); + const active = this.getActiveWorktreeWorkspaces(); + if (active.length < maxActive) return; + + const oldest = active.sort((a, b) => { + const aTime = a.lastActivityAt ?? a.createdAt ?? ""; + const bTime = b.lastActivityAt ?? b.createdAt ?? ""; + return aTime.localeCompare(bTime); + })[0]; + + if (!oldest) return; + this.log.info( + `Auto-suspending task ${oldest.taskId} (max: ${maxActive}, active: ${active.length})`, + ); + await this.autoSuspend(oldest.taskId, "max_worktrees"); + } + + async suspendInactiveWorktrees(): Promise { + if (!this.workspaceSettings.getAutoSuspendEnabled()) return; + const thresholdDays = this.workspaceSettings.getAutoSuspendAfterDays(); + + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - thresholdDays); + const cutoffStr = cutoff.toISOString(); + + const candidates = this.getActiveWorktreeWorkspaces().filter((ws) => { + return (ws.lastActivityAt ?? ws.createdAt ?? "") < cutoffStr; + }); + + for (const ws of candidates) { + this.log.info( + `Auto-suspending inactive task ${ws.taskId} (last activity: ${ws.lastActivityAt ?? ws.createdAt})`, + ); + await this.autoSuspend(ws.taskId, "inactivity"); + } + } + + startInactivityChecker(): void { + if (this.inactivityTimerId) return; + const ONE_HOUR_MS = 60 * 60 * 1000; + this.inactivityTimerId = setInterval(() => { + this.suspendInactiveWorktrees().catch((error) => { + this.log.error("Inactivity checker failed:", error); + }); + }, ONE_HOUR_MS); + } + + stopInactivityChecker(): void { + if (!this.inactivityTimerId) return; + clearInterval(this.inactivityTimerId); + this.inactivityTimerId = null; + } + + getSettings() { + return { + autoSuspendEnabled: this.workspaceSettings.getAutoSuspendEnabled(), + maxActiveWorktrees: this.workspaceSettings.getMaxActiveWorktrees(), + autoSuspendAfterDays: this.workspaceSettings.getAutoSuspendAfterDays(), + }; + } + + updateSettings(settings: { + autoSuspendEnabled?: boolean; + maxActiveWorktrees?: number; + autoSuspendAfterDays?: number; + }) { + if (settings.autoSuspendEnabled !== undefined) + this.workspaceSettings.setAutoSuspendEnabled(settings.autoSuspendEnabled); + if (settings.maxActiveWorktrees !== undefined) + this.workspaceSettings.setMaxActiveWorktrees(settings.maxActiveWorktrees); + if (settings.autoSuspendAfterDays !== undefined) + this.workspaceSettings.setAutoSuspendAfterDays( + settings.autoSuspendAfterDays, + ); + } + + private async withRollback(fn: (step: StepFn) => Promise): Promise { + const rollbacks: RollbackFn[] = []; + const step: StepFn = async (execute, rollback) => { + await execute(); + if (rollback) rollbacks.push(rollback); + }; + + try { + return await fn(step); + } catch (error) { + for (const rollback of rollbacks.reverse()) { + try { + await rollback(); + } catch (e) { + this.log.error("Rollback failed:", e); + } + } + throw error; + } + } + + private getActiveWorktreeWorkspaces(): Workspace[] { + return this.workspaceRepo.findAll().filter((ws) => { + if (ws.mode !== "worktree") return false; + if (this.suspensionRepo.findByWorkspaceId(ws.id)) return false; + if (this.archiveRepo.findByWorkspaceId(ws.id)) return false; + return true; + }); + } + + private async autoSuspend( + taskId: string, + reason: SuspensionReason, + ): Promise { + try { + await this.suspendTask(taskId, reason); + } catch (error) { + this.log.error(`Failed to auto-suspend task ${taskId}:`, error); + } + } + + private getWorkspaceWithRepo(taskId: string) { + const workspace = this.workspaceRepo.findByTaskId(taskId); + if (!workspace) throw new Error(`Workspace not found for task ${taskId}`); + + let folderPath: string | null = null; + if (workspace.repositoryId) { + const repo = this.repositoryRepo.findById(workspace.repositoryId); + if (!repo) throw new Error(`Repository not found for task ${taskId}`); + folderPath = repo.path; + } + + return { workspace, folderPath }; + } + + private createWorktreeManager(folderPath: string) { + return new WorktreeManager({ + mainRepoPath: folderPath, + worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), + }); + } + + private async deleteWorktreeOnDisk( + folderPath: string, + worktreePath: string, + ): Promise { + const manager = this.createWorktreeManager(folderPath); + await manager.deleteWorktree(worktreePath); + await forceRemove(path.dirname(worktreePath)); + } + + private async killTaskProcesses( + taskId: string, + worktreePath?: string, + ): Promise { + await this.sessionCanceller.cancelSessionsByTaskId(taskId); + this.processTracking.killByTaskId(taskId); + if (worktreePath) await this.fileWatcher.stopWatching(worktreePath); + } + + private async executeSuspend( + taskId: string, + reason: SuspensionReason, + step: StepFn, + ): Promise { + const { workspace, folderPath } = this.getWorkspaceWithRepo(taskId); + + if (this.suspensionRepo.findByWorkspaceId(workspace.id)) + throw new Error(`Task ${taskId} is already suspended`); + if (this.archiveRepo.findByWorkspaceId(workspace.id)) + throw new Error(`Task ${taskId} is already archived`); + + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + const isWorktreeMode = + workspace.mode === "worktree" && worktree && folderPath; + + const suspendedTask: SuspendedTask = { + taskId, + suspendedAt: new Date().toISOString(), + reason, + folderId: workspace.repositoryId ?? "", + mode: workspace.mode, + worktreeName: worktree?.name ?? null, + branchName: null, + checkpointId: isWorktreeMode ? `suspension-${worktree.name}` : null, + }; + + if (isWorktreeMode) { + const worktreePath = worktree.path; + + const branch = await this.getCurrentBranchName(worktreePath); + if (branch && branch !== "HEAD") suspendedTask.branchName = branch; + + const checkpointId = suspendedTask.checkpointId; + if (!checkpointId) + throw new Error("checkpointId must be set in worktree mode"); + + await step( + async () => { + await this.captureWorktreeCheckpoint( + folderPath, + worktreePath, + checkpointId, + ); + }, + async () => { + const git = createGitClient(folderPath); + await deleteCheckpoint(git, checkpointId); + }, + ); + + await step(async () => this.killTaskProcesses(taskId, worktreePath)); + await step(async () => + this.deleteWorktreeOnDisk(folderPath, worktreePath), + ); + } else { + await step(async () => this.killTaskProcesses(taskId)); + } + + await step( + async () => { + this.suspensionRepo.create({ + workspaceId: workspace.id, + branchName: suspendedTask.branchName, + checkpointId: suspendedTask.checkpointId, + reason, + }); + }, + async () => this.suspensionRepo.deleteByWorkspaceId(workspace.id), + ); + + return suspendedTask; + } + + private async executeRestore( + taskId: string, + recreateBranch: boolean | undefined, + step: StepFn, + ): Promise<{ taskId: string; worktreeName: string | null }> { + const { workspace, folderPath } = this.getWorkspaceWithRepo(taskId); + + const suspension = this.suspensionRepo.findByWorkspaceId(workspace.id); + if (!suspension) throw new Error(`Suspended task not found: ${taskId}`); + + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + let restoredWorktreeName: string | null = worktree?.name ?? null; + + if ( + folderPath && + workspace.mode === "worktree" && + suspension.checkpointId + ) { + const checkpointId = suspension.checkpointId; + await step( + async () => { + restoredWorktreeName = await this.restoreWorktreeFromCheckpoint( + folderPath, + workspace, + suspension.branchName, + checkpointId, + recreateBranch, + ); + }, + async () => { + if (restoredWorktreeName) { + const worktreePath = await this.deriveWorktreePath( + folderPath, + restoredWorktreeName, + ); + await this.deleteWorktreeOnDisk(folderPath, worktreePath); + } + }, + ); + + await step( + async () => { + if (!restoredWorktreeName) + throw new Error("Failed to restore worktree"); + const worktreePath = await this.deriveWorktreePath( + folderPath, + restoredWorktreeName, + ); + this.worktreeRepo.create({ + workspaceId: workspace.id, + name: restoredWorktreeName, + path: worktreePath, + }); + }, + async () => this.worktreeRepo.deleteByWorkspaceId(workspace.id), + ); + } + + await step( + async () => this.suspensionRepo.deleteByWorkspaceId(workspace.id), + async () => { + this.suspensionRepo.create({ + workspaceId: workspace.id, + branchName: suspension.branchName, + checkpointId: suspension.checkpointId, + reason: suspension.reason as SuspensionReason, + }); + }, + ); + + return { taskId, worktreeName: restoredWorktreeName }; + } + + private getCurrentBranchName(worktreePath: string): Promise { + return getCurrentBranchName(worktreePath); + } + + private captureWorktreeCheckpoint( + folderPath: string, + worktreePath: string, + checkpointId: string, + ): Promise { + return captureWorktreeCheckpoint(folderPath, worktreePath, checkpointId); + } + + private async restoreWorktreeFromCheckpoint( + folderPath: string, + workspace: Workspace, + branchName: string | null, + checkpointId: string, + recreateBranch?: boolean, + ): Promise { + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + + const newWorktree = await restoreWorktreeFromCheckpoint({ + mainRepoPath: folderPath, + worktreeBasePath: this.workspaceSettings.getWorktreeLocation(), + preferredName: worktree?.name ?? undefined, + branchName, + checkpointId, + recreateBranch, + }); + + if (worktree) this.worktreeRepo.deleteByWorkspaceId(workspace.id); + return newWorktree.worktreeName; + } + + private deriveWorktreePath( + folderPath: string, + worktreeName: string, + ): Promise { + return resolveWorktreePathByProbe( + this.workspaceSettings.getWorktreeLocation(), + folderPath, + worktreeName, + ); + } +} 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/packages/workspace-server/src/services/watcher-registry/watcher-registry.ts b/packages/workspace-server/src/services/watcher-registry/watcher-registry.ts new file mode 100644 index 0000000000..8645ad6d0d --- /dev/null +++ b/packages/workspace-server/src/services/watcher-registry/watcher-registry.ts @@ -0,0 +1,129 @@ +import type * as watcher from "@parcel/watcher"; +import { + type ScopedLogger, + WORKBENCH_LOGGER, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import { inject, injectable } from "inversify"; + +const UNSUBSCRIBE_TIMEOUT_MS = 2000; + +@injectable() +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; + } + + register(id: string, subscription: watcher.AsyncSubscription): void { + if (this._isShutdown) { + this.log.warn(`Attempted to register watcher after shutdown: ${id}`); + subscription.unsubscribe().catch((err) => { + this.log.warn(`Failed to unsubscribe rejected watcher ${id}:`, { + error: err, + }); + }); + return; + } + + if (this.subscriptions.has(id)) { + const existing = this.subscriptions.get(id); + existing?.unsubscribe().catch((err) => { + this.log.warn(`Failed to unsubscribe replaced watcher ${id}:`, { + error: err, + }); + }); + } + + this.subscriptions.set(id, subscription); + } + + async unregister(id: string): Promise { + const subscription = this.subscriptions.get(id); + if (!subscription) return; + + this.subscriptions.delete(id); + try { + await subscription.unsubscribe(); + this.log.debug(`Unregistered watcher: ${id}`); + } catch (err) { + this.log.warn(`Failed to unsubscribe watcher ${id}:`, { error: err }); + } + } + + async shutdownAll(): Promise { + if (this._isShutdown) { + this.log.warn("shutdownAll called but already shutdown"); + return; + } + + this._isShutdown = true; + const count = this.subscriptions.size; + + if (count === 0) { + this.log.info("No watchers to shutdown"); + return; + } + + this.log.info(`Shutting down ${count} watchers`); + + const entries = Array.from(this.subscriptions.entries()); + this.subscriptions.clear(); + + const results = await Promise.allSettled( + entries.map(([id, sub]) => this.unsubscribeWithTimeout(id, sub)), + ); + + const failures = results.filter((r) => r.status === "rejected").length; + const timeouts = results.filter( + (r) => r.status === "fulfilled" && r.value === "timeout", + ).length; + + if (failures > 0 || timeouts > 0) { + this.log.warn( + `Watcher shutdown: ${count - failures - timeouts} clean, ${timeouts} timed out, ${failures} failed`, + ); + } else { + this.log.info(`All ${count} watchers shutdown successfully`); + } + } + + private async unsubscribeWithTimeout( + id: string, + sub: watcher.AsyncSubscription, + ): Promise<"ok" | "timeout"> { + const timeoutPromise = new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), UNSUBSCRIBE_TIMEOUT_MS), + ); + + const unsubPromise = sub + .unsubscribe() + .then(() => "ok" as const) + .catch((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") { + this.log.warn( + `Watcher ${id} unsubscribe timed out after ${UNSUBSCRIBE_TIMEOUT_MS}ms`, + ); + } else { + 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/packages/workspace-server/src/services/workspace/schemas.ts b/packages/workspace-server/src/services/workspace/schemas.ts new file mode 100644 index 0000000000..68be421f75 --- /dev/null +++ b/packages/workspace-server/src/services/workspace/schemas.ts @@ -0,0 +1,285 @@ +import { z } from "zod"; +// PORT NOTE: workspace projection schemas moved to @posthog/shared/workspace-domain +// (single source of truth for the host service + renderer/UI). Imported for local +// use in the input/output schemas below and re-exported so existing +// @main/services/workspace/schemas consumers keep resolving. +import { + workspaceInfoSchema, + workspaceModeSchema, + workspaceSchema, + worktreeInfoSchema, +} from "@posthog/shared"; + +export { + workspaceInfoSchema, + workspaceModeSchema, + workspaceSchema, + worktreeInfoSchema, +}; + +// Input schemas +export const createWorkspaceInput = z + .object({ + taskId: z.string(), + mainRepoPath: z.string(), + folderId: z.string(), + folderPath: z.string(), + mode: workspaceModeSchema, + branch: z.string().optional(), + useExistingBranch: z.boolean().optional(), + }) + .refine( + (data) => + data.mode === "cloud" || + (data.mainRepoPath.length >= 2 && data.folderPath.length >= 2), + { + message: "Repository and folder paths must be valid for non-cloud mode", + }, + ); + +export const reconcileCloudWorkspacesInput = z.object({ + taskIds: z.array(z.string()), +}); + +export const reconcileCloudWorkspacesOutput = z.object({ + created: z.array(z.string()), +}); + +export const deleteWorkspaceInput = z.object({ + taskId: z.string(), + mainRepoPath: z.string(), +}); + +export const verifyWorkspaceInput = z.object({ + taskId: z.string(), +}); + +export const getWorkspaceInfoInput = z.object({ + taskId: z.string(), +}); + +// Output schemas +export const createWorkspaceOutput = workspaceInfoSchema; +export const verifyWorkspaceOutput = z.object({ + exists: z.boolean(), + missingPath: z.string().optional(), +}); +export const getWorkspaceInfoOutput = workspaceInfoSchema.nullable(); +export const getAllWorkspacesOutput = z.record(z.string(), workspaceSchema); + +export const workspaceErrorPayload = z.object({ + taskId: z.string(), + message: z.string(), +}); + +export const workspaceWarningPayload = z.object({ + taskId: z.string(), + title: z.string(), + message: z.string(), +}); + +export const workspacePromotedPayload = z.object({ + taskId: z.string(), + worktree: worktreeInfoSchema, + fromBranch: z.string(), +}); + +export const branchChangedPayload = z.object({ + taskId: z.string(), + branchName: z.string().nullable(), +}); + +export const linkedBranchChangedPayload = z.object({ + taskId: z.string(), + branchName: z.string().nullable(), +}); + +export const linkBranchInput = z.object({ + taskId: z.string(), + branchName: z.string(), +}); + +export const unlinkBranchInput = z.object({ + taskId: z.string(), +}); + +export const localBackgroundedPayload = z.object({ + mainRepoPath: z.string(), + localWorktreePath: z.string(), + branch: z.string(), +}); + +export const localForegroundedPayload = z.object({ + mainRepoPath: z.string(), +}); + +// Input/output schemas for local workspace backgrounding +export const isLocalBackgroundedInput = z.object({ + mainRepoPath: z.string(), +}); + +export const isLocalBackgroundedOutput = z.boolean(); + +export const getLocalWorktreePathInput = z.object({ + mainRepoPath: z.string(), +}); + +export const getLocalWorktreePathOutput = z.string(); + +export const backgroundLocalWorkspaceInput = z.object({ + mainRepoPath: z.string(), + branch: z.string(), +}); + +export const backgroundLocalWorkspaceOutput = z.string().nullable(); + +export const foregroundLocalWorkspaceInput = z.object({ + mainRepoPath: z.string(), +}); + +export const foregroundLocalWorkspaceOutput = z.boolean(); + +export const getLocalTasksInput = z.object({ + mainRepoPath: z.string(), +}); + +export const localTaskSchema = z.object({ + taskId: z.string(), +}); + +export const getLocalTasksOutput = z.array(localTaskSchema); + +export const getWorktreeTasksInput = z.object({ + worktreePath: z.string(), +}); + +export const getWorktreeTasksOutput = z.array(localTaskSchema); + +export const listGitWorktreesInput = z.object({ + mainRepoPath: z.string(), +}); + +export const getWorktreeFileUsageInput = z.object({ + mainRepoPath: z.string(), +}); + +export const getWorktreeFileUsageOutput = z.object({ + usesWorktreeLink: z.boolean(), + usesWorktreeInclude: z.boolean(), +}); + +export const gitWorktreeEntrySchema = z.object({ + worktreePath: z.string(), + head: z.string(), + branch: z.string().nullable(), + taskIds: z.array(z.string()), +}); + +export const listGitWorktreesOutput = z.array(gitWorktreeEntrySchema); + +export const getWorktreeSizeInput = z.object({ + worktreePath: z.string(), +}); + +export const getWorktreeSizeOutput = z.object({ + sizeBytes: z.number(), +}); + +export const deleteWorktreeInput = z.object({ + worktreePath: z.string(), + mainRepoPath: z.string(), +}); + +export const togglePinInput = z.object({ + taskId: z.string(), +}); + +export const togglePinOutput = z.object({ + isPinned: z.boolean(), + pinnedAt: z.string().nullable(), +}); + +export const markViewedInput = z.object({ + taskId: z.string(), +}); + +export const markActivityInput = z.object({ + taskId: z.string(), +}); + +export const getPinnedTaskIdsOutput = z.array(z.string()); + +export const getTaskTimestampsInput = z.object({ + taskId: z.string(), +}); + +export const getTaskTimestampsOutput = z.object({ + pinnedAt: z.string().nullable(), + lastViewedAt: z.string().nullable(), + lastActivityAt: z.string().nullable(), +}); + +export const getAllTaskTimestampsOutput = z.record( + z.string(), + z.object({ + pinnedAt: z.string().nullable(), + lastViewedAt: z.string().nullable(), + lastActivityAt: z.string().nullable(), + }), +); + +// Task PR status +export const taskPrStatusInput = z.object({ + taskId: z.string(), + cloudPrUrl: z.string().nullable(), +}); + +export const sidebarPrStateSchema = z + .enum(["merged", "open", "draft", "closed"]) + .nullable(); + +export const taskPrStatusOutput = z.object({ + prState: sidebarPrStateSchema, + hasDiff: z.boolean(), +}); + +export type TaskPrStatusInput = z.infer; +export type SidebarPrState = z.infer; +export type TaskPrStatus = z.infer; + +// Type exports +export type { + Workspace, + WorkspaceInfo, + WorkspaceMode, + WorktreeInfo, +} from "@posthog/shared"; + +export type CreateWorkspaceInput = z.infer; +export type ReconcileCloudWorkspacesInput = z.infer< + typeof reconcileCloudWorkspacesInput +>; +export type ReconcileCloudWorkspacesOutput = z.infer< + typeof reconcileCloudWorkspacesOutput +>; +export type DeleteWorkspaceInput = z.infer; +export type VerifyWorkspaceInput = z.infer; +export type GetWorkspaceInfoInput = z.infer; +export type ListGitWorktreesInput = z.infer; +export type GetWorktreeSizeInput = z.infer; +export type DeleteWorktreeInput = z.infer; +export type WorkspaceErrorPayload = z.infer; +export type WorkspaceWarningPayload = z.infer; +export type WorkspacePromotedPayload = z.infer; +export type BranchChangedPayload = z.infer; +export type LinkedBranchChangedPayload = z.infer< + typeof linkedBranchChangedPayload +>; +export type LinkBranchInput = z.infer; +export type UnlinkBranchInput = z.infer; +export type LocalBackgroundedPayload = z.infer; +export type LocalForegroundedPayload = z.infer; +export type IsLocalBackgroundedInput = z.infer; +export type GetLocalWorktreePathInput = z.infer< + typeof getLocalWorktreePathInput +>; 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..18d889aaa8 --- /dev/null +++ b/packages/workspace-server/src/services/workspace/workspace.test.ts @@ -0,0 +1,217 @@ +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { IAnalytics } from "@posthog/platform/analytics"; +import type { IWorkspaceSettings } from "@posthog/platform/workspace-settings"; +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 { WorkbenchLogger } from "@posthog/di/logger"; +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/packages/workspace-server/src/services/workspace/workspace.ts b/packages/workspace-server/src/services/workspace/workspace.ts new file mode 100644 index 0000000000..2f975d137a --- /dev/null +++ b/packages/workspace-server/src/services/workspace/workspace.ts @@ -0,0 +1,1168 @@ +import * as fs from "node:fs"; +import path from "node:path"; +import { createGitClient } from "@posthog/git/client"; +import { + getCurrentBranch, + getDefaultBranch, + hasTrackedFiles, +} 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 { + 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 { + 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 { + WORKBENCH_LOGGER, + type ScopedLogger, + type WorkbenchLogger, +} from "@posthog/di/logger"; +import type { + WorkspaceAgent, + WorkspaceFileWatcher, + WorkspaceFocus, + WorkspaceProvisioning, +} from "./ports"; +import type { + BranchChangedPayload, + CreateWorkspaceInput, + LinkedBranchChangedPayload, + ReconcileCloudWorkspacesOutput, + Workspace, + WorkspaceErrorPayload, + WorkspaceInfo, + WorkspacePromotedPayload, + WorkspaceWarningPayload, + WorktreeInfo, +} from "./schemas"; + +type TaskAssociation = + | { taskId: string; folderId: string; mode: "local" } + | { taskId: string; folderId: string | null; mode: "cloud" } + | { + taskId: string; + folderId: string; + mode: "worktree"; + worktree: string; + branchName: string | null; + }; + +export const WorkspaceServiceEvent = { + Error: "error", + Warning: "warning", + Promoted: "promoted", + BranchChanged: "branchChanged", + LinkedBranchChanged: "linkedBranchChanged", +} as const; + +export interface WorkspaceServiceEvents { + [WorkspaceServiceEvent.Error]: WorkspaceErrorPayload; + [WorkspaceServiceEvent.Warning]: WorkspaceWarningPayload; + [WorkspaceServiceEvent.Promoted]: WorkspacePromotedPayload; + [WorkspaceServiceEvent.BranchChanged]: BranchChangedPayload; + [WorkspaceServiceEvent.LinkedBranchChanged]: LinkedBranchChangedPayload; +} + +@injectable() +export class WorkspaceService extends TypedEventEmitter { + 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; + + if (workspace.mode === "cloud") { + return { + taskId, + folderId: workspace.repositoryId, + mode: "cloud", + }; + } + + if (!workspace.repositoryId) return null; + + if (workspace.mode === "worktree") { + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + if (!worktree) return null; + return { + taskId, + folderId: workspace.repositoryId, + mode: "worktree", + worktree: worktree.name, + branchName: null, + }; + } + + return { + taskId, + folderId: workspace.repositoryId, + mode: "local", + }; + } + + private getFolderPath(folderId: string): string | null { + const repo = this.repositoryRepo.findById(folderId); + return repo?.path ?? null; + } + + private getAllTaskAssociations(): TaskAssociation[] { + const workspaces = this.workspaceRepo.findAll(); + const result: TaskAssociation[] = []; + + for (const workspace of workspaces) { + if (workspace.mode === "cloud") { + result.push({ + taskId: workspace.taskId, + folderId: workspace.repositoryId, + mode: "cloud", + }); + continue; + } + + if (!workspace.repositoryId) continue; + + if (workspace.mode === "worktree") { + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + if (!worktree) continue; + result.push({ + taskId: workspace.taskId, + folderId: workspace.repositoryId, + mode: "worktree", + worktree: worktree.name, + branchName: null, + }); + } else { + result.push({ + taskId: workspace.taskId, + folderId: workspace.repositoryId, + mode: "local", + }); + } + } + + return result; + } + + /** + * Initialize branch change watching. Should be called after app is ready. + * Subscribes to GitStateChanged events and checks for branch renames. + */ + initBranchWatcher(): void { + if (this.branchWatcherInitialized) return; + this.branchWatcherInitialized = true; + + this.fileWatcher.onGitStateChanged(this.handleGitStateChanged.bind(this)); + + this.focus.onBranchRenamed(this.handleFocusBranchRenamed.bind(this)); + + this.agent.onAgentFileActivity(this.handleAgentFileActivity.bind(this)); + } + + private handleFocusBranchRenamed({ + worktreePath, + newBranch, + }: { + mainRepoPath: string; + worktreePath: string; + oldBranch: string; + newBranch: string; + }): void { + const associations = this.getAllTaskAssociations(); + for (const assoc of associations) { + if (assoc.mode !== "worktree") continue; + const folderPath = this.getFolderPath(assoc.folderId); + if (!folderPath) continue; + const derivedPath = this.deriveWorktreePath(folderPath, assoc.worktree); + if (derivedPath === worktreePath && assoc.branchName !== newBranch) { + this.updateAssociationBranchName(assoc.taskId, newBranch); + this.emit(WorkspaceServiceEvent.BranchChanged, { + taskId: assoc.taskId, + branchName: newBranch, + }); + } + } + } + + private async handleGitStateChanged({ + repoPath, + }: { + repoPath: string; + }): Promise { + const associations = this.getAllTaskAssociations(); + + for (const assoc of associations) { + if (assoc.mode === "cloud" || !assoc.folderId) continue; + + const folderPath = this.getFolderPath(assoc.folderId); + if (!folderPath) continue; + + if (assoc.mode === "worktree") { + const worktreePath = this.deriveWorktreePath( + folderPath, + assoc.worktree, + ); + if (worktreePath !== repoPath) continue; + + const currentBranch = await getBranchFromPath(repoPath); + if (currentBranch !== null && currentBranch !== assoc.branchName) { + this.updateAssociationBranchName(assoc.taskId, currentBranch); + this.emit(WorkspaceServiceEvent.BranchChanged, { + taskId: assoc.taskId, + branchName: currentBranch, + }); + } + } else if (assoc.mode === "local") { + if (folderPath !== repoPath) continue; + + const localWorktreePath = + await this.getLocalWorktreePathIfExists(folderPath); + const branchPath = localWorktreePath ?? folderPath; + const currentBranch = await getBranchFromPath(branchPath); + + if (currentBranch === null && localWorktreePath) { + continue; + } + + this.emit(WorkspaceServiceEvent.BranchChanged, { + taskId: assoc.taskId, + branchName: currentBranch, + }); + } + } + } + + private async handleAgentFileActivity({ + taskId, + branchName, + }: { + taskId: string; + branchName: string | null; + }): Promise { + if (!branchName) return; + + const dbRow = this.workspaceRepo.findByTaskId(taskId); + if (!dbRow || dbRow.mode !== "local") return; + if (!dbRow.repositoryId) return; + + const folderPath = this.getFolderPath(dbRow.repositoryId); + if (!folderPath) return; + + try { + const defaultBranch = await getDefaultBranch(folderPath); + if (branchName === defaultBranch) return; + } catch (error) { + 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; + } + + const currentLinked = dbRow.linkedBranch ?? null; + if (currentLinked === branchName) return; + + this.linkBranch(taskId, branchName, "agent"); + } + + private updateAssociationBranchName( + _taskId: string, + _branchName: string, + ): void {} + + public linkBranch( + taskId: string, + branchName: string, + source?: "agent" | "user", + ): void { + this.workspaceRepo.updateLinkedBranch(taskId, branchName); + this.emit(WorkspaceServiceEvent.LinkedBranchChanged, { + taskId, + branchName, + }); + this.analytics.track(ANALYTICS_EVENTS.BRANCH_LINKED, { + task_id: taskId, + branch_name: branchName, + source: source ?? "unknown", + }); + this.log.info("Linked branch to task", { taskId, branchName, source }); + } + + public unlinkBranch(taskId: string, source?: "agent" | "user"): void { + this.workspaceRepo.updateLinkedBranch(taskId, null); + this.emit(WorkspaceServiceEvent.LinkedBranchChanged, { + taskId, + branchName: null, + }); + this.analytics.track(ANALYTICS_EVENTS.BRANCH_UNLINKED, { + task_id: taskId, + source: source ?? "unknown", + }); + this.log.info("Unlinked branch from task", { taskId, source }); + } + + private getLocalWorktreePathIfExists( + mainRepoPath: string, + ): Promise { + return resolveLocalWorktreePath( + mainRepoPath, + this.workspaceSettings.getWorktreeLocation(), + ); + } + + // Batched cloud-workspace reconcile. The renderer calls this once on boot + // with every cloud taskId it sees that has no local workspace row, instead + // of firing one createWorkspace mutation per task. With 100+ cloud tasks + // the N-call pattern saturates the main thread on the tRPC IPC path; this + // collapses it to one IPC + one batched insert. + async reconcileCloudWorkspaces( + taskIds: string[], + ): Promise { + if (taskIds.length === 0) return { created: [] }; + + const existingTaskIds = new Set( + this.workspaceRepo.findAll().map((w) => w.taskId), + ); + const uniqueRequested = Array.from(new Set(taskIds)); + const toCreate = uniqueRequested.filter((id) => !existingTaskIds.has(id)); + if (toCreate.length === 0) return { created: [] }; + + this.log.info( + `Reconciling ${toCreate.length} cloud workspaces (requested ${taskIds.length})`, + ); + this.workspaceRepo.createCloudMany(toCreate); + return { created: toCreate }; + } + + async createWorkspace(options: CreateWorkspaceInput): Promise { + // Prevent concurrent workspace creation for the same task + const existingPromise = this.creatingWorkspaces.get(options.taskId); + if (existingPromise) { + this.log.warn( + `Workspace creation already in progress for task ${options.taskId}, waiting for existing operation`, + ); + return existingPromise; + } + + const promise = this.doCreateWorkspace(options); + this.creatingWorkspaces.set(options.taskId, promise); + + try { + return await promise; + } finally { + this.creatingWorkspaces.delete(options.taskId); + } + } + + private async doCreateWorkspace( + options: CreateWorkspaceInput, + ): Promise { + const { + taskId, + mainRepoPath, + folderPath, + mode, + branch, + useExistingBranch, + } = options; + + const existingWorkspace = await this.getWorkspaceInfo(taskId); + if (existingWorkspace) { + this.log.info( + `Workspace already exists for task ${taskId}, returning existing workspace`, + ); + return existingWorkspace; + } + + this.log.info( + `Creating workspace for task ${taskId} in ${mainRepoPath} (mode: ${mode}, useExistingBranch: ${useExistingBranch})`, + ); + + const repository = this.repositoryRepo.findByPath(mainRepoPath); + const repositoryId = repository?.id ?? null; + + if (mode === "cloud") { + this.workspaceRepo.create({ + taskId, + repositoryId, + mode: "cloud", + }); + + return { + taskId, + mode, + worktree: null, + branchName: null, + linkedBranch: null, + }; + } + + if (mode === "local") { + if (branch) { + const currentBranch = await getCurrentBranch(folderPath); + if (currentBranch === branch) { + this.log.info(`Already on branch ${branch}, skipping checkout`); + } else { + this.log.info( + `Creating/switching to branch ${branch} for task ${taskId}`, + ); + const saga = new CreateOrSwitchBranchSaga(); + const result = await saga.run({ + baseDir: folderPath, + branchName: branch, + }); + if (!result.success) { + const message = `Could not switch to branch "${branch}". Please commit or stash your changes first.`; + this.log.error(message, result.error); + this.emitWorkspaceError(taskId, message); + throw new Error(message); + } + if (result.data.created) { + this.log.info(`Created and switched to new branch ${branch}`); + } else { + this.log.info(`Switched to existing branch ${branch}`); + } + } + } + + this.workspaceRepo.create({ + taskId, + repositoryId, + mode: "local", + }); + + const localBranch = await getBranchFromPath(folderPath); + return { + taskId, + mode, + worktree: null, + branchName: localBranch, + linkedBranch: null, + }; + } + + await this.suspensionService.suspendLeastRecentIfOverLimit(); + + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); + const worktreeManager = new WorktreeManager({ + mainRepoPath, + worktreeBasePath, + }); + let worktree: WorktreeInfo; + + try { + const defaultBranch = await getDefaultBranch(mainRepoPath).catch(() => + getCurrentBranch(mainRepoPath).then((b) => b ?? "main"), + ); + const selectedBranch = branch ?? defaultBranch; + const isTrunkSelected = selectedBranch === defaultBranch; + + const onOutput = (data: string) => { + this.provisioning.emitOutput(taskId, data); + }; + + if (isTrunkSelected) { + this.log.info( + `Trunk branch selected (${defaultBranch}), creating detached worktree`, + ); + worktree = await worktreeManager.createWorktree({ + baseBranch: defaultBranch, + onOutput, + fetchBeforeCreate: true, + }); + this.log.info( + `Created detached worktree from trunk: ${worktree.worktreeName} at ${worktree.worktreePath}`, + ); + } else { + this.log.info( + `Non-trunk branch selected (${selectedBranch}), attempting checkout`, + ); + try { + worktree = await worktreeManager.createWorktreeForExistingBranch( + selectedBranch, + undefined, + { onOutput }, + ); + this.log.info( + `Created worktree with branch checkout: ${worktree.worktreeName} at ${worktree.worktreePath} (branch: ${selectedBranch})`, + ); + } catch (checkoutError) { + const errorMessage = + checkoutError instanceof Error + ? checkoutError.message + : String(checkoutError); + if (errorMessage.includes("is already used by worktree")) { + this.log.info( + `Branch ${selectedBranch} is occupied, falling back to detached worktree`, + ); + worktree = await worktreeManager.createWorktree({ + baseBranch: selectedBranch, + onOutput, + }); + this.log.info( + `Created detached worktree from occupied branch: ${worktree.worktreeName} at ${worktree.worktreePath}`, + ); + } else { + throw checkoutError; + } + } + } + + // Warn if worktree is empty but main repo has files + const worktreeHasFiles = await hasTrackedFiles(worktree.worktreePath); + if (!worktreeHasFiles) { + const mainHasFiles = await hasAnyFiles(mainRepoPath); + if (mainHasFiles) { + this.log.warn( + `Worktree ${worktree.worktreeName} is empty but main repo has files`, + ); + this.emitWorkspaceWarning( + taskId, + "Workspace is empty", + "No files are committed yet. Commit your files to see them in workspaces.", + ); + } + } + } catch (error) { + this.log.error(`Failed to create worktree for task ${taskId}:`, error); + throw new Error(`Failed to create worktree: ${String(error)}`); + } + + const createdWorkspace = this.workspaceRepo.create({ + taskId, + repositoryId, + mode: "worktree", + }); + + this.worktreeRepo.create({ + workspaceId: createdWorkspace.id, + name: worktree.worktreeName, + path: worktree.worktreePath, + }); + + return { + taskId, + mode, + worktree, + branchName: worktree.branchName, + linkedBranch: null, + }; + } + + async deleteWorkspace(taskId: string, mainRepoPath: string): Promise { + this.log.info(`Deleting workspace for task ${taskId}`); + + const association = this.findTaskAssociation(taskId); + if (!association) { + this.log.warn(`No workspace found for task ${taskId}`); + return; + } + + if (association.mode === "cloud") { + this.removeTaskAssociation(taskId); + this.log.info(`Cloud workspace deleted for task ${taskId}`); + return; + } + + const folderId = association.folderId; + const folderPath = this.getFolderPath(folderId); + if (!folderPath) { + this.log.warn( + `No folder found for task ${taskId}, removing association only`, + ); + this.removeTaskAssociation(taskId); + return; + } + + let worktreePath: string | null = null; + + if (association.mode === "worktree") { + worktreePath = this.deriveWorktreePath(folderPath, association.worktree); + } + + await this.agent.cancelSessionsByTaskId(taskId); + this.processTracking.killByTaskId(taskId); + + if (association.mode === "worktree" && worktreePath) { + await this.cleanupWorktree( + taskId, + mainRepoPath, + worktreePath, + association.branchName, + ); + + const otherWorkspacesForFolder = this.getAllTaskAssociations().filter( + (a) => + a.folderId === folderId && + a.taskId !== taskId && + a.mode === "worktree", + ); + + if (otherWorkspacesForFolder.length === 0) { + await this.cleanupRepoWorktreeFolder(folderPath); + } + } + + this.removeTaskAssociation(taskId); + + this.log.info(`Workspace deleted for task ${taskId}`); + } + + private removeTaskAssociation(taskId: string): void { + const workspace = this.workspaceRepo.findByTaskId(taskId); + if (workspace) { + this.worktreeRepo.deleteByWorkspaceId(workspace.id); + } + this.workspaceRepo.deleteByTaskId(taskId); + } + + private async cleanupRepoWorktreeFolder(folderPath: string): Promise { + 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)) { + this.log.warn( + `Skipping cleanup of worktree folder: path matches project folder (${folderPath})`, + ); + return; + } + + if (!fs.existsSync(repoWorktreeFolderPath)) { + return; + } + + const allFolders = this.repositoryRepo.findAll(); + const otherFoldersWithSameName = allFolders.filter( + (f) => f.path !== folderPath && path.basename(f.path) === repoName, + ); + + if (otherFoldersWithSameName.length > 0) { + this.log.info( + `Skipping cleanup of worktree folder ${repoWorktreeFolderPath}: used by other folders: ${otherFoldersWithSameName.map((f) => f.path).join(", ")}`, + ); + return; + } + + try { + // Safety check 3: Only delete if empty (ignoring .DS_Store) + const files = fs.readdirSync(repoWorktreeFolderPath); + const validFiles = files.filter((f) => f !== ".DS_Store"); + + if (validFiles.length > 0) { + 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 }); + this.log.info(`Cleaned up worktree folder at ${repoWorktreeFolderPath}`); + } catch (error) { + this.log.warn( + `Failed to cleanup worktree folder at ${repoWorktreeFolderPath}:`, + error, + ); + } + } + + async verifyWorkspaceExists( + taskId: string, + ): Promise<{ exists: boolean; missingPath?: string }> { + const association = this.findTaskAssociation(taskId); + if (!association) { + return { exists: false }; + } + + if (association.mode === "cloud") { + return { exists: true }; + } + + const folderPath = this.getFolderPath(association.folderId); + if (!folderPath) { + this.removeTaskAssociation(taskId); + return { exists: false, missingPath: "(folder not found)" }; + } + + if (association.mode === "local") { + const exists = fs.existsSync(folderPath); + if (!exists) { + this.log.info( + `Folder for task ${taskId} no longer exists, removing association`, + ); + this.removeTaskAssociation(taskId); + return { exists: false, missingPath: folderPath }; + } + return { exists: true }; + } + + if (association.mode === "worktree") { + const worktreePath = this.deriveWorktreePath( + folderPath, + association.worktree, + ); + const exists = fs.existsSync(worktreePath); + if (!exists) { + this.log.info( + `Worktree for task ${taskId} no longer exists, removing association`, + ); + this.removeTaskAssociation(taskId); + return { exists: false, missingPath: worktreePath }; + } + return { exists: true }; + } + + return { exists: false }; + } + + async getWorkspace(taskId: string): Promise { + const assoc = this.findTaskAssociation(taskId); + if (!assoc) return null; + + const dbRow = this.workspaceRepo.findByTaskId(taskId); + const linkedBranch = dbRow?.linkedBranch ?? null; + + if (assoc.mode === "cloud") { + return { + taskId, + folderId: assoc.folderId ?? "", + folderPath: "", + mode: "cloud", + worktreePath: null, + worktreeName: null, + branchName: null, + baseBranch: null, + linkedBranch, + createdAt: new Date().toISOString(), + }; + } + + const folderPath = this.getFolderPath(assoc.folderId); + if (!folderPath) return null; + + let worktreePath: string | null = null; + let worktreeName: string | null = null; + let branchName: string | null = null; + + if (assoc.mode === "worktree") { + worktreeName = assoc.worktree; + worktreePath = this.deriveWorktreePath(folderPath, worktreeName); + const gitBranch = await getBranchFromPath(worktreePath); + branchName = gitBranch ?? assoc.branchName; + } else if (assoc.mode === "local") { + const localWorktreePath = + await this.getLocalWorktreePathIfExists(folderPath); + const branchPath = localWorktreePath ?? folderPath; + branchName = await getBranchFromPath(branchPath); + } + + return { + taskId, + folderId: assoc.folderId, + folderPath, + mode: assoc.mode, + worktreePath, + worktreeName, + branchName, + baseBranch: null, + linkedBranch, + createdAt: new Date().toISOString(), + }; + } + + async getWorkspaceInfo(taskId: string): Promise { + const association = this.findTaskAssociation(taskId); + if (!association) { + return null; + } + + const dbRow = this.workspaceRepo.findByTaskId(taskId); + + if (association.mode === "cloud") { + return { + taskId, + mode: "cloud", + worktree: null, + branchName: null, + linkedBranch: dbRow?.linkedBranch ?? null, + }; + } + + const folderPath = association.folderId + ? this.getFolderPath(association.folderId) + : null; + let worktreeInfo: WorktreeInfo | null = null; + let branchName: string | null = null; + + if (association.mode === "worktree") { + if (folderPath) { + const worktreePath = this.deriveWorktreePath( + folderPath, + association.worktree, + ); + const gitBranch = await getBranchFromPath(worktreePath); + branchName = gitBranch ?? association.branchName; + worktreeInfo = { + worktreePath, + worktreeName: association.worktree, + branchName, + baseBranch: "main", + createdAt: new Date().toISOString(), + }; + } + } else if (association.mode === "local" && folderPath) { + branchName = await getBranchFromPath(folderPath); + } + + return { + taskId, + mode: association.mode, + worktree: worktreeInfo, + branchName, + linkedBranch: dbRow?.linkedBranch ?? null, + }; + } + + async getAllWorkspaces(): Promise> { + const associations = this.getAllTaskAssociations(); + const dbRows = this.workspaceRepo.findAll(); + const linkedBranchByTaskId = new Map( + dbRows.map((row) => [row.taskId, row.linkedBranch ?? null]), + ); + const workspaces: Record = {}; + + for (const assoc of associations) { + if (assoc.mode === "cloud") { + workspaces[assoc.taskId] = { + taskId: assoc.taskId, + folderId: assoc.folderId ?? "", + folderPath: "", + mode: "cloud", + worktreePath: null, + worktreeName: null, + branchName: null, + baseBranch: null, + linkedBranch: linkedBranchByTaskId.get(assoc.taskId) ?? null, + createdAt: new Date().toISOString(), + }; + continue; + } + + const folderPath = this.getFolderPath(assoc.folderId); + if (!folderPath) continue; + + let worktreePath: string | null = null; + let worktreeName: string | null = null; + + if (assoc.mode === "worktree") { + worktreeName = assoc.worktree; + worktreePath = this.deriveWorktreePath(folderPath, worktreeName); + } + + let branchName: string | null = null; + if (assoc.mode === "worktree" && worktreePath) { + const gitBranch = await getBranchFromPath(worktreePath); + branchName = gitBranch ?? assoc.branchName; + } else if (assoc.mode === "local") { + const localWorktreePath = + await this.getLocalWorktreePathIfExists(folderPath); + const branchPath = localWorktreePath ?? folderPath; + branchName = await getBranchFromPath(branchPath); + } + + workspaces[assoc.taskId] = { + taskId: assoc.taskId, + folderId: assoc.folderId, + folderPath, + mode: assoc.mode, + worktreePath, + worktreeName, + branchName, + baseBranch: null, + linkedBranch: linkedBranchByTaskId.get(assoc.taskId) ?? null, + createdAt: new Date().toISOString(), + }; + } + + return workspaces; + } + + /** + * Promote a local-mode task to worktree mode on an existing branch. + * This is used when focusing on another workspace would disrupt a local-mode task. + * The task gets its own worktree so it can continue working undisturbed. + */ + async promoteToWorktree( + taskId: string, + mainRepoPath: string, + branch: string, + ): Promise { + this.log.info( + `Promoting task ${taskId} to worktree mode on branch ${branch}`, + ); + + const association = this.findTaskAssociation(taskId); + if (!association) { + this.log.warn(`No association found for task ${taskId}`); + return null; + } + + if (association.mode !== "local") { + this.log.warn(`Task ${taskId} is not in local mode, cannot promote`); + return null; + } + + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); + const worktreeManager = new WorktreeManager({ + mainRepoPath, + worktreeBasePath, + }); + + let worktree: WorktreeInfo; + try { + const currentBranch = await getCurrentBranch(mainRepoPath); + if (currentBranch === branch) { + this.log.info( + `Main repo is on target branch ${branch}, detaching before creating worktree`, + ); + const detachSaga = new DetachHeadSaga(); + const detachResult = await detachSaga.run({ baseDir: mainRepoPath }); + if (!detachResult.success) { + throw new Error(`Failed to detach HEAD: ${detachResult.error}`); + } + } + + worktree = await worktreeManager.createWorktreeForExistingBranch(branch); + this.log.info( + `Created worktree for promoted task: ${worktree.worktreeName} at ${worktree.worktreePath}`, + ); + } catch (error) { + this.log.error( + `Failed to create worktree for promoted task ${taskId}:`, + error, + ); + throw new Error(`Failed to promote task to worktree: ${String(error)}`); + } + + const workspace = this.workspaceRepo.findByTaskId(taskId); + if (workspace) { + this.workspaceRepo.updateMode(taskId, "worktree"); + this.worktreeRepo.create({ + workspaceId: workspace.id, + name: worktree.worktreeName, + path: worktree.worktreePath, + }); + this.log.info(`Updated task ${taskId} association to worktree mode`); + } + + this.emit(WorkspaceServiceEvent.Promoted, { + taskId, + worktree, + fromBranch: branch, + }); + + return worktree; + } + + getLocalTasksForFolder(folderPath: string): Array<{ taskId: string }> { + const associations = this.getAllTaskAssociations(); + const folder = this.repositoryRepo.findByPath(folderPath); + if (!folder) return []; + + return associations + .filter((a) => a.mode === "local" && a.folderId === folder.id) + .map((a) => ({ taskId: a.taskId })); + } + + getWorktreeTasks(worktreePath: string): Array<{ taskId: string }> { + const associations = this.getAllTaskAssociations(); + const result: Array<{ taskId: string }> = []; + + for (const assoc of associations) { + if (assoc.mode !== "worktree") continue; + const folderPath = this.getFolderPath(assoc.folderId); + if (!folderPath) continue; + const derivedPath = this.deriveWorktreePath(folderPath, assoc.worktree); + if (derivedPath === worktreePath) { + result.push({ taskId: assoc.taskId }); + } + } + + return result; + } + + async listGitWorktrees(mainRepoPath: string): Promise< + Array<{ + worktreePath: string; + head: string; + branch: string | null; + taskIds: string[]; + }> + > { + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); + const twigWorktrees = await listTwigWorktrees( + mainRepoPath, + worktreeBasePath, + ); + + return twigWorktrees.map((wt) => ({ + worktreePath: wt.worktreePath, + head: wt.head, + branch: wt.branch, + taskIds: this.getWorktreeTasks(wt.worktreePath).map((t) => t.taskId), + })); + } + + async deleteWorktree( + mainRepoPath: string, + worktreePath: string, + ): Promise { + const worktree = this.worktreeRepo.findByPath(worktreePath); + if (worktree) { + const workspace = this.workspaceRepo.findById(worktree.workspaceId); + if (workspace) { + await this.deleteWorkspace(workspace.taskId, mainRepoPath); + return; + } + } + + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); + await deleteGitWorktree(mainRepoPath, worktreeBasePath, worktreePath); + + if (worktree) { + this.worktreeRepo.deleteByWorkspaceId(worktree.workspaceId); + } + } + + private async cleanupWorktree( + taskId: string, + mainRepoPath: string, + worktreePath: string, + branchName: string | null, + ): Promise { + try { + await this.fileWatcher.stopWatching(worktreePath); + } catch (error) { + this.log.warn( + `Failed to stop file watcher for worktree ${worktreePath}:`, + error, + ); + } + + try { + const worktreeBasePath = this.workspaceSettings.getWorktreeLocation(); + await deleteGitWorktree(mainRepoPath, worktreeBasePath, worktreePath); + } catch (error) { + this.log.error(`Failed to delete worktree for task ${taskId}:`, error); + } + + if (branchName) { + try { + const git = createGitClient(mainRepoPath); + await git.deleteLocalBranch(branchName, true); + this.log.info(`Deleted branch ${branchName} for task ${taskId}`); + } catch (error) { + this.log.warn( + `Failed to delete branch ${branchName} for task ${taskId}:`, + error, + ); + } + } + } + + private emitWorkspaceError(taskId: string, message: string): void { + this.emit(WorkspaceServiceEvent.Error, { taskId, message }); + } + + private emitWorkspaceWarning( + taskId: string, + title: string, + message: string, + ): void { + this.emit(WorkspaceServiceEvent.Warning, { taskId, title, message }); + } +} 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/packages/workspace-server/src/workspace-env.ts b/packages/workspace-server/src/workspace-env.ts new file mode 100644 index 0000000000..c017dafd77 --- /dev/null +++ b/packages/workspace-server/src/workspace-env.ts @@ -0,0 +1,74 @@ +import path from "node:path"; +import { getCurrentBranch, getDefaultBranch } from "@posthog/git/queries"; +import type { WorkspaceMode } from "@posthog/shared"; + +export interface WorkspaceEnvContext { + taskId: string; + folderPath: string; + worktreePath: string | null; + worktreeName: string | null; + mode: WorkspaceMode; +} + +const PORT_BASE = 50000; +const PORTS_PER_WORKSPACE = 20; +const MAX_WORKSPACES = 1000; + +function hashTaskId(taskId: string): number { + let hash = 0; + for (let i = 0; i < taskId.length; i++) { + const char = taskId.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash); +} + +function allocateWorkspacePorts(taskId: string): { + start: number; + end: number; + ports: number[]; +} { + const workspaceIndex = hashTaskId(taskId) % MAX_WORKSPACES; + const start = PORT_BASE + workspaceIndex * PORTS_PER_WORKSPACE; + const end = start + PORTS_PER_WORKSPACE - 1; + + const ports: number[] = []; + for (let port = start; port <= end; port++) { + ports.push(port); + } + + return { start, end, ports }; +} + +export async function buildWorkspaceEnv( + context: WorkspaceEnvContext, +): Promise> { + if (context.mode === "cloud") { + return {}; + } + + const workspaceName = + context.worktreeName ?? path.basename(context.folderPath); + const workspacePath = context.worktreePath ?? context.folderPath; + const rootPath = context.folderPath; + + const defaultBranch = await getDefaultBranch(rootPath); + + const workspaceBranch = (await getCurrentBranch(workspacePath)) ?? ""; + + const portAllocation = allocateWorkspacePorts(context.taskId); + + return { + POSTHOG_CODE: "1", + POSTHOG_CODE_WORKSPACE_NAME: workspaceName, + POSTHOG_CODE_WORKSPACE_PATH: workspacePath, + POSTHOG_CODE_ROOT_PATH: rootPath, + POSTHOG_CODE_DEFAULT_BRANCH: defaultBranch, + POSTHOG_CODE_WORKSPACE_BRANCH: workspaceBranch, + POSTHOG_CODE_WORKSPACE_PORTS: portAllocation.ports.join(","), + POSTHOG_CODE_WORKSPACE_PORTS_RANGE: String(PORTS_PER_WORKSPACE), + POSTHOG_CODE_WORKSPACE_PORTS_START: String(portAllocation.start), + POSTHOG_CODE_WORKSPACE_PORTS_END: String(portAllocation.end), + }; +} 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..f1b54f0589 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: @@ -235,6 +232,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 +247,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 @@ -885,6 +891,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 +911,80 @@ importers: packages/core: dependencies: + '@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 +1021,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 +1063,59 @@ 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-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 +1136,216 @@ 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) + '@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) + '@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-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) + '@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 + 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) + 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-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 + 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) + tippy.js: + specifier: ^6.3.7 + version: 6.3.7 + 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 +1362,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 +1398,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 +1435,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 +1450,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 +1489,37 @@ 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 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,12 +3269,6 @@ 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: @@ -14716,6 +15095,7 @@ snapshots: '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 + optional: true '@floating-ui/core@1.7.5': dependencies: @@ -14725,25 +15105,21 @@ snapshots: dependencies: '@floating-ui/core': 1.7.4 '@floating-ui/utils': 0.2.10 + 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.10': + optional: true '@floating-ui/utils@0.2.11': {} @@ -14777,6 +15153,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 +15175,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 +15302,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 @@ -16434,7 +16837,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) @@ -17962,6 +18365,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 +18416,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 +18463,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 +18481,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 +18500,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 +18578,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: @@ -22451,6 +22884,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) @@ -25310,6 +25769,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 +25820,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 +25949,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 +26007,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: diff --git a/scripts/check-host-boundaries.mjs b/scripts/check-host-boundaries.mjs new file mode 100644 index 0000000000..5837ef7040 --- /dev/null +++ b/scripts/check-host-boundaries.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { join, dirname } 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..0cdddb9777 --- /dev/null +++ b/scripts/host-boundary-allowlist.json @@ -0,0 +1,77 @@ +{ + "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/git/git-pr-host.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/main/trpc/routers/logs.ts": [ + "router-with-logic" + ], + "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/auth/components/AuthScreen.tsx": [ + "feature-ui-in-host" + ], + "apps/code/src/renderer/features/auth/components/InviteCodeScreen.tsx": [ + "feature-ui-in-host" + ], + "apps/code/src/renderer/features/auth/components/SignInCard.tsx": [ + "feature-ui-in-host" + ], + "apps/code/src/renderer/features/auth/hooks/authClient.ts": [ + "cloud-client-in-renderer" + ], + "apps/code/src/renderer/features/code-review/reviewHost.tsx": [ + "feature-ui-in-host" + ], + "apps/code/src/renderer/features/mcp-apps/components/McpAppHost.tsx": [ + "feature-ui-in-host" + ], + "apps/code/src/renderer/features/message-editor/components/PromptInput.stories.tsx": [ + "feature-ui-in-host" + ], + "apps/code/src/renderer/features/sessions/components/session-update/McpToolBlock.tsx": [ + "feature-ui-in-host" + ], + "apps/code/src/renderer/platform-adapters/billing-client.ts": [ + "cloud-client-in-renderer" + ], + "apps/code/src/renderer/platform-adapters/git-interaction.ts": [ + "cloud-client-in-renderer" + ], + "apps/code/src/renderer/platform-adapters/github-connect-client.ts": [ + "cloud-client-in-renderer" + ], + "apps/code/src/renderer/platform-adapters/integrations-client.ts": [ + "cloud-client-in-renderer" + ], + "apps/code/src/renderer/platform-adapters/setup-run-service.ts": [ + "cloud-client-in-renderer" + ], + "apps/code/src/renderer/platform-adapters/task-creation-host.ts": [ + "cloud-client-in-renderer" + ] + } +} 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