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/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 011494253..80d7a0611 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -24,6 +24,8 @@ import type { SignalReportTaskRelationship, SignalTeamConfig, SignalUserAutonomyConfig, + SlackChannelsQueryParams, + SlackChannelsResponse, SuggestedReviewersArtefact, Task, TaskRun, @@ -2250,9 +2252,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 +2280,41 @@ export class PostHogAPIClient { return (await response.json()) as SignalUserAutonomyConfig; } + async getSlackChannelsForIntegration( + integrationId: number, + params?: SlackChannelsQueryParams, + ): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/channels/`, + ); + const search = params?.search?.trim(); + if (search) { + url.searchParams.set("search", search); + } + if (params?.limit != null) { + url.searchParams.set("limit", String(params.limit)); + } + if (params?.offset != null) { + url.searchParams.set("offset", String(params.offset)); + } + if (params?.channelId) { + url.searchParams.set("channel_id", params.channelId); + } + const path = `/api/environments/${teamId}/integrations/${integrationId}/channels/${url.search}`; + + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Slack channels: ${response.statusText}`); + } + return (await response.json()) as SlackChannelsResponse; + } + async deleteSignalUserAutonomyConfig(): Promise { const url = new URL(`${this.api.baseUrl}/api/users/@me/signal_autonomy/`); const path = "/api/users/@me/signal_autonomy/"; 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/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; } diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts index 70d1c86dc..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"; @@ -466,6 +470,80 @@ 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; + } + + const queryKey = ["signals", "user-autonomy-config"]; + const previous = + queryClient.getQueryData(queryKey); + + // Optimistic update: reflect the user's choice in the UI before the + // server responds. Build the next snapshot from the previous one so + // unrelated fields (autostart_priority, etc.) are preserved. + const optimisticNext: SignalUserAutonomyConfig = { + ...(previous ?? + ({ autostart_priority: null } as SignalUserAutonomyConfig)), + ...("integrationId" in updates + ? { slack_notification_integration_id: updates.integrationId ?? null } + : {}), + ...("channel" in updates + ? { slack_notification_channel: updates.channel ?? null } + : {}), + ...("minPriority" in updates + ? { + slack_notification_min_priority: + (updates.minPriority as + | SignalReportPriority + | null + | undefined) ?? null, + } + : {}), + }; + queryClient.setQueryData( + queryKey, + optimisticNext, + ); + + try { + const fresh = await client.updateSignalUserAutonomyConfig(body); + queryClient.setQueryData( + queryKey, + fresh, + ); + } catch (error: unknown) { + // Roll back to whatever was in the cache before this attempt. + queryClient.setQueryData( + queryKey, + previous ?? null, + ); + const message = + error instanceof Error + ? error.message + : "Failed to update Slack notification setting"; + toast.error(message); + } + }, + [client, queryClient], + ); + return { displayValues, sourceStates, @@ -483,5 +561,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..49c6f167f --- /dev/null +++ b/apps/code/src/renderer/features/inbox/hooks/useSlackChannels.ts @@ -0,0 +1,54 @@ +import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; +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; + + return useAuthenticatedQuery( + [ + "slack", + "channels", + integrationId ?? null, + normalizedSearch ?? "", + limit, + offset ?? 0, + channelId ?? null, + ], + async (client) => { + if (!integrationId) { + return { channels: [] }; + } + return await client.getSlackChannelsForIntegration(integrationId, { + search: normalizedSearch, + limit, + offset, + channelId, + }); + }, + { + 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/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/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/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/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 new file mode 100644 index 000000000..99d66e784 --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/sections/SignalSlackNotificationsSettings.tsx @@ -0,0 +1,449 @@ +import { useSignalSourceManager } from "@features/inbox/hooks/useSignalSourceManager"; +import { useSlackChannels } from "@features/inbox/hooks/useSlackChannels"; +import { useSlackConnect } from "@features/integrations/hooks/useSlackConnect"; +import { useIntegrationSelectors } from "@features/integrations/stores/integrationStore"; +import { ModalInlineComboboxContent } from "@features/settings/components/ModalInlineComboboxContent"; +import { SettingsOptionSelect } from "@features/settings/components/SettingsOptionSelect"; +import { useDebouncedValue } from "@hooks/useDebouncedValue"; +import { CaretDown, Hash, Lock } from "@phosphor-icons/react"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, + Button as QuillButton, +} from "@posthog/quill"; +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 SLACK_CHANNEL_SEARCH_DEBOUNCE_MS = 300; + +const MIN_PRIORITY_OPTIONS: { + value: SignalReportPriority | typeof NOTIFY_ALL_VALUE; + label: string; +}[] = [ + { 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 { + const display = channelName.startsWith("#") ? channelName : `#${channelName}`; + return `${channelId}|${display}`; +} + +function parseChannelIdFromTargetValue( + value: string | null | undefined, +): string | null { + if (!value) return null; + return value.split("|")[0]?.trim() || null; +} + +function parseChannelNameFromTargetValue( + value: string | null | undefined, +): string | null { + if (!value) return null; + const display = value.split("|")[1]?.trim(); + if (!display) return null; + return display.startsWith("#") ? display.slice(1) : display; +} + +function getSlackIntegrationLabel(integration: { + id: number; + display_name?: string; + config?: { account?: { name?: string } }; +}): string { + return ( + integration.display_name ?? + integration.config?.account?.name ?? + `Slack workspace ${integration.id}` + ); +} + +interface SignalSlackNotificationsSettingsProps { + channelComboboxModal?: boolean; +} + +export function SignalSlackNotificationsSettings({ + channelComboboxModal = false, +}: SignalSlackNotificationsSettingsProps) { + const { slackIntegrations, hasSlackIntegration } = useIntegrationSelectors(); + const { userAutonomyConfig, handleUpdateSlackNotifications } = + useSignalSourceManager(); + const slackConnect = useSlackConnect(); + + const selectedIntegrationId = + userAutonomyConfig?.slack_notification_integration_id ?? null; + const selectedChannelTarget = + userAutonomyConfig?.slack_notification_channel ?? null; + const selectedChannelId = parseChannelIdFromTargetValue( + selectedChannelTarget, + ); + const selectedChannelName = parseChannelNameFromTargetValue( + 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 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 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 + + + Get pinged in Slack when you're a suggested reviewer on a new inbox + item. + + + + {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} + + ); + } + + 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 = visibleChannels.find((c) => c.id === rawValue); + 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, + }); + }; + + const channelTriggerLabel = (() => { + if (channelsSearchPending && !notificationsEnabled) { + return "Loading channels…"; + } + if (!notificationsEnabled) return "Pick a channel"; + 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 + + + Ping in Slack when you're a suggested reviewer on a new inbox item. + + + + + + + Workspace + + {slackIntegrations.length > 1 ? ( + + ) : slackIntegrations[0] ? ( + + {getSlackIntegrationLabel(slackIntegrations[0])} + + ) : null} + + {connectWorkspaceButton} + + + + + 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} + > + + + {notificationsEnabled && selectedChannelId ? ( + + ) : null} + + {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 84be4e055..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,9 +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 }[] = [ @@ -21,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, @@ -50,7 +59,7 @@ export function SignalSourcesSettings() { return ( - + Automatically analyze your product data and surface actionable insights. Choose which sources to enable for this project. @@ -92,34 +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/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 }; +} 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 fc58146b8..1a3efd5a3 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -527,6 +527,34 @@ 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; + has_more?: boolean; +} + +export interface SlackChannelsQueryParams { + search?: string; + limit?: number; + offset?: number; + channelId?: string; +}