From 2da57ed1022fbbebfe909fa746a25a1bb2d39ddf Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Mon, 18 May 2026 09:51:53 +0000 Subject: [PATCH 1/8] feat(signals): add slack notification picker in inbox source settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the new per-user Slack notification settings for the signals inbox (workspace + channel + minimum priority) in the inbox source settings panel. Reuses the existing /api/.../integrations/{id}/channels/ endpoint to populate the channel picker and the existing slack Integration store, so no new auth setup is required — users connect Slack once in PostHog Web and pick a channel here. Pairs with the backend changes in posthog/posthog#58774. Generated-By: PostHog Code Task-Id: 046d7425-f0b4-4ab4-97f8-cce0627927f0 --- apps/code/src/renderer/api/posthogClient.ts | 33 ++- .../inbox/hooks/useSignalSourceManager.ts | 37 +++ .../features/inbox/hooks/useSlackChannels.ts | 20 ++ .../integrations/stores/integrationStore.ts | 5 + .../SignalSlackNotificationsSettings.tsx | 255 ++++++++++++++++++ .../sections/SignalSourcesSettings.tsx | 2 + apps/code/src/shared/types.ts | 20 ++ 7 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts create mode 100644 apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 011494253..3d667077f 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -24,6 +24,7 @@ import type { SignalReportTaskRelationship, SignalTeamConfig, SignalUserAutonomyConfig, + SlackChannelsResponse, SuggestedReviewersArtefact, Task, TaskRun, @@ -2250,9 +2251,14 @@ export class PostHogAPIClient { return (await response.json()) as SignalUserAutonomyConfig; } - async updateSignalUserAutonomyConfig(updates: { - autostart_priority: string | null; - }): Promise { + async updateSignalUserAutonomyConfig( + updates: Partial<{ + autostart_priority: string | null; + slack_notification_integration_id: number | null; + slack_notification_channel: string | null; + slack_notification_min_priority: string | null; + }>, + ): Promise { const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); const path = "/api/users/@me/signal_autonomy/"; @@ -2273,6 +2279,27 @@ export class PostHogAPIClient { return (await response.json()) as SignalUserAutonomyConfig; } + async getSlackChannelsForIntegration( + integrationId: number, + ): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/channels/`, + ); + const path = `/api/environments/${teamId}/integrations/${integrationId}/channels/`; + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Slack channels: ${response.statusText}`); + } + return (await response.json()) as SlackChannelsResponse; + } + async deleteSignalUserAutonomyConfig(): Promise { const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); const path = "/api/users/@me/signal_autonomy/"; diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts index 70d1c86dc..cfa064067 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts @@ -466,6 +466,42 @@ export function useSignalSourceManager() { [client, queryClient], ); + const handleUpdateSlackNotifications = useCallback( + async (updates: { + integrationId?: number | null; + channel?: string | null; + minPriority?: string | null; + }) => { + if (!client) return; + // Translate frontend camelCase to the API's snake_case body. Only include + // keys the caller passed in, so other settings (e.g. autostart_priority) + // are not wiped. + const body: Record = {}; + if ("integrationId" in updates) { + body.slack_notification_integration_id = updates.integrationId ?? null; + } + if ("channel" in updates) { + body.slack_notification_channel = updates.channel ?? null; + } + if ("minPriority" in updates) { + body.slack_notification_min_priority = updates.minPriority ?? null; + } + try { + await client.updateSignalUserAutonomyConfig(body); + await queryClient.invalidateQueries({ + queryKey: ["signals", "user-autonomy-config"], + }); + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "Failed to update Slack notification setting"; + toast.error(message); + } + }, + [client, queryClient], + ); + return { displayValues, sourceStates, @@ -483,5 +519,6 @@ export function useSignalSourceManager() { handleUpdateAutostartPriority, userAutonomyConfig, handleUpdateUserAutonomyPriority, + handleUpdateSlackNotifications, }; } diff --git a/apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts b/apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts new file mode 100644 index 000000000..7097087f8 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts @@ -0,0 +1,20 @@ +import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; +import type { SlackChannelsResponse } from "@shared/types"; + +export function useSlackChannels(integrationId: number | null | undefined) { + return useAuthenticatedQuery( + ["slack", "channels", integrationId ?? null], + async (client) => { + if (!integrationId) { + return { channels: [] }; + } + return await client.getSlackChannelsForIntegration(integrationId); + }, + { + enabled: !!integrationId, + // Slack channel lists are cached server-side for an hour, so refresh + // sparingly on the client. + staleTime: 5 * 60_000, + }, + ); +} diff --git a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts b/apps/code/src/renderer/features/integrations/stores/integrationStore.ts index 112f42a9e..022f1eea8 100644 --- a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts +++ b/apps/code/src/renderer/features/integrations/stores/integrationStore.ts @@ -26,6 +26,8 @@ interface IntegrationStore { interface IntegrationSelectors { githubIntegrations: Integration[]; hasGithubIntegration: boolean; + slackIntegrations: Integration[]; + hasSlackIntegration: boolean; } export const useIntegrationStore = create((set) => ({ @@ -36,9 +38,12 @@ export const useIntegrationStore = create((set) => ({ export const useIntegrationSelectors = (): IntegrationSelectors => { const integrations = useIntegrationStore((state) => state.integrations); const githubIntegrations = integrations.filter((i) => i.kind === "github"); + const slackIntegrations = integrations.filter((i) => i.kind === "slack"); return { githubIntegrations, hasGithubIntegration: githubIntegrations.length > 0, + slackIntegrations, + hasSlackIntegration: slackIntegrations.length > 0, }; }; diff --git a/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx new file mode 100644 index 000000000..6ad030102 --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx @@ -0,0 +1,255 @@ +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceManager"; +import { useSlackChannels } from "@features/inbox/hooks/useSlackChannels"; +import { useIntegrationSelectors } from "@features/integrations/stores/integrationStore"; +import { ArrowSquareOutIcon } from "@phosphor-icons/react"; +import { Box, Button, Flex, Select, Text } from "@radix-ui/themes"; +import type { SignalReportPriority } from "@shared/types"; +import { openUrlInBrowser } from "@utils/browser"; +import { getPostHogUrl } from "@utils/urls"; + +const NOTIFY_OFF_VALUE = "__off__"; +const NOTIFY_ALL_VALUE = "__all__"; + +const MIN_PRIORITY_OPTIONS: { + value: SignalReportPriority | typeof NOTIFY_ALL_VALUE; + label: string; +}[] = [ + { + value: NOTIFY_ALL_VALUE, + label: "Every priority (including unprioritized)", + }, + { value: "P0", label: "P0 only (critical)" }, + { value: "P1", label: "P1 and above" }, + { value: "P2", label: "P2 and above" }, + { value: "P3", label: "P3 and above" }, + { value: "P4", label: "P4 and above" }, +]; + +function buildChannelTargetValue( + channelId: string, + channelName: string, +): string { + // Mirror the convention used by the rest of PostHog: `channelId|#channel-name`. + const display = channelName.startsWith("#") ? channelName : `#${channelName}`; + return `${channelId}|${display}`; +} + +function parseChannelIdFromTargetValue( + value: string | null | undefined, +): string | null { + if (!value) return null; + return value.split("|")[0]?.trim() || null; +} + +export function SignalSlackNotificationsSettings() { + const projectId = useAuthStateValue((s) => s.projectId); + const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + const { slackIntegrations, hasSlackIntegration } = useIntegrationSelectors(); + const { userAutonomyConfig, handleUpdateSlackNotifications } = + useSignalSourceManager(); + + const selectedIntegrationId = + userAutonomyConfig?.slack_notification_integration_id ?? null; + const selectedChannelTarget = + userAutonomyConfig?.slack_notification_channel ?? null; + const selectedChannelId = parseChannelIdFromTargetValue( + selectedChannelTarget, + ); + const minPriority = + userAutonomyConfig?.slack_notification_min_priority ?? null; + + // Default the integration selection to the first one if there's only one + // available — we still require an explicit channel pick to enable delivery. + const effectiveIntegrationId = + selectedIntegrationId ?? + (slackIntegrations.length === 1 ? slackIntegrations[0].id : null); + + const { data: channelsData, isPending: channelsLoading } = useSlackChannels( + effectiveIntegrationId, + ); + + const slackSettingsUrl = projectId + ? getPostHogUrl( + `/project/${projectId}/settings/project-integrations#slack`, + cloudRegion, + ) + : null; + + const notificationsEnabled = + !!selectedIntegrationId && !!selectedChannelTarget; + + if (!hasSlackIntegration) { + return ( + + + Slack notifications for new inbox items + + + Get pinged in Slack when a new inbox item lands and you're a suggested + reviewer. Connect Slack to PostHog first. + + + + + + ); + } + + const onChannelChange = (value: string) => { + if (value === NOTIFY_OFF_VALUE) { + // Turn off by clearing the channel; integration ID is kept so re-enabling + // remembers the previous workspace. + void handleUpdateSlackNotifications({ channel: null }); + return; + } + if (!effectiveIntegrationId) return; + const channel = channelsData?.channels?.find((c) => c.id === value); + if (!channel) return; + void handleUpdateSlackNotifications({ + integrationId: effectiveIntegrationId, + channel: buildChannelTargetValue(channel.id, channel.name), + }); + }; + + const onIntegrationChange = (value: string) => { + const integrationId = Number(value); + if (!Number.isFinite(integrationId)) return; + // Switching workspaces clears the channel — the previously picked + // channel won't exist in the new workspace. + void handleUpdateSlackNotifications({ + integrationId, + channel: null, + }); + }; + + const onMinPriorityChange = (value: string) => { + void handleUpdateSlackNotifications({ + minPriority: value === NOTIFY_ALL_VALUE ? null : value, + }); + }; + + return ( + + + Slack notifications for new inbox items + + + When a new inbox item lands and you're a suggested reviewer, PostHog + posts a Slack message in the channel you select. Pick a minimum priority + to filter out lower-urgency reports. + + + {slackIntegrations.length > 1 ? ( + + Slack workspace + + + + {slackIntegrations.map((integration) => { + const label = + integration.display_name ?? + integration.config?.account?.name ?? + `Slack workspace ${integration.id}`; + return ( + + {label} + + ); + })} + + + + ) : null} + + + + Notification channel + + + + + + Off — don't notify me + + {(channelsData?.channels ?? []) + .filter((c) => !c.is_private_without_access) + .map((channel) => ( + + {channel.is_private ? "🔒" : "#"} + {channel.name} + {channel.is_ext_shared ? " (shared)" : ""} + + ))} + + + + PostHog needs to be a member of the channel to post. Invite it with{" "} + /invite @PostHog in Slack if delivery fails. + + + + + + Minimum priority to notify + + + + + {MIN_PRIORITY_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + + + + ); +} diff --git a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx index 84be4e055..d93356ced 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx @@ -2,6 +2,7 @@ import { DataSourceSetup } from "@features/inbox/components/DataSourceSetup"; import { SignalSourceToggles } from "@features/inbox/components/SignalSourceToggles"; import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceManager"; import { GitHubIntegrationSection } from "@features/settings/components/sections/GitHubIntegrationSection"; +import { SignalSlackNotificationsSettings } from "@features/settings/components/sections/SignalSlackNotificationsSettings"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { Box, Flex, Select, Text, Tooltip } from "@radix-ui/themes"; import type { SignalReportPriority } from "@shared/types"; @@ -120,6 +121,7 @@ export function SignalSourcesSettings() { + ); } diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index fc58146b8..335af9b31 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -527,6 +527,26 @@ export interface SignalTeamConfig { export interface SignalUserAutonomyConfig { id?: string; autostart_priority: SignalReportPriority | null; + /** ID of the team-scoped Slack `Integration` row used to deliver inbox-item notifications. */ + slack_notification_integration_id?: number | null; + /** `channel_id|#channel-name` target — same convention used by Insight Alerts. */ + slack_notification_channel?: string | null; + /** Minimum priority that triggers a notification (P0 highest). `null` = every priority. */ + slack_notification_min_priority?: SignalReportPriority | null; created_at?: string; updated_at?: string; } + +export interface SlackChannelOption { + id: string; + name: string; + is_private: boolean; + is_member: boolean; + is_ext_shared: boolean; + is_private_without_access: boolean; +} + +export interface SlackChannelsResponse { + channels: SlackChannelOption[]; + lastRefreshedAt?: string; +} From 50ef3c36b46a439667fbd54d7ab34f5a5220a249 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Mon, 18 May 2026 15:05:58 +0000 Subject: [PATCH 2/8] feat(signals): connect Slack to PostHog directly from PostHog Code Adds an in-app Slack connect flow so users no longer have to leave PostHog Code for the PostHog web settings page just to wire up notifications. Flow: clicking "Connect Slack workspace" in the inbox source settings opens the browser to PostHog Cloud's Slack OAuth authorize endpoint with `next=/account-connected/slack-integration?...`. PostHog Cloud completes OAuth, creates the team-level Slack `Integration`, and redirects to a landing page that fires a `posthog-code://slack-integration?...` deep link back into the app. The new `SlackIntegrationService` (main process) handles the deep link, emits a callback event, and the renderer hook refreshes the integrations list. Mirrors the existing `GitHubIntegrationService` so each provider's deep link handler stays isolated. Pairs with backend changes in posthog/posthog#58774 that teach the `/account-connected/` page about the new `slack-integration` kind. Generated-By: PostHog Code Task-Id: 046d7425-f0b4-4ab4-97f8-cce0627927f0 --- apps/code/src/main/di/container.ts | 2 + apps/code/src/main/di/tokens.ts | 1 + apps/code/src/main/index.ts | 2 + .../services/slack-integration/schemas.ts | 8 + .../services/slack-integration/service.ts | 162 ++++++++++++++++++ apps/code/src/main/trpc/router.ts | 2 + .../main/trpc/routers/slack-integration.ts | 62 +++++++ .../integrations/hooks/useSlackConnect.ts | 134 +++++++++++++++ .../hooks/useSlackIntegrationCallback.ts | 90 ++++++++++ .../SignalSlackNotificationsSettings.tsx | 58 +++++-- 10 files changed, 502 insertions(+), 19 deletions(-) create mode 100644 apps/code/src/main/services/slack-integration/schemas.ts create mode 100644 apps/code/src/main/services/slack-integration/service.ts create mode 100644 apps/code/src/main/trpc/routers/slack-integration.ts create mode 100644 apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts create mode 100644 apps/code/src/renderer/features/integrations/hooks/useSlackIntegrationCallback.ts diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 959ea1431..77b67f8e0 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -58,6 +58,7 @@ import { ProcessTrackingService } from "../services/process-tracking/service"; import { ProvisioningService } from "../services/provisioning/service"; import { settingsStore } from "../services/settingsStore"; import { ShellService } from "../services/shell/service"; +import { SlackIntegrationService } from "../services/slack-integration/service"; import { SleepService } from "../services/sleep/service"; import { SuspensionService } from "../services/suspension/service"; import { TaskLinkService } from "../services/task-link/service"; @@ -136,6 +137,7 @@ container.bind(MAIN_TOKENS.ProcessTrackingService).to(ProcessTrackingService); container.bind(MAIN_TOKENS.PosthogPluginService).to(PosthogPluginService); container.bind(MAIN_TOKENS.SleepService).to(SleepService); container.bind(MAIN_TOKENS.ShellService).to(ShellService); +container.bind(MAIN_TOKENS.SlackIntegrationService).to(SlackIntegrationService); container.bind(MAIN_TOKENS.UIService).to(UIService); container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService); container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService); diff --git a/apps/code/src/main/di/tokens.ts b/apps/code/src/main/di/tokens.ts index c8225b2b1..0acbac7d2 100644 --- a/apps/code/src/main/di/tokens.ts +++ b/apps/code/src/main/di/tokens.ts @@ -60,6 +60,7 @@ export const MAIN_TOKENS = Object.freeze({ HandoffService: Symbol.for("Main.HandoffService"), GitHubIntegrationService: Symbol.for("Main.GitHubIntegrationService"), LinearIntegrationService: Symbol.for("Main.LinearIntegrationService"), + SlackIntegrationService: Symbol.for("Main.SlackIntegrationService"), DeepLinkService: Symbol.for("Main.DeepLinkService"), NotificationService: Symbol.for("Main.NotificationService"), McpCallbackService: Symbol.for("Main.McpCallbackService"), diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 57f06f765..bc2a48f09 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -24,6 +24,7 @@ import { trackAppEvent, } from "./services/posthog-analytics"; import type { PosthogPluginService } from "./services/posthog-plugin/service"; +import type { SlackIntegrationService } from "./services/slack-integration/service"; import type { SuspensionService } from "./services/suspension/service"; import type { TaskLinkService } from "./services/task-link/service"; import type { UpdatesService } from "./services/updates/service"; @@ -149,6 +150,7 @@ async function initializeServices(): Promise { container.get(MAIN_TOKENS.TaskLinkService); container.get(MAIN_TOKENS.InboxLinkService); container.get(MAIN_TOKENS.GitHubIntegrationService); + container.get(MAIN_TOKENS.SlackIntegrationService); container.get(MAIN_TOKENS.ExternalAppsService); container.get(MAIN_TOKENS.PosthogPluginService); diff --git a/apps/code/src/main/services/slack-integration/schemas.ts b/apps/code/src/main/services/slack-integration/schemas.ts new file mode 100644 index 000000000..06d0b9b5f --- /dev/null +++ b/apps/code/src/main/services/slack-integration/schemas.ts @@ -0,0 +1,8 @@ +export { + type CloudRegion, + cloudRegion, + type StartIntegrationFlowInput as StartSlackFlowInput, + type StartIntegrationFlowOutput as StartSlackFlowOutput, + startIntegrationFlowInput as startSlackFlowInput, + startIntegrationFlowOutput as startSlackFlowOutput, +} from "../integration-flow-schemas"; diff --git a/apps/code/src/main/services/slack-integration/service.ts b/apps/code/src/main/services/slack-integration/service.ts new file mode 100644 index 000000000..126677a8e --- /dev/null +++ b/apps/code/src/main/services/slack-integration/service.ts @@ -0,0 +1,162 @@ +import type { IMainWindow } from "@posthog/platform/main-window"; +import type { IUrlLauncher } from "@posthog/platform/url-launcher"; +import { getCloudUrlFromRegion } from "@shared/utils/urls"; +import { inject, injectable } from "inversify"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { logger } from "../../utils/logger"; +import { TypedEventEmitter } from "../../utils/typed-event-emitter"; +import type { DeepLinkService } from "../deep-link/service"; +import type { CloudRegion, StartSlackFlowOutput } from "./schemas"; + +const log = logger.scope("slack-integration-service"); + +const FLOW_TIMEOUT_MS = 5 * 60 * 1000; + +export const SlackIntegrationEvent = { + Callback: "callback", + FlowTimedOut: "flowTimedOut", +} as const; + +export interface SlackIntegrationCallback { + projectId: number | null; + integrationId: number | null; + status: "success" | "error"; + errorCode: string | null; + errorMessage: string | null; +} + +export interface SlackFlowTimedOut { + projectId: number; +} + +export interface SlackIntegrationEvents { + [SlackIntegrationEvent.Callback]: SlackIntegrationCallback; + [SlackIntegrationEvent.FlowTimedOut]: SlackFlowTimedOut; +} + +/** + * Drives the in-app "Connect Slack" flow: + * 1. The renderer asks for `startFlow(region, projectId)`, which opens the user's + * default browser at PostHog Cloud's Slack OAuth authorize endpoint. + * 2. PostHog Cloud completes Slack OAuth, creates the team-level Slack `Integration` + * row, and redirects to `/account-connected/slack-integration?integration_id=…`, + * which sends a `posthog-code://slack-integration?…` deep link. + * 3. The deep-link handler emits a `Callback` event; renderers refresh integrations. + * + * Mirrors `GitHubIntegrationService` so each provider's deep-link handler is independent. + */ +@injectable() +export class SlackIntegrationService extends TypedEventEmitter { + private pendingCallback: SlackIntegrationCallback | null = null; + private flowTimeout: ReturnType | null = null; + + constructor( + @inject(MAIN_TOKENS.DeepLinkService) + private readonly deepLinkService: DeepLinkService, + @inject(MAIN_TOKENS.UrlLauncher) + private readonly urlLauncher: IUrlLauncher, + @inject(MAIN_TOKENS.MainWindow) + private readonly mainWindow: IMainWindow, + ) { + super(); + + this.deepLinkService.registerHandler("slack-integration", (_path, params) => + this.handleCallback(params), + ); + } + + public async startFlow( + region: CloudRegion, + projectId: number, + ): Promise { + try { + const cloudUrl = getCloudUrlFromRegion(region); + // Lands on PostHog Cloud's AccountConnected page, which forwards to + // `posthog-code://slack-integration?…` with `integration_id` set. + const nextPath = `/account-connected/slack-integration?provider=slack&project_id=${projectId}&connect_from=posthog_code`; + const authorizeUrl = `${cloudUrl}/api/environments/${projectId}/integrations/authorize/?kind=slack&next=${encodeURIComponent(nextPath)}`; + + this.clearFlowTimeout(); + this.flowTimeout = setTimeout(() => { + log.warn("Slack integration flow timed out", { projectId }); + this.flowTimeout = null; + this.emit(SlackIntegrationEvent.FlowTimedOut, { projectId }); + }, FLOW_TIMEOUT_MS); + + await this.urlLauncher.launch(authorizeUrl); + + return { success: true }; + } catch (error) { + this.clearFlowTimeout(); + log.error("Failed to start Slack integration flow", { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + public consumePendingCallback(): SlackIntegrationCallback | null { + const pending = this.pendingCallback; + this.pendingCallback = null; + return pending; + } + + private handleCallback(params: URLSearchParams): boolean { + const projectIdRaw = params.get("project_id"); + const parsedProjectId = projectIdRaw ? Number(projectIdRaw) : null; + const integrationIdRaw = params.get("integration_id"); + const parsedIntegrationId = integrationIdRaw + ? Number(integrationIdRaw) + : null; + const status = params.get("status") === "error" ? "error" : "success"; + + const callback: SlackIntegrationCallback = { + projectId: + parsedProjectId !== null && Number.isFinite(parsedProjectId) + ? parsedProjectId + : null, + integrationId: + parsedIntegrationId !== null && Number.isFinite(parsedIntegrationId) + ? parsedIntegrationId + : null, + status, + errorCode: params.get("error_code") || null, + errorMessage: params.get("error_message") || null, + }; + + this.clearFlowTimeout(); + + if (status === "error") { + log.error("Received Slack integration callback with error", { + projectId: callback.projectId, + errorCode: callback.errorCode, + errorMessage: callback.errorMessage, + }); + } + + const hasListeners = this.listenerCount(SlackIntegrationEvent.Callback) > 0; + if (hasListeners) { + this.emit(SlackIntegrationEvent.Callback, callback); + } else { + this.pendingCallback = callback; + } + + if (this.mainWindow.isMinimized()) { + this.mainWindow.restore(); + } + this.mainWindow.focus(); + + return true; + } + + private clearFlowTimeout(): void { + if (this.flowTimeout) { + clearTimeout(this.flowTimeout); + this.flowTimeout = null; + } + } +} diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 75a5c85c2..9d69a3d29 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -30,6 +30,7 @@ import { provisioningRouter } from "./routers/provisioning"; import { secureStoreRouter } from "./routers/secure-store"; import { shellRouter } from "./routers/shell"; import { skillsRouter } from "./routers/skills"; +import { slackIntegrationRouter } from "./routers/slack-integration"; import { sleepRouter } from "./routers/sleep"; import { suspensionRouter } from "./routers/suspension.js"; import { uiRouter } from "./routers/ui"; @@ -72,6 +73,7 @@ export const trpcRouter = router({ secureStore: secureStoreRouter, shell: shellRouter, skills: skillsRouter, + slackIntegration: slackIntegrationRouter, ui: uiRouter, updates: updatesRouter, deepLink: deepLinkRouter, diff --git a/apps/code/src/main/trpc/routers/slack-integration.ts b/apps/code/src/main/trpc/routers/slack-integration.ts new file mode 100644 index 000000000..2c15097dc --- /dev/null +++ b/apps/code/src/main/trpc/routers/slack-integration.ts @@ -0,0 +1,62 @@ +import { container } from "../../di/container"; +import { MAIN_TOKENS } from "../../di/tokens"; +import { + startSlackFlowInput, + startSlackFlowOutput, +} from "../../services/slack-integration/schemas"; +import { + type SlackFlowTimedOut, + type SlackIntegrationCallback, + SlackIntegrationEvent, + type SlackIntegrationService, +} from "../../services/slack-integration/service"; +import { publicProcedure, router } from "../trpc"; + +const getService = () => + container.get(MAIN_TOKENS.SlackIntegrationService); + +export const slackIntegrationRouter = router({ + startFlow: publicProcedure + .input(startSlackFlowInput) + .output(startSlackFlowOutput) + .mutation(({ input }) => + getService().startFlow(input.region, input.projectId), + ), + + /** + * Subscribe to Slack integration deep link callbacks emitted after the user + * completes (or errors out of) the Slack OAuth flow on PostHog Cloud. + */ + onCallback: publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(SlackIntegrationEvent.Callback, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + /** + * Subscribe to flow timeout events (5 minutes with no deep link callback). + */ + onFlowTimedOut: publicProcedure.subscription(async function* (opts) { + const service = getService(); + const iterable = service.toIterable(SlackIntegrationEvent.FlowTimedOut, { + signal: opts.signal, + }); + for await (const data of iterable) { + yield data; + } + }), + + /** + * Get any integration callback that arrived before the renderer subscribed. + */ + consumePendingCallback: publicProcedure.query( + (): SlackIntegrationCallback | null => + getService().consumePendingCallback(), + ), +}); + +export type { SlackIntegrationCallback, SlackFlowTimedOut }; diff --git a/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts b/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts new file mode 100644 index 000000000..a54186678 --- /dev/null +++ b/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts @@ -0,0 +1,134 @@ +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { useSlackIntegrationCallback } from "@features/integrations/hooks/useSlackIntegrationCallback"; +import { trpcClient } from "@renderer/trpc/client"; +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const POLL_TIMEOUT_MS = 300_000; + +export type SlackConnectState = "idle" | "connecting" | "timed-out" | "error"; + +export interface SlackConnectError { + message: string; + code: string | null; +} + +interface Result { + state: SlackConnectState; + error: SlackConnectError | null; + isConnecting: boolean; + isTimedOut: boolean; + hasError: boolean; + connect: () => Promise; + reset: () => void; +} + +function invalidateIntegrationQueries(queryClient: QueryClient): void { + void queryClient.invalidateQueries({ queryKey: ["integrations", "list"] }); + void queryClient.invalidateQueries({ queryKey: ["integrations"] }); +} + +/** + * Drives the "Connect Slack workspace" button: + * - kicks off the main-process flow via `slackIntegration.startFlow`, + * - listens for the deep-link callback via `useSlackIntegrationCallback`, + * - refetches integration queries on success so the rest of the UI updates, + * - times out after 5 minutes and refetches as a fallback (a Slack admin who + * finishes the install in another browser still surfaces eventually). + */ +export function useSlackConnect(): Result { + const queryClient = useQueryClient(); + const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + const projectId = useAuthStateValue((s) => s.projectId); + + const [state, setState] = useState("idle"); + const [error, setError] = useState(null); + const stateRef = useRef(state); + stateRef.current = state; + + const timeoutRef = useRef | null>(null); + const clearLocalTimeout = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + useEffect(() => clearLocalTimeout, [clearLocalTimeout]); + + // Window-focus fallback — the deep link can occasionally miss (browser + // setting, OS prompt dismissed), so refetch when the user returns to the + // app while a connect is in flight. + useEffect(() => { + if (state !== "connecting") return; + const onFocus = () => invalidateIntegrationQueries(queryClient); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [state, queryClient]); + + useSlackIntegrationCallback({ + onSuccess: () => { + clearLocalTimeout(); + setState("idle"); + setError(null); + invalidateIntegrationQueries(queryClient); + }, + onError: (cbError) => { + clearLocalTimeout(); + setState("error"); + setError(cbError); + }, + onTimedOut: () => { + clearLocalTimeout(); + setState("timed-out"); + invalidateIntegrationQueries(queryClient); + }, + }); + + const reset = useCallback(() => { + clearLocalTimeout(); + setError(null); + setState("idle"); + }, [clearLocalTimeout]); + + const connect = useCallback(async () => { + if (stateRef.current === "connecting") return; + if (projectId === null || cloudRegion === null) return; + clearLocalTimeout(); + setError(null); + setState("connecting"); + try { + const res = await trpcClient.slackIntegration.startFlow.mutate({ + region: cloudRegion, + projectId, + }); + if (!res.success) { + throw new Error(res.error ?? "Failed to start Slack connection"); + } + timeoutRef.current = setTimeout(() => { + setState("timed-out"); + }, POLL_TIMEOUT_MS); + } catch (e) { + clearLocalTimeout(); + setError({ + message: + e instanceof Error ? e.message : "Failed to start Slack connection", + code: null, + }); + setState("error"); + } + }, [cloudRegion, projectId, clearLocalTimeout]); + + return useMemo( + () => ({ + state, + error, + isConnecting: state === "connecting", + isTimedOut: state === "timed-out", + hasError: state === "error", + connect, + reset, + }), + [state, error, connect, reset], + ); +} diff --git a/apps/code/src/renderer/features/integrations/hooks/useSlackIntegrationCallback.ts b/apps/code/src/renderer/features/integrations/hooks/useSlackIntegrationCallback.ts new file mode 100644 index 000000000..49676573b --- /dev/null +++ b/apps/code/src/renderer/features/integrations/hooks/useSlackIntegrationCallback.ts @@ -0,0 +1,90 @@ +import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { logger } from "@utils/logger"; +import { useEffect, useRef } from "react"; + +const log = logger.scope("slack-integration-callback-hook"); + +const DEFAULT_ERROR_MESSAGE = + "Slack connection failed. Please try connecting again."; + +export interface SlackCallbackError { + message: string; + code: string | null; +} + +interface Options { + onSuccess: (projectId: number | null, integrationId: number | null) => void; + onError: (error: SlackCallbackError) => void; + onTimedOut?: () => void; +} + +/** + * Subscribes to Slack integration deep link callbacks and drains any pending + * callback that arrived before the subscription was established (cold-start). + */ +export function useSlackIntegrationCallback({ + onSuccess, + onError, + onTimedOut, +}: Options): void { + const trpcReact = useTRPC(); + const hasConsumedPendingRef = useRef(false); + + const optsRef = useRef({ onSuccess, onError, onTimedOut }); + optsRef.current = { onSuccess, onError, onTimedOut }; + + useSubscription( + trpcReact.slackIntegration.onCallback.subscriptionOptions(undefined, { + onData: (data) => { + log.info("Received Slack integration deep link callback", data); + if (data.status === "error") { + optsRef.current.onError({ + message: data.errorMessage ?? DEFAULT_ERROR_MESSAGE, + code: data.errorCode, + }); + return; + } + optsRef.current.onSuccess(data.projectId, data.integrationId); + }, + }), + ); + + useSubscription( + trpcReact.slackIntegration.onFlowTimedOut.subscriptionOptions(undefined, { + onData: (data) => { + log.info("Slack integration flow timed out", data); + optsRef.current.onTimedOut?.(); + }, + }), + ); + + useEffect(() => { + if (hasConsumedPendingRef.current) return; + hasConsumedPendingRef.current = true; + void (async () => { + try { + const pending = + await trpcClient.slackIntegration.consumePendingCallback.query(); + if (!pending) return; + log.info( + "Consumed pending Slack integration callback on mount", + pending, + ); + if (pending.status === "error") { + optsRef.current.onError({ + message: pending.errorMessage ?? DEFAULT_ERROR_MESSAGE, + code: pending.errorCode, + }); + return; + } + optsRef.current.onSuccess(pending.projectId, pending.integrationId); + } catch (error) { + log.error( + "Failed to consume pending Slack integration callback", + error, + ); + } + })(); + }, []); +} diff --git a/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx index 6ad030102..3d24c8b42 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx @@ -1,12 +1,9 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceManager"; import { useSlackChannels } from "@features/inbox/hooks/useSlackChannels"; +import { useSlackConnect } from "@features/integrations/hooks/useSlackConnect"; import { useIntegrationSelectors } from "@features/integrations/stores/integrationStore"; -import { ArrowSquareOutIcon } from "@phosphor-icons/react"; -import { Box, Button, Flex, Select, Text } from "@radix-ui/themes"; +import { Box, Button, Callout, Flex, Select, Text } from "@radix-ui/themes"; import type { SignalReportPriority } from "@shared/types"; -import { openUrlInBrowser } from "@utils/browser"; -import { getPostHogUrl } from "@utils/urls"; const NOTIFY_OFF_VALUE = "__off__"; const NOTIFY_ALL_VALUE = "__all__"; @@ -43,11 +40,10 @@ function parseChannelIdFromTargetValue( } export function SignalSlackNotificationsSettings() { - const projectId = useAuthStateValue((s) => s.projectId); - const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const { slackIntegrations, hasSlackIntegration } = useIntegrationSelectors(); const { userAutonomyConfig, handleUpdateSlackNotifications } = useSignalSourceManager(); + const slackConnect = useSlackConnect(); const selectedIntegrationId = userAutonomyConfig?.slack_notification_integration_id ?? null; @@ -69,13 +65,6 @@ export function SignalSlackNotificationsSettings() { effectiveIntegrationId, ); - const slackSettingsUrl = projectId - ? getPostHogUrl( - `/project/${projectId}/settings/project-integrations#slack`, - cloudRegion, - ) - : null; - const notificationsEnabled = !!selectedIntegrationId && !!selectedChannelTarget; @@ -92,21 +81,37 @@ export function SignalSlackNotificationsSettings() { Get pinged in Slack when a new inbox item lands and you're a suggested - reviewer. Connect Slack to PostHog first. + reviewer. Connect a Slack workspace to your PostHog project to get + started — your browser opens, you approve the install, and you'll be + brought back here automatically. + {slackConnect.hasError && slackConnect.error ? ( + + {slackConnect.error.message} + + ) : null} + {slackConnect.isTimedOut ? ( + + + We didn't hear back from PostHog. If you completed the connection + in your browser it should appear shortly — otherwise try again. + + + ) : null} ); } @@ -191,6 +196,21 @@ export function SignalSlackNotificationsSettings() { ) : null} + + + + Notification channel From a68bb77c0c2a0dc91486b0673ff54aa9fa99484d Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Mon, 18 May 2026 20:52:03 +0000 Subject: [PATCH 3/8] fix(signals): make slack notification updates optimistic + searchable channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the notification channel picker from a Radix Themes Select to the searchable, virtualized Combobox from @posthog/quill (the same component used by GitHubRepoPicker and BranchSelector). Workspaces commonly have hundreds of channels — the old Select was unsearchable and rendered every option upfront. Also rewires handleUpdateSlackNotifications to apply changes via setQueryData optimistically, rather than waiting for the round-trip + cache invalidation. On error, rolls back to the previous snapshot. This makes channel and minimum-priority picks reflect immediately, and surfaces failures via toast. Generated-By: PostHog Code Task-Id: 046d7425-f0b4-4ab4-97f8-cce0627927f0 --- .../inbox/hooks/useSignalSourceManager.ts | 50 ++++- .../SignalSlackNotificationsSettings.tsx | 186 ++++++++++++++---- 2 files changed, 198 insertions(+), 38 deletions(-) diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts index cfa064067..e58e7281c 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts @@ -5,6 +5,10 @@ import type { Evaluation, SignalSourceConfig, } from "@renderer/api/posthogClient"; +import type { + SignalReportPriority, + SignalUserAutonomyConfig, +} from "@shared/types"; import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -486,12 +490,50 @@ export function useSignalSourceManager() { if ("minPriority" in updates) { body.slack_notification_min_priority = updates.minPriority ?? null; } + + const queryKey = ["signals", "user-autonomy-config"]; + const previous = + queryClient.getQueryData(queryKey); + + // Optimistic update: reflect the user's choice in the UI before the + // server responds. Build the next snapshot from the previous one so + // unrelated fields (autostart_priority, etc.) are preserved. + const optimisticNext: SignalUserAutonomyConfig = { + ...(previous ?? + ({ autostart_priority: null } as SignalUserAutonomyConfig)), + ...("integrationId" in updates + ? { slack_notification_integration_id: updates.integrationId ?? null } + : {}), + ...("channel" in updates + ? { slack_notification_channel: updates.channel ?? null } + : {}), + ...("minPriority" in updates + ? { + slack_notification_min_priority: + (updates.minPriority as + | SignalReportPriority + | null + | undefined) ?? null, + } + : {}), + }; + queryClient.setQueryData( + queryKey, + optimisticNext, + ); + try { - await client.updateSignalUserAutonomyConfig(body); - await queryClient.invalidateQueries({ - queryKey: ["signals", "user-autonomy-config"], - }); + const fresh = await client.updateSignalUserAutonomyConfig(body); + queryClient.setQueryData( + queryKey, + fresh, + ); } catch (error: unknown) { + // Roll back to whatever was in the cache before this attempt. + queryClient.setQueryData( + queryKey, + previous ?? null, + ); const message = error instanceof Error ? error.message diff --git a/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx index 3d24c8b42..4459c38b0 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx @@ -2,11 +2,24 @@ import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceMan import { useSlackChannels } from "@features/inbox/hooks/useSlackChannels"; import { useSlackConnect } from "@features/integrations/hooks/useSlackConnect"; import { useIntegrationSelectors } from "@features/integrations/stores/integrationStore"; +import { CaretDown, Hash, Lock } from "@phosphor-icons/react"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, + Button as QuillButton, +} from "@posthog/quill"; import { Box, Button, Callout, Flex, Select, Text } from "@radix-ui/themes"; import type { SignalReportPriority } from "@shared/types"; +import { useMemo, useRef, useState } from "react"; const NOTIFY_OFF_VALUE = "__off__"; const NOTIFY_ALL_VALUE = "__all__"; +const CHANNEL_COMBOBOX_LIMIT = 50; const MIN_PRIORITY_OPTIONS: { value: SignalReportPriority | typeof NOTIFY_ALL_VALUE; @@ -39,6 +52,15 @@ function parseChannelIdFromTargetValue( return value.split("|")[0]?.trim() || null; } +function parseChannelNameFromTargetValue( + value: string | null | undefined, +): string | null { + if (!value) return null; + const display = value.split("|")[1]?.trim(); + if (!display) return null; + return display.startsWith("#") ? display.slice(1) : display; +} + export function SignalSlackNotificationsSettings() { const { slackIntegrations, hasSlackIntegration } = useIntegrationSelectors(); const { userAutonomyConfig, handleUpdateSlackNotifications } = @@ -52,6 +74,9 @@ export function SignalSlackNotificationsSettings() { const selectedChannelId = parseChannelIdFromTargetValue( selectedChannelTarget, ); + const selectedChannelName = parseChannelNameFromTargetValue( + selectedChannelTarget, + ); const minPriority = userAutonomyConfig?.slack_notification_min_priority ?? null; @@ -68,6 +93,23 @@ export function SignalSlackNotificationsSettings() { const notificationsEnabled = !!selectedIntegrationId && !!selectedChannelTarget; + const channelAnchorRef = useRef(null); + const [channelComboboxOpen, setChannelComboboxOpen] = useState(false); + const [channelSearchQuery, setChannelSearchQuery] = useState(""); + + const visibleChannels = useMemo( + () => + (channelsData?.channels ?? []).filter( + (c) => !c.is_private_without_access, + ), + [channelsData?.channels], + ); + + const channelComboboxItems = useMemo( + () => [NOTIFY_OFF_VALUE, ...visibleChannels.map((c) => c.id)], + [visibleChannels], + ); + if (!hasSlackIntegration) { return ( { - if (value === NOTIFY_OFF_VALUE) { - // Turn off by clearing the channel; integration ID is kept so re-enabling - // remembers the previous workspace. + const onChannelComboboxChange = (rawValue: string | null) => { + setChannelComboboxOpen(false); + setChannelSearchQuery(""); + if (rawValue === null) return; + if (rawValue === NOTIFY_OFF_VALUE) { void handleUpdateSlackNotifications({ channel: null }); return; } if (!effectiveIntegrationId) return; - const channel = channelsData?.channels?.find((c) => c.id === value); + const channel = visibleChannels.find((c) => c.id === rawValue); if (!channel) return; void handleUpdateSlackNotifications({ integrationId: effectiveIntegrationId, @@ -149,6 +192,14 @@ export function SignalSlackNotificationsSettings() { }); }; + const channelTriggerLabel = (() => { + if (channelsLoading && !notificationsEnabled) return "Loading channels…"; + if (!notificationsEnabled) return "Pick a channel"; + if (selectedChannelName) return `#${selectedChannelName}`; + if (selectedChannelId) return selectedChannelId; + return "Pick a channel"; + })(); + return ( Notification channel - - + - - - Off — don't notify me - - {(channelsData?.channels ?? []) - .filter((c) => !c.is_private_without_access) - .map((channel) => ( - - {channel.is_private ? "🔒" : "#"} - {channel.name} - {channel.is_ext_shared ? " (shared)" : ""} - - ))} - - + onValueChange={(v) => onChannelComboboxChange(v as string | null)} + open={channelComboboxOpen} + onOpenChange={(open) => { + setChannelComboboxOpen(open); + if (!open) setChannelSearchQuery(""); + }} + inputValue={channelSearchQuery} + onInputValueChange={(v) => setChannelSearchQuery(v ?? "")} + disabled={!effectiveIntegrationId || channelsLoading} + > + + + {notificationsEnabled && selectedChannelId ? ( + + ) : null} + + {channelTriggerLabel} + + + + + } + /> + + + + {channelsLoading + ? "Loading channels…" + : "No channels match — make sure PostHog is in the channel."} + + + {(itemValue: string) => { + if (itemValue === NOTIFY_OFF_VALUE) { + return ( + + Off — don't notify me + + ); + } + const channel = visibleChannels.find( + (c) => c.id === itemValue, + ); + if (!channel) return null; + const Icon = channel.is_private ? Lock : Hash; + return ( + + + {channel.name} + {channel.is_ext_shared ? ( + + (shared) + + ) : null} + + ); + }} + + + + PostHog needs to be a member of the channel to post. Invite it with{" "} /invite @PostHog in Slack if delivery fails. From 9d3abe9fe185663165cc22a83ec11f308e481424 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 19 May 2026 01:21:05 +0200 Subject: [PATCH 4/8] refactor: add useDebouncedValue hook for search inputs Extract shared debounce logic and adopt it in IssuePicker. --- .../message-editor/components/IssuePicker.tsx | 24 +++++-------------- .../src/renderer/hooks/useDebouncedValue.ts | 21 ++++++++++++++++ 2 files changed, 27 insertions(+), 18 deletions(-) create mode 100644 apps/code/src/renderer/hooks/useDebouncedValue.ts diff --git a/apps/code/src/renderer/features/message-editor/components/IssuePicker.tsx b/apps/code/src/renderer/features/message-editor/components/IssuePicker.tsx index cb370e859..10a0d3ef5 100644 --- a/apps/code/src/renderer/features/message-editor/components/IssuePicker.tsx +++ b/apps/code/src/renderer/features/message-editor/components/IssuePicker.tsx @@ -1,3 +1,4 @@ +import { useDebouncedValue } from "@hooks/useDebouncedValue"; import { Combobox, ComboboxContent, @@ -8,7 +9,7 @@ import { } from "@posthog/quill"; import { useTRPC } from "@renderer/trpc/client"; import { useQuery } from "@tanstack/react-query"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import type { GithubRefKind, GithubRefState } from "../types"; import type { MentionChip } from "../utils/content"; import { @@ -46,24 +47,11 @@ export function IssuePicker({ }: IssuePickerProps) { const trpc = useTRPC(); const [query, setQuery] = useState(""); - const [debouncedQuery, setDebouncedQuery] = useState(""); - const timerRef = useRef | null>(null); + const { debounced: debouncedQuery, isPending: queryDebouncing } = + useDebouncedValue(query, 300); useEffect(() => { - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => { - setDebouncedQuery(query); - }, 300); - return () => { - if (timerRef.current) clearTimeout(timerRef.current); - }; - }, [query]); - - useEffect(() => { - if (!open) { - setQuery(""); - setDebouncedQuery(""); - } + if (!open) setQuery(""); }, [open]); const { data: refs = [], isFetching } = useQuery( @@ -77,7 +65,7 @@ export function IssuePicker({ ), ); - const isLoading = isFetching || query !== debouncedQuery; + const isLoading = isFetching || queryDebouncing; const handleValueChange = (value: Ref | null) => { if (!value) return; diff --git a/apps/code/src/renderer/hooks/useDebouncedValue.ts b/apps/code/src/renderer/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..0336ad4a9 --- /dev/null +++ b/apps/code/src/renderer/hooks/useDebouncedValue.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; + +/** Debounces `value` by `delayMs`. While waiting, `isPending` is true. */ +export function useDebouncedValue( + value: T, + delayMs: number, +): { + debounced: T; + isPending: boolean; +} { + const [debounced, setDebounced] = useState(value); + const isPending = value !== debounced; + + useEffect(() => { + if (value === debounced) return; + const id = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(id); + }, [value, delayMs, debounced]); + + return { debounced, isPending }; +} From 9a6279bcc89e7d5a8cba7c6574ed12d7ce6895c4 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 19 May 2026 01:21:36 +0200 Subject: [PATCH 5/8] feat(signals): search Slack channels via backend in notification settings Debounce combobox input and query the integrations channels API so large workspaces are searchable. Add modal-safe Quill portal handling for the inbox configuration dialog and shared settings dropdown styling. --- apps/code/src/renderer/api/posthogClient.ts | 17 +- .../inbox/components/InboxSourcesDialog.tsx | 26 +- .../features/inbox/hooks/useSlackChannels.ts | 50 +- .../components/ModalInlineComboboxContent.tsx | 56 ++ .../components/SettingsOptionSelect.tsx | 75 +++ .../SignalSlackNotificationsSettings.tsx | 502 ++++++++++-------- .../sections/SignalSourcesSettings.tsx | 52 +- apps/code/src/renderer/styles/globals.css | 15 + apps/code/src/shared/types.ts | 8 + 9 files changed, 543 insertions(+), 258 deletions(-) create mode 100644 apps/code/src/renderer/features/settings/components/ModalInlineComboboxContent.tsx create mode 100644 apps/code/src/renderer/features/settings/components/SettingsOptionSelect.tsx diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 3d667077f..80d7a0611 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -24,6 +24,7 @@ import type { SignalReportTaskRelationship, SignalTeamConfig, SignalUserAutonomyConfig, + SlackChannelsQueryParams, SlackChannelsResponse, SuggestedReviewersArtefact, Task, @@ -2281,12 +2282,26 @@ export class PostHogAPIClient { async getSlackChannelsForIntegration( integrationId: number, + params?: SlackChannelsQueryParams, ): Promise { const teamId = await this.getTeamId(); const url = new URL( `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/channels/`, ); - const path = `/api/environments/${teamId}/integrations/${integrationId}/channels/`; + const search = params?.search?.trim(); + if (search) { + url.searchParams.set("search", search); + } + if (params?.limit != null) { + url.searchParams.set("limit", String(params.limit)); + } + if (params?.offset != null) { + url.searchParams.set("offset", String(params.offset)); + } + if (params?.channelId) { + url.searchParams.set("channel_id", params.channelId); + } + const path = `/api/environments/${teamId}/integrations/${integrationId}/channels/${url.search}`; const response = await this.api.fetcher.fetch({ method: "get", diff --git a/apps/code/src/renderer/features/inbox/components/InboxSourcesDialog.tsx b/apps/code/src/renderer/features/inbox/components/InboxSourcesDialog.tsx index ac740a383..47dbbb931 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSourcesDialog.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSourcesDialog.tsx @@ -2,6 +2,13 @@ import { SignalSourcesSettings } from "@features/settings/components/sections/Si import { XIcon } from "@phosphor-icons/react"; import { Button, Dialog, Flex, Tooltip } from "@radix-ui/themes"; +/** Portaled Quill popups are outside Dialog.Content; ignore outside-dismiss for them. */ +function isQuillPortalEventTarget(target: EventTarget | null): boolean { + return ( + target instanceof Element && target.closest("[data-quill-portal]") !== null + ); +} + interface InboxSourcesDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -17,8 +24,21 @@ export function InboxSourcesDialog({ }: InboxSourcesDialogProps) { return ( - - + { + if (isQuillPortalEventTarget(event.target)) { + event.preventDefault(); + } + }} + onFocusOutside={(event) => { + if (isQuillPortalEventTarget(event.target)) { + event.preventDefault(); + } + }} + > + Inbox configuration @@ -32,7 +52,7 @@ export function InboxSourcesDialog({ - + {hasSignalSources && hasGithubIntegration ? ( diff --git a/apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts b/apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts index 7097087f8..49c6f167f 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts @@ -1,20 +1,54 @@ import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; -import type { SlackChannelsResponse } from "@shared/types"; +import type { + SlackChannelsQueryParams, + SlackChannelsResponse, +} from "@shared/types"; + +const DEFAULT_CHANNEL_PAGE_SIZE = 50; + +export interface UseSlackChannelsOptions extends SlackChannelsQueryParams { + enabled?: boolean; +} + +export function useSlackChannels( + integrationId: number | null | undefined, + options?: UseSlackChannelsOptions, +) { + const { + search, + limit = DEFAULT_CHANNEL_PAGE_SIZE, + offset, + channelId, + enabled = true, + } = options ?? {}; + const normalizedSearch = search?.trim() || undefined; -export function useSlackChannels(integrationId: number | null | undefined) { return useAuthenticatedQuery( - ["slack", "channels", integrationId ?? null], + [ + "slack", + "channels", + integrationId ?? null, + normalizedSearch ?? "", + limit, + offset ?? 0, + channelId ?? null, + ], async (client) => { if (!integrationId) { return { channels: [] }; } - return await client.getSlackChannelsForIntegration(integrationId); + return await client.getSlackChannelsForIntegration(integrationId, { + search: normalizedSearch, + limit, + offset, + channelId, + }); }, { - enabled: !!integrationId, - // Slack channel lists are cached server-side for an hour, so refresh - // sparingly on the client. - staleTime: 5 * 60_000, + enabled: !!integrationId && enabled, + refetchOnWindowFocus: false, + // Full lists are cached server-side for an hour; search pages refresh sooner. + staleTime: normalizedSearch || channelId ? 30_000 : 5 * 60_000, }, ); } diff --git a/apps/code/src/renderer/features/settings/components/ModalInlineComboboxContent.tsx b/apps/code/src/renderer/features/settings/components/ModalInlineComboboxContent.tsx new file mode 100644 index 000000000..3cc24910a --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/ModalInlineComboboxContent.tsx @@ -0,0 +1,56 @@ +import { Combobox as BaseCombobox } from "@base-ui/react/combobox"; +import type { ComponentProps, RefObject } from "react"; +import { useRef } from "react"; + +interface ModalInlineComboboxContentProps + extends ComponentProps { + anchor?: RefObject | Element | null; + side?: "top" | "bottom" | "left" | "right"; + sideOffset?: number; + align?: "start" | "center" | "end"; + alignOffset?: number; +} + +/** + * Quill's ComboboxContent portals to document.body. Inside a modal Radix Dialog + * that puts the search input outside the focus trap. This portals into a local + * container that stays within the dialog DOM tree instead. + */ +export function ModalInlineComboboxContent({ + className, + side = "bottom", + sideOffset = 6, + align = "start", + alignOffset = 0, + anchor, + children, + ...popupProps +}: ModalInlineComboboxContentProps) { + const portalContainerRef = useRef(null); + + return ( + <> +
+ + + + {children} + + + + + ); +} diff --git a/apps/code/src/renderer/features/settings/components/SettingsOptionSelect.tsx b/apps/code/src/renderer/features/settings/components/SettingsOptionSelect.tsx new file mode 100644 index 000000000..2f8a16c00 --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/SettingsOptionSelect.tsx @@ -0,0 +1,75 @@ +import { CaretDown } from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@posthog/quill"; + +interface SettingsOptionSelectOption { + value: string; + label: string; +} + +interface SettingsOptionSelectProps { + value: string; + options: SettingsOptionSelectOption[]; + onValueChange: (value: string) => void; + disabled?: boolean; + ariaLabel: string; + placeholder?: string; + className?: string; +} + +export function SettingsOptionSelect({ + value, + options, + onValueChange, + disabled, + ariaLabel, + placeholder = "Select…", + className, +}: SettingsOptionSelectProps) { + const selectedLabel = + options.find((opt) => opt.value === value)?.label ?? placeholder; + + return ( + + + {selectedLabel} + + + } + /> + + + {options.map((opt) => ( + + {opt.label} + + ))} + + + + ); +} diff --git a/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx index 4459c38b0..99d66e784 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx @@ -2,6 +2,9 @@ import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceMan import { useSlackChannels } from "@features/inbox/hooks/useSlackChannels"; import { useSlackConnect } from "@features/integrations/hooks/useSlackConnect"; import { useIntegrationSelectors } from "@features/integrations/stores/integrationStore"; +import { ModalInlineComboboxContent } from "@features/settings/components/ModalInlineComboboxContent"; +import { SettingsOptionSelect } from "@features/settings/components/SettingsOptionSelect"; +import { useDebouncedValue } from "@hooks/useDebouncedValue"; import { CaretDown, Hash, Lock } from "@phosphor-icons/react"; import { Combobox, @@ -13,34 +16,32 @@ import { ComboboxTrigger, Button as QuillButton, } from "@posthog/quill"; -import { Box, Button, Callout, Flex, Select, Text } from "@radix-ui/themes"; -import type { SignalReportPriority } from "@shared/types"; +import { Button, Callout, Flex, Text } from "@radix-ui/themes"; +import type { SignalReportPriority, SlackChannelOption } from "@shared/types"; import { useMemo, useRef, useState } from "react"; const NOTIFY_OFF_VALUE = "__off__"; const NOTIFY_ALL_VALUE = "__all__"; -const CHANNEL_COMBOBOX_LIMIT = 50; +const SLACK_CHANNEL_SEARCH_DEBOUNCE_MS = 300; const MIN_PRIORITY_OPTIONS: { value: SignalReportPriority | typeof NOTIFY_ALL_VALUE; label: string; }[] = [ - { - value: NOTIFY_ALL_VALUE, - label: "Every priority (including unprioritized)", - }, - { value: "P0", label: "P0 only (critical)" }, + { value: NOTIFY_ALL_VALUE, label: "All priorities" }, + { value: "P0", label: "P0 only" }, { value: "P1", label: "P1 and above" }, { value: "P2", label: "P2 and above" }, { value: "P3", label: "P3 and above" }, { value: "P4", label: "P4 and above" }, ]; +const SETTINGS_CONTROL_CLASS = "min-w-[200px] max-w-[240px]"; + function buildChannelTargetValue( channelId: string, channelName: string, ): string { - // Mirror the convention used by the rest of PostHog: `channelId|#channel-name`. const display = channelName.startsWith("#") ? channelName : `#${channelName}`; return `${channelId}|${display}`; } @@ -61,7 +62,25 @@ function parseChannelNameFromTargetValue( return display.startsWith("#") ? display.slice(1) : display; } -export function SignalSlackNotificationsSettings() { +function getSlackIntegrationLabel(integration: { + id: number; + display_name?: string; + config?: { account?: { name?: string } }; +}): string { + return ( + integration.display_name ?? + integration.config?.account?.name ?? + `Slack workspace ${integration.id}` + ); +} + +interface SignalSlackNotificationsSettingsProps { + channelComboboxModal?: boolean; +} + +export function SignalSlackNotificationsSettings({ + channelComboboxModal = false, +}: SignalSlackNotificationsSettingsProps) { const { slackIntegrations, hasSlackIntegration } = useIntegrationSelectors(); const { userAutonomyConfig, handleUpdateSlackNotifications } = useSignalSourceManager(); @@ -86,61 +105,87 @@ export function SignalSlackNotificationsSettings() { selectedIntegrationId ?? (slackIntegrations.length === 1 ? slackIntegrations[0].id : null); - const { data: channelsData, isPending: channelsLoading } = useSlackChannels( + const channelAnchorRef = useRef(null); + const [channelComboboxOpen, setChannelComboboxOpen] = useState(false); + const [channelSearchQuery, setChannelSearchQuery] = useState(""); + const { + debounced: debouncedChannelSearch, + isPending: channelSearchDebouncing, + } = useDebouncedValue( + channelSearchQuery.trim(), + SLACK_CHANNEL_SEARCH_DEBOUNCE_MS, + ); + + const { data: channelsData, isFetching: channelsFetching } = useSlackChannels( effectiveIntegrationId, + { + search: debouncedChannelSearch || undefined, + enabled: channelComboboxOpen, + }, ); + const channelsSearchPending = + channelComboboxOpen && (channelsFetching || channelSearchDebouncing); const notificationsEnabled = !!selectedIntegrationId && !!selectedChannelTarget; - const channelAnchorRef = useRef(null); - const [channelComboboxOpen, setChannelComboboxOpen] = useState(false); - const [channelSearchQuery, setChannelSearchQuery] = useState(""); - - const visibleChannels = useMemo( - () => - (channelsData?.channels ?? []).filter( - (c) => !c.is_private_without_access, - ), - [channelsData?.channels], - ); + const visibleChannels = useMemo(() => { + const channels = [...(channelsData?.channels ?? [])]; + if ( + selectedChannelId && + selectedChannelName && + !channels.some((channel) => channel.id === selectedChannelId) + ) { + channels.unshift( + configuredSlackChannelOption(selectedChannelId, selectedChannelName), + ); + } + return channels; + }, [channelsData?.channels, selectedChannelId, selectedChannelName]); const channelComboboxItems = useMemo( () => [NOTIFY_OFF_VALUE, ...visibleChannels.map((c) => c.id)], [visibleChannels], ); + const integrationOptions = useMemo( + () => + slackIntegrations.map((integration) => ({ + value: String(integration.id), + label: getSlackIntegrationLabel(integration), + })), + [slackIntegrations], + ); + if (!hasSlackIntegration) { return ( - - Slack notifications for new inbox items - - - Get pinged in Slack when a new inbox item lands and you're a suggested - reviewer. Connect a Slack workspace to your PostHog project to get - started — your browser opens, you approve the install, and you'll be - brought back here automatically. - - - - + + + Slack notifications + + + Get pinged in Slack when you're a suggested reviewer on a new inbox + item. + + + {slackConnect.hasError && slackConnect.error ? ( {slackConnect.error.message} @@ -193,201 +238,212 @@ export function SignalSlackNotificationsSettings() { }; const channelTriggerLabel = (() => { - if (channelsLoading && !notificationsEnabled) return "Loading channels…"; + if (channelsSearchPending && !notificationsEnabled) { + return "Loading channels…"; + } if (!notificationsEnabled) return "Pick a channel"; - if (selectedChannelName) return `#${selectedChannelName}`; + if (selectedChannelName) return selectedChannelName; if (selectedChannelId) return selectedChannelId; return "Pick a channel"; })(); + const channelComboboxPanel = ( + <> + + + {channelsSearchPending + ? "Loading channels…" + : "No channels match — make sure PostHog is in the channel."} + + + {(itemValue: string) => { + if (itemValue === NOTIFY_OFF_VALUE) { + return ( + + Off — don't notify me + + ); + } + const channel = visibleChannels.find((c) => c.id === itemValue); + if (!channel) return null; + const Icon = channel.is_private ? Lock : Hash; + return ( + + + {channel.name} + {channel.is_ext_shared ? ( + + (shared) + + ) : null} + + ); + }} + + + ); + + const channelComboboxPopupProps = { + anchor: channelAnchorRef, + side: "bottom" as const, + sideOffset: 4, + className: "min-w-[240px]", + }; + + const connectWorkspaceButton = ( + + ); + return ( - - Slack notifications for new inbox items - - - When a new inbox item lands and you're a suggested reviewer, PostHog - posts a Slack message in the channel you select. Pick a minimum priority - to filter out lower-urgency reports. - + + + Slack notifications + + + Ping in Slack when you're a suggested reviewer on a new inbox item. + + - {slackIntegrations.length > 1 ? ( - - Slack workspace - - + + + Workspace + + {slackIntegrations.length > 1 ? ( + - - {slackIntegrations.map((integration) => { - const label = - integration.display_name ?? - integration.config?.account?.name ?? - `Slack workspace ${integration.id}`; - return ( - - {label} - - ); - })} - - + ) : slackIntegrations[0] ? ( + + {getSlackIntegrationLabel(slackIntegrations[0])} + + ) : null} - ) : null} - - - - + {connectWorkspaceButton} + - - - Notification channel - -
- onChannelComboboxChange(v as string | null)} - open={channelComboboxOpen} - onOpenChange={(open) => { - setChannelComboboxOpen(open); - if (!open) setChannelSearchQuery(""); - }} - inputValue={channelSearchQuery} - onInputValueChange={(v) => setChannelSearchQuery(v ?? "")} - disabled={!effectiveIntegrationId || channelsLoading} - > - - - {notificationsEnabled && selectedChannelId ? ( - - ) : null} - - {channelTriggerLabel} - - - - + + + Channel +
+ - onChannelComboboxChange(v as string | null)} + open={channelComboboxOpen} + onOpenChange={(open) => { + setChannelComboboxOpen(open); + if (!open) setChannelSearchQuery(""); + }} + inputValue={channelSearchQuery} + onInputValueChange={(v) => setChannelSearchQuery(v ?? "")} + disabled={!effectiveIntegrationId} + modal={false} > - - - {channelsLoading - ? "Loading channels…" - : "No channels match — make sure PostHog is in the channel."} - - - {(itemValue: string) => { - if (itemValue === NOTIFY_OFF_VALUE) { - return ( - - Off — don't notify me - - ); - } - const channel = visibleChannels.find( - (c) => c.id === itemValue, - ); - if (!channel) return null; - const Icon = channel.is_private ? Lock : Hash; - return ( - - - {channel.name} - {channel.is_ext_shared ? ( - - (shared) - + + + {notificationsEnabled && selectedChannelId ? ( + ) : null} - - ); - }} - - - -
- - PostHog needs to be a member of the channel to post. Invite it with{" "} - /invite @PostHog in Slack if delivery fails. - -
- - - - Minimum priority to notify - - - - - {MIN_PRIORITY_OPTIONS.map((opt) => ( - - {opt.label} - - ))} - - + + {channelTriggerLabel} + + + + + } + /> + {channelComboboxModal ? ( + + {channelComboboxPanel} + + ) : ( + + {channelComboboxPanel} + + )} +
+
+
+ + Min. priority + +
+ + PostHog must be in the channel — invite with{" "} + /invite @PostHog + ); } + +function configuredSlackChannelOption( + id: string, + name: string, +): SlackChannelOption { + return { + id, + name, + is_private: false, + is_member: true, + is_ext_shared: false, + is_private_without_access: false, + }; +} diff --git a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx index d93356ced..11eb3c9f8 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx @@ -1,10 +1,11 @@ import { DataSourceSetup } from "@features/inbox/components/DataSourceSetup"; import { SignalSourceToggles } from "@features/inbox/components/SignalSourceToggles"; import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceManager"; +import { SettingsOptionSelect } from "@features/settings/components/SettingsOptionSelect"; import { GitHubIntegrationSection } from "@features/settings/components/sections/GitHubIntegrationSection"; import { SignalSlackNotificationsSettings } from "@features/settings/components/sections/SignalSlackNotificationsSettings"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; -import { Box, Flex, Select, Text, Tooltip } from "@radix-ui/themes"; +import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import type { SignalReportPriority } from "@shared/types"; const PRIORITY_OPTIONS: { value: SignalReportPriority; label: string }[] = [ @@ -22,7 +23,14 @@ const USER_PRIORITY_OPTIONS: { value: string; label: string }[] = [ ...PRIORITY_OPTIONS, ]; -export function SignalSourcesSettings() { +interface SignalSourcesSettingsProps { + /** Slack channel combobox is inside a Radix modal dialog (Inbox configuration). */ + slackNotificationsInModal?: boolean; +} + +export function SignalSourcesSettings({ + slackNotificationsInModal = false, +}: SignalSourcesSettingsProps) { const { displayValues, sourceStates, @@ -51,7 +59,7 @@ export function SignalSourcesSettings() { return ( - + Automatically analyze your product data and surface actionable insights. Choose which sources to enable for this project. @@ -93,35 +101,33 @@ export function SignalSourcesSettings() { - - Your PR auto-start threshold - - - Automatically start tasks assigned to you for reports at or above this - priority. Choose "Never" to opt out entirely. - - + + Your PR auto-start threshold + + + Automatically start tasks assigned to you for reports at or above + this priority. Choose "Never" to opt out entirely. + + + void handleUpdateUserAutonomyPriority( value === NEVER_VALUE ? null : value, ) } - > - - - {USER_PRIORITY_OPTIONS.map((opt) => ( - - {opt.label} - - ))} - - + /> - + ); } diff --git a/apps/code/src/renderer/styles/globals.css b/apps/code/src/renderer/styles/globals.css index 16aba59ab..30e1f7be6 100644 --- a/apps/code/src/renderer/styles/globals.css +++ b/apps/code/src/renderer/styles/globals.css @@ -77,6 +77,21 @@ opacity: 1; } +/* + * Quill portaled popovers/comboboxes default to z-index 50. + * + * - Settings (z-100) stacks above them — raise z-index. + * - Radix modal Dialog sets `body { pointer-events: none }` and only re-enables + * pointer events on the dialog DismissableLayer. Quill portals are separate + * body children, so they stay inert unless we set pointer-events here. + * (Radix Select/Popover add their own DismissableLayer; Quill does not.) + */ +body:has([data-overlay="settings"]) [data-quill-portal], +body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { + z-index: 101; + pointer-events: auto; +} + @config "../../../tailwind.config.js"; /* diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index 335af9b31..1a3efd5a3 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -549,4 +549,12 @@ export interface SlackChannelOption { export interface SlackChannelsResponse { channels: SlackChannelOption[]; lastRefreshedAt?: string; + has_more?: boolean; +} + +export interface SlackChannelsQueryParams { + search?: string; + limit?: number; + offset?: number; + channelId?: string; } From a6d73d30779c9b2181a87003421c345482e84ab4 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 19 May 2026 01:21:57 +0200 Subject: [PATCH 6/8] chore(inbox): use en dash in user-facing copy Replace em dashes with en dashes in inbox strings for typography consistency. --- .../features/inbox/components/utils/ReportCardContent.tsx | 2 +- .../inbox/components/utils/ReportImplementationPrLink.tsx | 4 ++-- .../src/renderer/features/inbox/hooks/useInboxDeepLink.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx b/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx index 83db11fb1..a6547cfbe 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx +++ b/apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx @@ -86,7 +86,7 @@ export function ReportCardContent({
diff --git a/apps/code/src/renderer/features/inbox/components/utils/ReportImplementationPrLink.tsx b/apps/code/src/renderer/features/inbox/components/utils/ReportImplementationPrLink.tsx index cc340f2dc..d120051d0 100644 --- a/apps/code/src/renderer/features/inbox/components/utils/ReportImplementationPrLink.tsx +++ b/apps/code/src/renderer/features/inbox/components/utils/ReportImplementationPrLink.tsx @@ -59,9 +59,9 @@ export function ReportImplementationPrLink({ const { reference: prReference, prNumber } = parseGitHubPrReference(prUrl); const tooltip = merged - ? `Merged — ${prReference}` + ? `Merged – ${prReference}` : state === "closed" - ? `Closed — ${prReference}` + ? `Closed – ${prReference}` : prReference; const iconSize = isSm ? 10 : 12; diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts index 77e7b4eec..ba6b2c9aa 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts @@ -48,7 +48,7 @@ export function useInboxDeepLink() { const openReport = useCallback( async (reportId: string) => { if (!client) { - log.warn("Ignoring inbox deep link — not authenticated"); + log.warn("Ignoring inbox deep link – not authenticated"); return; } From 7fb1e9127b793ea0c194505aefdfd4d5abe41f88 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 19 May 2026 07:59:25 +0200 Subject: [PATCH 7/8] feat(integrations): wire github finish setup and installation settings urls Add githubInstallationSettingsUrl helper and connect PostHog client finish_setup for GitHub App installation flow from notification settings. --- apps/code/src/renderer/api/posthogClient.ts | 27 ++++++++++ .../integrations/stores/integrationStore.ts | 1 + .../githubInstallationSettingsUrl.test.ts | 51 +++++++++++++++++++ .../utils/githubInstallationSettingsUrl.ts | 44 ++++++++++++++++ .../sections/GitHubIntegrationSection.tsx | 34 ++++++++++++- 5 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts create mode 100644 apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 80d7a0611..e7c1ce3aa 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -648,6 +648,33 @@ export class PostHogAPIClient { }; } + /** Seed team GitHub setup callback state before opening github.com installation settings. */ + async prepareGithubTeamIntegrationCallback( + teamId: number, + next: string, + ): Promise { + const urlPath = `/api/environments/${teamId}/integrations/github/prepare_callback/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ next }), + }, + }); + if (!response.ok && response.status !== 204) { + const err = (await response.json().catch(() => ({}))) as { + detail?: unknown; + }; + const detail = + typeof err.detail === "string" + ? err.detail + : "Failed to prepare GitHub callback"; + throw new Error(detail); + } + } + async getGithubUserIntegrations(): Promise { const urlPath = `/api/users/@me/integrations/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); diff --git a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts b/apps/code/src/renderer/features/integrations/stores/integrationStore.ts index 022f1eea8..c79b3915f 100644 --- a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts +++ b/apps/code/src/renderer/features/integrations/stores/integrationStore.ts @@ -7,6 +7,7 @@ export interface IntegrationAccount { export interface IntegrationConfig { account?: IntegrationAccount; + installation_id?: string | number | null; [key: string]: unknown; } diff --git a/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts new file mode 100644 index 000000000..f4928e961 --- /dev/null +++ b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + githubInstallationSettingsUrl, + resolveGithubInstallationId, +} from "./githubInstallationSettingsUrl"; + +describe("githubInstallationSettingsUrl", () => { + it("uses org settings for organization accounts", () => { + expect( + githubInstallationSettingsUrl("99", { + type: "Organization", + name: "posthog", + }), + ).toBe( + "https://github.com/organizations/posthog/settings/installations/99", + ); + }); + + it("uses user settings for personal accounts", () => { + expect( + githubInstallationSettingsUrl("42", { type: "User", name: "octocat" }), + ).toBe("https://github.com/settings/installations/42"); + }); +}); + +describe("resolveGithubInstallationId", () => { + it("prefers top-level installation_id then id then config", () => { + expect( + resolveGithubInstallationId({ + id: 99, + kind: "github", + installation_id: "a", + config: { installation_id: "c" }, + }), + ).toBe("a"); + expect( + resolveGithubInstallationId({ + id: 1, + kind: "github", + integration_id: 12345, + }), + ).toBe("12345"); + expect( + resolveGithubInstallationId({ + id: 1, + kind: "github", + config: { installation_id: "c" }, + }), + ).toBe("c"); + }); +}); diff --git a/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts new file mode 100644 index 000000000..512d6d02b --- /dev/null +++ b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts @@ -0,0 +1,44 @@ +import type { Integration } from "../stores/integrationStore"; + +interface GithubInstallationAccount { + type?: string | null; + name?: string | null; +} + +export function githubInstallationSettingsUrl( + installationId: string, + account?: GithubInstallationAccount | null, +): string { + const accountType = account?.type; + const accountName = account?.name; + if ( + typeof accountType === "string" && + accountType.toLowerCase() === "organization" && + typeof accountName === "string" && + accountName + ) { + return `https://github.com/organizations/${accountName}/settings/installations/${installationId}`; + } + return `https://github.com/settings/installations/${installationId}`; +} + +/** Resolves a GitHub App installation id from team or user integration payloads. */ +export function resolveGithubInstallationId( + integration: Integration, +): string | null { + const legacy = integration as { + installation_id?: string | null; + integration_id?: string | number | null; + }; + const candidates = [ + legacy.installation_id, + legacy.integration_id, + integration.config?.installation_id, + ]; + for (const value of candidates) { + if (value === null || value === undefined) continue; + const id = String(value).trim(); + if (id) return id; + } + return null; +} diff --git a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx index a094d175d..7843c362a 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx @@ -1,8 +1,14 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { describeGithubConnectError, useGithubConnect, } from "@features/integrations/hooks/useGithubUserConnect"; +import { useIntegrationSelectors } from "@features/integrations/stores/integrationStore"; +import { + githubInstallationSettingsUrl, + resolveGithubInstallationId, +} from "@features/integrations/utils/githubInstallationSettingsUrl"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { ArrowSquareOutIcon, @@ -11,6 +17,7 @@ import { InfoIcon, } from "@phosphor-icons/react"; import { Box, Button, Flex, Spinner, Text, Tooltip } from "@radix-ui/themes"; +import { openUrlInBrowser } from "@utils/browser"; export function GitHubIntegrationSection({ hasGithubIntegration, @@ -18,6 +25,8 @@ export function GitHubIntegrationSection({ hasGithubIntegration: boolean; }) { const { repositories, isLoadingRepos } = useRepositoryIntegration(); + const { githubIntegrations } = useIntegrationSelectors(); + const client = useOptionalAuthenticatedClient(); const projectId = useAuthStateValue((state) => state.projectId); const { error: connectError, @@ -30,6 +39,25 @@ export function GitHubIntegrationSection({ projectHasTeamIntegration: hasGithubIntegration, }); + const handleUpdateInGitHub = async () => { + const integration = githubIntegrations[0]; + if (!integration || projectId === null || !client) return; + const installationId = resolveGithubInstallationId(integration); + if (!installationId) return; + const nextPath = `/account-connected/github-integration?provider=github&project_id=${projectId}&connect_from=posthog_code`; + try { + await client.prepareGithubTeamIntegrationCallback(projectId, nextPath); + } catch { + return; + } + void openUrlInBrowser( + githubInstallationSettingsUrl( + installationId, + integration.config?.account, + ), + ); + }; + return ( - From 39b6b82aa2e4ab43adaaec18d607b1d66eebd9c2 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 19 May 2026 08:48:07 +0200 Subject: [PATCH 8/8] Revert "feat(integrations): wire github finish setup and installation settings urls" This reverts commit 7fb1e9127b793ea0c194505aefdfd4d5abe41f88. --- apps/code/src/renderer/api/posthogClient.ts | 27 ---------- .../integrations/stores/integrationStore.ts | 1 - .../githubInstallationSettingsUrl.test.ts | 51 ------------------- .../utils/githubInstallationSettingsUrl.ts | 44 ---------------- .../sections/GitHubIntegrationSection.tsx | 34 +------------ 5 files changed, 1 insertion(+), 156 deletions(-) delete mode 100644 apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts delete mode 100644 apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index e7c1ce3aa..80d7a0611 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -648,33 +648,6 @@ export class PostHogAPIClient { }; } - /** Seed team GitHub setup callback state before opening github.com installation settings. */ - async prepareGithubTeamIntegrationCallback( - teamId: number, - next: string, - ): Promise { - const urlPath = `/api/environments/${teamId}/integrations/github/prepare_callback/`; - const url = new URL(`${this.api.baseUrl}${urlPath}`); - const response = await this.api.fetcher.fetch({ - method: "post", - url, - path: urlPath, - overrides: { - body: JSON.stringify({ next }), - }, - }); - if (!response.ok && response.status !== 204) { - const err = (await response.json().catch(() => ({}))) as { - detail?: unknown; - }; - const detail = - typeof err.detail === "string" - ? err.detail - : "Failed to prepare GitHub callback"; - throw new Error(detail); - } - } - async getGithubUserIntegrations(): Promise { const urlPath = `/api/users/@me/integrations/`; const url = new URL(`${this.api.baseUrl}${urlPath}`); diff --git a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts b/apps/code/src/renderer/features/integrations/stores/integrationStore.ts index c79b3915f..022f1eea8 100644 --- a/apps/code/src/renderer/features/integrations/stores/integrationStore.ts +++ b/apps/code/src/renderer/features/integrations/stores/integrationStore.ts @@ -7,7 +7,6 @@ export interface IntegrationAccount { export interface IntegrationConfig { account?: IntegrationAccount; - installation_id?: string | number | null; [key: string]: unknown; } diff --git a/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts deleted file mode 100644 index f4928e961..000000000 --- a/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - githubInstallationSettingsUrl, - resolveGithubInstallationId, -} from "./githubInstallationSettingsUrl"; - -describe("githubInstallationSettingsUrl", () => { - it("uses org settings for organization accounts", () => { - expect( - githubInstallationSettingsUrl("99", { - type: "Organization", - name: "posthog", - }), - ).toBe( - "https://github.com/organizations/posthog/settings/installations/99", - ); - }); - - it("uses user settings for personal accounts", () => { - expect( - githubInstallationSettingsUrl("42", { type: "User", name: "octocat" }), - ).toBe("https://github.com/settings/installations/42"); - }); -}); - -describe("resolveGithubInstallationId", () => { - it("prefers top-level installation_id then id then config", () => { - expect( - resolveGithubInstallationId({ - id: 99, - kind: "github", - installation_id: "a", - config: { installation_id: "c" }, - }), - ).toBe("a"); - expect( - resolveGithubInstallationId({ - id: 1, - kind: "github", - integration_id: 12345, - }), - ).toBe("12345"); - expect( - resolveGithubInstallationId({ - id: 1, - kind: "github", - config: { installation_id: "c" }, - }), - ).toBe("c"); - }); -}); diff --git a/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts b/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts deleted file mode 100644 index 512d6d02b..000000000 --- a/apps/code/src/renderer/features/integrations/utils/githubInstallationSettingsUrl.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Integration } from "../stores/integrationStore"; - -interface GithubInstallationAccount { - type?: string | null; - name?: string | null; -} - -export function githubInstallationSettingsUrl( - installationId: string, - account?: GithubInstallationAccount | null, -): string { - const accountType = account?.type; - const accountName = account?.name; - if ( - typeof accountType === "string" && - accountType.toLowerCase() === "organization" && - typeof accountName === "string" && - accountName - ) { - return `https://github.com/organizations/${accountName}/settings/installations/${installationId}`; - } - return `https://github.com/settings/installations/${installationId}`; -} - -/** Resolves a GitHub App installation id from team or user integration payloads. */ -export function resolveGithubInstallationId( - integration: Integration, -): string | null { - const legacy = integration as { - installation_id?: string | null; - integration_id?: string | number | null; - }; - const candidates = [ - legacy.installation_id, - legacy.integration_id, - integration.config?.installation_id, - ]; - for (const value of candidates) { - if (value === null || value === undefined) continue; - const id = String(value).trim(); - if (id) return id; - } - return null; -} diff --git a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx index 7843c362a..a094d175d 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx @@ -1,14 +1,8 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { describeGithubConnectError, useGithubConnect, } from "@features/integrations/hooks/useGithubUserConnect"; -import { useIntegrationSelectors } from "@features/integrations/stores/integrationStore"; -import { - githubInstallationSettingsUrl, - resolveGithubInstallationId, -} from "@features/integrations/utils/githubInstallationSettingsUrl"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { ArrowSquareOutIcon, @@ -17,7 +11,6 @@ import { InfoIcon, } from "@phosphor-icons/react"; import { Box, Button, Flex, Spinner, Text, Tooltip } from "@radix-ui/themes"; -import { openUrlInBrowser } from "@utils/browser"; export function GitHubIntegrationSection({ hasGithubIntegration, @@ -25,8 +18,6 @@ export function GitHubIntegrationSection({ hasGithubIntegration: boolean; }) { const { repositories, isLoadingRepos } = useRepositoryIntegration(); - const { githubIntegrations } = useIntegrationSelectors(); - const client = useOptionalAuthenticatedClient(); const projectId = useAuthStateValue((state) => state.projectId); const { error: connectError, @@ -39,25 +30,6 @@ export function GitHubIntegrationSection({ projectHasTeamIntegration: hasGithubIntegration, }); - const handleUpdateInGitHub = async () => { - const integration = githubIntegrations[0]; - if (!integration || projectId === null || !client) return; - const installationId = resolveGithubInstallationId(integration); - if (!installationId) return; - const nextPath = `/account-connected/github-integration?provider=github&project_id=${projectId}&connect_from=posthog_code`; - try { - await client.prepareGithubTeamIntegrationCallback(projectId, nextPath); - } catch { - return; - } - void openUrlInBrowser( - githubInstallationSettingsUrl( - installationId, - integration.config?.account, - ), - ); - }; - return ( -