From e720e34dd119d0e33334573cc0fc369496bad4d1 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Mon, 1 Jun 2026 11:37:16 +0100 Subject: [PATCH 1/8] feat(mobile): highlight and surface current user in suggested reviewers (port #2435) Ports desktop PR #2435 to the mobile inbox report detail. - Wire the current user's uuid (via useUserQuery) into SuggestedReviewers so the existing isMe highlight shows. - Move the logged-in user to the front of the suggested-reviewers list when present and not already first, via a pure orderSuggestedReviewers util. - Add unit tests covering reorder, already-first no-op, absent user, and missing uuid. Generated-By: PostHog Code Task-Id: 6570d0da-5363-4355-94b2-4576776d02aa --- apps/mobile/src/app/inbox/[id].tsx | 17 +++++-- apps/mobile/src/features/inbox/utils.test.ts | 50 ++++++++++++++++++++ apps/mobile/src/features/inbox/utils.ts | 11 +++++ 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 apps/mobile/src/features/inbox/utils.test.ts diff --git a/apps/mobile/src/app/inbox/[id].tsx b/apps/mobile/src/app/inbox/[id].tsx index 05808e6277..e95bee4e6e 100644 --- a/apps/mobile/src/app/inbox/[id].tsx +++ b/apps/mobile/src/app/inbox/[id].tsx @@ -16,6 +16,7 @@ import { usePostHog } from "posthog-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useUserQuery } from "@/features/auth/hooks/useUserQuery"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet"; @@ -34,7 +35,10 @@ import type { SignalReportStatus, SuggestedReviewer, } from "@/features/inbox/types"; -import { inboxStatusLabel } from "@/features/inbox/utils"; +import { + inboxStatusLabel, + orderSuggestedReviewers, +} from "@/features/inbox/utils"; import { useThemeColors } from "@/lib/theme"; const statusColorMap: Record = { @@ -114,6 +118,7 @@ export default function ReportDetailScreen() { const insets = useSafeAreaInsets(); const posthog = usePostHog(); const { data: report, isLoading, error } = useInboxReport(reportId ?? null); + const { data: user } = useUserQuery(); const [reportRepo, setReportRepo] = useState(null); const [dismissOpen, setDismissOpen] = useState(false); const [discussOpen, setDiscussOpen] = useState(false); @@ -151,11 +156,12 @@ export default function ReportDetailScreen() { const suggestedReviewers = useMemo((): SuggestedReviewer[] => { for (const a of artefacts) { if (a.type === "suggested_reviewers") { - return (a.content as SuggestedReviewer[]) ?? []; + const reviewers = (a.content as SuggestedReviewer[]) ?? []; + return orderSuggestedReviewers(reviewers, user?.uuid); } } return []; - }, [artefacts]); + }, [artefacts, user?.uuid]); const findingsBySignalId = useMemo(() => { const map = new Map(); @@ -358,7 +364,10 @@ export default function ReportDetailScreen() { )} {/* Suggested reviewers */} - + {/* Signals */} {signals.length > 0 && ( diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts new file mode 100644 index 0000000000..9836d11ae5 --- /dev/null +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { SuggestedReviewer } from "./types"; +import { orderSuggestedReviewers } from "./utils"; + +function reviewer(login: string, uuid?: string): SuggestedReviewer { + return { + github_login: login, + github_name: login, + relevant_commits: [], + user: uuid + ? { + id: 1, + uuid, + email: `${login}@posthog.com`, + first_name: login, + last_name: "", + } + : null, + }; +} + +describe("orderSuggestedReviewers", () => { + it("moves the current user to the front", () => { + const reviewers = [ + reviewer("a", "uuid-a"), + reviewer("me", "uuid-me"), + reviewer("c", "uuid-c"), + ]; + const ordered = orderSuggestedReviewers(reviewers, "uuid-me"); + expect(ordered.map((r) => r.github_login)).toEqual(["me", "a", "c"]); + }); + + it("is a no-op when the current user is already first", () => { + const reviewers = [reviewer("me", "uuid-me"), reviewer("a", "uuid-a")]; + const ordered = orderSuggestedReviewers(reviewers, "uuid-me"); + expect(ordered).toBe(reviewers); + }); + + it("is a no-op when the current user is absent", () => { + const reviewers = [reviewer("a", "uuid-a"), reviewer("b", "uuid-b")]; + const ordered = orderSuggestedReviewers(reviewers, "uuid-me"); + expect(ordered).toBe(reviewers); + }); + + it("is a no-op when meUuid is missing", () => { + const reviewers = [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")]; + expect(orderSuggestedReviewers(reviewers, null)).toBe(reviewers); + expect(orderSuggestedReviewers(reviewers, undefined)).toBe(reviewers); + }); +}); diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index 588978b49f..41ee173117 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -2,6 +2,7 @@ import type { SignalReport, SignalReportOrderingField, SignalReportStatus, + SuggestedReviewer, } from "./types"; export function inboxStatusLabel(status: SignalReportStatus): string { @@ -87,3 +88,13 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] { !r.already_addressed, ); } + +export function orderSuggestedReviewers( + reviewers: SuggestedReviewer[], + meUuid: string | null | undefined, +): SuggestedReviewer[] { + if (!meUuid) return reviewers; + const meIndex = reviewers.findIndex((r) => r.user?.uuid === meUuid); + if (meIndex <= 0) return reviewers; + return [reviewers[meIndex], ...reviewers.filter((_, i) => i !== meIndex)]; +} From d37169710926cb433cef9d8c638c4f441f6ec866 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Mon, 1 Jun 2026 11:53:09 +0100 Subject: [PATCH 2/8] test(mobile): parameterise suggested-reviewer no-op cases with it.each Generated-By: PostHog Code Task-Id: 6570d0da-5363-4355-94b2-4576776d02aa --- apps/mobile/src/features/inbox/utils.test.ts | 39 ++++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index 9836d11ae5..a67748efac 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -30,21 +30,28 @@ describe("orderSuggestedReviewers", () => { expect(ordered.map((r) => r.github_login)).toEqual(["me", "a", "c"]); }); - it("is a no-op when the current user is already first", () => { - const reviewers = [reviewer("me", "uuid-me"), reviewer("a", "uuid-a")]; - const ordered = orderSuggestedReviewers(reviewers, "uuid-me"); - expect(ordered).toBe(reviewers); - }); - - it("is a no-op when the current user is absent", () => { - const reviewers = [reviewer("a", "uuid-a"), reviewer("b", "uuid-b")]; - const ordered = orderSuggestedReviewers(reviewers, "uuid-me"); - expect(ordered).toBe(reviewers); - }); - - it("is a no-op when meUuid is missing", () => { - const reviewers = [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")]; - expect(orderSuggestedReviewers(reviewers, null)).toBe(reviewers); - expect(orderSuggestedReviewers(reviewers, undefined)).toBe(reviewers); + it.each([ + { + label: "already first", + reviewers: [reviewer("me", "uuid-me"), reviewer("a", "uuid-a")], + meUuid: "uuid-me" as string | null | undefined, + }, + { + label: "absent", + reviewers: [reviewer("a", "uuid-a"), reviewer("b", "uuid-b")], + meUuid: "uuid-me" as string | null | undefined, + }, + { + label: "null meUuid", + reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], + meUuid: null as string | null | undefined, + }, + { + label: "undefined meUuid", + reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], + meUuid: undefined as string | null | undefined, + }, + ])("is a no-op when $label", ({ reviewers, meUuid }) => { + expect(orderSuggestedReviewers(reviewers, meUuid)).toBe(reviewers); }); }); From ffff6266e4f8dce5a285972b6562f3f016e34389 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 2 Jun 2026 14:53:21 +0100 Subject: [PATCH 3/8] fix(mobile): rename inbox route to match base branch catch-all rename Resolves the modify/rename conflict with main, which renamed inbox/[id].tsx to inbox/[...id].tsx. Generated-By: PostHog Code Task-Id: f65eb1e6-78ba-4c40-8077-b4cd78485c93 --- apps/mobile/src/app/inbox/{[id].tsx => [...id].tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/mobile/src/app/inbox/{[id].tsx => [...id].tsx} (100%) diff --git a/apps/mobile/src/app/inbox/[id].tsx b/apps/mobile/src/app/inbox/[...id].tsx similarity index 100% rename from apps/mobile/src/app/inbox/[id].tsx rename to apps/mobile/src/app/inbox/[...id].tsx From ff52fad9e1f0ea0c060ef4eb9c2b537e22b82eb3 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 2 Jun 2026 14:54:17 +0100 Subject: [PATCH 4/8] chore(mobile): align inbox route file with base before merge Temporarily matches main's inbox/[...id].tsx so the base branch merges cleanly; PR feature edits are re-applied after the merge. Generated-By: PostHog Code Task-Id: f65eb1e6-78ba-4c40-8077-b4cd78485c93 --- apps/mobile/src/app/inbox/[...id].tsx | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index e95bee4e6e..5da1282c78 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -16,7 +16,6 @@ import { usePostHog } from "posthog-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useUserQuery } from "@/features/auth/hooks/useUserQuery"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet"; @@ -35,10 +34,7 @@ import type { SignalReportStatus, SuggestedReviewer, } from "@/features/inbox/types"; -import { - inboxStatusLabel, - orderSuggestedReviewers, -} from "@/features/inbox/utils"; +import { inboxStatusLabel } from "@/features/inbox/utils"; import { useThemeColors } from "@/lib/theme"; const statusColorMap: Record = { @@ -112,13 +108,18 @@ function ActionabilityBadge({ value }: { value: string }) { } export default function ReportDetailScreen() { - const { id: reportId } = useLocalSearchParams<{ id: string }>(); + // Catch-all route: `id` arrives as string[] for `/inbox//` and + // we only read the first segment (the UUID). The slug is purely cosmetic; + // receivers ignore everything past the UUID, matching the desktop contract + // in `apps/code/src/shared/deeplink.ts`. Expo-router can hand us either a + // string or string[] depending on the URL shape, so tolerate both. + const { id: idParam } = useLocalSearchParams<{ id: string | string[] }>(); + const reportId = Array.isArray(idParam) ? idParam[0] : idParam; const router = useRouter(); const themeColors = useThemeColors(); const insets = useSafeAreaInsets(); const posthog = usePostHog(); const { data: report, isLoading, error } = useInboxReport(reportId ?? null); - const { data: user } = useUserQuery(); const [reportRepo, setReportRepo] = useState(null); const [dismissOpen, setDismissOpen] = useState(false); const [discussOpen, setDiscussOpen] = useState(false); @@ -156,12 +157,11 @@ export default function ReportDetailScreen() { const suggestedReviewers = useMemo((): SuggestedReviewer[] => { for (const a of artefacts) { if (a.type === "suggested_reviewers") { - const reviewers = (a.content as SuggestedReviewer[]) ?? []; - return orderSuggestedReviewers(reviewers, user?.uuid); + return (a.content as SuggestedReviewer[]) ?? []; } } return []; - }, [artefacts, user?.uuid]); + }, [artefacts]); const findingsBySignalId = useMemo(() => { const map = new Map(); @@ -364,10 +364,7 @@ export default function ReportDetailScreen() { )} {/* Suggested reviewers */} - + {/* Signals */} {signals.length > 0 && ( @@ -464,6 +461,7 @@ export default function ReportDetailScreen() { setDiscussOpen(false)} onSubmit={handleDiscussSubmit} /> From 7e455e65816840d7c9dc6457ccae917e7c1d058b Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Tue, 2 Jun 2026 14:54:43 +0100 Subject: [PATCH 5/8] feat(mobile): re-apply suggested-reviewer ordering after base merge Re-applies the current-user highlighting/ordering edits to inbox/[...id].tsx on top of the merged base. Generated-By: PostHog Code Task-Id: f65eb1e6-78ba-4c40-8077-b4cd78485c93 --- apps/mobile/src/app/inbox/[...id].tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index 5da1282c78..d68ae5b0ce 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -16,6 +16,7 @@ import { usePostHog } from "posthog-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useUserQuery } from "@/features/auth/hooks/useUserQuery"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet"; @@ -34,7 +35,10 @@ import type { SignalReportStatus, SuggestedReviewer, } from "@/features/inbox/types"; -import { inboxStatusLabel } from "@/features/inbox/utils"; +import { + inboxStatusLabel, + orderSuggestedReviewers, +} from "@/features/inbox/utils"; import { useThemeColors } from "@/lib/theme"; const statusColorMap: Record = { @@ -120,6 +124,7 @@ export default function ReportDetailScreen() { const insets = useSafeAreaInsets(); const posthog = usePostHog(); const { data: report, isLoading, error } = useInboxReport(reportId ?? null); + const { data: user } = useUserQuery(); const [reportRepo, setReportRepo] = useState(null); const [dismissOpen, setDismissOpen] = useState(false); const [discussOpen, setDiscussOpen] = useState(false); @@ -157,11 +162,12 @@ export default function ReportDetailScreen() { const suggestedReviewers = useMemo((): SuggestedReviewer[] => { for (const a of artefacts) { if (a.type === "suggested_reviewers") { - return (a.content as SuggestedReviewer[]) ?? []; + const reviewers = (a.content as SuggestedReviewer[]) ?? []; + return orderSuggestedReviewers(reviewers, user?.uuid); } } return []; - }, [artefacts]); + }, [artefacts, user?.uuid]); const findingsBySignalId = useMemo(() => { const map = new Map(); @@ -364,7 +370,10 @@ export default function ReportDetailScreen() { )} {/* Suggested reviewers */} - + {/* Signals */} {signals.length > 0 && ( From 60f3f69ce3c4b855c7de104c42aa1f77638d0d8e Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 3 Jun 2026 19:24:23 +0100 Subject: [PATCH 6/8] Merge origin/main into posthog-code/mobile-highlight-current-reviewer Resolve conflicts in mobile inbox utils, utils.test, and the inbox detail route by keeping both the suggested-reviewer ordering (this PR) and the inbox-viewed analytics additions from main. Generated-By: PostHog Code Task-Id: 29cd7721-2158-4246-ba1b-c73bf4611ebc --- .../db/migrations/0007_futuristic_domino.sql | 11 + .../main/db/migrations/0008_stiff_reptil.sql | 2 + .../db/migrations/meta/0007_snapshot.json | 626 +++++++++++++++++ .../db/migrations/meta/0008_snapshot.json | 640 ++++++++++++++++++ .../src/main/db/migrations/meta/_journal.json | 14 + .../auth-preference-repository.mock.ts | 52 ++ .../auth-preference-repository.ts | 90 ++- .../repositories/workspace-repository.mock.ts | 15 + .../db/repositories/workspace-repository.ts | 19 + apps/code/src/main/db/schema.ts | 24 + apps/code/src/main/services/auth/schemas.ts | 43 +- .../src/main/services/auth/service.test.ts | 244 ++++++- apps/code/src/main/services/auth/service.ts | 366 ++++++++-- .../findStaleFlagSuggestions.test.ts | 2 +- .../src/main/services/enrichment/service.ts | 4 +- .../src/main/services/git/service.test.ts | 8 + apps/code/src/main/services/git/service.ts | 130 +++- apps/code/src/main/services/oauth/schemas.ts | 1 - .../src/main/services/shell/service.test.ts | 14 + apps/code/src/main/services/shell/service.ts | 5 + .../src/main/services/workspace/schemas.ts | 17 + .../src/main/services/workspace/service.ts | 3 + apps/code/src/main/trpc/routers/auth.ts | 6 + apps/code/src/main/trpc/routers/workspace.ts | 8 + apps/code/src/renderer/App.tsx | 35 + apps/code/src/renderer/api/posthogClient.ts | 17 +- .../components/ScopeReauthPrompt.test.tsx | 11 +- .../components/AiApprovalScreen.test.tsx | 104 +++ .../components/AiApprovalScreen.tsx | 66 +- .../features/auth/hooks/authClient.ts | 13 +- .../features/auth/hooks/authMutations.ts | 15 +- .../features/auth/hooks/authQueries.ts | 8 +- .../features/auth/hooks/useAuthSession.ts | 11 +- .../features/auth/stores/authStore.test.ts | 43 +- .../features/auth/stores/authStore.ts | 64 +- .../components/EnrichmentPopover.tsx | 2 +- .../components/CommandCenterToolbar.tsx | 38 +- .../components/CommandCenterView.tsx | 2 +- .../hooks/useAutofillCommandCenter.test.ts | 37 + .../hooks/useAutofillCommandCenter.ts | 23 +- .../stores/commandCenterStore.test.ts | 23 + .../stores/commandCenterStore.ts | 13 +- .../git-interaction/hooks/useTaskPrUrl.ts | 13 +- .../inbox/components/DataSourceSetup.tsx | 8 +- .../inbox/components/detail/SignalCard.tsx | 4 +- .../list/GitHubConnectionBanner.tsx | 2 +- .../features/inbox/hooks/useEvaluations.ts | 2 +- .../inbox/hooks/useExternalDataSources.ts | 2 +- .../inbox/hooks/useSignalSourceConfigs.ts | 2 +- .../inbox/hooks/useSignalSourceManager.ts | 2 +- .../integrations/hooks/useSlackConnect.ts | 2 +- .../components/GitHubConnectPanel.tsx | 2 +- .../components/ProjectSelectStep.tsx | 26 +- .../hooks/useProjectsWithIntegrations.ts | 8 +- .../features/projects/hooks/useProjects.tsx | 135 ++-- .../sessions/hooks/useSessionConnection.ts | 6 +- .../service.recovery.integration.test.ts | 22 +- .../features/sessions/service/service.test.ts | 50 +- .../features/sessions/service/service.ts | 10 +- .../components/sections/GeneralSettings.tsx | 2 +- .../sections/GitHubIntegrationSection.tsx | 2 +- .../components/sections/GitHubSettings.tsx | 4 +- .../components/sections/PlanUsageSettings.tsx | 79 ++- .../components/sections/SlackSettings.tsx | 2 +- .../setup/services/setupRunService.ts | 2 +- .../sidebar/components/ProjectSwitcher.tsx | 11 +- .../components/CloudGithubMissingNotice.tsx | 2 +- .../terminal/services/TerminalManager.test.ts | 175 +++++ .../terminal/services/TerminalManager.ts | 83 ++- .../terminal/stores/terminalStore.test.ts | 72 ++ .../features/terminal/stores/terminalStore.ts | 44 +- .../src/renderer/hooks/useProjectQuery.ts | 2 +- apps/code/src/renderer/utils/posthogLinks.ts | 2 +- apps/code/src/shared/constants/oauth.test.ts | 2 +- apps/code/src/shared/constants/oauth.ts | 2 +- apps/code/src/shared/types/analytics.ts | 2 + apps/mobile/src/app/(tabs)/inbox.tsx | 71 +- apps/mobile/src/app/inbox/[...id].tsx | 125 +++- apps/mobile/src/app/task/[id].tsx | 6 + .../inbox/components/DismissReportSheet.tsx | 21 +- .../features/inbox/components/TinderView.tsx | 56 +- .../hooks/useInboxEngagementTracker.test.ts | 273 ++++++++ .../inbox/hooks/useInboxEngagementTracker.ts | 183 +++++ .../features/inbox/stores/inboxFilterStore.ts | 2 +- .../src/features/inbox/stores/inboxStore.ts | 16 + apps/mobile/src/features/inbox/utils.test.ts | 142 +++- apps/mobile/src/features/inbox/utils.ts | 87 +++ apps/mobile/src/features/tasks/types.ts | 2 + apps/mobile/src/lib/analytics.test.ts | 96 +++ apps/mobile/src/lib/analytics.ts | 198 ++++++ .../agent/src/server/agent-server.test.ts | 53 ++ packages/agent/src/server/agent-server.ts | 17 +- 92 files changed, 4533 insertions(+), 468 deletions(-) create mode 100644 apps/code/src/main/db/migrations/0007_futuristic_domino.sql create mode 100644 apps/code/src/main/db/migrations/0008_stiff_reptil.sql create mode 100644 apps/code/src/main/db/migrations/meta/0007_snapshot.json create mode 100644 apps/code/src/main/db/migrations/meta/0008_snapshot.json create mode 100644 apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx create mode 100644 apps/code/src/renderer/features/terminal/services/TerminalManager.test.ts create mode 100644 apps/code/src/renderer/features/terminal/stores/terminalStore.test.ts create mode 100644 apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts create mode 100644 apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts create mode 100644 apps/mobile/src/lib/analytics.test.ts create mode 100644 apps/mobile/src/lib/analytics.ts diff --git a/apps/code/src/main/db/migrations/0007_futuristic_domino.sql b/apps/code/src/main/db/migrations/0007_futuristic_domino.sql new file mode 100644 index 0000000000..034625c596 --- /dev/null +++ b/apps/code/src/main/db/migrations/0007_futuristic_domino.sql @@ -0,0 +1,11 @@ +CREATE TABLE `auth_org_project_preferences` ( + `account_key` text NOT NULL, + `cloud_region` text NOT NULL, + `org_id` text NOT NULL, + `last_selected_project_id` integer NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `auth_org_project_account_region_org_idx` ON `auth_org_project_preferences` (`account_key`,`cloud_region`,`org_id`);--> statement-breakpoint +ALTER TABLE `auth_preferences` ADD `last_selected_org_id` text; \ No newline at end of file diff --git a/apps/code/src/main/db/migrations/0008_stiff_reptil.sql b/apps/code/src/main/db/migrations/0008_stiff_reptil.sql new file mode 100644 index 0000000000..d43fdaeee9 --- /dev/null +++ b/apps/code/src/main/db/migrations/0008_stiff_reptil.sql @@ -0,0 +1,2 @@ +ALTER TABLE `workspaces` ADD `pr_url` text;--> statement-breakpoint +ALTER TABLE `workspaces` ADD `pr_state` text; diff --git a/apps/code/src/main/db/migrations/meta/0007_snapshot.json b/apps/code/src/main/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000000..84f42e0b98 --- /dev/null +++ b/apps/code/src/main/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,626 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cbfce23d-5f7a-4245-8e3b-5eee05597c8b", + "prevId": "805d2ed3-331d-4ba6-8379-30f926268064", + "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_org_project_preferences": { + "name": "auth_org_project_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 + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "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": { + "auth_org_project_account_region_org_idx": { + "name": "auth_org_project_account_region_org_idx", + "columns": ["account_key", "cloud_region", "org_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "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 + }, + "last_selected_org_id": { + "name": "last_selected_org_id", + "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": { + "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/apps/code/src/main/db/migrations/meta/0008_snapshot.json b/apps/code/src/main/db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000000..8cb8a527e5 --- /dev/null +++ b/apps/code/src/main/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,640 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "59b807ad-4cd5-4587-9b2b-a559039e97bb", + "prevId": "cbfce23d-5f7a-4245-8e3b-5eee05597c8b", + "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_org_project_preferences": { + "name": "auth_org_project_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 + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "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": { + "auth_org_project_account_region_org_idx": { + "name": "auth_org_project_account_region_org_idx", + "columns": ["account_key", "cloud_region", "org_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "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 + }, + "last_selected_org_id": { + "name": "last_selected_org_id", + "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": { + "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)" + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_state": { + "name": "pr_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json index 98745d4e45..23865da752 100644 --- a/apps/code/src/main/db/migrations/meta/_journal.json +++ b/apps/code/src/main/db/migrations/meta/_journal.json @@ -50,6 +50,20 @@ "when": 1777639303535, "tag": "0006_youthful_warstar", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1779747695687, + "tag": "0007_futuristic_domino", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1779747695688, + "tag": "0008_stiff_reptil", + "breakpoints": true } ] } diff --git a/apps/code/src/main/db/repositories/auth-preference-repository.mock.ts b/apps/code/src/main/db/repositories/auth-preference-repository.mock.ts index ae99875b68..146df33149 100644 --- a/apps/code/src/main/db/repositories/auth-preference-repository.mock.ts +++ b/apps/code/src/main/db/repositories/auth-preference-repository.mock.ts @@ -1,18 +1,25 @@ import type { + AuthOrgProjectPreference, AuthPreference, IAuthPreferenceRepository, + PersistAuthOrgProjectPreferenceInput, PersistAuthPreferenceInput, } from "./auth-preference-repository"; export interface MockAuthPreferenceRepository extends IAuthPreferenceRepository { _preferences: AuthPreference[]; + _orgProjectPreferences: AuthOrgProjectPreference[]; } export function createMockAuthPreferenceRepository(): MockAuthPreferenceRepository { let preferences: AuthPreference[] = []; + let orgProjectPreferences: AuthOrgProjectPreference[] = []; const clone = (value: AuthPreference): AuthPreference => ({ ...value }); + const cloneOrgProject = ( + value: AuthOrgProjectPreference, + ): AuthOrgProjectPreference => ({ ...value }); return { get _preferences() { @@ -21,6 +28,12 @@ export function createMockAuthPreferenceRepository(): MockAuthPreferenceReposito set _preferences(value) { preferences = value.map(clone); }, + get _orgProjectPreferences() { + return orgProjectPreferences.map(cloneOrgProject); + }, + set _orgProjectPreferences(value) { + orgProjectPreferences = value.map(cloneOrgProject); + }, get: (accountKey, cloudRegion) => { const preference = preferences.find( (entry) => @@ -40,6 +53,7 @@ export function createMockAuthPreferenceRepository(): MockAuthPreferenceReposito accountKey: input.accountKey, cloudRegion: input.cloudRegion, lastSelectedProjectId: input.lastSelectedProjectId, + lastSelectedOrgId: input.lastSelectedOrgId, createdAt: existingIndex >= 0 ? preferences[existingIndex].createdAt : timestamp, updatedAt: timestamp, @@ -53,5 +67,43 @@ export function createMockAuthPreferenceRepository(): MockAuthPreferenceReposito return clone(row); }, + getOrgProject: (accountKey, cloudRegion, orgId) => { + const preference = orgProjectPreferences.find( + (entry) => + entry.accountKey === accountKey && + entry.cloudRegion === cloudRegion && + entry.orgId === orgId, + ); + return preference ? cloneOrgProject(preference) : null; + }, + saveOrgProject: (input: PersistAuthOrgProjectPreferenceInput) => { + const timestamp = new Date().toISOString(); + const existingIndex = orgProjectPreferences.findIndex( + (entry) => + entry.accountKey === input.accountKey && + entry.cloudRegion === input.cloudRegion && + entry.orgId === input.orgId, + ); + + const row: AuthOrgProjectPreference = { + accountKey: input.accountKey, + cloudRegion: input.cloudRegion, + orgId: input.orgId, + lastSelectedProjectId: input.lastSelectedProjectId, + createdAt: + existingIndex >= 0 + ? orgProjectPreferences[existingIndex].createdAt + : timestamp, + updatedAt: timestamp, + }; + + if (existingIndex >= 0) { + orgProjectPreferences[existingIndex] = row; + } else { + orgProjectPreferences.push(row); + } + + return cloneOrgProject(row); + }, }; } diff --git a/apps/code/src/main/db/repositories/auth-preference-repository.ts b/apps/code/src/main/db/repositories/auth-preference-repository.ts index 6962e03e91..3a989baaa5 100644 --- a/apps/code/src/main/db/repositories/auth-preference-repository.ts +++ b/apps/code/src/main/db/repositories/auth-preference-repository.ts @@ -1,16 +1,28 @@ import { and, eq } from "drizzle-orm"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; -import { authPreferences } from "../schema"; +import { authOrgProjectPreferences, authPreferences } from "../schema"; import type { DatabaseService } from "../service"; export type AuthPreference = typeof authPreferences.$inferSelect; export type NewAuthPreference = typeof authPreferences.$inferInsert; +export type AuthOrgProjectPreference = + typeof authOrgProjectPreferences.$inferSelect; +export type NewAuthOrgProjectPreference = + typeof authOrgProjectPreferences.$inferInsert; export interface PersistAuthPreferenceInput { accountKey: string; cloudRegion: "us" | "eu" | "dev"; lastSelectedProjectId: number | null; + lastSelectedOrgId: string | null; +} + +export interface PersistAuthOrgProjectPreferenceInput { + accountKey: string; + cloudRegion: "us" | "eu" | "dev"; + orgId: string; + lastSelectedProjectId: number; } export interface IAuthPreferenceRepository { @@ -19,6 +31,14 @@ export interface IAuthPreferenceRepository { cloudRegion: "us" | "eu" | "dev", ): AuthPreference | null; save(input: PersistAuthPreferenceInput): AuthPreference; + getOrgProject( + accountKey: string, + cloudRegion: "us" | "eu" | "dev", + orgId: string, + ): AuthOrgProjectPreference | null; + saveOrgProject( + input: PersistAuthOrgProjectPreferenceInput, + ): AuthOrgProjectPreference; } const now = () => new Date().toISOString(); @@ -61,6 +81,7 @@ export class AuthPreferenceRepository implements IAuthPreferenceRepository { accountKey: input.accountKey, cloudRegion: input.cloudRegion, lastSelectedProjectId: input.lastSelectedProjectId, + lastSelectedOrgId: input.lastSelectedOrgId, createdAt: existing?.createdAt ?? timestamp, updatedAt: timestamp, }; @@ -86,4 +107,71 @@ export class AuthPreferenceRepository implements IAuthPreferenceRepository { } return saved; } + + getOrgProject( + accountKey: string, + cloudRegion: "us" | "eu" | "dev", + orgId: string, + ): AuthOrgProjectPreference | null { + return ( + this.db + .select() + .from(authOrgProjectPreferences) + .where( + and( + eq(authOrgProjectPreferences.accountKey, accountKey), + eq(authOrgProjectPreferences.cloudRegion, cloudRegion), + eq(authOrgProjectPreferences.orgId, orgId), + ), + ) + .limit(1) + .get() ?? null + ); + } + + saveOrgProject( + input: PersistAuthOrgProjectPreferenceInput, + ): AuthOrgProjectPreference { + const timestamp = now(); + const existing = this.getOrgProject( + input.accountKey, + input.cloudRegion, + input.orgId, + ); + + const row: NewAuthOrgProjectPreference = { + accountKey: input.accountKey, + cloudRegion: input.cloudRegion, + orgId: input.orgId, + lastSelectedProjectId: input.lastSelectedProjectId, + createdAt: existing?.createdAt ?? timestamp, + updatedAt: timestamp, + }; + + if (existing) { + this.db + .update(authOrgProjectPreferences) + .set(row) + .where( + and( + eq(authOrgProjectPreferences.accountKey, input.accountKey), + eq(authOrgProjectPreferences.cloudRegion, input.cloudRegion), + eq(authOrgProjectPreferences.orgId, input.orgId), + ), + ) + .run(); + } else { + this.db.insert(authOrgProjectPreferences).values(row).run(); + } + + const saved = this.getOrgProject( + input.accountKey, + input.cloudRegion, + input.orgId, + ); + if (!saved) { + throw new Error("Failed to persist auth org project preference"); + } + return saved; + } } diff --git a/apps/code/src/main/db/repositories/workspace-repository.mock.ts b/apps/code/src/main/db/repositories/workspace-repository.mock.ts index 775fed571b..78b112ed33 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.mock.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.mock.ts @@ -64,6 +64,8 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { lastActivityAt: null, linkedBranch: null, additionalDirectories: "[]", + prUrl: null, + prState: null, createdAt: now, updatedAt: now, }; @@ -84,6 +86,8 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { lastActivityAt: null, linkedBranch: null, additionalDirectories: "[]", + prUrl: null, + prState: null, createdAt: now, updatedAt: now, }; @@ -133,6 +137,17 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { current.includes(path) ? current.filter((p) => p !== path) : null, ); }, + updatePrCache: (taskId, update) => { + const w = findLiveByTaskId(taskId); + if (!w) return; + const now = new Date().toISOString(); + workspaces.set(w.id, { + ...w, + prUrl: update.prUrl, + prState: update.prState, + updatedAt: now, + }); + }, deleteAll: () => { workspaces.clear(); taskIndex.clear(); diff --git a/apps/code/src/main/db/repositories/workspace-repository.ts b/apps/code/src/main/db/repositories/workspace-repository.ts index 760ba9503a..b6e3eb8fa2 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.ts @@ -8,6 +8,7 @@ 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 CachedPrState = "open" | "merged" | "closed" | "draft"; export interface CreateWorkspaceData { taskId: string; @@ -15,6 +16,11 @@ export interface CreateWorkspaceData { mode: WorkspaceMode; } +export interface PrCacheUpdate { + prUrl: string | null; + prState: CachedPrState | null; +} + export interface IWorkspaceRepository { findById(id: string): Workspace | null; findByTaskId(taskId: string): Workspace | null; @@ -38,6 +44,7 @@ export interface IWorkspaceRepository { getAdditionalDirectories(taskId: string): string[]; addAdditionalDirectory(taskId: string, path: string): void; removeAdditionalDirectory(taskId: string, path: string): void; + updatePrCache(taskId: string, update: PrCacheUpdate): void; deleteAll(): void; } @@ -223,6 +230,18 @@ export class WorkspaceRepository implements IWorkspaceRepository { ); } + updatePrCache(taskId: string, update: PrCacheUpdate): void { + this.db + .update(workspaces) + .set({ + prUrl: update.prUrl, + prState: update.prState, + updatedAt: now(), + }) + .where(byTaskId(taskId)) + .run(); + } + deleteAll(): void { this.db.delete(workspaces).run(); } diff --git a/apps/code/src/main/db/schema.ts b/apps/code/src/main/db/schema.ts index 8823ad2744..dad389741e 100644 --- a/apps/code/src/main/db/schema.ts +++ b/apps/code/src/main/db/schema.ts @@ -33,6 +33,10 @@ export const workspaces = sqliteTable( lastActivityAt: text(), /** JSON-encoded array of absolute paths the agent can access for this task. */ additionalDirectories: text().notNull().default("[]"), + /** Cached PR URL for this task so task switches render without waiting on `gh`. */ + prUrl: text(), + /** Cached PR state — values match the `SidebarPrState` union (open/merged/closed/draft). */ + prState: text({ enum: ["open", "merged", "closed", "draft"] }), createdAt: createdAt(), updatedAt: updatedAt(), }, @@ -104,6 +108,7 @@ export const authPreferences = sqliteTable( accountKey: text().notNull(), cloudRegion: text({ enum: ["us", "eu", "dev"] }).notNull(), lastSelectedProjectId: integer(), + lastSelectedOrgId: text(), createdAt: createdAt(), updatedAt: updatedAt(), }, @@ -114,3 +119,22 @@ export const authPreferences = sqliteTable( ), ], ); + +export const authOrgProjectPreferences = sqliteTable( + "auth_org_project_preferences", + { + accountKey: text().notNull(), + cloudRegion: text({ enum: ["us", "eu", "dev"] }).notNull(), + orgId: text().notNull(), + lastSelectedProjectId: integer().notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [ + index("auth_org_project_account_region_org_idx").on( + t.accountKey, + t.cloudRegion, + t.orgId, + ), + ], +); diff --git a/apps/code/src/main/services/auth/schemas.ts b/apps/code/src/main/services/auth/schemas.ts index f165e6a22a..5d3cc21efb 100644 --- a/apps/code/src/main/services/auth/schemas.ts +++ b/apps/code/src/main/services/auth/schemas.ts @@ -4,13 +4,45 @@ import { cloudRegion, type oAuthTokenResponse } from "../oauth/schemas"; export const authStatusSchema = z.enum(["anonymous", "authenticated"]); export type AuthStatus = z.infer; +export const orgProjectsSchema = z.object({ + orgName: z.string(), + projects: z.array(z.object({ id: z.number(), name: z.string() })), +}); +export type OrgProjects = z.infer; + +export const orgProjectsMapSchema = z.record(z.string(), orgProjectsSchema); +export type OrgProjectsMap = z.infer; + +export function flattenProjectIds(map: OrgProjectsMap): number[] { + return Object.values(map).flatMap((org) => org.projects.map((p) => p.id)); +} + +export function findOrgForProject( + map: OrgProjectsMap, + projectId: number, + preferredOrgId: string | null, +): string | null { + if ( + preferredOrgId && + map[preferredOrgId]?.projects.some((p) => p.id === projectId) + ) { + return preferredOrgId; + } + for (const [orgId, org] of Object.entries(map)) { + if (org.projects.some((p) => p.id === projectId)) { + return orgId; + } + } + return null; +} + 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()), + orgProjectsMap: orgProjectsMapSchema, + currentOrgId: z.string().nullable(), + currentProjectId: z.number().nullable(), hasCodeAccess: z.boolean().nullable(), needsScopeReauth: z.boolean(), }); @@ -34,6 +66,11 @@ export const selectProjectInput = z.object({ projectId: z.number(), }); +export const switchOrgInput = z.object({ + orgId: z.string().min(1), +}); +export type SwitchOrgInput = z.infer; + export const validAccessTokenOutput = z.object({ accessToken: z.string(), apiHost: z.string(), diff --git a/apps/code/src/main/services/auth/service.test.ts b/apps/code/src/main/services/auth/service.test.ts index 8733ebd258..ec8c39247e 100644 --- a/apps/code/src/main/services/auth/service.test.ts +++ b/apps/code/src/main/services/auth/service.test.ts @@ -34,7 +34,6 @@ function mockTokenResponse( overrides: { accessToken?: string; refreshToken?: string; - scopedTeams?: number[]; scopedOrgs?: string[]; } = {}, ) { @@ -46,7 +45,6 @@ function mockTokenResponse( expires_in: 3600, token_type: "Bearer", scope: "", - scoped_teams: overrides.scopedTeams ?? [42], scoped_organizations: overrides.scopedOrgs ?? ["org-1"], }, }; @@ -98,7 +96,22 @@ describe("AuthService", () => { return (call as unknown as [() => void])[0]; } - const stubAuthFetch = (accountKey = "user-1") => { + const stubAuthFetch = ( + options: { + accountKey?: string; + currentOrgId?: string; + orgs?: Record< + string, + { name: string; projects: { id: number; name: string }[] } + >; + } = {}, + ) => { + const accountKey = options.accountKey ?? "user-1"; + const currentOrgId = options.currentOrgId ?? "org-1"; + const orgs = options.orgs ?? { + "org-1": { name: "Org 1", projects: [{ id: 42, name: "Project 42" }] }, + }; + vi.stubGlobal( "fetch", vi.fn(async (input: string | Request) => { @@ -107,7 +120,22 @@ describe("AuthService", () => { if (url.includes("/api/users/@me/")) { return { ok: true, - json: vi.fn().mockResolvedValue({ uuid: accountKey }), + json: vi.fn().mockResolvedValue({ + uuid: accountKey, + organization: { id: currentOrgId }, + }), + } as unknown as Response; + } + + const orgMatch = url.match(/\/api\/organizations\/([^/]+)\/$/); + if (orgMatch) { + const orgId = orgMatch[1]; + return { + ok: true, + json: vi.fn().mockResolvedValue({ + name: orgs[orgId]?.name ?? "Unknown", + teams: orgs[orgId]?.projects ?? [], + }), } as unknown as Response; } @@ -121,6 +149,7 @@ describe("AuthService", () => { beforeEach(() => { preferenceRepository._preferences = []; + preferenceRepository._orgProjectPreferences = []; repository.clearCurrent(); vi.clearAllMocks(); connectivityEmitter.removeAllListeners(); @@ -147,9 +176,9 @@ describe("AuthService", () => { status: "anonymous", bootstrapComplete: true, cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, hasCodeAccess: null, needsScopeReauth: false, }); @@ -168,9 +197,9 @@ describe("AuthService", () => { status: "anonymous", bootstrapComplete: true, cloudRegion: "us", - projectId: 123, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: 123, hasCodeAccess: null, needsScopeReauth: true, }); @@ -182,10 +211,19 @@ describe("AuthService", () => { mockTokenResponse({ accessToken: "new-access-token", refreshToken: "rotated-refresh-token", - scopedTeams: [42, 84], }), ); - stubAuthFetch(); + stubAuthFetch({ + orgs: { + "org-1": { + name: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }, + }); await service.initialize(); @@ -193,9 +231,17 @@ describe("AuthService", () => { status: "authenticated", bootstrapComplete: true, cloudRegion: "us", - projectId: 42, - availableProjectIds: [42, 84], - availableOrgIds: ["org-1"], + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }, + currentOrgId: "org-1", + currentProjectId: 42, hasCodeAccess: true, needsScopeReauth: false, }); @@ -234,29 +280,35 @@ describe("AuthService", () => { }); it("preserves the selected project across logout and re-login for the same account", async () => { + const orgs = { + "org-1": { + name: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }; 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(); + stubAuthFetch({ orgs }); await service.login("us"); await service.selectProject(84); @@ -265,7 +317,7 @@ describe("AuthService", () => { expect(service.getState()).toMatchObject({ status: "anonymous", cloudRegion: "us", - projectId: 84, + currentProjectId: 84, }); await service.login("us"); @@ -273,35 +325,49 @@ describe("AuthService", () => { expect(service.getState()).toMatchObject({ status: "authenticated", cloudRegion: "us", - projectId: 84, - availableProjectIds: [42, 84], + currentProjectId: 84, + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }, }); }); it("restores the selected project after app restart while logged out", async () => { + const orgs = { + "org-1": { + name: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }; 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(); + stubAuthFetch({ orgs }); await service.login("us"); await service.selectProject(84); @@ -320,8 +386,16 @@ describe("AuthService", () => { expect(service.getState()).toMatchObject({ status: "authenticated", cloudRegion: "us", - projectId: 84, - availableProjectIds: [42, 84], + currentProjectId: 84, + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }, }); }); @@ -464,7 +538,7 @@ describe("AuthService", () => { expect(service.getState()).toMatchObject({ status: "anonymous", cloudRegion: "us", - projectId: 42, + currentProjectId: 42, }); expect(oauthService.refreshToken).toHaveBeenCalledTimes(1); expect(repository.getCurrent()).toBeNull(); @@ -499,6 +573,118 @@ describe("AuthService", () => { }); }); + describe("switchOrg", () => { + const twoOrgs = { + "org-1": { + name: "Org 1", + projects: [{ id: 11, name: "Project 11" }], + }, + "org-2": { + name: "Org 2", + projects: [ + { id: 22, name: "Project 22" }, + { id: 33, name: "Project 33" }, + ], + }, + }; + + function arrangeTwoOrgs() { + vi.mocked(oauthService.startFlow).mockResolvedValue( + mockTokenResponse({ scopedOrgs: ["org-1", "org-2"] }), + ); + vi.mocked(oauthService.refreshToken).mockResolvedValue( + mockTokenResponse({ scopedOrgs: ["org-1", "org-2"] }), + ); + stubAuthFetch({ orgs: twoOrgs }); + } + + it("switches the active organization and refreshes its projects", async () => { + arrangeTwoOrgs(); + + await service.login("us"); + expect(service.getState().currentOrgId).toBe("org-1"); + + const state = await service.switchOrg("org-2"); + + expect(state.currentOrgId).toBe("org-2"); + expect(state.currentProjectId).toBe(22); + expect(state.orgProjectsMap["org-2"].projects).toEqual([ + { id: 22, name: "Project 22" }, + { id: 33, name: "Project 33" }, + ]); + }); + + it("throws when the target organization is not in the scoped map", async () => { + arrangeTwoOrgs(); + await service.login("us"); + + await expect(service.switchOrg("org-unknown")).rejects.toThrow( + /Invalid organization/i, + ); + }); + + it("restores the last selected project for the org when available", async () => { + arrangeTwoOrgs(); + + await service.login("us"); + await service.switchOrg("org-2"); + await service.selectProject(33); + await service.switchOrg("org-1"); + + const state = await service.switchOrg("org-2"); + expect(state.currentProjectId).toBe(33); + }); + + it("persists the new selected project so it survives restart", async () => { + arrangeTwoOrgs(); + + await service.login("us"); + await service.switchOrg("org-2"); + + expect(repository.getCurrent()?.selectedProjectId).toBe(22); + }); + }); + + describe("selectProject cross-org", () => { + it("PATCHes the user org and updates state when the chosen project lives in a different org", async () => { + const orgs = { + "org-1": { + name: "Org 1", + projects: [{ id: 1, name: "P1" }], + }, + "org-2": { + name: "Org 2", + projects: [{ id: 2, name: "P2" }], + }, + }; + vi.mocked(oauthService.startFlow).mockResolvedValue( + mockTokenResponse({ scopedOrgs: ["org-1", "org-2"] }), + ); + vi.mocked(oauthService.refreshToken).mockResolvedValue( + mockTokenResponse({ scopedOrgs: ["org-1", "org-2"] }), + ); + stubAuthFetch({ orgs }); + + const fetchSpy = vi.spyOn(global, "fetch"); + + await service.login("us"); + const state = await service.selectProject(2); + + expect(state.currentOrgId).toBe("org-2"); + expect(state.currentProjectId).toBe(2); + + const patchCalls = fetchSpy.mock.calls.filter( + ([, init]) => (init as RequestInit | undefined)?.method === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThan(0); + const [patchUrl, patchInit] = patchCalls[0]; + expect(String(patchUrl)).toMatch(/\/api\/users\/@me\//); + expect(String((patchInit as RequestInit).body)).toContain( + '"set_current_organization":"org-2"', + ); + }); + }); + describe("redeemInviteCode uses authenticatedFetch", () => { it("retries on 401 via authenticatedFetch", async () => { vi.mocked(oauthService.startFlow).mockResolvedValue( diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts index e59051aa16..4749898e09 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -25,6 +25,10 @@ import { type AuthServiceEvents, type AuthState, type AuthTokenResponse, + findOrgForProject, + flattenProjectIds, + type OrgProjects, + type OrgProjectsMap, type ValidAccessTokenOutput, } from "./schemas"; @@ -41,9 +45,9 @@ interface InMemorySession { accessTokenExpiresAt: number; refreshToken: string; cloudRegion: CloudRegion; - projectId: number | null; - availableProjectIds: number[]; - availableOrgIds: string[]; + orgProjectsMap: OrgProjectsMap; + currentOrgId: string | null; + currentProjectId: number | null; } interface StoredSessionInput { @@ -57,15 +61,46 @@ interface TokenResponseOptions { selectedProjectId: number | null; } +function pickInitialProjectId(args: { + orgProjectsMap: OrgProjectsMap; + currentOrgId: string | null; + lastSelectedOrgId: string | null; + preferredProjectId: number | null; +}): number | null { + const { + orgProjectsMap, + currentOrgId, + lastSelectedOrgId, + preferredProjectId, + } = args; + + const allProjectIds = flattenProjectIds(orgProjectsMap); + if (preferredProjectId && allProjectIds.includes(preferredProjectId)) { + return preferredProjectId; + } + + const fromCurrentOrg = currentOrgId + ? orgProjectsMap[currentOrgId]?.projects[0]?.id + : undefined; + if (fromCurrentOrg !== undefined) return fromCurrentOrg; + + const fromLastOrg = lastSelectedOrgId + ? orgProjectsMap[lastSelectedOrgId]?.projects[0]?.id + : undefined; + if (fromLastOrg !== undefined) return fromLastOrg; + + return allProjectIds[0] ?? null; +} + @injectable() export class AuthService extends TypedEventEmitter { private state: AuthState = { status: "anonymous", bootstrapComplete: false, cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, hasCodeAccess: null, needsScopeReauth: false, }; @@ -220,31 +255,144 @@ export class AuthService extends TypedEventEmitter { const session = this.requireSession(); - if (!session.availableProjectIds.includes(projectId)) { + if (!flattenProjectIds(session.orgProjectsMap).includes(projectId)) { throw new Error("Invalid project selection"); } + const newOrgId = + findOrgForProject( + session.orgProjectsMap, + projectId, + session.currentOrgId, + ) ?? session.currentOrgId; + + const orgProjectsMap = + newOrgId && newOrgId !== session.currentOrgId + ? await this.applyOrgChange(session, newOrgId) + : session.orgProjectsMap; + + this.commitSessionState(session, { + orgProjectsMap, + currentOrgId: newOrgId, + currentProjectId: projectId, + }); + return this.getState(); + } + async switchOrg(orgId: string): Promise { + await this.initialize(); + + const session = this.requireSession(); + + if (!session.orgProjectsMap[orgId]) { + throw new Error("Invalid organization"); + } + + const orgProjectsMap = await this.applyOrgChange(session, orgId); + const currentProjectId = this.pickProjectForOrg( + session, + orgProjectsMap, + orgId, + ); + + this.commitSessionState(session, { + orgProjectsMap, + currentOrgId: orgId, + currentProjectId, + }); + return this.getState(); + } + private async applyOrgChange( + session: InMemorySession, + orgId: string, + ): Promise { + await this.patchCurrentOrganization(orgId); + const refreshedProjects = await this.fetchOrgProjects( + session.accessToken, + session.cloudRegion, + orgId, + ); + if (!refreshedProjects) { + return session.orgProjectsMap; + } + return { + ...session.orgProjectsMap, + [orgId]: { + orgName: session.orgProjectsMap[orgId]?.orgName ?? "(unknown)", + projects: refreshedProjects, + }, + }; + } + private pickProjectForOrg( + session: InMemorySession, + orgProjectsMap: OrgProjectsMap, + orgId: string, + ): number | null { + const orgProjects = orgProjectsMap[orgId]?.projects ?? []; + const preferredProjectId = session.accountKey + ? (this.authPreferenceRepository.getOrgProject( + session.accountKey, + session.cloudRegion, + orgId, + )?.lastSelectedProjectId ?? null) + : null; + if ( + preferredProjectId && + orgProjects.some((p) => p.id === preferredProjectId) + ) { + return preferredProjectId; + } + return orgProjects[0]?.id ?? null; + } + private commitSessionState( + prevSession: InMemorySession, + next: { + orgProjectsMap: OrgProjectsMap; + currentOrgId: string | null; + currentProjectId: number | null; + }, + ): void { this.session = { - ...session, - projectId, + ...prevSession, + orgProjectsMap: next.orgProjectsMap, + currentOrgId: next.currentOrgId, + currentProjectId: next.currentProjectId, }; this.persistProjectPreference(this.session); this.persistSession({ refreshToken: this.session.refreshToken, cloudRegion: this.session.cloudRegion, - selectedProjectId: projectId, + selectedProjectId: next.currentProjectId, }); - this.updateState({ projectId }); - return this.getState(); + this.updateState({ + orgProjectsMap: next.orgProjectsMap, + currentOrgId: next.currentOrgId, + currentProjectId: next.currentProjectId, + }); + } + private async patchCurrentOrganization(orgId: string): Promise { + const { apiHost } = await this.getValidAccessToken(); + const response = await this.authenticatedFetch( + fetch, + `${apiHost}/api/users/@me/`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ set_current_organization: orgId }), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to switch organization: ${response.statusText}`); + } } async logout(): Promise { - const { cloudRegion, projectId } = this.state; + const { cloudRegion, currentProjectId } = this.state; this.authSessionRepository.clearCurrent(); this.session = null; - this.setAnonymousState({ cloudRegion, projectId }); + this.setAnonymousState({ cloudRegion, currentProjectId }); return this.getState(); } private executeAuthenticatedFetch( @@ -274,7 +422,7 @@ export class AuthService extends TypedEventEmitter { this.setAnonymousState({ bootstrapComplete: true, cloudRegion: stored.cloudRegion, - projectId: stored.selectedProjectId, + currentProjectId: stored.selectedProjectId, needsScopeReauth: true, }); return; @@ -296,7 +444,7 @@ export class AuthService extends TypedEventEmitter { this.setAnonymousState({ bootstrapComplete: true, cloudRegion: storedSession.cloudRegion, - projectId: storedSession.selectedProjectId, + currentProjectId: storedSession.selectedProjectId, }); } } @@ -331,7 +479,7 @@ export class AuthService extends TypedEventEmitter { return { refreshToken: this.session.refreshToken, cloudRegion: this.session.cloudRegion, - selectedProjectId: this.session.projectId, + selectedProjectId: this.session.currentProjectId, }; } @@ -373,7 +521,7 @@ export class AuthService extends TypedEventEmitter { this.session = null; this.setAnonymousState({ cloudRegion: input.cloudRegion, - projectId: input.selectedProjectId, + currentProjectId: input.selectedProjectId, }); throw new Error(lastError); } @@ -402,22 +550,26 @@ export class AuthService extends TypedEventEmitter { tokenResponse: AuthTokenResponse, options: TokenResponseOptions, ): Promise { - const availableProjectIds = tokenResponse.scoped_teams ?? []; - const availableOrgIds = tokenResponse.scoped_organizations ?? []; - const accountKey = await this.fetchAccountKey( + const scopedOrgIds = tokenResponse.scoped_organizations ?? []; + const { accountKey, currentOrgId } = await this.fetchUserContext( 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 orgProjectsMap = await this.buildOrgProjectsMap( + tokenResponse.access_token, + options.cloudRegion, + scopedOrgIds, + ); + const lastPrefs = accountKey + ? this.authPreferenceRepository.get(accountKey, options.cloudRegion) + : null; + const currentProjectId = pickInitialProjectId({ + orgProjectsMap, + currentOrgId, + preferredProjectId: + options.selectedProjectId ?? lastPrefs?.lastSelectedProjectId ?? null, + lastSelectedOrgId: lastPrefs?.lastSelectedOrgId ?? null, + }); const session: InMemorySession = { accountKey, @@ -425,13 +577,76 @@ export class AuthService extends TypedEventEmitter { accessTokenExpiresAt: Date.now() + tokenResponse.expires_in * 1000, refreshToken: tokenResponse.refresh_token, cloudRegion: options.cloudRegion, - projectId, - availableProjectIds, - availableOrgIds, + orgProjectsMap, + currentOrgId, + currentProjectId, }; return session; } + private async buildOrgProjectsMap( + accessToken: string, + cloudRegion: CloudRegion, + orgIds: string[], + ): Promise { + const entries = await Promise.all( + orgIds.map(async (orgId): Promise<[string, OrgProjects]> => { + const result = await this.fetchOrgWithProjects( + accessToken, + cloudRegion, + orgId, + ); + return [orgId, result ?? { orgName: "(unknown)", projects: [] }]; + }), + ); + + return Object.fromEntries(entries); + } + private async fetchOrgProjects( + accessToken: string, + cloudRegion: CloudRegion, + orgId: string, + ): Promise<{ id: number; name: string }[] | null> { + const result = await this.fetchOrgWithProjects( + accessToken, + cloudRegion, + orgId, + ); + return result?.projects ?? null; + } + private async fetchOrgWithProjects( + accessToken: string, + cloudRegion: CloudRegion, + orgId: string, + ): Promise { + const apiHost = getCloudUrlFromRegion(cloudRegion); + try { + const res = await this.executeAuthenticatedFetch( + fetch, + `${apiHost}/api/organizations/${orgId}/`, + {}, + accessToken, + ); + if (!res.ok) return null; + const raw = (await res.json().catch(() => null)) as { + name?: unknown; + teams?: unknown; + } | null; + const orgName = + typeof raw?.name === "string" && raw.name.length > 0 + ? raw.name + : "(unknown)"; + const teams = Array.isArray(raw?.teams) ? raw.teams : []; + const projects = teams + .map((t) => t as { id?: unknown; name?: unknown }) + .filter((t) => typeof t.id === "number" && typeof t.name === "string") + .map((t) => ({ id: t.id as number, name: t.name as string })); + return { orgName, projects }; + } catch (error) { + log.warn("Failed to fetch org with projects", { orgId, error }); + return null; + } + } private async authenticateWithFlow( runFlow: () => Promise<{ success: boolean; @@ -448,7 +663,7 @@ export class AuthService extends TypedEventEmitter { const session = await this.createSessionFromTokenResponse(result.data, { cloudRegion: region, - selectedProjectId: this.state.projectId, + selectedProjectId: this.state.currentProjectId, }); await this.syncAuthenticatedSession(session); } @@ -465,7 +680,7 @@ export class AuthService extends TypedEventEmitter { this.persistSession({ refreshToken: session.refreshToken, cloudRegion: session.cloudRegion, - selectedProjectId: session.projectId, + selectedProjectId: session.currentProjectId, }); this.session = session; @@ -473,9 +688,9 @@ export class AuthService extends TypedEventEmitter { status: "authenticated", bootstrapComplete: true, cloudRegion: session.cloudRegion, - projectId: session.projectId, - availableProjectIds: session.availableProjectIds, - availableOrgIds: session.availableOrgIds, + orgProjectsMap: session.orgProjectsMap, + currentOrgId: session.currentOrgId, + currentProjectId: session.currentProjectId, needsScopeReauth: false, }); await this.updateCodeAccessFromSession(); @@ -502,50 +717,72 @@ export class AuthService extends TypedEventEmitter { this.authPreferenceRepository.save({ accountKey: session.accountKey, cloudRegion: session.cloudRegion, - lastSelectedProjectId: session.projectId, + lastSelectedProjectId: session.currentProjectId, + lastSelectedOrgId: session.currentOrgId, }); + + const orgIdForProject = session.currentProjectId + ? findOrgForProject( + session.orgProjectsMap, + session.currentProjectId, + session.currentOrgId, + ) + : null; + if (orgIdForProject && session.currentProjectId) { + this.authPreferenceRepository.saveOrgProject({ + accountKey: session.accountKey, + cloudRegion: session.cloudRegion, + orgId: orgIdForProject, + lastSelectedProjectId: session.currentProjectId, + }); + } } private isSessionExpiring(session: InMemorySession): boolean { return session.accessTokenExpiresAt - Date.now() <= TOKEN_EXPIRY_SKEW_MS; } - private async fetchAccountKey( + private async fetchUserContext( accessToken: string, cloudRegion: "us" | "eu" | "dev", - ): Promise { + ): Promise<{ accountKey: string | null; currentOrgId: string | null }> { try { - const response = await fetch( + const response = await this.executeAuthenticatedFetch( + fetch, `${getCloudUrlFromRegion(cloudRegion)}/api/users/@me/`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, + {}, + accessToken, ); if (!response.ok) { - return null; + return { accountKey: null, currentOrgId: null }; } const data = (await response.json().catch(() => ({}))) as { uuid?: unknown; distinct_id?: unknown; email?: unknown; + organization?: { id?: unknown } | null; }; + let accountKey: string | null = null; 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; + accountKey = data.uuid; + } else if ( + typeof data.distinct_id === "string" && + data.distinct_id.length > 0 + ) { + accountKey = data.distinct_id; + } else if (typeof data.email === "string" && data.email.length > 0) { + accountKey = data.email; } - return null; + const orgId = data.organization?.id; + const currentOrgId = + typeof orgId === "string" && orgId.length > 0 ? orgId : null; + + return { accountKey, currentOrgId }; } catch (error) { - log.warn("Failed to resolve auth account key", { error }); - return null; + log.warn("Failed to resolve user context", { error }); + return { accountKey: null, currentOrgId: null }; } } private requireSession(): InMemorySession { @@ -557,16 +794,19 @@ export class AuthService extends TypedEventEmitter { private setAnonymousState( partial: Pick< Partial, - "bootstrapComplete" | "cloudRegion" | "projectId" | "needsScopeReauth" + | "bootstrapComplete" + | "cloudRegion" + | "currentProjectId" + | "needsScopeReauth" > = {}, ): void { this.updateState({ status: "anonymous", bootstrapComplete: partial.bootstrapComplete ?? true, cloudRegion: partial.cloudRegion ?? null, - projectId: partial.projectId ?? null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: partial.currentProjectId ?? null, hasCodeAccess: null, needsScopeReauth: partial.needsScopeReauth ?? false, }); diff --git a/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts b/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts index 4b394f783e..de63b38d57 100644 --- a/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts +++ b/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts @@ -17,7 +17,7 @@ function authedStub(): AuthService { return { getState: vi.fn(() => ({ status: "authenticated", - projectId: 42, + currentProjectId: 42, cloudRegion: "us", })), getValidAccessToken: vi.fn(async () => ({ diff --git a/apps/code/src/main/services/enrichment/service.ts b/apps/code/src/main/services/enrichment/service.ts index e859d2ecc2..29375d7db6 100644 --- a/apps/code/src/main/services/enrichment/service.ts +++ b/apps/code/src/main/services/enrichment/service.ts @@ -203,7 +203,7 @@ export class EnrichmentService { const state = this.authService.getState(); if ( state.status !== "authenticated" || - !state.projectId || + !state.currentProjectId || !state.cloudRegion ) { return null; @@ -213,7 +213,7 @@ export class EnrichmentService { return { apiKey: auth.accessToken, host: auth.apiHost, - projectId: state.projectId, + projectId: state.currentProjectId, }; } catch (err) { log.debug("Failed to resolve access token", { diff --git a/apps/code/src/main/services/git/service.test.ts b/apps/code/src/main/services/git/service.test.ts index afe6a4ff4f..5c80e000c3 100644 --- a/apps/code/src/main/services/git/service.test.ts +++ b/apps/code/src/main/services/git/service.test.ts @@ -23,11 +23,14 @@ vi.mock("../../utils/logger.js", () => ({ }, })); +import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; import type { AgentService } from "../agent/service"; import type { LlmGatewayService } from "../llm-gateway/service"; import type { WorkspaceService } from "../workspace/service"; import { GitService, mapPrState } from "./service"; +const stubWorkspaceRepo = {} as IWorkspaceRepository; + describe("GitService.getPrChangedFiles", () => { let service: GitService; @@ -37,6 +40,7 @@ describe("GitService.getPrChangedFiles", () => { {} as LlmGatewayService, {} as WorkspaceService, { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + stubWorkspaceRepo, ); }); @@ -149,6 +153,7 @@ describe("GitService.getGhAuthToken", () => { {} as LlmGatewayService, {} as WorkspaceService, { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + stubWorkspaceRepo, ); }); @@ -211,6 +216,7 @@ describe("GitService.getPrUrlForBranch", () => { {} as LlmGatewayService, {} as WorkspaceService, { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + stubWorkspaceRepo, ); }); @@ -329,6 +335,7 @@ describe("GitService.getPrReviewComments", () => { {} as LlmGatewayService, {} as WorkspaceService, { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + stubWorkspaceRepo, ); }); @@ -487,6 +494,7 @@ describe("GitService.resolveReviewThread", () => { {} as LlmGatewayService, {} as WorkspaceService, { getSessionEnvForTask: async () => ({}) } as unknown as AgentService, + stubWorkspaceRepo, ); }); diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 99ee93a957..dab3bda0aa 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -38,6 +38,7 @@ 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 type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; import { MAIN_TOKENS } from "../../di/tokens"; import { logger } from "../../utils/logger"; import { TypedEventEmitter } from "../../utils/typed-event-emitter"; @@ -133,6 +134,7 @@ function toUnifiedDiffPatch( @injectable() export class GitService extends TypedEventEmitter { private lastFetchTime = new Map(); + private taskPrRevalidations = new Map>(); constructor( @inject(MAIN_TOKENS.LlmGatewayService) @@ -141,6 +143,8 @@ export class GitService extends TypedEventEmitter { private readonly workspaceService: WorkspaceService, @inject(MAIN_TOKENS.AgentService) private readonly agentService: AgentService, + @inject(MAIN_TOKENS.WorkspaceRepository) + private readonly workspaceRepo: IWorkspaceRepository, ) { super(); } @@ -1977,51 +1981,159 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`; } } + /** + * Returns cached PR state for the task immediately and kicks off a background + * revalidation against `gh`. The fresh result is written back to the DB and, + * if the state changed, broadcast via `WorkspaceServiceEvent.TaskPrInfoChanged` + * so the renderer can update without re-querying. + * + * `hasDiff` is computed synchronously for worktrees without a cached PR — it + * relies only on local git state, so it stays cheap. + */ async getTaskPrStatus( taskId: string, cloudPrUrl: string | null, ): Promise<{ prState: SidebarPrState; hasDiff: boolean }> { + const cached = this.workspaceRepo.findByTaskId(taskId); + const cachedPrState = (cached?.prState ?? null) as SidebarPrState; + + void this.revalidateTaskPrStatus(taskId, cloudPrUrl); + + if (cachedPrState) return { prState: cachedPrState, hasDiff: false }; + + const hasDiff = await this.computeWorktreeHasDiff(taskId); + return { prState: null, hasDiff }; + } + + getCachedPrUrl(taskId: string): { prUrl: string | null } { + const row = this.workspaceRepo.findByTaskId(taskId); + return { prUrl: row?.prUrl ?? null }; + } + + private async computeWorktreeHasDiff(taskId: string): Promise { const workspace = await this.workspaceService.getWorkspace(taskId); - if (!workspace) return { prState: null, hasDiff: false }; + if ( + !workspace || + workspace.mode !== "worktree" || + !workspace.worktreePath + ) { + return false; + } + if (workspace.linkedBranch) return false; + const [diffStats, syncStatus] = await Promise.all([ + this.getDiffStats(workspace.worktreePath), + this.getGitSyncStatus(workspace.worktreePath), + ]); + return ( + (diffStats?.filesChanged ?? 0) > 0 || + (syncStatus?.aheadOfDefault ?? 0) > 0 + ); + } + + /** + * Performs the actual `gh` lookups for a task's PR, writes the result to the + * workspaces cache, and emits `TaskPrInfoChanged` when the cached value + * changed. Deduplicated per task so concurrent callers share one network + * roundtrip. + */ + private async revalidateTaskPrStatus( + taskId: string, + cloudPrUrl: string | null, + ): Promise { + const inFlight = this.taskPrRevalidations.get(taskId); + if (inFlight) return inFlight; + + const promise = this.computeTaskPrStatus(taskId, cloudPrUrl) + .then((fresh) => { + const cached = this.workspaceRepo.findByTaskId(taskId); + if (!cached) return; + + const cachedPrUrl = cached.prUrl ?? null; + const cachedPrState = (cached.prState ?? null) as SidebarPrState; + + this.workspaceRepo.updatePrCache(taskId, { + prUrl: fresh.prUrl, + prState: fresh.prState, + }); + + // Emit only when PR identity or state actually changed. `hasDiff` is + // not persisted (and is recomputed inline on each `getTaskPrStatus` + // call), so it must not feed into the emit decision — otherwise a + // worktree with uncommitted changes but no PR would emit on every + // revalidation cycle. + if (cachedPrUrl === fresh.prUrl && cachedPrState === fresh.prState) { + return; + } + + // String literal (rather than `WorkspaceServiceEvent.TaskPrInfoChanged`) + // avoids a circular import: workspace/service eagerly loads the DI + // container, which in turn re-enters this module. + this.workspaceService.emit("taskPrInfoChanged", { + taskId, + prUrl: fresh.prUrl, + prState: fresh.prState, + }); + }) + .catch((err) => { + log.warn("Failed to revalidate task PR status", { taskId, err }); + }) + .finally(() => { + this.taskPrRevalidations.delete(taskId); + }); + + this.taskPrRevalidations.set(taskId, promise); + return promise; + } + + private async computeTaskPrStatus( + taskId: string, + cloudPrUrl: string | null, + ): Promise<{ + prUrl: string | null; + prState: SidebarPrState; + hasDiff: boolean; + }> { + const workspace = await this.workspaceService.getWorkspace(taskId); + if (!workspace) return { prUrl: null, 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 { + prUrl: cloudPrUrl, prState: mapPrState(details.state, details.merged, details.draft), hasDiff: false, }; } - return { prState: null, hasDiff: false }; + return { prUrl: cloudPrUrl, prState: null, hasDiff: false }; } - if (isCloud) return { prState: null, hasDiff: false }; + if (isCloud) return { prUrl: null, 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 { + prUrl, prState: mapPrState(details.state, details.merged, details.draft), hasDiff: false, }; } } - return { prState: null, hasDiff: false }; + return { prUrl: null, 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 { + prUrl: prStatus.prUrl, prState: mapPrState( prStatus.prState, false, @@ -2040,9 +2152,9 @@ ${truncatedDiff || "(no diff available)"}${contextSection}`; (diffStats?.filesChanged ?? 0) > 0 || (syncStatus?.aheadOfDefault ?? 0) > 0; - return { prState: null, hasDiff }; + return { prUrl: null, prState: null, hasDiff }; } - return { prState: null, hasDiff: false }; + return { prUrl: null, prState: null, hasDiff: false }; } } diff --git a/apps/code/src/main/services/oauth/schemas.ts b/apps/code/src/main/services/oauth/schemas.ts index aef4a0280a..e3333f45c8 100644 --- a/apps/code/src/main/services/oauth/schemas.ts +++ b/apps/code/src/main/services/oauth/schemas.ts @@ -24,7 +24,6 @@ export const oAuthTokenResponse = z.object({ 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; diff --git a/apps/code/src/main/services/shell/service.test.ts b/apps/code/src/main/services/shell/service.test.ts index 6cafe2b3fb..c7b6b36177 100644 --- a/apps/code/src/main/services/shell/service.test.ts +++ b/apps/code/src/main/services/shell/service.test.ts @@ -374,6 +374,20 @@ describe("ShellService", () => { expect(service.check("session-1")).toBe(false); }); + it("emits an exit event for explicit teardown", async () => { + const exitHandler = vi.fn(); + service.on(ShellEvent.Exit, exitHandler); + + await service.create("session-1"); + + service.destroy("session-1"); + + expect(exitHandler).toHaveBeenCalledWith({ + sessionId: "session-1", + exitCode: 130, + }); + }); + it("does nothing for non-existent session", () => { expect(() => service.destroy("nonexistent")).not.toThrow(); }); diff --git a/apps/code/src/main/services/shell/service.ts b/apps/code/src/main/services/shell/service.ts index f82fec5da1..8dfead65e6 100644 --- a/apps/code/src/main/services/shell/service.ts +++ b/apps/code/src/main/services/shell/service.ts @@ -23,6 +23,7 @@ declare module "node-pty" { const log = logger.scope("shell"); const PTY_ENCODING = "utf8"; +const DESTROYED_EXIT_CODE = 130; export interface ShellSession { pty: pty.IPty; @@ -314,6 +315,10 @@ export class ShellService extends TypedEventEmitter { } session.pty.destroy(); this.sessions.delete(sessionId); + this.emit(ShellEvent.Exit, { + sessionId, + exitCode: DESTROYED_EXIT_CODE, + }); } } diff --git a/apps/code/src/main/services/workspace/schemas.ts b/apps/code/src/main/services/workspace/schemas.ts index 2569bab385..c71ca41f95 100644 --- a/apps/code/src/main/services/workspace/schemas.ts +++ b/apps/code/src/main/services/workspace/schemas.ts @@ -112,6 +112,12 @@ export const linkedBranchChangedPayload = z.object({ branchName: z.string().nullable(), }); +export const taskPrInfoChangedPayload = z.object({ + taskId: z.string(), + prUrl: z.string().nullable(), + prState: z.enum(["merged", "open", "draft", "closed"]).nullable(), +}); + export const linkBranchInput = z.object({ taskId: z.string(), branchName: z.string(), @@ -252,6 +258,14 @@ export const taskPrStatusInput = z.object({ cloudPrUrl: z.string().nullable(), }); +export const cachedPrUrlInput = z.object({ + taskId: z.string(), +}); + +export const cachedPrUrlOutput = z.object({ + prUrl: z.string().nullable(), +}); + export const sidebarPrStateSchema = z .enum(["merged", "open", "draft", "closed"]) .nullable(); @@ -264,6 +278,8 @@ export const taskPrStatusOutput = z.object({ export type TaskPrStatusInput = z.infer; export type SidebarPrState = z.infer; export type TaskPrStatus = z.infer; +export type CachedPrUrlInput = z.infer; +export type CachedPrUrlOutput = z.infer; // Type exports export type WorkspaceMode = z.infer; @@ -291,6 +307,7 @@ export type BranchChangedPayload = z.infer; export type LinkedBranchChangedPayload = z.infer< typeof linkedBranchChangedPayload >; +export type TaskPrInfoChangedPayload = z.infer; export type LinkBranchInput = z.infer; export type UnlinkBranchInput = z.infer; export type LocalBackgroundedPayload = z.infer; diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts index cef4af0cde..5f83fdd2c8 100644 --- a/apps/code/src/main/services/workspace/service.ts +++ b/apps/code/src/main/services/workspace/service.ts @@ -39,6 +39,7 @@ import type { CreateWorkspaceInput, LinkedBranchChangedPayload, ReconcileCloudWorkspacesOutput, + TaskPrInfoChangedPayload, Workspace, WorkspaceErrorPayload, WorkspaceInfo, @@ -128,6 +129,7 @@ export const WorkspaceServiceEvent = { Promoted: "promoted", BranchChanged: "branchChanged", LinkedBranchChanged: "linkedBranchChanged", + TaskPrInfoChanged: "taskPrInfoChanged", } as const; export interface WorkspaceServiceEvents { @@ -136,6 +138,7 @@ export interface WorkspaceServiceEvents { [WorkspaceServiceEvent.Promoted]: WorkspacePromotedPayload; [WorkspaceServiceEvent.BranchChanged]: BranchChangedPayload; [WorkspaceServiceEvent.LinkedBranchChanged]: LinkedBranchChangedPayload; + [WorkspaceServiceEvent.TaskPrInfoChanged]: TaskPrInfoChangedPayload; } @injectable() diff --git a/apps/code/src/main/trpc/routers/auth.ts b/apps/code/src/main/trpc/routers/auth.ts index 161d071145..bb0704179a 100644 --- a/apps/code/src/main/trpc/routers/auth.ts +++ b/apps/code/src/main/trpc/routers/auth.ts @@ -7,6 +7,7 @@ import { loginOutput, redeemInviteCodeInput, selectProjectInput, + switchOrgInput, validAccessTokenOutput, } from "../../services/auth/schemas"; import type { AuthService } from "../../services/auth/service"; @@ -56,6 +57,11 @@ export const authRouter = router({ .output(authStateSchema) .mutation(async ({ input }) => getService().selectProject(input.projectId)), + switchOrg: publicProcedure + .input(switchOrgInput) + .output(authStateSchema) + .mutation(async ({ input }) => getService().switchOrg(input.orgId)), + redeemInviteCode: publicProcedure .input(redeemInviteCodeInput) .output(authStateSchema) diff --git a/apps/code/src/main/trpc/routers/workspace.ts b/apps/code/src/main/trpc/routers/workspace.ts index 8e84c79534..57a14fe993 100644 --- a/apps/code/src/main/trpc/routers/workspace.ts +++ b/apps/code/src/main/trpc/routers/workspace.ts @@ -3,6 +3,8 @@ import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; import type { GitService } from "../../services/git/service"; import { + cachedPrUrlInput, + cachedPrUrlOutput, createWorkspaceInput, createWorkspaceOutput, deleteWorkspaceInput, @@ -223,9 +225,15 @@ export const workspaceRouter = router({ getGitService().getTaskPrStatus(input.taskId, input.cloudPrUrl), ), + getCachedPrUrl: publicProcedure + .input(cachedPrUrlInput) + .output(cachedPrUrlOutput) + .query(({ input }) => getGitService().getCachedPrUrl(input.taskId)), + onError: subscribe(WorkspaceServiceEvent.Error), onWarning: subscribe(WorkspaceServiceEvent.Warning), onPromoted: subscribe(WorkspaceServiceEvent.Promoted), onBranchChanged: subscribe(WorkspaceServiceEvent.BranchChanged), onLinkedBranchChanged: subscribe(WorkspaceServiceEvent.LinkedBranchChanged), + onTaskPrInfoChanged: subscribe(WorkspaceServiceEvent.TaskPrInfoChanged), }); diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index a5748db25b..6f064712bc 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -139,6 +139,41 @@ function App() { }), ); + useSubscription( + trpcReact.workspace.onTaskPrInfoChanged.subscriptionOptions(undefined, { + onData: ({ taskId, prUrl, prState }) => { + // Push the fresh PR state into every matching getTaskPrStatus query + // (one per cloudPrUrl variant). hasDiff isn't carried by the event — + // it's recomputed inline by the next refetch — so we preserve any + // existing value rather than overwriting it. + queryClient.setQueriesData<{ + prState: typeof prState; + hasDiff: boolean; + }>( + { + ...trpcReact.workspace.getTaskPrStatus.pathFilter(), + predicate: (query) => { + const [, params] = query.queryKey as [ + unknown, + { input?: { taskId?: string } } | undefined, + ]; + return params?.input?.taskId === taskId; + }, + }, + (prev) => (prev ? { ...prev, prState } : { prState, hasDiff: false }), + ); + + // Keep the cached PR URL warm so `useTaskPrUrl`'s "Open PR" fast-path + // sees the new URL immediately instead of waiting for `getCachedPrUrl` + // to go stale. + queryClient.setQueryData( + trpcReact.workspace.getCachedPrUrl.queryKey({ taskId }), + { prUrl }, + ); + }, + }), + ); + useSubscription( trpcReact.focus.onBranchRenamed.subscriptionOptions(undefined, { onData: ({ worktreePath, newBranch }) => { diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index e47c4e63e2..150b501901 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -581,8 +581,8 @@ export class PostHogAPIClient { } } - setTeamId(teamId: number): void { - this._teamId = teamId; + setTeamId(teamId: number | null | undefined): void { + this._teamId = teamId ?? null; } private async getTeamId(): Promise { @@ -693,6 +693,19 @@ export class PostHogAPIClient { }); } + async approveAiDataProcessing(): Promise { + const urlPath = `/api/organizations/@current/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + await this.api.fetcher.fetch({ + method: "patch", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ is_ai_data_processing_approved: true }), + }, + }); + } + async getProject(projectId: number) { //@ts-expect-error this is not in the generated client const data = await this.api.get("/api/projects/{project_id}/", { diff --git a/apps/code/src/renderer/components/ScopeReauthPrompt.test.tsx b/apps/code/src/renderer/components/ScopeReauthPrompt.test.tsx index 0cb091af0e..de6675ce61 100644 --- a/apps/code/src/renderer/components/ScopeReauthPrompt.test.tsx +++ b/apps/code/src/renderer/components/ScopeReauthPrompt.test.tsx @@ -9,9 +9,12 @@ const authState = { status: "anonymous" as const, bootstrapComplete: true, cloudRegion: null as "us" | "eu" | "dev" | null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {} as Record< + string, + { orgName: string; projects: { id: number; name: string }[] } + >, + currentOrgId: null as string | null, + currentProjectId: null as number | null, hasCodeAccess: null, needsScopeReauth: false, }; @@ -57,7 +60,7 @@ describe("ScopeReauthPrompt", () => { vi.clearAllMocks(); authState.status = "anonymous"; authState.cloudRegion = null; - authState.projectId = null; + authState.currentProjectId = null; authState.hasCodeAccess = null; authState.needsScopeReauth = false; }); diff --git a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx new file mode 100644 index 0000000000..9075faa289 --- /dev/null +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx @@ -0,0 +1,104 @@ +import { Theme } from "@radix-ui/themes"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const approveAiDataProcessing = vi.fn(); +const logoutMutate = vi.fn(); +const openSettings = vi.fn(); + +vi.mock("@features/auth/hooks/authClient", () => ({ + useAuthenticatedClient: () => ({ approveAiDataProcessing }), +})); + +vi.mock("@features/auth/hooks/authMutations", () => ({ + useLogoutMutation: () => ({ mutate: logoutMutate }), +})); + +vi.mock("@features/auth/hooks/authQueries", () => ({ + authKeys: { currentUsers: () => ["auth", "current-user"] }, +})); + +vi.mock("@features/settings/components/SettingsDialog", () => ({ + SettingsDialog: () => null, +})); + +vi.mock("@features/settings/stores/settingsDialogStore", () => ({ + useSettingsDialogStore: ( + selector: (state: { open: typeof openSettings }) => unknown, + ) => selector({ open: openSettings }), +})); + +vi.mock("@utils/analytics", () => ({ track: vi.fn() })); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: {}, +})); + +import { AiApprovalScreen } from "./AiApprovalScreen"; + +function renderInTheme(isAdmin: boolean) { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false }, queries: { retry: false } }, + }); + return render( + + + + + , + ); +} + +describe("AiApprovalScreen", () => { + beforeEach(() => { + approveAiDataProcessing.mockReset(); + logoutMutate.mockReset(); + openSettings.mockReset(); + }); + + it("calls approveAiDataProcessing once when the admin clicks the button", async () => { + approveAiDataProcessing.mockResolvedValueOnce(undefined); + const user = userEvent.setup(); + + renderInTheme(true); + + const button = screen.getByRole("button", { + name: /Approve AI data processing/i, + }); + await user.click(button); + + await waitFor(() => + expect(approveAiDataProcessing).toHaveBeenCalledExactlyOnceWith(), + ); + }); + + it("renders the ask-admin copy and no approve button for non-admin users", () => { + renderInTheme(false); + + expect( + screen.getByText( + /Ask an organization admin to approve AI data processing/i, + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Approve AI data processing/i }), + ).not.toBeInTheDocument(); + }); + + it("shows an error callout when the approval request rejects", async () => { + approveAiDataProcessing.mockRejectedValueOnce(new Error("forbidden")); + const user = userEvent.setup(); + + renderInTheme(true); + + await user.click( + screen.getByRole("button", { name: /Approve AI data processing/i }), + ); + + expect( + await screen.findByText(/Could not approve AI data processing/i), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx index 2dfce464d4..88df47dc64 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx @@ -1,20 +1,14 @@ import { FullScreenLayout } from "@components/FullScreenLayout"; +import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useLogoutMutation } from "@features/auth/hooks/authMutations"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { authKeys } from "@features/auth/hooks/authQueries"; import { SettingsDialog } from "@features/settings/components/SettingsDialog"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; -import { - ArrowSquareOut, - GearSix, - Robot, - SignOut, - WarningCircle, -} from "@phosphor-icons/react"; -import { Button, Callout, Flex, Text } from "@radix-ui/themes"; +import { GearSix, Robot, SignOut, WarningCircle } from "@phosphor-icons/react"; +import { Button, Callout, Flex, Spinner, 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 { useMutation, useQueryClient } from "@tanstack/react-query"; import { track } from "@utils/analytics"; import { motion } from "framer-motion"; import { useEffect } from "react"; @@ -28,7 +22,20 @@ interface AiApprovalScreenProps { export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { const logoutMutation = useLogoutMutation(); const openSettings = useSettingsDialogStore((s) => s.open); - const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + const client = useAuthenticatedClient(); + const queryClient = useQueryClient(); + + const approveMutation = useMutation({ + mutationFn: async () => { + await client.approveAiDataProcessing(); + }, + onSuccess: async () => { + track(ANALYTICS_EVENTS.AI_CONSENT_GRANTED_INAPP); + await queryClient.invalidateQueries({ + queryKey: authKeys.currentUsers(), + }); + }, + }); // biome-ignore lint/correctness/useExhaustiveDependencies: fire once on mount; later isAdmin changes from query resolution should not re-fire useEffect(() => { @@ -40,15 +47,6 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { enableOnFormTags: true, }); - const approvalUrl = cloudRegion - ? `${getCloudUrlFromRegion(cloudRegion)}/settings/organization-details#organization-ai-consent` - : null; - - const openApproval = () => { - if (!approvalUrl) return; - void trpcClient.os.openExternal.mutate({ url: approvalUrl }); - }; - const footerLeft = ( - - Opens PostHog in your browser. Come back here once you've - approved. - + {approveMutation.isError && ( + + + + + + Could not approve AI data processing. Try again or + contact support. + + + )} ) : ( diff --git a/apps/code/src/renderer/features/auth/hooks/authClient.ts b/apps/code/src/renderer/features/auth/hooks/authClient.ts index 42d23a1990..34805c4360 100644 --- a/apps/code/src/renderer/features/auth/hooks/authClient.ts +++ b/apps/code/src/renderer/features/auth/hooks/authClient.ts @@ -30,11 +30,11 @@ export function createAuthenticatedClient( getCloudUrlFromRegion(authState.cloudRegion), getValidAccessToken, refreshAccessToken, - authState.projectId ?? undefined, + authState.currentProjectId ?? undefined, ); - if (authState.projectId) { - client.setTeamId(authState.projectId); + if (authState.currentProjectId) { + client.setTeamId(authState.currentProjectId); } return client; @@ -49,7 +49,12 @@ export function useOptionalAuthenticatedClient(): PostHogAPIClient | null { return useMemo( () => createAuthenticatedClient(authState), - [authState.cloudRegion, authState.projectId, authState.status, authState], + [ + authState.cloudRegion, + authState.currentProjectId, + authState.status, + authState, + ], ); } diff --git a/apps/code/src/renderer/features/auth/hooks/authMutations.ts b/apps/code/src/renderer/features/auth/hooks/authMutations.ts index a371710d5d..81501a0751 100644 --- a/apps/code/src/renderer/features/auth/hooks/authMutations.ts +++ b/apps/code/src/renderer/features/auth/hooks/authMutations.ts @@ -26,7 +26,7 @@ function useAuthFlowMutation( await refreshAuthStateQuery(); useAuthUiStateStore.getState().clearStaleRegion(); track(ANALYTICS_EVENTS.USER_LOGGED_IN, { - project_id: state.projectId?.toString() ?? "", + project_id: state.currentProjectId?.toString() ?? "", region, }); }, @@ -59,6 +59,19 @@ export function useSelectProjectMutation() { }); } +export function useSwitchOrgMutation() { + return useMutation({ + mutationFn: async (orgId: string) => { + resetSessionService(); + return await trpcClient.auth.switchOrg.mutate({ orgId }); + }, + onSuccess: async () => { + clearAuthScopedQueries(); + await refreshAuthStateQuery(); + }, + }); +} + export function useRedeemInviteCodeMutation() { return useMutation({ mutationFn: async (code: string) => diff --git a/apps/code/src/renderer/features/auth/hooks/authQueries.ts b/apps/code/src/renderer/features/auth/hooks/authQueries.ts index c7a7198c71..ee8dada494 100644 --- a/apps/code/src/renderer/features/auth/hooks/authQueries.ts +++ b/apps/code/src/renderer/features/auth/hooks/authQueries.ts @@ -15,9 +15,9 @@ export const ANONYMOUS_AUTH_STATE: AuthState = { status: "anonymous", bootstrapComplete: false, cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, hasCodeAccess: null, needsScopeReauth: false, }; @@ -58,7 +58,7 @@ export function getAuthIdentity(authState: AuthState): string | null { return null; } - return `${authState.cloudRegion}:${authState.projectId ?? "none"}`; + return `${authState.cloudRegion}:${authState.currentProjectId ?? "none"}`; } export function useAuthState() { diff --git a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts index f3b946ce93..25ec0f3583 100644 --- a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts +++ b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts @@ -68,7 +68,7 @@ function useAuthAnalyticsIdentity( identifyUser(distinctId, { email: currentUser.email, uuid: currentUser.uuid, - project_id: authState.projectId?.toString() ?? "", + project_id: authState.currentProjectId?.toString() ?? "", region: authState.cloudRegion ?? "", }); @@ -79,11 +79,16 @@ function useAuthAnalyticsIdentity( properties: { email: currentUser.email, uuid: currentUser.uuid, - project_id: authState.projectId?.toString() ?? "", + project_id: authState.currentProjectId?.toString() ?? "", region: authState.cloudRegion ?? "", }, }); - }, [authIdentity, authState.cloudRegion, authState.projectId, currentUser]); + }, [ + authIdentity, + authState.cloudRegion, + authState.currentProjectId, + currentUser, + ]); } function useSeatSync( diff --git a/apps/code/src/renderer/features/auth/stores/authStore.test.ts b/apps/code/src/renderer/features/auth/stores/authStore.test.ts index f5d0ec9518..b014b6d0fe 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.test.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.test.ts @@ -6,6 +6,7 @@ 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 mockSwitchOrg = vi.hoisted(() => ({ mutate: vi.fn() })); const mockRedeemInviteCode = vi.hoisted(() => ({ mutate: vi.fn() })); const mockLogout = vi.hoisted(() => ({ mutate: vi.fn() })); const mockGetCurrentUser = vi.fn(); @@ -19,6 +20,7 @@ vi.mock("@renderer/trpc/client", () => ({ login: mockLogin, signup: mockSignup, selectProject: mockSelectProject, + switchOrg: mockSwitchOrg, redeemInviteCode: mockRedeemInviteCode, logout: mockLogout, }, @@ -85,16 +87,23 @@ vi.mock("@stores/navigationStore", () => ({ })); 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"], + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [ + { id: 1, name: "Project 1" }, + { id: 2, name: "Project 2" }, + ], + }, + }, + currentOrgId: "org-1", + currentProjectId: 1, hasCodeAccess: true, needsScopeReauth: false, }; @@ -120,9 +129,9 @@ describe("authStore", () => { status: "anonymous", bootstrapComplete: true, cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, hasCodeAccess: null, needsScopeReauth: false, }); @@ -131,9 +140,9 @@ describe("authStore", () => { staleCloudRegion: null, isAuthenticated: false, client: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, needsProjectSelection: false, needsScopeReauth: false, hasCodeAccess: null, @@ -146,7 +155,7 @@ describe("authStore", () => { await useAuthStore.getState().checkCodeAccess(); expect(useAuthStore.getState().isAuthenticated).toBe(true); - expect(useAuthStore.getState().projectId).toBe(1); + expect(useAuthStore.getState().currentProjectId).toBe(1); }); it("logs in through the main auth service", async () => { @@ -177,9 +186,9 @@ describe("authStore", () => { status: "anonymous", bootstrapComplete: true, cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, hasCodeAccess: null, needsScopeReauth: false, }); @@ -188,10 +197,6 @@ describe("authStore", () => { 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 () => { @@ -211,7 +216,7 @@ describe("authStore", () => { expect(useAuthStore.getState().isAuthenticated).toBe(false); expect(useAuthStore.getState().client).toBeNull(); - expect(useAuthStore.getState().projectId).toBeNull(); + expect(useAuthStore.getState().currentProjectId).toBeNull(); expect(useAuthStore.getState().needsScopeReauth).toBe(false); resolveLogout(); diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 8de660445c..41cb715ca3 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -1,5 +1,10 @@ +import { authKeys, getAuthIdentity } from "@features/auth/hooks/authQueries"; import { useSeatStore } from "@features/billing/stores/seatStore"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { + flattenProjectIds, + type OrgProjectsMap, +} from "@main/services/auth/schemas"; import { PostHogAPIClient } from "@renderer/api/posthogClient"; import { trpcClient } from "@renderer/trpc/client"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; @@ -39,9 +44,9 @@ interface AuthStoreState { staleCloudRegion: CloudRegion | null; isAuthenticated: boolean; client: PostHogAPIClient | null; - projectId: number | null; - availableProjectIds: number[]; - availableOrgIds: string[]; + orgProjectsMap: OrgProjectsMap; + currentOrgId: string | null; + currentProjectId: number | null; needsProjectSelection: boolean; needsScopeReauth: boolean; hasCodeAccess: boolean | null; @@ -51,6 +56,7 @@ interface AuthStoreState { loginWithOAuth: (region: CloudRegion) => Promise; signupWithOAuth: (region: CloudRegion) => Promise; selectProject: (projectId: number) => Promise; + switchOrg: (orgId: string) => Promise; logout: () => Promise; } @@ -88,10 +94,7 @@ function clearAuthenticatedRendererState(options?: { if (options?.clearAllQueries) { queryClient.clear(); - return; } - - queryClient.removeQueries({ queryKey: ["currentUser"], exact: true }); } async function syncAuthState(): Promise { @@ -101,14 +104,16 @@ async function syncAuthState(): Promise { useAuthStore.setState((state) => { const regionChanged = authState.cloudRegion !== state.cloudRegion; - const projectChanged = authState.projectId !== state.projectId; + const projectChanged = + authState.currentProjectId !== state.currentProjectId; const client = isAuthenticated && authState.cloudRegion ? regionChanged || projectChanged || !state.client - ? createClient(authState.cloudRegion, authState.projectId) + ? createClient(authState.cloudRegion, authState.currentProjectId) : state.client : null; + const projectIds = flattenProjectIds(authState.orgProjectsMap); return { ...state, isAuthenticated, @@ -117,13 +122,13 @@ async function syncAuthState(): Promise { ? null : (authState.cloudRegion ?? state.staleCloudRegion), client, - projectId: authState.projectId, - availableProjectIds: authState.availableProjectIds, - availableOrgIds: authState.availableOrgIds, + orgProjectsMap: authState.orgProjectsMap, + currentOrgId: authState.currentOrgId, + currentProjectId: authState.currentProjectId, needsProjectSelection: isAuthenticated && - authState.availableProjectIds.length > 1 && - authState.projectId === null, + projectIds.length > 1 && + authState.currentProjectId === null, needsScopeReauth: authState.needsScopeReauth, hasCodeAccess: authState.hasCodeAccess, }; @@ -144,7 +149,8 @@ async function syncAuthState(): Promise { const authSyncKey = JSON.stringify({ status: authState.status, cloudRegion: authState.cloudRegion, - projectId: authState.projectId, + currentOrgId: authState.currentOrgId, + currentProjectId: authState.currentProjectId, }); if (authSyncKey === lastCompletedAuthSyncKey) { @@ -160,13 +166,14 @@ async function syncAuthState(): Promise { inFlightAuthSync = (async () => { try { const user = await client.getCurrentUser(); - queryClient.setQueryData(["currentUser"], user); + const authIdentity = getAuthIdentity(authState); + queryClient.setQueryData(authKeys.currentUser(authIdentity), user); const distinctId = user.distinct_id || user.email; identifyUser(distinctId, { email: user.email, uuid: user.uuid, - project_id: authState.projectId?.toString() ?? "", + project_id: authState.currentProjectId?.toString() ?? "", region: authState.cloudRegion ?? "", }); @@ -177,7 +184,7 @@ async function syncAuthState(): Promise { properties: { email: user.email, uuid: user.uuid, - project_id: authState.projectId?.toString() ?? "", + project_id: authState.currentProjectId?.toString() ?? "", region: authState.cloudRegion ?? "", }, }); @@ -202,9 +209,9 @@ export const useAuthStore = create((set) => ({ isAuthenticated: false, client: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, needsProjectSelection: false, needsScopeReauth: false, hasCodeAccess: null, @@ -222,7 +229,7 @@ export const useAuthStore = create((set) => ({ const result = await trpcClient.auth.login.mutate({ region }); await syncAuthState(); track(ANALYTICS_EVENTS.USER_LOGGED_IN, { - project_id: result.state.projectId?.toString() ?? "", + project_id: result.state.currentProjectId?.toString() ?? "", region, }); }, @@ -231,7 +238,7 @@ export const useAuthStore = create((set) => ({ const result = await trpcClient.auth.signup.mutate({ region }); await syncAuthState(); track(ANALYTICS_EVENTS.USER_LOGGED_IN, { - project_id: result.state.projectId?.toString() ?? "", + project_id: result.state.currentProjectId?.toString() ?? "", region, }); }, @@ -243,6 +250,13 @@ export const useAuthStore = create((set) => ({ useNavigationStore.getState().navigateToTaskInput(); }, + switchOrg: async (orgId: string) => { + sessionResetCallback?.(); + await trpcClient.auth.switchOrg.mutate({ orgId }); + await syncAuthState(); + useNavigationStore.getState().navigateToTaskInput(); + }, + logout: async () => { track(ANALYTICS_EVENTS.USER_LOGGED_OUT); sessionResetCallback?.(); @@ -255,9 +269,9 @@ export const useAuthStore = create((set) => ({ staleCloudRegion: state.cloudRegion ?? null, isAuthenticated: false, client: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, needsProjectSelection: false, needsScopeReauth: false, hasCodeAccess: null, diff --git a/apps/code/src/renderer/features/code-editor/components/EnrichmentPopover.tsx b/apps/code/src/renderer/features/code-editor/components/EnrichmentPopover.tsx index b837f4e49c..09bb2a756b 100644 --- a/apps/code/src/renderer/features/code-editor/components/EnrichmentPopover.tsx +++ b/apps/code/src/renderer/features/code-editor/components/EnrichmentPopover.tsx @@ -248,7 +248,7 @@ export function EnrichmentPopover() { const entry = useEnrichmentPopoverStore((s) => s.entry); const anchorRect = useEnrichmentPopoverStore((s) => s.anchorRect); const close = useEnrichmentPopoverStore((s) => s.close); - const projectId = useAuthStateValue((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.currentProjectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const ref = useRef(null); diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterToolbar.tsx b/apps/code/src/renderer/features/command-center/components/CommandCenterToolbar.tsx index a6bd878d18..42b798c413 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterToolbar.tsx +++ b/apps/code/src/renderer/features/command-center/components/CommandCenterToolbar.tsx @@ -1,15 +1,10 @@ -import { getSessionService } from "@features/sessions/service/service"; import { MagnifyingGlassMinus, MagnifyingGlassPlus, - Stop, Trash, } from "@phosphor-icons/react"; import { Flex, Select, Text } from "@radix-ui/themes"; -import type { - CommandCenterCellData, - StatusSummary, -} from "../hooks/useCommandCenterData"; +import type { StatusSummary } from "../hooks/useCommandCenterData"; import { type LayoutPreset, useCommandCenterStore, @@ -68,7 +63,6 @@ const LAYOUT_OPTIONS: { interface CommandCenterToolbarProps { summary: StatusSummary; - cells: CommandCenterCellData[]; } function StatusSummaryText({ summary }: { summary: StatusSummary }) { @@ -85,10 +79,7 @@ function StatusSummaryText({ summary }: { summary: StatusSummary }) { ); } -export function CommandCenterToolbar({ - summary, - cells, -}: CommandCenterToolbarProps) { +export function CommandCenterToolbar({ summary }: CommandCenterToolbarProps) { const layout = useCommandCenterStore((s) => s.layout); const setLayout = useCommandCenterStore((s) => s.setLayout); const clearAll = useCommandCenterStore((s) => s.clearAll); @@ -96,20 +87,6 @@ export function CommandCenterToolbar({ const zoomIn = useCommandCenterStore((s) => s.zoomIn); const zoomOut = useCommandCenterStore((s) => s.zoomOut); - 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); - } - } - }; - return ( - - + {switchOrgMutation.isError && ( + + Switching failed. Try again or switch from the sidebar. + + )} + + )} + )} diff --git a/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx index bd75a0934d..8939fbf40d 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx @@ -12,7 +12,7 @@ import { getPostHogUrl } from "@utils/urls"; import { SignalSlackNotificationsSettings } from "./SignalSlackNotificationsSettings"; export function SlackSettings() { - const projectId = useAuthStateValue((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.currentProjectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const { isLoading } = useIntegrations(); const { slackIntegrations, hasSlackIntegration } = useIntegrationSelectors(); diff --git a/apps/code/src/renderer/features/setup/services/setupRunService.ts b/apps/code/src/renderer/features/setup/services/setupRunService.ts index eda36203ea..373352fcd2 100644 --- a/apps/code/src/renderer/features/setup/services/setupRunService.ts +++ b/apps/code/src/renderer/features/setup/services/setupRunService.ts @@ -351,7 +351,7 @@ export class SetupRunService { const apiHost = authState.cloudRegion ? getCloudUrlFromRegion(authState.cloudRegion) : null; - const projectId = authState.projectId; + const projectId = authState.currentProjectId; if (!apiHost || !projectId) { log.error("Missing auth for discovery", { apiHost, projectId }); diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx index 7aaa897d27..08d182b228 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx @@ -1,8 +1,12 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useLogoutMutation, useSelectProjectMutation, } from "@features/auth/hooks/authMutations"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { + useAuthStateValue, + useCurrentUser, +} 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"; @@ -61,10 +65,11 @@ export function ProjectSwitcher() { const [dialogOpen, setDialogOpen] = useState(false); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const client = useOptionalAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ client }); const selectProjectMutation = useSelectProjectMutation(); const logoutMutation = useLogoutMutation(); - const { groupedProjects, currentProject, currentProjectId, currentUser } = - useProjects(); + const { groupedProjects, currentProject, currentProjectId } = useProjects(); const handleProjectSelect = (projectId: number) => { if (projectId !== currentProjectId) { diff --git a/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx b/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx index 2e8b37bdad..b97fbcd54b 100644 --- a/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx +++ b/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx @@ -8,7 +8,7 @@ import { ArrowSquareOutIcon, InfoIcon } from "@phosphor-icons/react"; import { Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; export function CloudGithubMissingNotice() { - const projectId = useAuthStateValue((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.currentProjectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const { hasGithubIntegration: hasTeamGithubIntegration } = useRepositoryIntegration(); diff --git a/apps/code/src/renderer/features/terminal/services/TerminalManager.test.ts b/apps/code/src/renderer/features/terminal/services/TerminalManager.test.ts new file mode 100644 index 0000000000..6fec18102a --- /dev/null +++ b/apps/code/src/renderer/features/terminal/services/TerminalManager.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const checkQuery = vi.fn(); + const createMutate = vi.fn(); + const createCommandMutate = vi.fn(); + const writeMutate = vi.fn(); + const resizeMutate = vi.fn(); + const openExternalMutate = vi.fn(); + const logInfo = vi.fn(); + const logError = vi.fn(); + + class MockTerminal { + cols = 80; + rows = 24; + options: Record; + dataHandler: ((data: string) => void) | null = null; + loadAddon = vi.fn(); + attachCustomKeyEventHandler = vi.fn(); + write = vi.fn(); + writeln = vi.fn(); + clear = vi.fn(); + refresh = vi.fn(); + focus = vi.fn(); + dispose = vi.fn(); + + constructor(options: Record) { + this.options = options; + terminalInstances.push(this); + } + + onData(handler: (data: string) => void) { + this.dataHandler = handler; + return { dispose: vi.fn() }; + } + + open(element: HTMLElement) { + const terminalElement = document.createElement("div"); + terminalElement.className = "xterm"; + element.appendChild(terminalElement); + } + + emitData(data: string) { + this.dataHandler?.(data); + } + } + + const terminalInstances: MockTerminal[] = []; + + return { + checkQuery, + createMutate, + createCommandMutate, + writeMutate, + resizeMutate, + openExternalMutate, + logInfo, + logError, + MockTerminal, + terminalInstances, + }; +}); + +vi.mock("@renderer/trpc", () => ({ + trpcClient: { + shell: { + check: { query: mocks.checkQuery }, + create: { mutate: mocks.createMutate }, + createCommand: { mutate: mocks.createCommandMutate }, + write: { mutate: mocks.writeMutate }, + resize: { mutate: mocks.resizeMutate }, + }, + os: { + openExternal: { mutate: mocks.openExternalMutate }, + }, + }, +})); + +vi.mock("@utils/logger", () => ({ + logger: { + scope: () => ({ + info: mocks.logInfo, + error: mocks.logError, + }), + }, +})); + +vi.mock("@utils/platform", () => ({ + isMac: false, +})); + +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class { + fit = vi.fn(); + }, +})); + +vi.mock("@xterm/addon-serialize", () => ({ + SerializeAddon: class { + serialize = vi.fn(() => "serialized-terminal-state"); + }, +})); + +vi.mock("@xterm/addon-web-links", () => ({ + WebLinksAddon: class {}, +})); + +vi.mock("@xterm/xterm", () => ({ + Terminal: mocks.MockTerminal, +})); + +import { terminalManager } from "./TerminalManager"; + +describe("TerminalManager shell recovery", () => { + const sessionId = "shell-recovery-test"; + + beforeEach(() => { + mocks.checkQuery.mockReset(); + mocks.createMutate.mockReset(); + mocks.createCommandMutate.mockReset(); + mocks.writeMutate.mockReset(); + mocks.resizeMutate.mockReset(); + mocks.openExternalMutate.mockReset(); + mocks.logInfo.mockReset(); + mocks.logError.mockReset(); + mocks.terminalInstances.length = 0; + + mocks.checkQuery.mockResolvedValue(true); + mocks.createMutate.mockResolvedValue(undefined); + mocks.createCommandMutate.mockResolvedValue(undefined); + mocks.writeMutate.mockResolvedValue(undefined); + mocks.resizeMutate.mockResolvedValue(undefined); + }); + + afterEach(() => { + terminalManager.destroy(sessionId); + }); + + it("recreates a missing interactive shell and retries the triggering input", async () => { + terminalManager.create({ + sessionId, + persistenceKey: "task-1-shell", + cwd: "/repo", + taskId: "task-1", + }); + + await vi.waitFor(() => { + expect(mocks.checkQuery).toHaveBeenCalledWith({ sessionId }); + }); + + mocks.checkQuery.mockResolvedValueOnce(false); + mocks.writeMutate + .mockRejectedValueOnce(new Error(`Shell session ${sessionId} not found`)) + .mockResolvedValue(undefined); + + mocks.terminalInstances[0].emitData("a"); + + await vi.waitFor(() => { + expect(mocks.createMutate).toHaveBeenCalledWith({ + sessionId, + cwd: "/repo", + taskId: "task-1", + }); + }); + + await vi.waitFor(() => { + expect(mocks.writeMutate).toHaveBeenCalledTimes(2); + }); + + expect(mocks.writeMutate.mock.calls[1][0]).toEqual({ + sessionId, + data: "a", + }); + }); +}); diff --git a/apps/code/src/renderer/features/terminal/services/TerminalManager.ts b/apps/code/src/renderer/features/terminal/services/TerminalManager.ts index 44d4d6e03a..54bafddfee 100644 --- a/apps/code/src/renderer/features/terminal/services/TerminalManager.ts +++ b/apps/code/src/renderer/features/terminal/services/TerminalManager.ts @@ -1,4 +1,5 @@ import { trpcClient } from "@renderer/trpc"; +import { getErrorMessage } from "@shared/errors"; import { logger } from "@utils/logger"; import { isMac } from "@utils/platform"; import { FitAddon } from "@xterm/addon-fit"; @@ -40,6 +41,8 @@ export interface TerminalInstance { persistenceKey: string; cwd?: string; taskId?: string; + command?: string; + recoveryPromise: Promise | null; } export interface CreateOptions { @@ -139,6 +142,15 @@ function attachKeyHandlers(term: XTerm) { }); } +function isMissingShellSessionError( + error: unknown, + sessionId: string, +): boolean { + return getErrorMessage(error).includes( + `Shell session ${sessionId} not found`, + ); +} + class TerminalManagerImpl { private instances = new Map(); private listeners = new Map>>(); @@ -189,6 +201,8 @@ class TerminalManagerImpl { persistenceKey, cwd, taskId, + command, + recoveryPromise: null, }; if (initialState) { @@ -200,7 +214,10 @@ class TerminalManagerImpl { trpcClient.shell.write .mutate({ sessionId, data }) .catch((error: Error) => { - log.error("Failed to write to shell:", error); + this.handleMissingSessionError(sessionId, instance, error, { + reason: "write", + retryData: data, + }); }); this.scheduleSave(sessionId, instance); }); @@ -297,6 +314,66 @@ class TerminalManagerImpl { } } + private handleMissingSessionError( + sessionId: string, + instance: TerminalInstance, + error: unknown, + options: { reason: "write" | "resize"; retryData?: string }, + ): void { + if (!isMissingShellSessionError(error, sessionId)) { + log.error(`Failed to ${options.reason} shell:`, error); + return; + } + + this.recoverMissingSession(sessionId, instance, options.reason) + .then(() => { + if (options.retryData === undefined || !instance.isReady) { + return; + } + + return trpcClient.shell.write + .mutate({ sessionId, data: options.retryData }) + .catch((retryError: Error) => { + log.error( + "Failed to retry write after shell recovery:", + retryError, + ); + }); + }) + .catch((recoveryError: Error) => { + log.error("Failed to recover missing shell session:", recoveryError); + }); + } + + private recoverMissingSession( + sessionId: string, + instance: TerminalInstance, + reason: "write" | "resize", + ): Promise { + if (instance.command) { + this.handleExit(sessionId); + return Promise.resolve(); + } + + if (instance.recoveryPromise) { + return instance.recoveryPromise; + } + + log.info("Recovering missing shell session", { sessionId, reason }); + instance.isReady = false; + + instance.recoveryPromise = this.initializeSession( + sessionId, + instance, + instance.cwd, + instance.taskId, + ).finally(() => { + instance.recoveryPromise = null; + }); + + return instance.recoveryPromise; + } + private scheduleSave(sessionId: string, instance: TerminalInstance): void { if (instance.saveTimeout) { clearTimeout(instance.saveTimeout); @@ -348,7 +425,9 @@ class TerminalManagerImpl { rows: instance.term.rows, }) .catch((error: Error) => { - log.error("Failed to resize shell:", error); + this.handleMissingSessionError(sessionId, instance, error, { + reason: "resize", + }); }); } } diff --git a/apps/code/src/renderer/features/terminal/stores/terminalStore.test.ts b/apps/code/src/renderer/features/terminal/stores/terminalStore.test.ts new file mode 100644 index 0000000000..05fdeb89f8 --- /dev/null +++ b/apps/code/src/renderer/features/terminal/stores/terminalStore.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { getProcess, managerOn } = vi.hoisted(() => ({ + getProcess: vi.fn(), + managerOn: vi.fn(() => vi.fn()), +})); + +vi.mock("@renderer/trpc/client", () => ({ + trpcClient: { + shell: { + getProcess: { + query: getProcess, + }, + }, + }, +})); + +vi.mock("../services/TerminalManager", () => ({ + terminalManager: { + on: managerOn, + }, +})); + +import { clearPersistedSessionIds, useTerminalStore } from "./terminalStore"; + +describe("terminalStore persistence", () => { + beforeEach(() => { + localStorage.clear(); + getProcess.mockReset(); + managerOn.mockClear(); + useTerminalStore.setState({ + terminalStates: {}, + pollingIntervals: {}, + }); + }); + + it("does not persist process-local shell session ids", () => { + useTerminalStore + .getState() + .setSerializedState("task-1-shell", "scrollback"); + useTerminalStore + .getState() + .setSessionId("task-1-shell", "shell-stale-session"); + + const persisted = JSON.parse(localStorage.getItem("terminal-store") ?? ""); + + expect(persisted.state.terminalStates["task-1-shell"]).toEqual({ + serializedState: "scrollback", + sessionId: null, + }); + }); + + it("clears session ids from old persisted terminal state", () => { + expect( + clearPersistedSessionIds({ + terminalStates: { + "task-1-shell": { + serializedState: "scrollback", + sessionId: "shell-stale-session", + }, + }, + }), + ).toEqual({ + terminalStates: { + "task-1-shell": { + serializedState: "scrollback", + sessionId: null, + }, + }, + }); + }); +}); diff --git a/apps/code/src/renderer/features/terminal/stores/terminalStore.ts b/apps/code/src/renderer/features/terminal/stores/terminalStore.ts index 859aff6ac2..7a1cea25c9 100644 --- a/apps/code/src/renderer/features/terminal/stores/terminalStore.ts +++ b/apps/code/src/renderer/features/terminal/stores/terminalStore.ts @@ -22,12 +22,49 @@ interface TerminalStoreState { stopPolling: (key: string) => void; } +type PersistedTerminalStoreState = { + terminalStates: Record< + string, + { + serializedState: string | null; + sessionId: null; + } + >; +}; + const DEFAULT_TERMINAL_STATE: TerminalState = { serializedState: null, sessionId: null, processName: null, }; +export function clearPersistedSessionIds(persistedState: unknown) { + if (!persistedState || typeof persistedState !== "object") { + return persistedState; + } + + const state = persistedState as { + terminalStates?: Record>; + }; + + if (!state.terminalStates || typeof state.terminalStates !== "object") { + return persistedState; + } + + return { + ...state, + terminalStates: Object.fromEntries( + Object.entries(state.terminalStates).map(([key, value]) => [ + key, + { + ...value, + sessionId: null, + }, + ]), + ), + }; +} + export const useTerminalStore = create()( persist( (set, get) => ({ @@ -132,11 +169,14 @@ export const useTerminalStore = create()( }), { name: "terminal-store", - partialize: (state) => ({ + version: 1, + migrate: (persistedState) => + clearPersistedSessionIds(persistedState) as PersistedTerminalStoreState, + partialize: (state): PersistedTerminalStoreState => ({ terminalStates: Object.fromEntries( Object.entries(state.terminalStates).map(([k, v]) => [ k, - { serializedState: v.serializedState, sessionId: v.sessionId }, + { serializedState: v.serializedState, sessionId: null }, ]), ), }), diff --git a/apps/code/src/renderer/hooks/useProjectQuery.ts b/apps/code/src/renderer/hooks/useProjectQuery.ts index a0137df388..aa845ca9d2 100644 --- a/apps/code/src/renderer/hooks/useProjectQuery.ts +++ b/apps/code/src/renderer/hooks/useProjectQuery.ts @@ -2,7 +2,7 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; export function useProjectQuery() { - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); return useAuthenticatedQuery( ["project", projectId], diff --git a/apps/code/src/renderer/utils/posthogLinks.ts b/apps/code/src/renderer/utils/posthogLinks.ts index 5512b0ea4c..0d274a9ae6 100644 --- a/apps/code/src/renderer/utils/posthogLinks.ts +++ b/apps/code/src/renderer/utils/posthogLinks.ts @@ -9,7 +9,7 @@ export interface LinkOverrides { function resolveProjectId(override?: number | null): number | null { if (override != null) return override; - return getCachedAuthState().projectId ?? null; + return getCachedAuthState().currentProjectId ?? null; } function withProjectId( diff --git a/apps/code/src/shared/constants/oauth.test.ts b/apps/code/src/shared/constants/oauth.test.ts index 4aac1ce9f2..0c94e061d4 100644 --- a/apps/code/src/shared/constants/oauth.test.ts +++ b/apps/code/src/shared/constants/oauth.test.ts @@ -8,7 +8,7 @@ describe("OAUTH_SCOPES guard", () => { scopes: OAUTH_SCOPES, }).toMatchInlineSnapshot(` { - "scopeVersion": 4, + "scopeVersion": 5, "scopes": [ "*", ], diff --git a/apps/code/src/shared/constants/oauth.ts b/apps/code/src/shared/constants/oauth.ts index f59ce0cca2..076734aee2 100644 --- a/apps/code/src/shared/constants/oauth.ts +++ b/apps/code/src/shared/constants/oauth.ts @@ -7,7 +7,7 @@ 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; +export const OAUTH_SCOPE_VERSION = 5; // Token refresh settings export const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000; // 30 minutes before expiry diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index a5e701366b..b029dfb24b 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -743,6 +743,7 @@ export const ANALYTICS_EVENTS = { ONBOARDING_ABANDONED: "Onboarding abandoned", AI_CONSENT_GATE_SHOWN: "Ai consent gate shown", AI_CONSENT_APPROVED: "Ai consent approved", + AI_CONSENT_GRANTED_INAPP: "Ai consent granted in-app", // Setup / onboarding events SETUP_DISCOVERY_STARTED: "Setup discovery started", @@ -864,6 +865,7 @@ export type EventPropertyMap = { [ANALYTICS_EVENTS.ONBOARDING_ABANDONED]: OnboardingAbandonedProperties; [ANALYTICS_EVENTS.AI_CONSENT_GATE_SHOWN]: AiConsentGateShownProperties; [ANALYTICS_EVENTS.AI_CONSENT_APPROVED]: never; + [ANALYTICS_EVENTS.AI_CONSENT_GRANTED_INAPP]: never; // Setup / onboarding events [ANALYTICS_EVENTS.SETUP_DISCOVERY_STARTED]: SetupDiscoveryStartedProperties; diff --git a/apps/mobile/src/app/(tabs)/inbox.tsx b/apps/mobile/src/app/(tabs)/inbox.tsx index 7279820b7a..0c892acafc 100644 --- a/apps/mobile/src/app/(tabs)/inbox.tsx +++ b/apps/mobile/src/app/(tabs)/inbox.tsx @@ -1,5 +1,5 @@ -import { useRouter } from "expo-router"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useFocusEffect, useRouter } from "expo-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { FilterSheet } from "@/features/inbox/components/FilterSheet"; @@ -13,29 +13,92 @@ import { decidedIds, useDismissedReportsStore, } from "@/features/inbox/stores/dismissedReportsStore"; -import { useInboxFilterStore } from "@/features/inbox/stores/inboxFilterStore"; +import { + DEFAULT_STATUS_FILTER, + useInboxFilterStore, +} from "@/features/inbox/stores/inboxFilterStore"; import { useInboxStore } from "@/features/inbox/stores/inboxStore"; import type { SignalReport } from "@/features/inbox/types"; +import { buildInboxViewedProperties } from "@/features/inbox/utils"; import { useIntegrations } from "@/features/tasks/hooks/useIntegrations"; +import { ANALYTICS_EVENTS, useAnalytics } from "@/lib/analytics"; type InboxViewMode = "list" | "tinder"; export default function InboxScreen() { const insets = useSafeAreaInsets(); const router = useRouter(); - const { reports, isFetching, isLoading, error } = useInboxReports(); + const { reports, totalCount, isFetching, isLoading, error } = + useInboxReports(); const [filterOpen, setFilterOpen] = useState(false); const [reviewerOpen, setReviewerOpen] = useState(false); const [viewMode, setViewMode] = useState("list"); const reviewerFilterCount = useInboxFilterStore( (s) => s.suggestedReviewerFilter.length, ); + const sourceProductFilter = useInboxFilterStore((s) => s.sourceProductFilter); + const statusFilter = useInboxFilterStore((s) => s.statusFilter); + const suggestedReviewerFilter = useInboxFilterStore( + (s) => s.suggestedReviewerFilter, + ); + + const analytics = useAnalytics(); + // Fire INBOX_VIEWED once per focus when the report list has settled. We + // bump a focus counter on every focus so the useEffect re-runs even when + // the data is already cached (no loading/filter/list change to trigger it + // on its own), then guard against double-fires within the same focus via + // a ref keyed on the focus-version we last fired for. + const [focusVersion, setFocusVersion] = useState(0); + useFocusEffect( + useCallback(() => { + setFocusVersion((v) => v + 1); + }, []), + ); + const viewedFiredForFocusRef = useRef(null); + useEffect(() => { + if (focusVersion === 0) return; + if (isLoading) return; + if (viewedFiredForFocusRef.current === focusVersion) return; + viewedFiredForFocusRef.current = focusVersion; + analytics.track( + ANALYTICS_EVENTS.INBOX_VIEWED, + buildInboxViewedProperties(reports, totalCount, { + sourceProductFilter, + statusFilter, + suggestedReviewerFilter, + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }), + ); + }, [ + analytics, + focusVersion, + isLoading, + reports, + totalCount, + sourceProductFilter, + statusFilter, + suggestedReviewerFilter, + ]); // ── Tinder mode data ────────────────────────────────────────────────────── const decided = useDismissedReportsStore(decidedIds); const setCurrentIndex = useInboxStore((s) => s.setCurrentIndex); + const setLastVisibleReportIds = useInboxStore( + (s) => s.setLastVisibleReportIds, + ); const { repositoryOptions } = useIntegrations(); + // Snapshot the visible-list IDs into the store so the detail screen can + // record rank/list_size on OPENED. Only the list view exposes a rank — the + // tinder card stack swaps cards in place. + useEffect(() => { + if (viewMode === "list") { + setLastVisibleReportIds(reports.map((r) => r.id)); + } else { + setLastVisibleReportIds([]); + } + }, [viewMode, reports, setLastVisibleReportIds]); + // Same data as the list view, excluding already-decided reports. const tinderReports = useMemo( () => reports.filter((r) => !decided.includes(r.id)), diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index d68ae5b0ce..d51e1a6896 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -14,20 +14,33 @@ import { } from "phosphor-react-native"; import { usePostHog } from "posthog-react-native"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { + ActivityIndicator, + type NativeScrollEvent, + type NativeSyntheticEvent, + Pressable, + ScrollView, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useUserQuery } from "@/features/auth/hooks/useUserQuery"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet"; -import { DismissReportSheet } from "@/features/inbox/components/DismissReportSheet"; +import { + type DismissReportResult, + DismissReportSheet, +} from "@/features/inbox/components/DismissReportSheet"; import { SignalCard } from "@/features/inbox/components/SignalCard"; import { SuggestedReviewers } from "@/features/inbox/components/SuggestedReviewers"; +import { DISMISSAL_REASON_OPTIONS } from "@/features/inbox/constants"; +import { useInboxEngagementTracker } from "@/features/inbox/hooks/useInboxEngagementTracker"; import { useInboxReport, useInboxReportArtefacts, useInboxReportSignals, } from "@/features/inbox/hooks/useInboxReports"; +import { useInboxStore } from "@/features/inbox/stores/inboxStore"; import type { ActionabilityJudgmentContent, SignalFindingContent, @@ -39,6 +52,7 @@ import { inboxStatusLabel, orderSuggestedReviewers, } from "@/features/inbox/utils"; +import { computeReportAgeHours, useAnalytics } from "@/lib/analytics"; import { useThemeColors } from "@/lib/theme"; const statusColorMap: Record = { @@ -133,6 +147,59 @@ export default function ReportDetailScreen() { const artefactsQuery = useInboxReportArtefacts(reportId ?? null); const signalsQuery = useInboxReportSignals(reportId ?? null); + // ── Engagement analytics ──────────────────────────────────────────────── + const analytics = useAnalytics(); + const lastVisibleReportIds = useInboxStore((s) => s.lastVisibleReportIds); + const previousOpenedReportId = useInboxStore((s) => s.previousOpenedReportId); + const setPreviousOpenedReportId = useInboxStore( + (s) => s.setPreviousOpenedReportId, + ); + const rank = useMemo(() => { + if (!reportId) return -1; + const idx = lastVisibleReportIds.indexOf(reportId); + return idx; + }, [reportId, lastVisibleReportIds]); + const listSize = lastVisibleReportIds.length; + const tracker = useInboxEngagementTracker({ + analytics, + report: report ?? null, + rank, + listSize, + openMethod: "click", + previousReportId: previousOpenedReportId, + }); + // Remember this report as the "previous" once it's been opened so the next + // OPENED event can chain to it. + useEffect(() => { + if (!reportId) return; + setPreviousOpenedReportId(reportId); + }, [reportId, setPreviousOpenedReportId]); + + const handleScroll = useCallback( + (_event: NativeSyntheticEvent) => { + tracker.signalScroll(); + }, + [tracker], + ); + + const handleToggleSignals = useCallback(() => { + // Fire analytics outside the state updater — Strict Mode double-invokes + // updaters in development, which would double-fire the event. + const next = !signalsExpanded; + if (next && report) { + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: "expand_signal", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + }); + } + setSignalsExpanded(next); + }, [report, tracker, signalsExpanded]); + useEffect(() => { if (!reportId) return; let cancelled = false; @@ -193,6 +260,15 @@ export default function ReportDetailScreen() { const handleStartTask = useCallback(() => { if (!report) return; Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: "create_pr", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + }); const prompt = `Act on this signal report. Investigate the root cause, implement the fix, and open a PR if appropriate.\n\n${report.summary ?? ""}`; router.push({ pathname: "/task", @@ -202,12 +278,41 @@ export default function ReportDetailScreen() { signalReport: report.id, }, }); - }, [report, router, reportRepo]); - - const handleDismissed = useCallback(() => { - setDismissOpen(false); - if (router.canGoBack()) router.back(); - }, [router]); + }, [report, router, reportRepo, tracker]); + + const handleDismissed = useCallback( + (result: DismissReportResult) => { + setDismissOpen(false); + if (report) { + const reasonOption = DISMISSAL_REASON_OPTIONS.find( + (o) => o.value === result.reason, + ); + const isSnooze = + reasonOption !== undefined && + "snoozesInsteadOfDismiss" in reasonOption && + reasonOption.snoozesInsteadOfDismiss === true; + tracker.signalAction({ + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + action_type: isSnooze ? "snooze" : "dismiss", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + ...(isSnooze + ? {} + : { + dismissal_reason: result.reason, + ...(result.note + ? { dismissal_note: result.note.slice(0, 1000) } + : {}), + }), + }); + } + if (router.canGoBack()) router.back(); + }, + [router, report, tracker], + ); const handleDiscussSubmit = useCallback( ({ prompt, question }: { prompt: string; question: string }) => { @@ -299,6 +404,8 @@ export default function ReportDetailScreen() { paddingTop: 16, paddingBottom: insets.bottom + 100, }} + onScroll={handleScroll} + scrollEventThrottle={250} > {/* Badges row */} @@ -379,7 +486,7 @@ export default function ReportDetailScreen() { {signals.length > 0 && ( setSignalsExpanded((v) => !v)} + onPress={handleToggleSignals} hitSlop={6} accessibilityRole="button" accessibilityState={{ expanded: signalsExpanded }} diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 8c70304751..a6acd60af4 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -41,6 +41,7 @@ import { useTaskStore } from "@/features/tasks/stores/taskStore"; import type { Task } from "@/features/tasks/types"; import { getSessionActivityPhase } from "@/features/tasks/utils/sessionActivity"; import { useScreenInsets } from "@/hooks/useScreenInsets"; +import { useActiveTaskAnalyticsContext } from "@/lib/analytics"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; @@ -94,6 +95,11 @@ export default function TaskDetailScreen() { }; }, [taskId, setFocusedTaskId]); + // Tag every PostHog event fired while this task is open with the originating + // inbox report id, so a discuss-launched run can be filtered down in PostHog. + // Cleared when the screen unmounts. Matches the desktop super-property. + useActiveTaskAnalyticsContext(task?.signal_report ?? null); + const session = taskId ? getSessionForTask(taskId) : undefined; // Optimistic echo set by the new-task screen (or the terminal-resume path diff --git a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx index ade712ab78..65fc913399 100644 --- a/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx +++ b/apps/mobile/src/features/inbox/components/DismissReportSheet.tsx @@ -20,12 +20,23 @@ import { } from "../constants"; import { useDismissReport } from "../hooks/useInboxReports"; +export interface DismissReportResult { + reason: DismissalReasonOptionValue; + /** Trimmed note text the user provided, if any. Empty/whitespace-only notes become null. */ + note: string | null; +} + interface DismissReportSheetProps { visible: boolean; reportId: string; reportTitle: string; onClose: () => void; - onDismissed: () => void; + /** + * Fires after the API confirms the dismissal. The result is passed back so + * callers can route the reason/note through their own analytics — keeping + * this sheet stateless about the surface it was launched from. + */ + onDismissed: (result: DismissReportResult) => void; } export function DismissReportSheet({ @@ -53,10 +64,14 @@ export function DismissReportSheet({ const handleConfirm = async () => { if (!reason || dismiss.isPending) return; setError(null); + const trimmedNote = note.trim(); try { - await dismiss.mutateAsync({ reason, note: note.trim() || undefined }); + await dismiss.mutateAsync({ + reason, + note: trimmedNote || undefined, + }); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - onDismissed(); + onDismissed({ reason, note: trimmedNote || null }); } catch (err) { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); setError( diff --git a/apps/mobile/src/features/inbox/components/TinderView.tsx b/apps/mobile/src/features/inbox/components/TinderView.tsx index ae468b8c4e..5f8928555a 100644 --- a/apps/mobile/src/features/inbox/components/TinderView.tsx +++ b/apps/mobile/src/features/inbox/components/TinderView.tsx @@ -22,6 +22,11 @@ import type { CreateTaskOptions, RepositoryOption, } from "@/features/tasks/types"; +import { + ANALYTICS_EVENTS, + computeReportAgeHours, + useAnalytics, +} from "@/lib/analytics"; import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; import { getReportRepository } from "../api"; @@ -138,6 +143,34 @@ export function TinderView({ const dismissReport = useDismissedReportsStore((s) => s.dismissReport); const acceptReport = useDismissedReportsStore((s) => s.acceptReport); + const analytics = useAnalytics(); + + const trackReportAction = useCallback( + ( + report: SignalReport, + actionType: "dismiss" | "create_pr", + position: number, + total: number, + ) => { + analytics.track(ANALYTICS_EVENTS.INBOX_REPORT_ACTION, { + report_id: report.id, + report_title: report.title ?? null, + report_age_hours: computeReportAgeHours(report.created_at), + priority: report.priority ?? null, + actionability: report.actionability ?? null, + action_type: actionType, + // Tinder cards stack like a list of rows the user is acting on + // without opening a detail view — closest desktop analogue. + surface: "list_row", + is_bulk: false, + bulk_size: 1, + rank: position, + list_size: total, + }); + }, + [analytics], + ); + // Local state const [expandedReport, setExpandedReport] = useState( null, @@ -162,15 +195,22 @@ export function TinderView({ toastTimer.current = setTimeout(() => setToast(null), 10_000); }, []); + const reportsRef = useRef(reports); + reportsRef.current = reports; + const handleDismiss = useCallback( (reportId: string) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const visible = reportsRef.current; + const idx = visible.findIndex((r) => r.id === reportId); + const target = idx >= 0 ? visible[idx] : null; + if (target) trackReportAction(target, "dismiss", idx, visible.length); dismissReport(reportId); // Don't advanceCard() — the parent filters dismissed IDs from the // reports array, so removing the report shifts the next one into // the current index position automatically. }, - [dismissReport], + [dismissReport, trackReportAction], ); const handleAccept = useCallback( @@ -178,6 +218,11 @@ export function TinderView({ setCreating(true); setError(null); showToastPending(report.title ?? "Untitled report"); + // Snapshot rank/list_size before the swipe completes — accepting filters + // the report out of the visible deck. + const visibleBefore = reportsRef.current; + const acceptedRank = visibleBefore.findIndex((r) => r.id === report.id); + const acceptedListSize = visibleBefore.length; try { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); @@ -214,6 +259,7 @@ export function TinderView({ }); acceptReport(report.id); + trackReportAction(report, "create_pr", acceptedRank, acceptedListSize); showToastDone(task.id, report.title ?? "Untitled report"); } catch (e) { const message = @@ -225,7 +271,13 @@ export function TinderView({ setCreating(false); } }, - [repositoryOptions, showToastPending, showToastDone, acceptReport], + [ + repositoryOptions, + showToastPending, + showToastDone, + acceptReport, + trackReportAction, + ], ); const currentReport = diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts new file mode 100644 index 0000000000..2e970def6b --- /dev/null +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.test.ts @@ -0,0 +1,273 @@ +import { createElement } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// posthog-react-native pulls in real react-native at import time, which vitest +// can't parse. The hook only uses `Analytics.track`, which is passed in by the +// caller — so mocking the module to a no-op keeps the import graph quiet. +vi.mock("posthog-react-native", () => ({ + usePostHog: () => null, +})); + +import { ANALYTICS_EVENTS, type Analytics } from "@/lib/analytics"; +import type { SignalReport } from "../types"; +import { + type InboxEngagementTracker, + type UseInboxEngagementTrackerOptions, + useInboxEngagementTracker, +} from "./useInboxEngagementTracker"; + +function makeReport(overrides: Partial = {}): SignalReport { + return { + id: "r1", + title: "Report 1", + summary: null, + status: "ready", + total_weight: 0, + signal_count: 0, + created_at: "2026-01-01T12:00:00Z", + updated_at: "2026-01-01T12:00:00Z", + artefact_count: 0, + priority: "P1", + actionability: "immediately_actionable", + source_products: ["error_tracking"], + ...overrides, + }; +} + +function renderTracker(initial: UseInboxEngagementTrackerOptions) { + const trackerRef: { current: InboxEngagementTracker | null } = { + current: null, + }; + let currentOptions = initial; + function Wrapper() { + trackerRef.current = useInboxEngagementTracker(currentOptions); + return null; + } + let renderer: ReactTestRenderer | null = null; + act(() => { + renderer = create(createElement(Wrapper)); + }); + return { + tracker: () => { + if (!trackerRef.current) throw new Error("tracker not initialised"); + return trackerRef.current; + }, + rerender: (next: UseInboxEngagementTrackerOptions) => { + currentOptions = next; + act(() => { + renderer?.update(createElement(Wrapper)); + }); + }, + unmount: () => { + act(() => { + renderer?.unmount(); + }); + }, + }; +} + +describe("useInboxEngagementTracker", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T13:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("fires OPENED on mount with the right snapshot of report fields", () => { + const track = vi.fn(); + const analytics: Analytics = { track }; + const report = makeReport(); + renderTracker({ + analytics, + report, + rank: 2, + listSize: 5, + openMethod: "click", + previousReportId: "prev-1", + }); + expect(track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.INBOX_REPORT_OPENED, + expect.objectContaining({ + report_id: "r1", + report_title: "Report 1", + report_age_hours: 1, + status: "ready", + priority: "P1", + actionability: "immediately_actionable", + source_products: ["error_tracking"], + rank: 2, + list_size: 5, + open_method: "click", + previous_report_id: "prev-1", + }), + ); + }); + + it("fires CLOSED on unmount with time_spent, scrolled, close_method", () => { + const track = vi.fn(); + const report = makeReport(); + const hook = renderTracker({ + analytics: { track }, + report, + rank: 0, + listSize: 1, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalScroll(); + }); + vi.advanceTimersByTime(2500); + hook.unmount(); + const closeCall = track.mock.calls.find( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, + ); + expect(closeCall).toBeDefined(); + expect(closeCall?.[1]).toMatchObject({ + report_id: "r1", + scrolled: true, + close_method: "deselected", + priority: "P1", + actionability: "immediately_actionable", + }); + expect((closeCall?.[1] as { time_spent_ms: number }).time_spent_ms).toBe( + 2500, + ); + }); + + it("fires SCROLLED at most once per open", () => { + const track = vi.fn(); + const hook = renderTracker({ + analytics: { track }, + report: makeReport(), + rank: 0, + listSize: 1, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalScroll(); + hook.tracker().signalScroll(); + hook.tracker().signalScroll(); + }); + const scrollCalls = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED, + ); + expect(scrollCalls).toHaveLength(1); + }); + + it("signalAction inherits rank/list_size/priority/actionability from the current open", () => { + const track = vi.fn(); + const report = makeReport({ priority: "P0" }); + const hook = renderTracker({ + analytics: { track }, + report, + rank: 3, + listSize: 7, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalAction({ + report_id: "r1", + report_title: "Report 1", + report_age_hours: 1, + action_type: "create_pr", + surface: "detail_pane", + is_bulk: false, + bulk_size: 1, + }); + }); + const actionCall = track.mock.calls.find( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_ACTION, + ); + expect(actionCall?.[1]).toMatchObject({ + rank: 3, + list_size: 7, + priority: "P0", + actionability: "immediately_actionable", + }); + }); + + it("does not re-fire OPENED/CLOSED when rank/listSize/report change while the same report stays open", () => { + // Regression for a background-refetch spike: rank, listSize, and the + // report shape are inputs to OPENED but only `reportId` should gate the + // open/close lifecycle. + const track = vi.fn(); + const report = makeReport(); + const hook = renderTracker({ + analytics: { track }, + report, + rank: 2, + listSize: 5, + openMethod: "click", + previousReportId: null, + }); + const openedBefore = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_OPENED, + ).length; + const closedBefore = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, + ).length; + hook.rerender({ + analytics: { track }, + report: makeReport({ priority: "P2", actionability: "not_actionable" }), + rank: 4, + listSize: 6, + openMethod: "click", + previousReportId: null, + }); + const openedAfter = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_OPENED, + ).length; + const closedAfter = track.mock.calls.filter( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, + ).length; + expect(openedAfter).toBe(openedBefore); + expect(closedAfter).toBe(closedBefore); + }); + + it("signalAction lets explicit overrides win for a different report", () => { + const track = vi.fn(); + const hook = renderTracker({ + analytics: { track }, + report: makeReport(), + rank: 0, + listSize: 1, + openMethod: "click", + previousReportId: null, + }); + act(() => { + hook.tracker().signalAction({ + report_id: "other-report", + report_title: "Other", + report_age_hours: 0, + action_type: "dismiss", + surface: "toolbar", + is_bulk: false, + bulk_size: 1, + rank: 9, + list_size: 12, + priority: "P4", + actionability: "not_actionable", + dismissal_reason: "other", + dismissal_note: "junk", + }); + }); + const actionCall = track.mock.calls.find( + ([name]) => name === ANALYTICS_EVENTS.INBOX_REPORT_ACTION, + ); + expect(actionCall?.[1]).toMatchObject({ + report_id: "other-report", + rank: 9, + list_size: 12, + priority: "P4", + actionability: "not_actionable", + dismissal_reason: "other", + dismissal_note: "junk", + }); + }); +}); diff --git a/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts new file mode 100644 index 0000000000..67ac03e03a --- /dev/null +++ b/apps/mobile/src/features/inbox/hooks/useInboxEngagementTracker.ts @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useRef } from "react"; +import { + ANALYTICS_EVENTS, + type Analytics, + computeReportAgeHours, + type InboxReportActionProperties, + type InboxReportCloseMethod, + type InboxReportOpenMethod, +} from "@/lib/analytics"; +import type { SignalReport } from "../types"; + +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 { + signalScroll(): void; + signalAction( + action: Omit< + InboxReportActionProperties, + "rank" | "list_size" | "priority" | "actionability" + > & { + rank?: number; + list_size?: number; + priority?: string | null; + actionability?: string | null; + }, + ): void; +} + +export interface UseInboxEngagementTrackerOptions { + analytics: Analytics; + report: SignalReport | null; + /** Rank of the report in the visible inbox list, or -1 if not in a list view. */ + rank: number; + /** Size of the visible inbox list, or 0 if not in a list view. */ + listSize: number; + /** Method that brought the user to this report. */ + openMethod: InboxReportOpenMethod; + /** Previously-opened report id; null on the first open of a session. */ + previousReportId: string | null; +} + +export function useInboxEngagementTracker( + options: UseInboxEngagementTrackerOptions, +): InboxEngagementTracker { + const { analytics, report, rank, listSize, openMethod, previousReportId } = + options; + + const openInfoRef = useRef(null); + + const analyticsRef = useRef(analytics); + analyticsRef.current = analytics; + + // Snapshot the inputs through refs so the OPENED/CLOSED lifecycle effect + // can read them without being a dep — a background list refetch (rank / + // listSize / report shape changing while the user is reading) would + // otherwise fire spurious CLOSED+OPENED pairs. + const reportRef = useRef(report); + reportRef.current = report; + const rankRef = useRef(rank); + rankRef.current = rank; + const listSizeRef = useRef(listSize); + listSizeRef.current = listSize; + const openMethodRef = useRef(openMethod); + openMethodRef.current = openMethod; + const previousReportIdRef = useRef(previousReportId); + previousReportIdRef.current = previousReportId; + + const fireClose = useCallback((closeMethod: InboxReportCloseMethod) => { + const info = openInfoRef.current; + if (!info) return; + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_CLOSED, { + report_id: info.reportId, + report_title: info.reportTitle, + report_age_hours: computeReportAgeHours(info.reportCreatedAt), + priority: info.reportPriority, + actionability: info.reportActionability, + time_spent_ms: Date.now() - info.openedAt, + scrolled: info.hasScrolled, + close_method: closeMethod, + }); + openInfoRef.current = null; + }, []); + + const reportId = report?.id ?? null; + + useEffect(() => { + if (!reportId) return; + const snapshotReport = reportRef.current; + if (!snapshotReport) return; + + const info: OpenInfo = { + reportId, + reportTitle: snapshotReport.title ?? null, + reportCreatedAt: snapshotReport.created_at ?? null, + reportPriority: snapshotReport.priority ?? null, + reportActionability: snapshotReport.actionability ?? null, + openedAt: Date.now(), + rank: rankRef.current, + listSize: listSizeRef.current, + hasScrolled: false, + }; + openInfoRef.current = info; + + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_OPENED, { + report_id: info.reportId, + report_title: info.reportTitle, + report_age_hours: computeReportAgeHours(info.reportCreatedAt), + status: snapshotReport.status ?? null, + priority: info.reportPriority, + actionability: info.reportActionability, + source_products: snapshotReport.source_products ?? [], + rank: info.rank, + list_size: info.listSize, + open_method: openMethodRef.current, + previous_report_id: previousReportIdRef.current, + }); + + return () => { + fireClose("deselected"); + }; + }, [reportId, fireClose]); + + const signalScroll = useCallback(() => { + const info = openInfoRef.current; + if (!info || info.hasScrolled) return; + info.hasScrolled = true; + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED, { + report_id: info.reportId, + report_title: info.reportTitle, + report_age_hours: computeReportAgeHours(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) => { + const info = openInfoRef.current; + const currentInfo = + info && info.reportId === action.report_id ? info : null; + const { + rank: rankOverride, + list_size: listSizeOverride, + priority: priorityOverride, + actionability: actionabilityOverride, + ...rest + } = action; + analyticsRef.current.track(ANALYTICS_EVENTS.INBOX_REPORT_ACTION, { + ...rest, + rank: + rankOverride !== undefined ? rankOverride : (currentInfo?.rank ?? -1), + list_size: + listSizeOverride !== undefined + ? listSizeOverride + : (currentInfo?.listSize ?? 0), + priority: + priorityOverride !== undefined + ? priorityOverride + : (currentInfo?.reportPriority ?? null), + actionability: + actionabilityOverride !== undefined + ? actionabilityOverride + : (currentInfo?.reportActionability ?? null), + }); + }, + [], + ); + + return { signalScroll, signalAction }; +} diff --git a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts index f8efc7baf5..0f2f3391e1 100644 --- a/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxFilterStore.ts @@ -19,7 +19,7 @@ export type SourceProduct = | "zendesk" | "conversations"; -const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ +export const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ "ready", "pending_input", "in_progress", diff --git a/apps/mobile/src/features/inbox/stores/inboxStore.ts b/apps/mobile/src/features/inbox/stores/inboxStore.ts index 76dded4d93..7bdb2ec206 100644 --- a/apps/mobile/src/features/inbox/stores/inboxStore.ts +++ b/apps/mobile/src/features/inbox/stores/inboxStore.ts @@ -15,6 +15,14 @@ interface InboxStoreState { skippedIds: SkippedSet; /** Index of the currently visible card in the deck */ currentIndex: number; + /** + * Snapshot of the report IDs visible in the last-rendered list view, used + * for analytics (rank + list_size) when a detail screen is opened by tapping + * a list row. + */ + lastVisibleReportIds: string[]; + /** Most recently opened report ID, used for `previous_report_id` on OPENED events. */ + previousOpenedReportId: string | null; } interface InboxStoreActions { @@ -24,6 +32,8 @@ interface InboxStoreActions { resetSkipped: () => void; setCurrentIndex: (index: number) => void; advanceCard: () => void; + setLastVisibleReportIds: (ids: string[]) => void; + setPreviousOpenedReportId: (id: string | null) => void; } type InboxStore = InboxStoreState & InboxStoreActions; @@ -33,6 +43,8 @@ export const useInboxStore = create((set) => ({ orderDirection: "desc", skippedIds: new Set(), currentIndex: 0, + lastVisibleReportIds: [], + previousOpenedReportId: null, setOrderByField: (orderByField) => set({ orderByField }), setOrderDirection: (orderDirection) => set({ orderDirection }), @@ -45,4 +57,8 @@ export const useInboxStore = create((set) => ({ resetSkipped: () => set({ skippedIds: new Set(), currentIndex: 0 }), setCurrentIndex: (currentIndex) => set({ currentIndex }), advanceCard: () => set((state) => ({ currentIndex: state.currentIndex + 1 })), + setLastVisibleReportIds: (lastVisibleReportIds) => + set({ lastVisibleReportIds }), + setPreviousOpenedReportId: (previousOpenedReportId) => + set({ previousOpenedReportId }), })); diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index a67748efac..c1e511f78e 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import type { SuggestedReviewer } from "./types"; -import { orderSuggestedReviewers } from "./utils"; +import type { + SignalReport, + SignalReportStatus, + SuggestedReviewer, +} from "./types"; +import { buildInboxViewedProperties, orderSuggestedReviewers } from "./utils"; function reviewer(login: string, uuid?: string): SuggestedReviewer { return { @@ -55,3 +59,137 @@ describe("orderSuggestedReviewers", () => { expect(orderSuggestedReviewers(reviewers, meUuid)).toBe(reviewers); }); }); + +const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ + "ready", + "pending_input", + "in_progress", + "failed", + "candidate", + "potential", +]; + +function makeReport( + partial: Partial & Pick, +): SignalReport { + return { + title: null, + summary: null, + status: "ready", + total_weight: 0, + signal_count: 0, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + artefact_count: 0, + ...partial, + }; +} + +describe("buildInboxViewedProperties", () => { + it("emits zero counts for an empty list", () => { + const props = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(props).toMatchObject({ + report_count: 0, + total_count: 0, + ready_count: 0, + has_active_filters: false, + is_empty: true, + is_gated_due_to_scale: false, + priority_p0_count: 0, + priority_p1_count: 0, + priority_p2_count: 0, + priority_p3_count: 0, + priority_p4_count: 0, + priority_unknown_count: 0, + actionability_immediately_actionable_count: 0, + actionability_requires_human_input_count: 0, + actionability_not_actionable_count: 0, + actionability_unknown_count: 0, + }); + }); + + it("breaks visible reports down by priority and actionability", () => { + const reports: SignalReport[] = [ + makeReport({ + id: "1", + priority: "P0", + actionability: "immediately_actionable", + status: "ready", + }), + makeReport({ + id: "2", + priority: "P2", + actionability: "requires_human_input", + status: "ready", + }), + makeReport({ + id: "3", + priority: "P2", + actionability: "not_actionable", + status: "potential", + }), + makeReport({ id: "4", status: "failed" }), + ]; + + const props = buildInboxViewedProperties(reports, 4, { + sourceProductFilter: [], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + + expect(props.report_count).toBe(4); + expect(props.total_count).toBe(4); + expect(props.ready_count).toBe(2); + expect(props.priority_p0_count).toBe(1); + expect(props.priority_p2_count).toBe(2); + expect(props.priority_unknown_count).toBe(1); + expect(props.actionability_immediately_actionable_count).toBe(1); + expect(props.actionability_requires_human_input_count).toBe(1); + expect(props.actionability_not_actionable_count).toBe(1); + expect(props.actionability_unknown_count).toBe(1); + }); + + it("marks filters active when any of status/source/reviewer differs from defaults", () => { + const narrowed = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: ["ready"], + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(narrowed.has_active_filters).toBe(true); + expect(narrowed.status_filter_count).toBe(1); + + const sourced = buildInboxViewedProperties([], 0, { + sourceProductFilter: ["error_tracking"], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(sourced.has_active_filters).toBe(true); + expect(sourced.source_product_filter).toEqual(["error_tracking"]); + + const reviewer = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: DEFAULT_STATUS_FILTER, + suggestedReviewerFilter: ["uuid-1"], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(reviewer.has_active_filters).toBe(true); + }); + + it("treats a reordered default status set as not filtered", () => { + const props = buildInboxViewedProperties([], 0, { + sourceProductFilter: [], + statusFilter: [...DEFAULT_STATUS_FILTER].reverse(), + suggestedReviewerFilter: [], + defaultStatusFilter: DEFAULT_STATUS_FILTER, + }); + expect(props.has_active_filters).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index 41ee173117..44250eef02 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -1,3 +1,4 @@ +import type { InboxViewedProperties } from "@/lib/analytics"; import type { SignalReport, SignalReportOrderingField, @@ -98,3 +99,89 @@ export function orderSuggestedReviewers( if (meIndex <= 0) return reviewers; return [reviewers[meIndex], ...reviewers.filter((_, i) => i !== meIndex)]; } + +interface InboxViewedFilterState { + sourceProductFilter: string[]; + statusFilter: SignalReportStatus[]; + suggestedReviewerFilter: string[]; + /** Default status filter as defined in the filter store, used to detect whether the user has narrowed it. */ + defaultStatusFilter: SignalReportStatus[]; +} + +/** + * Build the property payload for the `Inbox viewed` analytics event. + * + * Mirrors apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx so + * desktop and mobile send the same shape into PostHog. + */ +export function buildInboxViewedProperties( + reports: SignalReport[], + totalCount: number, + filters: InboxViewedFilterState, +): InboxViewedProperties { + const priorityCounts = { + P0: 0, + P1: 0, + P2: 0, + P3: 0, + P4: 0, + unknown: 0, + }; + const actionabilityCounts = { + immediately_actionable: 0, + requires_human_input: 0, + not_actionable: 0, + unknown: 0, + }; + let readyCount = 0; + for (const r of reports) { + if (r.status === "ready") readyCount += 1; + const p = r.priority; + if (p === "P0" || p === "P1" || p === "P2" || p === "P3" || p === "P4") { + priorityCounts[p] += 1; + } else { + priorityCounts.unknown += 1; + } + const a = r.actionability; + if ( + a === "immediately_actionable" || + a === "requires_human_input" || + a === "not_actionable" + ) { + actionabilityCounts[a] += 1; + } else { + actionabilityCounts.unknown += 1; + } + } + + const statusFiltered = + filters.statusFilter.length !== filters.defaultStatusFilter.length || + filters.statusFilter.some((s) => !filters.defaultStatusFilter.includes(s)); + const hasActiveFilters = + statusFiltered || + filters.sourceProductFilter.length > 0 || + filters.suggestedReviewerFilter.length > 0; + + return { + report_count: reports.length, + total_count: totalCount, + ready_count: readyCount, + has_active_filters: hasActiveFilters, + source_product_filter: filters.sourceProductFilter, + status_filter_count: filters.statusFilter.length, + is_empty: totalCount === 0, + is_gated_due_to_scale: false, + priority_p0_count: priorityCounts.P0, + priority_p1_count: priorityCounts.P1, + priority_p2_count: priorityCounts.P2, + priority_p3_count: priorityCounts.P3, + priority_p4_count: priorityCounts.P4, + priority_unknown_count: priorityCounts.unknown, + actionability_immediately_actionable_count: + actionabilityCounts.immediately_actionable, + actionability_requires_human_input_count: + actionabilityCounts.requires_human_input, + actionability_not_actionable_count: actionabilityCounts.not_actionable, + actionability_unknown_count: actionabilityCounts.unknown, + }; +} diff --git a/apps/mobile/src/features/tasks/types.ts b/apps/mobile/src/features/tasks/types.ts index 9e7ec5a54a..2cb96dba56 100644 --- a/apps/mobile/src/features/tasks/types.ts +++ b/apps/mobile/src/features/tasks/types.ts @@ -7,6 +7,8 @@ export interface Task { created_at: string; updated_at: string; origin_product: string; + /** Inbox report UUID when origin_product is "signal_report". */ + signal_report?: string | null; repository?: string | null; github_integration?: number | null; internal?: boolean; diff --git a/apps/mobile/src/lib/analytics.test.ts b/apps/mobile/src/lib/analytics.test.ts new file mode 100644 index 0000000000..f0f1e104cc --- /dev/null +++ b/apps/mobile/src/lib/analytics.test.ts @@ -0,0 +1,96 @@ +import { createElement } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + computeReportAgeHours, + useActiveTaskAnalyticsContext, +} from "./analytics"; + +const mockPosthog = { + register: vi.fn(), + unregister: vi.fn(), + capture: vi.fn(), +}; + +vi.mock("posthog-react-native", () => ({ + usePostHog: () => mockPosthog, +})); + +describe("computeReportAgeHours", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T12:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns 0 for null/undefined input", () => { + expect(computeReportAgeHours(null)).toBe(0); + expect(computeReportAgeHours(undefined)).toBe(0); + }); + + it("rounds to one decimal", () => { + // 1h 35m before "now" → 1.6h + expect(computeReportAgeHours("2026-01-01T10:25:00Z")).toBe(1.6); + }); + + it("clamps at 0 when clock skew puts createdAt in the future", () => { + expect(computeReportAgeHours("2026-01-02T00:00:00Z")).toBe(0); + }); +}); + +function renderActiveTaskHook(initial: string | null) { + let currentId: string | null = initial; + function Wrapper() { + useActiveTaskAnalyticsContext(currentId); + return null; + } + let renderer: ReactTestRenderer | null = null; + act(() => { + renderer = create(createElement(Wrapper)); + }); + return { + rerender: (id: string | null) => { + currentId = id; + act(() => { + renderer?.update(createElement(Wrapper)); + }); + }, + unmount: () => { + act(() => { + renderer?.unmount(); + }); + }, + }; +} + +describe("useActiveTaskAnalyticsContext", () => { + beforeEach(() => { + mockPosthog.register.mockClear(); + mockPosthog.unregister.mockClear(); + }); + + it("registers and unregisters signal_report_id as the prop changes", () => { + const hook = renderActiveTaskHook("report-1"); + expect(mockPosthog.register).toHaveBeenCalledWith({ + signal_report_id: "report-1", + }); + + hook.rerender("report-2"); + expect(mockPosthog.unregister).toHaveBeenCalledWith("signal_report_id"); + expect(mockPosthog.register).toHaveBeenLastCalledWith({ + signal_report_id: "report-2", + }); + + hook.unmount(); + expect(mockPosthog.unregister).toHaveBeenLastCalledWith("signal_report_id"); + }); + + it("never registers when the id is null", () => { + const hook = renderActiveTaskHook(null); + expect(mockPosthog.register).not.toHaveBeenCalled(); + expect(mockPosthog.unregister).not.toHaveBeenCalled(); + hook.unmount(); + }); +}); diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts new file mode 100644 index 0000000000..e9ccebdca2 --- /dev/null +++ b/apps/mobile/src/lib/analytics.ts @@ -0,0 +1,198 @@ +import type { PostHogEventProperties } from "@posthog/core"; +import { usePostHog } from "posthog-react-native"; +import { useEffect, useMemo } from "react"; + +/** + * Event names mirror apps/code/src/shared/types/analytics.ts so PostHog reports + * funnel the same events from desktop and mobile into a single bucket. + */ +export const ANALYTICS_EVENTS = { + INBOX_VIEWED: "Inbox viewed", + INBOX_REPORT_OPENED: "Inbox report opened", + INBOX_REPORT_CLOSED: "Inbox report closed", + INBOX_REPORT_SCROLLED: "Inbox report scrolled", + INBOX_REPORT_ACTION: "Inbox report action", +} as const; + +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; + is_gated_due_to_scale: boolean; + priority_p0_count: number; + priority_p1_count: number; + priority_p2_count: number; + priority_p3_count: number; + priority_p4_count: number; + priority_unknown_count: number; + 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 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"; + has_question?: boolean; + question_text?: string; +} + +export type EventPropertyMap = { + [ANALYTICS_EVENTS.INBOX_VIEWED]: InboxViewedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_OPENED]: InboxReportOpenedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_CLOSED]: InboxReportClosedProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_SCROLLED]: InboxReportScrolledProperties; + [ANALYTICS_EVENTS.INBOX_REPORT_ACTION]: InboxReportActionProperties; +}; + +export interface Analytics { + track( + eventName: K, + properties: EventPropertyMap[K], + ): void; +} + +export function useAnalytics(): Analytics { + const posthog = usePostHog(); + return useMemo( + () => ({ + track: (eventName, properties) => { + // Our typed property interfaces don't carry an index signature; cast + // to the wider PostHog event-properties shape without losing the + // narrower call-site type-check. + posthog?.capture( + eventName, + properties as unknown as PostHogEventProperties, + ); + }, + }), + [posthog], + ); +} + +/** + * Tag every subsequent PostHog event with `signal_report_id` for as long as + * the calling screen is mounted with a non-null `signalReportId`. Clears the + * super-property on unmount or when `signalReportId` becomes null. Mirrors the + * desktop `setActiveTaskAnalyticsContext` super-property behaviour so events + * fired while inside a discuss-launched task can be filtered down to a single + * inbox report. + */ +export function useActiveTaskAnalyticsContext( + signalReportId: string | null | undefined, +): void { + const posthog = usePostHog(); + useEffect(() => { + if (!posthog || !signalReportId) return; + posthog.register({ signal_report_id: signalReportId }); + return () => { + posthog.unregister("signal_report_id"); + }; + }, [posthog, signalReportId]); +} + +/** Report age at fire time in hours, rounded to one decimal. Clamped at 0 to guard against clock skew. */ +export function computeReportAgeHours( + 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); +} diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 388ca57797..b01db26433 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -1403,6 +1403,59 @@ describe("AgentServer HTTP Mode", () => { expect(prompt).not.toContain("Push to the existing PR branch"); delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; }); + + describe("identity instructions", () => { + it.each([ + { + label: "no repository, no PR", + config: { repositoryPath: undefined }, + }, + { label: "repository, no PR", config: {} }, + ])( + "injects PostHog Slack app identity for Slack-origin runs ($label)", + ({ config }) => { + process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack"; + const s = createServer(config); + const prompt = ( + s as unknown as TestableServer + ).buildCloudSystemPrompt(); + expect(prompt).toContain("# Identity"); + expect(prompt).toContain("PostHog Slack app"); + expect(prompt).toContain("Do NOT refer to yourself as Claude"); + delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; + }, + ); + + it("injects identity for Slack-origin runs with an existing PR", () => { + process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack"; + const s = createServer(); + const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt( + "https://github.com/org/repo/pull/1", + ); + expect(prompt).toContain("# Identity"); + expect(prompt).toContain("PostHog Slack app"); + delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; + }); + + it.each([ + { label: "no origin set", origin: undefined }, + { label: "signal_report origin", origin: "signal_report" }, + { label: "posthog_code origin", origin: "posthog_code" }, + ])("omits identity block for non-Slack runs ($label)", ({ origin }) => { + if (origin) { + process.env.POSTHOG_CODE_INTERACTION_ORIGIN = origin; + } else { + delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; + } + const s = createServer(); + const prompt = ( + s as unknown as TestableServer + ).buildCloudSystemPrompt(); + expect(prompt).not.toContain("# Identity"); + expect(prompt).not.toContain("PostHog Slack app"); + delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; + }); + }); }); describe("buildDetectedPrContext", () => { diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 3c3bde54eb..8f6551556d 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1683,6 +1683,13 @@ export class AgentServer { private buildCloudSystemPrompt(prUrl?: string | null): string { const taskId = this.config.taskId; const shouldAutoCreatePr = this.shouldAutoPublishCloudChanges(); + const isSlack = this.getCloudInteractionOrigin() === "slack"; + const identityInstructions = isSlack + ? ` +# Identity +You are the PostHog Slack app, PostHog's agent for helping users with their product data and coding tasks from Slack. When introducing yourself or referring to yourself in messages to the user, identify as "PostHog Slack app". Do NOT refer to yourself as Claude, an Anthropic assistant, or any underlying model name. +` + : ""; const signedCommitInstructions = ` ## Committing (signed commits required) Commits MUST be signed. \`git commit\` and \`git push\` are blocked in this environment. @@ -1701,7 +1708,7 @@ we want: if (prUrl) { if (!shouldAutoCreatePr) { - return ` + return `${identityInstructions} # Cloud Task Execution This task already has an open pull request: ${prUrl} @@ -1715,7 +1722,7 @@ ${signedCommitInstructions} `; } - return ` + return `${identityInstructions} # Cloud Task Execution This task already has an open pull request: ${prUrl} @@ -1749,7 +1756,7 @@ When the user explicitly asks to clone or work in a GitHub repository: - If the user explicitly asks you to open or update a pull request, create a branch, stage your changes with \`git add\` and commit them with the \`git_signed_commit\` tool (do NOT use \`git commit\`/\`git push\` — they are blocked), and open a draft pull request from inside the clone. Before opening the PR, check the cloned repo for a PR template at \`.github/pull_request_template.md\` (or variants; fall back to the org's \`.github\` repo via \`gh api\`) and use it as the body structure, and search for matching open issues with \`gh issue list --search\` to include \`Closes #\` / \`Refs #\` links. - Do NOT create branches, commits, push changes, or open pull requests unless the user explicitly asks for that`; - return ` + return `${identityInstructions} # Cloud Task Execution — No Repository Mode You are a helpful assistant with access to PostHog via MCP tools. You can help with both code tasks and data/analytics questions. @@ -1771,7 +1778,7 @@ ${signedCommitInstructions} } if (!shouldAutoCreatePr) { - return ` + return `${identityInstructions} # Cloud Task Execution Do the requested work, but stop with local changes ready for review. @@ -1782,7 +1789,7 @@ ${signedCommitInstructions} `; } - return ` + return `${identityInstructions} # Cloud Task Execution After completing the requested changes: From b0f699519dca28afe15e47280069034a8383709f Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 3 Jun 2026 19:30:55 +0100 Subject: [PATCH 7/8] chore(mobile): align inbox files with main before merge Temporarily reset the three inbox files that conflict with main to main's exact content so the base branch can be merged in via a clean (conflict-free) merge commit. The suggested-reviewer ordering feature is re-applied in the following commit. Generated-By: PostHog Code Task-Id: 29cd7721-2158-4246-ba1b-c73bf4611ebc --- apps/mobile/src/app/inbox/[...id].tsx | 17 ++---- apps/mobile/src/features/inbox/utils.test.ts | 62 +------------------- apps/mobile/src/features/inbox/utils.ts | 11 ---- 3 files changed, 6 insertions(+), 84 deletions(-) diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index d51e1a6896..2438b71813 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -23,7 +23,6 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useUserQuery } from "@/features/auth/hooks/useUserQuery"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet"; @@ -48,10 +47,7 @@ import type { SignalReportStatus, SuggestedReviewer, } from "@/features/inbox/types"; -import { - inboxStatusLabel, - orderSuggestedReviewers, -} from "@/features/inbox/utils"; +import { inboxStatusLabel } from "@/features/inbox/utils"; import { computeReportAgeHours, useAnalytics } from "@/lib/analytics"; import { useThemeColors } from "@/lib/theme"; @@ -138,7 +134,6 @@ export default function ReportDetailScreen() { const insets = useSafeAreaInsets(); const posthog = usePostHog(); const { data: report, isLoading, error } = useInboxReport(reportId ?? null); - const { data: user } = useUserQuery(); const [reportRepo, setReportRepo] = useState(null); const [dismissOpen, setDismissOpen] = useState(false); const [discussOpen, setDiscussOpen] = useState(false); @@ -229,12 +224,11 @@ export default function ReportDetailScreen() { const suggestedReviewers = useMemo((): SuggestedReviewer[] => { for (const a of artefacts) { if (a.type === "suggested_reviewers") { - const reviewers = (a.content as SuggestedReviewer[]) ?? []; - return orderSuggestedReviewers(reviewers, user?.uuid); + return (a.content as SuggestedReviewer[]) ?? []; } } return []; - }, [artefacts, user?.uuid]); + }, [artefacts]); const findingsBySignalId = useMemo(() => { const map = new Map(); @@ -477,10 +471,7 @@ export default function ReportDetailScreen() { )} {/* Suggested reviewers */} - + {/* Signals */} {signals.length > 0 && ( diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index c1e511f78e..56c53cb951 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -1,64 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { - SignalReport, - SignalReportStatus, - SuggestedReviewer, -} from "./types"; -import { buildInboxViewedProperties, orderSuggestedReviewers } from "./utils"; - -function reviewer(login: string, uuid?: string): SuggestedReviewer { - return { - github_login: login, - github_name: login, - relevant_commits: [], - user: uuid - ? { - id: 1, - uuid, - email: `${login}@posthog.com`, - first_name: login, - last_name: "", - } - : null, - }; -} - -describe("orderSuggestedReviewers", () => { - it("moves the current user to the front", () => { - const reviewers = [ - reviewer("a", "uuid-a"), - reviewer("me", "uuid-me"), - reviewer("c", "uuid-c"), - ]; - const ordered = orderSuggestedReviewers(reviewers, "uuid-me"); - expect(ordered.map((r) => r.github_login)).toEqual(["me", "a", "c"]); - }); - - it.each([ - { - label: "already first", - reviewers: [reviewer("me", "uuid-me"), reviewer("a", "uuid-a")], - meUuid: "uuid-me" as string | null | undefined, - }, - { - label: "absent", - reviewers: [reviewer("a", "uuid-a"), reviewer("b", "uuid-b")], - meUuid: "uuid-me" as string | null | undefined, - }, - { - label: "null meUuid", - reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], - meUuid: null as string | null | undefined, - }, - { - label: "undefined meUuid", - reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], - meUuid: undefined as string | null | undefined, - }, - ])("is a no-op when $label", ({ reviewers, meUuid }) => { - expect(orderSuggestedReviewers(reviewers, meUuid)).toBe(reviewers); - }); -}); +import type { SignalReport, SignalReportStatus } from "./types"; +import { buildInboxViewedProperties } from "./utils"; const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ "ready", diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index 44250eef02..2ac3de63ee 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -3,7 +3,6 @@ import type { SignalReport, SignalReportOrderingField, SignalReportStatus, - SuggestedReviewer, } from "./types"; export function inboxStatusLabel(status: SignalReportStatus): string { @@ -90,16 +89,6 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] { ); } -export function orderSuggestedReviewers( - reviewers: SuggestedReviewer[], - meUuid: string | null | undefined, -): SuggestedReviewer[] { - if (!meUuid) return reviewers; - const meIndex = reviewers.findIndex((r) => r.user?.uuid === meUuid); - if (meIndex <= 0) return reviewers; - return [reviewers[meIndex], ...reviewers.filter((_, i) => i !== meIndex)]; -} - interface InboxViewedFilterState { sourceProductFilter: string[]; statusFilter: SignalReportStatus[]; From f156ac7e2c060904611d82096f7bc3254aac1424 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 3 Jun 2026 19:31:51 +0100 Subject: [PATCH 8/8] feat(mobile): re-apply suggested-reviewer ordering after base merge Re-apply the orderSuggestedReviewers helper, its tests, and the inbox detail wiring on top of the merged base. These were temporarily reset to main's content to allow a clean merge of the base branch. Generated-By: PostHog Code Task-Id: 29cd7721-2158-4246-ba1b-c73bf4611ebc --- apps/mobile/src/app/inbox/[...id].tsx | 17 ++++-- apps/mobile/src/features/inbox/utils.test.ts | 62 +++++++++++++++++++- apps/mobile/src/features/inbox/utils.ts | 11 ++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index 2438b71813..d51e1a6896 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -23,6 +23,7 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useUserQuery } from "@/features/auth/hooks/useUserQuery"; import { MarkdownText } from "@/features/chat/components/MarkdownText"; import { getReportRepository } from "@/features/inbox/api"; import { DiscussReportSheet } from "@/features/inbox/components/DiscussReportSheet"; @@ -47,7 +48,10 @@ import type { SignalReportStatus, SuggestedReviewer, } from "@/features/inbox/types"; -import { inboxStatusLabel } from "@/features/inbox/utils"; +import { + inboxStatusLabel, + orderSuggestedReviewers, +} from "@/features/inbox/utils"; import { computeReportAgeHours, useAnalytics } from "@/lib/analytics"; import { useThemeColors } from "@/lib/theme"; @@ -134,6 +138,7 @@ export default function ReportDetailScreen() { const insets = useSafeAreaInsets(); const posthog = usePostHog(); const { data: report, isLoading, error } = useInboxReport(reportId ?? null); + const { data: user } = useUserQuery(); const [reportRepo, setReportRepo] = useState(null); const [dismissOpen, setDismissOpen] = useState(false); const [discussOpen, setDiscussOpen] = useState(false); @@ -224,11 +229,12 @@ export default function ReportDetailScreen() { const suggestedReviewers = useMemo((): SuggestedReviewer[] => { for (const a of artefacts) { if (a.type === "suggested_reviewers") { - return (a.content as SuggestedReviewer[]) ?? []; + const reviewers = (a.content as SuggestedReviewer[]) ?? []; + return orderSuggestedReviewers(reviewers, user?.uuid); } } return []; - }, [artefacts]); + }, [artefacts, user?.uuid]); const findingsBySignalId = useMemo(() => { const map = new Map(); @@ -471,7 +477,10 @@ export default function ReportDetailScreen() { )} {/* Suggested reviewers */} - + {/* Signals */} {signals.length > 0 && ( diff --git a/apps/mobile/src/features/inbox/utils.test.ts b/apps/mobile/src/features/inbox/utils.test.ts index 56c53cb951..c1e511f78e 100644 --- a/apps/mobile/src/features/inbox/utils.test.ts +++ b/apps/mobile/src/features/inbox/utils.test.ts @@ -1,6 +1,64 @@ import { describe, expect, it } from "vitest"; -import type { SignalReport, SignalReportStatus } from "./types"; -import { buildInboxViewedProperties } from "./utils"; +import type { + SignalReport, + SignalReportStatus, + SuggestedReviewer, +} from "./types"; +import { buildInboxViewedProperties, orderSuggestedReviewers } from "./utils"; + +function reviewer(login: string, uuid?: string): SuggestedReviewer { + return { + github_login: login, + github_name: login, + relevant_commits: [], + user: uuid + ? { + id: 1, + uuid, + email: `${login}@posthog.com`, + first_name: login, + last_name: "", + } + : null, + }; +} + +describe("orderSuggestedReviewers", () => { + it("moves the current user to the front", () => { + const reviewers = [ + reviewer("a", "uuid-a"), + reviewer("me", "uuid-me"), + reviewer("c", "uuid-c"), + ]; + const ordered = orderSuggestedReviewers(reviewers, "uuid-me"); + expect(ordered.map((r) => r.github_login)).toEqual(["me", "a", "c"]); + }); + + it.each([ + { + label: "already first", + reviewers: [reviewer("me", "uuid-me"), reviewer("a", "uuid-a")], + meUuid: "uuid-me" as string | null | undefined, + }, + { + label: "absent", + reviewers: [reviewer("a", "uuid-a"), reviewer("b", "uuid-b")], + meUuid: "uuid-me" as string | null | undefined, + }, + { + label: "null meUuid", + reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], + meUuid: null as string | null | undefined, + }, + { + label: "undefined meUuid", + reviewers: [reviewer("a", "uuid-a"), reviewer("me", "uuid-me")], + meUuid: undefined as string | null | undefined, + }, + ])("is a no-op when $label", ({ reviewers, meUuid }) => { + expect(orderSuggestedReviewers(reviewers, meUuid)).toBe(reviewers); + }); +}); const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ "ready", diff --git a/apps/mobile/src/features/inbox/utils.ts b/apps/mobile/src/features/inbox/utils.ts index 2ac3de63ee..44250eef02 100644 --- a/apps/mobile/src/features/inbox/utils.ts +++ b/apps/mobile/src/features/inbox/utils.ts @@ -3,6 +3,7 @@ import type { SignalReport, SignalReportOrderingField, SignalReportStatus, + SuggestedReviewer, } from "./types"; export function inboxStatusLabel(status: SignalReportStatus): string { @@ -89,6 +90,16 @@ export function getActionableReports(reports: SignalReport[]): SignalReport[] { ); } +export function orderSuggestedReviewers( + reviewers: SuggestedReviewer[], + meUuid: string | null | undefined, +): SuggestedReviewer[] { + if (!meUuid) return reviewers; + const meIndex = reviewers.findIndex((r) => r.user?.uuid === meUuid); + if (meIndex <= 0) return reviewers; + return [reviewers[meIndex], ...reviewers.filter((_, i) => i !== meIndex)]; +} + interface InboxViewedFilterState { sourceProductFilter: string[]; statusFilter: SignalReportStatus[];