diff --git a/apps/code/index.html b/apps/code/index.html index cba9815f7a..4da71f1607 100644 --- a/apps/code/index.html +++ b/apps/code/index.html @@ -13,4 +13,4 @@ - \ No newline at end of file + diff --git a/apps/code/src/shared/constants.ts b/apps/code/src/shared/constants.ts index 280a0c34cd..b3fc672e90 100644 --- a/apps/code/src/shared/constants.ts +++ b/apps/code/src/shared/constants.ts @@ -1,3 +1,13 @@ +export const BILLING_FLAG = "posthog-code-billing"; +export const EXPERIMENT_SUGGESTIONS_FLAG = + "posthog-code-experiment-suggestions"; +export const SELF_DRIVING_SETUP_TASK_FLAG = + "posthog-code-self-driving-setup-task"; +export const SYNC_CLOUD_TASKS_FLAG = "posthog-code-sync-cloud-tasks"; +export const HOME_TAB_FLAG = "posthog-code-home-tab"; +export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; +export const BRANCH_PREFIX = "posthog-code/"; +export const DATA_DIR = ".posthog-code"; export const WORKTREES_DIR = ".posthog-code/worktrees"; export const LEGACY_DATA_DIRS = [ ".twig", diff --git a/docs/workflow-architecture.md b/docs/workflow-architecture.md index 75397bf663..6e167a7986 100644 --- a/docs/workflow-architecture.md +++ b/docs/workflow-architecture.md @@ -1,12 +1,12 @@ # Home workflow architecture -The Home tab's data layer — workstream grouping, PR polling, situation -classification, and workflow-config persistence — runs **server-side in +The Home tab's data layer – workstream grouping, PR polling, situation +classification, and workflow-config persistence – runs **server-side in PostHog**, in the `tasks` product (`products/tasks/` in the posthog repo). The Electron app is a thin authenticated client. The Electron app and the PostHog `tasks` product share wire shapes and -classification logic — if you change either, update both sides and this doc. +classification logic – if you change either, update both sides and this doc. --- @@ -19,10 +19,10 @@ wants available when work lands in each situation. Three concerns sit on top of that: -1. **Storage** — the per-user bindings JSON (`CodeWorkflowConfig`). -2. **PR / signal polling** — CI status, review decision, threads, mergeability +1. **Storage** – the per-user bindings JSON (`CodeWorkflowConfig`). +2. **PR / signal polling** – CI status, review decision, threads, mergeability for each tracked PR (`CodePrSnapshot`). -3. **Grouping + classification** — group a user's tasks into workstreams and +3. **Grouping + classification** – group a user's tasks into workstreams and compute the situations each is in (`CodeWorkstream`). All three live in PostHog now. @@ -32,30 +32,30 @@ All three live in PostHog now. ## 2. Server (PostHog `products/tasks/backend/`) **Pure logic** (ported 1:1 from the original TypeScript; unit-tested without -Django) — `code_workstreams/`: +Django) – `code_workstreams/`: -- `situations.py` — situation ids, priority order, attention set. -- `classify.py` — `classify(input) → set[SituationId]`, `pick_primary_situation`. -- `grouping.py` — `build_workstreams(tasks, pr_by_task, now)`: groups tasks +- `situations.py` – situation ids, priority order, attention set. +- `classify.py` – `classify(input) → set[SituationId]`, `pick_primary_situation`. +- `grouping.py` – `build_workstreams(tasks, pr_by_task, now)`: groups tasks (PR URL → repo+branch → path), extracts the active-agent set, classifies, and buckets into needs-attention / in-progress. -- `default_workflow.py`, `validation.py` — default bindings + save validation. +- `default_workflow.py`, `validation.py` – default bindings + save validation. **Models** (`models.py`): -- `CodeWorkflowConfig` — per `(team, user)` bindings + monotonic `version`. -- `CodePrSnapshot` — per `(team, pr_url)` polled GitHub state (shared across a +- `CodeWorkflowConfig` – per `(team, user)` bindings + monotonic `version`. +- `CodePrSnapshot` – per `(team, pr_url)` polled GitHub state (shared across a team's users). -- `CodeWorkstream` — per `(team, user, key)` grouped + classified workstream; +- `CodeWorkstream` – per `(team, user, key)` grouped + classified workstream; the API reads these rows directly. **Temporal worker** (`temporal/code_workstreams/`, task queue `TASKS_TASK_QUEUE`): -- `evaluate-code-workstreams` (dispatcher) — a Temporal **Schedule** every 3 min +- `evaluate-code-workstreams` (dispatcher) – a Temporal **Schedule** every 3 min enumerates teams with recent code activity and fans out one child workflow per team (bounded concurrency). -- `evaluate-team-code-workstreams` — per team: `load_team_pr_urls` → +- `evaluate-team-code-workstreams` – per team: `load_team_pr_urls` → `poll_team_pull_requests` (GitHub GraphQL via the team integration, rate-limit aware, heartbeated) → `rebuild_team_workstreams` (group + classify + upsert `CodeWorkstream`, prune stale). @@ -81,7 +81,7 @@ Responses use the exact camelCase wire shapes the Electron app validates. ## 3. Electron app (`apps/code/`) -Thin authenticated clients over the REST API — no local persistence, no `gh` +Thin authenticated clients over the REST API – no local persistence, no `gh` polling, no client-side classification: | Concern | File | @@ -97,7 +97,7 @@ Delivery for v1 is **REST + client poll**: `HomeService` polls `GET /code_home/` and emits `home.onSnapshotUpdated`; `WorkflowService` calls the config endpoints and emits `workflow.onChanged`. Both subscriptions write back into the TanStack Query cache. A realtime push channel (SSE) is a future -enhancement — the tRPC subscription contract wouldn't change. +enhancement – the tRPC subscription contract wouldn't change. `WorkflowService.get()` surfaces network/load failures rather than masking them: the config endpoint is the only source of truth, so when it can't be reached the @@ -107,10 +107,10 @@ canvas shows an offline/error state with a retry instead of fabricating a config ## 4. What is intentionally NOT here yet -- **`unresolvedThreads` for PRs the user doesn't author** — only the M3 reviewer +- **`unresolvedThreads` for PRs the user doesn't author** – only the M3 reviewer flow needs it; `is_current_user_requested_reviewer` defaults false. -- **Realtime push (SSE)** — v1 is client poll; the worker keeps server data fresh. -- **Snooze / mute / viewed** (`home_attention_state`) — M4, not migrated yet. -- **`auto`-trigger actions** — deliberately omitted. -- **Continue-as-new batching in the dispatcher** — current active-team counts +- **Realtime push (SSE)** – v1 is client poll; the worker keeps server data fresh. +- **Snooze / mute / viewed** (`home_attention_state`) – M4, not migrated yet. +- **`auto`-trigger actions** – deliberately omitted. +- **Continue-as-new batching in the dispatcher** – current active-team counts fan out in one pass (capped + logged); page with continue-as-new at larger scale. diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index e0243ee76b..1072884399 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -1112,7 +1112,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const newAbortController = new AbortController(); const { sessionId: _drop, ...rest } = prev.queryOptions; - // parseMcpServers yields only http/sse/stdio — carry over any in-process + // parseMcpServers yields only http/sse/stdio – carry over any in-process // ("sdk") server so the local-tools server (signed commits) survives. const preservedInProcess = Object.fromEntries( Object.entries(prev.queryOptions.mcpServers ?? {}).filter( diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index bf8f65fe70..c2699f79e1 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -18,6 +18,7 @@ import type { AvailableSuggestedReviewersResponse, DismissalArtefact, PriorityJudgmentArtefact, + RepoSelectionArtefact, SandboxEnvironment, SandboxEnvironmentInput, Signal, @@ -324,6 +325,7 @@ type AnyArtefact = | PriorityJudgmentArtefact | ActionabilityJudgmentArtefact | SignalFindingArtefact + | RepoSelectionArtefact | SuggestedReviewersArtefact | DismissalArtefact; @@ -434,6 +436,26 @@ function normalizeSignalFindingArtefact( }; } +function normalizeRepoSelectionArtefact( + value: Record, +): RepoSelectionArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + + const contentValue = isObjectRecord(value.content) ? value.content : null; + if (!contentValue) return null; + + return { + id, + type: "repo_selection", + created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + content: { + repository: optionalString(contentValue.repository), + reason: optionalString(contentValue.reason) ?? "", + }, + }; +} + function normalizeDismissalArtefact( value: Record, ): DismissalArtefact | null { @@ -482,6 +504,9 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { if (dispatchType === "priority_judgment") { return normalizePriorityJudgmentArtefact(value); } + if (dispatchType === "repo_selection") { + return normalizeRepoSelectionArtefact(value); + } if (dispatchType === "dismissal") { return normalizeDismissalArtefact(value); } @@ -1088,7 +1113,7 @@ export class PostHogAPIClient { return all; } - async getTask(taskId: string) { + async getTask(taskId: string): Promise { const teamId = await this.getTeamId(); const data = await this.api.get(`/api/projects/{project_id}/tasks/{id}/`, { path: { project_id: teamId.toString(), id: taskId }, diff --git a/packages/core/src/git/router-schemas.ts b/packages/core/src/git/router-schemas.ts index 2a6c155b9b..7feadcce0e 100644 --- a/packages/core/src/git/router-schemas.ts +++ b/packages/core/src/git/router-schemas.ts @@ -351,6 +351,23 @@ export const getPrChangedFilesInput = z.object({ }); export const getPrChangedFilesOutput = z.array(changedFileSchema); +// getPrDiffStatsBatch schemas +export const prDiffStatsSchema = z.object({ + additions: z.number(), + deletions: z.number(), + changedFiles: z.number(), +}); +export type PrDiffStats = z.infer; + +export const getPrDiffStatsBatchInput = z.object({ + prUrls: z.array(z.string()), +}); +export const getPrDiffStatsBatchOutput = z.record( + z.string(), + prDiffStatsSchema, +); + +// getPrDetailsByUrl schemas export const getPrDetailsByUrlInput = z.object({ prUrl: z.string(), }); diff --git a/packages/core/src/inbox/artefacts.test.ts b/packages/core/src/inbox/artefacts.test.ts new file mode 100644 index 0000000000..a4e972613e --- /dev/null +++ b/packages/core/src/inbox/artefacts.test.ts @@ -0,0 +1,49 @@ +import type { SuggestedReviewer } from "@posthog/shared/types"; +import { describe, expect, it } from "vitest"; +import { + extractSuggestedReviewers, + reviewerInitials, + suggestedReviewerDisplayName, +} from "./artefacts"; + +describe("artefacts", () => { + it("extracts suggested reviewers from artefacts", () => { + const reviewers: SuggestedReviewer[] = [ + { + github_login: "benw", + github_name: "Ben W.", + relevant_commits: [], + user: null, + }, + ]; + + expect( + extractSuggestedReviewers([ + { type: "priority_judgment", content: {} }, + { type: "suggested_reviewers", content: reviewers }, + ]), + ).toEqual(reviewers); + }); + + it("prefers user names for display", () => { + expect( + suggestedReviewerDisplayName({ + github_login: "benw", + github_name: "Ben W.", + relevant_commits: [], + user: { + id: 1, + uuid: "uuid-1", + email: "ben@posthog.com", + first_name: "Ben", + last_name: "W.", + }, + }), + ).toBe("Ben W."); + }); + + it("derives reviewer initials from names and emails", () => { + expect(reviewerInitials("Ben W.", null)).toBe("BW"); + expect(reviewerInitials("", "ben@posthog.com")).toBe("BE"); + }); +}); diff --git a/packages/core/src/inbox/artefacts.ts b/packages/core/src/inbox/artefacts.ts new file mode 100644 index 0000000000..1978f710bc --- /dev/null +++ b/packages/core/src/inbox/artefacts.ts @@ -0,0 +1,88 @@ +import type { + RepoSelectionArtefact, + SuggestedReviewer, +} from "@posthog/shared/types"; + +function hasRepositoryContent( + content: unknown, +): content is RepoSelectionArtefact["content"] { + return ( + typeof content === "object" && + content !== null && + "repository" in content && + typeof content.repository === "string" + ); +} + +export function extractRepoSelectionRepository( + results: { type: string; content: unknown }[] | undefined, +): string | null { + const artefact = results?.find( + (entry): entry is RepoSelectionArtefact => + entry.type === "repo_selection" && hasRepositoryContent(entry.content), + ); + return artefact?.content.repository ?? null; +} + +export function suggestedReviewerDisplayName( + reviewer: SuggestedReviewer, +): string { + if (reviewer.user) { + const name = + `${reviewer.user.first_name} ${reviewer.user.last_name}`.trim(); + if (name) return name; + if (reviewer.user.email) return reviewer.user.email; + } + return reviewer.github_name ?? reviewer.github_login; +} + +export function extractSuggestedReviewers( + results: { type: string; content: unknown }[] | undefined, +): SuggestedReviewer[] { + const artefact = results?.find( + ( + entry, + ): entry is { type: "suggested_reviewers"; content: SuggestedReviewer[] } => + entry.type === "suggested_reviewers" && Array.isArray(entry.content), + ); + return artefact?.content ?? []; +} + +const AVATAR_PALETTE = [ + "bg-(--orange-9) text-white", + "bg-(--blue-9) text-white", + "bg-(--purple-9) text-white", + "bg-(--green-9) text-white", + "bg-(--pink-9) text-white", + "bg-(--teal-9) text-white", +] as const; + +export function reviewerAvatarToneClass(seed: string): string { + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash + seed.charCodeAt(i) * (i + 1)) % 9973; + } + return AVATAR_PALETTE[hash % AVATAR_PALETTE.length]; +} + +export function reviewerInitials( + name: string | null | undefined, + email: string | null | undefined, +): string { + const trimmedName = name?.trim() ?? ""; + if (trimmedName) { + const parts = trimmedName.split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return `${parts[0][0] ?? ""}${parts[parts.length - 1][0] ?? ""}`.toUpperCase(); + } + return trimmedName.slice(0, 2).toUpperCase(); + } + + const trimmedEmail = email?.trim() ?? ""; + if (trimmedEmail) { + const local = trimmedEmail.split("@")[0] ?? trimmedEmail; + return local.slice(0, 2).toUpperCase(); + } + + return "??"; +} diff --git a/packages/core/src/inbox/buildCreatePrReportPrompt.test.ts b/packages/core/src/inbox/buildCreatePrReportPrompt.test.ts deleted file mode 100644 index 0a83014d78..0000000000 --- a/packages/core/src/inbox/buildCreatePrReportPrompt.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildCreatePrReportPrompt } from "./reportPrompts"; - -describe("buildCreatePrReportPrompt", () => { - it.each([ - { isDevBuild: false, expectedScheme: "posthog-code" }, - { isDevBuild: true, expectedScheme: "posthog-code-dev" }, - ])( - "uses the $expectedScheme deeplink scheme when isDevBuild=$isDevBuild", - ({ isDevBuild, expectedScheme }) => { - const prompt = buildCreatePrReportPrompt({ - reportId: "abc123", - isDevBuild, - }); - expect(prompt).toContain(`${expectedScheme}://inbox/abc123`); - }, - ); - - it("references the inbox MCP tools so the agent fetches the detail itself", () => { - const prompt = buildCreatePrReportPrompt({ - reportId: "abc123", - isDevBuild: false, - }); - expect(prompt).toContain("inbox MCP tools"); - }); - - it("asks the agent to open a PR", () => { - const prompt = buildCreatePrReportPrompt({ - reportId: "abc123", - isDevBuild: false, - }); - expect(prompt).toMatch(/open a PR/i); - }); - - it("tells the agent to stop rather than guess if the report can't be fetched", () => { - const prompt = buildCreatePrReportPrompt({ - reportId: "abc123", - isDevBuild: false, - }); - expect(prompt).toMatch(/can't fetch the report/i); - expect(prompt).toMatch(/instead of guessing/i); - }); - - it("appends user feedback when provided", () => { - const prompt = buildCreatePrReportPrompt({ - reportId: "abc123", - isDevBuild: false, - feedback: "Use the v2 endpoint, not v1.", - }); - expect(prompt).toMatch(/Additional feedback from the user/i); - expect(prompt).toContain("Use the v2 endpoint, not v1."); - }); - - it.each([ - { label: "undefined", feedback: undefined }, - { label: "empty string", feedback: "" }, - { label: "whitespace only", feedback: " " }, - ])("omits the feedback section when feedback is $label", ({ feedback }) => { - const base = buildCreatePrReportPrompt({ - reportId: "abc123", - isDevBuild: false, - }); - const prompt = buildCreatePrReportPrompt({ - reportId: "abc123", - isDevBuild: false, - feedback, - }); - expect(prompt).toBe(base); - expect(prompt).not.toMatch(/Additional feedback/i); - }); -}); diff --git a/packages/core/src/inbox/inboxQuery.test.ts b/packages/core/src/inbox/inboxQuery.test.ts new file mode 100644 index 0000000000..f722e4200b --- /dev/null +++ b/packages/core/src/inbox/inboxQuery.test.ts @@ -0,0 +1,85 @@ +import type { SignalReport } from "@posthog/shared/types"; +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { + findReportInInboxListCache, + inboxReportDetailQueryKey, + resolveInboxReportDetailCache, + seedInboxReportDetailCache, +} from "./inboxQuery"; + +function fakeReport(id: string): SignalReport { + return { + id, + title: `Report ${id}`, + summary: "Summary", + status: "ready", + total_weight: 1, + signal_count: 1, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + artefact_count: 0, + priority: "P2", + actionability: "immediately_actionable", + is_suggested_reviewer: false, + source_products: [], + implementation_pr_url: null, + }; +} + +describe("inboxQuery", () => { + it("finds a report in an infinite list cache", () => { + const queryClient = new QueryClient(); + const report = fakeReport("r-42"); + + queryClient.setQueryData( + ["inbox", "signal-reports", "infinite-list", { status: "ready" }], + { + pages: [{ results: [report], count: 1 }], + pageParams: [0], + }, + ); + + expect(findReportInInboxListCache(queryClient, "r-42")).toEqual(report); + }); + + it("seeds and resolves the detail cache", () => { + const queryClient = new QueryClient(); + const report = fakeReport("r-7"); + + seedInboxReportDetailCache(queryClient, report); + + expect(queryClient.getQueryData(inboxReportDetailQueryKey("r-7"))).toEqual( + report, + ); + expect(resolveInboxReportDetailCache(queryClient, "r-7")).toEqual(report); + }); + + it("returns undefined when the report is not cached", () => { + const queryClient = new QueryClient(); + expect( + resolveInboxReportDetailCache(queryClient, "missing"), + ).toBeUndefined(); + }); + + it("ignores unrelated cache shapes under the shared key prefix", () => { + const queryClient = new QueryClient(); + const seededDetail = fakeReport("seeded-detail"); + const listReport = fakeReport("in-list"); + + seedInboxReportDetailCache(queryClient, seededDetail); + queryClient.setQueryData( + ["inbox", "signal-reports", "scope-count", "for-you"], + 42, + ); + queryClient.setQueryData( + ["inbox", "signal-reports", "list", { status: "ready" }], + { results: [listReport], count: 1 }, + ); + + expect(findReportInInboxListCache(queryClient, "in-list")).toEqual( + listReport, + ); + expect(findReportInInboxListCache(queryClient, "missing")).toBeUndefined(); + }); +}); diff --git a/packages/core/src/inbox/inboxQuery.ts b/packages/core/src/inbox/inboxQuery.ts new file mode 100644 index 0000000000..4eefc985f6 --- /dev/null +++ b/packages/core/src/inbox/inboxQuery.ts @@ -0,0 +1,105 @@ +import type { + SignalReport, + SignalReportsQueryParams, + SignalReportsResponse, +} from "@posthog/shared/types"; +import type { InfiniteData, QueryClient } from "@tanstack/react-query"; + +/** + * React Query key factory for inbox-reports queries. Lives in its own + * trpc-free leaf module so utils can share keys without pulling the + * renderer trpc client into unit-test imports. + */ +export const inboxReportKeys = { + all: ["inbox", "signal-reports"] as const, + list: (params?: SignalReportsQueryParams) => + [...inboxReportKeys.all, "list", params ?? {}] as const, + infiniteList: (params?: SignalReportsQueryParams) => + [...inboxReportKeys.all, "infinite-list", params ?? {}] as const, + detail: (reportId: string) => + [...inboxReportKeys.all, reportId, "detail"] as const, + artefacts: (reportId: string) => + [...inboxReportKeys.all, reportId, "artefacts"] as const, + signals: (reportId: string) => + [...inboxReportKeys.all, reportId, "signals"] as const, + availableSuggestedReviewers: (authIdentity: string | null) => + [ + ...inboxReportKeys.all, + authIdentity ?? "anonymous", + "available-reviewers", + ] as const, + signalProcessingState: ["inbox", "signal-processing-state"] as const, +}; + +/** Shared keys for the per-team / per-user Self-driving config queries. */ +export const signalsConfigKeys = { + teamConfig: ["signals", "team-config"] as const, + userAutonomyConfig: ["signals", "user-autonomy-config"] as const, + sourceConfigs: ["signals", "source-configs"] as const, +}; + +export function inboxReportDetailQueryKey(reportId: string) { + return inboxReportKeys.detail(reportId); +} + +export function findReportInInboxListCache( + queryClient: QueryClient, + reportId: string, +): SignalReport | undefined { + /** + * `getQueriesData` matches by prefix, so every query under + * `["inbox", "signal-reports", ...]` is returned – including detail entries + * seeded as bare `SignalReport`s and scope-count entries holding a `number`. + * Narrow each entry by shape before peeking at `pages` / `results`. + */ + const entries = queryClient.getQueriesData({ + queryKey: inboxReportKeys.all, + }); + + for (const [, data] of entries) { + if (!data || typeof data !== "object") continue; + + if ( + "pages" in data && + Array.isArray((data as InfiniteData).pages) + ) { + const pages = (data as InfiniteData).pages; + for (const page of pages) { + if (!page || !Array.isArray(page.results)) continue; + const found = page.results.find((report) => report.id === reportId); + if (found) return found; + } + continue; + } + + if ( + "results" in data && + Array.isArray((data as SignalReportsResponse).results) + ) { + const found = (data as SignalReportsResponse).results.find( + (report) => report.id === reportId, + ); + if (found) return found; + } + } + + return undefined; +} + +export function seedInboxReportDetailCache( + queryClient: QueryClient, + report: SignalReport, +): void { + queryClient.setQueryData(inboxReportDetailQueryKey(report.id), report); +} + +export function resolveInboxReportDetailCache( + queryClient: QueryClient, + reportId: string, +): SignalReport | undefined { + const seeded = queryClient.getQueryData( + inboxReportDetailQueryKey(reportId), + ); + if (seeded) return seeded; + return findReportInInboxListCache(queryClient, reportId); +} diff --git a/packages/core/src/inbox/buildDiscussReportPrompt.test.ts b/packages/core/src/inbox/reportActions.test.ts similarity index 52% rename from packages/core/src/inbox/buildDiscussReportPrompt.test.ts rename to packages/core/src/inbox/reportActions.test.ts index 47a0366381..ebb47bd04b 100644 --- a/packages/core/src/inbox/buildDiscussReportPrompt.test.ts +++ b/packages/core/src/inbox/reportActions.test.ts @@ -1,5 +1,77 @@ import { describe, expect, it } from "vitest"; -import { buildDiscussReportPrompt } from "./reportPrompts"; +import { + buildCreatePrReportPrompt, + buildDiscussReportPrompt, +} from "./reportActions"; + +describe("buildCreatePrReportPrompt", () => { + it.each([ + { isDevBuild: false, expectedScheme: "posthog-code" }, + { isDevBuild: true, expectedScheme: "posthog-code-dev" }, + ])( + "uses the $expectedScheme deeplink scheme when isDevBuild=$isDevBuild", + ({ isDevBuild, expectedScheme }) => { + const prompt = buildCreatePrReportPrompt({ + reportId: "abc123", + isDevBuild, + }); + expect(prompt).toContain(`${expectedScheme}://inbox/abc123`); + }, + ); + + it("references the inbox MCP tools so the agent fetches the detail itself", () => { + const prompt = buildCreatePrReportPrompt({ + reportId: "abc123", + isDevBuild: false, + }); + expect(prompt).toContain("inbox MCP tools"); + }); + + it("asks the agent to open a PR", () => { + const prompt = buildCreatePrReportPrompt({ + reportId: "abc123", + isDevBuild: false, + }); + expect(prompt).toMatch(/open a PR/i); + }); + + it("tells the agent to stop rather than guess if the report can't be fetched", () => { + const prompt = buildCreatePrReportPrompt({ + reportId: "abc123", + isDevBuild: false, + }); + expect(prompt).toMatch(/can't fetch the report/i); + expect(prompt).toMatch(/instead of guessing/i); + }); + + it("appends user feedback when provided", () => { + const prompt = buildCreatePrReportPrompt({ + reportId: "abc123", + isDevBuild: false, + feedback: "Use the v2 endpoint, not v1.", + }); + expect(prompt).toMatch(/Additional feedback from the user/i); + expect(prompt).toContain("Use the v2 endpoint, not v1."); + }); + + it.each([ + { label: "undefined", feedback: undefined }, + { label: "empty string", feedback: "" }, + { label: "whitespace only", feedback: " " }, + ])("omits the feedback section when feedback is $label", ({ feedback }) => { + const base = buildCreatePrReportPrompt({ + reportId: "abc123", + isDevBuild: false, + }); + const prompt = buildCreatePrReportPrompt({ + reportId: "abc123", + isDevBuild: false, + feedback, + }); + expect(prompt).toBe(base); + expect(prompt).not.toMatch(/Additional feedback/i); + }); +}); describe("buildDiscussReportPrompt", () => { it("uses the production deeplink scheme outside dev builds", () => { diff --git a/packages/core/src/inbox/reportPrompts.ts b/packages/core/src/inbox/reportActions.ts similarity index 51% rename from packages/core/src/inbox/reportPrompts.ts rename to packages/core/src/inbox/reportActions.ts index 3d58a6bfba..3295e79b34 100644 --- a/packages/core/src/inbox/reportPrompts.ts +++ b/packages/core/src/inbox/reportActions.ts @@ -1,8 +1,29 @@ +import { buildDiscussReportPrompt as buildSharedDiscussReportPrompt } from "@posthog/shared"; import { buildInboxDeeplink, - buildDiscussReportPrompt as buildSharedDiscussReportPrompt, getDeeplinkProtocol, -} from "@posthog/shared"; +} from "@posthog/shared/deeplink"; +import type { SignalReport } from "@posthog/shared/types"; + +/** + * Should the Create PR action be offered on this report? + * + * Mirrors the server-side autostart rules: only when the report is ready and + * actually actionable, or when it's blocked on user input the user can supply. + * Hidden once an implementation PR exists or the issue is already fixed. + */ +export function canCreateImplementationPr(report: SignalReport): boolean { + if (report.implementation_pr_url) return false; + if (report.already_addressed === true) return false; + if (report.status === "pending_input") return true; + if (report.status === "ready") { + return ( + report.actionability === "immediately_actionable" || + report.actionability === "requires_human_input" + ); + } + return false; +} interface BuildCreatePrReportPromptOptions { reportId: string; @@ -16,7 +37,7 @@ export function buildCreatePrReportPrompt({ feedback, }: BuildCreatePrReportPromptOptions): string { const reportLink = `${getDeeplinkProtocol(isDevBuild)}://inbox/${reportId}`; - const base = `Act on PostHog inbox report ${reportId} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, its signals, and any suggested reviewers; investigate the root cause; implement the fix; and open a PR. If you can't fetch the report, stop and report that instead of guessing what it contains.`; + const base = `Act on PostHog inbox report ${reportId} ([inbox item](${reportLink})). Use the inbox MCP tools to fetch the report, its contributing findings, and any suggested reviewers; investigate the root cause; implement the fix; and open a PR. If you can't fetch the report, stop and report that instead of guessing what it contains.`; const trimmedFeedback = feedback?.trim(); if (!trimmedFeedback) return base; return `${base}\n\nAdditional feedback from the user (take this into account, including any questions raised in the report thread):\n${trimmedFeedback}`; diff --git a/packages/core/src/inbox/reportFilters.test.ts b/packages/core/src/inbox/reportFiltering.test.ts similarity index 97% rename from packages/core/src/inbox/reportFilters.test.ts rename to packages/core/src/inbox/reportFiltering.test.ts index 116343f145..f6bbf325a1 100644 --- a/packages/core/src/inbox/reportFilters.test.ts +++ b/packages/core/src/inbox/reportFiltering.test.ts @@ -1,14 +1,11 @@ -import type { - SignalReport, - SignalReportPriority, -} from "@posthog/shared/domain-types"; +import type { SignalReport, SignalReportPriority } from "@posthog/shared/types"; import { describe, expect, it } from "vitest"; import { buildPriorityFilterParam, buildSignalReportListOrdering, buildSuggestedReviewerFilterParam, filterReportsBySearch, -} from "./reportFilters"; +} from "./reportFiltering"; function makeReport(overrides: Partial = {}): SignalReport { return { diff --git a/packages/core/src/inbox/reportFilters.ts b/packages/core/src/inbox/reportFiltering.ts similarity index 77% rename from packages/core/src/inbox/reportFilters.ts rename to packages/core/src/inbox/reportFiltering.ts index 9fde96a9f4..1df6db14c2 100644 --- a/packages/core/src/inbox/reportFilters.ts +++ b/packages/core/src/inbox/reportFiltering.ts @@ -3,7 +3,17 @@ import type { SignalReportOrderingField, SignalReportPriority, SignalReportStatus, -} from "@posthog/shared/domain-types"; +} from "@posthog/shared/types"; + +/** + * Comma-separated statuses for the inbox query. We pull `failed` so the Runs + * tab can surface failed runs in its Recently finished section. + */ +export const INBOX_PIPELINE_STATUS_FILTER = + "potential,candidate,in_progress,ready,pending_input,failed"; + +/** Polling interval for inbox queries while the Electron window is focused. */ +export const INBOX_REFETCH_INTERVAL_MS = 3000; function normalizeReviewerId(value: string): string { return value.trim(); @@ -48,7 +58,7 @@ export function buildStatusFilterParam(statuses: SignalReportStatus[]): string { /** * Comma-separated `ordering` for the signal report list API: - * 1. Status rank (ready first — semantic server-side rank, always applied) + * 1. Status rank (ready first – semantic server-side rank, always applied) * 2. Suggested reviewer (current user's reports first) * 3. Toolbar-selected field (priority, total_weight, created_at, etc.) */ @@ -80,21 +90,3 @@ export function buildPriorityFilterParam( } return Array.from(new Set(priorities)).join(","); } - -/** Count of reports surfaced to the current user as up for review. */ -export function countUpForReview(reports: SignalReport[]): number { - return reports.filter(isReportUpForReview).length; -} - -/** Deduped list of enabled source products across the given reports' sources. */ -export function deriveEnabledProducts( - sources: { source_product: string; enabled: boolean }[], -): string[] { - const enabled = new Set(); - for (const source of sources) { - if (source.enabled) { - enabled.add(source.source_product); - } - } - return Array.from(enabled); -} diff --git a/packages/core/src/inbox/reportMembership.test.ts b/packages/core/src/inbox/reportMembership.test.ts new file mode 100644 index 0000000000..19b7c96090 --- /dev/null +++ b/packages/core/src/inbox/reportMembership.test.ts @@ -0,0 +1,270 @@ +import type { SignalReport } from "@posthog/shared/types"; +import { describe, expect, it } from "vitest"; +import { + computeInboxTabCounts, + countInboxScopeReports, + EMPTY_TAB_COUNTS, + INBOX_SCOPE_ENTIRE_PROJECT, + INBOX_SCOPE_FOR_YOU, + isAgentRunReport, + isExcludedFromInbox, + isInboxDetailPath, + isPullRequestReport, + isReportTabReport, + matchesReviewerScope, + teammateInboxScope, +} from "./reportMembership"; + +function fakeReport(overrides: Partial = {}): SignalReport { + return { + id: "r1", + title: "Test report", + summary: "Summary", + status: "ready", + total_weight: 1, + signal_count: 1, + created_at: "2026-06-05T00:00:00Z", + updated_at: "2026-06-05T00:00:00Z", + artefact_count: 0, + priority: null, + actionability: null, + is_suggested_reviewer: false, + source_products: [], + implementation_pr_url: null, + ...overrides, + }; +} + +describe("isInboxDetailPath", () => { + it("matches detail paths for each inbox tab", () => { + expect(isInboxDetailPath("/code/inbox/pulls/abc")).toBe(true); + expect(isInboxDetailPath("/code/inbox/reports/abc")).toBe(true); + expect(isInboxDetailPath("/code/inbox/runs/abc")).toBe(true); + }); + + it("does not match tab list paths", () => { + expect(isInboxDetailPath("/code/inbox/pulls")).toBe(false); + expect(isInboxDetailPath("/code/inbox/reports")).toBe(false); + expect(isInboxDetailPath("/code/inbox/runs")).toBe(false); + expect(isInboxDetailPath("/code/inbox")).toBe(false); + }); + + it("does not match paths with extra trailing segments", () => { + expect(isInboxDetailPath("/code/inbox/pulls/abc/edit")).toBe(false); + expect(isInboxDetailPath("/code/inbox/runs/abc/")).toBe(false); + }); + + it("does not match unrelated paths", () => { + expect(isInboxDetailPath("/code/agents")).toBe(false); + expect(isInboxDetailPath("/code/inbox/agents")).toBe(false); + expect(isInboxDetailPath("/")).toBe(false); + }); +}); + +describe("inbox scope", () => { + it("counts for-you and entire-project scopes", () => { + const reports = [ + fakeReport({ id: "1", is_suggested_reviewer: true }), + fakeReport({ id: "2", is_suggested_reviewer: false }), + fakeReport({ + id: "3", + status: "suppressed", + is_suggested_reviewer: true, + }), + ]; + + expect(countInboxScopeReports(reports, INBOX_SCOPE_FOR_YOU)).toBe(1); + expect(countInboxScopeReports(reports, INBOX_SCOPE_ENTIRE_PROJECT)).toBe(2); + }); + + it("builds teammate scope keys", () => { + expect(teammateInboxScope("uuid-1")).toBe("teammate:uuid-1"); + }); +}); + +describe("tabFilters", () => { + describe("isPullRequestReport", () => { + it("returns true when implementation_pr_url is set", () => { + expect( + isPullRequestReport( + fakeReport({ implementation_pr_url: "https://gh/p/1" }), + ), + ).toBe(true); + }); + + it("returns false when implementation_pr_url is null", () => { + expect( + isPullRequestReport(fakeReport({ implementation_pr_url: null })), + ).toBe(false); + }); + + it("returns false when report is suppressed", () => { + expect( + isPullRequestReport( + fakeReport({ + implementation_pr_url: "https://gh/p/1", + status: "suppressed", + }), + ), + ).toBe(false); + }); + }); + + describe("isExcludedFromInbox", () => { + it("returns true for suppressed and deleted", () => { + expect(isExcludedFromInbox(fakeReport({ status: "suppressed" }))).toBe( + true, + ); + expect(isExcludedFromInbox(fakeReport({ status: "deleted" }))).toBe(true); + }); + + it("returns false for failed (surfaced inside the Runs tab) and other pipeline statuses", () => { + expect(isExcludedFromInbox(fakeReport({ status: "failed" }))).toBe(false); + expect(isExcludedFromInbox(fakeReport({ status: "ready" }))).toBe(false); + }); + }); + + describe("isAgentRunReport", () => { + it("returns true when status is in_progress", () => { + expect(isAgentRunReport(fakeReport({ status: "in_progress" }))).toBe( + true, + ); + }); + + it("returns true when status is pending_input", () => { + expect(isAgentRunReport(fakeReport({ status: "pending_input" }))).toBe( + true, + ); + }); + + it("returns false for ready", () => { + expect(isAgentRunReport(fakeReport({ status: "ready" }))).toBe(false); + }); + }); + + describe("isReportTabReport", () => { + it("excludes reports with a PR", () => { + expect( + isReportTabReport( + fakeReport({ implementation_pr_url: "https://gh/p/1" }), + ), + ).toBe(false); + }); + + it("excludes in-progress reports", () => { + expect(isReportTabReport(fakeReport({ status: "in_progress" }))).toBe( + false, + ); + }); + + it("includes ready non-PR reports", () => { + expect( + isReportTabReport( + fakeReport({ status: "ready", implementation_pr_url: null }), + ), + ).toBe(true); + }); + + it("excludes pending_input reports (they go to Runs)", () => { + expect( + isReportTabReport( + fakeReport({ status: "pending_input", implementation_pr_url: null }), + ), + ).toBe(false); + }); + }); + + describe("matchesReviewerScope", () => { + it("'for-you' keeps reports addressed to me", () => { + expect( + matchesReviewerScope( + fakeReport({ is_suggested_reviewer: true }), + "for-you", + ), + ).toBe(true); + expect( + matchesReviewerScope( + fakeReport({ is_suggested_reviewer: false }), + "for-you", + ), + ).toBe(false); + }); + + it("'entire-project' includes every in-inbox report", () => { + expect( + matchesReviewerScope( + fakeReport({ is_suggested_reviewer: false }), + "entire-project", + ), + ).toBe(true); + expect( + matchesReviewerScope( + fakeReport({ is_suggested_reviewer: true }), + "entire-project", + ), + ).toBe(true); + }); + + it("teammate scope passes through client-filtered rows", () => { + expect( + matchesReviewerScope( + fakeReport({ is_suggested_reviewer: false }), + "teammate:abc", + ), + ).toBe(true); + }); + }); + + describe("computeInboxTabCounts", () => { + const reports: SignalReport[] = [ + fakeReport({ + id: "1", + implementation_pr_url: "https://gh/1", + status: "ready", + is_suggested_reviewer: true, + }), + fakeReport({ + id: "2", + implementation_pr_url: "https://gh/2", + status: "ready", + is_suggested_reviewer: false, + }), + fakeReport({ id: "3", status: "ready", is_suggested_reviewer: true }), + fakeReport({ + id: "4", + status: "ready", + is_suggested_reviewer: false, + }), + fakeReport({ + id: "5", + status: "in_progress", + is_suggested_reviewer: true, + }), + fakeReport({ + id: "6", + status: "pending_input", + is_suggested_reviewer: false, + }), + ]; + + it("returns zeros for an empty list", () => { + expect(computeInboxTabCounts([], "for-you")).toEqual(EMPTY_TAB_COUNTS); + }); + + it("for-you counts only my queue except runs (runs are always project-wide)", () => { + expect(computeInboxTabCounts(reports, "for-you")).toEqual({ + pulls: 1, + reports: 1, + runs: 2, + }); + }); + + it("entire-project counts the full inbox", () => { + expect(computeInboxTabCounts(reports, "entire-project")).toEqual({ + pulls: 2, + reports: 2, + runs: 2, + }); + }); + }); +}); diff --git a/packages/core/src/inbox/reportMembership.ts b/packages/core/src/inbox/reportMembership.ts new file mode 100644 index 0000000000..6504b17267 --- /dev/null +++ b/packages/core/src/inbox/reportMembership.ts @@ -0,0 +1,190 @@ +import type { SignalReport } from "@posthog/shared/types"; + +/** + * Statuses that are out of the inbox entirely (user-suppressed or removed). + * `failed` is NOT in here: failed runs surface in the Runs tab's Recently + * finished section so the user can see what went wrong. Other tabs filter + * them out via their own predicates. + */ +export const INBOX_EXCLUDED_STATUSES = new Set([ + "suppressed", + "deleted", +]); + +export function isExcludedFromInbox(report: SignalReport): boolean { + return INBOX_EXCLUDED_STATUSES.has(report.status); +} + +export type InboxScope = "for-you" | "entire-project" | `teammate:${string}`; + +export const INBOX_SCOPE_FOR_YOU: InboxScope = "for-you"; +export const INBOX_SCOPE_ENTIRE_PROJECT: InboxScope = "entire-project"; + +export function teammateInboxScope(uuid: string): InboxScope { + return `teammate:${uuid}`; +} + +export function parseTeammateInboxScope(scope: InboxScope): string | null { + if (!scope.startsWith("teammate:")) return null; + const uuid = scope.slice("teammate:".length).trim(); + return uuid || null; +} + +export function isTeammateInboxScope( + scope: InboxScope, +): scope is `teammate:${string}` { + return parseTeammateInboxScope(scope) != null; +} + +export function inboxScopeTriggerLabel( + scope: InboxScope, + teammateName?: string | null, +): string { + if (scope === INBOX_SCOPE_FOR_YOU) return "For you"; + if (scope === INBOX_SCOPE_ENTIRE_PROJECT) return "Entire project"; + return teammateName?.trim() || "Teammate"; +} + +export function matchesInboxScope( + report: SignalReport, + scope: InboxScope, +): boolean { + if (isExcludedFromInbox(report)) return false; + if (scope === INBOX_SCOPE_ENTIRE_PROJECT) return true; + if (isTeammateInboxScope(scope)) return true; + return report.is_suggested_reviewer === true; +} + +export function countInboxScopeReports( + reports: SignalReport[], + scope: InboxScope, +): number { + return reports.filter((report) => matchesInboxScope(report, scope)).length; +} + +export type InboxTabKey = "pulls" | "reports" | "runs"; + +export const INBOX_TAB_KEYS: InboxTabKey[] = ["pulls", "reports", "runs"]; + +export const INBOX_TAB_LABEL: Record = { + pulls: "Pull requests", + reports: "Reports", + runs: "Runs", +}; + +/** + * Canonical inbox tab list routes. Use these constants instead of hard-coding + * `/code/inbox/pulls` etc., so renames stay in one place. + * + * Detail routes (`/code/inbox//$reportId`) stay as TanStack Router + * literals at call sites – TanStack's typed-link API needs them as literal + * strings to infer params. + */ +export const INBOX_TAB_LIST_ROUTE: Record< + InboxTabKey, + `/code/inbox/${InboxTabKey}` +> = { + pulls: "/code/inbox/pulls", + reports: "/code/inbox/reports", + runs: "/code/inbox/runs", +}; + +const INBOX_DETAIL_PATH_RE = new RegExp( + `^/code/inbox/(${INBOX_TAB_KEYS.join("|")})/[^/]+$`, +); + +export function isInboxDetailPath(pathname: string): boolean { + return INBOX_DETAIL_PATH_RE.test(pathname); +} + +/** PR tab membership: Responder shipped a draft PR and the report is still in-inbox. */ +export function isPullRequestReport(report: SignalReport): boolean { + return !!report.implementation_pr_url && !isExcludedFromInbox(report); +} + +// ── Runs-tab partitioning ───────────────────────────────────────────────── +// The Runs tab is task-centric: it shows reports whose run is queued, live, or +// recently finished. Each section uses a different predicate; `isAgentRunReport` +// stays as the umbrella for "this report's run is in motion or just finished" +// so other tabs can keep excluding the same set. + +const QUEUED_RUN_STATUSES = new Set([ + "potential", + "candidate", +]); + +const LIVE_RUN_STATUSES = new Set([ + "in_progress", + "pending_input", +]); + +const FINISHED_RUN_STATUSES = new Set([ + "ready", + "failed", +]); + +export function isQueuedRunReport(report: SignalReport): boolean { + return QUEUED_RUN_STATUSES.has(report.status); +} + +export function isLiveRunReport(report: SignalReport): boolean { + return LIVE_RUN_STATUSES.has(report.status); +} + +export function isFinishedRunReport(report: SignalReport): boolean { + return FINISHED_RUN_STATUSES.has(report.status); +} + +/** + * Used by the Runs tab count chip + cross-tab exclusion: only "in motion" + * runs (queued or live). Finished runs surface inside the Runs tab as recent + * history but don't inflate the count badge. + */ +export function isAgentRunReport(report: SignalReport): boolean { + return isQueuedRunReport(report) || isLiveRunReport(report); +} + +export function isReportTabReport(report: SignalReport): boolean { + if (isExcludedFromInbox(report)) return false; + if (report.status === "failed") return false; // failed runs live in the Runs tab only + if (isPullRequestReport(report)) return false; + if (isAgentRunReport(report)) return false; + return true; +} + +export function matchesReviewerScope( + report: SignalReport, + scope: InboxScope, +): boolean { + return matchesInboxScope(report, scope); +} + +export interface InboxTabCounts { + pulls: number; + reports: number; + runs: number; +} + +export const EMPTY_TAB_COUNTS: InboxTabCounts = { + pulls: 0, + reports: 0, + runs: 0, +}; + +export function computeInboxTabCounts( + reports: SignalReport[], + scope: InboxScope, +): InboxTabCounts { + const counts: InboxTabCounts = { ...EMPTY_TAB_COUNTS }; + for (const report of reports) { + if (isExcludedFromInbox(report)) continue; + // Runs count is project-wide: reviewer assignment is an output of + // research, so the For-you / teammate filter is meaningless until a + // report reaches a downstream tab. + if (isAgentRunReport(report)) counts.runs += 1; + if (!matchesReviewerScope(report, scope)) continue; + if (isPullRequestReport(report)) counts.pulls += 1; + if (isReportTabReport(report)) counts.reports += 1; + } + return counts; +} diff --git a/packages/core/src/inbox/reportPresentation.test.ts b/packages/core/src/inbox/reportPresentation.test.ts new file mode 100644 index 0000000000..7a6f071a19 --- /dev/null +++ b/packages/core/src/inbox/reportPresentation.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { + deriveHeadline, + displayConventionalCommitTitle, + formatSignalReportSummaryMarkdown, + parseConventionalCommitTitle, +} from "./reportPresentation"; + +describe("deriveHeadline", () => { + it("returns null for null/undefined/empty input", () => { + expect(deriveHeadline(null)).toBeNull(); + expect(deriveHeadline(undefined)).toBeNull(); + expect(deriveHeadline("")).toBeNull(); + expect(deriveHeadline(" ")).toBeNull(); + }); + + it("returns the only sentence when there's just one", () => { + expect(deriveHeadline("Single short sentence with no terminator")).toBe( + "Single short sentence with no terminator", + ); + expect(deriveHeadline("Single short sentence.")).toBe( + "Single short sentence.", + ); + }); + + it("keeps only the first sentence of a multi-sentence summary", () => { + expect( + deriveHeadline( + "First sentence here. Second sentence follows. And a third.", + ), + ).toBe("First sentence here."); + }); + + it("handles `!` and `?` terminators", () => { + expect(deriveHeadline("Surprise! That's the headline.")).toBe("Surprise!"); + expect(deriveHeadline("Is this the headline? Yes.")).toBe( + "Is this the headline?", + ); + }); + + it("cuts at the first newline before considering further sentences", () => { + expect(deriveHeadline("First line headline\nSecond paragraph here.")).toBe( + "First line headline", + ); + }); + + it("strips trailing Markdown emphasis at the sentence boundary", () => { + expect(deriveHeadline("**Bold headline.** Then prose.")).toBe( + "Bold headline.", + ); + expect(deriveHeadline("_Italic._ Continuation.")).toBe("Italic."); + expect(deriveHeadline("`code-led headline.` Plain after.")).toBe( + "code-led headline.", + ); + }); + + it("truncates long single sentences with an ellipsis", () => { + const longSentence = `${"a".repeat(160)}`; + const headline = deriveHeadline(longSentence); + expect(headline?.endsWith("…")).toBe(true); + expect(headline?.length).toBeLessThanOrEqual(141); + }); + + it("does not truncate sentences under the limit", () => { + expect(deriveHeadline("Short enough sentence here.")).toBe( + "Short enough sentence here.", + ); + }); +}); + +describe("formatSignalReportSummaryMarkdown", () => { + it.each([ + { + name: "puts section body text on a new line after the header", + input: + "**What's happening:** Error tracking issue keyed on `app:dashboard_query`.", + expected: + "**What's happening:**\n\nError tracking issue keyed on `app:dashboard_query`.", + }, + { + name: "separates consecutive section headers onto their own lines", + input: + "**What's happening:** Users hit rate limits. **Root cause:** All four rate limiters are contended. **How to resolve:** Reduce blocking.", + expected: + "**What's happening:**\n\nUsers hit rate limits.\n\n**Root cause:**\n\nAll four rate limiters are contended.\n\n**How to resolve:**\n\nReduce blocking.", + }, + { + name: "separates a section header from preceding intro text", + input: + "Users on busy orgs are hitting hard limits. **What's happening:** Error tracking issue.", + expected: + "Users on busy orgs are hitting hard limits.\n\n**What's happening:**\n\nError tracking issue.", + }, + { + name: "leaves content without section headers unchanged", + input: "Plain summary with no structured sections.", + expected: "Plain summary with no structured sections.", + }, + ])("$name", ({ input, expected }) => { + expect(formatSignalReportSummaryMarkdown(input)).toBe(expected); + }); +}); + +describe("parseConventionalCommitTitle", () => { + it("returns null for empty or non-conventional titles", () => { + expect(parseConventionalCommitTitle(null)).toBeNull(); + expect(parseConventionalCommitTitle("")).toBeNull(); + expect(parseConventionalCommitTitle("Fix tooltip overflow")).toBeNull(); + expect(parseConventionalCommitTitle("feat:")).toBeNull(); + }); + + it("parses type, scope, and description", () => { + expect( + parseConventionalCommitTitle("fix(auth): Stop duplicate sessions"), + ).toEqual({ + type: "fix", + scope: "auth", + description: "Stop duplicate sessions", + }); + }); + + it("normalizes type to lowercase", () => { + expect(parseConventionalCommitTitle("Feat(ui): Add inbox tab")).toEqual({ + type: "feat", + scope: "ui", + description: "Add inbox tab", + }); + }); + + it("parses titles without scope", () => { + expect(parseConventionalCommitTitle("chore: Bump dependencies")).toEqual({ + type: "chore", + scope: null, + description: "Bump dependencies", + }); + }); + + it("parses breaking-change markers", () => { + expect(parseConventionalCommitTitle("feat!: Drop legacy API")).toEqual({ + type: "feat", + scope: null, + description: "Drop legacy API", + }); + expect( + parseConventionalCommitTitle("fix(api)!: Remove deprecated route"), + ).toEqual({ + type: "fix", + scope: "api", + description: "Remove deprecated route", + }); + }); +}); + +describe("displayConventionalCommitTitle", () => { + it("returns description for conventional titles", () => { + expect( + displayConventionalCommitTitle( + "fix(auth): Stop duplicate sessions", + "Untitled", + ), + ).toBe("Stop duplicate sessions"); + }); + + it("returns full title or fallback otherwise", () => { + expect(displayConventionalCommitTitle("Plain title", "Untitled")).toBe( + "Plain title", + ); + expect(displayConventionalCommitTitle(null, "Untitled")).toBe("Untitled"); + }); +}); diff --git a/packages/core/src/inbox/reportPresentation.ts b/packages/core/src/inbox/reportPresentation.ts new file mode 100644 index 0000000000..b2718396ba --- /dev/null +++ b/packages/core/src/inbox/reportPresentation.ts @@ -0,0 +1,189 @@ +import type { SignalReportStatus } from "@posthog/shared/types"; + +const MAX_HEADLINE_LENGTH = 140; + +// Matches the first sentence terminator (. ! ?) optionally followed by closing +// Markdown emphasis markers (* _ `), before whitespace or end of input. Capture +// group 1 keeps the terminator so we don't lose it, but trailing emphasis is +// dropped at the boundary. +const SENTENCE_END = /([.!?])[*_`]*(?=\s|$)/; + +const EDGE_EMPHASIS = /^[*_`\s]+|[*_`\s]+$/g; + +/** + * Compact single-sentence headline derived from a report summary, for list + * rendering. Cuts at the first newline, then at the first sentence terminator, + * strips edge Markdown emphasis, and truncates to ~140 chars with an ellipsis. + * + * Returns null for empty / non-string input so callers can fall back to the + * full summary or a placeholder. + */ +export function deriveHeadline( + summary: string | null | undefined, +): string | null { + if (typeof summary !== "string") return null; + const trimmed = summary.trim(); + if (!trimmed) return null; + + const firstLine = trimmed.split(/\r?\n/, 1)[0] ?? ""; + + let headline = firstLine; + const sentenceMatch = SENTENCE_END.exec(firstLine); + if (sentenceMatch) { + headline = firstLine.slice( + 0, + sentenceMatch.index + sentenceMatch[1].length, + ); + } + + headline = headline.replace(EDGE_EMPHASIS, "").trim(); + if (!headline) return null; + + if (headline.length > MAX_HEADLINE_LENGTH) { + headline = `${headline.slice(0, MAX_HEADLINE_LENGTH).trimEnd()}…`; + } + + return headline; +} + +export function inboxStatusLabel(status: SignalReportStatus): string { + switch (status) { + case "ready": + return "Ready"; + case "pending_input": + return "Needs input"; + case "in_progress": + return "Researching"; + case "candidate": + return "Queued"; + case "potential": + return "Gathering"; + case "failed": + return "Failed"; + case "suppressed": + return "Suppressed"; + case "deleted": + return "Deleted"; + default: + return status; + } +} + +export function inboxStatusAccentCss(status: SignalReportStatus): string { + switch (status) { + case "ready": + return "var(--green-9)"; + case "pending_input": + return "var(--violet-9)"; + case "in_progress": + return "var(--amber-9)"; + case "candidate": + return "var(--cyan-9)"; + case "potential": + return "var(--gray-9)"; + case "failed": + return "var(--red-9)"; + default: + return "var(--gray-8)"; + } +} + +const SIGNAL_SUMMARY_SECTION_HEADERS = [ + "What's happening", + "Root cause", + "How to resolve", +] as const; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Inserts line breaks around signal report summary section headers so each + * label and its body render on separate lines (matches agent output like + * `**What's happening:** text`). + */ +export function formatSignalReportSummaryMarkdown(content: string): string { + let result = content; + + for (const header of SIGNAL_SUMMARY_SECTION_HEADERS) { + const escaped = escapeRegExp(header); + const boldHeaderPattern = `\\*\\*${escaped}:\\*\\*`; + + result = result.replace( + new RegExp(`([^\\n])\\s*(${boldHeaderPattern})`, "gi"), + "$1\n\n$2", + ); + + result = result.replace( + new RegExp(`(${boldHeaderPattern})\\s+`, "gi"), + "$1\n\n", + ); + } + + return result; +} + +/** Matches `type(scope): description` and optional breaking-change `!`. */ +const CONVENTIONAL_COMMIT_TITLE = /^(\w+)(?:\(([^)]*)\))?!?:\s*(.+)$/; + +export interface ParsedConventionalCommitTitle { + type: string; + scope: string | null; + description: string; +} + +export function parseConventionalCommitTitle( + title: string | null | undefined, +): ParsedConventionalCommitTitle | null { + if (typeof title !== "string") return null; + + const trimmed = title.trim(); + if (!trimmed) return null; + + const match = CONVENTIONAL_COMMIT_TITLE.exec(trimmed); + if (!match) return null; + + const type = match[1].toLowerCase(); + const scopeRaw = match[2]?.trim(); + const description = match[3].trim(); + + if (!description) return null; + + return { + type, + scope: scopeRaw ? scopeRaw : null, + description, + }; +} + +export function displayConventionalCommitTitle( + title: string | null | undefined, + fallback: string, +): string { + const parsed = parseConventionalCommitTitle(title); + if (parsed) return parsed.description; + const trimmed = title?.trim(); + return trimmed ? trimmed : fallback; +} + +export interface ParsedPrUrl { + owner: string; + repo: string; + number: string; + repoSlug: string; +} + +export function parsePrUrl(prUrl: string): ParsedPrUrl | null { + try { + const url = new URL(prUrl); + const match = url.pathname.match( + /^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:$|[/?#])/, + ); + if (!match) return null; + const [, owner, repo, number] = match; + return { owner, repo, number, repoSlug: `${owner}/${repo}` }; + } catch { + return null; + } +} diff --git a/packages/core/src/inbox/signalReportTaskService.ts b/packages/core/src/inbox/signalReportTaskService.ts index 67d17ea86c..71fe958f57 100644 --- a/packages/core/src/inbox/signalReportTaskService.ts +++ b/packages/core/src/inbox/signalReportTaskService.ts @@ -13,7 +13,7 @@ import { REPORT_MODEL_RESOLVER, type ReportModelResolver } from "./identifiers"; import { buildCreatePrReportPrompt, buildDiscussReportPrompt, -} from "./reportPrompts"; +} from "./reportActions"; import { buildSignalReportTaskInput } from "./reportTaskCreation"; export type SignalReportTaskKind = "discuss" | "create-pr"; diff --git a/packages/core/src/inbox/suggestedReviewers.ts b/packages/core/src/inbox/suggestedReviewers.ts deleted file mode 100644 index c46f59672f..0000000000 --- a/packages/core/src/inbox/suggestedReviewers.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { AvailableSuggestedReviewer } from "@posthog/shared/domain-types"; - -export interface CurrentSuggestedReviewerUser { - uuid: string; - email?: string | null; - first_name?: string | null; - last_name?: string | null; -} - -export interface SuggestedReviewerFilterOption { - uuid: string; - name: string; - email: string; - github_login: string; - isMe: boolean; - showSeparatorBelow: boolean; -} - -function normalizeString(value: string | null | undefined): string { - return typeof value === "string" ? value.trim() : ""; -} - -function buildCurrentUserName( - currentUser?: CurrentSuggestedReviewerUser | null, -): string { - const firstName = normalizeString(currentUser?.first_name); - const lastName = normalizeString(currentUser?.last_name); - return [firstName, lastName].filter(Boolean).join(" "); -} - -function sortReviewerOptionsByName( - reviewers: SuggestedReviewerFilterOption[], -): SuggestedReviewerFilterOption[] { - return [...reviewers].sort((a, b) => { - const aName = normalizeString(a.name).toLowerCase(); - const bName = normalizeString(b.name).toLowerCase(); - const aEmail = normalizeString(a.email).toLowerCase(); - const bEmail = normalizeString(b.email).toLowerCase(); - - return ( - aName.localeCompare(bName) || - aEmail.localeCompare(bEmail) || - a.uuid.localeCompare(b.uuid) - ); - }); -} - -export function getSuggestedReviewerDisplayName( - reviewer: Pick, -): string { - const baseLabel = - normalizeString(reviewer.name) || - normalizeString(reviewer.email) || - "Unknown user"; - - return reviewer.isMe ? `${baseLabel} (Me)` : baseLabel; -} - -export function buildSuggestedReviewerFilterOptions( - reviewers: AvailableSuggestedReviewer[], - currentUser?: CurrentSuggestedReviewerUser | null, -): SuggestedReviewerFilterOption[] { - const byUuid = new Map(); - - for (const reviewer of reviewers) { - const uuid = normalizeString(reviewer.uuid); - if (!uuid || byUuid.has(uuid)) { - continue; - } - - byUuid.set(uuid, { - uuid, - name: normalizeString(reviewer.name), - email: normalizeString(reviewer.email), - github_login: normalizeString(reviewer.github_login), - isMe: false, - showSeparatorBelow: false, - }); - } - - const currentUserUuid = normalizeString(currentUser?.uuid); - if (currentUserUuid) { - const existing = byUuid.get(currentUserUuid); - byUuid.set(currentUserUuid, { - uuid: currentUserUuid, - name: buildCurrentUserName(currentUser) || existing?.name || "", - email: normalizeString(currentUser?.email) || existing?.email || "", - github_login: existing?.github_login || "", - isMe: true, - showSeparatorBelow: true, - }); - } - - const options = Array.from(byUuid.values()); - const meOption = options.find((option) => option.isMe) ?? null; - const otherOptions = sortReviewerOptionsByName( - options.filter((option) => !option.isMe), - ); - - return meOption ? [meOption, ...otherOptions] : otherOptions; -} diff --git a/packages/core/src/sidebar/sidebarData.types.ts b/packages/core/src/sidebar/sidebarData.types.ts index 250d507b59..cf03c1fc6e 100644 --- a/packages/core/src/sidebar/sidebarData.types.ts +++ b/packages/core/src/sidebar/sidebarData.types.ts @@ -32,6 +32,7 @@ export interface SidebarData { isHomeActive: boolean; isHomeViewActive: boolean; isInboxActive: boolean; + isAgentsActive: boolean; isCommandCenterActive: boolean; isSkillsActive: boolean; isMcpServersActive: boolean; diff --git a/packages/host-router/src/routers/git.router.ts b/packages/host-router/src/routers/git.router.ts index 5412d82e7f..b744032757 100644 --- a/packages/host-router/src/routers/git.router.ts +++ b/packages/host-router/src/routers/git.router.ts @@ -60,6 +60,8 @@ import { getPrChangedFilesOutput, getPrDetailsByUrlInput, getPrDetailsByUrlOutput, + getPrDiffStatsBatchInput, + getPrDiffStatsBatchOutput, getPrReviewCommentsInput, getPrReviewCommentsOutput, getPrTemplateInput, @@ -489,6 +491,15 @@ export const gitRouter = router({ }), ), + getPrDiffStatsBatch: publicProcedure + .input(getPrDiffStatsBatchInput) + .output(getPrDiffStatsBatchOutput) + .query(({ ctx, input }) => + getWorkspaceClient(ctx.container).git.getPrDiffStatsBatch.query({ + prUrls: input.prUrls, + }), + ), + getPrDetailsByUrl: publicProcedure .input(getPrDetailsByUrlInput) .output(getPrDetailsByUrlOutput) diff --git a/packages/shared/package.json b/packages/shared/package.json index 52f3054e53..e94c931cb8 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -16,6 +16,22 @@ "types": "./dist/domain-types.d.ts", "import": "./dist/domain-types.js" }, + "./types": { + "types": "./dist/types.d.ts", + "import": "./dist/types.js" + }, + "./deeplink": { + "types": "./dist/deeplink.d.ts", + "import": "./dist/deeplink.js" + }, + "./dismissalReasons": { + "types": "./dist/dismissalReasons.d.ts", + "import": "./dist/dismissalReasons.js" + }, + "./constants": { + "types": "./dist/constants.d.ts", + "import": "./dist/constants.js" + }, "./mcp-sandbox-proxy": { "types": "./dist/mcp-sandbox-proxy.d.ts", "import": "./dist/mcp-sandbox-proxy.js" diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts new file mode 100644 index 0000000000..b0a3336e29 --- /dev/null +++ b/packages/shared/src/constants.ts @@ -0,0 +1,11 @@ +export { + BILLING_FLAG, + DISCOVERY_RUN_FLAG, + EXPERIMENT_SUGGESTIONS_FLAG, + HOME_TAB_FLAG, + SYNC_CLOUD_TASKS_FLAG, +} from "./flags"; + +export const SELF_DRIVING_SETUP_TASK_FLAG = + "posthog-code-self-driving-setup-task"; +export const BRANCH_PREFIX = "posthog-code/"; diff --git a/packages/shared/src/deeplink.ts b/packages/shared/src/deeplink.ts new file mode 100644 index 0000000000..eb09258e6d --- /dev/null +++ b/packages/shared/src/deeplink.ts @@ -0,0 +1,7 @@ +export { + buildInboxDeeplink, + DEEPLINK_PROTOCOL_DEVELOPMENT, + DEEPLINK_PROTOCOL_PRODUCTION, + getDeeplinkProtocol, + isPostHogCodeDeeplink, +} from "./deep-links"; diff --git a/packages/shared/src/dismissalReasons.ts b/packages/shared/src/dismissalReasons.ts new file mode 100644 index 0000000000..5492455b26 --- /dev/null +++ b/packages/shared/src/dismissalReasons.ts @@ -0,0 +1,5 @@ +export { + DISMISSAL_REASON_OPTIONS, + type DismissalReasonOptionValue, + isDismissalReasonSnooze, +} from "./dismissal-reasons"; diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index aef154d699..a616fa6346 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -355,6 +355,19 @@ export interface SignalFindingContent { verified: boolean; } +/** Artefact with `type: "repo_selection"` - selected repository for the report run. */ +export interface RepoSelectionArtefact { + id: string; + type: "repo_selection"; + content: RepoSelectionContent; + created_at: string; +} + +export interface RepoSelectionContent { + repository: string | null; + reason: string; +} + /** Artefact with `type: "suggested_reviewers"` — content is an enriched reviewer list. */ export interface SuggestedReviewersArtefact { id: string; @@ -461,6 +474,7 @@ export interface SignalReportArtefactsResponse { | PriorityJudgmentArtefact | ActionabilityJudgmentArtefact | SignalFindingArtefact + | RepoSelectionArtefact | SuggestedReviewersArtefact | DismissalArtefact )[]; diff --git a/packages/shared/src/inbox-types.ts b/packages/shared/src/inbox-types.ts index 6e89622bef..578c1b2eb9 100644 --- a/packages/shared/src/inbox-types.ts +++ b/packages/shared/src/inbox-types.ts @@ -4,3 +4,14 @@ export interface AvailableSuggestedReviewer { email: string; github_login: string; } + +export type SourceProduct = + | "session_replay" + | "error_tracking" + | "llm_analytics" + | "github" + | "linear" + | "zendesk" + | "conversations" + | "pganalyze" + | "signals_scout"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 59b6619bdd..55fb5bedf9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -83,7 +83,7 @@ export { parseImageDataUrl, } from "./image"; export { buildDiscussReportPrompt } from "./inbox-prompts"; -export type { AvailableSuggestedReviewer } from "./inbox-types"; +export type { AvailableSuggestedReviewer, SourceProduct } from "./inbox-types"; export { EXTERNAL_LINKS } from "./links"; export { getOauthClientIdFromRegion, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000000..4635212a7a --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,6 @@ +export * from "./domain-types"; +export * from "./inbox-types"; +export type { + SignalReportOrderingField, + SignalReportStatus, +} from "./signal-types"; diff --git a/packages/shared/tsup.config.ts b/packages/shared/tsup.config.ts index e4bbc2152a..f1685d8101 100644 --- a/packages/shared/tsup.config.ts +++ b/packages/shared/tsup.config.ts @@ -4,8 +4,12 @@ export default defineConfig({ entry: [ "src/index.ts", "src/analytics-events.ts", + "src/constants.ts", + "src/deeplink.ts", + "src/dismissalReasons.ts", "src/domain-types.ts", "src/mcp-sandbox-proxy.ts", + "src/types.ts", ], format: ["esm"], dts: true, diff --git a/packages/ui/src/features/agents/components/AgentsView.tsx b/packages/ui/src/features/agents/components/AgentsView.tsx new file mode 100644 index 0000000000..83deedf8c9 --- /dev/null +++ b/packages/ui/src/features/agents/components/AgentsView.tsx @@ -0,0 +1,48 @@ +import { RobotIcon } from "@phosphor-icons/react"; +import { ConfigureAgentsSection } from "@posthog/ui/features/inbox/components/ConfigureAgentsSection"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { Flex, Text } from "@radix-ui/themes"; +import { useMemo } from "react"; + +export function AgentsView() { + const headerContent = useMemo( + () => ( + + + + Agents + + + ), + [], + ); + + useSetHeaderContent(headerContent); + + return ( + + + + Agents + + + Set up the agents that watch your product – which sources they read, + which repos they ship to, who they loop in. + + + +
+
+ +
+
+
+ ); +} diff --git a/packages/ui/src/features/home/components/WorkstreamBits.tsx b/packages/ui/src/features/home/components/WorkstreamBits.tsx index f97cc9246a..618d5ec384 100644 --- a/packages/ui/src/features/home/components/WorkstreamBits.tsx +++ b/packages/ui/src/features/home/components/WorkstreamBits.tsx @@ -38,7 +38,7 @@ export function StatusGlyph({ backgroundColor: c.tint, color: c.fg, }} - title={`${v.label} — ${v.description}`} + title={`${v.label} – ${v.description}`} > @@ -55,7 +55,7 @@ export function StatusDot({ sid }: { sid: SituationId }) { ); } -/** Compact CI signal — icon-only by default, optional inline label. */ +/** Compact CI signal – icon-only by default, optional inline label. */ export function CiIndicator({ status, showLabel = false, diff --git a/packages/ui/src/features/home/config/ConfigMap.tsx b/packages/ui/src/features/home/config/ConfigMap.tsx index 4d33788dee..f0d860c368 100644 --- a/packages/ui/src/features/home/config/ConfigMap.tsx +++ b/packages/ui/src/features/home/config/ConfigMap.tsx @@ -69,7 +69,7 @@ export function ConfigMap() { return; } if (result.status === "invalid") { - toast.error("Can't save — fix the errors below"); + toast.error("Can't save – fix the errors below"); setDiagnostics(result.diagnostics ?? []); } } catch (error) { @@ -243,7 +243,7 @@ export function ConfigMap() {
- {errors.length} error{errors.length === 1 ? "" : "s"} — fix before + {errors.length} error{errors.length === 1 ? "" : "s"} – fix before saving
    diff --git a/packages/ui/src/features/home/config/SituationStation.tsx b/packages/ui/src/features/home/config/SituationStation.tsx index cd0ee6307a..454d8d8ba5 100644 --- a/packages/ui/src/features/home/config/SituationStation.tsx +++ b/packages/ui/src/features/home/config/SituationStation.tsx @@ -69,7 +69,7 @@ export function SituationStation({ id, bindings }: Props) { width: layout.w, height: layout.h, }} - aria-label={`${meta?.label ?? id} — ${actions.length} action${actions.length === 1 ? "" : "s"}`} + aria-label={`${meta?.label ?? id} – ${actions.length} action${actions.length === 1 ? "" : "s"}`} >
    = { export interface FlowArrow { from: SituationId; to: SituationId; - /** Visual hint — `branch` arrows are drawn dotted to suggest "and/or". */ + /** Visual hint – `branch` arrows are drawn dotted to suggest "and/or". */ kind: "main" | "branch"; } -// Decorative hints about the typical progression of work — NOT runtime edges. +// Decorative hints about the typical progression of work – NOT runtime edges. // The system doesn't enforce or observe these transitions. export const FLOW_ARROWS: FlowArrow[] = [ { from: "working", to: "in_review", kind: "main" }, @@ -92,7 +92,7 @@ export const SITUATION_TONE: Record< }, }; -/** Centre point of a station — used to anchor arrows. */ +/** Centre point of a station – used to anchor arrows. */ export function stationCentre(s: StationLayout): { x: number; y: number } { return { x: s.x + s.w / 2, y: s.y + s.h / 2 }; } diff --git a/packages/ui/src/features/home/hooks/useBoundActions.ts b/packages/ui/src/features/home/hooks/useBoundActions.ts index 406addd2ed..b45695d57c 100644 --- a/packages/ui/src/features/home/hooks/useBoundActions.ts +++ b/packages/ui/src/features/home/hooks/useBoundActions.ts @@ -8,7 +8,7 @@ import { useWorkflow } from "@posthog/ui/features/home/hooks/useWorkflow"; import { useMemo } from "react"; export interface BoundAction extends WorkflowAction { - /** Situation this action came from — used for telemetry + tooltips. */ + /** Situation this action came from – used for telemetry + tooltips. */ situationId: SituationId; situationLabel: string; } diff --git a/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts b/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts index 0c9db96bb9..82a6d46c39 100644 --- a/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts +++ b/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts @@ -22,7 +22,7 @@ export interface WorkstreamPresentation { accent: SituationCss; /** PR author login when it's someone else's PR, else null. */ author: string | null; - /** Situations to render as chips — primary + the calm `in_review` are omitted. */ + /** Situations to render as chips – primary + the calm `in_review` are omitted. */ extraSituations: SituationId[]; generating: boolean; /** A task in this workstream is blocked awaiting a permission response. */ diff --git a/packages/ui/src/features/home/stores/workflowEditorStore.ts b/packages/ui/src/features/home/stores/workflowEditorStore.ts index 39b381a5cb..f2e33c9ba2 100644 --- a/packages/ui/src/features/home/stores/workflowEditorStore.ts +++ b/packages/ui/src/features/home/stores/workflowEditorStore.ts @@ -9,7 +9,7 @@ import { } from "@posthog/core/workflow/schemas"; import { create } from "zustand"; -// Uncommitted editor state for the Config view — the in-flight draft, dirty +// Uncommitted editor state for the Config view – the in-flight draft, dirty // flag, and diagnostics. The persisted workflow lives in the tRPC query cache. export type Selection = | { kind: "action"; situationId: SituationId; actionId: string } diff --git a/packages/ui/src/features/home/utils/situationDisplay.ts b/packages/ui/src/features/home/utils/situationDisplay.ts index c0f3d3ac90..b26e22085a 100644 --- a/packages/ui/src/features/home/utils/situationDisplay.ts +++ b/packages/ui/src/features/home/utils/situationDisplay.ts @@ -55,11 +55,11 @@ export const SITUATION_VISUAL: Record = ]), ) as Record; -/** CSS-var passthroughs for a colour scale — used in inline `style`. */ +/** CSS-var passthroughs for a colour scale – used in inline `style`. */ export interface SituationCss { /** Readable text / icon colour, also good on a tinted fill (`--c-11`). */ fg: string; - /** Saturated solid — dots and accent rails (`--c-9`). */ + /** Saturated solid – dots and accent rails (`--c-9`). */ solid: string; /** Soft translucent fill for chips / glyph backgrounds (`--c-a3`). */ tint: string; diff --git a/packages/ui/src/features/inbox/CLAUDE.md b/packages/ui/src/features/inbox/CLAUDE.md new file mode 100644 index 0000000000..732af2ad39 --- /dev/null +++ b/packages/ui/src/features/inbox/CLAUDE.md @@ -0,0 +1,122 @@ +# PostHog Self-driving Inbox + +The Inbox is the PostHog Code surface for **Self-driving**: agents that watch product signals, summarize what matters, and can ship pull requests. This document is an architecture map for agents working in this area. It explains where responsibilities live, which backend contracts are relied on, and what not to accidentally rebuild. + +## Product Model + +The renderer still talks to backend endpoints and TypeScript types with the legacy `signals` naming. User-facing copy should say **Self-driving**, **Responder**, **report**, **run**, or **finding** depending on context. Do not rename backend paths or shared API fields unless the PostHog Cloud backend has changed too. + +The main objects are: + +- `SignalReport`: the unit shown in all Inbox tabs. +- Findings: the source observations that contributed to a report, fetched separately for detail screens. +- Artefacts: structured agent output attached to a report, such as priority, actionability, suggested reviewers, repo selection, and findings from research. +- Report tasks: links from a report to tasks created for research or implementation. + +## Information Architecture + +Inbox has three tabs and one reviewer-scope control: + +| Tab | Route | Membership | +| --- | --- | --- | +| Pull requests | `/code/inbox/pulls` | Reports with `implementation_pr_url` set | +| Reports | `/code/inbox/reports` | Reports without a PR and not currently running | +| Runs | `/code/inbox/runs` | Reports that are still in progress or waiting on input | + +Detail pages live under the same tab: `/code/inbox//$reportId`. + +Responder configuration is **not** an Inbox tab. It is the top-level Responders sidebar item at `/code/agents`. The legacy `/code/inbox/agents` route redirects there. + +Reviewer scope is a UI preference stored in `inboxReviewerScopeStore`. It filters the list between reports suggested for the current user and reports for someone else. It does not change tab membership; the tab predicates are independent. + +## Ownership Boundaries + +Keep the renderer thin: + +- Components render reports, route between tabs/details, and call hooks. +- Hooks wrap existing API clients and React Query. They should not orchestrate multi-step business workflows. +- Zustand stores hold UI preferences only: reviewer scope, filters, and selected report IDs used by task creation flows. +- Business decisions, report generation, task orchestration, and source configuration behavior belong in the PostHog Cloud backend or existing main-process services. + +Do not add frontend-only controls that imply a backend capability. If the UI exposes a new action, first identify the backend endpoint or task flow that makes it real. + +## Routes and Shell + +`InboxView` is the layout shell for `/code/inbox/*`. It owns the page header, tab bar, reviewer scope control, and nested route outlet. Route files live in `apps/code/src/renderer/routes/code/inbox/`. + +The tab components are intentionally simple: + +- `PullRequestsTab` partitions scoped reports with `isPullRequestReport`. +- `ReportsTab` partitions with `isReportTabReport`. +- `RunsTab` partitions with `isAgentRunReport`. + +The detail components share the same shape: load the report, render a common header, then render tab-specific sections. Detail sections should explain the report in product terms, not expose backend object names. + +## Data Flow + +`useInboxAllReports` is the list source of truth. It reads UI scope/filter state, calls the paginated report list hook, returns filtered reports, and computes counts used by the tabs. Multiple tab bodies can call it because React Query dedupes the underlying request. + +Tab membership and counts live in `utils/reportMembership.ts`. Keep that file as the canonical place for report partitioning rules so the tab bodies, counts, and tests stay aligned. + +Detail screens layer additional data on top of the base report: + +- `useInboxReportById(reportId)` for the report record. +- `useInboxReportSignals(reportId)` for contributing findings. +- `useInboxReportArtefacts(reportId)` for structured outputs such as suggested reviewers and repo selection. +- `useReportTasks(reportId, status)` for linked research/implementation tasks. + +List cards should prefer fields already present in the list response. Fetching per-card secondary data is acceptable only for small, clearly bounded adornments; avoid new N+1 request patterns without a batching plan. + +## Backend Contracts + +The Inbox reads from PostHog Cloud's Self-driving backend, currently implemented in the legacy `products/signals/backend` Django app: + +- `GET /api/projects/{teamId}/signals/reports/`: paginated report list. Supports filters such as status, ordering, source product, suggested reviewers, and priority. +- `GET /api/projects/{teamId}/signals/reports/{id}/`: single report detail. +- `GET /api/projects/{teamId}/signals/reports/{id}/signals/`: contributing findings. +- `GET /api/projects/{teamId}/signals/reports/{id}/artefacts/`: structured report artefacts. +- `GET /api/projects/{teamId}/signals/reports/{id}/tasks/`: tasks linked to a report. + +The shared renderer type for the report is `SignalReport` in `apps/code/src/shared/types.ts`. If the backend serializer changes, update that type and the normalizers in `posthogClient.ts` together. + +Card headlines are derived client-side from `summary` by `utils/reportPresentation.ts`; there is no backend headline field. + +## Configuration Surface + +Responder setup lives in `features/agents/components/AgentsView.tsx`, which mounts `ConfigureAgentsSection`. This surface composes existing GitHub, Slack, source-toggle, and MCP configuration pieces. Keep setup copy outcome-focused: the user is asking Self-driving to figure out what matters, not choosing internal artefact types. + +Onboarding/setup should be task-backed when it starts work. Do not model it as a static checklist if the intended behavior is to launch an agent task. + +## UI Architecture + +The current UI is single-column, route-based, and card/list oriented. Do not reintroduce the old split-pane list/detail layout. + +Shared primitives exist to keep the surfaces consistent: + +- `InboxDetailPageHeader` for detail headers. +- `DetailSection` for content sections inside detail screens. +- `SignalsList` and the existing detail `SignalCard` for contributing findings. +- Badge and metadata helpers in `components/utils/` and `InboxMetaRow`. +- `SOURCE_PRODUCT_META` for source-product labels and icons. + +When adding or changing UI, reuse those primitives first. Avoid encoding one-off layout systems inside a tab component. + +## Things to Avoid + +- Do not reuse the deleted legacy `ReportListRow`, `ReportDetailPane`, or old list/detail stores. +- Do not put page-level Inbox title or navigation into the global app header; `InboxView` owns the Inbox page chrome. +- Do not add a configure shortcut back into the Inbox header; Responders configuration is a sidebar destination. +- Do not add Scout UI until a corresponding backend exists in this repo. +- Do not put preview shims or mock report data in `apps/code/index.html`; the app shell should stay minimal. +- Do not call `electronTRPC` directly from Inbox code. Use the existing API client, React Query hooks, or tRPC client wrappers. +- Do not preserve compatibility with unshipped intermediate UI shapes on this branch. Replace them cleanly. + +## Testing + +Keep tests close to the pure logic: + +- `utils/reportMembership.test.ts` covers tab predicates, reviewer scope, routes, and counts. +- `utils/reportPresentation.test.ts` covers card headline derivation and related text shaping. +- Parser/display helpers such as conventional-commit title parsing and reviewer display should stay unit-tested. + +Use typecheck for route and hook integration. Browser screenshots are useful for design review, but preview fixtures/tooling should live outside the production `index.html` shell. diff --git a/packages/ui/src/features/inbox/components/AgentRunCard.tsx b/packages/ui/src/features/inbox/components/AgentRunCard.tsx new file mode 100644 index 0000000000..2e5a57063e --- /dev/null +++ b/packages/ui/src/features/inbox/components/AgentRunCard.tsx @@ -0,0 +1,162 @@ +import { + isFinishedRunReport, + isLiveRunReport, + isQueuedRunReport, +} from "@posthog/core/inbox/reportMembership"; +import { deriveHeadline } from "@posthog/core/inbox/reportPresentation"; +import { formatRelativeTimeLong } from "@posthog/shared"; +import type { SignalReport } from "@posthog/shared/types"; +import { + InboxMetaRow, + InboxMetaSeparator, + InboxMetaText, +} from "@posthog/ui/features/inbox/components/InboxMetaRow"; +import { InboxMetaSourceStack } from "@posthog/ui/features/inbox/components/InboxMetaSourceStack"; +import { InboxBadge } from "@posthog/ui/features/inbox/components/utils/InboxBadge"; +import { hasKnownSourceProduct } from "@posthog/ui/features/inbox/components/utils/source-product-icons"; +import { Flex, Text } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; + +export type RunVariant = "queued" | "live" | "completed" | "failed"; + +/** Single source of truth for the four-bucket lifecycle of a run-shaped report. */ +export function resolveRunVariant(report: SignalReport): RunVariant { + if (isQueuedRunReport(report)) return "queued"; + if (isLiveRunReport(report)) return "live"; + if (isFinishedRunReport(report)) { + return report.status === "failed" ? "failed" : "completed"; + } + return "live"; +} + +export const RUN_VARIANT_TIMESTAMP_LABEL: Record = { + queued: "Queued", + live: "Started", + completed: "Finished", + failed: "Failed", +}; + +interface VariantMeta { + label: string; + badgeTone: "default" | "info" | "success" | "destructive"; + orbClass: string; + dotClass: string; + ariaLabel: string; +} + +const VARIANT_META: Record = { + queued: { + label: "Queued", + badgeTone: "default", + orbClass: "bg-(--gray-3) ring-(--gray-5)", + dotClass: "bg-(--gray-9)", + ariaLabel: "Queued", + }, + live: { + label: "Running", + badgeTone: "info", + orbClass: "bg-(--blue-2) ring-(--blue-5)", + dotClass: "bg-(--blue-10) animate-pulse", + ariaLabel: "In progress", + }, + completed: { + label: "Completed", + badgeTone: "success", + orbClass: "bg-(--green-2) ring-(--green-5)", + dotClass: "bg-(--green-9)", + ariaLabel: "Completed", + }, + failed: { + label: "Failed", + badgeTone: "destructive", + orbClass: "bg-(--red-2) ring-(--red-5)", + dotClass: "bg-(--red-9)", + ariaLabel: "Failed", + }, +}; + +function pickTimestamp(report: SignalReport, variant: RunVariant): string { + if (variant === "live") return report.created_at; + return report.updated_at ?? report.created_at; +} + +function RunStatusOrb({ meta }: { meta: VariantMeta }) { + return ( + + + + ); +} + +interface AgentRunCardProps { + report: SignalReport; +} + +export function AgentRunCard({ report }: AgentRunCardProps) { + const navigate = useNavigate(); + const hasSource = hasKnownSourceProduct(report.source_products); + const runId = `…-${report.id.split("-").pop() ?? report.id}`; + const variant = resolveRunVariant(report); + const meta = VARIANT_META[variant]; + const timestampSource = pickTimestamp(report, variant); + const headline = deriveHeadline(report.summary); + + return ( + + ); +} diff --git a/packages/ui/src/features/inbox/components/AgentRunDetail.tsx b/packages/ui/src/features/inbox/components/AgentRunDetail.tsx new file mode 100644 index 0000000000..d98e7078f6 --- /dev/null +++ b/packages/ui/src/features/inbox/components/AgentRunDetail.tsx @@ -0,0 +1,509 @@ +import { + ArrowRightIcon, + CaretDownIcon, + CopyIcon, + FileTextIcon, + GitPullRequestIcon, + MagnifyingGlassIcon, + TerminalIcon, + WarningIcon, +} from "@phosphor-icons/react"; +import { + deriveHeadline, + parsePrUrl, +} from "@posthog/core/inbox/reportPresentation"; +import { Button } from "@posthog/quill"; +import { + isTerminalStatus, + type SignalReport, + type SignalReportTaskRelationship, + type Task, + type TaskRunStatus, +} from "@posthog/shared/types"; +import { + RUN_VARIANT_TIMESTAMP_LABEL, + resolveRunVariant, +} from "@posthog/ui/features/inbox/components/AgentRunCard"; +import { DetailSection } from "@posthog/ui/features/inbox/components/DetailSection"; +import { InboxDetailPageHeader } from "@posthog/ui/features/inbox/components/InboxDetailPageHeader"; +import { + InboxMetaSeparator, + InboxMetaText, +} from "@posthog/ui/features/inbox/components/InboxMetaRow"; +import { InboxMetaSourceStack } from "@posthog/ui/features/inbox/components/InboxMetaSourceStack"; +import { InboxReportDetailGate } from "@posthog/ui/features/inbox/components/InboxReportDetailGate"; +import { PrDiffStats } from "@posthog/ui/features/inbox/components/PrDiffStats"; +import { RightColumnSection } from "@posthog/ui/features/inbox/components/RightColumnSection"; +import { + SignalsList, + SignalsListSkeleton, +} from "@posthog/ui/features/inbox/components/SignalsList"; +import { ForYouBadge } from "@posthog/ui/features/inbox/components/utils/ForYouBadge"; +import { InboxBadge } from "@posthog/ui/features/inbox/components/utils/InboxBadge"; +import { SignalReportPriorityBadge } from "@posthog/ui/features/inbox/components/utils/SignalReportPriorityBadge"; +import { SignalReportSummaryMarkdown } from "@posthog/ui/features/inbox/components/utils/SignalReportSummaryMarkdown"; +import { + getSourceProductMeta, + hasKnownSourceProduct, +} from "@posthog/ui/features/inbox/components/utils/source-product-icons"; +import { useInboxReportSignals } from "@posthog/ui/features/inbox/hooks/useInboxReports"; +import { useReportTasks } from "@posthog/ui/features/inbox/hooks/useReportTasks"; +import { TaskLogsPanel } from "@posthog/ui/features/task-detail/components/TaskLogsPanel"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { openTask } from "@posthog/ui/router/useOpenTask"; +import { DropdownMenu, Flex, Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; + +export function TaskRunStatusDot({ status }: { status: TaskRunStatus }) { + const terminal = isTerminalStatus(status); + const color = terminal + ? status === "failed" || status === "cancelled" + ? "bg-(--red-9)" + : "bg-(--green-9)" + : "bg-(--blue-9)"; + const animate = terminal ? "" : " animate-pulse"; + return ( + + ); +} + +export const RELATIONSHIP_LABEL: Record = + { + research: "Research", + implementation: "Implementation", + repo_selection: "Repo selection", + }; + +interface RelevantTask { + task: Task; + relationship: SignalReportTaskRelationship; + startedAt: string; +} + +/** Prefer in-motion tasks; tie-break by most-recently-created. */ +function pickPrimaryTask(tasks: RelevantTask[]): RelevantTask | null { + if (tasks.length === 0) return null; + return [...tasks].sort((a, b) => { + const aInMotion = !isTerminalStatus(a.task.latest_run?.status ?? ""); + const bInMotion = !isTerminalStatus(b.task.latest_run?.status ?? ""); + if (aInMotion !== bInMotion) return aInMotion ? -1 : 1; + return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(); + })[0]; +} + +function RunOutputWidget({ report }: { report: SignalReport }) { + if (report.status === "ready") { + return ; + } + + if (report.status === "failed") { + return ( + + + + + + + Run failed + + + Research couldn't complete – check the task log below for the error. + The Responder may retry automatically. + + + + ); + } + + return ( + + + + ); +} + +function RunOutputReadyCard({ report }: { report: SignalReport }) { + const prUrl = report.implementation_pr_url; + const isPr = !!prUrl; + const prRef = prUrl ? parsePrUrl(prUrl) : null; + const sourceMeta = getSourceProductMeta(report.source_products?.[0]); + const headline = deriveHeadline(report.summary); + + return ( + + + + + {isPr ? ( + + ) : ( + + )} + + {prRef ? ( + + {prRef.repoSlug}#{prRef.number} + + ) : ( + Report + )} + + {prUrl ? ( + + ) : sourceMeta ? ( + + + + + {sourceMeta.label} + + ) : null} + + {(report.title || headline) && ( + + {report.title || headline} + + )} + + {isPr ? "Open the pull request" : "Open the report"} + + + + + ); +} + +interface AgentRunDetailProps { + reportId: string; +} + +export function AgentRunDetail({ reportId }: AgentRunDetailProps) { + return ( + + {(report) => } + + ); +} + +function AgentRunDetailContent({ report }: { report: SignalReport }) { + const { data: signalsResp } = useInboxReportSignals(report.id); + const { data: reportTasks, isLoading: isLoadingReportTasks } = useReportTasks( + report.id, + report.status, + ); + const signals = signalsResp?.signals ?? []; + const hasSource = hasKnownSourceProduct(report.source_products); + const isLive = report.status === "in_progress"; + const headerVariant = resolveRunVariant(report); + const headerTimestamp = + headerVariant === "live" + ? report.created_at + : (report.updated_at ?? report.created_at); + // UUIDs are time-based here, so the prefix collides across reports — show + // the random tail segment instead so adjacent runs read as distinct. + const runId = `…-${report.id.split("-").pop() ?? report.id}`; + + const primaryTask = useMemo( + () => pickPrimaryTask(reportTasks ?? []), + [reportTasks], + ); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const selectedEntry = + reportTasks?.find((rt) => rt.task.id === selectedTaskId) ?? primaryTask; + const selectedTask = selectedEntry?.task ?? null; + + // Any prior terminal research counts — completed, failed, or cancelled — + // because a re-run signals "we already tried, now we're trying again", + // regardless of how the first attempt ended. + const isReResearch = useMemo(() => { + if (!reportTasks) return false; + const researchTasks = reportTasks.filter( + (rt) => rt.relationship === "research", + ); + if (researchTasks.length < 2) return false; + const hasInFlight = researchTasks.some( + (rt) => + rt.task.latest_run?.status && + !isTerminalStatus(rt.task.latest_run.status), + ); + const hasPriorTerminal = researchTasks.some((rt) => + isTerminalStatus(rt.task.latest_run?.status ?? ""), + ); + return hasInFlight && hasPriorTerminal; + }, [reportTasks]); + + const handleCopyLink = () => { + const url = `${window.location.origin}/code/inbox/runs/${report.id}`; + navigator.clipboard + .writeText(url) + .then(() => toast.success("Link copied")) + .catch(() => toast.error("Couldn't copy link")); + }; + + return ( + + + / + {runId} + + } + reportTitle={report.title} + fallbackTitle="Untitled run" + badges={ + <> + {isLive ? ( + + + Running + + ) : ( + Finished + )} + {isReResearch && ( + + Re-research + + )} + {report.priority && ( + + )} + {report.is_suggested_reviewer && } + + } + meta={ + <> + + {RUN_VARIANT_TIMESTAMP_LABEL[headerVariant]} + + + {hasSource && ( + <> + + + + )} + {signals.length > 0 && ( + <> + + + {signals.length} finding{signals.length === 1 ? "" : "s"} + + + )} + + } + actions={ + <> + {selectedTask && ( + + )} + + + } + /> + +
    +
    + + + + setSelectedTaskId(id)} + /> + } + > + {isLoadingReportTasks ? ( + + + + + ) : selectedTask ? ( +
    + +
    + ) : ( + + + Waiting for the linked task + + + Once Self-driving links this run to a task, this panel will + show the same live log UI as the task detail page. No + separate mock log is shown here. + + + )} +
    +
    + + + {(signals.length > 0 || report.signal_count > 0) && ( + + {signals.length || report.signal_count} finding + {(signals.length || report.signal_count) === 1 ? "" : "s"} + + } + > + {signals.length > 0 ? ( + + ) : ( + + )} + + )} + +
    +
    +
    + ); +} + +function TaskLogRightSlot({ + entries, + selectedEntry, + onSelect, +}: { + entries: RelevantTask[]; + selectedEntry: RelevantTask | null | undefined; + onSelect: (id: string) => void; +}) { + if (!selectedEntry) return null; + if (entries.length <= 1) { + return ( + + {selectedEntry.task.id} + + ); + } + return ( + + + + + + {entries.map((entry) => { + const status = entry.task.latest_run?.status ?? "not_started"; + return ( + onSelect(entry.task.id)} + > + + + + {RELATIONSHIP_LABEL[entry.relationship]} + + + {entry.task.id.slice(0, 8)} + + + + ); + })} + + + ); +} diff --git a/packages/ui/src/features/inbox/components/CardSkeleton.tsx b/packages/ui/src/features/inbox/components/CardSkeleton.tsx new file mode 100644 index 0000000000..a4a98260cd --- /dev/null +++ b/packages/ui/src/features/inbox/components/CardSkeleton.tsx @@ -0,0 +1,68 @@ +interface CardSkeletonProps { + /** Number of rows to render. */ + count?: number; + /** Row style: bordered rows joined into a list, or freestanding cards. */ + variant?: "rows" | "cards"; +} + +export function CardSkeleton({ + count = 4, + variant = "rows", +}: CardSkeletonProps) { + if (variant === "cards") { + return ( +
    + {Array.from({ length: count }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton row + + ))} +
    + ); + } + + return ( +
    + {Array.from({ length: count }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton row + + ))} +
    + ); +} + +function SkeletonRow({ + rounded, + bordered, +}: { + rounded?: boolean; + bordered?: boolean; +}) { + return ( +
    + +
    + + +
    + + +
    +
    +
    + + +
    +
    + ); +} diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx new file mode 100644 index 0000000000..4f1f12f403 --- /dev/null +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -0,0 +1,438 @@ +import { ArrowSquareOutIcon, PlugsConnectedIcon } from "@phosphor-icons/react"; +import { + REPORT_MODEL_RESOLVER, + type ReportModelResolver, +} from "@posthog/core/inbox/identifiers"; +import { + TASK_SERVICE, + type TaskCreationInput, + type TaskService, +} from "@posthog/core/task-detail/taskService"; +import { useService } from "@posthog/di/react"; +import { Button } from "@posthog/quill"; +import { ANALYTICS_EVENTS, getCloudUrlFromRegion } from "@posthog/shared"; +import { SELF_DRIVING_SETUP_TASK_FLAG } from "@posthog/shared/constants"; +import type { SignalReportPriority } from "@posthog/shared/types"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { DataSourceSetup } from "@posthog/ui/features/inbox/components/DataSourceSetup"; +import { + ResponderAgentRoster, + ResponderAgentRosterSkeleton, +} from "@posthog/ui/features/inbox/components/ResponderAgentRoster"; +import { resolveDefaultModel } from "@posthog/ui/features/inbox/hooks/resolveDefaultModel"; +import { useSignalSourceManager } from "@posthog/ui/features/inbox/hooks/useSignalSourceManager"; +import { + useIntegrations, + useRepositoryIntegration, + useUserRepositoryIntegration, +} from "@posthog/ui/features/integrations/useIntegrations"; +import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; +import { GitHubIntegrationSection } from "@posthog/ui/features/settings/sections/GitHubIntegrationSection"; +import { SlackInboxNotificationsSettings } from "@posthog/ui/features/settings/sections/SlackInboxNotificationsSettings"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; +import { Badge } from "@posthog/ui/primitives/Badge"; +import { toast } from "@posthog/ui/primitives/toast"; +import { openTask } from "@posthog/ui/router/useOpenTask"; +import { track } from "@posthog/ui/shell/analytics"; +import { logger } from "@posthog/ui/shell/logger"; +import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; +import { useQueryClient } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { toast as sonnerToast } from "sonner"; + +const AUTOSTART_PRIORITY_OPTIONS: { + value: SignalReportPriority; + label: string; +}[] = [ + { value: "P0", label: "P0 – Critical only" }, + { value: "P1", label: "P1 – High and above" }, + { value: "P2", label: "P2 – Medium and above" }, + { value: "P3", label: "P3 – Low and above" }, + { value: "P4", label: "P4 – All priorities" }, +]; + +const NEVER_AUTOSTART_VALUE = "__never__"; + +const USER_AUTOSTART_OPTIONS: { value: string; label: string }[] = [ + { value: NEVER_AUTOSTART_VALUE, label: "Never – review everything first" }, + ...AUTOSTART_PRIORITY_OPTIONS, +]; + +const AUTONOMY_SETUP_PROMPT = `Set up PostHog Self-driving for this product. + +Inspect the connected PostHog project and repository, figure out which Self-driving inputs would be useful first, connect the minimum useful context, and leave a concise report of what is configured and what still needs user input. Do not invent integrations that are not available.`; + +const log = logger.scope("agents-setup-task"); + +export function ConfigureAgentsSection() { + const { + displayValues, + sourceStates, + setupSource, + isLoading, + handleToggle, + handleSetup, + handleSetupComplete, + handleSetupCancel, + userAutonomyConfig, + userAutonomyConfigLoading, + handleUpdateUserAutonomyPriority, + evaluationsUrl, + } = useSignalSourceManager(); + const { hasGithubIntegration, isLoadingIntegrations } = + useRepositoryIntegration(); + const { isLoading: isLoadingSlackIntegrations } = useIntegrations(); + const isLoadingSlack = isLoadingIntegrations || isLoadingSlackIntegrations; + const showSetupTask = useFeatureFlag(SELF_DRIVING_SETUP_TASK_FLAG); + const userAutostartPriority = + userAutonomyConfig?.autostart_priority ?? NEVER_AUTOSTART_VALUE; + + return ( + + {showSetupTask ? : null} + + + + + + + {isLoading ? ( + + ) : ( + + )} + + + + + + + + + + + Your PR auto-start threshold + + + Reports at or above this priority can start an implementation task + for you. The backend deduplicates per report, and these runs count + toward usage. + + + {userAutonomyConfigLoading ? ( + + ) : ( + + void handleUpdateUserAutonomyPriority( + value === NEVER_AUTOSTART_VALUE ? null : value, + ) + } + /> + )} + + + + + + + + + + Manage MCP servers + + + Connect or disconnect Notion, PagerDuty, Linear, Zendesk, GitHub + – anything that speaks MCP. + + + + + + + + ); +} + +function SetupTaskSection() { + const [isStartingSetupTask, setIsStartingSetupTask] = useState(false); + const { + repositories, + getUserIntegrationIdForRepo, + isLoadingRepos, + hasGithubIntegration, + } = useUserRepositoryIntegration(); + const { invalidateTasks } = useCreateTask(); + const taskService = useService(TASK_SERVICE); + const modelResolver = useService(REPORT_MODEL_RESOLVER); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const queryClient = useQueryClient(); + const lastUsedCloudRepository = useSettingsStore( + (state) => state.lastUsedCloudRepository, + ); + + const setupRepository = useMemo(() => { + const normalizedLastUsed = lastUsedCloudRepository?.toLowerCase() ?? null; + if (normalizedLastUsed && repositories.includes(normalizedLastUsed)) { + return normalizedLastUsed; + } + return repositories[0] ?? null; + }, [lastUsedCloudRepository, repositories]); + + const handleStartSetup = useCallback(async () => { + if (isStartingSetupTask) return; + if (isLoadingRepos) { + toast.error("Still loading GitHub repositories"); + return; + } + if (!hasGithubIntegration || !setupRepository) { + toast.error("Connect GitHub before starting Self-driving setup"); + return; + } + if (!cloudRegion) { + toast.error("Sign in to start Self-driving setup"); + return; + } + + const githubUserIntegrationId = + getUserIntegrationIdForRepo(setupRepository); + if (!githubUserIntegrationId) { + toast.error("Connect a GitHub integration with repository access"); + return; + } + + setIsStartingSetupTask(true); + const toastId = toast.loading( + "Starting Self-driving setup...", + setupRepository, + ); + + try { + const settings = useSettingsStore.getState(); + const adapter = settings.lastUsedAdapter ?? "claude"; + const apiHost = getCloudUrlFromRegion(cloudRegion); + const model = + settings.lastUsedModel ?? + (await resolveDefaultModel( + queryClient, + apiHost, + adapter, + modelResolver, + )); + + if (!model) { + sonnerToast.dismiss(toastId); + toast.error("Failed to start Self-driving setup", { + description: + "Couldn't resolve a default model. Open the task page once and pick a model, then try again.", + }); + return; + } + + const input: TaskCreationInput = { + content: AUTONOMY_SETUP_PROMPT, + taskDescription: AUTONOMY_SETUP_PROMPT, + repository: setupRepository, + githubUserIntegrationId, + workspaceMode: "cloud", + executionMode: "auto", + adapter, + model, + reasoningLevel: settings.lastUsedReasoningEffort ?? undefined, + }; + + const result = await taskService.createTask(input, (output) => { + invalidateTasks(output.task); + void openTask(output.task); + }); + + sonnerToast.dismiss(toastId); + if (result.success) { + track(ANALYTICS_EVENTS.TASK_CREATED, { + auto_run: true, + created_from: "command-menu", + repository_provider: "github", + workspace_mode: "cloud", + has_branch: false, + cloud_run_source: "manual", + adapter, + }); + } else { + toast.error("Failed to start Self-driving setup", { + description: result.error, + }); + log.error("Self-driving setup task creation failed", { + failedStep: result.failedStep, + error: result.error, + repository: setupRepository, + }); + } + } catch (error) { + sonnerToast.dismiss(toastId); + const description = + error instanceof Error ? error.message : "Unknown error"; + toast.error("Failed to start Self-driving setup", { description }); + log.error("Unexpected error during Self-driving setup task creation", { + error, + repository: setupRepository, + }); + } finally { + setIsStartingSetupTask(false); + } + }, [ + cloudRegion, + getUserIntegrationIdForRepo, + hasGithubIntegration, + invalidateTasks, + isLoadingRepos, + isStartingSetupTask, + setupRepository, + queryClient, + modelResolver, + taskService.createTask, + ]); + + return ( + + + + + + + + Let an agent figure it out + + + Setup required + + + + The agent will look at your connected PostHog project and repo, + choose useful inputs, and tell you what still needs your + attention. + + + + + + + ); +} + +interface SubsectionProps { + title: string; + description?: ReactNode; + children: ReactNode; +} + +function Subsection({ title, description, children }: SubsectionProps) { + return ( + + + + + {title} + + + {description ? ( + + {description} + + ) : null} + + {children} + + ); +} diff --git a/packages/ui/src/features/inbox/components/ConventionalCommitScopeTag.tsx b/packages/ui/src/features/inbox/components/ConventionalCommitScopeTag.tsx new file mode 100644 index 0000000000..8da94401ef --- /dev/null +++ b/packages/ui/src/features/inbox/components/ConventionalCommitScopeTag.tsx @@ -0,0 +1,43 @@ +import { cn } from "@posthog/quill"; +import { + formatConventionalCommitTag, + getConventionalCommitTypeMeta, +} from "@posthog/ui/features/inbox/components/conventionalCommitTypeMeta"; +import { InboxBadge } from "@posthog/ui/features/inbox/components/utils/InboxBadge"; +import type { ReactNode } from "react"; + +interface ConventionalCommitScopeTagProps { + type: string; + scope: string | null; + compact?: boolean; +} + +export function ConventionalCommitScopeTag({ + type, + scope, + compact = false, +}: ConventionalCommitScopeTagProps): ReactNode { + const meta = getConventionalCommitTypeMeta(type); + const IconComponent = meta.icon; + const label = formatConventionalCommitTag(type, scope); + + return ( + + + {label} + + ); +} diff --git a/packages/ui/src/features/inbox/components/DataSourceSetup.tsx b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx index 65a7f04a4e..0cac564c41 100644 --- a/packages/ui/src/features/inbox/components/DataSourceSetup.tsx +++ b/packages/ui/src/features/inbox/components/DataSourceSetup.tsx @@ -1,6 +1,4 @@ -import type { DataSourceService } from "@posthog/core/inbox/dataSourceService"; -import { DATA_SOURCE_SERVICE } from "@posthog/core/inbox/identifiers"; -import { useService } from "@posthog/di/react"; +import { useHostTRPC } from "@posthog/host-router/react"; import { Button } from "@posthog/quill"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; @@ -13,12 +11,31 @@ import { useGithubRepositories, useRepositoryIntegration, } from "@posthog/ui/features/integrations/useIntegrations"; -import { toast } from "@posthog/ui/primitives/toast"; import { Box, Flex, Text, TextField } from "@radix-ui/themes"; +import { useMutation } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; type DataSourceType = "github" | "linear" | "zendesk" | "pganalyze"; +const REQUIRED_SCHEMAS: Record = { + github: ["issues"], + linear: ["issues"], + zendesk: ["tickets"], + pganalyze: ["issues", "servers"], +}; + +/** PostHog DWH: full table replication (non-incremental); API enum value `full_refresh`. */ +const FULL_TABLE_REPLICATION = "full_refresh" as const; + +function schemasPayload(source: DataSourceType) { + return REQUIRED_SCHEMAS[source].map((name) => ({ + name, + should_sync: true, + sync_type: FULL_TABLE_REPLICATION, + })); +} + interface DataSourceSetupProps { source: DataSourceType; onComplete: () => void; @@ -50,7 +67,6 @@ interface SetupFormProps { function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); - const dataSourceService = useService(DATA_SOURCE_SERVICE); const { repositories, getIntegrationIdForRepo, @@ -91,7 +107,6 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { setRepo(null); }, [isLoadingRepos, repo, repositories]); - // Auto-select the first repo once loaded useEffect(() => { if (repo === null && repositories.length > 0) { setRepo(repositories[0]); @@ -103,9 +118,16 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { setLoading(true); try { - await dataSourceService.createGithubDataSource(client, projectId, { - repository: repo, - githubIntegrationId: selectedIntegrationId, + await client.createExternalDataSource(projectId, { + source_type: "Github", + payload: { + repository: repo, + auth_method: { + selection: "oauth", + github_integration_id: selectedIntegrationId, + }, + schemas: schemasPayload("github"), + }, }); toast.success("GitHub data source created"); onComplete(); @@ -116,14 +138,7 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { } finally { setLoading(false); } - }, [ - projectId, - client, - onComplete, - repo, - selectedIntegrationId, - dataSourceService, - ]); + }, [projectId, client, onComplete, repo, selectedIntegrationId]); const handleRefreshRepositories = useCallback(() => { void refreshRepositories() @@ -157,7 +172,7 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { ? "We didn't hear back from GitHub. If the browser tab was closed, click Try again." : connecting ? "Waiting for GitHub… finish authorizing in your browser, then return here." - : "Connect your GitHub account to import issues as signals."; + : "Connect your GitHub account to import issues as Self-driving findings."; return ( @@ -165,7 +180,7 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { className={ hasConnectError ? "text-(--red-11) text-sm" - : "text-(--gray-11) text-sm" + : "text-gray-11 text-sm" } > {statusMessage} @@ -245,63 +260,94 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { ); } +const POLL_INTERVAL_MS = 3_000; +const POLL_TIMEOUT_MS = 5 * 60 * 1000; + function LinearSetup({ onComplete }: SetupFormProps) { const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); - const dataSourceService = useService(DATA_SOURCE_SERVICE); + const trpc = useHostTRPC(); const [loading, setLoading] = useState(false); const [oauthConnected, setOauthConnected] = useState(false); const [linearIntegrationId, setLinearIntegrationId] = useState< number | string | null >(null); const [pollError, setPollError] = useState(null); - const pollAbortRef = useRef(null); + const pollTimerRef = useRef | null>(null); + const pollTimeoutRef = useRef | null>(null); - useEffect( - () => () => { - pollAbortRef.current?.abort(); - }, - [], + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } + }, []); + + useEffect(() => stopPolling, [stopPolling]); + + const startLinearFlow = useMutation( + trpc.linearIntegration.startFlow.mutationOptions(), ); const handleOAuthConnect = useCallback(async () => { if (!cloudRegion || !projectId || !client) return; setLoading(true); setPollError(null); - const controller = new AbortController(); - pollAbortRef.current = controller; try { - const integrationId = - await dataSourceService.connectLinearAndAwaitIntegration( - client, - cloudRegion, - projectId, - controller.signal, - ); - setLoading(false); - setOauthConnected(true); - setLinearIntegrationId(integrationId); - toast.success("Linear connected"); + await startLinearFlow.mutateAsync({ + region: cloudRegion, + projectId, + }); + + pollTimerRef.current = setInterval(async () => { + try { + const integrations = + await client.getIntegrationsForProject(projectId); + const linearIntegration = integrations.find( + (i: { kind: string }) => i.kind === "linear", + ) as { id: number | string } | undefined; + if (linearIntegration) { + stopPolling(); + setLoading(false); + setOauthConnected(true); + setLinearIntegrationId(linearIntegration.id); + toast.success("Linear connected"); + } + } catch { + // Ignore individual poll failures + } + }, POLL_INTERVAL_MS); + + pollTimeoutRef.current = setTimeout(() => { + stopPolling(); + setLoading(false); + setPollError("Connection timed out. Please try again."); + }, POLL_TIMEOUT_MS); } catch (error) { - if (controller.signal.aborted) return; setLoading(false); - setPollError( + toast.error( error instanceof Error ? error.message : "Failed to connect Linear", ); } - }, [cloudRegion, projectId, client, dataSourceService]); + }, [cloudRegion, projectId, client, stopPolling, startLinearFlow]); const handleSubmit = useCallback(async () => { if (!projectId || !client || !linearIntegrationId) return; setLoading(true); try { - await dataSourceService.createLinearDataSource( - client, - projectId, - linearIntegrationId, - ); + await client.createExternalDataSource(projectId, { + source_type: "Linear", + payload: { + linear_integration_id: linearIntegrationId, + schemas: schemasPayload("linear"), + }, + }); toast.success("Linear data source created"); onComplete(); } catch (error) { @@ -311,7 +357,7 @@ function LinearSetup({ onComplete }: SetupFormProps) { } finally { setLoading(false); } - }, [projectId, client, linearIntegrationId, onComplete, dataSourceService]); + }, [projectId, client, linearIntegrationId, onComplete]); return ( @@ -353,7 +399,6 @@ function LinearSetup({ onComplete }: SetupFormProps) { function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); - const dataSourceService = useService(DATA_SOURCE_SERVICE); const [subdomain, setSubdomain] = useState(""); const [apiKey, setApiKey] = useState(""); const [email, setEmail] = useState(""); @@ -368,10 +413,14 @@ function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { setLoading(true); try { - await dataSourceService.createZendeskDataSource(client, projectId, { - subdomain: subdomain.trim(), - apiKey: apiKey.trim(), - email: email.trim(), + await client.createExternalDataSource(projectId, { + source_type: "Zendesk", + payload: { + subdomain: subdomain.trim(), + api_key: apiKey.trim(), + email_address: email.trim(), + schemas: schemasPayload("zendesk"), + }, }); toast.success("Zendesk data source created"); onComplete(); @@ -382,15 +431,7 @@ function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { } finally { setLoading(false); } - }, [ - projectId, - client, - subdomain, - apiKey, - email, - onComplete, - dataSourceService, - ]); + }, [projectId, client, subdomain, apiKey, email, onComplete]); const canSubmit = subdomain.trim() && apiKey.trim() && email.trim(); @@ -443,7 +484,6 @@ function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { function PgAnalyzeSetup({ onComplete, onCancel }: SetupFormProps) { const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); - const dataSourceService = useService(DATA_SOURCE_SERVICE); const [apiKey, setApiKey] = useState(""); const [organizationSlug, setOrganizationSlug] = useState(""); const [loading, setLoading] = useState(false); @@ -457,9 +497,13 @@ function PgAnalyzeSetup({ onComplete, onCancel }: SetupFormProps) { setLoading(true); try { - await dataSourceService.createPgAnalyzeDataSource(client, projectId, { - apiKey: apiKey.trim(), - organizationSlug: organizationSlug.trim(), + await client.createExternalDataSource(projectId, { + source_type: "PgAnalyze", + payload: { + api_key: apiKey.trim(), + organization_slug: organizationSlug.trim(), + schemas: schemasPayload("pganalyze"), + }, }); toast.success("pganalyze data source created"); onComplete(); @@ -470,14 +514,7 @@ function PgAnalyzeSetup({ onComplete, onCancel }: SetupFormProps) { } finally { setLoading(false); } - }, [ - projectId, - client, - apiKey, - organizationSlug, - onComplete, - dataSourceService, - ]); + }, [projectId, client, apiKey, organizationSlug, onComplete]); const canSubmit = apiKey.trim() && organizationSlug.trim(); @@ -529,10 +566,13 @@ function SetupFormContainer({ children: React.ReactNode; }) { return ( - + - {title} + {title} {children} diff --git a/packages/ui/src/features/inbox/components/DetailBackLink.tsx b/packages/ui/src/features/inbox/components/DetailBackLink.tsx new file mode 100644 index 0000000000..cb1e66f10b --- /dev/null +++ b/packages/ui/src/features/inbox/components/DetailBackLink.tsx @@ -0,0 +1,19 @@ +import { ArrowLeftIcon } from "@phosphor-icons/react"; +import { Link } from "@tanstack/react-router"; + +interface DetailBackLinkProps { + to: string; + label: string; +} + +export function DetailBackLink({ to, label }: DetailBackLinkProps) { + return ( + + + {label} + + ); +} diff --git a/packages/ui/src/features/inbox/components/DetailSection.tsx b/packages/ui/src/features/inbox/components/DetailSection.tsx new file mode 100644 index 0000000000..c72d787816 --- /dev/null +++ b/packages/ui/src/features/inbox/components/DetailSection.tsx @@ -0,0 +1,38 @@ +import type { IconProps } from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; +import type { ComponentType, ReactNode } from "react"; + +interface DetailSectionProps { + Icon: ComponentType; + title: string; + rightSlot?: ReactNode; + children: ReactNode; +} + +export function DetailSection({ + Icon, + title, + rightSlot, + children, +}: DetailSectionProps) { + return ( + + + + + + {title} + + +
    + {rightSlot &&
    {rightSlot}
    } + +
    {children}
    + + ); +} diff --git a/packages/ui/src/features/inbox/components/DismissReportDialog.tsx b/packages/ui/src/features/inbox/components/DismissReportDialog.tsx index 9e48cf6673..b0ee01aaeb 100644 --- a/packages/ui/src/features/inbox/components/DismissReportDialog.tsx +++ b/packages/ui/src/features/inbox/components/DismissReportDialog.tsx @@ -2,21 +2,15 @@ import { DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, isDismissalReasonSnooze, -} from "@posthog/shared"; -import type { SignalReport } from "@posthog/shared/domain-types"; -import { - AlertDialog, - Flex, - RadioGroup, - Text, - TextArea, -} from "@radix-ui/themes"; -import { useEffect, useState } from "react"; -import { Button } from "../../../primitives/Button"; +} from "@posthog/shared/dismissalReasons"; +import type { SignalReport } from "@posthog/shared/types"; import { ExplainedPauseLabel, ExplainedSuppressLabel, -} from "./utils/ExplainedDismissOptionLabels"; +} from "@posthog/ui/features/inbox/components/utils/ExplainedDismissOptionLabels"; +import { Button } from "@posthog/ui/primitives/Button"; +import { Dialog, Flex, RadioGroup, Text, TextArea } from "@radix-ui/themes"; +import { useEffect, useRef, useState } from "react"; export interface DismissReportDialogResult { reason: DismissalReasonOptionValue; @@ -27,6 +21,8 @@ export interface DismissReportDialogProps { open: boolean; onOpenChange: (open: boolean) => void; report: SignalReport; + /** When greater than 1, copy reflects a bulk dismiss of the current selection. */ + selectedCount?: number; isSubmitting: boolean; /** * When snooze is not allowed for the current selection, the "Already fixed elsewhere" @@ -40,19 +36,73 @@ export function DismissReportDialog({ open, onOpenChange, report, + selectedCount = 1, isSubmitting, snoozeDisabledReason, onConfirm, }: DismissReportDialogProps) { - const [reason, setReason] = useState(null); - const [note, setNote] = useState(""); + const onOpenChangeRef = useRef(onOpenChange); + onOpenChangeRef.current = onOpenChange; + // Radix Themes nests Content inside the overlay scroll area, so backdrop clicks + // often land on padding/overlay nodes that never reach Content's dismiss layer. useEffect(() => { - if (open) { - setReason(null); - setNote(""); - } - }, [open]); + if (!open || isSubmitting) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + + const overlay = document.querySelector( + '.rt-DialogOverlay[data-state="open"]', + ); + const content = document.querySelector( + '.rt-DialogContent[data-state="open"]', + ); + if (!overlay?.contains(target) || content?.contains(target)) return; + + onOpenChangeRef.current(false); + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + return () => + document.removeEventListener("pointerdown", handlePointerDown, true); + }, [open, isSubmitting]); + + return ( + + { + if (!isSubmitting) onOpenChange(false); + }} + onEscapeKeyDown={() => { + if (!isSubmitting) onOpenChange(false); + }} + > + + + + ); +} + +function DismissReportDialogBody({ + report, + selectedCount, + isSubmitting, + snoozeDisabledReason, + onConfirm, +}: Omit & { + selectedCount: number; +}) { + const [reason, setReason] = useState(null); + const [note, setNote] = useState(""); const handleConfirm = () => { if (!reason) return; @@ -62,83 +112,81 @@ export function DismissReportDialog({ const alreadyFixedDisabled = snoozeDisabledReason !== null; return ( - - - - - Dismiss report " - {report.title?.trim() ? report.title : "Untitled signal"}"? - - - - This report will be removed from your inbox. -
    - Your feedback is saved on the report and helps the agent. -
    - - - - setReason(value as DismissalReasonOptionValue) - } - > - - {DISMISSAL_REASON_OPTIONS.map((option) => { - const snoozesInsteadOfDismiss = isDismissalReasonSnooze( - option.value, - ); - const disabled = - snoozesInsteadOfDismiss && alreadyFixedDisabled; - - return snoozesInsteadOfDismiss ? ( - - ) : ( - - ); - })} - - - -